-
[Spring] Spring Security JWT 구현 (1)Code 2024. 12. 7. 23:46
프로젝트 진행 중 필요했던 로그인 기능 구현을 위해 학습한 내용, 구현한 내용을 정리한 글입니다. 🥹 프로젝트 진행에 필요한 정도만 구현하였기 때문에 OAuth 소셜 로그인이나 요런 녀석들은 생략했습니다.
JWT(JSON Web Token)
- Header, Payload, Signature로 구성
- 정보를 Base64 URL-safe Encode을 통해 인코딩해 직렬화
- API 요청 시 JWT를 전달하여 인증, 인가를 진행하는 토큰 인증 방식의 한 종류
구현 방식
1. 고려 사항
- 모바일로 접속하는 유저풀이 없으며, 비브라우저 환경을 고려하지 않음
- 모든 회원은 관리자 레벨임
- Access Token, Refresh Token
- 값이 존재하지 않는 경우
- 유효하지 않은 경우(사용자가 일치하지 않는 경우 등)
- 유효시간이 만료된 경우
- Access Token이 만료되지 않았는데 Refresh Token을 통해 재발급된 경우
- 로그아웃 처리를 완료했는데 JWT가 만료되지 않은 경우
- CSRF Token
- 값이 존재하지 않는 경우
- 유효하지 않은 경우(다른 세션에서 사용하려고 하는 경우)
- 유효시간이 만료된 경우
- 유효시간이 만료되었는데 앞의 두 토큰이 탈취당한 채로 재발급된 경우
-> 이건 CSRF Token의 보안 영역이 아니라고 해서 제외(API 요청 위조 방지에 목적)
2. 구현 방식
- Access Token, Refresh Token의 저장 위치
- Local Storage나 Session에 저장: XSS에 대해 고민해야 함
- Header에 저장: 프론트에서 따로 처리해줘야 해서 귀찮다.. 이런 작업을 최소화하고 싶다. 웹소켓처럼 따로 헤더로 쏴줘서 인증해야 하는 다른 통신이 없어서 그냥 패스
- Cookie에 저장하는 것으로 결정
- HttpOnly, Secure 설정을 하고 쿠키에 저장해야 한다.
- Redis(In-Memory DB) 방식으로 토큰을 관리한다.
- {토큰명}:username:{아이디}로 저장한다.
- Refresh token은 Redis에 바로 저장하되, Access token은 (나름) stateless한 관리를 위해 쿠키로 관리한다.
- 각 토큰은 유효시간이 지나면 Redis에서 삭제하는 방식으로 관리한다.
- 두 토큰을 모두 쿠키에 전송하므로, CSRF 공격의 방지를 위해 CSRF 토큰 처리
- 정상적인 로그아웃을 수행해 쿠키를 삭제했으나, JWT의 유효시간이 남은 경우
-> 토큰의 탈취 가능성을 방지하기 위해 Redis에서 JWT에 대한 블랙리스트를 관리한다.
구현
1. 프로젝트 환경 설정
application.yml
jwt: secret: "secret key" access-token-expiration: 300 refresh-token-expiration: 86400
- secret는 임의의 난수를 생성해서 추가했다.
build.gradle
// redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' // security implementation 'org.springframework.boot:spring-boot-starter-security' // jwt implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
- redis, security, jwt 관련 의존성 설정을 추가해야 한다.
2. Entity, Repository 생성
- 유저에 대한 기본 엔티티와 레포지토리 생성(생략)
- 본 글에서는 역할 기반으로 권한을 나눠줄 거라 Role에 대한 정보도 저장했다!
3. Redis 설정
3-0. RedisConfig.java
- connection, template를 구현해줬다.
3-1. RedisService.java
- 레디스가 모든 토큰의 저장소가 될 것이기 때문에 토큰별로 redis를 거치는 서비스들은 전부 여기에서 관리하고, util에서는 호출만 하는 형식으로 분리하고자 했다... 그런데 비슷한 이름과 역할의 메서드가 util에서도 중복으로 생겨서 어떤 식으로 관리하는 게 효율적인지 모르겠다.
- redis에서는 {토큰명}:{log여부}:username:{username} 의 형식으로 키값을 저장했다.
@Service @RequiredArgsConstructor public class RedisTokenService { @Value("${jwt.refresh-token-expiration}") private long refreshTokenExpiration; private final RedisTemplate<String, String> redisTemplate; // Refresh Token public void saveRefreshToken(String username, String refreshToken) { String key = "refreshToken:username:" + username; redisTemplate.opsForValue().set(key, refreshToken, refreshTokenExpiration, TimeUnit.SECONDS); } public void deleteRefreshToken(String username) { String key = "refreshToken:username:" + username; redisTemplate.delete(key); } public boolean validateRefreshToken(String username, String refreshToken) { String key = "refreshToken:username:" + username; String storedToken = redisTemplate.opsForValue().get(key); return storedToken != null && storedToken.equals(refreshToken); } // Access Token public void addBlacklist(String token, long expirationTime) { String key = "blacklist:" + token; redisTemplate.opsForValue().set(key, "blacklisted", expirationTime, TimeUnit.SECONDS); } public boolean isBlacklisted(String token) { String key = "blacklist:" + token; return Boolean.TRUE.equals(redisTemplate.hasKey(key)); } // CSRF Token public void saveCsrfToken(String username, String csrfToken, long csrfTokenExpiration) { String key = "csrfToken:username:" + username; String logKey = "csrfToken:log:username:" + username; redisTemplate.opsForValue().set(key, csrfToken, csrfTokenExpiration, TimeUnit.SECONDS); redisTemplate.opsForValue().set(logKey, csrfToken, refreshTokenExpiration, TimeUnit.SECONDS); } public String getCsrfToken(String username) { String key = "csrfToken:username:" + username; return redisTemplate.opsForValue().get(key); } public boolean validateCsrfToken(String username, String csrfToken) { String logKey = "csrfToken:log:username:" + username; String logToken = redisTemplate.opsForValue().get(logKey); if (logToken != null && Objects.equals(logToken, csrfToken)) { redisTemplate.delete(logKey); return true; } return false; } public void deleteCsrfToken(String username) { String key = "csrfToken:username:" + username; String logKey = "csrfToken:log:username:" + username; redisTemplate.delete(key); redisTemplate.delete(logKey); } }
4. TokenUtil
JwtUtil.java
- AccessToken, RefreshToken에 대한 전반적인 로직(Create, Get, Delete, Validate)을 담당한다.
- JWT는 그 자체로 정보를 담고 있기 때문에, 최소한의 정보만 저장하려고 했는데...... 뭔가 많이 생겼다.
특히 RefreshToken은 AccessToken의 재발급 목적으로만 존재하니 username만 남겨두려고 했는데 생각 없이 짜다가 결국 하나 더 넣어줬다. role에 대한 정보는 제외했다.
@Component @RequiredArgsConstructor public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.access-token-expiration}") private long accessTokenExpiration; @Value("${jwt.refresh-token-expiration}") private long refreshTokenExpiration; private final RedisTokenService redisTokenService; private String createToken(String sub, String userId, String roleType, long expiration) { Header header = Jwts.header() .add("typ", "JWT") .build(); Claims claims = Jwts.claims() .add("sub", sub) .add("user_id", userId) .add("role_type", roleType) .build(); Date now = new Date(); return Jwts.builder() .header().add(header).and() .claims(claims) .issuedAt(now) .expiration(new Date(now.getTime() + expiration * 1000L)) .signWith(Keys.hmacShaKeyFor(secret.getBytes())) .compact(); } private Claims extractClaims(String token) { return Jwts.parser() .verifyWith(Keys.hmacShaKeyFor(secret.getBytes())) .build() .parseSignedClaims(token) .getPayload(); } public boolean validateAccessToken(String accessToken) { if (redisTokenService.isBlacklisted(accessToken)) { throw new CommonException(JwtMessageType.INVALID_VERIFICATION_TOKEN); } try { extractClaims(accessToken); return true; } catch (JwtException e) { return false; } } public boolean validateRefreshToken(String refreshToken) { if (refreshToken == null) { throw new CommonException(JwtMessageType.REFRESH_TOKEN_NOT_EXIST); } try { extractClaims(refreshToken); return true; } catch (JwtException e) { return false; } } public void addBlacklist(String accessToken) { long expirationTime = extractClaims(accessToken).getExpiration().getTime() - new Date().getTime(); redisTokenService.addBlacklist(accessToken, expirationTime); } public String createAccessToken(String username, String userId, String roleType) { return createToken(username, userId, roleType, accessTokenExpiration); } public String createRefreshToken(String username, String userId) { return createToken(username, userId, null, refreshTokenExpiration); } public String getUsername(String token) { return Jwts.parser() .verifyWith(Keys.hmacShaKeyFor(secret.getBytes())) .build() .parseSignedClaims(token) .getPayload() .get("sub", String.class); } public String getUserId(String token) { return Jwts.parser() .verifyWith(Keys.hmacShaKeyFor(secret.getBytes())) .build() .parseSignedClaims(token) .getPayload() .get("user_id", String.class); } public String getRoleType(String token) { return Jwts.parser() .verifyWith(Keys.hmacShaKeyFor(secret.getBytes())) .build() .parseSignedClaims(token) .getPayload() .get("role_type", String.class); } }
CsrfTokenUtil
- 임의의 문자열로 csrf 토큰을 생성하는 메서드와 토큰을 검증하는 메서드를 추가해줬다.
- 토큰을 검증할 때 true이면 유효한 토큰이고, 레디스 서비스에서 validate할 때 error가 잡히지 않고 false로 리턴하는 경우 재발급되도록 했다.
- csrf 토큰은 redis에 세션 시간만큼 유지되므로, redis에서 날아가면 재발급을 받을 때 따로 검증할 수 있는 방법이 없었다...
- 그래서 redisTokenService에서 csrf 토큰을 저장할 때, 같은 내용을 log:라는 키를 추가하여 refresh token만큼 길게 저장을 하고, 재발급받을 때 해당 유저가 발급했던 csrf 토큰인지 확인하고 log를 삭제하도록 했다.. 원시적인 방법이라고 생각한다..^_ㅠ
- 해당 고민 내용은 필터에서 좀 더 자세하게 적어보려고 한다...
@Component @RequiredArgsConstructor public class CsrfTokenUtil { private final SecureRandom secureRandom = new SecureRandom(); public String generateToken() { byte[] tokenBytes = new byte[32]; secureRandom.nextBytes(tokenBytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); } public boolean validateToken(String providedToken, String username, RedisTokenService redisTokenService) { String storedToken = redisTokenService.getCsrfToken(username); if (storedToken == null) { if (!redisTokenService.validateCsrfToken(username, providedToken)) { throw new CommonException(JwtMessageType.INVALID_VERIFICATION_TOKEN); } return true; } if (!providedToken.equals(storedToken)) { throw new CommonException(JwtMessageType.INVALID_VERIFICATION_TOKEN); } return true; } }
여기까지 토큰에 관련된 세팅과 서비스 생성이 끝났다. 작성하다 보니 내용이 길어져서 시큐리티 설정과 커스텀 필터, 유저 서비스에 대한 부분은 다음 글에서 작성하려고 한다! 확실히 프론트까지 연동시키려고 하니 중간중간 코드에 변화가 많이 생겼다... 양도 많아지고...🥹
'Code' 카테고리의 다른 글
[Spring] Spring Security JWT 구현 (2) (0) 2024.12.12 [Spring] Kafka 채팅 파티션 분산 (3) (3) 2024.11.11 [Spring] 채팅 구현 중 웹소켓 중복 구독, 연결 끊김 문제 해결 (2) (0) 2024.11.03 Kafka 메모 (0) 2024.10.02 [Spring] Web Socket, STOMP, MongoDB 환경에서 채팅 구현 (1) (2) 2024.09.26