[Project] 스프링 시큐리티 없이 JWT 인증 구현하기

clean·2024년 4월 27일
0

mewsinsa 기록

목록 보기
5/7
post-thumbnail

Oauth2 카카오 로그인 구현하기에서 이어지는 내용이기는 하나, OAuth2가 아닌 JWT 인증에 대한 정보만 필요하시다면 앞의 포스트를 읽지 않으셔도 이해 가능합니다.

Overview

카카오 로그인 과정

원래 인가 코드, 토큰 발행은 각각의 단계를 가지지만, 그림에선 단순화 하여 한 단계인 것처럼 표현했습니다.

  1. 사용자가 카카오 로그인을 합니다.
  2. 제 서버가 카카오 서버와 통신하는 단계입니다.
    2-1. 로그인에 성공하면 카카오 인증 서버는 쿼리 스트링을 통해서 제 서버로 카카오 인증 토큰을 발행받을 때 필요한 인가 코드를 보내줍니다.
    2-2. 제 서버는 인가 코드와 API key를 가지고 카카오 서버에 액세스 토큰을 요청합니다.
  3. 액세스 토큰을 받으면 그 액세스 토큰을 가지고 카카오 회원의 정보를 가져올 수 있습니다. 저는 이메일과 이름 정보만 가져왔습니다.
  4. 카카오 로그인한 사용자가 뮤신사의 회원인지 확인합니다(DB 조회)
    4-1. 회원이 아니면 이름, 이메일 주소와 함께 회원가입 페이지로 리다이렉트 시킵니다.
    4-2. 회원이면 Jwt를 발행합니다.
  5. Refresh Token과 Access Token은 각각 DB(refresh_token 테이블, access_token 테이블)에 저장해둡니다.

로그아웃 과정

  1. 클라이언트가 로그아웃을 요청합니다.
  2. 서버는 클라이언트가 헤더에 넣어서 보낸 액세스 토큰을 parse해서 subject에 저장되어 있는 memberId를 조회합니다. 그리고 그 멤버 아이디를 통해서 refresh_token DB와 access_token DB에 있는 해당 회원의 토큰을 모두 지웁니다.

이런 방식으로 로그인, 로그아웃을 구현하였기 때문에 클라이언트 요청 헤더에 액세스 토큰이 포함되어 있다면, 다음과 같은 검증 과정이 필요할 것 같습니다.

  • 변조되지 않은 유효한 토큰인지 (내가 지정한 signKey로 잘 parse 되는지)
  • 해당 액세스 토큰이 DB(logout_token 테이블)에 존재하는지.
    • DB에 존재하지 않는다면 로그아웃된 사용자의 토큰이므로 다시 로그인하라는 의미의 에러 코드를 내려줍니다.
  • 액세스 토큰의 유효 기간이 지나지 않았는지
    • 지났다면 리프레시 토큰으로 재발급을 하라는 의미의 에러 코드를 내려줍니다.

구현해봅시다!

오늘은 로그인 요청이 들어오면 JWT를 발행하여 클라이언트로 넘기고, 클라이언트가 헤더에 JWT 액세스 토큰을 포함하고 요청을 보내면 올바른 토큰인지 확인하여 인가하는 부분을 구현해보고자 합니다.

Jwt와 관련된 상수값 설정하기

카카오 소셜로그인 때와 마찬가지로, Jwt 인증/인가를 구현할 때 필요한 고정된 값들이 존재합니다.

대표적으로 Jwt를 해싱할 때 필요한 secretKey, 유저의 패스워드를 DB에 저장할 때 레인보우 공격을 피하기 위해 패스워드 + salt 문자열을 해싱하여 저장하는데, 그 때 필요한 salt 문자열 등이 있습니다.

그런 고정된 값들은 application-jwt.properties에 정의해두었습니다.

jwt를 해싱할 때 필요한 시크릿 키와 비밀번호를 해싱할 때 더해주는 솔트 문자열은 외부에 공개되면 안됩니다.
따라서 깃허브에 올라가지 않도록 .gitignore에 추가해줍니다.

