[2023.09.14] Spring Boot 로그인 구현(Spring Security + JWT)

아스라이지새는달·2023년 10월 25일
5
post-thumbnail

Spring Boot 프로젝트를 진행하면서 로그인 기능을 구현하게 되었는데 레퍼런스 검색 시 제일 많이 나오는 것이 Spring Security + JWT였다. 로그인 기능은 어디서든 쓰일 수 있기 때문에 추후 다시 쓸 것을 고려해서 Spring Security와 JWT에 대해 정리하는 포스트를 작성하기로 하였다.

❓ Spring Security? JWT? 그게 뭔데?

❗️ Spring Security

Spring Security는 Spring을 기반으로 하는 애플리케이션의 보안을 담당하는 Spring 하위 프레임워크이다.

Spring Security는 크게 인증 절차를 진행 한 후에 인가 절차를 진행하는 두 가지 동작을 수행한다. 인가 과정에서 해당 리소스에 대한 접근 권한이 있는지 확인을 한다.

  • 인증(Authentication) : 해당 사용자 본인이 맞는지를 확인하는 절차
  • 인가(Authorization) : 인증된 사용자가 요청한 리소스에 접근이 가능한지를 결정하는 절차

Spring Security는 이러한 인증과 인가를 위해 Principal을 아이디로 Credential을 비밀번호로 사용하는 Credential 기반의 인증 방식을 사용한다.

  • Principal(접근 주체) : 보호받는 리소스에 접근하는 대상
  • Credential(비밀번호) : 리소스에 접근하는 대상의 비밀번호

Spring Security Authentication Architecture(인증 구조)


1. Client로부터 Http Request를 수신한다.

2. AuthentiactionFilter가 요청을 가로채어 UsernamePasswordAuthenticationToken(인증용 객체)을 생성한다.

3. 생성한 인증용 객체를 AuthenticationFilter를 통해 AuthentiactionManager에게 전달한다.

4. 인증을 처리할 수 있는 AuthenticationProvider를 선택하여 인증용 객체를 다시 전달한다.

5. 실제 DB에서 사용자 정보를 불러올 UserDetailsService에게 사용자 이름(username)을 넘겨준다.

6. 넘겨받은 사용자 이름(username)을 통해 DB에서 사용자 정보를 찾고 이를 UserDetails 객체로 반환한다.

7. UserDetails객체를 AuthenticationProvider에게 전달

8. AuthenticationProvider는 전달받은 UserDetails을 사용자의 입력 정보와 비교하고 인증에 성공하면 AuthenticationManager에게 검증된 인증 객체를 전달한다. 이 때 인증을 실패하면 AuthenticationException을 던진다.

9. 검증된 인증 객체를 AuthenticationFilter에게 전달한다.

10. 검증된 인증 객체를 SecurityContextHolder에 담은 이후 AuthenticationSuccessHandler를 실행한다. 실패 시 AuthenticationFailureHandler를 실행한다.


❗️ JWT(JSON Web Token)

JWT(JSON Web Token)란 클라이언트와 서버 사이에서 통신할 때 권한을 위해 사용되는 토큰이다. JWT는 토큰 자체를 정보로 사용하는 Self-Contained 방식으로 정보를 전달한다.

JWT는 헤더(Header), 페이로드(Payload), 서명(Signature)로 나누어져 있다.

  • 헤더(Header) : 알고리즘 방식(alg)을 지정, 토큰의 타입(typ)을 지정한다.

  • 페이로드(Payload) : 전달하려는 정보가 담겨져있다. 탈취되어 수정이 될 수 있기 때문에 최소한의 정보만을 담는다. 일반적으로 사용자의 id, 토큰의 유효기간 등이 포함된다.

  • 서명(Signature) : 토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드이다. Header, Payload, 서버가 지정한 Secret Key로 암호화시켜 토큰을 변조하기 어렵게 만든다.

JWT 동작 과정

  1. 사용자가 로그인 요청을 한다.

  2. 서버는 DB에 존재하는 사용자인지 확인한다.

  3. DB에 존재한다면 서버는 Secret Key를 통해 Access Token을 발급하여 클라이언트에 전달한다.

  4. 클라이언트는 인증이 필요한 요청마다 발급받은 Access Token을 헤더에 실어 서버에 보낸다.

  5. 서버는 해당 토큰의 유효성을 검증하고 토큰이 유효하다면 클라이언트의 요청에 대해 응답한다.

❓ 그래서 어떻게 사용하는건데?

종속성(Dependency) 추가

해당 프로젝트는 Java 11, SpringBoot 2.7.15 gradle 환경에서 진행되었기 때문에 build.gradle에 종속성을 추가하였다.

build.gradle

