Spring Security을 적용하지 않고 JWT 인증 방식을 간단하게 구현한 과정을 설명한 글입니다.
JWT(JSON Web Token)이란 JSON 형식으로 인증에 필요한 정보들을 담고 비밀키로 암호화한 서명을 포함한 토큰으로 사용자에 대한 인증을 수행하기 위해 사용됩니다.
여기서 주목해야할 특징은 다음 두 가지입니다.
먼저 첫 번째 특징 "인증에 필요한 정보를 담음" 에 대해 알아봅시다.
JWT는 Header, Payload, Signature 총 세 가지 부분이 연결되어 하나의 String 값으로 표현됩니다. 이 때, 인증에 피룡한 정보가 담기는 곳이 바로 Payload 입니다.
사용자나 시스템과 같은 토큰의 주체에 관한 정보를 key-value 형태로 담아 보낼 수 있습니다. 이를 통해 DB 조회 없이도 토큰의 소유자는 누군지, 이름은 무엇인지 등의 정보를 알 수 있게 됩니다.
두 번째 특징으로는 "비밀키를 통한 암호화"가 있습니다.
앞서 언급한 JWT의 세 가지 부분 중 Signature에 해당됩니다. Header와 Payload를 합친 문자열을 서버의 비밀키로 암호화한 서명을 통해 토큰의 유효성을 검증할 수 있게 됩니다. 서명에 사용된 비밀키는 서버만 알고 있기 때문에 서명을 만들 수 있는 주체는 오직 서버뿐입니다. 이를 통해 토큰의 진위성을 보장해줄 수 있게 됩니다.
그렇다면 JWT를 통해 어떻게 회원 인증을 구현할 수 있을까요? accessToken과 refreshToken 을 사용한 인증 과정을 아래와 같이 정리할 수 있습니다.
JWT와 관련된 여러 라이브러리가 있는데 그 중 JJWT를 사용하였습니다. JJWT 깃허브에 들어가보면 README 문서화가 잘 되어 있어서 읽어가면서 공부하면 좋을 것 같습니다!
JJWT는 Java에서 JWT(JSON Web Token)를 생성, 파싱, 검증하는 데 사용되는 라이브러리로, Jwts 클래스를 사용하여 간단하게 토큰을 생성, 파싱, 검증할 수 있습니다.
조금 더 자세히 설명해보자면, JJWT는 토큰 생성을 위한 객체들을 추상화하기 위한 라이브러리 입니다. JJWT 라이브러리를 사용하는 개발자가 실제 토큰 생성 모듈에 직접 의존하는 것이 아닌 이를 추상화한 인터페이스에 의존하게 됨으로서, 의존성을 역전(DIP) 시키고 내부 로직을 캡슐화 시킨다는 장점이 있습니다. 만약 토큰 생성 모듈 내부의 변화가 생기더라도 개발자가 자신의 코드를 직접 수정하는 일은 발생하지 않게 되는 것이죠.
본격적으로 JJWT를 사용하기에 앞서 의존성을 설치해주어야 합니다. build.gradle 파일에 아래 코드를 작성하고, IntelliJ 기준 우측 상단의 Gradle 아이콘을 눌러 Reload 시켜주면 설치가 완료됩니다.
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
JwtUtil 클래스 내부에 다음과 같은 Token 발급 메소드를 작성했습니다.
public String createAccessToken(UserEntity userEntity) {
return Jwts.builder()
.subject(String.valueOf(userEntity.getUserId()))
.claim("userId", userEntity.getUserId())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessExpiration))
.signWith(secretKey)
.compact();
}
사용된 메소드들을 하나씩 살펴보겠습니다.
위의 과정을 거쳐 최종적으로 accessToken 문자열이 생성됩니다. refreshToken도 위와 동일한 과정으로 발급됩니다. expiration 메소드의 파라미터로 accessExpiration 대신 refreshExpiration을 넣어주면 됩니다.
여기서 잠깐 accessToKen과 refreshToken의 만료 시간 설정에 대해 짚고 넘어가면 좋을 것 같습니다. 이것에 대한 이해를 위해서는 refreshToKen의 도입 계기를 먼저 알아보겠습니다.
AccessToken만을 이용하여 인증을 한다면, 유효기간 설정에 따른 단점이 존재하게 됩니다. 토큰의 유효 기간을 짧게 설정한다면 사용자가 인증 과정을 자주 거쳐야 합니다. 이는 당연하게도 사용자 경험의 저하로 연결됩니다. 그렇다고해서 유효 기간을 무작정 길게 설정한다면 토큰을 탈취한 공격자가 긴 만료 시간 전까지 마음껏 리소스에 접근할 수 있게 됩니다. JWT 방식의 특성상 서버가 이미 생성된 토큰에 대해서는 추적이나 제어가 불가능하기 때문입니다.
이러한 단점을 극복자고자 도입된 것이 refreshToken의 개념입니다.
보안을 위해 accessToken의 유효 기간은 짧게 설정하되, refreshToken을 통해 자동으로 accessToken을 재발급 받음으로서 사용자가 직접 재인증 과정을 거치지 않아도 되도록 만들어줍니다. 이 때 사용되는 refreshToken의 유효 기간은 상대적으로 길게 설정을 해서 사용자 경험이 저하되는 것을 방지해줍니다.
정리하자면 일반적으로 accessToken의 유효기간은 짧게, refreshToken의 유효기간은 길게 설정합니다. 토큰을 정확히 어떻게 다루느냐는 프로젝트나 회사마다 정책의 차이가 존재하기 때문에 명확한 정답은 없습니다.
다음으로는 토큰의 유효성을 검증하는 메소드 입니다. 마찬가지로 사용된 메소드들을 하나씩 정리해보겠습니다.
public Boolean verifyToken(String token) {
try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (Exception e) {
throw new CustomException(ErrorCode.UNAUTHORIZED_TOKEN);
}
}
유효성 검증 과정에서 발생할 수 있는 예외는 다음과 같은 것들이 있습니다.위 코드에서는 발생하는 예외를 catch 문에서 CustomException 객체로 처리했습니다.
이제 각 계층 별 코드를 살펴보겠습니다. 먼저 로그인, 로그아웃 로직과 관련된 도메인의 이름을 Auth 로 설정했기 때문에 AuthController, AuthService 라고 부르겠습니다.
@PostMapping("/token")
public ResponseEntity<DataResponseDto<TokenDto>> login(@RequestBody LoginDto loginDto) {
TokenDto tokenDto = authService.login(loginDto);
return ResponseEntity.status(HttpStatus.OK)
.body(DataResponseDto.of(HttpStatus.OK, "LOGIN_SUCCESS", "로그인에 성공했습니다.", tokenDto));
}
요청 body는 String 타입의 email과 password를 필드로 가지는 LoginDto를 정의해서 사용했습니다.
응답에는 String 타입의 accessToken과 refreshToken을 필드로 가지는 TokenDto를 담을 수 있게 작성했습니다.
public TokenDto login(LoginDto loginDto) {
if (loginDto == null ||
!StringUtils.hasText(loginDto.email()) ||
!StringUtils.hasText(loginDto.password())) {
throw new CustomException(ErrorCode.BAD_REQUEST);
}
UserEntity userEntity = userRepository.findByEmail(loginDto.email())
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
if(!userRepository.isValidUser(loginDto)) {
throw new CustomException(ErrorCode.UNAUTHORIZED_USER);
}
String accessToken = jwtUtils.createAccessToken(userEntity);
String refreshToken = jwtUtils.createRefreshToken(userEntity);
return TokenDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
다음으로 로그인 비지니스 로직을 처리하는 서비스 계층의 login 메서드 입니다. 먼저 로그인 정보로 들어온 email과 password의 값의 존재 여부를 확인하고 비어있다면 BAD_REQUEST 예외를 던집니다.
이후에는 User DB에서 해당 로그인 정보에 매핑되는 사용자가 있는지 확인하고 유효한 사용자라면 앞서 작성한 JwtUtils의 createAccess(Refresh)Token 메소드를 통해 토큰을 발급합니다.
마지막으로 TokenDto 객체를 builde() 메소드를 통해 생성하고 반환해줍니다.
스프링을 사용한 첫 프로젝트여서 아직 코드 자체에 대한 완성도는 낮습니다! 특히 로그인 정보에 대한 유효성 검증 부분은 아직 구현하지 못한 상태라는 점 참고 부탁드립니다. 빠른 시일 내에 기능 추가 및 리팩토링을 해서 포스팅 해놓겠습니다 🙌 혹시라도 글을 읽으시면서 이상한 점이나 오류를 발견하신다면 언제든 댓글에 남겨주세요!
[이미지 출처] https://cloud.google.com/apigee/docs/api-platform/security/oauth/using-jwt-oauth?hl=ko