그리고 JwtProperties.java라는 클래스를 만들어 해당 클래스의 변수들에 @Value 어노테이션으로 값을 넣어주고 그 클래스를 빈으로 등록해주었습니다.
이제 이 값들이 필요한 클래스에서 해당 빈을 의존성 주입받아 사용할 수 있습니다.

JwtProperties.java

package com.mewsinsa.auth.jwt;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Component;

@Component
@PropertySource("classpath:application-jwt.properties")
public class JwtProperties {
  @Value("${jwt.issuer}")
  private String issuer;
  @Value("${jwt.secret_key}")
  private String secretKey;

  @Value("${jwt.sign_in.password.salt}")
  private String passwordSalt;


}

토큰을 발행, 검사해주는 클래스 만들기

저는 JwtProvider.java라고 하는 토큰을 발행해주고 토큰이 올바른지 검사하고 읽는 클래스를 별도로 만들었습니다.

클래스 전체 내용은 아래와 같습니다.

package com.mewsinsa.auth.jwt;


import com.mewsinsa.auth.jwt.controller.dto.AccessTokenDto;
import com.mewsinsa.auth.jwt.controller.dto.RefreshTokenDto;

import com.mewsinsa.auth.jwt.domain.JwtToken;

import com.mewsinsa.auth.jwt.repository.AccessTokenRepository;
import com.mewsinsa.auth.jwt.repository.RefreshTokenRepository;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
public class JwtProvider {
  public static final long ACCESSTOKEN_TIME = 1000 * 60 * 30; // 30분
  public static final long REFRESHTOKEN_TIME = 1000 * 60 * 60 * 24 * 14; // 2주
  public static final String ACCESS_PREFIX_STRING = "Bearer ";
  public static final String ACCESS_HEADER_STRING = "Authorization";
  public static final String REFRESH_HEADER_STRING = "RefreshToken";


  private static String key;
  private static String keyBase64Encoded; // properties에 정의된 값
  private static SecretKey signingKey;

  private final AccessTokenRepository accessTokenRepository;
  private final RefreshTokenRepository refreshTokenRepository;

  @Autowired
  public JwtProvider(@Value("${jwt.secret_key}") String keyParam,
      AccessTokenRepository accessTokenRepository, RefreshTokenRepository refreshTokenRepository) {
    key = keyParam;
    keyBase64Encoded = Base64.getEncoder().encodeToString(key.getBytes());
    signingKey = Keys.hmacShaKeyFor(keyBase64Encoded.getBytes());
    this.accessTokenRepository = accessTokenRepository;
    this.refreshTokenRepository = refreshTokenRepository;

  }

  //==Getter==//

  public String getKey() {
    return key;
  }

  //==SigningKey==//
  public static SecretKey getSigningKey() {
    return signingKey;
  }

  /**
   * @return JWT Token(액세스 토큰 + 리프레시 토큰)
   */
  public JwtToken createJwtToken(Long memberId, String nickname, Boolean isAdmin) {
    String accessToken = createAccessToken(memberId, nickname, isAdmin);
    String refreshToken = createRefreshToken(memberId);

    return new JwtToken(accessToken, refreshToken);
  }

  /**
   * 리프레시 토큰으로 액세스 토큰 재발급 요청이 왔을 때 호출됩니다.
   * @return 액세스 토큰
   */
  public String createAccessToken(Long memberId, String nickname, Boolean isAdmin) {
    Map<String, Object> claims = new HashMap<>();

    claims.put("memberId", memberId);
    claims.put("nickname", nickname);
    claims.put("isAdmin", Boolean.toString(isAdmin));

    Date expiration = new Date(System.currentTimeMillis() + ACCESSTOKEN_TIME);

    String accessToken = ACCESS_PREFIX_STRING + Jwts.builder()
        .subject(Long.toString(memberId))
        .claims(claims)
        .expiration(expiration)
        .signWith(this.getSigningKey())
        .compact();

    // 액세스 토큰을 DB에 저장
    accessTokenRepository.deleteAccessTokenByMemberId(memberId);
    accessTokenRepository.addAccessToken(new AccessTokenDto(memberId, accessToken, expiration));

    return accessToken;
  }

