(Spring) 취향 기반 향수 추천 서비스 - 6. 세번째 기능 Jwt토큰 발급

김준석·2023년 3월 23일
0

향수 추천 서비스

목록 보기
7/21

목표

  • 이전 글에 카카오 Oauth로 토큰을 발급받아, 유저정보를 받아왔다. 이번에는 받아온 유저정보로 Jwt 토큰을 발급하는 과정을 쓸 것이다.
@Component
public class JwtProvider {

    @Value("${jwt.secret}")
    private String secretKey;
    private long tokenValidTime =1800000L;

    private final LoginService loginService;

    public JwtProvider(@Lazy LoginService loginService) {
        this.loginService = loginService;
    }
    
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }
    
    public String createToken(String userPk) {
        Claims claims = Jwts.claims().setSubject(userPk);

        Date now = new Date();
        String accessToken = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidTime))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
        return accessToken;
    }

    public String createRefreshToken(String uerPk) {
        String refreshToken = Jwts.builder()
                .setId(uerPk)
                .setExpiration(new Date(System.currentTimeMillis() + tokenValidTime * 5))
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .signWith(SignatureAlgorithm.HS512, secretKey)
                .compact();
        return refreshToken;
    }
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = loginService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }

    public String resolveRefreshToken(HttpServletRequest request){
        return request.getHeader("X-REFRESH-TOKEN");
    }

    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}
  • 자체적으로 @Bean으로 등록하기 위해 @Component 어노테이션을 사용했다, 이후 @PostConstruct를 통해 의존성 주입이 끝난 뒤 secretKey를 초기화시켰다.
    필드에 secretKey와 토큰 유효시간 (30분)으로 선언했다.
  • creatToken()메서드는 AccessToken을 생성하는 메서드이다.
    Claims에는 페이로드에 userPk(memberId)를 담았다. setIssuedAt()은 발행한 시간인데 현재날짜로 설정했고, setExpiration(만료날짜)는 현재날짜 + vokenValidTime(30분으로 설정하였다.
    signWith()은 서명 알고리즘+시크릿키인데 HS256과 앞에 필드로 선언한 secretKey가 들어간다.
  • getUserPk는 토큰으로 고유 식별자를 복호화하는 것이다.
    -resolveToken은 프론트에서 헤더에 담아서 줄 request를 받을 메서드이다.
    -validateToken은 jwtToken을 받아 토큰 만료시간이 유효한지 판단하는 메서드이다.

Domain

Member.java

@Getter
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private Long id;
    @NotNull
    private Long memberId;
    @NotNull
    private String nickname;

    @Column(length = 100)
    private String email;

    private String thumbnailImage;

    @Builder
    public Member(Long id, Long memberId, String nickname, String email, String thumbnailImage) {
        this.id = id;
        this.memberId = memberId;
        this.nickname = nickname;
        this.email = email;
        this.thumbnailImage = thumbnailImage;
    }
}
  • memberId, nickname, email, thumbnailImage를 필드로 선언하였다.

Token.java

@Entity(name = "token")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Token {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "id", nullable = false)
    private Long id;

    @NotNull
    private String refreshToken;

    @NotNull
    private Long memberId;

    @Builder
    public Token(Long id, String refreshToken, Long memberId) {
        this.id = id;
        this.refreshToken = refreshToken;
        this.memberId = memberId;
    }

}

memberId와 refreshToken을 저장해둔다.
AccessToken이 만료되면 refreshToken을 검증하여 새로운 AccessToken을 발급해줘야 한다.

Service

LoginService.java

@Service
public class LoginService implements UserDetailsService {
    private final JwtProvider jwtProvider;
    private final TokenRepository tokenRepository;
    private final MemberService memberService;

    public LoginService(JwtProvider jwtProvider, TokenRepository tokenRepository, MemberService memberService) {
        this.jwtProvider = jwtProvider;
        this.tokenRepository = tokenRepository;
        this.memberService = memberService;
    }

