모아모아팀 프로젝트 진행 중에 Access Token 이외에 Refresh Token을 도입하게 되어서 해당 내용을 정리한다.
모아모아팀은 0.3 버전까지 (스프린트3이 끝날 때까지) Access Token만을 사용하여 로그인을 구현하고 있었다.
(Github 로그인을 활용하여 Client에서 로그인 요청시 Github 인증 서버로부터 인증 코드를 받고, 이를 우리쪽 서버로 보내서 코드로 부터 사용자 정보(GithubId, ProfileUrl 등) 를 얻고, 해당 payload로 access token을 만들어 발급하는 방식이다.)
하지만 AccessToken만을 사용하고 있다 보니, 토큰의 만료시간(당시 1시간)이 지나면 로그아웃되어 해당 만료시간을 기준으로 사용자는 계속해서 재로그인을 해야하는 번거로움이 있었다. 또한 현재 로그아웃 기능도 구현되어 있지 않은 상태였다.
(로그아웃 API 구현 X)
우리팀은 사용자의 편의성을 위해 accessToken이 만료되었다는 응답을 client로 보내고, client는 만료되었다는 응답이 왔을 때 Refresh Token을 실은 요청을 보내 accessToken을 재발급받기를 원했다.
또한 새로운 accessToken을 발급 받으면 accessToken을 클라이언트측에 저장하고, 기존에 요청했던 리소스를 다시 fetch하길 원했으며 refreshToken 조차도 만료되면 그 때 저장되어있는 accessToken을 제거하고 로그아웃 하길 원했다.
JWT에서 Refresh Token은 왜 필요할까? 를 보면 우리 모아모아
팀의 요구사항 이외에도 Refresh Token을 써야하는 이유를 알아볼 수 있는데, 간단하게 정리하면 다음과 같다.
Refresh Token을 활용해서 JWT 유출문제를 해결할 수 있다. AccessToken의 유효시간을 짧게 설정하고, Refresh Token의 유효시간은 길게 설정한다. AccessToken의 노출시간을 짧게 함으로써 공격자의 공격가능한 시간을 줄일 수 있다. 하지만 이렇게 되면 짧은 시간만 client가 액세스 토큰을 가지고 인증이 가능하기 때문에 액세스 토큰을 다시 재발급해주어야하는데, 이 때 refresh token을 활용한다.
즉, 사용자는 access와 refresh 토큰을 모두 서버에 전송하여 전자로 인증하고, 만료됐을 시 후자로 새로운 access token을 발급받는다.
이렇게 둘 모두를 확인하여 재발급해주는 과정을 통해서 한 쪽만 공격자에 의해 탈취된 경우 공격자는 새로운(유효한) 액세스 토큰(인증을 위한) 을 발급받을 수 없게 된다.
Is a Refresh Token really necessary when using JWT token authentication? 스택 오버플로우를 참고해보면, Refresh Token 탈취에 대해서 다음과 같은 방법을 제안하고 있다.
물론 우리팀에 적용한 Refresh Token 전략(?) 의 경우 위의 내용과 일부 차이가 있다.
우리팀에서는 Refresh Token을 어떻게 사용하고 적용하였는지 살펴보겠다.
가장 먼저 Refresh Token을 이용한 동작과정을 보면 다음과 같다.
가장 먼저 앞선 스택오버플로우의 글에서 추천했던 대로 단순히 올바른 Refresh Token이 왔을 때 access Token이 발급되지 않도록 하였다. 왜냐하면 Refresh Token이 탈취되었을 때, 공격자는 원하는대로 accessToken을 발급받을 수 있게 되기 때문이다. 하지만 refresh token 과 함께 accessToken을 확인하면 Refresh Token만을 탈취하여서는 공격자가 원하는대로 토큰을 발급받을 수 없게 된다.
하지만 스택오버플로우 글에서 와는 약간 차이를 두었다. DB에 access token 자체와 refresh token 자체를 저장하는 것이 아니라 githubId
와 함께 RefreshToken
을 저장하도록 해주었다.
스택오버플로우와 동일하게 단순 문자열로 구성된 액세스 토큰 자체를 저장하지 않은 이유는 AuthController 측을 보면 알 수 있다.
(해당 AuthController
코드는 모든 리팩토링 및 수정이 이루어진 이후의 코드이다. 예를 들어 초기 구현에서 refreshToken()
메소드를 보면 githubId 를 얻어올 때 어노테이션이 @AuthenticatedRefresh
가 아닌 @AuthenticationPrincipal
이다. 초기 구현 코드를 보려면 다음 PR을 확인하면 좋다. [BE] issue229: Refresh Token 적용하기)
@RestController
@RequiredArgsConstructor
public class AuthController {
private static final String REFRESH_TOKEN = "refreshToken";
private static final int REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60;
private final AuthService authService;
@PostMapping("/api/auth/login")
public ResponseEntity<AccessTokenResponse> login(@RequestParam final String code) {
final TokensResponse tokenResponse = authService.createToken(code);
final AccessTokenResponse response = new AccessTokenResponse(tokenResponse.getAccessToken(), authService.getExpireTime());
final ResponseCookie cookie = putTokenInCookie(tokenResponse);
return ResponseEntity.ok().header("Set-Cookie", cookie.toString()).body(response);
}
@GetMapping("/api/auth/refresh")
public ResponseEntity<AccessTokenResponse> refreshToken(@AuthenticatedRefresh Long githubId, @CookieValue String refreshToken) {
return ResponseEntity.ok().body(authService.refreshToken(githubId, refreshToken));
}
@DeleteMapping("/api/auth/logout")
public ResponseEntity<Void> logout(@AuthenticationPrincipal Long githubId) {
authService.logout(githubId);
return ResponseEntity.noContent().header("Set-Cookie", removeCookie().toString()).build();
}
private ResponseCookie putTokenInCookie(final TokensResponse tokenResponse) {
return ResponseCookie.from(REFRESH_TOKEN, tokenResponse.getRefreshToken())
.maxAge(REFRESH_TOKEN_EXPIRATION)
.path("/")
.sameSite("None")
.secure(true)
.httpOnly(true)
.build();
}
private ResponseCookie removeCookie() {
return ResponseCookie.from(REFRESH_TOKEN, null)
.maxAge(0)
.path("/")
.sameSite("None")
.secure(true)
.httpOnly(true)
.build();
}
}
refreshToken 메소드의 파라미터에 @AuthenticatedRefresh
어노테이션이 붙은 것을 볼 수 있다.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthenticatedRefresh {
}
아래 HandlerMethodArgumentResolver
를 상속받는 AuthenticatedRefreshArgumentResolver
를 빈으로 등록해주었는데,
여기서 supportsParameter는 앞서 등록한 custom 어노테이션인 AuthenticatedRefresh
로 등록해준 것을 확인할 수 있다. 즉, AuthenticatedRefresh
어노테이션이 붙은 메소드로 요청이 오게 되면 아래 resolveArgument
메소드가 동작하여 RefreshToken
과 함께 요청되어 온 AccessToken
이 올바른지를 확인한다.
AuthenticationExtractor.extract()
로 부터 토큰을 얻은 후에 tokenProvider의 getPaylod()
메소드를 호출하여 githubId를 얻고 반환한다.
@Component
@RequiredArgsConstructor
public class AuthenticatedRefreshArgumentResolver implements HandlerMethodArgumentResolver {
private final TokenProvider tokenProvider;
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticatedRefresh.class);
}
@Override
public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
final String token = AuthenticationExtractor.extract(request);
if (token == null) {
throw new UnauthorizedException("인증 타입이 올바르지 않습니다.");
}
return Long.valueOf(tokenProvider.getPayload(token));
}
}
아래는 getPayload 메소드이다.
여기서 현재 액세스 토큰의 유효성을 검증하고 있는데, 이로 인해 에러가 발생하고 해결하였던 부분은 아래 이어서 나올 것이다.(지금와서 보면 액세스 토큰은 만료되었기 때문에 refresh 요청을 보내는 것일 것인데, 여기서 유효성을 검증하는 것이 아이러니 하긴 하다..😅)
@Component
public class JwtTokenProvider implements TokenProvider {
...
@Override
public String getPayload(final String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
...
}
즉, 이렇게 /api/auth/refresh
로 요청이 오면 Service 단으로 refreshToken과 githubId 만을 넘긴다. 그리고 또한 Controller 앞단에서, ArgumentResolver 단에서 ToknProvider
의 getPayload
를 통해 토큰의 유효성을 검사하고 payload를 얻어오기 때문에 토큰 자체를 저장할 필요성이 없었으며, AccessToken의 payload에 애초에 githubId 만을 담고 있기 때문에 DB에 액세스 토큰 그 자체를 저장해둘 필요가 없게 되어서 githubId
와 함께 RefreshToken
을 저장해주었다.
그럼 이제 AuthController
각각의 API인 토큰 발급(login)
, 토큰 재발급(refresh)
, 로그아웃(logout)
순으로 RefreshToken을 어떻게 적용하였는지 살펴보자.
앞서 Controller 를 보아서 알 수 있다시피 AuthController
는 총 3개의 API를 제공한다.
기존에 존재하고 있던 login()
메소드 뿐 아니라 refreshToken()
(다시 생각해보니 메소드를 refresh 혹은 "토큰 재발급" 이라는 네이밍으로 변경하는 것이 적절한 것 같다.😅)과 logout()
이다.
RefreshToken을 도입하면서 가장 먼저 기존 /api/auth/login
과 POST 메소드로 매핑되어 있는 login()
메소드를 수정해주어야 했다.
기존에는 authService.createToken()
에서 TokenResponse, 즉 accessToken
만을 담고 있는 것을 body에 담아 반환해주면 되었다. 그러면 client 측에서 이를 Authorization
헤더에 저장하고, 향후 요청에 포함해서 보내게 된다.
하지만 이제 토큰을 발급할 때, AccessToken 뿐 아니라 RefreshToken도 함께 전달하여야했다. 따라서 위의 코드에서와 같이 AccessToken 뿐 아니라 RefreshToken을 담은 TokensResponse
라고 하는 DTO를 AuthService
로 부터 반환받게 되었다.
그러고 나서는 AccessToken과 RefreshToken을 각각 해당 DTO로 부터 뽑아내어야했다. AccessToken은 body에 담아서 반환하지만, RefreshToken은 쿠키에 담아서 반환해야 하기 때문이다. (쿠키에 담는 이유는 밑에 나온다.)
또한 Front에서 실제 401 에러를 만나기 전에, 즉 AccessToken이 만료되기 이전에 Front단에서 AccessToken의 유효시간보다 조금 적게 시간을 측정하고 refresh 요청을 보내길 원했다. (이에 대해서도 밑에서 이야기하려고 한다.) 따라서 AccessToken과 함께 유효시간을 보내주어야했기 때문에 authService
로 부터 AccessToken
의 ExpireTime
을 받고 이를 DTO로 만들어주는 코드를 확인할 수 있다.
putTokenInCookie
메소드는 RefreshToken을 쿠키에 담는 메소드이다.
정리하면 기존에는 AccessToken 만을 응답 body에 포함해서 응답하면 되었던 것에서 이제는 RefreshToken은 쿠키에 담고, AccessToken과 함께 AccessToken의 유효시간을 응답 body에 포함하여 응답하게 된 것이다.
그럼 이제 AuthService
의 createToken()
쪽 코드를 보자.
@Transactional
public TokensResponse createToken(final String code) {
final String accessToken = oAuthClient.getAccessToken(code);
final GithubProfileResponse githubProfileResponse = oAuthClient.getProfile(accessToken);
memberService.saveOrUpdate(githubProfileResponse.toMember());
final Long githubId = githubProfileResponse.getGithubId();
final Optional<Token> token = tokenRepository.findByGithubId(githubId);
final TokensResponse tokenResponse = tokenProvider.createToken(githubProfileResponse.getGithubId());
if (token.isPresent()) {
token.get().updateRefreshToken(tokenResponse.getRefreshToken());
return tokenResponse;
}
tokenRepository.save(new Token(githubProfileResponse.getGithubId(), tokenResponse.getRefreshToken()));
return tokenResponse;
}
우선 앞쪽 3줄의 코드(saveOrUpdate()
메소드까지)는 매개변수로 받은 code
(Gihub쪽에서 제공하는 인증 코드)로부터 프로필 정보를 가져오는 코드이다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class GithubProfileResponse {
@JsonProperty("id")
private Long githubId;
@JsonProperty("login")
private String username;
@JsonProperty("avatar_url")
private String imageUrl;
@JsonProperty("html_url")
private String profileUrl;
public Member toMember() {
return new Member(githubId, username, imageUrl, profileUrl);
}
}
그리고 이렇게 가져온 GithubProfileResponse
로부터 Member
객체를 만들어 saveOrUpdate()
메소드로 넘겨준다.
해당 메소드는 githubId로 판단하여 우리쪽 DB에 저장된 사용자라고 하면 imageUrl
과 같은 정보를 update 해주고, 만약 우리쪽 서비스에 새로운 사용자라고 한다면, 즉 DB에 저장되어 있지 않은 사용자라고 하면 새롭게 저장하는 코드로써 여기까지는 기존의 로그인 로직과 동일하다.
새롭게 추가된 부분 혹은 새롭게 수정된 부분은 그 아래부분이다.
가장 먼저 앞서 보인 Token 테이블과 매핑되는 Token 도메인을 구현하였고, 해당 도메인(Entity)와 매핑되는 JpaRepository
를 상속받는 TokenRepository
에 의존성을 가진다.
@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
public class Token {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@Column(nullable = false)
private Long githubId;
private String refreshToken;
public Token(final Long githubId, final String refreshToken) {
this(null, githubId, refreshToken);
}
public void updateRefreshToken(final String refreshToken) {
this.refreshToken = refreshToken;
}
}
public interface TokenRepository extends JpaRepository<Token, Long> {
Optional<Token> findByGithubId(Long githubId);
}
Github쪽에서 제공해준 code로 부터 얻은 githubId(이는 유일한 값)로 DB에 저장된 Token이 있는지 식별하여 Optional 하게 가져온다.
그리고 DB에 githubId와 매핑되는 row의 존재유무와 관계없이 TokenProvider
의 createToken
메소드의 인자로 githubId를 넘기면서 토큰을 생성한다.
@Component
public class JwtTokenProvider implements TokenProvider {
...
@Override
public TokensResponse createToken(final Long payload) {
final Date now = new Date();
String accessToken = Jwts.builder()
.setSubject(payload.toString())
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + validityInMilliseconds))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
String refreshToken = Jwts.builder()
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return new TokensResponse(accessToken, refreshToken);
}
...
}
여기서 refreshToken
생성쪽을 보면 accessToken
의 생성 때와 달리 setSubject()
메소드의 호출이 없는 것을 알 수 있다. 즉, refreshToken은 단순히 accessToken의 재발급 용도로 사용하기 위해서 payload에 아무 값도 담지 않은 것을 볼 수 있다.
또 다르게 주목해야할 부분은 key를 accessToken과 동일한 것을 사용하고 있는데, 이는 향후 수정된 코드를 보면 서로 다른 키를 사용해주도록 수정했음을 알 수 있을 것이다. 현재는 키가 탈취되면 refreshToken 쪽이나 accessToken 모두를 복호화할 수 있다. 물론 refreshToken의 payload에 아무 값도 넣지 않고 있지만..서로 다른 키를 사용하여 양쪽 모두 복호화 가능하다는 위험을 줄이는 것이 적절하다고 판단하여 서로 다른 키를 사용하도록 수정해주었다.
다시 이어서 AuthService
의 createToken()
쪽 코드를 보자.
DB에서 조회해온 token이 존재하면 도메인의 refreshToken 값을 업데이트해준 이후에 이를 반환하며 메소드를 종료하고 있으며 (변경 감지에 의해 DB에 update 쿼리가 나가게 되어 DB값 또한 변경한 도메인의 필드 값으로 update 된다.), 만약 조회해온 DB에 값이 없으면 save() 를 호출하여 Token 도메인을 저장해주고 있는 것을 확인할 수 있다.
여기서 분기 이전에 TokensResponse
를 생성한 이유를 알 수 있는데, 이러나 저러나 refreshToken은 새롭게 생성하여 저장하여야하고, createToken()
호출 시에 accessToken과 함께 refreshToken을 생성하므로 분기에 앞서서 생성해주었다.
refresh 요청 처리에 앞서서 고민한 포인트는 다음 두 가지이다.
먼저 첫번째 질문에 대한 답으로 RefreshToken을 활용하게 된 것이고, 두 번째 대답에 대한 답은 이 글의 앞서서 가장 먼저 언급하였으므로 생략하겠다.
먼저 AuthController
쪽 코드를 보면 쿠키에 담긴 RefreshToken과 함께 AccessToken을 받는다고 구현하였고, 실제로 그렇게 하고 있다.
@GetMapping("/api/auth/refresh")
public ResponseEntity<AccessTokenResponse> refreshToken(@AuthenticationPrincipal Long githubId, @CookieValue String refreshToken) {
return ResponseEntity.ok().body(authService.refreshToken(githubId, refreshToken));
}
하지만, 이와 관련해서도 프론트 팀원과의 의견 차이가 존재하였다. 프론트에서는 쿠키에 RefreshToken만을 담아서 요청을 보내고 있었다. 나의 생각과는 달랐던 것이다. 구현을 하면서 꾸준하게 어떻게 구현하고 있다라고 하는 이야기를 서로 하지 않았기 때문에 발생한 일이라고 생각한다. 구현 이전에 서로 어떻게 할까? 라고 이야기만 하였다.
그리고 나는 access Token이 만료되었다는 응답이 왔을 때 Refresh Token과 함께 access token을 발급 요청한다.
라고 하는 요구사항에 맞게끔 구현을 하였다. 하지만 프론트에서는 굳이 AccessToken을 보낼 필요가 없다고 생각하여 이를 함께 보내지 않도록 구현하게 되었고, 이 과정에서 소통의 부재가 일어났던 것이다.
실제로 프론트 팀원의 의견은 RefreshToken만 보내면 되지 않냐는 것이었다.
하지만 나는 DB 테이블 구조를 보아도 알 수 있다시피 AccessToken을 함께 받고, 이를 통해서 githubId를 얻은 뒤 요청에 함께 온 RefreshToken이 정말 그 사용자가 맞는지, 즉 정말 그 githubId 소유자의 RefreshToken이 맞는지 검증하는 로직이 추가적으로 필요하다고 생각하였다.
결국, Front쪽을 설득하여 현재와 같은 구조를 이루기는 했지만, 이 과정에서 구현 중에도 계속해서 소통이 필요하다라는 것을 깨달을 수 있는 좋은 경험이었다.
다시 돌아와서 코드를 보면 AuthService
의 refreshToken()
메소드를 호출하고 반환값을 응답 body에 포함하면 Controller의 책임은 끝이다.
AuthService
의 refreshToken()
메소드는 다음과 같다.
public AccessTokenResponse refreshToken(final Long githubId, final String refreshToken) {
final Token token = tokenRepository.findByGithubId(githubId)
.orElseThrow(TokenNotFoundException::new);
if (!token.getRefreshToken().equals(refreshToken)) {
throw new UnauthorizedException("유효하지 않은 토큰입니다.");
}
String accessToken = tokenProvider.recreationAccessToken(githubId, refreshToken);
return new AccessTokenResponse(accessToken, tokenProvider.getValidityInMilliseconds());
}
우선 refresh 요청을 보낸다? 이는 곧 DB Token 테이블에 githubId와 매핑되는 row 가 무조건 존재한다는 것을 이야기한다. 만약 존재하지 않는다면 이는 예외이다.
따라서 첫 줄을 위와 같이 구현해주었다.
그리고 나서 if문은 조회해온 Token으로 부터 RefreshToken을 가져왔을 때 이것이 요청으로 넘어온 RefresToken과 동일하지 않다면 이는 유효하지 않은 RefreshToken으로 요청이 온 것이므로 이 또한 예외이므로 이를 검사해주는 것이다.
만약 앞선 조건들을 모두 통과하였다면 이는 정상적인 refresh 요청이므로 TokenProvider
의 recreationAccessToken()
메소드를 호출하여 새로운 accessToken을 발급받고 이와 함께 accessToken의 유효시간을 담아 반환하여주면 된다.
그럼 AccessToken의 재발급 부분 코드를 보자.
@Override
public String recreationAccessToken(final Long githubId, final String refreshToken) {
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(refreshToken);
Date tokenExpirationDate = claims.getBody().getExpiration();
validateTokenExpiration(tokenExpirationDate);
return createAccessToken(githubId);
}
private void validateTokenExpiration(Date tokenExpirationDate) {
if (tokenExpirationDate.before(new Date())) {
throw new RefreshTokenExpirationException();
}
}
private String createAccessToken(final Long githubId) {
final Date now = new Date();
return Jwts.builder()
.setSubject(Long.toString(githubId))
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + validityInMilliseconds))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
간단하다. RefresToken 의 유효시간을 확인해주고 AccessToken을 만들어 반환하면 된다. RefershToken을 만들었을 때와 동일한 key를 통해서 parseClaimsJws()
메소드를 통해서 리프래쉬 토큰을 Jws로 파싱한다.
그리고 파싱한 Jws 로 부터 Expiration 을 통해서 토큰의 유효시간을 얻고, 이것이 현재 시간 이후이면 된다. 만약 이전이라고 한다면(즉 유효시간을 넘겼다면) 리프래쉬 토큰의 유효시간이 만료되었다는 예외를 던지게 된다.
validateTokenExpiration()
메소드를 무사히 통과하였다고 하면 RefreshToken의 유효시간이 지나지 않았다는 것이고, AccessToken을 생성하여 주면 된다.
마지막으로 로그아웃
기능을 구현해주었다.
@DeleteMapping("/api/auth/logout")
public ResponseEntity<Void> logout(@AuthenticationPrincipal Long githubId) {
authService.logout(githubId);
return ResponseEntity.noContent().header("Set-Cookie", removeCookie().toString()).build();
}
private ResponseCookie removeCookie() {
return ResponseCookie.from(REFRESH_TOKEN, null)
.maxAge(0)
.path("/")
.secure(true)
.httpOnly(true)
.build();
}
이 때는 AccessToken만을 필요로 한다. AccessToken으로 부터 ArgumentResolver를 통해서 githubId
를 얻어오고 logout()
메소드를 호출해주고, removeCookie() 메소드를 통해서 maxAge
를 0으로 해주어 쿠키를 제거해준다. 그리고 logout() 메소드에서는 DB에 저장된 Token 테이블에서 1대1 매핑되는 row를 찾아 삭제해준다.
여기서 maxAge를 0으로한 쿠키를 생성해주는 이유는 쿠키를 직접적으로 제거할 수 없기 때문이다. (쿠키의 관리는 웹 클라이언트가 하기 때문에 삭제할 수 있는 명령이 없다.) 따라서 쿠키의 유지시간을 0으로 해주어 REFERSH_TOKEN
의 쿠키를 제거해주는 방향으로 구현해주었다.
@Transactional
public void logout(final Long githubId) {
final Token token = tokenRepository.findByGithubId(githubId)
.orElseThrow(TokenNotFoundException::new);
tokenRepository.delete(token);
}
refresh token 도입기 글을 참고하여 프론트 팀원과 논의하여 우리팀에서는 Refresh Token을 DB에 저장하고, 쿠키로 실어나르기로 결정하였다.
해당글을 참고하면 크게 2가지가 가능하다. 우리팀과 같은 방법 하나와 refresh token을 프론트 서버의 session에 저장하는 것이다.
하지만 결정적으로 사용자가 많아질 때 session에 대한 관리가 힘들어지고, 결정적으로 프론트에서 session까지 관리하기에 유지보수 비용이 많이 든다는 것이었다.
따라서 우리는 refresh token을 백엔드에 저장하고 클라이언트는 쿠키에 실린 refresh token을 보내 확인하는 방식으로 구현을 하였다.
그리고 또한 쿠키에 담아서 Refresh Token 을 실어나르는 이유가 있다. 앞서 쿠키를 생성하는 컨트롤러 쪽 코드를 보자.
가장 먼저 secure(true)
로 설정해주었다. 이는 SameSite 속성을 None으로 설정해주었기 때문에 추가한 속성인데, 추가된 쿠키는 HTTPS 프로토콜에서만 전송이 가능하도록 한 것이며 SameSite 가 None 인 이유는 서로 다른 도메인간의 쿠키 전송에 대한 보안
을 None 으로 설정해주는 것이다. 즉, 동일 사이트 혹은 크로스 사이트 모두에서 쿠키 전송이 가능하다. 단, HTTPS 위에서. 이외에도 여러 설정이 가능한데, Strict
로 설정할 경우 서로 다른 도메인에서는 전송이 불가능해지게 되고, 이 외에도 Lax 옵션이 존재한다.
다음으로는 주목할만한 설정인 httpOnly(true)
이다.
HTTP Only와 Secure Cookie 를 참고하였는데, httpOnly를 하면 CSS(Cross Site Scripting)
공격을 막을 수 있다. 기본적으로 쿠키는 JS 로 접근이 가능하다. 따라서 해커들은 JS로 쿠키를 가로채고자 시도할 수 있는데, 이를 통한 가장 대표적인 공격이 CSS이다. 하지만, 이러한 취약점을 해결하는 하나의 방법이 바로 브라우저에서 쿠키에 접근할 수 없도록 제한하는 것이고, 이 방법이 바로 HTTP Only Cookie
이다.
이렇게 되면 클라이언트 측에서 자바스크립트를 통해서 쿠키를 탈취하는 문제를 막을 수 있게 되지만, 여전히 네트워크 에서 직접 해당 패킷에 접근하여 쿠키를 가로채는 것은 막을 수 없다. 하지만 우리 모아모아 서비스의 경우 HTTPS 프로토콜을 사용하여 데이터가 암호화되어 전송되기 때문에 쿠키 또한 암호화되어 전송되므로 제3자는 중간의 패킷을 가로챈다고 하더라도 내용을 알 수가 없게 된다. 그리고 우리는 secure(true)
를 통해서 HTTPS 가 아닌 통신에서는 쿠키를 전송하지 않도록 설정도 해주었다.
이렇게 httpOnly와 secure 를 설정한 쿠키를 통해서 refreshToken을 주고 받는 이유는 accessToken을 재발급 받을 수 있는 수단인 RefreshToken을 보다 안전하게 관리해야한다고 생각했기 때문이다. (물론 현재 구조에서는 refresh 요청시 access와 refresh 두 토큰 모두 필요하므로 refreshToken만 탈취해서는 accessToken을 발급받을 수 없게 시스템을 구성하였다. 하지만, 프론트 팀원이 이야기했던 것과 같이 refreshToken만을 이용해서 refresh 요청을 받고 액세스 토큰을 재발급해준다면 refreshToken의 관리는 더욱 중요해지게 된다.)
이렇게 구현을 마치고 나서 프론트측도 dev 서버에 배포하고 백엔드도 dev 서버에 배포하여 QA를 진행해보았다.
그런데, 생각보다 잘 동작하지 않았고 총 4번의 hotfix가 이루어졌다.
앞서 계속해서 언급한 것과 같이 프론트와의 소통 부재로 인해서 AccessToken 을 함께 보내지 않는 문제가 있었고, 이 부분은 Front 팀원을 설득해서 AccessToken을 함께 보내 쿠키로 담겨온 RefreshToken이 정말 그 사용자가 발급받았던 것이 맞는지 함께 확인한 이후에 refresh를 수행하도록 수정해주었다.
첫번째 수정 (PR 넘버 250) 은 쿠키에 SameSite None
을 추가해주는 수정이었다. 그리고 이렇게 SameSite 에 None 을 추가해준 이유는 서로 다른 도메인 간의 쿠키 전송에 대한 보안
을 None으로 설정해주기 위함임을 앞서 설정하였으므로 추가적인 설명을 생략하겠다.
다음으로 발생한 이슈는 조금 심각한 이슈였다.
PR 넘버 257 를 통해서 확인할 수 있는데, 로그인 이후에 다음과 같이 아무런 쿠키도 저장되지 않는 이슈를 발견하였기 때문이다.
그래서 우선 WebConfig
코드에서 addCorsMappings
에 exposedHeaders("Set-Cookie")
를 추가하여 다음과 같이 수정해주었다.
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns(allowedOrigins)
.allowedMethods(ALLOW_METHODS)
.exposedHeaders(HttpHeaders.LOCATION)
.exposedHeaders("Set-Cookie")
.allowCredentials(true);
}
exposedHeaders
에 반환하고 싶은 헤더값을 추가시켜 주는 메소드이고, Spring boot CORS Response Header값이 넘어오지 않는 이슈 글을 참고하여 수정해준 것이었다. 하지만 해당 부분을 수정했음에도 문제는 지속되었던 것을 보면 여기서 문제가 발생하지는 않았던 것 같다.
사실 나중에 알게 된 사실인데, 쿠키는 잘 담기고 있었다. (위의 exposedHeaders 를 추가한 이후에 확인한 것이라 해당 코드가 문제였는지는 파악하지 못하였다 ㅠ.ㅠ)
우리 시스템 구조를 보면 다음과 같다.
따라서 사용자가 접근하고 있는 dev.moamoa.space
에는 쿠키가 담기지 않은 것으로 보이지만, 실제로 요청을 보내는 NGINX 쪽에는 쿠키가 담겨있는 것이다.
즉, client - front - reverse froxy - back
라는 구조에서 볼 때 front 와 reverse froxy 사이인 실제 API를 호출하는 앞단의 NGINX 에 쿠키가 저장되는 것이다.
이를 모르고, 쿠키가 계속해서 저장되지 않아, 위의 그림과 같이 원래 BE 쪽 도메인인 moamoa.ne.kr
를 버리고 프론트와 동일하게 moamoa.space
로 변경해보기도 하고, 쿠키를 생성하는 부분을 계속해서 수정해보기 하며 삽질을 많이 했던 것이 기억난다. 거의 구글링해서 찾을 수 있는 문서들은 모두 찾아보기도 하였으며 [cookie] SameSite 설정 등의 글을 찾아보며 구글 정책 변경으로 인한 문제인가 등 여러 고민을 해보았던 것이 기억난다.
다음으로 있었던 이슈는 Interceptor에서 access token의 만료시간을 refresh
요청 때에도 검사하는 어처구니 없게 구현을 했던 이슈였고, 관련한 수정 사항은 [BE] issue: 266: Interceptor refresh 요청 제외 에서 확인할 수 있다.
사실 지금와서 돌이켜 생각해보면 정말 어처구니가 없는 오류였던 것 같다...당연히 AccessToken이 만료되어서 refresh 요청을 하는 것인데, refresh요청시 함께 보내는 accessToken의 만료시간을 검사한다는 것이 논리적으로 말이 안되기 때문이다.
사실 Interceptor 쪽을 고려하지 못해서 발생한 문제였다. Interceptor 쪽의 preHandle()
메소드에서 인증이 필요한 요청은 validateToken()
메소드를 호출하여 토큰의 유효성을 검증하고, 요청의 Attribute 로 token을 넣어주고 있었다. 그리고 validateToken()
메소드는 다음과 같이 구성되어 있었다.
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) {
if (isPreflight(request)) {
return true;
}
if (authenticationRequestMatcher.isRequiredAuth(request)) {
final String token = AuthenticationExtractor.extract(request);
validateToken(token, request.getRequestURI());
request.setAttribute("payload", tokenProvider.getPayload(token));
}
return true;
}
private void validateToken(String token) {
if (token == null || !tokenProvider.validateToken(token)) {
throw new UnauthorizedException("유효하지 않은 토큰입니다.");
}
}
즉, refresh 요청 또한 TokenProvider
클래스의 validateToken()
메소드를 호출하고 있는 것이었다.
따라서 위의 코드를 다음과 같이 수정해주었다.
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) {
if (isPreflight(request)) {
return true;
}
if (authenticationRequestMatcher.isRequiredAuth(request)) {
final String token = AuthenticationExtractor.extract(request);
validateToken(token, request.getRequestURI());
request.setAttribute("payload", token);
}
return true;
}
private void validateToken(String token, String requestURI) {
if (requestURI.equals("/api/auth/refresh") && token != null) {
return;
}
if (token == null || !tokenProvider.validateToken(token)) {
throw new UnauthorizedException("유효하지 않은 토큰입니다.");
}
}
눈에 띄는 변화점은 parameter 이다. 이전에 토큰만 넘겨주고 있었는데, 이제는 refresh 요청인지 확인하기 위해서 requestURI 를 함께 보낸다. 그리고 만약 요청으로 온 URI가 /api/auth/refresh
즉 refresh 요청이고, 토큰이 null이 아니면 유효성 검사없이 validateToken()
을 통과하고 바로 request의 Attribute로 토큰을 추가해준다.
그리고 추가적으로 refresh 요청을 받는 Controller에도 수정이 필요해졌다.
@GetMapping("/api/auth/refresh")
public ResponseEntity<AccessTokenResponse> refreshToken(@AuthenticatedRefresh Long githubId, @CookieValue String refreshToken) {
return ResponseEntity.ok().body(authService.refreshToken(githubId, refreshToken));
}
기존의 AuthenticationArgumentResolver
는 토큰으로부터 githubId를 가져올 때 TokenProvider의 getPayload 메소드를 호출한다.
@Component
@RequiredArgsConstructor
public class AuthenticationArgumentResolver implements HandlerMethodArgumentResolver {
private final TokenProvider tokenProvider;
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class);
}
@Override
public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
final String token = AuthenticationExtractor.extract(request);
if (token == null) {
throw new UnauthorizedException("인증 타입이 올바르지 않습니다.");
}
return Long.valueOf(tokenProvider.getPayload(token));
}
}
그런데, getPayload()
메소드 내부를 보면 다음과 같다.
@Override
public String getPayload(final String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
여기서 주목해주어야 하는 부분은 바로 parseClaimsJws()
메소드이다.
parseClaimJws()
메소드가 던지는 예외에 ExpiredJwtException
이 포함되어 있다. 즉, Jws 로 파싱하려고 해당 메소드를 호출할 때 알아서 Jwt의 유효시간을 함께 확인하고 만약 만료된 Jwt 토큰이면 해당 예외가 던져지는 것이다. 실제로 설명은 다음과 같이 명세되어 있다. if the specified JWT is a Claims JWT and the Claims has an expiration time before the time this method is invoked.
따라서 TokenProvider
클래스의 getPayloadWithExpiredToken
메소드를 추가해주고 다음과 같이 구현하였다.
@Override
public String getPayloadWithExpiredToken(final String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
} catch (ExpiredJwtException e) {
return e.getClaims().getSubject();
}
}
ExpiredJwtExceptino
이 발생하면 이를 무시하고 payload 를 가져오겠다는 것이다. 그리고 refresh 요청시에만 사용할 용도로 AuthenticatedRefreshArgumentResolver
를 추가로 구현해주고, 해당 클래스의 resolveArgument()
에서는 getPayload()
가 아닌 앞서 추가적으로 구현한 getPayloadWithExpiredToken()
을 통해서 githubId를 가져오도록 구현해주었다.
@Component
@RequiredArgsConstructor
public class AuthenticatedRefreshArgumentResolver implements HandlerMethodArgumentResolver {
private final TokenProvider tokenProvider;
@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticatedRefresh.class);
}
@Override
public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
final String token = AuthenticationExtractor.extract(request);
if (token == null) {
throw new UnauthorizedException("인증 타입이 올바르지 않습니다.");
}
return Long.valueOf(tokenProvider.getPayloadWithExpiredToken(token));
}
}
이로써 앞서 발생하였던, refresh 요청시 accessToken이 만료되어 발생하는 문제를 해결해주었다.
이번에 refreshToken을 도입하면서 프론트 팀원들과 고민에 빠졌다. 기존에는 액세스 토큰을 세션 스토리지에 저장하고 있었는데, 이번에 refresh token 을 도입하면서 메모리에 저장(자바스크립트 변수에 저장한다고 하는데, 정확한 방법은 잘 모르겠다..😅)하고 싶다는 것이었다.
프론트 팀원이 참고한 자료를 공유해주었었는데, 슬렉 메시지가 증발하는 바람에 자료를 첨부하지 못하겠지만..메모리에 저장하는 이유는 다음과 같았다.
세션 스토리지에 저장할 때는 이를 조작할 수 있다고 한다. 하지만 메모리에 액세스 토큰을 저장하게 되면 접근이 불가능하게 되므로 좀 더 안정적이고, 보안적으로 좋아진다는 것이었다.
하지만 백엔드, 즉 서버를 개발하는 입장에서는 반갑지 않은 이야기였다.
메모리에 액세스 토큰을 저장하게 되면 새로고침시 액세스 토큰을 잃어버리게 된다. 즉, refresh 요청이 필요하게 된다. 그리고 이렇게 되면 앞서 언급한 것과 같이 단지 RefreshToken만을 가지고 새로운 토큰을 발급해주어야 한다. (access 와 refresh 모두를 이용하여 검증하는 것이 불가능해진다. 왜냐하면 access 토큰이 없기 때문이다.)
그리고 이렇게 되면 새로고침 시마다 새롭게 API 요청을 서버로 보내게 된다. 즉 어떻게 보면 불필요한 트래픽이 증가하게 되는 것이다. 그리고 이것이 적절하다는 생각이 들지 않았다. 새로고침시마다 액세스 토큰을 재발급 받는게 과연 우리가 RefreshToken을 도입하는 동기와 일치하는가? 프론트팀의 입장도 이해는 가지만 왜 이렇게 해야하는가?
에 대해서는 전혀 납득이 가지 않았다. 실제로 세션 스토리지에 저장하면 해당 컴퓨터를 다른 사람이 사용하게 되면 조작하는 등 문제가 발생할 수 있다고 한다. 즉 PC방과 같이 여러 사람들이 공용으로 PC를 사용할 때 문제가 된다는 것이다. 하지만 이는 클라이언트측의 잘못아닐까? 로그아웃을 제대로 해야하는 것이 아닐까? 하는 생각이 들었다.
따라서 나는 세션 스토리지에 기존과 같이 저장해줄 것을 요구하였다. 하지만 설득이 쉽지만은 않았다. 보안적으로 조금 더 치밀한 방법으로 구현해보고 싶다는 프론트 크루들의 마음이 굳건했기 때문이다. 결국, 좀처럼 의견이 좁혀지지 않아 현재 우아한형제들에서 약 5년간 근무중인 사촌형에게 도움을 구하였다.
우리팀의 상황을 자초지종 설명하였다. 요약하면 액세스 토큰을 메모리에 저장할 것이냐, 세션 스토리지에 저장할 것이냐 였다. 형은 오히려 RefreshToken을 사용하는 이유를 되물었다.
나는 RefreshToken이 없으면 우리서비스에서는 현재 1시간 마다 계속해서 로그인을 해주어야하기 때문이라고 답하였고, 거기에 답이 있었다. 우리가 RefreshToken을 도입하는 것은 사용자의 빈번한 로그인으로 인한 불편함을 개선시켜주기 위함이다. 그런데 새로고침시에 refresh 하여 새로운 액세스 토큰을 받는다? 이것은 우리가 처음 RefreshToken을 도입하려는 취지와는 어울리지 않는다.
하지만 그렇다고 세션 스토리지에 저장하는 것은 프론트 팀원들의 요구를 충족시켜주지 못한다. 여기서 다시 질문을 받았다. 액세스 토큰도 Refresh 처럼 쿠키에 저장하면 안되나?? 머리를 한 대 맞은 느낌이었다. 왜 이런 생각을 못했을까? A(메모리) 아니면 B(세션 스토리지) 라는 틀 안에만 갇혀서 생각하고 고민하고 있던 것이다. 액세스 토큰도 리프래시 토큰 처럼 충분히 쿠키에 담으면 프론트 팀원들의 요구도 충족시키면서 우리가 원하는대로 액세스 토큰이 만료되었을 때 리프래시토큰을 이용하여 액세스 토큰을 재발급해 사용자로하여금 빈번한 로그인을 개선한다는 요구도 충족되게 되는 것이다.
새로운 가르침이었다. 이전 장바구니 때에도 그렇고, 이번 모아모아팀에서도 그렇고 팀원들 사이에 두 가지 선택사항에서 의견이 좁혀지지 않으면 "제3의 방안을 고려하자." 라고 항상 말하였었다. 하지만 좀처럼 지켜지지 않았다. 한 번도 제3안 방안을 생각하고 이를 시도해본 적이 없었다. 이번 RefreshToken을 도입해보면서 항상 말로만 지켜왔던 제3의 방안을 고려해볼 수 있게 된 것이다.
앞서 형이 해준 조언대로 액세스 토큰을 쿠키에 담는 방향으로 수정한 코드는 아래와 같다.
@RestController
@RequiredArgsConstructor
public class AuthController {
private static final String REFRESH_TOKEN = "refreshToken";
private static final String ACCESS_TOKEN = "accessToken";
private final AuthService authService;
@PostMapping("/api/auth/login")
public ResponseEntity<AccessTokenResponse> login(@RequestParam final String code) {
final TokensResponse tokenResponse = authService.createToken(code);
final ResponseCookie accessCookie = putInCookie(ACCESS_TOKEN, tokenResponse.getAccessToken(),
tokenResponse.getAccessExpireLength());
final ResponseCookie refreshCookie = putInCookie(REFRESH_TOKEN, tokenResponse.getRefreshToken(),
tokenResponse.getRefreshExpireLength());
return ResponseEntity.ok()
.header("Set-Cookie", accessCookie.toString(), refreshCookie.toString())
.build();
}
@GetMapping("/api/auth/refresh")
public ResponseEntity<AccessTokenResponse> refreshToken(@AuthenticatedRefresh Long githubId,
@CookieValue String refreshToken) {
final AccessTokenResponse response = authService.refreshToken(githubId, refreshToken);
final ResponseCookie accessCookie = putInCookie(ACCESS_TOKEN, response.getAccessToken(),
response.getExpiredTime());
return ResponseEntity.ok().header("Set-Cookie", accessCookie.toString()).build();
}
@DeleteMapping("/api/auth/logout")
public ResponseEntity<Void> logout(@AuthenticationPrincipal Long githubId) {
authService.logout(githubId);
return ResponseEntity.noContent()
.header("Set-Cookie", removeCookie(REFRESH_TOKEN).toString(), removeCookie(ACCESS_TOKEN).toString())
.build();
}
private ResponseCookie putInCookie(final String cookieName, final String cookieValue, final long cookieAge) {
return ResponseCookie.from(cookieName, cookieValue)
.maxAge(cookieAge)
.path("/")
.sameSite("None")
.secure(true)
.httpOnly(true)
.build();
}
private ResponseCookie removeCookie(final String cookieName) {
return ResponseCookie.from(cookieName, null)
.maxAge(0)
.path("/")
.sameSite("None")
.secure(true)
.httpOnly(true)
.build();
}
}
@Component
public class JwtTokenProvider implements TokenProvider {
private final SecretKey accessKey;
private final SecretKey refreshKey;
private final long accessExpireLength;
private final long refreshExpireLength;
public JwtTokenProvider(
@Value("${security.jwt.token.access-secret-key}") final String accessSecretKey,
@Value("${security.jwt.token.access-expire-length}") final long accessExpireLength,
@Value("${security.jwt.token.refresh-secret-key}") final String refreshSecretKey,
@Value("${security.jwt.token.refresh-expire-length}") final long refreshExpireLength
) {
this.accessKey = Keys.hmacShaKeyFor(accessSecretKey.getBytes(UTF_8));
this.refreshKey = Keys.hmacShaKeyFor(refreshSecretKey.getBytes(UTF_8));
this.accessExpireLength = accessExpireLength;
this.refreshExpireLength = refreshExpireLength;
}
@Override
public TokensResponse createToken(final Long payload) {
final Date now = new Date();
String accessToken = Jwts.builder()
.setSubject(payload.toString())
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + accessExpireLength))
.signWith(accessKey, HS256)
.compact();
String refreshToken = Jwts.builder()
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + refreshExpireLength))
.signWith(refreshKey, HS256)
.compact();
return new TokensResponse(accessToken, refreshToken, accessExpireLength, refreshExpireLength);
}
@Override
public String getPayload(final String token) {
return Jwts.parserBuilder()
.setSigningKey(accessKey)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
@Override
public String getPayloadWithExpiredToken(final String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(accessKey)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
} catch (ExpiredJwtException e) {
return e.getClaims().getSubject();
}
}
@Override
public boolean validateToken(final String token) {
try {
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(accessKey)
.build()
.parseClaimsJws(token);
Date tokenExpirationDate = claims.getBody().getExpiration();
validateTokenExpiration(tokenExpirationDate);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
@Override
public AccessTokenResponse recreationAccessToken(final Long githubId, final String refreshToken) {
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(refreshKey)
.build()
.parseClaimsJws(refreshToken);
Date tokenExpirationDate = claims.getBody().getExpiration();
validateTokenExpiration(tokenExpirationDate);
return new AccessTokenResponse(createAccessToken(githubId), accessExpireLength);
}
private void validateTokenExpiration(Date tokenExpirationDate) {
if (tokenExpirationDate.before(new Date())) {
throw new RefreshTokenExpirationException();
}
}
private String createAccessToken(final Long githubId) {
final Date now = new Date();
return Jwts.builder()
.setSubject(Long.toString(githubId))
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + accessExpireLength))
.signWith(accessKey, HS256)
.compact();
}
}
@NoArgsConstructor(access = PRIVATE)
public class AuthenticationExtractor {
private static final String ACCESS_TOKEN_TYPE = AuthenticationExtractor.class.getSimpleName() + ".ACCESS_TOKEN_TYPE";
public static String extract(HttpServletRequest request) {
Enumeration<String> headers = request.getHeaders(COOKIE);
while (headers.hasMoreElements()) {
String value = headers.nextElement();
final String accessToken = Arrays.stream(value.split(" "))
.filter(str -> str.startsWith("accessToken"))
.findAny().orElseThrow();
final String[] splitAccessToken = accessToken.split("=");
if (splitAccessToken.length < 2) {
return null;
}
if (splitAccessToken[0].equals("accessToken")) {
String authHeaderValue = splitAccessToken[1];
request.setAttribute(ACCESS_TOKEN_TYPE, splitAccessToken[1]);
int commaIndex = authHeaderValue.indexOf(',');
if (commaIndex > 0) {
authHeaderValue = authHeaderValue.substring(0, commaIndex);
}
return authHeaderValue;
}
}
return null;
}
}
진짜 짱이네요