  /**
   * 외부에서 호출될 수 없습니다.
   * @return 리프레시 토큰
   */
  private String createRefreshToken(Long memberId) {
    Date expiration = new Date(System.currentTimeMillis() + REFRESHTOKEN_TIME);

    String refreshToken = Jwts.builder()
        .subject(Long.toString(memberId))
        .claim("memberId", memberId)
        .expiration(expiration)
        .signWith(getSigningKey())
        .compact();

    // 리프레시 토큰을 DB에 저장
    refreshTokenRepository.deleteRefreshTokenByMemberId(memberId);
    refreshTokenRepository.addRefreshToken(new RefreshTokenDto(refreshToken, memberId, expiration));

    return refreshToken;
  }

  /**
   * 토큰의 클레임들을 가져옵니다.
   * @param token 액세스 또는 리프레시 토큰
   * @return 해당 토큰의 클레임들. parse에 실패할 경우 null을 반환합니다.
   */
  public Jws<Claims> parseClaims(String token) {
    Jws<Claims> claimsJws;
    try {

      claimsJws = Jwts.parser()
          .verifyWith(getSigningKey())
          .build()
          .parseSignedClaims(token);

    } catch(ExpiredJwtException ex) {
      return null; // 만료되었음
    } catch(JwtException ex) {
      throw new IllegalArgumentException("잘못된 토큰입니다.");
    }

    return claimsJws;
  }


}

하나하나씩 살펴 보겠습니다.

토큰 발행

토큰을 발행하는 메소드는 createJwtToken 메소드입니다.

  /**
   * @return JWT Token(액세스 토큰 + 리프레시 토큰)
   */
  public JwtToken createJwtToken(Long memberId, String nickname, Boolean isAdmin) {
    String accessToken = createAccessToken(memberId, nickname, isAdmin);
    String refreshToken = createRefreshToken(memberId);

    return new JwtToken(accessToken, refreshToken);
  }

토큰을 발행할 때는 액세스 토큰과 리프레스 토큰을 모두 발행하여 클라이언트로 넘겨주어야 합니다. 따라서 해당 메소드에서도 액세스 토큰, 리프레시 토큰을 만드는 메소드를 호출해서 각각 만들고 이를 JwtToken으로 만들어 리턴하고 있습니다.

JwtToken.java

package com.mewsinsa.auth.jwt.domain;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class JwtToken {
  private String accessToken;
  private String refreshToken;

  //==Constructor==//
  public JwtToken(String accessToken, String refreshToken) {
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
  }

  //==Getter==//
  public String getAccessToken() {
    return accessToken;
  }

  public String getRefreshToken() {
    return refreshToken;
  }

  //==Setter==//
  public void setAccessToken(String accessToken) {
    this.accessToken = accessToken;
  }

  public void setRefreshToken(String refreshToken) {
    this.refreshToken = refreshToken;
  }
}

createAccessToken 메소드
이 메소드는 JwtToken을 만들 때도 호출되지만, 리프레시 토큰을 가지고 액세스 토큰 재발행 요청이 올때도 사용됩니다.

 /**
   * 리프레시 토큰으로 액세스 토큰 재발급 요청이 왔을 때 호출됩니다.
   * @return 액세스 토큰
   */
  public String createAccessToken(Long memberId, String nickname, Boolean isAdmin) {
    Map<String, Object> claims = new HashMap<>();

    claims.put("memberId", memberId);
    claims.put("nickname", nickname);
    claims.put("isAdmin", Boolean.toString(isAdmin));

    Date expiration = new Date(System.currentTimeMillis() + ACCESSTOKEN_TIME);

    String accessToken = ACCESS_PREFIX_STRING + Jwts.builder()
        .subject(Long.toString(memberId))
        .claims(claims)
        .expiration(expiration)
        .signWith(this.getSigningKey())
        .compact();

    // 액세스 토큰을 DB에 저장
    accessTokenRepository.deleteAccessTokenByMemberId(memberId);
    accessTokenRepository.addAccessToken(new AccessTokenDto(memberId, accessToken, expiration));

    return accessToken;
  }