// Spring Security 추가
implementation 'org.springframework.security:spring-security-test'
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT 추가
implementation 'io.jsonwebtoken:jjwt:0.9.1'

Entity

Member

사용자는 studentId, password, name, role, email 로 구성된다. studentId가 아이디 역할을 한다.

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Integer studentId;

    private String password;

    private String name;

    @Enumerated(EnumType.STRING)
    private Role role;

    private String email;
    
}

Role

Role은 사용자의 권한을 위해 만들었고 enum 클래스로 만들었다. STUDENT, PROFESSOR, ADMIN으로 나뉘며 @JsonValue와 @JsonCreator에 관해서는 추후 포스팅 할 예정이다.

public enum Role {

    ROLE_STUDENT("ROLE_STUDENT"),
    ROLE_PROFESSOR("ROLE_PROFESSOR"),
    ROLE_ADMIN("ROLE_ADMIN");

    String role;

    Role(String role) {
        this.role = role;
    }

    @JsonValue
    public String value() {
        return role;
    }

    @JsonCreator
    public static Role parsing(String inputValue) {
        return Arrays.stream(Role.values()).filter(type -> type.value().equals(inputValue)).findFirst().orElse(null);
    }
    
}

Repository

MemberRepository

MemberRepository는 JpaRepository를 상속받아 작성한다. 이 때 <>안은 Entity, Entity의 PK type이다.

MemberRepository는 DB에 접근하는 메서드를 사용하기 위한 인터페이스로 본인의 코드에 맞게 메서드를 선언해주면 된다. 필자는 학번 즉 studentId로 DB에서 Member를 찾을 것이기에 findBystudentId로 선언하였다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    Optional<Member> findBystudentId(Integer studentId);
    
}

UserDetails 관련

CustomUserDetails

UserDetails는 Spring Security가 관리하는 User의 세부정보를 나타내는 역할을 한다.

Spring Security의 AuthenticationProvider가 UserDetailsService를 통해 DB에 있는 User 정보를 불러오는데 이 때 UserDetailsService는 DB의 User 정보를 UserDetails라는 객체로 반환한다.

UserDetails는 interface이므로 구현을 통해 class로 만들어준다. 그래서 Custom이다. 위에서 작성한 Entity에 맞게 작성해준다.

public class MemberDetails implements UserDetails {

    private final Member member;

    public MemberDetails(Member member) {
        this.member = member;
    }

    public final Member getMember() {
        return member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority(member.getRole().value()));

        return authorities;
    }

    @Override
    public String getUsername() {
        return String.valueOf(member.getStudentId());
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
    
}

CustomUserDetailsService

UserDetailsService는 위에서 언급했듯이 Spring Security의 AuthenticationProvider가 DB에 있는 User 정보를 불러올 때 사용된다.

UserDetailsService 또한 interface이므로 구현체를 작성해준다. loadUserByUsername 메서드를 반드시 Override 해주어야 하는데 인자 타입이 String인 것에 유념해준다. 필자는 studentId를 ID로 사용하였고 studentId의 type이 Integer이기 때문에 인자를 형변환하였다.

@Service
@RequiredArgsConstructor
public class MemberDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        Member member = memberRepository.findBystudentId(Integer.valueOf(username)).orElseThrow(
                () -> new UsernameNotFoundException("Invalid Authentication.")
        );

        return new MemberDetails(member);
    }
    
}

JWT 설정

JwtProvider

JwtProvider는 토큰을 생성하고 검증하기 위한 클래스이다.

Secret Key는 256bit(32bytes)이상이 되어야 한다.
리눅스 사용자라면 터미널에서 openssl rand -hex 64입력해보자. 64bytes의 랜덤한 문자열을 반환해준다. 이를 Secret Key로 사용하면 된다. 보안을 위해 secretKey 작성은 생략하였다.

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    private String secretKey = "";

    // 토큰 유효시간 30분
    private final long tokenValidTime = 1000L * 60 * 30;

    private final MemberDetailsService userDetailsService;

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // token 생성
    public String createToken(String account, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(account);
        claims.put("roles", roles);
        Date now = new Date();

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    // 권한 정보 획득
    // Spring Security 인증과정에서 권한확인을 위한 기능
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getAccount(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // token에 담겨있는 멤버 account 획득
    public String getAccount(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // Authorization Header를 통한 인증
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }

    public boolean validateToken(String token) {
        try {
            // Bearer 검증
            if(!token.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")) {
                return false;
            } else {
                token = token.split(" ")[1].trim();
            }
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return !claims.getBody().getExpiration().before(new Date()); // 만료되었다면 false return
        } catch(Exception e) {
            return false;
        }
    }
    
}

Security Configuration

JwtAuthenticationFilter

요청이 Servlet으로 도달하기 전에 가로채어 UsernamePasswordAuthenticationToken 객체를 만들고 이를 AuthenticationManager에게 전달한다.

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(request);

        if(token != null && jwtTokenProvider.validateToken(token)) {
            token = token.split(" ")[1].trim();
            Authentication auth = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        filterChain.doFilter(request, response);
    }
    
}

