해당 코드들은 제가 여기저기서 찾아보고 짜집기한것 + 제 프로젝트 에서만 동작될 수 있는 코드들일수도 있기 때문에 재사용시에 입맛에 맞게 수정을 하시면 좋습니다. 공부용으로 구현한것들 이기 때문에 디테일한점이 부족할 수 있습니다.
해당 글은 공부를 위해 Spring + Security + JWT + Redis를 통한 회원인증/허가 구현 (3) - 로그인 시 Access, Refresh Token 부여/ 사용
를 참고하며 제 프로젝트에 적용시키기 위해 수정했던 코드를 순서대로 정리하기 위해 작성되었습니다.
기본적으로 스프링 시큐리티가 제공하는 폼 로그인으로 세션을 활용한 인증까지 구현해보았으면 이걸 토대로 Jwt토큰을 활용하여 인증을 받도록 방식을 변경해보자.
기존의 토큰 인증 방식에서 가장 큰 문제점은, 토큰이 탈취되는걸 방지하기 위해 짧은 시간의 유효기간을 가진 토큰을 발급해서 인증을 받고, 해당 토큰 만료될때마다 새로 발급을 받아 인증을 해야 하는 부분이 사용자에겐 번거롭게 느껴질 수 있다는 점이다.
이러한 문제점을 해결하기위해 리프레쉬 토큰을 따로 발급해 액세스 토큰이 만료될때마다 새로운 인증을 받게 하기보단, 액세스 토큰이 만료될때마다 기존의 리프레쉬 토큰에서 사용자의 정보를 가져와 그걸 토대로 인증을 위핸 액세스 토큰을 재 발급해 로그인이 유지되게끔 할것이다.
이를 좀더 효율적으로 구현하기 위해 만료기한을 설정할 수 있는 redis 를 활용해 리프레쉬 토큰을 저장할것이다.
(redis 를 다른 기능 구현에 활용할 수 있기 때문에 사용법을 익혀두면 유용하게 쓸 일이 많다.)
Redis는 Memcached와 비슷한 캐시 시스템으로서 동일한 기능을 제공하면서 영속성, 다양한 데이터 구조와 같은 부가적인 기능을 지원하고 있습니다. 레디스는 모든 데이터를 메모리에 저장하고 조회합니다. 즉, 인메모리 데이터베이스 입니다. 이 말만 들으면 Redis에 모든 데이터를 메모리에 저장하는 빠른 DB가 다라고 생각할지도 모릅니다. 하지만 빠른 성능은 레디스의 특징 중 일부분 입니다. 다른 인메모리 디비들과의 가장 큰 차이점은 레디스의 다양한 자료구조 입니다.
레디스는 말그대로 인메모리 데이터베이스로, 모든 데이터를 메모리에 저장하고 조죄한다.
다양한 자료구조를 지원하며 해당 자료구조를 활용해서
등에 활용이 가능하며, 우리는 이중에서 레디스를 활용하여 레디스를 키값 스토어로 사용할것이다.
우리는 일단 레디스를 활용해서 리프레쉬 토큰을 저장할것이기 때문에, 연동 설정을 해주어야한다. (레디스 설치는 건너뛴다.)
redis:
host: localhost
port: 6379
application.yaml 파일의 스프링 부분 밑에다가 레디스 서버의 주소와 포트를 직접 정해준다.
그리고 RedisUtil 클래스를 작성해 서비스 빈으로 등록한다.
@Service
public class RedisUtil {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private final String REDIS_KEY_PREFIX = "LOGOUT_";
private final String EXPIRED_DURATION = "EXPIRE_DURATION";
해당 빈은 레디스를 활용하여 레디스 서버에 데이터를 생성, 설정 할때 필요한 메소드들을 선언하는데 쓰인다.
그리고 해당 레디스를 활용할 메소드들을 선언해준다.
우리는 리프레쉬 토큰을 기한을 설정해 저장할것이고, 해당 데이터를 불러와 확인하는 기능을 가진 메소드를 구현해주면 된다.
public String getData(String key){
ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
return valueOperations.get(key);
} //레디스에 저장되어있는 데이터를 가져오는 메소드
public void setData(String key, String value){
ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
valueOperations.set(key,value);
} //만료 기한을 가지지 않은 데이터를 생성또는 설정하는 메소드
public void setDataExpire(String key,String value,long duration){
ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
Duration expireDuration = Duration.ofMillis(duration);
valueOperations.set(key,value,expireDuration);
} //이미 생성되어있거나 생성되어있지 않은 데이터를 만료기한까지 설정해 저장하는 메소드
public void deleteData(String key){
stringRedisTemplate.delete(key);
} //로그아웃시에 리프레쉬 토큰을 삭제하기 위한 메소드
public void setBlackList(String key, Object o, Long second) {
stringRedisTemplate.opsForValue().set(REDIS_KEY_PREFIX + key, o.toString(), Duration.ofMillis(second));
} //로그아웃시에 기존의 액세스 토큰을 로그아웃된 토큰으로 만료되었음을 표시하게 하기위한 메소드
public boolean hasKeyBlackList(String key) {
return Boolean.TRUE.equals(stringRedisTemplate.hasKey(REDIS_KEY_PREFIX + key));
} //토큰이 탈취되어 잘못된 인증을 시도하는 접근이 있는지 확인하는 메소드
단순히 토큰을 활용해서 로그아웃시에 리프레쉬, 액세스 토큰만 만료시키지 않고 잘못된 접근을 방지하기 위해 누군가가 로그아웃시에 남은 액세스토큰의 시간동안 잘못된 접근을 하지 않도록 우리는 액세스 토큰에 따로 로그아웃이라는 이름을 붙여 서버에 잠깐 저장해 값을 검증할것이다.
Spring Security -2 Security Filter
스프링 시큐리티는 해당 글에 정리했듯이, 인증이 필요한 모든 과정에 기존에 설정해두었던 config 클래스를 기반으로 모든 필터가 파이프라인 형태로 한바퀴씩 돌면서 인증과정을 거친다.
우리는 해당 필터들중에 UsernamePasswordAthenticationFilter 를 상속받은 클래스로 필터메소드를 재정의해 jwt 토큰을 활용해 인증을 하도록 변경해보자.
일단 우리는 스프링 시큐리티가 기존에 제공하는 폼 로그인을 사용하지 않기때문에, 직접 설정해줘야하는 것들이 있다.
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, java.io.IOException {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException e) throws IOException, ServletException, java.io.IOException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UnAuthorized");
}
}
이렇게 해당 인가,인증이 실패했을때 어떠한 에러를 출력할 것인지 , 따로 설정을 해주어야한다.
일단 로그인을 구현하기 전에 기본적인 Jwt토큰을 생성,검증 할 수 있는 틀을 만들자.
build.gradle 에 jwt토큰을 활용하기 위한 디펜던시를 추가해준다.
implementation('io.jsonwebtoken:jjwt-root:0.11.5')
implementation('io.jsonwebtoken:jjwt-impl:0.11.5')
implementation('io.jsonwebtoken:jjwt-jackson:0.11.5')
그리고 application.yaml 에 마찬가지로 jwt키 관련 세팅을 해준다.
jwt:
secret: "secretkey"
(시크릿키 관련은 해당 문자열이 어떠한 조건에 맞지 않으면 예외를 발생시키기 때문에 적당한 키를 찾아서 입력해주도록 하자. 마찬가지로 시크릿키를 따로 노출시키지 않고 랜덤값으로 설정 해주는게 좋다.)
@Component
public class TokenProvider {
private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private final RedisUtil redisUtil;
public final static long TOKEN_VALIDATION_SECOND = 1000L * 600; // 10분
public final static long REFRESH_TOKEN_VALIDATION_SECOND = 1000L * 60 * 60 * 24 * 7; // 7일
//액세스토큰과 리프레쉬 토큰의 만료시간 설정
final static public String ACCESS_TOKEN_NAME = "accessToken";
final static public String REFRESH_TOKEN_NAME = "refreshToken";
//토큰 이름 설정
@Value("${spring.jwt.secret}")
private String SECRET_KEY;
//키 발급에 사용될 시크릿키 (yaml 파일에서 값을 가져온다.)
public TokenProvider(RedisUtil redisUtil) {
this.redisUtil = redisUtil;
}
private Key getSigningKey(String secretKey) {
byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
} //서명키를 시크릿키를 바탕으로 암호화해 설정한다.
public Claims extractAllClaims(String token) throws ExpiredJwtException {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey(SECRET_KEY))
.build()
.parseClaimsJws(token)
.getBody();
} // 식별을 위해 저장했거나 저장된 값들을 다시 가져오기 위한 메소드(압축해제와 비슷하다.)
public String getUsername(String token) {
return extractAllClaims(token).get("username", String.class);
} //payload 에서 유저이름을 가져온다.
public Boolean isTokenExpired(String token) {
final Date expiration = extractAllClaims(token).getExpiration();
return expiration.before(new Date());
} //토큰이 만료되었는지 확인해주는 메소드
public String generateToken(BoardPrincipal member) {
return doGenerateToken(member.getUsername(), TOKEN_VALIDATION_SECOND);
}
public String generateRefreshToken(BoardPrincipal member) {
return doGenerateToken(member.getUsername(), REFRESH_TOKEN_VALIDATION_SECOND);
}
public String doGenerateToken(String username, long expireTime) {
Claims claims = Jwts.claims();
claims.put("username", username);
String jwt = Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expireTime))
.signWith(getSigningKey(SECRET_KEY), SignatureAlgorithm.HS256)
.compact();
return jwt;
} //토큰을 생성해주는 메소드 (claim에 식별을 위한 다양한 정보를 저장할 수 있다.)
public Long getExpireTime(String token) {
return extractAllClaims(token).getExpiration().getTime();
} //토큰의 만료시간을 반환해주는 메소드
public Boolean validateToken(String token) {
if (redisUtil.hasKeyBlackList("LOGOUT_"+token)) {
return false;
}
return true;
} //토큰을 검증해주는 메소드. 탈취당한 토큰이라면 접근을 거부시킨다.
}
이렇게 기본적으로 토큰을 검증하고, 생성할 메소드들을 선언한 Provider 클래스를 작성해준다.
어떻게보면 repository 와도 결이 비슷하다고 생각한다.
JWT토큰은 사용자의 속성을 저장하는 클레임 기반의 웹 토큰이기 때문에 클레임에 식별을 위한 정보를 저장하거나 가져올 수 있다.
그리고 우리는 리프레쉬 토큰은 레디스에 저장하지만 인증시에는 쿠키에 토큰을 저장해서 활용할것이기 때문에 Cookie 를 좀더 쉽게 다루기 위한 CookieUtil 클래스도 작성해주자.
@Service
public class CookieUtil {
public Cookie createCookie(String cookieName, String value){
Cookie token = new Cookie(cookieName,value);
token.setHttpOnly(true);
token.setMaxAge((int) TokenProvider.REFRESH_TOKEN_VALIDATION_SECOND);
token.setPath("/");
return token;
}
public Cookie getCookie(HttpServletRequest req, String cookieName){
final Cookie[] cookies = req.getCookies();
if(cookies==null) return null;
for(Cookie cookie : cookies){
if(cookie.getName().equals(cookieName))
return cookie;
}
return null;
}
}
쿠키는 여러개가 저장될 수 있기 때문에 원하는 값만 가져올때 매번 스트림이나 반복문을 사용하지 않고 따로 메소드를 작성해서 값을 찾아주면 번거로운 일이 사라질것이다.
그리고 이제 인증에 쓰일 필터를 재정의해주자.
@Service
public class JwtRequestFilter extends OncePerRequestFilter {
@Autowired
private UserSecurityService userDetailsService;
@Autowired
private TokenProvider tokenProvider;
@Autowired
private CookieUtil cookieUtil;
@Autowired
private RedisUtil redisUtil;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException, java.io.IOException {
final Cookie jwtToken = cookieUtil.getCookie(httpServletRequest, TokenProvider.ACCESS_TOKEN_NAME);
//인증이 필요한 부분에 매번 이 필터가 실행된다고 보면 된다.
String username = null;
String jwt = null;
String refreshJwt = null;
String refreshUname = null;
// 액세스 토큰을 1차적으로 가져와서 쿠키에 저장한뒤에, 만료되었을시에 리프레쉬 토큰을 확인후 액세스토큰을 재발급 받는다.
try {
if (jwtToken != null) {
jwt = jwtToken.getValue();
username = tokenProvider.getUsername(jwt); //액세스토큰의 payload 에서 유저이름을 가져온다.
} else {
logger.warn("Cannot find access token");
}
if (username != null) { //액세스 토큰의 유저네임을 기반으로 인증을 거쳐 로그인을 유지해준다.
System.out.println("userDetails : " + username);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (tokenProvider.validateToken(jwt)) {
logger.info("validateToken : " + jwt); //토큰이 만료되지 않았다면 로그인을 유지시켜준다.
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
} else {
logger.warn("Cannot find username from access token");
}
} catch (ExpiredJwtException e) { //토큰이 만료되었을때 리프레쉬 토큰을 검증하여 액세스토큰을 재발급해준다.
String refreshToken = cookieUtil.getCookie(httpServletRequest, TokenProvider.REFRESH_TOKEN_NAME).getValue();
System.out.println("refreshToken : " + refreshToken);
if (refreshToken != null) {
refreshJwt = refreshToken;
} else {
logger.warn("Cannot find refresh token");
}
} catch (Exception e) {
}
try {
if (refreshJwt != null) { //리프레쉬 토큰이 만료되지 않았을때 레디스에서 유저이름을 가져와 액세스토큰을 재발급 해주고 로그인을 유지시켜준다.
refreshUname = redisUtil.getData(refreshJwt);
if (refreshUname.equals(tokenProvider.getUsername(refreshJwt))) {
UserDetails userDetails = userDetailsService.loadUserByUsername(refreshUname);
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null,userDetails.getAuthorities() );
usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
Cookie newAccessToken = cookieUtil.createCookie(TokenProvider.ACCESS_TOKEN_NAME, tokenProvider.doGenerateToken(refreshUname, TokenProvider.TOKEN_VALIDATION_SECOND));
httpServletResponse.addCookie(newAccessToken);
}
} else {
logger.warn("Cannot find refresh token");
}
} catch (ExpiredJwtException e) {
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
}
코드가 길긴 하지만, 나름대로 정리해 주석을 달아보았다.
기본적인 흐름은
로그인시에 쿠키에 액세스 토큰과 리프레쉬 토큰을 저장한다.
시큐리티 필터가 돌면서 액세스 토큰을 최초로 쿠키에서 값을 가져와 해당 토큰을 기반으로 인증을 진행하고 , SecurityContext에 인증 정보를 저장해 로그인을 유지한다.
해당 액세스 토큰이 만료되었을때, 쿠키에 저장된 리프레쉬 토큰을 가져와 저장한다.
리프레쉬 토큰이 존재한다면, 저장하고 레디스에서 리프레쉬 토큰으로 저장된 키의 값(username) 을 가져와 해당 정보로 authentication을 가져와 contextHolder 에 저장하고, 새로 액세스 토큰을 발급받아 쿠키로 저장해서 로그인을 유지한다.
이렇게 흘러가는것 같다.
다음 글에선 로그인과 로그아웃을 jwt토큰을 활용하여 진행하도록 구현해보자
참고한 글