    @Override
    public UserDetails loadUserByUsername(String refreshToken) throws UsernameNotFoundException {
        return (UserDetails) memberService.findMemberByEmail(refreshToken);
    }

    //토큰 저장
    public Token saveToken(LoginResponse loginResponse, Member member) {
        Token token = Token.builder()
                .refreshToken(loginResponse.getRefreshToken())
                .memberId(member.getMemberId())
                .build();
        if (!tokenRepository.existsByMemberId(member.getMemberId())) {
            tokenRepository.save(token);
        }
        return token;
    }

    public LoginResponse generateToken(Long memberId) {
        Member member = memberService.findByMemberPk(memberId);
        String accessToken = jwtProvider.createToken(String.valueOf(member.getMemberId()));
        String refreshToken = jwtProvider.createRefreshToken(String.valueOf(member.getMemberId()));

        LoginResponse loginResponse = LoginResponse.builder()
                .id(member.getId())
                .memberId(member.getMemberId())
                .email(member.getEmail())
                .nickname(member.getNickname())
                .thumbnailImage(member.getThumbnailImage())
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();

        saveToken(loginResponse, member);
        return loginResponse;
    }

    public LoginResponse permitClientRequest(String accessToken) {
        Member member = memberService.findByMemberPk(Long.valueOf(jwtProvider.getUserPk(accessToken)));
        if (!memberService.isMemberLogout(accessToken)) {
            throw new MemberAlreadyLogoutException();
        }
        return LoginResponse.builder()
                .id(member.getId())
                .email(member.getEmail())
                .nickname(member.getNickname())
                .build();
    }

    @Transactional
    public LoginResponse generateNewAccessToken(String refreshToken) {
        Token token = tokenRepository.findByRefreshToken(refreshToken).orElseThrow(UserNotFoundException::new);
        Member member = memberService.findByMemberPk(token.getMemberId());

        return LoginResponse.builder()
                .id(member.getId())
                .nickname(member.getNickname())
                .email(member.getEmail())
                .accessToken(regenerateAccessToken(String.valueOf(member.getMemberId())))
                .build();
    }

    public String regenerateAccessToken(String userPk) {
        return jwtProvider.createToken(userPk);
    }

}
  • saveToken()은 RefreshToken을 저장하는 용도로 사용되며, TokenRepository에 유저 정보가 없으면 refreshToken을 저장한다.
  • generateToken은 맨 처음 로그인 시 accessToken과 refreshToken을 만들어주고 이를 서버로 응답하는 역할을 한다.
  • permitClientRequest()는 로그아웃을 한 회원이 아니라면, 토큰을 검증하여 Response를 해주는 것이다.
  • generateNewAccessToken은 새로운 토큰을 발급해주는 메서드이다. 토큰 만료기한이 지나면, 클라이언트는 만료된 accessToken과 refreshToken을 보내 서버에서 이를 검증한다. 그리고 일치하면 새로운 accessToken을 발행시켜준다.

Interceptor

JwtInterceptor.java

@Component
public class JwtInterceptor implements HandlerInterceptor {

    private final JwtProvider jwtProvider;

    public JwtInterceptor(JwtProvider jwtProvider) {
        this.jwtProvider = jwtProvider;
    }

    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler) {
       if(!(handler instanceof HandlerMethod)){
           return true;
       }
        HandlerMethod handlerMethod = (HandlerMethod) handler;

        LoginCheck loginCheck = handlerMethod.getMethodAnnotation(LoginCheck.class);
        String accessToken = httpServletRequest.getHeader("Authorization");

        if (loginCheck == null) {
            return true;
        }
        if (jwtProvider.validateToken(accessToken) == false) {
            throw new TokenExpiredException();
        }
        return true;
    }
}
  • 인터셉터란 클라이언트의 요청이 컨트롤러에 도착하기전에 가로채서(interceptor) 유효한지 검증하기 위해 사용하였다.
  • 현재는 만료만을 판단하였는데, 추후에 탈취 등 여러 방면에서 신경써서 리팩토링 할 예정이다.

