[Java + Spring] JWT 로 카카오 로그인 로직 구현하기

김유정·2024년 1월 20일
0
post-thumbnail

jwt를 구현하려고 검색해보니까 대부분 Spring Security를 사용해서 SecurityContext에 Authentication 객체를 저장하여 로그인 로직을 구현하는 글이었다. 그런데 꼭 Spring Security를 활용해야할까 의문이 들었다.

그래서 내가 구현하고자 하는 로그인 로직을 정리해봤다.

로직 정리

우리 서비스에서 필요한 건 <카카오 계정으로 로그인하려는 이 사용자가 진짜 카카오톡 유저인지 그리고 그게 맞다면 이 유저의 이름과 이메일은 무엇인지> 에 대한 정보이다.

  • 여기서 진짜 카카오톡 유저가 맞는지에 대한 "인증"은 프론트엔드쪽에서 처리하기로 했다.
  • 백엔드쪽에서는 인증받은 유저에 대한 카카오 엑세스토큰으로 유저 정보를 조회하여 로그인을 처리해야했다.
  • 카카오 엑세스 토큰은 로그인 또는 회원가입 시 카카오 유저 정보를 조회할 때만 사용한다. 이후 프론트엔드와 백엔드가 소통할 때 필요한 건 <이 유저가 로그인한 유저인지>에 대한 정보이다. 따라서 로그인한 이후에는 카카오톡 엑세스 토큰이 필요하지 않다.
  • 그렇다면, 로그인 이후 프론트엔드 측에서 "이 유저는 카카오톡으로 로그인했어!!"라는 정보를 넘겨줄 수 있도록 카카오톡에서 조회한 유저 정보에 인가 처리할 때 필요한 정보를 추가하여 엑세스 토큰을 만들어서 클라이언트에 전달하자.

이를 바탕으로 아래와 같은 흐름으로 구현하고자 한다.

로그인 or 회원가입 처리

로그인 이후 권한이 필요한 요청 처리

정리를 해보니 아래와 같은 구성으로도 충분히 간단하게 로그인을 구현할 수 있다고 생각하여 Spring Security를 사용하지 않고 구현했다.

  1. JwtService: 엑세스 토큰 및 리프레쉬 토큰 생성과 토큰 검증 메서드 구현
  2. UserService: 카카오 로그인 처리
  3. LoginInterceptor: 컨트롤러로 요청을 넘기기 전 헤더에 있는 토큰을 추출하여 토큰 검증 진행

구현 - 로그인 or 회원가입 처리

Jwt를 활용하여 로그인할 수 있도록 준비부터 해보자. 우선 토큰을 생성하고 검증할 수 있도록 메서드를 구현해야한다.

