https://thiswooin.tistory.com/92
지난 시간까지 알아 본 Redis를 Spring에서 연동하는 방법.
이번에는 RedisRepository 방식을 활용하여, 기존 Access Token만 발행하던 인증인가 시스템에서
2시간 기한의 AccessToken과 2주 기한의 RefreshToken을 생성하여 AccessToken이 탈취당했을 때의 취약점을 조금이나마 더 보완하고, 유저 경험적으로도 짧은 AccessToken의 기한으로 인한 잦은 재발행을 위한 행동 없이 자동으로 갱신할 수 있도록 개선해보려 한다.
0. 현재 코드
build.gradle
//jwt 부분
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-web-services'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
compileOnly 'org.projectlombok:lombok'
무엇보다도 Security환경에서 진행되고 있고, 다른 의존성도 혹시 몰라 최대한 끌어와봤습니다.
Jwt
- JwtAuthenticationFilter : 인증에 대한 기능을 구현하는 필터 클래스.
- JwtAuthorizationFilter : 인가 관련 기능 구현 필터 클래스.
- JwtUtil : Jwt토큰 생성, 가져오기 등 유틀 클래스.
WebSecurityConfig
// 필터 관리
http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
아까 위에서 설명된 두 가지 필터를 WebSecurityConfig클래스에 위 코드처럼 필터로 등록해 두었다.
1. JwtAuthorizaionFilter : 우선 인가정보가 있는지 확인. (이미 인가 정보가 있으면 인증이 필요없으니)
2. JwtAuthenticationFilter : 인가정보가 없는 경우 인증과정을 거친다. 결론적으로, UsernamePasswordAuthenticationFilter에서 사용될 Authentication을 set하게 된다.
인증인가 순서가 조금 헷갈린다.
필터동작되는 과정 요모조모를 어느정도 타협하고 이해하고 넘어갔어서 더 흔들리는 듯 하다.
1. RefreshToken 발행하기
우선은 AccessToken만 발행되던 기존의 코드에 같은 방식으로 RefreshToken을 생성해준다.
JwtUtil
//Refresh Token 관련 멤버
public static final String REFRESH_TOKEN_HEADER = "Refresh-Token";
private final long REFRESH_TOKEN_TIME = 14 * 24 * 60 * 60 * 1000L; // 14일 세팅
...
public String createRefreshToken(String username, UserRoleEnum role) {
Date date = new Date();
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username)
.claim(AUTHORIZATION_KEY, role)
.setExpiration(new Date(date.getTime() + REFRESH_TOKEN_TIME))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
}
JwtAuthenticationFilter
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("로그인 성공 및 JWT 생성");
// refactor 토큰을 한번에 처리할 수 있게 합칠 수도 있지 않을까?
String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername();
UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole();
String accessToken = jwtUtil.createToken(username, role);
String refreshToken = jwtUtil.createRefreshToken(username, role); //refresh token 생성
response.addHeader(JwtUtil.AUTHORIZATION_HEADER, accessToken);
response.addHeader(JwtUtil.REFRESH_TOKEN_HEADER, refreshToken);
//클라이언트로 헤더에 담아서 데이터를 보내주는 것 까지만 됐다. 이제 서버측에도 저장 해둬야한다.
기존에 가져와진 username, role 데이터를 똑같이 claims에 포함하여 RefreshToken을 만들어 두기로 했다.
이 부분은 다들 많이 다른데 우선은 Token을 갱신할 때, RefreshToken에서 바로 데이터를 가져오고 싶어서 이후에 수정했던 부분이다.
2. RefreshToken Redis에 저장하기
JwtAuthenticationFilter (추가)
...
String accessToken = jwtUtil.createToken(username, role);
String refreshToken = jwtUtil.createRefreshToken(username, role); //refresh token 생성
...
//생성한 accessToken, refreshToken와 함께 Redis상에서 Key역할을 해 줄 값을 같이 담아서 저장해준다.
refreshTokenRepository.save(new RefreshToken(((UserDetailsImpl) authResult.getPrincipal()).getUser().getUsername(), refreshToken, accessToken));
아까 위에서 수정한 JwtAuthenticationFilter 코드 아래에 Redis저장해주는 코드를 추가해준다.
여기서 new RefreshToken은 Dto의 역할로 만든 Entity 이다.
RefreshToken.class
@AllArgsConstructor
@Getter
@RedisHash(value = "refreshToken", timeToLive = 14*24*60*60) // 일*시*분*초 14일로 설정해 둠.
public class RefreshToken {
@Id // 이 태그를 통해 키 값이 된다.
private String username;
private String refreshToken;
private String accessToken;
RefreshToken의 만료시간인 14일과 동일하게 설정해두었다.
Redis 저장 확인
현재 Repository를 활용하여 저장하기 때문에 위와 같은 형태로 저장된 것을 확인할 수 있고,
Time To Live 14일이 제대로 적용된 것도 확인할 수 있다.
그리고 @Id에 Username을 설정해두어서 아래처럼 이를 통한 조회도 가능하다.
Username은 이후에 변동가능성이 있는 값이라 PK값인 user_id로 변경해줘야할 듯하다.
RefreshTokenRepository
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
Optional<RefreshToken> findByAccessToken(String accessToken);
}
느낀점
이번 만큼 구글링하며 본 코드들이 각양각색인 적이 또 없었던 것 같다.
그만큼 각기다른 코드들도 동작을 하는 걸 보면 새삼 개발자의 능력이 대단해 보인다.
맞으면 '왜 맞지?', 틀리면 '왜 틀리지?' 끊임없이 고민하며 성장할 수 밖에 없는 직무인 것 같다.
매력적이다.