public interface OAuthService {
default OAuthAccessToken requestAccessToken(String code) {
return null;
}
default OAuthAccessToken renewAccessToken(Long userId) {
return null;
}
default Member requestUserInfo(OAuthAccessToken accessToken) {
return null;
}
}
다음은 OAuthService 의 구현체들이 공통적으로 수행해야하는 작업입니다.
OAuthAccessToken requestAccessToken(String code) : AccessToken 을 발급받습니다.
OAuthAccessToken renewAccessToken(Long userId) : AccessToken 을 갱신합니다.
Member requestUserInfo(OAuthAccessToken accessToken) : Resource Server 에서 사용자 정보를 조회합니다.
GitHubAccessToken 발급
GitHubAccessToken gitHubAccessToken = webClient.post()
.uri(accessTokenUri)
.accept(MediaType.APPLICATION_JSON)
.bodyValue(
GitHubAccessTokenRequest.of(
code,
clientId,
clientSecret
)
)
.retrieve()
.bodyToMono(GitHubAccessToken.class)
.blockOptional()
.orElseThrow(GitHubOAuthException::new);
GoogleAccessToken 발급
GoogleAccessToken googleAccessToken = webClient.post()
.uri(accessTokenUri)
.accept(MediaType.APPLICATION_JSON)
.bodyValue(
GoogleAccessTokenRequest.of(
code,
clientId,
clientSecret
)
)
.retrieve()
.bodyToMono(GoogleAccessToken.class)
.blockOptional()
.orElseThrow(GoogleAccessTokenException::new);
각각 Resource Server 에서 응답 받은 결과 값을 인터페이스 스펙에 맞춰 변환해주는 작업이 필요합니다.
@Service("github")
public class GitHubOAuthService implements OAuthService {
@Service("google")
public class GoogleOAuthService implements OAuthService {
private final Map<String, OAuthService> oAuthServices;
@GetMapping("/{resource-server}/callback")
public ResponseEntity<Void> login(@PathVariable("resource-server") String resourceServer,
String code, HttpServletResponse response) {
OAuthService oAuthService = oAuthServices.get(resourceServer);
....
redirect url
인 /{resource-server}/callback 에서 {resource-server} 값과 일치시키면 login 메서드 하나로 모든 OAuth 로그인 처리를 수행할 수 있습니다.jwt (json web token) 는 웹표준 (RFC 7519) 으로 전자 서명된 토큰입니다.
client, server 사이에서 주고 받는 토큰으로 server 에서 서명 된 jwt 생성하여 클라이언트에게 발행하고, client 에서 보관합니다.
client 는 발행 받은 토큰을 Authorization Header 담아 요청이 필요할 때마다 같이 전송합니다.
server 에서는 로그인 여부를 확인하기 위해 헤더에 담긴 jwt 를 검증하는 작업이 필요합니다.
토큰 자체에 사용자의 권한 정보나 서비스를 사용하기 위한 정보가 포함됩니다.
[header].[payload].[signature]
header
{
"alg": 알고리즘 종류
"typ": 토큰의 타입
}
payload
{
"iss": (Issuer): 발급자
"sub": (Subject): 제목
"aud": (Audience): 대상자
"exp": (Expiration time): 만료 시간
"nbf": (Not before): 활성 날짜
"iat": (Issued At): 발급 시간
"jti": (JWT Id): JWT 토큰 식별자 (issuer가 여러 명일 때 이를 구분하기 위한 값)
}
토큰에 담아 보내고자 하는 데이터를 클레임(claim) 이라고 하고, payload 에 key - value 형태로 저장합니다.
jwt 의 표준 스펙상 key 의 이름은 3글자로 되어있습니다.
표준 스펙 외에도 claim 을 정의할 수 있습니다. (단, payload 에 중요한 정보를 담지 않습니다.)
header 와 payload는 json 이 디코딩되어있을 뿐이지 특별한 암호화가 걸려있는 것이 아니기 때문에 누구나 jwt 를 가지고 디코딩을 한다면 header 나 payload 에 담긴 값을 알 수 있기 때문입니다. jwt 에서 header 와 payload 는 특별한 암호화 없이 흔히 사용할 수 있는 base64 인코딩을 사용하기 때문에 서버가 아니더라도 그 값들을 확인할 수 있습니다. 그래서 jwt 는 단순히 "식별을 하기 위한" 정보만을 담아두어야 하는 것입니다.
payload (데이터) 가 많아지면 토큰이 켜져서 요청 시 서버에 부담이 갈 수 있습니다.
토큰이 사용자 정보가 DB 에서 갱신되더라도 해당 정보로 토큰을 재발급하지 않으면 적용되지 않는 문제가 있습니다.
데이터를 자체적으로 가지고 있기 때문에 데이터를 얻기 위한 별도의 작업이 줄어 서버의 부담이 줄어들게 됩니다.
토큰의 만료시간까지는 강제적으로 만료시킬 수 없으므로 탈취 당하면 만료 시까지 별다른 조치를 취할 수 없다는 단점이 존재합니다.
시그니처에는 위의 헤더와 페이로드를 합친 문자열을 서명한 값입니다. 서명은 헤더의 alg 에 정의된 알고리즘과 secret key 를 이용해 생성하고 base64 url-safe 로 인코딩합니다. secret key 를 포함해서 암호화 되어있습니다.
public String issueAccessToken(Member member) {
return JWT.create()
.withAudience(member.getId().toString())
.withIssuer(issuer)
.withIssuedAt(Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()))
.withExpiresAt(Date.from(LocalDateTime.now().plusHours(1L).atZone(ZoneId.systemDefault()).toInstant()))
.withClaim(SOCIAL_ID, member.getSocialId())
.withClaim(RESOURCE_SERVER, member.getResourceServer().name())
.withClaim(TOKEN_TYPE, ACCESS_TOKEN)
.sign(algorithm);
}
public String issueRefreshToken(Member member) {
return JWT.create()
.withAudience(member.getId().toString())
.withIssuer(issuer)
.withIssuedAt(Date.from(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant()))
.withExpiresAt(Date.from(LocalDateTime.now().plusWeeks(2L).atZone(ZoneId.systemDefault()).toInstant()))
.withClaim(SOCIAL_ID, member.getSocialId())
.withClaim(RESOURCE_SERVER, member.getResourceServer().name())
.withClaim(TOKEN_TYPE, REFRESH_TOKEN)
.sign(algorithm);
}
String jwtAccessToken = jwtTokenProvider.issueAccessToken(member);
String jwtRefreshToken = jwtTokenProvider.issueRefreshToken(member);
response.setHeader(ACCESS_TOKEN, jwtAccessToken);
response.setHeader(REFRESH_TOKEN, jwtRefreshToken);
redisService.saveJwtRefreshToken(member.getId().toString(), jwtRefreshToken);
응답결과
HandlerInterceptor 인터페이스를 구현한 JwtInterceptor 을 통해 로그인 여부를 검사합니다.
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
return verifyJwtToken(request, response);
}
private boolean verifyJwtToken(HttpServletRequest request, HttpServletResponse response) {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (!Strings.isNotBlank(authorizationHeader) || !authorizationHeader.startsWith(BEARER)) {
response.setStatus(HttpStatus.FORBIDDEN.value());
return false;
}
try {
String accessToken = authorizationHeader.replaceFirst(BEARER, Strings.EMPTY).trim();
DecodedJWT decodedJWT = jwtTokenProvider.verifyToken(accessToken);
request.setAttribute(USER_ID, jwtTokenProvider.getUserId(decodedJWT));
} catch (TokenExpiredException e) { // 해당 토큰의 기한이 만료되었다면?!
response.setStatus(HttpStatus.UNAUTHORIZED.value()); // UNAUTHORIZED(401) 으로 응답한다.
return false;
} catch (JWTVerificationException ex) { // 유효한 토큰이 아니라면?!
response.setStatus(HttpStatus.FORBIDDEN.value());
return false;
}
return true;
}
@GetMapping("/update/jwt-access-token")
public void updateJwtAccessToken(@AccessTokenHeader String accessToken,
@RefreshTokenHeader String refreshToken,
HttpServletResponse response) {
try {
jwtTokenProvider.verifyToken(accessToken); // Access Token 한 번 더 검증!
} catch (TokenExpiredException e) {
DecodedJWT decodedJWT = jwtTokenProvider.decodeToken(refreshToken);
Long userId = jwtTokenProvider.getUserId(decodedJWT);
validateRefreshToken(refreshToken, redisService.getJwtRefreshToken(userId.toString()));
Member member = memberService.findMember(userId);
response.setHeader(ACCESS_TOKEN, jwtTokenProvider.issueAccessToken(member));
}
}
private void validateRefreshToken(String refreshToken1, String refreshToken2) {
if (refreshToken1.equals(refreshToken2)) {
throw new JwtRefreshTokenException();
}
}
public void saveJwtRefreshToken(String userId, String refreshToken) {
ValueOperations<String, String> values = redisTemplate.opsForValue();
values.set(userId, refreshToken, Duration.ofDays(14));
}
public String getJwtRefreshToken(String userId) {
ValueOperations<String, String> values = redisTemplate.opsForValue();
return values.get(userId);
}
public void removeJwtRefreshToken(String userId) {
redisTemplate.delete(userId);
}
@GetMapping
public ResponseEntity<Void> login(@LoginId Long loginId) {
redisService.removeJwtRefreshToken(loginId.toString());
log.debug("loginId: {}", redisService.getJwtRefreshToken(loginId.toString()));
return ResponseEntity.ok().build();
}
Version 1 - 어플리케이션이 서버 입장에서 jwt_refresh_token 을, 클라이언트 입장에서 oauth_access_token, oauth_refresh_token 을 저장하고 있도록 member 테이블에 칼럼 추가
->
Version 2 - jwt_refresh_token 칼럼을 삭제하고 해당 토큰은 Redis 를 사용하여 보관하도록 변경합니다.
https://aws.amazon.com/ko/elasticache/what-is-redis/
데이터를 디스크 또는 SSD에 저장하는 대부분의 데이터베이스 관리 시스템과는 달리 모든 Redis 는 데이터를 서버의 주 메모리에 저장합니다. 디스크에 엑세스해야 할 필요가 없습니다.
캐싱
Redis를 사용하면 사용자가 다양한 데이터 유형에 매핑되는 키를 저장할 수 있습니다. 기본적인 데이터 유형은 String 이지만, 거의 모든 유형의 데이터가 Redis를 사용하여 인 메모리에 저장될 수 있습니다.
GitHub Resource Server 의 경우, 만료된 Access Token 을 받으면 Refresh Token 을 보내줍니다.
응답 받은 Refresh Token 으로 다시 Resource Server 에 요청하여 Access Token 을 새로 발급 받을 수 있습니다.
Google Resource Server 의 경우, Access Token 의 유효기간이 1시간 입니다.
Access Token 을 발급 받을 때 Refresh Token 도 함께 발급 받습니다. (별도의 요청 파라미터를 설정해야합니다.)
Spring Batch 를 사용하여 주기적으로 Access Token 을 갱신할 수 있습니다.
GitHub Repository: https://github.com/naneun/2-weeks-of-individual-study
Good!