createRefreshToken 메소드

  /**
   * 외부에서 호출될 수 없습니다.
   * @return 리프레시 토큰
   */
  private String createRefreshToken(Long memberId) {
    Date expiration = new Date(System.currentTimeMillis() + REFRESHTOKEN_TIME);

    String refreshToken = Jwts.builder()
        .subject(Long.toString(memberId))
        .claim("memberId", memberId)
        .expiration(expiration)
        .signWith(getSigningKey())
        .compact();

    // 리프레시 토큰을 DB에 저장
    refreshTokenRepository.deleteRefreshTokenByMemberId(memberId);
    refreshTokenRepository.addRefreshToken(new RefreshTokenDto(refreshToken, memberId, expiration));

    return refreshToken;
  }

카카오 로그인 마저 구현

저번 포스트에서 카카오로부터 회원 정보만 가져왔었는데, 이제 토큰을 발행해서 로그인을 마저 구현해보겠습니다.

KakaoLoginController.java

@GetMapping("/code")
  public ResponseEntity<Object> kakaoLogin(@RequestParam(value = "code", required = false) String code,
      @RequestParam(value = "error", required = false) String error,
      @RequestParam(value = "error_description", required = false) String error_description,
      @RequestParam(value = "state", required = false) String state) {

    KakaoTokenResponseDto kakaoToken = kakaoLoginService.getToken(code);

    // 액세스 토큰으로 회원 정보 얻어오기
    KakaoUserInfoDto userInfo = kakaoLoginService.getUserInfo(kakaoToken);

    // 회원 정보로 회원인지 판단
    Long memberId = kakaoLoginService.findMemberIdByEmail(userInfo.getKakaoAccount().getEmail());

    if(memberId != null) {

      // 토큰을 발급
      JwtToken jwtToken = jwtService.login(memberId);

      Map<String, String> jwtMap = new HashMap<>();
      jwtMap.put(JwtProvider.ACCESS_HEADER_STRING, jwtToken.getAccessToken());
      jwtMap.put(JwtProvider.REFRESH_HEADER_STRING, jwtToken.getRefreshToken());

      SuccessResult result = new Builder(DetailedStatus.CREATED)
          .message("로그인에 성공하여 jwt가 발행되었습니다.")
          .data(jwtMap).build();

      return new ResponseEntity<>(result, HttpStatus.CREATED);

    } else { // 회원 가입으로 리다이렉트
      Map<String, String> memberInfo = new HashMap<>();

      memberInfo.put("name", userInfo.getKakaoAccount().getName());
      memberInfo.put("email", userInfo.getKakaoAccount().getEmail());

      HttpHeaders headers = new HttpHeaders();
      headers.setLocation(URI.create("/auth/sign-up-page"));
      return new ResponseEntity<>(memberInfo, headers, HttpStatus.MOVED_PERMANENTLY);
    }
  }

회원 정보를 받아오면, 회원의 email로 DB의 member 테이블을 조회합니다. (email은 member 테이블에서 UNIQUE 키입니다.)

만약 회원이 있다면, 이미 회원 가입이 된 것이므로 토큰을 발행하여 응답으로 넘겨줍니다.
만약 member 테이블에 정보가 없다면, 아직 회원가입을 하지 않은 사람이기에 회원가입 페이지로 리다이렉트 시켜주었습니다. (저는 이 부분을 리다이렉션으로 하였지만, 그렇게 하지 않고 클라이언트에 유저 정보와 에러코드로 응답을 준다음에 클라이언트가 회원가입 페이지를 요청하도록 하는 것도 좋을 것 같습니다.)

저 컨트롤러 코드 안에서 호출되고 있는 login 메소드는 다음과 같습니다.

JwtService.java

  @Transactional
  public JwtToken login(Long memberId) {
    // 멤버 찾기
    Member member = memberRepository.findMemberById(memberId);

    // 토큰 발행
    return jwtProvider.createJwtToken(memberId, member.getNickname(), member.getAdmin());
  }

