[Spring Boot] 프로젝트 템플릿 만들기(5) - 인증
프로젝트를 수행할 때 마다 로그인 기능은 매번 필수적으로 들어가는 기능이였다.
로그인 방식이 별도의 요청에 의해 변경되거나 oauth로 변경되지 않는 한, 인증 방식은 동일할 것으로 예상되었기 때문에 인증 과정도 템플릿에 포함하게 되었다.
쿠키 + Jwt 방식으로 인증을 구현했으며, 인증의 주체는 Spring Security 이다.
인증에 사용한 스택은 아래와 같다.
- Spring Security 6.22
- Jwt (Json Web Token)
토큰 검증 과정
인증은 클라이언트가 서버로 요청을 보낼 시, Spring Security가 HTTP Header에 있는 Cookie를 가져와 토큰 검증을 수행한다. 만약 유효한 인증토큰이 아니라면 Controller로 요청이 전달되지 않고, 예외가 발생하여 클라이언트에게 반환된다.
JwtAuthFilter.class
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Cookie[] cookies = request.getCookies();
String accessToken = null;
String refreshToken = null;
if (cookies != null) {
// 1. Bearer 제거한 토큰 값 가져오기
for (Cookie cookie : cookies) {
if (cookie == null) {
continue;
}
if (cookie.getName().equals("Authorization")) {
accessToken = jwtTokenProvider.resolveToken(cookie);
}
if (cookie.getName().equals("Refresh")) {
refreshToken = jwtTokenProvider.resolveToken(cookie);
}
}
}
// 토큰이 유효하면 인증객체 생성 후 SecurityContext -> SecurityContextHolder 에 저장
if (accessToken != null) {
if (jwtTokenProvider.validateToken(accessToken) == TokenState.VALID) {
setAuthentication(jwtTokenProvider.getUserInfoFromToken(accessToken).getSubject());
} else if (jwtTokenProvider.validateToken(accessToken) == TokenState.EXPIRED) {
// Access Token 만료 시,
// Access Token Cookie 삭제
ResponseCookie responseCookie = ResponseCookie.from(Constants.AUTHORIZATION_HEADER, null).
path("/").
// httpOnly(true).
sameSite("None").
secure(true).
maxAge(1).
build();
response.addHeader("Set-Cookie", responseCookie.toString());
throw new RuntimeException("만료된 JWT token 입니다.");
}
} else if (refreshToken != null) {
if (jwtTokenProvider.validateRefreshToken(refreshToken)) {
setAuthentication(jwtTokenProvider.getUserInfoFromToken(refreshToken).getSubject());
}
}
filterChain.doFilter(request, response);
}
public void setAuthentication(String memberId){
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = jwtTokenProvider.createAuthentication(memberId);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
1. 쿠키에서 토큰 값 가져오기
Cookie[] cookies = request.getCookies();
String accessToken = null;
String refreshToken = null;
if (cookies != null) {
// 1. Bearer 제거한 토큰 값 가져오기
for (Cookie cookie : cookies) {
if (cookie == null) {
continue;
}
if (cookie.getName().equals("Authorization")) {
accessToken = jwtTokenProvider.resolveToken(cookie);
}
if (cookie.getName().equals("Refresh")) {
refreshToken = jwtTokenProvider.resolveToken(cookie);
}
}
}
JwtTokenProvider.resolveToken()
httpServletRequest 내 쿠키를 조회하여 Bearer 를 제외한 토큰 값을 가져온다.
public String resolveToken(Cookie cookie) throws UnsupportedEncodingException {
String bearerToken = URLDecoder.decode(cookie.getValue(), "UTF-8");
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(Constants.BEARER_PREFIX)){
return bearerToken.substring(7);
}else{
Log.varLog("jwt", bearerToken);
throw new CustomException(ExceptionStatus.TokenUnsupportedJwtException);
}
}
2. 토큰 유효성 검증
// 토큰이 유효하면 인증객체 생성 후 SecurityContext -> SecurityContextHolder 에 저장
if (accessToken != null) {
if (jwtTokenProvider.validateToken(accessToken) == TokenState.VALID) {
setAuthentication(jwtTokenProvider.getUserInfoFromToken(accessToken).getSubject());
} else if (jwtTokenProvider.validateToken(accessToken) == TokenState.EXPIRED) {
// Access Token 만료 시,
// Access Token Cookie 삭제
ResponseCookie responseCookie = ResponseCookie.from(Constants.AUTHORIZATION_HEADER, null).
path("/").
// httpOnly(true).
sameSite("None").
secure(true).
maxAge(1).
build();
response.addHeader("Set-Cookie", responseCookie.toString());
throw new RuntimeException("만료된 JWT token 입니다.");
}
} else if (refreshToken != null) {
if (jwtTokenProvider.validateRefreshToken(refreshToken)) {
setAuthentication(jwtTokenProvider.getUserInfoFromToken(refreshToken).getSubject());
}
}
JwtTokenProvider .validateToken()
가져온 토큰 값의 유효성을 검증한다.
// 토큰을 해제하여 검증한다. 파싱된 정보는 Claims에 저장된다.
public TokenState validateToken(String accessToken){
try{
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken);
return TokenState.VALID;
} catch (SecurityException | MalformedJwtException e) {
throw new MalformedJwtException(ExceptionStatus.TokenSecurityExceptionOrMalformedJwtException.getErrorMessage());
} catch (ExpiredJwtException e) {
return TokenState.EXPIRED;
} catch (UnsupportedJwtException e) {
throw new UnsupportedJwtException(ExceptionStatus.TokenUnsupportedJwtException.getErrorMessage());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(ExceptionStatus.TokenIllegalArgumentException.getErrorMessage());
}
}
서버에 존재하는 Secret Key로 토큰을 뜯는다. 해제된 토큰의 정보는 Claims 객체에 저장된다.
정상적이지 않을 경우 각 Case 마다의 예외를 뱉는다.
토큰이 유효할 경우, 인증객체(Autentication) 를 생성한다. 만약 만료된 토큰이면 쿠키를 삭제하고 만료된 토큰임을 클라이언트에게 알린다.
3. 인증객체 생성
JwtAuthFilter.setAuthentication()
SecurityContextHolder 구조로 FilterChain을 통과할 때 인증된 사용자임을 알리는 객체를 생성한다.
public void setAuthentication(String memberId){
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication = jwtTokenProvider.createAuthentication(memberId);
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
구조는 아래와 같다.
유저 정보가 저장될 객체는 Authentication 내부 Principal 객체이며, 내부에 UserDetails 라는 객체의 형태로 저장된다.
JwtTokenProvider.createAuthentication()
public Authentication createAuthentication(String memberId) {
UserDetails userDetails = userDetailsService.loadUserByUsername(memberId);
return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
}
Authentication 객체를 생성하며, 내부에 UserDetails를 포함한다.
UserDetailsServiceImpl.loadUserByUsername()
@Override
public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException {
Member member = memberRepository.findByEmailAndDeletedFalse(memberId)
.orElseThrow(() -> new CustomException(ExceptionStatus.MemberNotFoundException));
return new UserDetailsImpl(member);
}
회원 Id로 조회한 Member 객체를 담은 UserDetails 객체를 생성한다.
@AllArgsConstructor
@Getter
public class UserDetailsImpl implements UserDetails {
private final Member member;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
String role = member.getMemberRole().getRole();
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role);
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(simpleGrantedAuthority);
return authorities;
}
...
}
내부에는 회원 정보가 존재하며, 권한을 의미하는 Authorities 리스트를 가져올 수 있다. 권한은 Member 객체에 이미 등록되어있는 회원의 Role을 등록한다.
최종적으로는 아래와 같은 정보로 Authentication 객체가 생성된다.
- Principal : UserDetails 객체
- Credential : null
- Authority : UserDetails 내부 Member의 Role
Authencitaion 객체가 존재하면, Spring Security의 FilterChain을 통과할 때 인증된 사용자로 간주하고 다음 필터를 통과할 수 있게 된다.
토큰 발급 과정
토큰 발급은 로그인 시에 이루어진다. DB에서 입력한 회원정보와 일치하는 회원이 있는지 조회하고, 입력한 정보가 맞다면 Access Token(이하 AT) 과 Refresh Token(이하 RT)을 발급하여 HTTP Header 내 Cookie에 넣어 반환한다. 이 때 발급한 RT는 Memory DB 인 Redis에 저장하여 관리한다.
토큰 발급 후에는 클라이언트가 인증된 사용자임을 서버에 알리기 위해 Header에 AT를 넣어 요청을 보낸다.
토큰 탈취 피해를 최소화하기 위하여, AT의 유효시간은 비교적 짧게 설정하며, AT가 만료된다면 Header에 있는 RT로 AT 재발급을 진행한다.
MemberController.login()
@Operation(summary = "유저 로그인", description = "로그인 성공 시 인증 토큰 발급하여 반환")
@ApiResponse(responseCode = "200", description = "SUCCESSED")
@PostMapping("/login")
public ResponseEntity<ResponseDto> login(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
@RequestBody final MemberAuthRequest request) {
return ResponseEntity.ok(memberService.login(httpServletRequest, httpServletResponse, request));
}
MemberService.login()
@Transactional(readOnly = true)
public ResponseDto login(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
MemberAuthRequest request) {
Member member = findByMemberIdAndDeletedFalse(request.getEmail());
// password Check
PasswordEncoderUtil.checkPassword(request.getPassword(), member.getPassword());
// Create AccessToken & RefreshToken
setAccessTokenToHttpHeader(member, httpServletResponse);
setRefreshTokenToHttpHeader(member, httpServletRequest, httpServletResponse);
return ResponseDto.success(Constants.API_RESPONSE_SUCCESSED);
}
입력한 회원정보가 유효하면, AT와 RT를 발급한다.
1. Access Token 생성
MemberService.setAccessTokenToHttpHeader()
public void setAccessTokenToHttpHeader(Member member,
HttpServletResponse response){
try{
String accessToken = jwtTokenProvider.createToken(member.getEmail(), member.getMemberRole(), TokenType.ACCESS);
ResponseCookie cookie = ResponseCookie.from(
Constants.AUTHORIZATION_HEADER,
URLEncoder.encode(accessToken, "UTF-8"))
.path("/")
// .httpOnly(true)
.sameSite("None")
.secure(true)
.maxAge(Constants.ACCESS_TOKEN_TIME)
.build();
response.addHeader("Set-Cookie", cookie.toString());
}catch (UnsupportedEncodingException e){
throw new CustomException(ExceptionStatus.AccessTokenCreateFailedException);
}
}
JwtTokenProvider.createToken()
public String createToken(String memberId, Role role, TokenType tokenType){
Date date = new Date();
long time = tokenType == TokenType.ACCESS ? Constants.ACCESS_TOKEN_TIME : Constants.REFRESH_TOKEN_TIME;
return Constants.BEARER_PREFIX +
Jwts.builder()
.setSubject(memberId)
.claim(Constants.AUTHORIZATION_KEY, role)
.setExpiration(new Date(date.getTime() + time))
.setIssuedAt(date)
.signWith(key, signatureAlgorithm)
.compact();
}
회원 Id, 권한, 유효시간, 생성일자 등을 담은 Jwt를 서버에 저장된 Secret Key로 서명하여 생성한다.
생성된 Jwt는 HttpHeader에 Cookie로 담는다. 쿠키 Header는 "Authorizaion" 으로 지정하였다.
MemberService.setRefreshTokenToHttpHeader()
public void setRefreshTokenToHttpHeader(Member member,
HttpServletRequest request,
HttpServletResponse response){
try {
String refreshToken = jwtTokenProvider.createToken(member.getEmail(), member.getMemberRole(), TokenType.REFRESH);
ResponseCookie cookie = ResponseCookie.from(
Constants.REFRESH_HEADER,
URLEncoder.encode(refreshToken, "UTF-8"))
.path("/")
// .httpOnly(true)
.sameSite("None")
.secure(true)
.maxAge(Constants.REFRESH_TOKEN_TIME)
.build();
response.addHeader("Set-Cookie", cookie.toString());
// 기존 저장된 RFT가 있는지 검색 후 있으면 만료 처리
Optional<RefreshToken> findRefreshToken = redisRepository.findById(member.getEmail());
long expiration = Constants.REFRESH_TOKEN_TIME / 1000;
if(findRefreshToken.isPresent()){
RefreshToken updatedRefreshToken = findRefreshToken.get().updateToken(refreshToken, expiration);
redisRepository.save(updatedRefreshToken);
} else {
// 기존 RFT가 없을 경우 생성된 RFT로 저장
RefreshToken refreshTokenToSave = RefreshToken.builder()
.email(member.getEmail())
.refreshToken(refreshToken)
.expiration(expiration)
.build();
redisRepository.save(refreshTokenToSave);
}
}catch (UnsupportedEncodingException e){
throw new CustomException(ExceptionStatus.RefreshTokenCreateFailedException);
}
}
Refresh 토큰 생성과정도 위와 동일하며, Cookie Header는 "Refresh" 로 지정했다.
기존 Redis에 Key : Value 로 저장된 RT를 조회하여 이미 존재하는 RT가 있다면 유효시간을 수정하여 만료처리 한다.
새로운 RT는 Key 값을 Email로 하여 저장하도록 지정했다.
아래는 Redis에 저장될 RefreshToken 객체의 형태이다.
@RedisHash(value = "email")
@Getter
@Builder
@AllArgsConstructor
public class RefreshToken {
@Id
private String email;
private String refreshToken;
@TimeToLive
private Long expiration;
public RefreshToken updateToken(String refreshToken, Long expiration) {
this.refreshToken = refreshToken;
this.expiration = expiration;
return this;
}
}
RT 생성 및 Redis 저장이 완료되면 생성된 쿠키를 클라이언트에게 반환한다.
Access Token 재발급 과정
AT가 만료될 경우 클라이언트는 AT 재발급 요청을 서버로 보낸다. 서버에서는 Redis에 저장한 RT를 조회하고, 유효한 RT인지 검증을 진행한다. 정상적인 RT 라면 새로운 AT를 발급하여 반환한다.
MemberController.reIssueAccessToken()
@Operation(summary = "토큰 재발급", description = "Refresh Token 을 보내줘야 합니다.")
@PostMapping("/reissue") // access token이 만료됐을 경우
public ResponseEntity<ResponseDto> reIssueAccessToken(@Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl userDetails,
HttpServletResponse httpServletResponse) {
return ResponseEntity.ok(memberService.reIssueAccessToken(userDetails.getMember(), httpServletResponse));
}
MemberService.reIssueAccessToken()
public ResponseDto reIssueAccessToken(Member member, HttpServletResponse response) {
RefreshToken refreshToken = redisRepository.findById(member.getEmail()).orElseThrow(
() -> new CustomException(ExceptionStatus.RefreshTokenNotFoundException));
Log.varLog("email" ,refreshToken.getEmail());
setAccessTokenToHttpHeader(member, response);
return ResponseDto.success(Constants.API_RESPONSE_SUCCESSED);
}
회원 email 로 redis에 저장된 RT를 조회한다. 조회한 RT가 유효하면 새로운 AT를 발급하여 반환한다.
Refresh Token 개선점
토큰이 탈취당했을 때 해커가 서버에서는 클라이언트의 토큰을 따로 조작할 방법이 없다. 따라서 피해를 최소화 하기 위한 방법이 무엇이 있을지 고민해봤다.
생각해본 개선 방법은 다음과 같다.
1. Access Token 재발급 시, RT를 매번 교체한다.
2. 유효하지 않은 RT로 클라이언트 단에서 요청이 들어오면 로그아웃 처리한다.
3. Cookie의 HttpOnly 옵션
HttpOnly 옵션을 걸면 브라우저에서 쿠키에 접근할 수 없도록 처리하여 XSS 공격을 방지할 수 있다.
RT를 매번 갱신하게 되면 이미 사용한 RT를 다시 사용할 수 없도록 처리할 수 있다.
하지만 사용되지 않은 RT가 탈취되게 되면 해커는 계속 AT를 갱신하여 사용할 수 있으므로 대응할 방법이 없다.
개선점을 찾으면서 완벽한 기술은 존재하지 않는다는 것을 느꼈다.
클라이언트 별로 IP White List 를 DB에 등록해서 사용하고, 각 Member별로 허용된 IP 에서 접근했을 때만 토큰을 생성하도록 처리하는 방법도 있을 것 같다. 하지만 실무에서는 클라이언트 IP를 수집하는 것 자체가 개인정보 보호 정책에 위배될 수 있기 때문에 실현이 어려운 방법인 것 같다는 생각이 든다.