[Spring Boot] JWT를 이용한 로그인 구현

yeonjiyooo·3일 전
1

Spring

목록 보기
2/2

Spring Security을 적용하지 않고 JWT 인증 방식을 간단하게 구현한 과정을 설명한 글입니다.

1. JWT란 무엇인가?

JWT(JSON Web Token)이란 JSON 형식으로 인증에 필요한 정보들을 담고 비밀키로 암호화한 서명을 포함한 토큰으로 사용자에 대한 인증을 수행하기 위해 사용됩니다.

여기서 주목해야할 특징은 다음 두 가지입니다.

  1. 인증에 필요한 정보를 담음
  2. 비밀키로 암호화

먼저 첫 번째 특징 "인증에 필요한 정보를 담음" 에 대해 알아봅시다.

JWT는 Header, Payload, Signature 총 세 가지 부분이 연결되어 하나의 String 값으로 표현됩니다. 이 때, 인증에 피룡한 정보가 담기는 곳이 바로 Payload 입니다.
사용자나 시스템과 같은 토큰의 주체에 관한 정보를 key-value 형태로 담아 보낼 수 있습니다. 이를 통해 DB 조회 없이도 토큰의 소유자는 누군지, 이름은 무엇인지 등의 정보를 알 수 있게 됩니다.

Token

두 번째 특징으로는 "비밀키를 통한 암호화"가 있습니다.

앞서 언급한 JWT의 세 가지 부분 중 Signature에 해당됩니다. Header와 Payload를 합친 문자열을 서버의 비밀키로 암호화한 서명을 통해 토큰의 유효성을 검증할 수 있게 됩니다. 서명에 사용된 비밀키는 서버만 알고 있기 때문에 서명을 만들 수 있는 주체는 오직 서버뿐입니다. 이를 통해 토큰의 진위성을 보장해줄 수 있게 됩니다.

2. JWT를 통한 회원 인증 과정

그렇다면 JWT를 통해 어떻게 회원 인증을 구현할 수 있을까요? accessToken과 refreshToken 을 사용한 인증 과정을 아래와 같이 정리할 수 있습니다.

  1. 클라이언트가 서버에게 로그인 정보 (ex. email, pw)를 전송합니다.
  2. 로그인 정보를 받은 서버는 이를 바탕으로 올바른 로그인 요청인지 검증합니다.
  3. 만약 유효한 로그인 정보라면 accessToken과 refreshToken을 발급하고, 이를 응답에 담아 보냅니다.
  4. Token 정보를 응답 받은 클라이언트는 앞으로의 모든 요청의 헤더에 accessToken 값을 담아 보냅니다.
  5. 서버에서는 클라이언트 요청 헤더에 담긴 accessToken 의 유효성을 검증합니다.
  6. 유효한 Token임이 검증된 경우에만 요청을 정상적으로 처리하고, 만약 유효하지 않은 Token이라면 (ex. expiration이 지난 경우) 401 Unauthorized 를 발생시킵니다.
  7. 401 Unauthorized 를 응답 받은 클라이언트는 refreshToken을 활용하여 accessToken 재발급을 시도합니다.
  8. refreshToken 값의 유효성 여부를 검증하고 이 결과를 바탕으로 서버는 accessToken을 재발급 해주거나, token을 만료시킵니다.
  9. accessToken 재발급에 성공한 경우 5번 과정부터 다시 반복하며 요청 - 응답을 반복합니다.
  10. accessToken 재발급에 실패한 경우는 사용자가 직접 재인증 과정을 거쳐야 합니다.

3. JWT 인증을 위한 JwtUtils

JWT와 관련된 여러 라이브러리가 있는데 그 중 JJWT를 사용하였습니다. JJWT 깃허브에 들어가보면 README 문서화가 잘 되어 있어서 읽어가면서 공부하면 좋을 것 같습니다!

JJWT Github

JJWT는 Java에서 JWT(JSON Web Token)를 생성, 파싱, 검증하는 데 사용되는 라이브러리로, Jwts 클래스를 사용하여 간단하게 토큰을 생성, 파싱, 검증할 수 있습니다.

