이어서...
attemptAuthentication 인증이 완료되면 -> successfulAuthentication (인증 후 후처리)
attemptAuthentication 인증이 실패하면 -> unsuccessfulAuthentication
1. successfulAuthentication 구현 전 참고해야할 내용
4) JWT 토큰을 만들어서 응답해주면 된다.
마지막 4 단계인 토큰 발급 후 응답!
attemptAuthentication 함수에서 인증이 성공한 뒤에
이 함수가 종료되고, 이어서 다른 내부 메소드인 successfulAuthentication 함수가 실행된다!
인증에 성공한 사용자의 정보를 기반으로 JWT 토큰을 생성하고, 이를 응답으로 반환할 것이다.
우리는 이 과정을 successfulAuthentication 메소드에서 처리할 것이다!
우리는 접근성/보안성의 이유로 JWT 형식의 토큰을 두 가지 발급한다고 했다.
https://jinhos-devlog.tistory.com/entry/JWT%EB%9E%80
5. Refresh Token 사용하기 참고
액세스 토큰 (Access Token): 사용자가 인증되었음을 증명하는 토큰.
주로 클라이언트가 서버로 요청을 보낼 때 사용된다.
이 토큰은 사용자의 인증 정보 및 권한을 포함하고 있으며, 일반적으로 짧은 유효 기간을 가진다.
리프레시 토큰 (Refresh Token): 엑세스 토큰보다 오랜 유효 기간을 가지며,
액세스 토큰의 만료 시간이 지난 경우에 사용되어 새로운 액세스 토큰을 발급받기 위해 사용된다.
주로 보다 보안적으로 중요한 정보를 담고 있으며, 보통은 암호화되어 Redis에 저장된다.
successfulAuthentication 을 완성하기 전에 Jwt에 관한 Util을 만들어주자.
(생성 및 관리에 관한 메소드들)
2. 토큰 발급 코드 작성
2-1. JwtDto
public record JwtDto(
String accessToken,
String refreshToken
) {
}
2-2. JwtUtil에 발급 코드 작성
@Slf4j
@Component
public class JwtUtil {
private final SecretKey secretKey;
private final Long accessExpMs;
private final Long refreshExpMs;
private final RedisUtil redisUtil;
public JwtUtil(
// 해당 @Value 값들은 yml에서 설정할 수 있다
@Value("${spring.jwt.secret}") String secret,
@Value("${spring.jwt.token.access-expiration-time}") Long access,
@Value("${spring.jwt.token.refresh-expiration-time}") Long refresh,
RedisUtil redis) {
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),
Jwts.SIG.HS256.key().build().getAlgorithm());
accessExpMs = access;
refreshExpMs = refresh;
redisUtil = redis;
}
// JWT 토큰을 입력으로 받아 토큰의 페이로드에서 사용자 이름(Username)을 추출
public String getUsername(String token) throws SignatureException {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
// JWT 토큰을 입력으로 받아 토큰의 페이로드에서 사용자 이름(roll)을 추출
public String getRoles(String token) throws SignatureException{
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("role", String.class);
}
// JWT 토큰의 페이로드에서 만료 시간을 검색, 밀리초 단위의 Long 값으로 반환
public long getExpTime(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration()
.getTime();
}
// Token 발급
public String tokenProvider(PrincipalDetails principalDetails, Instant expiration) {
Instant issuedAt = Instant.now();
String authorities = principalDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
return Jwts.builder()
.header()
.add("typ", "JWT")
.and()
.subject(principalDetails.getUsername())
.claim("role", authorities)
.issuedAt(Date.from(issuedAt))
.expiration(Date.from(expiration))
.signWith(secretKey)
.compact();
}
// principalDetails 객체에 대해 새로운 JWT 액세스 토큰을 생성
public String createJwtAccessToken(PrincipalDetails principalDetails) {
Instant expiration = Instant.now().plusMillis(accessExpMs);
return tokenProvider(principalDetails, expiration);
}
// principalDetails 객체에 대해 새로운 JWT 리프레시 토큰을 생성
public String createJwtRefreshToken(PrincipalDetails principalDetails) {
Instant expiration = Instant.now().plusMillis(refreshExpMs);
String refreshToken = tokenProvider(principalDetails, expiration);
// 레디스에 저장
redisUtil.save(
principalDetails.getUsername(),
refreshToken,
refreshExpMs,
TimeUnit.MILLISECONDS
);
return refreshToken;
}
}
secret, access-expiration-time, refresh-expiration-time 와 같은 값들은 보안적으로 중요하기 때문에
소스 코드에 직접 하드코딩하는 것은 보안상 권장되지 않는다.
application.yml 파일에 설정해주자.
spring:
redis:
host: localhost
port: 6379
jwt:
secret: testSecretKey20240316testSecretKey20240316testSecretKey20240316
token:
access-expiration-time: 3600000
refresh-expiration-time: 86400000
추가적으로 getExpTime(), reissueToken(), resolveAccessToken(), validateRefreshToken() 등은 이후에 다시 작성하겠다!
일단은 토큰 생성하는 코드만.
3. RedisConfig 와 RedisUtil
그리고 리프레시 토큰은 Redis에 저장한다고 하였다!
Redis를 사용하기 위해 Config와 Util을 만들어주자.
https://jinhos-devlog.tistory.com/entry/SpringBoot%EC%97%90%EC%84%9C-Redis-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0#4.%20RedisConfig%20%EC%9E%91%EC%84%B1-1
RedisConfig 작성 & 템플릿 사용하기 (RedisUtil) 참고
1) RedisConfig
@Configuration
public class RedisConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
2) RedisUtil
@Component
public class RedisUtil {
private final RedisTemplate<String, Object> redisTemplate;
public RedisUtil(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void save(String key, Object val, Long time, TimeUnit timeUnit) {
redisTemplate.opsForValue().set(key, val, time, timeUnit);
}
public boolean hasKey(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
public boolean delete(String key) {
return Boolean.TRUE.equals(redisTemplate.delete(key));
}
}
참고 : Redis는 <Key : Value> 형태로 저장된다!
4. successfulAuthentication 구현
자... 리프레시 토큰을 저장할 RedisConfig 설정과, RedisUtil,
그리고 JwtUtil_토큰 발급 메서드 까지 만들었으니!
successfulAuthentication에서 토큰을 발급해 날려주면 끝!!!
// 스프링 시큐리티에 UsernamePasswordAuthenticationFilter 라는게 있음
// /login 요청해서 username,password 전송하면 (POST)
// UsernamePasswordAuthenticationFilter 필터가 작동함
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
// /login 요청을 하면, 로그인 시도를 위해서 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
// 중략
}
// JWT Token 생성해서 response에 담아주기
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
log.info("[*] Login Success! - Login with " + principalDetails.getUsername());
JwtDto jwtDto = new JwtDto(
jwtUtil.createJwtAccessToken(principalDetails),
jwtUtil.createJwtRefreshToken(principalDetails)
);
log.info("Access Token: " + jwtDto.accessToken());
log.info("Refresh Token: " + jwtDto.refreshToken());
HttpResponseUtil.setSuccessResponse(response, HttpStatus.CREATED, jwtDto);
}
}
5. unsuccessfulAuthentication
사용자의 로그인이 실패했을 때 호출되는 메서드이다!
successfulAuthentication와 같은 단에 놓아주자.
(successfulAuthentication 아래)
인증 실패 사유에 따라 적절한 오류 메시지를 설정하여, 쏴주자!
// 스프링 시큐리티에 UsernamePasswordAuthenticationFilter 라는게 있음
// /login 요청해서 username,password 전송하면 (POST)
// UsernamePasswordAuthenticationFilter 필터가 작동함
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
// /login 요청을 하면, 로그인 시도를 위해서 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
log.info("JwtAuthenticationFilter : 로그인 시도 중");
// 중략
}
// JWT Token 생성해서 response에 담아주기
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
log.info("[*] Login Success! - Login with " + principalDetails.getUser().getUsername());
JwtDto jwtDto = new JwtDto(
jwtUtil.createJwtAccessToken(principalDetails),
jwtUtil.createJwtRefreshToken(principalDetails)
);
// 테스트용 (실제 사용 시 삭제)
log.info("Access Token: " + jwtDto.getAccessToken());
log.info("Refresh Token: " + jwtDto.getRefreshToken());
HttpResponseUtil.setSuccessResponse(response, HttpStatus.CREATED, jwtDto);
}
}
+ 참고 ApiResponse,
API 응답을 표현하는 데 사용되는 클래스이다.
이 클래스는 클라이언트에게 HTTP 응답을 반환할 때 사용되며, 주로 JSON 형식으로 응답을 전송하는 데 사용된다.
HttpResponseUtill, ApiResponse 등. 구글링 해보면 정보가 많이 나올 것이다~~~~
끝!
자, JwtAuthenticationFilter 구현은 이렇게 끝이 났다!!!
6. SecurityConfig에 Filter 추가하기
@Configuration // IoC 빈(bean)을 등록
@EnableWebSecurity // 필터 체인 관리 시작 어노테이션
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final JwtUtil jwtUtil;
private final RedisUtil redisUtil;
// 중략
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// 중략
// JWT login
// Jwt Filter (with login)
JwtAuthenticationFilter loginFilter = new JwtAuthenticationFilter(
authenticationManager(authenticationConfiguration), jwtUtil);
loginFilter.setFilterProcessesUrl("/login");
http
.addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
- 우리는 이전에 formLogin(AbstractHttpConfigurer::disable); 으로 formlogin 방식을 disable했다!
- loginFilter.setFilterProcessesUrl("/login");
- 즉, 로그인 페이지의 URL은 "/login"이며, 로그인을 처리하는 URL도 "/login"이고,
로그인 후에는 원래 요청한 페이지로 리다이렉트된다! - ( 참고로 /login이 default 값이라, 사실 따로 설정하지 않아도 된다! 명시적으로 작성한 것 뿐.)
- (다만, 로그인 요청 처리를 "/loginuser" 등으로 post 하는 걸로 바꾸고 싶다.면 해당 메서드에서 url을 변경하면된다!)
7. TEST
포스트 맨으로 테스트를 해보자!!
1) Success Case
body에 json 형식으로, 회원가입된 계정을 담아서
http://localhost:8080/login url로 post를 쏴보자!
- 1 줄 : /login 요청해서 username,password 전송하면 (POST), JwtAuthenticationFilter이 돌아가고,
attemptAuthentication를 통해 로그인이 시도된다. - 2 줄 : authenticationManager.authenticate(authenticationToken); 과정에서
PrincipalDetailsService의 loadUserByUsername() 함수가 실행되고 정상이면 authentication이 return된다. - 3 줄 : PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
log.info("로그인 완료 : " + principalDetails.getUser().getUsername()); - 4 줄 : successfulAuthentication 함수가 실행되고,
log.info("[*] Login Success! - Login with " + principalDetails.getUser().getUsername()); 코드 실행 - 5 줄 : 테스트 용으로 token 로그 찍은 부분.
2) Error Case
- 1, 2 줄 : authenticationManager.authenticate(authenticationToken); 과정에서 PrincipalDetailsService의 loadUserByUsername() 함수가 실행되고 정상이면 authentication이 return된다.
- 3 줄 : 그러나 "로그인 완료 : " 로그가 뜨지 않은 것을 보아, 정상적으로 authentication이 return되지 않은 것을 알 수 있다!
- 3 줄 : 그리고 unsuccessfulAuthentication가 실행되었고,
사용자가 제공한 자격 증명(예: 사용자 이름 또는 암호)이 유효하지 않은 경우에 발생하는
BadCredentialsException이 발생했다!
다음 포스팅에서 "Authorization(인가) 과정에 대해 알아보겠다!