Search Yooniversal

Project4 min read

[Spring] JWT로 인증 구현하기

서버는 인증된 클라이언트에게 토큰을 발급해주고 클라이언트는 인증이 필요한 API를 사용할 때마다 발급받은 토큰을 헤더에 담아 요청을 보내야 한다. 스프링 프로젝트에 **JWT(Json Web Token)**를 적용해보는 과정을 담았다.

JWT 설정

build.gradle

JWT를 사용하기 위해 build.gradle에 다음 내용을 추가해주고 generate 해주자.

implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.0'

PrincipalDetails

현재 사용자 정보를 가져올 수 있도록 하는 PrincipalDetails이다. UserDetails를 구현하면서 다음 함수들을 같이 구현하는데 getAuthorities()에서 현재 사용자의 권한 정보를 반환해줄 수 있도록 설정해주자.

@Data
public class PrincipalDetails implements UserDetails {

    private User user;

    public PrincipalDetails(User user) {
        this.user = user;
    }

    //현재 사용자가 갖고있는 권한들을 반환
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRoles().stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

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

    @Override
    public String getUsername() {
        return user.getEmail();
    }

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

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

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

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

만약 User 엔티티가 권한 정보를 담는 필드를 갖고있지 않다면 아래 코드처럼 설정해주자.

@Entity @Getter
@NoArgsConstructor(access = PROTECTED)
public class User {
  ...
  @NonNull
  @ElementCollection(fetch = EAGER)
  private List<String> roles = new ArrayList<>();
  ...
}
  • @ElementCollection : 해당 필드가 여러 값을 담을 수 있도록 하며, fetchType 기본값이 LAZY임
  • 위처럼 설정할 경우 user_roles라는 테이블이 생성되고 user 테이블과 연관관계를 맺게 됨

JwtTokenProvider

토큰 생성, 토큰에서 인증 정보 조회, PK 조회 그리고 토큰 유효성 검사 기능을 제공하는 JwtTokenProvider을 정의해주자.

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private String secretKey = "secret-value";
    private long tokenValidTime = 30 * 60 * 1000L; //토큰 유효시간
    private final UserDetailsService userDetailsService;

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

    //토큰 생성
    public String createToken(String email, List<String> roles) {
        //JWT에 저장할 정보 생성
        Claims claims = Jwts.claims().setSubject(email);
        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();
    }

    //인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    //회원 PK 조회
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    //토큰 유효한지 확인
    public boolean validateToken(String token) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    //request의 header에서 토큰 가져오기
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }
}
  • init() : @PostConstruct에 의해 서비스 실행 전에 secretKey를 초기화
  • createToken() : 파라미터 email, roles로 토큰 생성 후 반환
    • User의 PK가 email이어서 파라미터로 설정
  • getAuthentication() : 토큰에서 인증 정보 조회
  • getUserPk() : 토큰에서 현재 회원의 PK 정보 반환
  • validateToken() : 토큰 유효성 검사
  • resolveToken() : request의 헤더에서 토큰 반환
    • 토큰을 헤더의 Authorization에 입력하는 경우를 예시로 설정했음
    • 이 때 Authorization에는 Bearer {token 값}이 아닌 {token 값}만 들어가야 함

JwtAuthenticationFilter

HTTP 요청이 들어오면 가장 먼저 거치게 될 필터 JwtAuthenticationFilter를 정의해주자. jwtTokenProvider로 토큰을 가져오고, 검증하고 유저 정보를 얻어서 SecurityContext에 저장한다.

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        //헤더에서 토큰 가져오기
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
        
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token); //유저 정보
            SecurityContextHolder.getContext().setAuthentication(authentication); //SecurityContext에 객체 저장
        }

        //다음 Filter 실행
        chain.doFilter(request, response);
    }
}
  • JwtTokenProvider는 주입받아서 사용

Spring Security

스프링 시큐리티 설정을 담고있는 SecurityConfig 클래스다. filterChain()에서 위에서 정의했던 JwtAuthenticationFilter와 JwtTokenProvider를 적용해주자. 이 때 JwtTokenProvider는 주입받아 사용한다. JWT 발급 및 인증 필터를 적용할 수 있다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final UserDetailsService userDetailsService;
    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf((csrf) -> csrf.disable())
                .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        http
                .authorizeHttpRequests((authorizeHttpRequests) ->
                        authorizeHttpRequests
                                //로그인 및 회원가입 API를 인증없이 사용할 수 있도록 설정
                                .requestMatchers("/api/login", "api/signup").permitAll()
                                //이외 모든 접근은 인증 필요
                                .anyRequest().authenticated()
                )
                .formLogin((formLogin) ->
                        formLogin
                                .usernameParameter("email")
                                .passwordParameter("password")
                                .loginPage("/login")
                                .failureUrl("/login?failed")
                                .loginProcessingUrl("/login/process")
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public static BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }
}
  • session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)를 통해 세션 설정 해제
  • formLogin()을 사용하지 않는 경우(=별도 로그인 페이지를 지원할 경우) formLogin(AbstractHttpConfigurer::disable)로 설정
  • UsernamePasswordAuthenticationFilter : Spring Security에서 인증 처리를 담당하는 필터

로그인 API 테스트

DTO

사용자 email과 password를 받을 LoginDto를 정의해주자.

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class LoginDto {
    private String email;
    private String password;
}
  • 만약 @AllArgsConstructor를 설정해두고 기본 생성자(@NoArgsConstructor)를 설정해주지 않으면 테스트 시 정상적으로 input이 넘어오지 않으므로 주의

Service

service 클래스에는 로그인 처리를 해줄 login() 함수를 정의해주자.

public User login(LoginDto loginDto) {
    User user = userRepository.findByEmail(loginDto.getEmail())
            .orElseThrow(() -> new NoResultException("User doesn't exist"));

    return passwordEncoder.matches(loginDto.getPassword(), user.getPassword()) ? user : null;
}
  • 입력받은 email과 password로 DB에 저장된 회원 정보와 일치하는지 체크
  • password 비교 시 BCryptPasswordEncoder.matches() 사용
    • loginDto의 password를 인코딩한 것과 DB에 저장된 이전에 인코딩된 password를 비교하면 안됨!

Controller

컨트롤러에서는 JwtTokenProvider를 주입받아 로그인에 성공한 인증된 사용자에게 새 토큰을 발급받고 반환한다. 토큰을 발급하는 과정에서 email과 roles를 파라미터로 넘겨준다.

@RestController
@RequiredArgsConstructor
public class SignController {

    private final UserService userService;
    private final JwtTokenProvider jwtTokenProvider;

    @PostMapping("/api/login")
    public String loginFromAPI(@RequestBody LoginDto loginDto) {
        User user = userService.login(loginDto);
        return user != null ?
                jwtTokenProvider.createToken(user.getEmail(), user.getRoles()) : "일치하는 유저 정보가 없습니다.";
    }

ID/PW를 json 형태로 전달해 테스트할 것이므로 파라미터 LoginDto는 @RequestBody로 받아야 한다.

Postman에서 테스트

postman_login_test.png
로그인에 성공 후 응답으로 토큰이 오는걸 확인할 수 있다😄

References