[Spring Boot] OAuth 카카오 연동 인증과 로그인 (with next.js)

사이드 프로젝트를 하면서, 카카오 로그인을 구현하게 되어 다음에 또 헤매지 않도록 정리해 둔다.

spring security는 사용하지 않고, spring boot 기반으로 구현하였다.

 

아래는 카카오 개발자 공식 문서에 나와있는 oauth 기반 인증 과정이다.

https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api

과정을 간단하게 요약해보면,

1. 카카오 인증서버로부터 인가 코드를 받는다.

2. 받은 코드로 access, refresh Token을 받는다.

3. 받은 토큰으로 카카오 유저 정보를 가져온다.

4. 유저정보를 이용해 내 서비스에서 로그인/회원가입을 처리한다. (선택)

 

Resource Server - OAuth 2.0 서비스를 제공하고 Resource 를 관리 해주는 서버, ex) google, naver, kakao

 

Authorization Server - 사용자 정보에 접근 하기 위한 인증서버. 아이디와 비밀번호를 보내면, 인가 코드와 토큰을 얻을 수 있다. ex) google, naver, kakao

 

Resource Owner - 카카오 로그인을 하려는 사용자

 

Client - 서비스를 제공하는 애플리케이션 서버, 내 서비스

 

1. 애플리케이션 추가

https://developers.kakao.com/console/app

카카오 개발자 사이트에 들어가 애플리케이션을 생성한다.

 

2. 애플리케이션 설정

플랫폼에 서비스할 사이트 도메인을 추가한다. 로컬의 경우 localhost 추가하면 된다.

 

 

카카오 로그인을 활성화 시키고, redirect URL을 지정한다.

해당 URL로 인가 코드를 받아서 토큰을 발행 받는다.

 

 

동의 항목을 설정한다. 나는 닉네임과 이메일만 필요해서 두 항목만 동의 설정을 해 두었다.

 

 

앱 키는 이곳에서 확인할 수 있다.

 

 

3. Step 1 인가코드 받기

카카오에서 accessToken, refreshToken을 받기 위해선 우선 인가 코드라는 것을 먼저 받아야 한다.

https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#kakaologin

 

프론트에서 아래 Link 를 건다.

KAKAO_CLIENT_ID - 위에 있는 REST API 키 값

