Spring Security + jwt 로그인 기능 (1)

허진혁·2023년 5월 24일
0

동작 과정

Security와 jwt의 동작은 위와 같이 진행될 거에요. 다만 5번부터 나오는 refresh token의 경우 redis를 활용할 예정이에요.

우선 프로젝트 구조는

JWT

JwtAuthenticationFilter

  • 클라이언트 요청 시 JWT 인증을 하기 위해 Custom Filter로 UsernamePasswordAuthenticationFilter 이전에 실행될 거에요.
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_TYPE = "Bearer";

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        // 1. Request Header 에서 JWT 토큰 추출
        String token = resolveToken((HttpServletRequest) request);

        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

JwtTokenProvider

  • JWT 의 생명 주기 및 검증 기능이 포함될 클래스에요. 토큰 생성, 토큰 복호화 및 정보 추출, 토큰 유효성 검증의 기능을 구현할 거에요.

  • @Value(@Value("${JWT.SECRET}") 주의점 : 256bits 이상으로 해야해요. 만약 낮게 설정한다면 에러가 뜰 거에요.

public class JwtTokenProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 30 * 60 * 1000L;              // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L;    // 7일

    private final Key key;

    public JwtTokenProvider(@Value("${JWT.SECRET}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
    public MemberLoginDto.TokenResDto generateToken(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
		
        // refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
        
        return MemberLoginDto.TokenResDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .refreshTokenExpirationTime(REFRESH_TOKEN_EXPIRE_TIME)
                .build();
    }

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new SystemException(ErrorCode.INVALID_TOKEN);
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    // 토큰 정보를 검증하는 메서드
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

    public Long getExpiration(String accessToken) {
        // accessToken 남은 유효시간
        Date expiration = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody().getExpiration();
        // 현재 시간
        Long now = new Date().getTime();
        return (expiration.getTime() - now);
    }
}

Security

WebSecurityConfig

  • 기존에는 WebSecurityConfigurerAdapter을 상속 받아 SecurityFilterChain을 오버라이딩 했었지만, deprecated 되었어요.
  • Secutiry 설정을 위한 class로 SecurityFilterChain를 Bean으로 등록해요.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebConfig {

    private final String SWAGGER = "/swagger-ui/**";

    private final String[] MEMBER_PERMIT = {
            ...
    };

    private final String[] MEMBER_AUTH = {
            ...
    };

    private final String[] POST_AUTH = {
            ...
    };

    private final String[] FOLLOW_AUTH = {
            ...
    };

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .cors();

        httpSecurity
                .authorizeHttpRequests()
                .antMatchers(SWAGGER).permitAll()
                .antMatchers(MEMBER_PERMIT).permitAll()
                .antMatchers(MEMBER_AUTH).authenticated()
                .antMatchers(FOLLOW_AUTH).authenticated()
                .antMatchers(POST_AUTH).authenticated();

        httpSecurity
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        httpSecurity
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)

        return httpSecurity.build();
    }
}
  • csrf.disable() 사용 이유

csrf(cross site request forgery) : 사이트 간 위조 요청
인증된 사용자의 토큰을 탈취해 위조된 요청을 보냈을 경우를 파악해 방지하는 위한 것이에요.

 -  ✅ disalbe 이유?
  rest api 에서는 권한이 필요한 요청을 위해서 인증 정보를 포함시켜야 함.
  서버에 인증정보를 저장하지 않기 때문에 작성할 필요 없음.
  (JWT를 쿠키에 저장하지 않기 때문)
  • .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 사용 이유

jwt를 사용할 목적이기에 세션을 사용하지 않기 때문이에요.

  • .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) 사용 이유

UsernamePasswordAuthenticationFilter에 가기 전에 직접 만든 jwtTokenProvider를 사용하기 위함이에요.


CustomUserDetailsService

  • 스프링 시큐리티에서 지원해주는 인터페이스에요.
  • 인증에 필요한 UserDetailsService interface의 loadUserByUsername 메서드를 구현하는 것이 핵심이에요. 이를 통해 DB에 접근하여 사용자 정보를 가져올 수 있어요.
  • 저는 MemberDetailService로 구현해서 인터페이스를 구현할 거에요.
@Service
@Slf4j
@RequiredArgsConstructor
public class MemberDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        log.info(">>> 회원 정보 찾기, {}", email);
        return memberRepository.findByEmail(email)
                .map(this::createUserDetails)
                .orElseThrow(() -> new SystemException(String.format("%s %s", email, ErrorCode.USER_NOT_FOUND),
                        ErrorCode.USER_NOT_FOUND)
                );
    }

    private UserDetails createUserDetails(Member member) {
        return new MemberDetail(member);
    }
}

UserDetails

  • 해당 유저의 세부사항을 담고 있어요.
  • 저는 MemberDetails 클래스를 만들어 인터페이스를 구현할 거에요.
@Getter
public class MemberDetail implements UserDetails {

    private final Member member;

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

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();

        // 역할 목록
        GrantedAuthority roleAuthority = new SimpleGrantedAuthority("ROLE_USER");
        authorities.add(roleAuthority);

        return authorities;
    }

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

    @Override
    public String getUsername() {
        return this.member.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;
    }
}

PasswordEncoder

PasswordEncoderConfig

  • password를 request에서 들어온 그대로 db에 저장하는 것이 아니라 암호화하여 db에 저장하기 위해 사용할 거에요.
  • 이전에는 securityConfig에 같이 Bean으로 등록하여 사용 가능했으나, 스프링부트가 업데이트가 되면서 빈 참조순환이 발생하여 새로운 Config 클래스를 만들어 IoC(ApplicationContext) 컨테이너가 관리하게 해줄거에요.
@Configuration
public class PasswordEncoderConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

MemberLoginDto

@Getter @Setter
public class MemberLoginDto {
    private String email;
    private String password;

    public UsernamePasswordAuthenticationToken toAuthenticationToken() {
        return new UsernamePasswordAuthenticationToken(email, password);
    }

    @Getter @Builder
    public static class TokenResDto {
        private String grantType;
        private String accessToken;
        private String refreshToken;
        private Long refreshTokenExpirationTime;
    }
}

응답

다음 편에는 redis를 사용한 refresh token 구현을 할거에요.

참고자료

Spring Security JWT 로그인 구현

profile
Don't ever say it's over if I'm breathing

3개의 댓글

comment-user-thumbnail
2024년 8월 17일

안녕하세요, 포스팅 잘 봤습니다. 작성해주신 포스팅 참고하여 SpringSecurity + JWT 구현하려고 하는데
JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드에서 작성하신 SystemException 부분은 클래스를 따로 작성하신걸까요?

1개의 답글