SecurityConfig

Spring Security의 설정을 위한 클래스이다.

antMatcher()를 통해 권한에 따른 접속 가능 url을 설정할 수 있다.
permitAll()은 모든 권한에 대한 허용
hasRole()은 특정 권한에 대한 허용을 설정한다.

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .csrf().disable()
                // CORS 설정
                .cors(c -> {
                        CorsConfigurationSource source = request -> {
                            CorsConfiguration config = new CorsConfiguration();
                            config.setAllowedOrigins(List.of("*"));
                            config.setAllowedMethods(List.of("*"));
                            return config;
                        };
                        c.configurationSource(source);
                    }
                )
                // Session 정책 설정
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // 조건별 요청 허용/제한 설정
                .authorizeRequests()
                .antMatchers("/api/member/login", "/api/reservation/**", "/v2/api-docs", "/swagger-resources/**", "/swagger-ui/index.html", "/swagger-ui.html","/webjars/**", "/swagger/**", "/favicon.ico").permitAll()
                .antMatchers("/api/member/admin/**").hasRole("ADMIN") // /admin으로 시작하는 요청은 ADMIN 권한이 있는 유저에게만 허용
                .antMatchers("/api/member/**").hasAnyRole("STUDENT", "PROFESSOR", "ADMIN")
                .anyRequest().denyAll()
                .and()
                // JWT 인증 필터
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
                // error handling
                .exceptionHandling()
                .accessDeniedHandler(new AccessDeniedHandler() {
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                        response.setStatus(403);
                        response.setCharacterEncoding("utf-8");
                        response.setContentType("text/html; charset=UTF-8");
                        response.getWriter().write("Unauthorized User.");
                    }
                })
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                        response.setStatus(401);
                        response.setCharacterEncoding("utf-8");
                        response.setContentType("text/html; charset=UTF-8");
                        response.getWriter().write("Unauthenticated User.");
                    }
                });
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
    
}

DTO

login request, response에 사용될 Dto를 만들어준다. 본인이 사용하고자 하는 방향대로 작성해주면 된다.

LoginRequestDto

request에는 login할 때 필요한 id(studentId)password를 담게끔 하였다.

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequestDto {

    private Integer studentId;

    private String password;

}

LoginResponseDto

response에서는 id와 token에 대한 정보만 알려주도록 하였다.

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponseDto {

    private Long id;

    private String token;

}

GetUserDto

멤버의 이름 정보를 가져오기 위한 Dto이다.

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class GetUserDto {

    private String userName;

}

GetUserAllInfoDto

관리자가 멤버의 모든 정보를 가져오기 위한 Dto이다.

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GetUserAllInfoDto {

    private Long id;

    private Integer studentId;

    private String name;

    private Role role;

    private String email;

}

Controller

MemberController