JwtService

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtService implements InitializingBean {

    private final RedisDao redisDao;
    private final JwtProperties jwtProperties;
    private long tokenValidityInMillySeconds;
    private long refreshTokenValidityInMillySeconds;
    private Key key;
    @Autowired
    RedisTemplate<String, String> redisTemplate;
    private ValueOperations<String, String> valueOperations;

    // application.yaml에 정의해놓은 jwt 관련 값들 세팅
    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecret());
        this.key = Keys.hmacShaKeyFor(keyBytes);
        tokenValidityInMillySeconds = jwtProperties.getTokenValidityInMinutes() * 60 * 1000;
        refreshTokenValidityInMillySeconds = jwtProperties.getRefreshTokenValidityInMinutes() * 60 * 1000;
        valueOperations = redisTemplate.opsForValue();
    }

    // 엑세스 토큰 생성
    public String createAccessToken(String mail) {
        Date validity = new Date(System.currentTimeMillis() + tokenValidityInMillySeconds);

        return Jwts.builder()
                .setSubject(mail)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }

    // 토큰 검증
    public void validateToken(String token) {
        if (redisDao.getValues(token) != null) {
            throw new CustomException(IS_TOKEN_LOGOUT);
        }
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("JWT 토큰이 잘못되었습니다.");
        }
    }

    // 토큰에서 유저 메일 정보 추출
    public String getUserEmail(String token) {
        Claims body = Jwts.parserBuilder().setSigningKey(key).build()
                .parseClaimsJws(token)
                .getBody();
        return body.getSubject();
    }

    // 리프레쉬 토큰 생성
    public String createRefreshToken(String mail) {
        Date validity = new Date(System.currentTimeMillis() + refreshTokenValidityInMillySeconds);
        String refreshToken = Jwts.builder()
                .setSubject(mail)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();

        String redisKey = REFRESH_TOKEN.getPrefix() + mail;
        valueOperations.set(redisKey, refreshToken);

        return refreshToken;
    }

    // 리프레쉬 토큰으로 엑세스토큰 재발급받기
    public String reissueAccessToken(String refreshToken) {
        validateToken(refreshToken);
        String mail = getUserEmail(refreshToken);
        String redisKey = REFRESH_TOKEN.getPrefix() + mail;
        if (refreshToken.equals(valueOperations.get(redisKey))) {
            return createAccessToken(mail);
        }
        return null;
    }

    // 헤더에서 토큰 추출하기
    public String getToken(NativeWebRequest request) {
        return getToken(Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION)));
    }

    // 헤더에서 토큰 추출하기
    public String getToken(Optional<String> authorization) {
        String[] splitAuthorization = authorization
                .orElseThrow(() -> new NullPointerException("header에 authorization 값이 없습니다."))
                .split(" ");

        return (splitAuthorization.length > 1) ? splitAuthorization[1] : splitAuthorization[0];
    }
}
JwtProperties
@Data
@Configuration
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {

    private String header;
    private String secret;
    private long tokenValidityInMinutes;
    private long refreshTokenValidityInMinutes;

}

이제 준비는 끝났다. 이걸 활용해서 카카오톡 로그인을 해보자.

UserController

@RestController
@RequiredArgsConstructor
@RequestMapping("/auth")
public class UserController {

    @PostMapping("/login/kakao")
    public CustomResponseEntity<UserResponse.Login> loginByKakao(@RequestParam String authorizationCode) {
        return CustomResponseEntity.success(
                userService.loginByKakao(authorizationCode)
        );
    }
    
}