서비스 자체 로그인 구현하기

이제 카카오 로그인이 아닌 서비스 자체 로그인(아이디, 비밀번호를 입력해서 로그인하기)을 구현해보겠습니다.

JwtController.java

@RequestMapping("/auth")
@RestController
public class JwtController {
  Logger log = LoggerFactory.getLogger(getClass());

  private final JwtService jwtService;

  public JwtController(JwtService jwtService) {
    this.jwtService = jwtService;
  }
  
  //... 내용 생략
  
  @PostMapping("/login")
  public ResponseEntity<SuccessResult> login(@RequestBody LoginRequestDto loginRequestDto) {
    JwtToken jwtToken = jwtService.mewsinsaLogin(loginRequestDto.getId(),
        loginRequestDto.getPassword());

    Map<String, String> jwtMap = new HashMap<>();
    jwtMap.put(JwtProvider.ACCESS_HEADER_STRING, jwtToken.getAccessToken());
    jwtMap.put(JwtProvider.REFRESH_HEADER_STRING, jwtToken.getRefreshToken());

    SuccessResult result = new Builder(DetailedStatus.CREATED)
        .message("로그인에 성공하여 jwt가 발행되었습니다.")
        .data(jwtMap).build();

    return new ResponseEntity<>(result, HttpStatus.CREATED);
  }
  
}

JwtService.java

  // 과정:
  // 1. mewsinsaId로 DB에서 회원 찾기
  // 2. 없으면?(null)이면 -> CustomException 던지기 (아이디 없음 A003)
  // 3. 있으면? -> 비밀번호를 해싱해서 멤버 비밀번호랑 비교하기
  // 4. 같으면? -> 토큰 발행
  // 5. 다르면? -> CustomException 던지기 (비밀번호가 틀림 A004)
  @Transactional
  public JwtToken mewsinsaLogin(String mewsinsaId, String password) {
    Member member = memberRepository.findMemberByMewsinsaId(mewsinsaId);
    if(member == null) {
      throw new NonExistentMemberException(mewsinsaId, "회원이 존재하지 않습니다.");
    }

    // 비밀번호를 해싱
    String encryptedPassword = getEncryptedPassword(password);
    if(!encryptedPassword.equals(member.getPassword())) { // 같지 않음
      throw new IncorrectPasswordException(mewsinsaId, "비밀번호가 틀립니다.");
    }

    // 토큰 발행
    return jwtProvider.createJwtToken(member.getMemberId(), member.getNickname(), member.getAdmin());
  }

LoginRequestDto.java

package com.mewsinsa.auth.jwt.controller.dto;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class LoginRequestDto {
  private String id;
  private String password;

  public String getId() {
    return id;
  }

  public String getPassword() {
    return password;
  }

  public void setId(String id) {
    this.id = id;
  }

  public void setPassword(String password) {
    this.password = password;
  }
}

/auth/login으로 POST 요청이 들어오면, 요청으로 들어온 아이디와 비밀번호가 DB member 테이블에 저장된 것과 동일한지를 확인합니다.
이때 비밀번호는 DB에 저장할 때 거쳤던 동일한 해싱 과정을 거쳐 비교합니다. 비밀번호의 저장 방법에 대한 내용은 이 포스트에서 확인하실 수 있습니다.

회원이 없다면 클라이언트에게 에러 코드를, 회원이 있다면 토큰을 발행하여 응답으로 넘깁니다.


로그아웃

JwtController.java

  @PostMapping("/logout")
  public ResponseEntity<SuccessResult> logout(@RequestHeader(value=JwtProvider.ACCESS_HEADER_STRING, required=false) String accessToken) {

    if(accessToken == null) {
        throw new IllegalStateException();
    }

    String actualToken = accessToken.replaceFirst("Bearer ", ""); // Bearer 제거
    Jws<Claims> claimsJws;
    try {
      // 토큰 읽어서 memberId 알아내기
      claimsJws = Jwts.parser()
          .verifyWith(JwtProvider.getSigningKey())
          .build()
          .parseSignedClaims(actualToken);
    } catch(JwtException e) {
      throw new IllegalStateException();
    }

    Long memberId = Long.parseLong(claimsJws.getPayload().getSubject());

    jwtService.logout(memberId); // refresh, access token을 DB에서 지워주기

    SuccessResult result = new Builder(DetailedStatus.OK)
        .message("로그아웃 되었습니다.")
        .build();
    return new ResponseEntity<>(result, HttpStatus.OK);
  }