실제 프로젝트에서는 아직 개발 중이라 ADMIN(관리자) 페이지의 API url을 임시로 /api/member로 하였다.
하지만 Security Config에서는 ADMIN만 허용하는 url을 /api/member/admin/** 으로 하였기 때문에 동작 테스트를 위해 getUserAsAdmin()이라는 임의의 메소드를 선언하였다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/member")
public class MemberController {

    private final MemberService memberService;
    
	// 로그인 요청
    @PostMapping("/login")
    private ResponseEntity<LoginResponseDto> loginRequest(@RequestBody LoginRequestDto request) throws Exception {
        return ResponseEntity.ok().body(memberService.login(request));
    }

    // 로그인 정보 가져오기(해당 멤버의 이름 가져오기)
    @GetMapping("/{id}") // id : member-id
    private ResponseEntity<GetUserDto> getUserById(@PathVariable Long id) {
        return ResponseEntity.ok().body(memberService.getUserNameById(id));
    }

	// 로그인 정보 가져오기(해당 멤버의 모든 정보 가져오기)
    @GetMapping("/admin/{id}") // id : member-id
    private ResponseEntity<GetUserAllInfoDto> getUserAsAdmin(@PathVariable Long id) {
        return ResponseEntity.ok().body(memberService.getUserAsAdmin(id));
    }

}

Service

MemberServiceImpl

구현하고자 하는 로직에 맞춰서 메소드를 작성해준다.

@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;

	@Override
    public LoginResponseDto login(LoginRequestDto request) throws Exception {
        Member member = memberRepository.findBystudentId(request.getStudentId()).orElseThrow(() ->
                new BadCredentialsException("Invalid Account Information."));

        if(!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
            throw new BadCredentialsException("Invalid Account Information.");
        }

        List<String> roles = new ArrayList<>();
        roles.add(member.getRole().value());

        return LoginResponseDto.builder()
                .id(member.getId())
                .token(jwtTokenProvider.createToken(String.valueOf(member.getStudentId()), roles))
                .build();
    }
    
    @Override
    public GetUserDto getUserNameById(Long id) {
        Member member = memberRepository.findByid(id);

        GetUserDto newDto = new GetUserDto();
        newDto.setUserName(member.getName());

        return newDto;
    }

    @Override
    public GetUserAllInfoDto getUserAsAdmin(Long id) {
        Member member = memberRepository.findByid(id);
        GetUserAllInfoDto newDto = new GetUserAllInfoDto();

        newDto.setId(member.getId());
        newDto.setStudentId(member.getStudentId());
        newDto.setName(member.getName());
        newDto.setRole(member.getRole());
        newDto.setEmail(member.getEmail());

        return newDto;
    }

}

❓ 잘 작동할까?

DB 세팅

사전에 DB에 데이터를 넣어주었다. 넣어준 데이터는 다음과 같다.

emailnamepasswordrolestudend_id
test1@gmail.comtest1test!!ROLE_STUDENT20201111
test2@gmail.comtest2test@@ROLE_ADMIN20202222


테스트

로그인 전 토큰 없이 로그인 정보 가져오기 시도

로그인 전, 즉 인증이 되기 전 멤버의 이름을 가져와본다.
인증이 되지 않았고 토큰이 없기 때문에 설정한대로 "Unauthenticated User."이 출력된다.

로그인

DB에 세팅한대로 로그인을 하면 LoginResponseDto에 id와 token 정보가 담겨 반환된다.

토큰 발급 후 정상 토큰으로 로그인 정보 가져오기 시도

로그인 후, 즉 인증이 완료된 후 발급된 토큰을 가지고 다시 멤버의 이름을 가져와본다.
인증이 완료되었고 토큰이 유효하기 때문에 정상적으로 멤버의 이름을 반환한다.

토큰 발급 후 잘못된 토큰으로 로그인 정보 가져오기 시도

인증이 완료된 후 발급된 토큰이 아닌 잘못된(훼손된) 토큰을 가지고 멤버의 이름을 가져와본다.
토큰이 유효하기 않기 때문에 "Unauthenticated User."이 출력된다.

권한에 따른 접근 제한

위에서 로그인 하고 발급 받은 토큰은 STUDENT 권한에 대한 토큰이므로 ADMIN 권한에 대한 URL에는 접근이 제한되는 것을 확인할 수 있다. 설정한대로 "Unauthorized User."이 출력된다.

권한에 따른 접근 허용

ROLE_ADMIN 권한을 가진 멤버로 다시 로그인 하고 이 때 발급된 토큰으로 API 요청을 보내면 정상적으로 멤버 정보가 출력되는 것을 확인할 수 있다.


🔍 Reference

Spring Security

https://velog.io/@kyungwoon/Spring-Security-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC

https://hello-judy-world.tistory.com/216#4.-spring-security-%EC%9D%B8%EC%A6%9D-%EC%B2%98%EB%A6%AC-%EA%B3%BC%EC%A0%95

https://doozi0316.tistory.com/entry/Spring-Security-Spring-Security%EC%9D%98-%EA%B0%9C%EB%85%90%EA%B3%BC-%EB%8F%99%EC%9E%91-%EA%B3%BC%EC%A0%95

https://velog.io/@hope0206/Spring-Security-%EA%B5%AC%EC%A1%B0-%ED%9D%90%EB%A6%84-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%97%AD%ED%95%A0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0

https://velog.io/@platinouss/Spring-Security-%EC%82%AC%EC%9A%A9%EC%9E%90%EB%A5%BC-%EA%B8%B0%EC%88%A0%ED%95%98%EB%8A%94-UserDetails

JWT

https://velog.io/@hahan/JWT%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80

https://mangkyu.tistory.com/56

로그인 기능

https://velog.io/@jkijki12/Spirng-Security-Jwt-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0#spring-security-%EC%9D%B8%EC%A6%9D%EA%B3%BC%EC%A0%95

https://jooky.tistory.com/5

https://velog.io/@nyong_i/Jwt%EB%A1%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#jwtauthenticationfilter

https://velog.io/@junho5336/SpringBoot-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-with.-SpringSecurity-JWT

profile
웹 백엔드 개발자가 되는 그날까지

2개의 댓글

comment-user-thumbnail
2023년 10월 26일

잘 보고 갑니다^^

1개의 답글