UserService

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    private final JwtService jwtService;
    private final KakaoProperties kakaoProperties;
    private RestTemplate restTemplate = new RestTemplate();
    
    public UserResponse.Login loginByKakao(String authorizationCode) {
        // 1. 카카오 인가코드로 <엑세스 토큰> 받기
        KakaoTokenResponse kakaoTokenResponse = getKakaoToken(authorizationCode);
        // 2. 엑세스 토큰으로 카카오에서 유저 정보 조회
        KakaoUserResponse kakaoUserResponse = getKakaoUserInfo(kakaoTokenResponse);
        // 3. 회원가입 또는 로그인 처리
        Users user = saveOrUpdate(kakaoUserResponse);
        // 4. 엑세스 토큰 발급
        String accessToken = jwtService.createAccessToken(user.getEmail());
        // 5. 리프레쉬 토큰 발급
        String refreshToken = jwtService.createRefreshToken(user.getEmail());

        return UserLoginResponse.response(user, accessToken, refreshToken);
    }
    
    // 1. 카카오 인가코드로 <엑세스 토큰> 받기
    private KakaoTokenResponse getKakaoToken(String authorizationCode) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", kakaoProperties.getGrantType());
        params.add("client_id", kakaoProperties.getClientId());
        params.add("redirect_uri", kakaoProperties.getRedirectUri());
        params.add("code", authorizationCode);

        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
        return restTemplate.postForObject(
                kakaoProperties.getTokenUri(), request, KakaoTokenResponse.class
        );
    }

    // 2. 엑세스 토큰으로 카카오에서 유저 정보 조회
    private KakaoUserResponse getKakaoUserInfo(KakaoTokenResponse kakaoTokenResponse) {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.setBearerAuth(kakaoTokenResponse.getAccessToken());

        return restTemplate.postForObject(
                kakaoProperties.getUserInfoUri(), new HttpEntity<>(headers), KakaoUserResponse.class
        );
    }
    
    // 3. 회원가입 또는 로그인 처리
    private Users saveOrUpdate(KakaoTokenResponse kakaoTokenResponse) {
        String email = kakaoUserResponse.getKakaoAccount().getEmail();
        String name = kakaoUserResponse.getProperties().getNickname();
    
        Users user = userRepository.findByEmail(email)
                .map(entity -> entity.update(name))
                .orElse(toUserEntity(request));
        return userRepository.save(user);
    }

    private Users toUserEntity(UserRequest.Login request) {
        return Users.builder()
                .email(request.getEmail())
                .name(request.getName())
                .build();
    }
}
KakaoTokenResponse
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class KakaoTokenResponse {

    @JsonProperty("access_token")
    private String accessToken;
    @JsonProperty("token_type")
    private String tokenType;
    @JsonProperty("refresh_token")
    private String refreshToken;
    @JsonProperty("id_token")
    private String idToken;
    @JsonProperty("expires_in")
    private int expiresIn;
    private String scope;
    @JsonProperty("refresh_token_expires_in")
    private int refreshTokenExpiresIn;

}
KakaoUserResponse
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class KakaoUserResponse {

    private long id;
    @JsonProperty("has_signed_up")
    private boolean hasSignedUp;
    @JsonProperty("connected_at")
    private LocalDateTime connectedAt;
    private KakaoProperties properties;
    @JsonProperty("kakao_account")
    private KakaoAccount kakaoAccount;

    @AllArgsConstructor
    @NoArgsConstructor
    @Getter
    public static class KakaoProperties {
        private String nickname;
    }

    @AllArgsConstructor
    @NoArgsConstructor
    @Getter
    public static class KakaoAccount {
        @JsonProperty("profile_nickname_needs_agreement")
        private boolean profileNicknameNeedsAgreement;
        private KakaoProfile profile;
        @JsonProperty("has_email")
        private boolean hasEmail;
        @JsonProperty("email_needs_agreement")
        private boolean emailNeedsAgreement;
        @JsonProperty("is_email_valid")
        private boolean isEmailValid;
        @JsonProperty("is_email_verified")
        private boolean isEmailVerified;
        private String email;
    }

    @AllArgsConstructor
    @NoArgsConstructor
    @Getter
    public static class KakaoProfile {
        private String nickname;
    }

}
UserLoginResponse
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginResponse {
  private Long userId;
  private String email;
  private String name;
  private String accessToken;
  private String refreshToken;

  public static UserLoginResponse response(Users user, String token, String refreshToken) {
      return UserLoginResponse.builder()
              .userId(user.getUserId())
              .email(user.getEmail())
              .name(user.getName())
              .accessToken(token)
              .refreshToken(refreshToken)
              .build();
  }
}

구현 - 로그인 이후 권한이 필요한 요청 처리

LoginInterceptor

컨트롤러에 요청을 전달하기 전에 토큰이 유효한지 검증하기 위해 인터셉터를 만들었다.

토큰이 없다면, 검증은 진행하지 않는다.(권한이 필요한데 토큰을 넘기지 않는 문제에 대해서는 다른 곳에서 처리할 것이다.)

토큰이 유효하다면 true가 반환되어 컨트롤러 메서드가 실행될 것이고,
토큰이 유효하지 않다면, 예외가 발생하고 그걸 전역 예외 핸들러가 잡아서 클라이언트에 실패 코드와 메세지를 던질 것이다.

@Component
@RequiredArgsConstructor
public class LoginInterceptor implements HandlerInterceptor {

    private final JwtService jwtService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        if (request.getMethod().equals(HttpMethod.OPTIONS.toString())) {
            return true;
        }

        String authorization = request.getHeader(AUTHORIZATION);
        if (authorization != null) {
            String token = jwtService.getToken(Optional.of(authorization));
            jwtService.validateToken(token);
        }