궁금한 점

사실, 단순 구현만 해놓은 자료들이 많았기 때문에, 이를 이후에 어떻게 활용할 지 갈피를 잡기가 힘들었다.
그럼 로그인이 필요한 요청 마다, 컨트롤러에 permitClientRequest()와 같은 메서드를 붙여야 하는건가..? 라고 생각했다.
찾아보니까, @LoginCheck라는 커스텀 어노테이션을 생성해 인터셉터에서 로그인이 필요한 Api에 접근할 때 가로채게 할 수 있는 편리한 기능이 있었다!

###LoginCheck
LoginCheck.java

@Retention(RUNTIME)
@Target(METHOD)
public @interface LoginCheck {
}

###Controller
LoginController.java

@RestController
@RequestMapping("/member")
public class LoginController {

    private final LoginService loginService;
    private final JwtInterceptor jwtInterceptor;

    private final JwtProvider jwtProvider;

    public LoginController(LoginService loginService, JwtInterceptor jwtInterceptor, JwtProvider jwtProvider) {
        this.loginService = loginService;
        this.jwtInterceptor = jwtInterceptor;
        this.jwtProvider = jwtProvider;
    }

    @LoginCheck
    @PostMapping("/response")
    public ResponseEntity resolveToken(HttpServletRequest httpServletRequest) {
        String accessToken = jwtProvider.resolveToken(httpServletRequest);

        return ResponseEntity.ok(loginService.permitClientRequest(accessToken));
    }

    @PostMapping("/regenerate")
    public ResponseEntity<LoginResponse> regenerateEntity(HttpServletRequest httpServletRequest) {

        String accessToken = jwtProvider.resolveToken(httpServletRequest);
        String refreshToken = jwtProvider.resolveRefreshToken(httpServletRequest);

        return ResponseEntity.ok(loginService.generateNewAccessToken(refreshToken));
    }
}

resolveToken()에서처럼 로그인이 필요한 Api마다 @LoginCheck 어노테이션을 사용할 수 있다.

아직 완료된게 아니....

아직 다 구현을 못한 부분이 꽤나 있다.
토큰이 탈취된 경우, Authentication등..
지금 빠르게 개발을 마쳐야 하는 시점인지라, 생각없이 빠르게 구현해봐야 내 지식이 될 것 같지 않다. 졸업 전시가 끝나고 이 부분은 차분히 리팩토링 할 예정이다.

어려웠던 점

로그인 기능은 정말 들어갈 수록 어려운 것 같다. 보안 측면에서도 고려해야할 사항도 많고..
원리를 이해하는 것부터 상당히 힘들었다.ㅜㅜ

프론트와 통신중에 계속 막혔던 부분은, 토큰이 만료되었을 때 401에러를 응답하는 부분이었다.
컨트롤러에서 401에러를 응답하게 해도, 인터셉터에서 처리하게 해도, 서버 콘솔에서만 401에러가 뜨고 클라이언트에는 응답이 안됐다 ㅜ..

이 부분도 잘 몰랐는데, Exception들을 관리해주는 클래스를 생성하여 해결할 수 있었다.

@RestControllerAdvice
public class GlobalExceptionHandler {
    private static final int NOT_FOUND_ERROR = 404;
    private static final int UNAUTHORIZED_ERROR = 401;
    @ExceptionHandler(ScentNotFoundException.class)
    public ResponseEntity<?> handleScentNotFoundException(ScentNotFoundException e){
        return ResponseEntity.status(NOT_FOUND_ERROR).body(e.getMessage());
    }

