Spring Security 를 사용한 토큰 발급 흐름
또한 위의 토큰 발급 과정을 구현하기 위해선 Spring Security 를 위한 기본 설정이 필요함
Spring Security 기본 설정
JwtSecurityConfig
: 토큰 발급용 코드인 JwtTokenProvider 와 인증/인가용 필터인 JwtFilter 을 Spring Security Filter Chain 에 추가하기 위한 설정 코드SecurityConfig
: 인증/인가를 사용할 API 설정 코드JwtTokenProvider
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final RedisTemplate<String, String> redisTemplate;
@Value("${spring.jwt.secret}")
private String secretKey;
@Value("${spring.jwt.token.access-expiration-time}")
private long accessExpirationTime;
@Value("${spring.jwt.token.refresh-expiration-time}")
private long refreshExpirationTime;
private final UserDetailsServiceImpl userDetailsService;
/**
* Access Token 생성
*/
public String generateAccessToken(Authentication authentication){
Claims claims = Jwts.claims().setSubject(authentication.getName());
Date now = new Date();
Date expireDate = new Date(now.getTime() + accessExpirationTime);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
/**
* Refresh Token 생성
*/
public void generateRefreshToken(Authentication authentication){
Claims claims = Jwts.claims().setSubject(authentication.getName());
Date now = new Date();
Date expireDate = new Date(now.getTime() + refreshExpirationTime);
String refreshToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
// redis 에 저장
redisTemplate.opsForValue().set(
authentication.getName(),
refreshToken,
refreshExpirationTime,
TimeUnit.MILLISECONDS
);
}
/**
* 토큰으로부터 Claims 을 만들고, 이를 통해 User 객체와 Authentication 객체 리턴
*/
public Authentication getAuthentication(String token) {
String email = Jwts.parser().
setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody().getSubject();
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
/**
* Token 검증
*/
public boolean validateToken(String token){
try{
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return true;
} catch(ExpiredJwtException e) {
log.error("Token 만료");
throw new RuntimeException("Token 만료. 재발급 필요");
} catch(JwtException e) {
log.error("잘못된 Access Token 타입");
throw new RuntimeException("잘못된 Access Token 타입");
} catch (IllegalArgumentException e) {
log.error("헤더가 비어있음");
throw new RuntimeException("헤더가 비어있음");
}
}
}
generateAccessToken
: Authentication 객체를 사용해 Access Token 을 생성generateRefreshToken
: Authentication 객체를 사용해 Refresh Token 을 생성 및 Redis 저장getAuthentication
: Access Token 이나 Refresh Token 에서 유저의 정보를 추출할 때 사용validateToken
: 파라미터로 입력된 Access Token 이나 Refresh Token 을 검증함JwtFilter
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private static final List<String> EXCLUDE_URL =
Collections.unmodifiableList(
Arrays.asList(
"/api/v1/user/login",
"/api/v1/user/join",
"/api/v1/user/reissue"
));
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (jwtTokenProvider.validateToken(token)) {
// HTTP 헤더에 입력한 Access Token을 사용해 인증용 객체인 authentication을 생성
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// 접근한 유저의 authentication 객체를 SecurityContextHolder에 저장함
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 다음 필터로 넘어감
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
return EXCLUDE_URL.stream().anyMatch(exclude -> exclude.equalsIgnoreCase(request.getServletPath()));
}
/**
* HTTP 헤더에서 Access Token 추출
*/
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
EXCLUDE_URL
: 필터를 거치면 안되는 요청 URL을 담은 리스트doFilterInternal
: 요청이 들어오면 거치는 필터 로직shouldNotFilter
: 필터를 거치면 안되는 예외 URL 설정resolveToken
: HTTP 헤더에서 Access Token 추출JwtSecurityConfig
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void configure(HttpSecurity http) throws Exception {
JwtFilter jwtFilter = new JwtFilter(jwtTokenProvider);
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
}
}
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable);
// 권한 규칙 설정
http.authorizeHttpRequests(
authorize -> authorize
.requestMatchers("/api/v1/user/login").permitAll()
.requestMatchers("/api/v1/user/join").permitAll()
.requestMatchers("/api/v1/user/reissue").permitAll()
.anyRequest().authenticated()
).apply(new JwtSecurityConfig(jwtTokenProvider));
return http.build();
}
}
UserController
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/user")
public class UserController {
private final AuthService authService;
/**
* 회원가입
*/
@PostMapping("/join")
public ResponseEntity<String> join(@RequestBody UserDto userDto) {
authService.join(userDto);
return ResponseEntity.status(HttpStatus.OK).body("회원가입 완료");
}
/**
* 로그인
*/
@PostMapping("/login")
public ResponseEntity<UserDto> login(@RequestBody UserDto userDto) {
return ResponseEntity.status(HttpStatus.OK).body(authService.login(userDto));
}
@GetMapping("/reissue")
public ResponseEntity<TokenDto> reissue(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization-refresh");
String refreshToken = "";
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
refreshToken = bearerToken.substring(7);
}
String newAccessToken = authService.reissueAccessToken(refreshToken);
return ResponseEntity.status(HttpStatus.OK).body(new TokenDto(newAccessToken, ""));
}
@GetMapping("/test")
public ResponseEntity<String> accessTokenTest() {
return ResponseEntity.status(HttpStatus.OK).body("인증 성공");
}
}
AuthService
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final RedisTemplate<String, String> redisTemplate;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
public void join(UserDto userDto) {
User user = userDto.toUser(passwordEncoder);
userRepository.save(user);
}
@Transactional
public UserDto login(UserDto userDto) {
UsernamePasswordAuthenticationToken authenticationToken = userDto.toAuthentication();
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
String accessToken = jwtTokenProvider.generateAccessToken(authentication);
jwtTokenProvider.generateRefreshToken(authentication);
return new UserDto(userDto.getUserName(), userDto.getEmail(), accessToken);
}
/*
* Access Token 이 만료되었으므로, Redis 에 있는 Refresh Token 조회
* 만약 Refresh Token 까지 만료되었으면 재로그인 해야 함
* 하지만 Refresh Token 이 만료되지 않았으면, Access Token 재발급
*/
public String reissueAccessToken(String refreshToken) {
if (jwtTokenProvider.validateToken(refreshToken)) {
Authentication authentication = jwtTokenProvider.getAuthentication(refreshToken);
if (refreshToken.equals(redisTemplate.opsForValue().get(authentication.getName())))
return jwtTokenProvider.generateAccessToken(authentication);
else {
throw new RuntimeException("요청한 Refresh Token 불일치");
}
} else {
throw new RuntimeException("Access Token && Refresh Token 만료. 재로그인 필요");
}
}
}
join
: 스프링 컨테이너에 등록한 PasswordEncoder 을 사용해 유저가 회원가입을 위해 입력한 패스워드를 암호화하고 DB에 저장함login
reissueAccessToken
UserDetailsServiceImpl
@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
// username (email) 이 DB에 존재하는지 확인
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 시큐리티 세션에 유저 정보 저장
return userRepository.findUserByEmail(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("사용자가 존재하지 않습니다."));
}
// DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴
private UserDetails createUserDetails(User user) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(user.getAuthority().toString());
return new org.springframework.security.core.userdetails.User(
user.getEmail(),
user.getPassword(),
Collections.singleton(grantedAuthority)
);
}
}
loadUserByUsername
: username (email) 이 DB에 존재하는지 확인createUserDetails
: DB 에 User 값이 존재한다면 UserDetails 객체로 만들어서 리턴전체 코드