JWT 변경 및 Redis 연동을 통한 로그아웃 기능을 추가하는 것은 애플리케이션의 보안을 강화하고, 사용자 인증 관리를 효율적으로 처리할 수 있게 합니다. JWT와 Redis를 활용하여 로그인한 사용자의 토큰을 관리함으로써, 로그아웃 시 토큰을 무효화할 수 있습니다. 이 구현은 로그인 상태를 서버 측에서 제어할 수 있게 하여, 보안성을 높이는 중요한 역할을 합니다.
Gradle 설정
build.gradle에 JWT 관련 의존성과 Redis를 위한 의존성을 추가합니다.
// jwt
implementation 'io.jsonwebtoken:jjwt:0.9.1'
AuthController 수정
AuthController에서는 로그인, 로그아웃, 회원가입 기능을 처리합니다. 로그아웃 시, Redis에 저장된 사용자의 토큰을 삭제하여 로그아웃을 구현합니다.
@PostMapping("/join")
public void join(@RequestBody RegisterDto registerDto) {
authService.join(registerDto);
}
@PostMapping("/login")
public TokenResponseDto login(@RequestBody LoginDto loginDto) {
return authService.login(loginDto);
}
@PostMapping("/logout")
public void logout() {
authService.logout();
}
SecurityConfig 수정
SecurityConfig에서는 JWT 토큰 프로바이더와 RedisTemplate을 활용하여 인증 필터를 설정합니다.
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
private final RedisTemplate<String, String> redisTemplate;
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/static/css/**, /static/js/**, *.ico");
// swagger
web.ignoring().antMatchers(
"/v2/api-docs", "/configuration/ui",
"/swagger-resources", "/configuration/security",
"/swagger-ui.html", "/webjars/**","/swagger/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers( "/api/auth/join", "/api/auth/login", "/swagger-ui/**", "/swagger-resources/**").permitAll() //인증 필요 없는 url
.anyRequest().authenticated()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션 사용 안함
.and()
.formLogin()
.disable()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider,redisTemplate), UsernamePasswordAuthenticationFilter.class); // RedisTemplate 추가
}
}
JwtAuthenticationFilter 구현
JwtAuthenticationFilter에서는 HTTP 요청의 헤더에서 JWT 토큰을 추출하고, 토큰의 유효성을 검증합니다. Redis에서 토큰의 존재 여부를 확인하여 인증 처리를 합니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
private final RedisTemplate<String, String> redisTemplate;
@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)) {
String key = "JWT_TOKEN:" + jwtTokenProvider.getUserPk(token);
String storedToken = redisTemplate.opsForValue().get(key);
//**로그인 여부 체크**
if(redisTemplate.hasKey(key) && storedToken != null) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
}
JwtTokenProvider 구현
JwtTokenProvider에서는 JWT 토큰의 생성, 유효성 검증, 사용자 인증 정보 추출 등의 기능을 구현합니다.
@RequiredArgsConstructor
@Component
@Slf4j
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String SECRET_KEY;
private final long tokenValidTime = 30 * 60 * 1000L; //토큰 유효시간 -> 30분
private final UserDetailsServiceImpl userDetailsService;
//객체 초기화, secretKey를 Base64로 인코딩
@PostConstruct
protected void init() {
SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
}
//토큰 생성
public String createToken(String userPk) {
//adminPk => loginId
Claims claims = Jwts.claims().setSubject(userPk); //JWT payload 에 저장되는 정보단위
Date now = new Date();
return Jwts.builder()
.setClaims(claims) //정보 저장
.setIssuedAt(now) //토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) //토큰 유효시각 설정
.signWith(SignatureAlgorithm.HS256, SECRET_KEY) //암호화 알고리즘, secret 값 설정
.compact();
}
// 인증 성공시 SecurityContextHolder에 저장할 Authentication 객체 생성
public Authentication getAuthentication(String token) {
UserDetailsImpl userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// Jwt Token에서 User PK 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(SECRET_KEY)
.parseClaimsJws(token).getBody().getSubject();
}
//토큰 유효성, 만료일자 확인
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
log.debug(e.getMessage());
return false;
}
}
//Request의 Header에서 token 값 가져오기
public String resolveToken(HttpServletRequest request) {
return request.getHeader("x-auth-token");
}
}
UserDetailsImpl 및 UserDetailsServiceImpl 추가
UserDetailsImpl
@RequiredArgsConstructor
@Getter
@Setter
@Builder
public class UserDetailsImpl implements UserDetails {
private final User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return null;
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
UserDetailsServiceImpl
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetailsImpl loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email).orElseThrow(
() -> new UsernameNotFoundException("Can't find user with this email. -> " + email));
if(user != null){
UserDetailsImpl userDetails = new UserDetailsImpl(user);
return userDetails;
}
return null;
}
}
AuthService 수정
AuthService에서는 로그인 시 JWT 토큰을 생성하고 Redis에 저장합니다. 로그아웃 시에는 Redis에서 해당 토큰을 삭제합니다. Redis에 값이 없으면 접근이 안되도록 설계하였습니다.
@Transactional
public void join(RegisterDto registerDto) {
if (userRepository.existsByEmail(registerDto.getEmail())) {
throw new BadRequestException("이미 가입되어있는 이메일 입니다.");
}
User user = registerDto.toEntity();
user.hashPassword(bCryptPasswordEncoder);
userRepository.save(user);
}
@Transactional
public TokenResponseDto login(LoginDto loginDto) {
User user = userRepository.findByEmail(loginDto.getEmail()).orElseThrow(
() -> new BadRequestException("이메일이 존재하지 않습니다.")
);
user.checkPassword(loginDto.getPassword(), bCryptPasswordEncoder);
//로그인 성공시 jwt 토큰 생성
String token = jwtTokenProvider.createToken(user.getEmail());
// redis 저장
redisTemplate.opsForValue().set("JWT_TOKEN:" + user.getEmail(), token);
return new TokenResponseDto(token);
}
@Transactional
public void logout() {
//Token에서 로그인한 사용자 정보 get해 로그아웃 처리
UserDetailsImpl userDetails = (UserDetailsImpl) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
User user = userDetails.getUser(); // UserDetailsImpl에서 User 객체를 반환하는 메소드를 사용
String key = "JWT_TOKEN:" + user.getEmail();
if (redisTemplate.opsForValue().get(key) != null) {
redisTemplate.delete(key); // Token 삭제
}
}
결론
이 구현을 통해, 애플리케이션은 사용자의 로그인 상태를 효과적으로 관리할 수 있게 되며, 로그아웃 시 토큰을 무효화함으로써 보안을 강화할 수 있습니다. Redis를 활용한 로그아웃 기능은 분산 시스템에서의 세션 관리에도 유용하게 사용될 수 있습니다.
참고자료
Spring Security + jwt로 로그인/로그아웃 구현하기
Spring Security와 jwt를 이용해 로그인/로그아웃 구현
velog.io
'프로젝트 (Java) > 예약마켓' 카테고리의 다른 글
[프로젝트] 26. ResponseBodyAdvice를 활용한 공통 응답 형식 추가 (0) | 2024.01.31 |
---|---|
[프로젝트] 25. 토큰에서 사용자 ID를 가져오도록 리팩토링 (0) | 2024.01.31 |
[프로젝트] 23. 로그아웃 기능을 위한 Redis 추가 (0) | 2024.01.29 |
[프로젝트] 22. 뉴스피드 알림 기능 리팩토링 (0) | 2024.01.29 |
[프로젝트] 21. 뉴스피드 알림 기능 추가 (0) | 2024.01.29 |