4 분 소요

서버는 인증된 클라이언트에게 토큰을 발급해주고 클라이언트는 인증이 필요한 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()에서 위에서 정의했던
JwtAuthenticationFilterJwtTokenProvider를 적용해주자. 이 때 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

사용자 emailpassword를 받을 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;
}
  • 입력받은 emailpassword로 DB에 저장된 회원 정보와 일치하는지 체크
  • password 비교 시 BCryptPasswordEncoder.matches() 사용
    • loginDtopassword를 인코딩한 것과 DB에 저장된 이전에 인코딩된 password를 비교하면 안됨!

Controller

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

@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

카테고리:

업데이트:

댓글남기기