예전에 짰던 코드라 토큰을 파싱할때 에러가 나면 IllegalStateException을 던지는 게 조금 마음에 안듭니다...
토큰이 만료됐다면 이미 로그아웃 됐다는 에러 코드를, 토큰이 파싱이 안된다면 잘못된 토큰이라는 에러 코드를 내려줄 수 있도록 커스텀 Exception을 만들어 던지는 코드로 수정하고 싶습니다.

JwtService.java

  @Transactional
  public void logout(Long memberId) {
    try {
      // 멤버의 access, refresh 토큰 삭제
      accessTokenRepository.deleteAccessTokenByMemberId(memberId);
      refreshTokenRepository.deleteRefreshTokenByMemberId(memberId);
    } catch(Exception e) {
      throw new IllegalArgumentException(e.getMessage());
    }
  }

로그아웃은 요청이 들어오면 리프레시 토큰과 액세스 토큰을 DB에서 지워주는 식으로 구현하였습니다.


리프레시 토큰으로 액세스 토큰 재발급해주기

액세스 토큰의 만료 기간은 30분으로 매우 짧습니다.
30분마다 로그인이 풀려버린다면 매우 화가 날 것입니다.
따라서 리프레시 토큰이 존재합니다.
리프레시 토큰의 유효기간은 주로 7일~14일 정도로 액세스 토큰보다 깁니다. 만약 서버가 클라이언트에게 액세스 토큰이 만료되었다는 에러코드를 내려주면, 클라이언트는 리프레시 토큰을 헤더에 넣고 액세스 토큰을 재발급해달라는 요청을 보낼 수 있습니다.

지금부터 액세스 토큰을 재발급해주는 기능을 구현해보겠습니다.

JwtController.java

  @PostMapping("/reissue-access-token")
  public ResponseEntity<Object> reissueAccessToken(@RequestHeader(value=JwtProvider.REFRESH_HEADER_STRING, required=false) String refreshToken) {
    if(refreshToken == null) { // 메인페이지로
      HttpHeaders headers = new HttpHeaders();
      headers.setLocation(URI.create("/"));
      return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY);
    }

    JwtToken jwtToken = jwtService.reissueAccessToken(refreshToken);

    if(jwtToken == null) { // 메인 페이지로
      HttpHeaders headers = new HttpHeaders();
      headers.setLocation(URI.create("/"));
      return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY);
    }

    SuccessResult result = new Builder(DetailedStatus.CREATED)
        .message("access token이 재발급 되었습니다.")
        .data(jwtToken)
        .build();

    return new ResponseEntity<>(result, HttpStatus.CREATED);
  }

JwtService.java

  // refreshToken을 읽고 accessToken을 재발급합니다.
  @Transactional
  public JwtToken reissueAccessToken(String refreshToken) {
    Jws<Claims> claims = jwtProvider.parseClaims(refreshToken);

    // 리턴 값이 null이라면 만료
    if(claims == null) {
      // refresh Token을 DB에서 삭제
      refreshTokenRepository.deleteRefreshTokenByTokenValue(refreshToken);
      return null;
    }

    Long memberId = Long.parseLong(claims.getPayload().getSubject());
    Date expiration = claims.getPayload().getExpiration();
    String nickname = claims.getPayload().get("nickname", String.class);
    boolean isAdmin = Boolean.parseBoolean(claims.getPayload().get("isAdmin", String.class));

    // refreshToken과 DB에 저장된 refreshToken의 값이 일치하는지 확인
    RefreshTokenDto findRefreshToken = refreshTokenRepository.findRefreshTokenByMemberId(
        memberId);
    if(!findRefreshToken.getTokenValue().equals(refreshToken)) {
      throw new InvalidTokenException("잘못된 리프레시 토큰입니다.");
    }

    // 재발급
    String accessToken = jwtProvider.createAccessToken(memberId, nickname, isAdmin);

    return new JwtToken(accessToken, refreshToken);
  }

