이번에 토이프로젝트를 진행하면서 스프링으로 처음 로그인 서비스를 만들었다. OAuth2를 통한 카카오 로그인을 구현하려 했는데, 처음엔 스프링 시큐리티를 사용해서 구현하려 했지만 이해가 잘되지 않았고 그래서 일단 시큐리티 없이 쌩으로 구현을 해보자! 라는 생각으로 만들게 됐다.
1. OAuth2 로그인의 흐름
OAuth2 로그인이 진행되는 과정을 살펴보자.
OAuth를 제공하는 서비스마다 다르겠지만, 이 흐름도는 카카오의 OAuth 흐름에 대해 그린 것이다.
- 사용자가 서비스를 이용하기 위해 로그인 창에 접근하면, 클라이언트는 사용자의 브라우저를 카카오 검증 서버로 리다이렉트 시킨다.
- 사용자가 정보를 입력하면 검증 서버는 입력한 정보가 일치하는지 검증한다.
- 일치한다면 클라이언트가 검증 서버에 등록한 정보제공 동의 목록에 동의 여부를 물어본다.
- 정보가 일치한다면 인증을 허가하는 인가 코드를 발급한다.
- 인가 코드를 서비스의 서버에 보내고, 서버에서는 이 인가 코드를 통해 검증 서버에 Access Token을 요청한다.
- 이 경우 인가 코드를 클라이언트가 받아 다시 서버로 인가 코드를 보내 서버가 처리하는 경우가 있고, 인가 코드 자체를 서버가 받아 바로 처리하는 경우가 있다. 이번에 만드는 프로젝트의 경우 전자에 해당했다.
- 검증 서버에서 받아온 Access Token으로 다시 검증 서버에 사용자가 제공에 동의한 정보를 받아온다.
- 받아온 정보로 로그인을 한다.
이렇게 설명할 수 있겠다.
2. 직접 해보기
위 흐름도와 Kakao Developer docs를 참고하여 OAuth 로그인을 만들어보자.
서버의 입장에서 로그인하는 과정은 다음과 같다.
1. 클라이언트에서 인가코드를 담은 GET 요청을 보낸다.
2. 요청에서 인가코드를 가져와 Kakao Auth Server에 Access Token을 요청한다.
3. 받아온 Access Token으로 Kakao Auth Server에 사용자의 정보를 요청한다.
4. 받아온 사용자의 정보(여기선 닉네임과 이메일)를 토대로 로그인을 완료한다.
코드를 살펴보자.
@Slf4j
@Service
@RequiredArgsConstructor
public class KakaoOAuthService {
private final KakaoAccessTokenRequest kakaoAccessTokenRequest;
/**
* 인가 코드를 받아서 kakao accessToken 을 발급. 하나의 Http object 를 만들어 kakao auth 서버로 요청 -> access token 받아오기.
* https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token
*/
public KakaoAccessTokenResponse getAccessToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8");
MultiValueMap<String, String> params = kakaoAccessTokenRequest.withCode(code);
RestTemplate rt = new RestTemplate();
HttpEntity<MultiValueMap<String, String>> accessTokenRequest = new HttpEntity<>(params, headers);
return rt.exchange(
"https://kauth.kakao.com/oauth/token",
HttpMethod.POST,
accessTokenRequest,
KakaoAccessTokenResponse.class
).getBody();
}
/**
* kakao access token 으로 유저 정보 받아오기.
* https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info
*/
public UserInfoResponse getUserInfo(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
headers.add(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8");
RestTemplate rt = new RestTemplate();
HttpEntity<MultiValueMap<String, String>> userInfoRequest = new HttpEntity<>(headers);
return rt.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.GET,
userInfoRequest,
UserInfoResponse.class
).getBody();
}
}
2-1. 인가코드를 통해 Access Token 받아오기
클라이언트로부터 인가코드를 받아와 Kakao Auth Server에 Access Token을 요청하는 로직을 살펴보자.
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token 에 들어가보면 인가코드로 Access Token을 어떻게 받아올 수 있는지 설명이 나와있다.
POST /oauth/token 요청을 https://kauth.kakao.com/v2/user/me 에 보낼 수 있도록 필요한 header, body를 담은 http object를 만드는 것이다.
2-2. Access Token으로 사용자의 정보 받아오기
마찬가지로 https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info 에 들어가보면 어떻게 사용자 정보를 받아올 수 있는지에 대한 설명이 나와있다.
물론 사용자 정보를 가져오기 위해서는 사전에 설정한 사용자 동의 항목에 대해서 사용자의 동의가 있어야 가져올 수 있다.
헤더에 Access Token을 담아 https://kapi.kakao.com/v2/user/me 에 요청을 보낸다.
2-3. 사용자의 정보를 토대로 로그인 완료하기
위 두 가지 메서드를 통해서 사용자 정보를 받아왔다면, 이제 이 정보를 토대로 로그인을 완성해야 한다.
먼저 코드를 보자.
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {
private final UserService userService;
private final JwtProvider jwtProvider;
private final KakaoOAuthService kakaoOAuthService;
@GetMapping("/login")
public UserResponseDto login(@RequestParam String code, HttpServletRequest request, HttpServletResponse response) {
log.info("USER LOGIN 실행. 인가 코드={}", code);
KakaoAccessTokenResponse accessToken = kakaoOAuthService.getAccessToken(code);
UserInfoResponse userInfo = kakaoOAuthService.getUserInfo(accessToken.getAccessToken());
KakaoAccount profile = userInfo.getKakaoAccount();
String nickname = profile.getNickName();
String email = profile.getEmail();
if (userService.isExistUserByEmail(email)) {
if (!jwtProvider.validateRefreshJwt(request)) {
log.info("이미 가입된 유저. 토큰 만료, 재발급 실행");
JwtToken token = jwtProvider.createToken(email);
response.setHeader("X-AUTH-TOKEN", token.getAccessToken());
response.setHeader("X-AUTH-REFRESH", token.getRefreshToken());
response.setHeader("X-AUTH-GRANT", token.getGrantType());
userService.setUserRefreshToken(email, token.getRefreshToken()); //refresh token 갱신
} else {
log.info("이미 가입된 유저. 토큰 유효. access token 재발급");
response.setHeader("X-AUTH-TOKEN", jwtProvider.reGenerateAccessToken(email));
}
return userService.getUserByEmail(email);
}
JwtToken token = jwtProvider.createToken(email);
response.addHeader("X-AUTH-TOKEN", token.getAccessToken());
response.addHeader("X-AUTH-REFRESH", token.getRefreshToken());
response.addHeader("X-AUTH-GRANT", token.getGrantType());
UserCreateDto newUser = new UserCreateDto(nickname, 1, email, token.getRefreshToken());
return userService.createUser(newUser);
}
}
GET /user/login?code={인가코드} 요청이 클라이언트로부터 들어오면, KakaoOAuthService의 getAccessToken, getUserInfo 메서드를 통해 사용자의 정보를 받아온다. 내 경우에는 닉네임과 이메일을 받아왔다.
이제 이메일을 key로 하여 jwt를 생성/발급 하여 로그인을 완성해준다. (jwt 발급 로직 및 정리, 아쉬운 점은 2편에 계속)