@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;
}
}
}
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;
}
}
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을 발급해줘야 한다.
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);
}
}
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;
}
}
사실, 단순 구현만 해놓은 자료들이 많았기 때문에, 이를 이후에 어떻게 활용할 지 갈피를 잡기가 힘들었다.
그럼 로그인이 필요한 요청 마다, 컨트롤러에 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());
}
}
엄청 방대한 지식들 중 내가 아는것은 극히 일부에 불과하다는걸 또 다시 느꼈다. 개발자라는 진로를 선택한 이유중 하나는 이런 모르는 것들을 하나하나 알게되는 과정이 꽤 재미있기 때문이었다.
또, 기능 요구사항이든, 어떤 개념이든 나에게 생각할 거리를 던져주는 것이 내 적성에 너무 잘 맞는 것 같다.
그러니까 천천히 성장 합시다! 돌석석돌.