    @ExceptionHandler(SeasonNotFoundException.class)
    public ResponseEntity<?> handleSeasonNotFoundException(SeasonNotFoundException e){
        return ResponseEntity.status(NOT_FOUND_ERROR).body(e.getMessage());
    }

    @ExceptionHandler(MoodNotFoundException.class)
    public ResponseEntity<?> handleMoodNotFoundException(MoodNotFoundException e){
        return ResponseEntity.status(NOT_FOUND_ERROR).body(e.getMessage());
    }

    @ExceptionHandler(SurveyNotFoundException.class)
    public ResponseEntity<?> handleSurveyNotFoundException(SurveyNotFoundException e){
        return ResponseEntity.status(NOT_FOUND_ERROR).body(e.getMessage());
    }

    @ExceptionHandler(PostNotFoundException.class)
    public ResponseEntity<?> handlePostNotFoundException(PostNotFoundException e){
        return ResponseEntity.status(NOT_FOUND_ERROR).body(e.getMessage());
    }

    @ExceptionHandler(PerfumeNotFoundException.class)
    public ResponseEntity<?> handlePerfumeNotFoundException(PerfumeNotFoundException e){
        return ResponseEntity.status(NOT_FOUND_ERROR).body(e.getMessage());
    }

    @ExceptionHandler(BrandNotFoundException.class)
    public ResponseEntity<?> handleBrandNotFoundException(BrandNotFoundException e){
        return ResponseEntity.status(NOT_FOUND_ERROR).body(e.getMessage());
    }

    @ExceptionHandler(MemberAlreadyExistException.class)
    public ResponseEntity<?> handleMemberAlreadyExistException(MemberAlreadyExistException e){
        return ResponseEntity.status(NOT_FOUND_ERROR).body(e.getMessage());
    }

    @ExceptionHandler(EmailNotFoundException.class)
    public ResponseEntity<?> handleEmailNotFoundException(EmailNotFoundException e){
        return ResponseEntity.status(NOT_FOUND_ERROR).body(e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<?> handleTokenInvalidException(TokenInvalidException e){
        return ResponseEntity.status(UNAUTHORIZED_ERROR).body(e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<?> handleUserNotFoundException(UserNotFoundException e){
        return ResponseEntity.status(NOT_FOUND_ERROR).body(e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<?> handleRecommendNotFoundException(RecommendNotFoundException e){
        return ResponseEntity.status(NOT_FOUND_ERROR).body(e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<?> handleTokenExpiredException(TokenExpiredException e){
        return ResponseEntity.status(UNAUTHORIZED_ERROR).body(e.getMessage());
    }
    @ExceptionHandler
    public ResponseEntity<?> handleMemberAlreadyLogoutException(MemberAlreadyLogoutException e){
        return ResponseEntity.status(UNAUTHORIZED_ERROR).body(e.getMessage());
    }
}
  • @RestControllerAdvice는 ControllerAdvice와 ResponseBody를 합친 어노테이션이다.
  • 주로 Exception을 담당하는 클래스에 사용된다.
  • 컨트롤러에서 발생한 예외들을 처리해줄 수 있으며, 사용자가 예외 상황을 잘 알 수있게 도와준다.
  • @ExceptionHandler을 사용하였는데
    예를들어 NullPointException이 발생되었을때, 이를 클라이언트에 어떻게 Response할지 내가 Handling을 할 수 있게 도와준다.

느낀점?

엄청 방대한 지식들 중 내가 아는것은 극히 일부에 불과하다는걸 또 다시 느꼈다. 개발자라는 진로를 선택한 이유중 하나는 이런 모르는 것들을 하나하나 알게되는 과정이 꽤 재미있기 때문이었다.
또, 기능 요구사항이든, 어떤 개념이든 나에게 생각할 거리를 던져주는 것이 내 적성에 너무 잘 맞는 것 같다.

그러니까 천천히 성장 합시다! 돌석석돌.

향수추천서비스 깃허브

profile
기록하면서 성장하기!

0개의 댓글