JwtProvider.java

  /**
   * 토큰의 클레임들을 가져옵니다.
   * @param token 액세스 또는 리프레시 토큰
   * @return 해당 토큰의 클레임들. parse에 실패할 경우 null을 반환합니다.
   */
  public Jws<Claims> parseClaims(String token) {
    Jws<Claims> claimsJws;
    try {

      claimsJws = Jwts.parser()
          .verifyWith(getSigningKey())
          .build()
          .parseSignedClaims(token);

    } catch(ExpiredJwtException ex) {
      return null; // 만료되었음
    } catch(JwtException ex) {
      throw new IllegalArgumentException("잘못된 토큰입니다.");
    }

    return claimsJws;
  }

만약 리프레시 토큰이 헤더에 없거나, 리프레시 토큰이 만료되거나 잘못된 토큰이라 액세스 토큰이 재발급 되지 않는다면 /로 리다이렉트합니다.

리프레시 토큰이 올바르다면 (잘 parse되고, DB refresh_token 테이블의 것과 동일함) 액세스 토큰을 발행해서 응답으로 넘겨줍니다.


회원가입

JwtController.java

  @PostMapping("/sign-up")
  public ResponseEntity<SuccessResult> signUp(@RequestBody SignUpRequestDto signUpRequestDto) throws Exception {
    jwtService.signUp(signUpRequestDto);

    SuccessResult result = new Builder(DetailedStatus.CREATED)
        .message("회원 가입에 성공하였습니다.")
        .build();

    return new ResponseEntity<>(result, HttpStatus.CREATED);
  }

SignUpRequestDto.java

package com.mewsinsa.auth.jwt.controller.dto;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class SignUpRequestDto {
  @NotEmpty
  private String mewsinsaId;
  @NotEmpty
  private String password;

  @NotEmpty
  private String name;
  private String nickname;
  @Email
  @NotEmpty
  private String email;
  @NotEmpty
  private String phone;
  private String profileImage;
  @NotEmpty
  private Integer tierId;
  @NotEmpty
  private Boolean isAdmin;
  private Long points;

  //==Constructor==//
  public SignUpRequestDto() {
  }

  public SignUpRequestDto(String mewsinsaId, String password, String name, String nickname,
      String email, String phone, String profileImage, Integer tierId, Boolean isAdmin,
      Long points) {
    this.mewsinsaId = mewsinsaId;
    this.password = password;
    this.name = name;
    this.nickname = nickname;
    this.email = email;
    this.phone = phone;
    this.profileImage = profileImage;
    this.tierId = tierId;
    this.isAdmin = isAdmin;
    this.points = points;
  }

  //==Getter==//
  public String getMewsinsaId() {
    return mewsinsaId;
  }

  public String getPassword() {
    return password;
  }

  public String getName() {
    return name;
  }

  public String getNickname() {
    return nickname;
  }

  public String getEmail() {
    return email;
  }

  public String getPhone() {
    return phone;
  }

  public String getProfileImage() {
    return profileImage;
  }

  public Integer getTierId() {
    return tierId;
  }

  public Boolean getAdmin() {
    return isAdmin;
  }

  public Long getPoints() {
    return points;
  }

  //==Setter==//
  public void setMewsinsaId(String mewsinsaId) {
    this.mewsinsaId = mewsinsaId;
  }

  public void setPassword(String password) {
    this.password = password;
  }

  public void setName(String name) {
    this.name = name;
  }

  public void setNickname(String nickname) {
    this.nickname = nickname;
  }

  public void setEmail(String email) {
    this.email = email;
  }

  public void setPhone(String phone) {
    this.phone = phone;
  }

  public void setProfileImage(String profileImage) {
    this.profileImage = profileImage;
  }

  public void setTierId(Integer tierId) {
    this.tierId = tierId;
  }

  public void setAdmin(Boolean admin) {
    isAdmin = admin;
  }

  public void setPoints(Long points) {
    this.points = points;
  }
}

