[Spring] Security + JWT + Redis를 활용한 로그인 구현 (1)
Security를 활용한 로그인 구현을 위해 여러 블로그들을 참고하여 작성한 글입니다.내용에 잘못된 점이 있다면 지적해주시면 감사하겠습니다! 먼저 로그인 구현에 앞서 JWT가 뭔지 개념을 정리해
sksmsfbrjs51.tistory.com
로그인 구현 이전에 JWT에 대한 개념글을 정리해보았다. JWT에 대한 이해가 필요하다면 이전 게시글을 참고하면 좋을 것 같다!
구현에 앞서 Spring Security 동작 단계를 살펴보자
Spring Security 동작 단계

- 사용자가 로그인 정보와 함께 요청을 보낸다.
- AuthenticationFilter가 해당 요청을 가로채고 ,가로챈 정보를 통해 UsernamePasswordAuthenticationToken의 인증용 객체를 생성한다.
- AuthenticationManager의 구현체인 ProviderManager에게 생성한 UsernamePasswordToken 객체를 전달한다.
- AuthenticationManager는 등록된 AuthenticationProvider(들)을 조회하여 인증을 요구한다.
- UserDetailsService를 호출하고 사용자 정보를 넘겨준다.
- 넘겨받은 사용자 정보를 통해 UserDetails 객체를 만든다
- AuthenticationProvider(들)은 UserDetails를 넘겨 받고 사용자 정보와 비교한다.
- 인증이 완료되면 권한등을 포함하여(UserDetails) Authentication 객체를 반환한다.
- 다시 최초의 AuthenticationFilter에 Authentication 객체가 반환된다.
- Authentication 객체를 SecurityContext principal의 Authentication 영역에 저장한다.
사용 라이브러리
implementation 'com.auth0:java-jwt:4.4.0' // jwt 최신 모듈
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-redis' // redis
Security Logic
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public WebSecurityCustomizer webSecurityCustomizer() { // security를 적용하지 않을 리소스
return web -> web.ignoring()
.requestMatchers("/error", "/favicon.ico");
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// stateless한 rest api를 개발할 것이므로 csrf 공격에 대한 옵션은 꺼둔다.
.csrf(AbstractHttpConfigurer::disable) // csrf 비활성화 -> cookie를 사용하지 않으면 꺼도 된다. (cookie를 사용할 경우 httpOnly(XSS 방어), sameSite(CSRF 방어)로 방어해야 한다.)
.formLogin(AbstractHttpConfigurer::disable) // security 기본 로그인 비활성화
.httpBasic(AbstractHttpConfigurer::disable) // REST API이므로 basic auth 사용 x
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // cors 설정
// 특정 URL에 대한 권한 설정.
.authorizeHttpRequests(authz -> authz
.requestMatchers("/swagger", "/swagger-ui/index.html", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/", "/api/auth/*", "/api/auth/pw/*", "/api/user/notice").permitAll() // 특정 url에 대한 인가 요청 허용
.requestMatchers("/api/admin/*").hasRole("ADMIN") // ADMIN 권한일 때 요청.
.anyRequest().authenticated() // 그 외 요청은 인증 필요.
)
// Token 로그인 방식에서는 session 필요 없음.
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // addFilterBefore(after, before)
.addFilterBefore(new TokenExceptionFilter(), jwtAuthenticationFilter.getClass()); // 토큰 예외 핸들링
return httpSecurity.build();
}
@Bean
public PasswordEncoder passwordEncoder() { // 비밀번호 암호화
// BCrypt Encoder 사용
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*"));
configuration.addAllowedHeader("*");
configuration.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE"));
configuration.setAllowCredentials(true);
configuration.addExposedHeader("Authorization"); // Access-Control-Expose-Headers 사용
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
Jwt를 사용하여 AuthenticationFilter에서 사용자 정보를 토큰을 통해 얻어내어 SecurityContext에 저장하는 방식으로 설계를 진행하였다.
해당 JWT 토큰에 대한 인증 필터에 사용되는 JWT Provider를 살펴보자.
JWT Provider
@Slf4j
@Component
public class JwtProvider {
private final Algorithm algorithm;
private final RedisService redisService;
@Value("${jwt.access-token.expiration}")
private Long accessTokenValidTime; // 24시간
@Value("${jwt.refresh-token.expiration}")
private Long refreshTokenValidTime; // 2주
public JwtProvider(@Value("${jwt.secret-key}") String secretKey, RedisService redisService) {
this.algorithm = Algorithm.HMAC256(secretKey);
this.redisService = redisService;
}
public String getEmailFromAccessToken(String token) {
return JWT.decode(token).getClaim("email").asString();
}
public boolean validate(String token) {
try {
JWT.require(algorithm).build().verify(token);
if (redisService.hasKeyBlackList(token)) { // JWT BlackList
return false;
}
return true;
} catch (JWTVerificationException e) {
return false;
}
}
// 액세스 토큰 생성
public String createAccessToken(String email, String role) {
return JWT.create()
.withIssuedAt(Instant.now())
.withExpiresAt(Instant.now().plus(accessTokenValidTime, ChronoUnit.HOURS))
.withClaim("email", email)
.withSubject(role)
.sign(algorithm);
}
// 리프레시 토큰 생성
public String createRefreshToken() {
return JWT.create()
.withIssuedAt(Instant.now())
.withExpiresAt(Instant.now().plus(refreshTokenValidTime, ChronoUnit.HOURS))
.sign(algorithm);
}
// JWT 만료 시간 확인
public Long getTokenExpiration(String token) {
return JWT.require(algorithm).build().verify(token).getExpiresAt().getTime();
}
}
Access Token과 Refresh Token 생성 메서드를 구현했으며, 토큰 decoding을 통한 Email 값 추출 메서드를 구현해놓은 상태이다.
로그인 로직
public LoginResponseDTO login(LoginRequestDTO requestDTO) {
String email = requestDTO.getEmail();
Member member = authRepository.findByEmail(email).orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));
if(!passwordEncoder.matches(requestDTO.getPassword(), member.getPassword())) {
throw new CustomException(ErrorCode.INVALID_PASSWORD);
}
String role = member.getRole().toString();
String accessToken = jwtProvider.createAccessToken(email, role);
String refreshToken = jwtProvider.createRefreshToken();
// refresh token 저장.
if(redisService.getRefreshToken(email) != null) {
redisService.deleteRefreshToken(email);
}
redisService.saveRefreshToken(email, refreshToken);
return new LoginResponseDTO(accessToken, refreshToken, member.getUsername(), member.getStudentId());
}
클라이언트에게 email 과 password를 request body로 받은 뒤, 처리하는 로직이다.
access Token과 Refresh Token을 발급하여 클라이언트에게 전송해준다.
기존에 refresh token이 있다면 삭제 후 재발급해주었고, refreshToken을 redis 서버에 저장해주는 식으로 로직을 구성하였습니다
로그아웃 로직
// 로그아웃
@Transactional
public void logout(TokenDTO tokenDTO) {
String accessToken = tokenDTO.getAccessToken();
String email = jwtProvider.getEmailFromAccessToken(accessToken);
log.info("access token: {}", accessToken);
log.info("email : {}", email);
// redis에서 refresh Token 제거
redisService.deleteRefreshToken(email);
// 해당 엑세스 토큰의 남은 유효시간을 얻어서 Access Token blacklist에 등록하여 만료시키기
Long tokenExpiration = jwtProvider.getTokenExpiration(accessToken);
redisService.setBlackList(accessToken, "logout", tokenExpiration);
}
JWT BlackList 기술을 사용하여, 클라이언트에서 요청으로 보내준 Access Token을 통해 Email을 decoding하여 Redis 서버에서 email을 키 값으로 하는 Refresh Token을 제거해준다.
또한 해당 Access Token의 만료시간을 추출하여, Redis Server에 BlackList 로써 해당 Access Token을 저장해준다.
사용자가 BlackList에 등록된 Access Token으로 API 요청 시 401 UnAuthorization Error를 발생하게끔 Validate 메서드를 수정해준다.
- JwtProvider
public boolean validate(String token) {
try {
JWT.require(algorithm).build().verify(token);
if (redisService.hasKeyBlackList(token)) { // JWT BlackList
return false;
}
return true;
} catch (JWTVerificationException e) {
return false;
}
}
Access Token 재발급
// 액세스 토큰 재발급
@Transactional
public ReissueAccessTokenResponseDTO reissueAccessToken(String refreshToken, Member member) {
if(jwtProvider.validate(refreshToken)) {
String refreshTokenInRedis = redisService.getRefreshToken(member.getEmail());
if (refreshToken.equals(refreshTokenInRedis)) {
String accessToken = jwtProvider.createAccessToken(member.getEmail(), member.getRole().toString());
return new ReissueAccessTokenResponseDTO(accessToken);
}
} else {
throw new JWTVerificationException("Refresh Token Expired");
}
return null;
}
Refresh Token에 대한 유효성 검사를 진행하여 Redis에 존재하는 Refresh Token을 가져와 클라이언트에서 요청받은 Refresh Token과 대조하여 같으면, 새로운 Access Token을 발급해준다.
참고
https://colabear754.tistory.com/171
[Spring Security] Spring Security와 JWT를 사용하여 사용자 인증 구현하기(Spring Boot 3.0.0 이상)
[수정사항] 스프링 시큐리티 6.1부터 SecurityFilterChain의 일부 설정들을 메소드체이닝으로 설정하는 방식이 Deprcated 되고 7부터 삭제될 예정이어서 람다를 사용한 방식으로 코드 수정 목차 시작하기
colabear754.tistory.com
[Security] JWT 로컬 + 소셜 로그인 튜토리얼
Background 이번 프로젝트에 소셜 로그인을 도입하면서 별별 문제를 다 겪었는데, 생각보다 소셜로그인 튜토리얼이 없다는 걸 느꼈다. 사실 해놓고 보면 진짜 별 거 없어서 안 쓴 걸까? 하는 생각도
velog.io
· Redis를 활용해서 Refresh Token 구현하기
👩🏻💻 지식 창고 📚
inkyu-yoon.github.io
https://dev-chw.tistory.com/29?category=1078615
[ SpringSecurity + JWT + Redis ] 로그인과 RefreshToken을 이용한 AccessToken 재발급편 👨💻
사실 로그인과 토큰 재발급을 나눠서 진행하려 했는데, 로그인 API가 사실 회원가입과 크게 다를 부분이 없어서 한꺼번에 다루기로 했다. 회원가입에서 save만 빠지면 사실상 로그인이기 때문에
dev-chw.tistory.com
'Back-end > Spring' 카테고리의 다른 글
[Spring] JWT + Redis를 활용한 로그아웃 구현 (Jwt BlackList) (3) | 2024.10.06 |
---|---|
[Spring] Query Parameter vs Path Variable (2) | 2024.10.02 |
[Spring] @NotNull vs @Column(nullable = false) (0) | 2024.08.19 |
[Spring] 무한 redirection (0) | 2024.08.14 |
[Spring] Security + JWT + Redis를 활용한 로그인 구현 (1) (0) | 2024.07.20 |