조금 더 자세히 설명해보자면, JJWT는 토큰 생성을 위한 객체들을 추상화하기 위한 라이브러리 입니다. JJWT 라이브러리를 사용하는 개발자가 실제 토큰 생성 모듈에 직접 의존하는 것이 아닌 이를 추상화한 인터페이스에 의존하게 됨으로서, 의존성을 역전(DIP) 시키고 내부 로직을 캡슐화 시킨다는 장점이 있습니다. 만약 토큰 생성 모듈 내부의 변화가 생기더라도 개발자가 자신의 코드를 직접 수정하는 일은 발생하지 않게 되는 것이죠.

2.1 Dependency 설치

본격적으로 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'
}

2.2 Token 발급

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();
}

사용된 메소드들을 하나씩 살펴보겠습니다.

  • builder(): JWT 빌더 객체 생성
    헤더와 페이로드가 비어있는 상태의 JWT 빌더 객체를 생성합니다.
  • subject(): 표준 클레임 sub 설정
    sub: 토큰의 주체가 되는 사용자의 식별자 (ex. userId)
    JWT의 표준 클레임 중 하나인 sub를 설정하는 메소드입니다. 이 때 subject 메소드의 파라미터로는 String만 가능하기 때문에 타입을 맞춰주어야 합니다.
    위 코드에서 사용된 userEntity의 userId의 타입은 Long 이기 때문에 String.valueOf()를 통해 타입 변환을 해주었습니다.
  • claim(): 커스텀 클레임 추가
    sub와 같은 표준 클레임 외에도 비지니스 로직과 관련된 커스텀 클레임을 추가할 수 있습니다.
  • issuedAt(): 표준 클레임 iat 설정
    iat: 토큰이 발급된 시각
    Date()를 통해 토큰 생성 메소드가 실행되는 시점의 시각을 토큰 발급 시간 iat로 설정합니다.
  • expiration(): 표준 클레임 exp 설정
    exp: 토큰이 만료되는 시간
    토큰이 생성되는 현재 시각에 미리 설정한 토큰 만료 시간 accessExpiration을 더하여 만료 시간을 설정합니다.
  • signWith(): JWS 서명 설정
    미리 설정 해놓은 secretKey 암호화에 사용되는 비밀키로 설정합니다. 이 단계에서 암호화 알고리즘을 의미하는 표준 클레임 alg이 자동으로 세팅됩니다.
  • compact(): Header와 Payload Base64 인코딩
    앞에서 설정한 Header와 Payload를 인코딩 한 후 .으로 이어 붙이고 비밀키로 암호화한 서명을 만들어 Header.Payload.Sinature 형태의 문자열을 생성합니다.

위의 과정을 거쳐 최종적으로 accessToken 문자열이 생성됩니다. refreshToken도 위와 동일한 과정으로 발급됩니다. expiration 메소드의 파라미터로 accessExpiration 대신 refreshExpiration을 넣어주면 됩니다.

➕ AccessToken / RefreshToken

여기서 잠깐 accessToKen과 refreshToken의 만료 시간 설정에 대해 짚고 넘어가면 좋을 것 같습니다. 이것에 대한 이해를 위해서는 refreshToKen의 도입 계기를 먼저 알아보겠습니다.

AccessToken만을 이용하여 인증을 한다면, 유효기간 설정에 따른 단점이 존재하게 됩니다. 토큰의 유효 기간을 짧게 설정한다면 사용자가 인증 과정을 자주 거쳐야 합니다. 이는 당연하게도 사용자 경험의 저하로 연결됩니다. 그렇다고해서 유효 기간을 무작정 길게 설정한다면 토큰을 탈취한 공격자가 긴 만료 시간 전까지 마음껏 리소스에 접근할 수 있게 됩니다. JWT 방식의 특성상 서버가 이미 생성된 토큰에 대해서는 추적이나 제어가 불가능하기 때문입니다.