JwtService.java

  public void signUp(SignUpRequestDto signUpRequestDto) {
    String encryptedPassword = getEncryptedPassword(signUpRequestDto.getPassword());

    Member member = new Member.Builder()
        .mewsinsaId(signUpRequestDto.getMewsinsaId())
        .password(encryptedPassword)
        .name(signUpRequestDto.getName())
        .nickname(signUpRequestDto.getNickname())
        .email(signUpRequestDto.getEmail())
        .phone(signUpRequestDto.getPhone())
        .profileImage(signUpRequestDto.getProfileImage())
        .tierId(signUpRequestDto.getTierId())
        .isAdmin(signUpRequestDto.getAdmin())
        .points(signUpRequestDto.getPoints())
        .build();

    // DB에 회원 정보 저장
    try {
      memberRepository.addMember(member);
    } catch (DataIntegrityViolationException e) {
      throw new DuplicateMemberInfoException("아이디 또는 이메일 또는 연락처가 기존 회원과 중복됩니다.");
    } catch (Exception e) {
      throw new IllegalArgumentException(e.getMessage());
    }
  }

Jwt와는 관련이 없지만, 인증과 관련된 부분이기에 회원가입 코드도 포스트에 넣었습니다.

회원가입 요청이 들어오면 해당 정보를 DB member 테이블에 저장합니다.
만약 mewsinsa_id, email, phone 등 유니크 해야하는 필드가 기존 회원과 중복된다면 DuplicateMemberInfoException이라는 커스텀 익셉션을 발생시켰습니다.


Redis로 성능 개선해보기

현재는 액세스 토큰과 리프레시 토큰이 DB에 저장되어 있습니다.
그런데 이런 토큰들을 DB에 저장하면 조금 성능상 좋지 않거나 번거로운 점이 생깁니다.

  1. 만기된 토큰을 직접 삭제해주어야한다.
    DB table에 만기된 토큰이 남아있으면 스프링 배치를 써서 특정 시간마다 지워주거나, 조회한 토큰이 만기된 토큰일 때마다 직접 지워주는 수밖에 없습니다.

  2. 인가가 필요할 때마다 DB에 접근해야한다.
    이곳에는 인가에 대한 내용이 나와있지 않지만, "주문하기" 등과 같은 회원만이 할 수 있는 요청이 들어오면 그 요청을 보낸 사람이 로그인 되어있는지를 확인해야합니다. (이는 스프링 인터셉터로 구현하였는데, 해당 내용도 다른 포스트에서 다뤄보겠습니다.)
    그렇다면 로그인이 필요한 요청이 들어올 때마다 해당 액세스 토큰이 DB에 있는지 확인하기 위해 디스크에 있는 DB에 접근해야합니다. (물론 반복된 쿼리 결과를 빠르게 주기 위한 캐시가 있긴하지만 캐시미스가 나는 경우에는 디스크에 접근을 해야합니다.)

우선 1번은 구현할 것이 늘어나기에 번거로운 부분이고, 두번째 부분은 성능상 악영향을 줄 수 있는 부분입니다.

이런 점들을 개선하기 위해 많은 서버가 세션 관리(토큰 관리)를 메모리 DB인 레디스를 이용해 하고 있습니다.
레디스는 메모리에 값을 저장하기에 디스크까지 접근하지 않아도 되고, TTL(Time To live)를 설정할 수 있어, 액세스 토큰이 저장된지 30분이 지나면 자동으로 그 값을 지워줍니다.

지금 인증 부분에서 토큰을 저장하는 위치를 레디스로 옮기는 작업을 하고 있습니다.
구현이 완료되면 해당 내용을 포스팅해보려합니다. :)

Reference

profile
블로그 이전하려고 합니다! 👉 https://onfonf.tistory.com 🍀

0개의 댓글