수정중입니다~
JWT를 사용한 이유와 JWT가 무엇인지, 쿠키-세션 방식과의 차이는 무엇인지는 따로 포스팅하도록 하겠습니다.
로그인 관련 API 설계는 다음과 같습니다
- /login 로그인
- /login/{userType} SNS 로그인(카카오 로그인)
- /refresh Access Token 재발급 요청
- /logout 로그아웃
Refresh Token 처리에 대해서 프론트 분께서 이 포스팅을 참고해 진행해보자고 제안하셨습니다.
안그래도 어떻게 토큰을 전달해야 안전할 지 고민이 되서 찾아보고 따로 정리해뒀는데 그 내용과 결론이 같아서 기뻤습니다~~! (여기에 뒤늦게 포스팅했습니다 👀)
Access token은 공식규격이니까 헤더에 싣고, Refresh token은 그럴 필요없으니 보안을 위해 http only 쿠키로 넘기기로 했습니다.
/refresh 가 호출되면 프론트에서 받아온 RefreshToken 쿠키를 사용해서 AccessToken을 재발급하게 되며, 다음 상황에 호출이 됩니다.
- 리프레시 됐을 때 (또는 사이트 창을 껐다가 다시 켰을 때)
- Access Token이 만료됐을 때
LoginController.java
@Tag(name = "login/logout", description = "로그인 API")
@RestController
@RequiredArgsConstructor
@Slf4j
public class LoginController {
private final LoginService loginService;
private final AuthTokenProvider authTokenProvider;
private final CookieProvider cookieProvider;
@Operation(summary = "기본 로그인", description = "AccessToken은 헤더로, RefreshToken은 쿠키로 반환합니다.")
@PostMapping("/login")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(responseCode = "400", description = "BAD REQUEST"),
@ApiResponse(responseCode = "404", description = "NOT FOUND"),
})
public ResponseEntity<?> login(@RequestBody @Valid final DefaultLoginRequestDTO loginDTO){
final User loginUser = loginService.defaultLogin(loginDTO);
AuthToken AT = authTokenProvider.issueAccessToken(loginUser);
AuthToken RT = authTokenProvider.issueRefreshToken(loginUser);
loginService.updateRefresh(loginUser, RT);
ResponseCookie RTcookie = cookieProvider.createRefreshTokenCookie(RT.getToken());
return ResponseUtils.getLoginSuccessResponse(loginUser.getId(), AT, RTcookie,"로그인에 성공했습니다.");
}
...//sns 로그인
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "새로고침 됐을때, accessToken이 만료됐을 때 호출", description = "RefreshToken 쿠키를 사용해서 AccessToken을 재발급합니다.")
@PostMapping("/refresh")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(responseCode = "401", description = "UNAUTHORIZED")
})
public ResponseEntity<?> refresh(@CookieValue(value = "refreshToken") String refreshTokenStr){
AuthToken refreshToken = authTokenProvider.convertAuthToken(refreshTokenStr);
//토큰 검증
if(!refreshToken.validate()) throw new UnAuthorizedException(UnAuthorizedExceptionType.REFRESH_TOKEN_UN_AUTHORIZED);
Long userId = refreshToken.getUserIdFromClaims();
final AuthToken reissuedAT = loginService.refresh(userId, refreshToken);
//set response
AuthDTO authDTO = new AuthDTO(userId);
return ResponseEntity.ok()
.header(HttpHeaders.AUTHORIZATION, reissuedAT.getToken())
.body(new ResponseDTO<>(authDTO, "AccessToken이 재발급되었습니다."));
}
}
컨트롤러에서 쿠키에 Refresh token을 설정하는 로직은 중복되기에 CookieProvider 클래스의 메서드로 따로 분리했습니다. 로그아웃 시 Refresh token 쿠키를 삭제하는 메서드도 이 클래스에 작성했습니다. (깃헙에서 코드를 볼 수 있습니다)
http secure 설정을 하려면 SSL 인증서가 필요하기 때문에 시간관계 상 ResponseCookie의 secure 설정을 false로 해뒀습니다. ㅠㅠ
LoginServiceImpl.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class LoginServiceImpl implements LoginService{
private final AuthTokenProvider authTokenProvider;
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
private final SnsAPIService snsAPIService;
@Override
public User defaultLogin(DefaultLoginRequestDTO loginDTO) {
//비밀번호 일치 확인
User dbUser = userRepository.findByEmail(loginDTO.getEmail())
.orElseThrow(() -> new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_USER));
if(passwordEncoder.matches(loginDTO.getPassword(), dbUser.getPassword())) return dbUser;
else throw new UserInvalidInputException(UserInvalidInputExceptionType.ACCOUNT_NOT_MATCH);
}
...//snsLogin
//RT 검증 실패시 401 에러 발생
@Override
public AuthToken refresh(Long userId, AuthToken refreshToken) {
//DB에 있는 RT랑 비교
User user = userRepository.findById(userId)
.orElseThrow(()-> new EntityNotExistsException(EntityNotExistsExceptionType.NOT_FOUND_USER));
if(!refreshToken.getToken().equals(user.getRefreshToken())) throw new UnAuthorizedException(UnAuthorizedExceptionType.REFRESH_TOKEN_UN_AUTHORIZED);
//RT가 유효하므로 AT 재발급
return authTokenProvider.issueAccessToken(user);
}
@Transactional
@Override
public void updateRefresh(User loginUser, AuthToken refreshToken) {
loginUser.updateRefreshToken(refreshToken.getToken()); //DB의 RT 갈아끼우기
}
}
JWT 라이브러리로는 jjwt를 사용했습니다. build.gradle에 다음 코드를 추가하면 됩니다.
//jjwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
프로젝트를 진행하면서 인프런의 '스프링 시큐리티 & JWT 강의'를 듣기도 하고,
oauth2 부분은 [React.js, 스프링 부트, AWS로 배우는 웹 개발 101] 를 이북으로 학습하고, 따로 조사한 내용들을 바탕으로 구현했습니다.
다음과 같이 시나리오를 짜봤습니다.
1. 로그인
- [ 서버 ] 는 AT, RT 발급 또는 재발급
- 최초발급일 경우 RT를 DB에 저장
- 재발급일 경우 RT를 DB에 업데이트
2. 권한이 필요한 요청 들어올 때 : JWT 필터 작동
- [ 클라이언트 ]는 발급받은 두 토큰을 보관하고, 매 요청마다 AT를 헤더에 담아서 요청
- [ 서버 ] 는 AT 검증 후 응답
- success: AT가 유효한 경우
200 OK
- fail: AT가 만료된 경우 RT도 만료되면
401 Unauthorized
- fail: AT가 만료 된 경우 RT는 유효하면 -> 서버가 판단할 수 X
401 Unauthorized
- success: AT가 유효한 경우
3. fail=401
->[ 클라이언트 ] 에서 AT와 RT를 보내 토큰 재발급 요청 보냄
- [ 서버 ] 는 검증 후 응답 (정상적인 요청의 경우 AT가 만료되었으므로 이 요청이 옴)
- 토큰의 payload(Subject)의 type을 통해 AT인지 RT인지 구분
- sucess: RT가 유효한 경우 AT 재발급
200 OK
- fail: RT 만료또는 유효하지 않으면 로그인 유도
401 Unauthorized
- fail: 유효한 두 토큰 모두 만료가 되지 않았는데 재발급을 요청한 경우 **두 토큰을 파기**시키고 로그인을 유도
403 Forbidden
- sucess: RT가 유효한 경우 AT 재발급
4. 로그아웃
- [ 서버 ] (AT는 삭제 불가능) RT를 DB와 대조해서 삭제
구현한 클래스는 다음과 같습니다.
너무 많아서 설명으로 대체하겠습니다. 자세한 코드는 깃헙에서 보실 수 있습니다.
AuthToken, AuthTokenProvider,CustomUserDetails 등의 클래스는 이 포스팅을 참고하여 구현했습니다.
- AuthToken: JWT를 담는 용의 클래스
- subject와 만료시간을 파라미터로 받는 JWT 토큰 생성 메서드 구현
- 토큰을 검증하는(Claim을 얻기 위해 파싱) 메서드 구현
- AuthTokenProvider: AuthToken 객체 생성 및 AuthToken의 메서드를 사용해 토큰 발행, 파싱, 검증하는 역할
- JwtConfig: 시크릿키, 토큰 만료시간 등을 저장하는 설정 클래스
- AuthTokenProvider의 생성자에 시크릿키를 전달해 빈으로 등록한다.
- JwtAuthorizationFilter: 제일 중요한 역할을 함! 인증이 필요한 모든 요청은 이 필터를 거친다.
- BasicAuthenticationFilter를 상속받도록 구현했다.
- http 요청 헤더에서 토큰 String을 가져와 파싱 및 검증(1.Claims 추출 2.JWT 타입이 Access Token인지)
- 검증이 성공하면 사용자 인증정보를 SecurityContextHolder에 등록한다. (토큰은 요청이 끝날때까지 보관된다)
- JwtExceptionEntryPoint: jjwt의 AuthenticationException 발생 시 호출되며 JWT관련 에러 처리를 함
- Why) Filter는 Dispatcher Servlet 보다 앞단에 존재하며 Handler Intercepter는 뒷단에 존재하기 때문에 Filter에서 보낸 예외는 Exception Handler로 처리를 못한다.
- 참고: https://velog.io/@hellonayeon/spring-boot-jwt-expire-exception
- CustomLogoutHandler: LogoutHandler를 구현한 클래스로 /logout 요청이 들어오면 JwtConfig에서 RT쿠키를 삭제처리한 후 이 클래스의 logout 메서드가 호출된다.
- logout 메서드: Security Context의 유저 email을 검증 후 DB의 User Table에 있는 RT를 삭제한 후 response 처리함
- CustomUserDetails: UserDetails 클래스를 구현한 커스텀 클래스
- User의 pk, email, UserType(sns 유저 구분용),JwtType을 필드로 갖는다.
- JwtType: AT와 RT를 구분하기 위한 enum타입 클래스로, CustomUserDetails의 필드로 사용된다.
- CustomArgumentResolver: ???
SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private String REFRESH_COOKIE = "refreshToken";
private final CustomLogoutHandler customLogoutHandler;
private final AuthTokenProvider authTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors()//기본 cors 설정
.and()
.csrf().disable()
.formLogin().disable() //formLogin 인증 비활성화
.httpBasic().disable() //httpBasic 인증 비활성화
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests().antMatchers(
"/oauth2/**", "/", "/login/**", "/users", "/refresh", "/swagger-ui/**", "/api-docs/**")
.permitAll()
.anyRequest().authenticated();
http
.apply(new MyCustomDsl());
http
.logout().permitAll()
.logoutUrl("/logout")
.deleteCookies(REFRESH_COOKIE) //로그아웃 후 쿠키 삭제
.addLogoutHandler(customLogoutHandler) //DB에서 RT 삭제
.logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK));
http
.exceptionHandling()
.authenticationEntryPoint(new JwtExceptionEntryPoint()); //예외처리
return http.build();
}
@Bean
public PasswordEncoder getPasswordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
public class MyCustomDsl extends AbstractHttpConfigurer<MyCustomDsl, HttpSecurity> {
@Override
public void configure(HttpSecurity http) throws Exception {
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
http
.addFilterAfter(new JwtAuthorizationFilter(authenticationManager, authTokenProvider), CorsFilter.class);
}
}
}
Secret key
JWT의 서명에 사용되는 HS512는 SHA-512를 사용한 HMAC-SHA 알고리즘입니다. 참고
HS512로 서명하기 위해서는 시크릿키가 최소 64에서 128 바이트여야 합니다. 알파벳은 1 바이트니까.. 시크릿키는 최소 64글자 이상이어야 합니다. 참고
The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HS512 MUST have a size >= 512 bits (the key size must be greater than or equal to the hash output size). Consider using the io.jsonwebtoken.security.Keys class's 'secretKeyFor(SignatureAlgorithm.HS512)' method to create a key guaranteed to be secure enough for HS512. See https://tools.ietf.org/html/rfc7518#section-3.2 for more information.
OpenSSL을 사용해서 다음과 같이 시크릿 키를 발급했습니다. 출처
NMA8JPctFuna59f5NMA8JPctFuna59f5NMA8JPctFuna59f5NMA8JPctFuna59f5NMA8JPctFuna59f5NMA8JPctFuna59f5
시크릿 키는 절대 git에 올리면 안됩니다. gitignore 처리를 해놓고 따로 관리해야해요. 그래서 다음과 같이 properties파일을 여러개 만들고 프로파일을 나눕니다.
application-jwt.properties
jwt.secret=NMA8JPctFuna59f5NMA8JPctFuna59f5NMA8JPctFuna59f5NMA8JPctFuna59f5NMA8JPctFuna59f5NMA8JPctFuna59f5
jwt.access.expiration=3600
jwt.refresh.expiration=2592000
application.properties에 다음 코드를 추가해줍니다 (주의: gitignore된 파일들이므로 로컬 환경에서만 추가해야 합니다)
# Profile
spring.profiles.include=jwt, local, sns
CI/CD를 담당하는 팀원분께 제가 코드를 따로 전달해드렸고, 개발 서버에 어떻게 넣냐고 물어봤더니
- 해당 정보를 암호화해서 git에 올린다.
- 쉘스크립트로 서버내에서 jar파일 구동할때 외부에서 properties를 주입한다.
이렇게 두 가지 방법이 있는데 2번으로 설정하셨다고 해요. (다음에는 저도 젠킨스로 자동화를 구축해보고 싶네요..!)
'회고록' 카테고리의 다른 글
[IT기획 인턴] 서한그룹 드림버스컴퍼니 - 공통 교육 (3) | 2024.01.10 |
---|---|
[Numble 챌린지 개발일지] 5주차 (1) 로그아웃 리디렉션 해결 (0) | 2022.12.15 |
[Numble 챌린지 개발일지] 4주차 (1) 회원, 모임 로직 구현하기 (0) | 2022.12.14 |
[Numble 챌린지 개발일지] 3주차 REST API에 Swagger 입히기 (0) | 2022.11.09 |
[Numble 챌린지 개발일지] 2주차 협업방식 정하기, ERD 설계 (0) | 2022.11.06 |