이러한 단점을 극복자고자 도입된 것이 refreshToken의 개념입니다.

보안을 위해 accessToken의 유효 기간은 짧게 설정하되, refreshToken을 통해 자동으로 accessToken을 재발급 받음으로서 사용자가 직접 재인증 과정을 거치지 않아도 되도록 만들어줍니다. 이 때 사용되는 refreshToken의 유효 기간은 상대적으로 길게 설정을 해서 사용자 경험이 저하되는 것을 방지해줍니다.

정리하자면 일반적으로 accessToken의 유효기간은 짧게, refreshToken의 유효기간은 길게 설정합니다. 토큰을 정확히 어떻게 다루느냐는 프로젝트나 회사마다 정책의 차이가 존재하기 때문에 명확한 정답은 없습니다.

2.3 Token 유효성 검증

다음으로는 토큰의 유효성을 검증하는 메소드 입니다. 마찬가지로 사용된 메소드들을 하나씩 정리해보겠습니다.

public Boolean verifyToken(String token) {
    try {
        Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token);
        return true;
    } catch (Exception e) {
        throw new CustomException(ErrorCode.UNAUTHORIZED_TOKEN);
    }
}
  • parser(): JWT parser를 만들기 위한 builder 반환
  • verifyWith(): 서명 검증에 사용할 비밀키 등록
    서버가 자신이 서명한 토큰이 맞는지 확인하기 위해 검증에 사용할 비밀키를 등록하는 메소드입니다. 파서는 해당 비밀키를 받아 헤더에 적힌 alg과 호환되는 키 형식인지 확인하고, 맞지 않는다면 예외(WeakKeyException)를 발생시킵니다.
  • build(): JwtParser를 생성
    앞선 메소드에서 설정한 정보들을 바탕으로 JwtParser를 만들어 반환해줍니다. 토큰 검증을 위한 준비가 끝난 단계라고 생각하면 됩니다.
  • parseSignedClaims(): 토큰 정보를 검증하는 메소드
    생성된 JwtParser의 메소드로 토큰 문자열은 Header, Payload, Signature 세 부분으로 분해한 후 Base64 디코딩을 진행합니다. 서명은 verifyWith에서 받은 비밀키로 검증을 진행하고 Payload는 Claims로 매핑하여 반환해줍니다. 또한 exp, nbf와 같은 클레임에 대한 유효성 체크도 진행합니다.
    반환값은 Jws로 getHeader(), getPayload()로 토큰의 값에 접근할 수 있습니다.

유효성 검증 과정에서 발생할 수 있는 예외는 다음과 같은 것들이 있습니다.위 코드에서는 발생하는 예외를 catch 문에서 CustomException 객체로 처리했습니다.

  • 서명 불일치/위조 : io.jsonwebtoken.security.SecurityException
  • 만료: ExpiredJwtException
  • 포맷/구조 이상: MalforemdJwtException

4. AuthController

이제 각 계층 별 코드를 살펴보겠습니다. 먼저 로그인, 로그아웃 로직과 관련된 도메인의 이름을 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를 담을 수 있게 작성했습니다.

5. AuthService

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() 메소드를 통해 생성하고 반환해줍니다.

6. 마치며

스프링을 사용한 첫 프로젝트여서 아직 코드 자체에 대한 완성도는 낮습니다! 특히 로그인 정보에 대한 유효성 검증 부분은 아직 구현하지 못한 상태라는 점 참고 부탁드립니다. 빠른 시일 내에 기능 추가 및 리팩토링을 해서 포스팅 해놓겠습니다 🙌 혹시라도 글을 읽으시면서 이상한 점이나 오류를 발견하신다면 언제든 댓글에 남겨주세요!


[이미지 출처] https://cloud.google.com/apigee/docs/api-platform/security/oauth/using-jwt-oauth?hl=ko

profile
1 ^ 365 = 1 이지만 1.01 ^ 365 = 37쩜 어쩌고... 이다!

0개의 댓글