API_URL - 등록했던 리다이렉트 URL 중 하나

  const kakaoLoginUrl = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${API_URL}/oauth/redirect&response_type=code`;

 

이렇게 하면, redirect url로 인가 코드가 전달된다.

 

4. Step 2 토큰받기, Step 3 사용자 로그인 처리

인가 코드를 이용해 카카오 유저 정보에 접근할 수 있는 토큰을 받는다.

토큰을 사용해 사용자 로그인후 사용자 정보를 조회한다.

조회한 정보를 내 서비스 DB에 저장한다.

 

우선, DTO, Entity 를 정의한다.

@Data
@Builder
public class TokenDto {
	private String accessToken;
	private String refreshToken;
}

TokenDto.java

 

@Data
public class KaKaoTokenResponseDto {
	@JsonProperty("access_token")
	private String accessToken;
	@JsonProperty("token_type")
	private String tokenType;
	@JsonProperty("refresh_token")
	private String refreshToken;
	@JsonProperty("expires_in")
	private int expiresIn;

	@JsonProperty("scope")
	private String[] scopes;
	@JsonProperty("refresh_token_expires_in")
	private int refreshTokenExpiresIn;

	public void setScopes(String scopes) {
		this.scopes = scopes.split(" ");
	}
}

KaKaoTokenResponseDto.java

 

@Data
@Builder
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class User {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private int id;
	private String nickname;
	private String email;
	private String provider;
}

User.java

 

1. 인가 코드를 이용해 토큰을 받는다.

2. 받은 토큰으로 카카오 유저 정보를 받는다.

3. 받은 카카오 유저 정보를 내 DB에 저장한다.

4. 내 애플리케이션용 토큰을 발급하는 로직을 구현한다.

@Slf4j
@Service
@RequiredArgsConstructor
public class OAuthService {
	//github.com/jwtk/jjwt (JWTs 문서)
	private static final int EXPIRE_TIME = 24 * 60 * 60 * 1000;
	private final ObjectMapper objectMapper;
	private final UserRepository userRepository;
	@Value("${oauth.kakao.client-id}")
	private String clientId;
	@Value("${oauth.kakao.redirect-url}")
	private String redirectUrl;
	@Value("${jwt.secret}")
	private String secret;

	public KaKaoTokenResponseDto getOAuthToken(String code) {
		WebClient webClient = WebClient.builder()
			.baseUrl("https://kauth.kakao.com")
			.defaultHeader(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8")
			.build();

		try {
			return webClient.post()
				.uri(uriBuilder -> uriBuilder.path("/oauth/token")
					.queryParam("grant_type", "authorization_code")
					.queryParam("client_id", clientId)
					.queryParam("redirect_uri", redirectUrl)
					.queryParam("code", code)
					.build())
				.retrieve()
				.bodyToMono(KaKaoTokenResponseDto.class)
				.block();
		} catch (WebClientResponseException e) {
			log.error("[카카오 로그인 토큰 발급 실패] " + e.getMessage());
			throw e;
		}
	}

	public User getUserInfo(KaKaoTokenResponseDto kaKaoTokenResponseDto) throws Exception {
		WebClient webClient = WebClient.builder()
			.baseUrl("https://kapi.kakao.com")
			.defaultHeader(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8")
			.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + kaKaoTokenResponseDto.getAccessToken())
			.build();

		try {
			String res = webClient.post()
				.uri(uriBuilder -> uriBuilder.path("/v2/user/me").build())
				.retrieve()
				.bodyToMono(String.class)
				.block();

			// kakao 에서 받은 유저 정보 파싱
			JsonNode root = objectMapper.readTree(res);
			String nickname = root.path("kakao_account").path("profile").path("nickname").asText();
			String email = root.path("kakao_account").path("email").asText();
			return User.builder().nickname(nickname).email(email).provider("kakao").build();
		} catch (Exception e) {
			log.error("[카카오 로그인 유저 정보 가져오기 실패] " + e.getMessage());
			throw e;
		}
	}

	public TokenDto getAuthToken(String code) throws Exception {
		KaKaoTokenResponseDto kaKaoTokenResponseDto = getOAuthToken(code);
		User user = getUserInfo(kaKaoTokenResponseDto);
		if (userRepository.findByEmailAndProvider(user.getEmail(), "kakao").isEmpty()) {
			userRepository.save(user);
		}

		return makeToken(user);
	}

	public TokenDto makeToken(User user) {
		String accessToken = Jwts.builder()
			.subject(UUID.randomUUID().toString())
			.claim("email", user.getEmail())
			.claim("provider", user.getProvider())
			.issuedAt(new Date())
			// 1일
			.expiration(new Date(System.currentTimeMillis() + EXPIRE_TIME))
			.signWith(this.getSecret())
			.compact();

		String refreshToken = Jwts.builder()
			.subject(UUID.randomUUID().toString())
			.claim("email", user.getEmail())
			.claim("provider", user.getProvider())
			.issuedAt(new Date())
			// 1주일
			.expiration(new Date(System.currentTimeMillis() + EXPIRE_TIME * 7))
			.signWith(this.getSecret())
			.compact();

		log.info("acc: " + accessToken);
		log.info("res: " + refreshToken);

		return TokenDto.builder().accessToken(accessToken).refreshToken(refreshToken).build();
	}

	public boolean verifyToken(String token) {
		return Jwts
			.parser()
			.verifyWith(this.getSecret())
			.build()
			.parseSignedClaims(token)
			.getPayload().getExpiration().after(new Date());
	}

	public User getUserFromToken(String token) {
		Claims claims = Jwts
			.parser()
			.verifyWith(this.getSecret())
			.build()
			.parseSignedClaims(token)
			.getPayload();

		return this.userRepository.findByEmailAndProvider(claims.get("email").toString(),
				claims.get("provider").toString())
			.orElseThrow(() -> new ValidationException("유저를 찾을 수 없습니다."));
	}

	public String getToken(String token) {
		if (Objects.isNull(token)) {
			throw new ValidationException("토큰 인증 실패: 토큰값이 존재하지 않음");
		}

		String[] str = token.split(" ");
		if (str.length != 2 || !str[0].equals("Bearer")) {
			throw new ValidationException("지원하지 않는 토큰 타입입니다.");
		}
		return str[1];
	}

	private SecretKey getSecret() {
		byte[] bytes = Decoders.BASE64.decode(this.secret);
		return Keys.hmacShaKeyFor(bytes);
	}
}

OAuthService.java

 

getAuthToken 메서드가 첫 진입점이다.

getOAuthToken 메서드를 호출하는데, 여기서 받은 인가 코드를 이용해 카카오 토큰을 발급받는다.

그 다음 getUserInfo 메서드에서 발급받은 카카오 토큰으로 카카오 유저 정보를 가져온다.

유저정보를 이용해 내 DB를 조회하여 저장된 데이터가 있는지 확인하고, 저장되어 있지 않다면 저장한다.

유저정보를 기반으로, 내 서비스에서 사용할 토큰을 발급받는다.

 

이 외에 토큰 확인하는 메서드 등도 구현했다.

 

@RestController
@RequestMapping("/oauth")
@RequiredArgsConstructor
@Slf4j
public class OAuthController {
	private final OAuthService oAuthService;
	@Value("${fe.url}")
	private String feUrl;

	@Operation(summary = "oauth 로그인 리다이렉트", description = "oauth 로그인 리다이렉트(kakao)")
	@GetMapping("/redirect")
	public void redirect(@RequestParam("code") String code,
		HttpServletRequest request, HttpServletResponse response) throws Exception {
		TokenDto tokenDto = oAuthService.getAuthToken(code);

		// 리다이렉트 URL
		String redirectUrl = request.getHeader("referer");
		if (StringUtils.isEmpty(redirectUrl)) {
			redirectUrl = feUrl;
		}

		// 쿠키 허용 도메인
		URI uri = new URI(redirectUrl);
		String domain = uri.getHost();
		if (domain.startsWith("www")) {
			domain = domain.replace("www", "");
			log.info("check: " + domain);
		} else if (domain.startsWith("dev")) {
			domain = domain.replace("dev", "");
			log.info("check: " + domain);
		}

		String accessTokenCookie = ResponseCookie.from("accessToken", tokenDto.getAccessToken())
			.domain(domain)
			.path("/")
			.build()
			.toString();

		String refreshTokenCookie = ResponseCookie.from("refreshToken", tokenDto.getRefreshToken())
			.domain(domain)
			.path("/")
			.build()
			.toString();

		response.addHeader("set-Cookie", accessTokenCookie);
		response.addHeader("set-Cookie", refreshTokenCookie);

		response.sendRedirect(redirectUrl);
	}
}

OAuthController.java

 

우리 서비스가 개발서버 하나만 있다보니, 로컬에서 로그인할 경우 로컬로 리다이렉트 되도록 할 필요가 있었다.

레퍼러를 사용해서 리다이렉트할 url을 지정해주었다.

그리고 쿠키의 경우 도메인이 바뀌면 리다이렉트시 날라가버려서, 쿠키의 domain 으로 지정해 주었다.

로컬의 경우 localhost 를 사용하는게 아니라 로컬 도메인을 설정해서 요청할 경우 쿠키를 담아 보낼 수 있도록 구현하였다.

 

 

컨트롤러 로직이 좀 길고, OAuthService 라는 이름에 맞지않게 내 토큰 확인하는 메서드 등이 들어있다.

리팩토링이 필요한데, 급한건 아니라고 생각해서 일단 나중에 리팩토링을 하려고 한다.

나중에 볼때 아 이런느낌이구나 라는 정도만 참고하려고 정리해 둔다.