        return true;
    }

}
CommonRestExceptionHandler(토큰 검증 시 발생 예외 처리)

토큰을 검증하는 과정에서 유효하지 않거나 만료되거나 기타 문제로 인해 예외가 발생하면, 해당 핸들러가 처리한다.

@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice
public class CommonRestExceptionHandler extends RuntimeException {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(
            {SecurityException.class, MalformedJwtException.class}
    )
    public CustomResponseEntity<String> securityExceptionHandler(
            SecurityException e, HttpServletRequest request
    ) {
        log.error("잘못된 JWT 서명입니다. | url: \"{}\", message: {}", request.getRequestURI(), e.getMessage());

        return CustomResponseEntity.fail("잘못된 JWT 서명입니다.");
    }

    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(ExpiredJwtException.class)
    public CustomResponseEntity<String> expiredJwtExceptionHandler(
            ExpiredJwtException e, HttpServletRequest request
    ) {
        log.error("url: \"{}\", message: {}", request.getRequestURI(), e.getMessage());

        return CustomResponseEntity.fail("토큰이 만료되었습니다.");
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(UnsupportedJwtException.class)
    public CustomResponseEntity<String> unsupportedJwtExceptionHandler(
            ExpiredJwtException e, HttpServletRequest request
    ) {
        String errorMessage = "지원되지 않는 JWT 토큰입니다.";
        log.error("url: \"{}\", message: {}", request.getRequestURI(), errorMessage);

        return CustomResponseEntity.fail(errorMessage);
    }

}

이제 토큰을 어떻게 검증할 건지까지는 구현했으니 컨트롤러에서 요청을 받아보자

GroupController

토큰 검증을 무사히 거쳐 컨트롤러에 왔다면, 이제 유저 정보를 조회하여 요청을 처리할 차례이다.

여기서 JwtService의 getUserEmail 메서드를 사용하여 유저 메일을 조회하고, 데이터베이스에서 유저 정보를 조회해도 되지만 흔하게 자주 사용될 로직이기에 @Login 어노테이션과 HandlerMethodArgumentResolver 활용하여 처리했다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/group")
public class GroupController {

    // 모임 생성 API
    @PostMapping("")
    public CustomResponseEntity<GroupCreateResponse> createGroup(
            @RequestBody @Valid GroupCreateRequest request, @Login Users user
    ) {
        return CustomResponseEntity.success(groupService.createGroup(request.toServiceRequest(), user));
    }
    
}

Login Annotation

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}

LoginArgumentResolver

@Login 어노테이션이 달린 파라미터는 해당 리졸버가 처리한 후 값을 주입해준다.

여기서 유저 정보를 조회하려면 토큰이 존재해야하기 때문에, resolverArgument 내에서 jwtService.getToken()으로 토큰을 조회한다. getToken 메서드는 헤더에 토큰 값이 없으면 예외를 발생시키고 전역 핸들러가 처리하여 클라이언트에 실패 코드와 메세지를 던질 것이다.

따라서 정리하자면, @Login 어노테이션이 붙은 User 파라미터가 존재하는 API는 반드시 헤더에 엑세스토큰을 담아 보내야하는 것이다.

@Component
@RequiredArgsConstructor
public class LoginArgumentResolver implements HandlerMethodArgumentResolver {

    private final JwtService jwtService;
    private final UserRepository userRepository;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginAnnotation = parameter.getParameterAnnotation(Login.class) != null;
        boolean isUserClass = parameter.getParameterType().equals(Users.class);

        return isLoginAnnotation && isUserClass;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        String token = jwtService.getToken(webRequest);
        String email = jwtService.getUserEmail(token);

        return userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("해당 유저는 존재하지 않습니다. email=" + email));
    }

}

이제 드디어 로그인부터 권한이 필요한 요청을 처리하는 것까지 모두 구현했다!

다음에는 Spring Security를 활용하여 로그인 로직을 구현해보고 비교해봐도 좋을 것 같다.

참고

0개의 댓글