✒️ 들어가기 전
카카오 OAuth 로그인을 구현하는 방법에 대해 상세히 알아보자.
다음 글은 KAKAO DEVELOPLER 공식 문서를 참고하여 작성함.
https://developers.kakao.com/docs/latest/ko/kakaologin/common
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api
구현 코드
https://github.com/jinho7/6th_SPRING/commit/75637e4bed09b0eba2807db00a45c60f39b591f5
✒️ OAuth의 개념 이해
OAuth는 Open Authorization의 약자이다.
애플리케이션이 특정 시스템의 보호된 리소스에 접근하기 위해,
사용자 인증(Authentication) 을 통해 사용자의 리소스 접근 권한(Authorization)을 위임받는 것을 의미한다!
즉, 사용자 인증을 위한 개방형 표준 프로토콜이다.
OAuth는 카카오, 구글, 애플, 페이스북 등 다양한 회사에서 지원한다.
우리는 대표적으로 쓰는 [카카오 OAuth2.0]에 대해 알아볼 것이다.
이를 통해 Third-party 애플리케이션에서 리소스 소유자를 대신하여 리소스 서버에서 제공하는 자원에 대한 접근 권한을 얻을 수 있다!
OAuth2.0은 OAuth1.0에 비해 보안이 강화된 버전이다.
✒️ 카카오 로그인 방식의 이해
바로 구현하고 싶은 분들은 0단계로 이동
https://developers.kakao.com/docs/latest/ko/kakaologin/common#login-seq
위에서 사용자 클라이언트 / 서비스 서버는 우리가 흔히 말하는 프런트 / 백엔드 단이라고 생각하면 편하다.
그리고 카카오 인증 서버와 카카오 API 서버를 각각
카카오의 인증[로그인 처리]을 위한 서버 / 그리고 카카오의 백엔드 서버[사용자 정보 등을 가져옴] 라고 생각하면 편하다.
OAuth 흐름을 차근차근 살펴보자.
💡 STEP 1: 카카오 로그인
1.1 카카오 로그인 요청
사용자가 프런트 단에서 "카카오로 로그인" 버튼을 클릭한다.
이때 서비스 서버는 카카오 인증 서버로 리다이렉트 할 URL을 생성한다.
https://kauth.kakao.com/oauth/authorize? client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&response_type=code
1.2 인가 코드 받기 요청
사용자의 브라우저가 위 URL로 리다이렉트 된다.
카카오 인증 서버는 사용자에게 카카오 계정으로 로그인할 것을 요청한다.
1.3 로그인 및 동의
사용자는 해당 로그인 페이지에서 카카오 계정으로 로그인한다.
카카오는 사용자에게 서비스가 요청한 권한(예: 프로필 정보 접근)에 대한 동의를 구한다.
1.4 인가 코드 발급
사용자가 동의하면, 카카오 인증 서버는 사용자를 우리가 지정한 redirect_uri로 리다이렉트시킨다.
우리는 redirect_uri을 스프링 서버 쪽으로 하여 처리한다.
(위와 같이 localhost:8080/~)
그렇다는 것은?????
사용자가 카카오 로그인을 완료하면, 카카오는 사용자를 이 스프링 서버 URL로 리다이렉트 시킬 것이고,
이 URL에는 인증 코드(authorization code)가 쿼리 파라미터로 포함된다.
https://your-redirect-uri?code=AUTHORIZATION_CODE
스프링 서버는 이 인증 코드를 받아 처리한다.
서버로 리다이렉트하여 코드 검증이 이루어지면 좋은 점은 뭘까?
보안성과 편리함이다.
인증 코드가 프런트엔드를 거치지 않고 직접 백엔드로 전달된다는 점과,
백엔드에서 한 번에 간편하게 처리할 수 있다는 점이 좋다.
[ 하지만 요즘 웹 애플리케이션에서는 프런트엔드로 리다이렉트하여 OAuth 인증을 처리하는 방식을 선호한다. ]
[ 이유는 아래에 서술해 두겠다. ]
프런트엔드와 백엔드가 분리된 구조에서는 사용자 경험 면에서 약간의 제한이 있을 수 있다.
프론트엔드와 백엔드가 분리된 구조에서
프런트엔드로 리다이렉트 하게 되면, 사용자 경험을 더 부드럽게 만들 수 있다.
예를 들어, 프런트엔드로 리다이렉트되면 페이지 전체를 새로고침하지 않고도 로그인 프로세스를 완료할 수 있다.
그래서 많은 개발자들이 프론트엔드로 인가 코드를 받은 후, 다시 백엔드로 전송하는 방식을 선택한다고 한다.
또한, JWT를 사용과도 관련이 있다.
JWT를 사용할 때는 이 방식이 REST API의 철학과도 잘 맞고, 사용자 경험도 더 부드럽게 만들 수 있다.
JWT를 포함한 로직을 생각해 보자.
프런트엔드가 인증 코드를 받아 백엔드로 보내면, 백엔드는 이를 검증하고 JWT를 생성해 반환한다.
( 한 번 더 요청을 보내야 하지만, 오히려 로직이 분리되는 면이 좋다는 의견도 있다. )
다만 우리는 현재 테스트로 OAuth를 서버에서 구현 중이며,
직접 프런트를 구현할 수 없으므로, 백엔드로 리다이렉트 하는 방식으로 진행한다.
1.5 토큰으로 교환 요청
서비스 서버는 이 인가 코드를 이용해 카카오 인증 서버에 액세스 토큰을 요청한다.
POST https://kauth.kakao.com/oauth/token
Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&code=AUTHORIZATION_CODE
1.6 토큰 발급
카카오 인증 서버는 유효한 요청이면 액세스 토큰과 리프레시 토큰을 발급한다.
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{
"token_type":"bearer",
"access_token":"${ACCESS_TOKEN}",
"expires_in":43199,
"refresh_token":"${REFRESH_TOKEN}",
"refresh_token_expires_in":5184000,
"scope":"account_email profile"
}
참고로 AccessToken의 유효기간은 Android, iOS: 12시간 / JavaScript: 2시간 / REST API: 6시간,
RefreshToken의 유효기간은 2달, 만료 시간 1달 남은 시점부터 갱신 가능 이다.
💡 STEP 2: 회원 확인 및 가입 / 로그인
2.1 토큰으로 사용자 정보 가져오기
서비스 서버는 발급받은 액세스 토큰을 이용해 카카오 API 서버에 사용자 정보를 요청한다.
GET https://kapi.kakao.com/v2/user/me
Authorization: Bearer ACCESS_TOKEN
2.2 회원 여부 확인
카카오에서 받은 사용자 정보 중 카카오 계정의 고유 ID를 이용해
(식별자로써 중복 불가능하다면 email 등을 활용해도 된다.)
해당 사용자가 이미 여러분의 서비스에 가입되어 있는지 확인한다.
이러한 고유 식별자 사용자 정보를 통해 DB를 검색 후,
가입된 사용자라면 로그인을 해주고
아니라면 회원 가입을 해준다.
(참고로 저희에게 로그인이란 jwt를 client에게 보내는 것)
2.2.1 신규 사용자 회원 가입 처리
만약 신규 사용자라면, 카카오에서 받은 정보를 바탕으로 우리 서비스에 회원가입 시킨다.
이때 추가 정보 입력이 필요하다면 사용자에게 추가 정보를 요청할 수 있다.
곧이어 생성된 계정으로 로그인 처리를 한다.
[JWT를 생성하여 클라이언트에게 반환]
2.2.2. 기존 사용자 로그인 처리
로그인 처리!!!
JWT(JSON Web Token)를 생성하여 클라이언트에게 반환.
✒️ 0. 구현 전 Kakao Developer Setting
💡 애플리케이션 생성
https://developers.kakao.com/console/app
우선, 해당 Kakao Developer에서 애플리케이션을 추가해주어야 한다.
그리고 다음과 같이 알맞은 정보를 입력하고 저장을 누른다.
💡 Web 플랫폼 등록
그리고 플랫폼으로 이동하여, 사이트 도메인을 추가시킨다.
Local에서 테스트 할 예정이기에
http://localhost:8080 를 등록해준다.
실제 배포를 했다면, 서버의 도메인 URI 중 하나를 등록해주자.
아래처럼 등록되면 성공한 것이다.
💡 카카오 로그인 활성화
일단 우선은, 카카오 로그인을 누르고,
[카카오 로그인] 을 활성화 해주자!
💡 Redirect URI 설정
그리고 아까 길게 설명했던 Redirect URI를 설정해주자.
(아까 설명대로, 만약 프런트 쪽으로 인가 코드를 받고 싶으면 프런트 쪽 도메인으로 설정하면 된다.)
(주소 여러 개 설정도 가능하다.)
http://localhost:8080/auth/login/kakao
http://localhost:8080/auth/kakao/callback,
http://localhost:8080/callback
등으로도 많이 쓴다. ~ 편한대로! 일관성만 지켜주자!
💡 동의 항목 설정
이제 실제 개발에 앞서, 마지막으로 사용자 정보를 요청하기 위해 동의 항목까지 설정해주자!
'사용 안함'으로 되어있는 닉네임과 카카오 계정을 '필수 동의'로 변경하여 받아올 수 있게끔 해주자!
그러나, 닉네임과 프로필 사진을 제외한 나머지 개인정보 항목에 대해서는 카카오 내의 검수가 필요하다.
사업자 정보를 등록하거나, 비즈니스 인증(비즈앱 전환) 을 완료하면, 권한이 필요한 동의항목에 대한 심사를 신청할 수 있다.
아까 회원가입 여부를 판단할 때,
카카오 계정의 고유 ID를 이용해 판단한다고 하였다.
허나, 그렇다면 kakao_id 또한 DB에 저장해야 한다.
그래도 상관은 없지만, 식별자로써 email 등을 활용해도 된다.
🚨 주의 : 닉네임은 Unique한 값이 아니기 때문에 식별자로 사용할 수 없다.
테스트 단계에서 사업자 등록을 하기에는 너무 어렵다...
그럼 email까지는 받아보고 싶다면 어떻게 해야 할까?
다행히 비즈앱 등록만 한다면 email은 받아볼 수 있다.
💡 비즈 앱 전환 - 이메일 수집
해당 과정은 필수가 아니지만,
이왕 email을 받아온 김에 식별자로써 email을 활용해서 이후에 DB에서 user를 찾아보겠다!
- 앱 만약 이미지를 등록하지 않았다면, 해당 화면이 뜰 것이다.
- 만약 이미지를 등록한 상태라면, 아래 [개인 개발자 비즈 앱 전환]을 눌러주자.
이렇게 하면 이메일 수집이 활성화 된 모습을 볼 수 있다!
-> 필수 동의로 변경해주자.
✒️ 1. 로그인 요청 / 인가 코드 받기
kakao developers의 문서를 보면
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-code-sample
다음과 같은 요청을 통해 인가 코드를 받을 수 있다고 적혀있다.
https://kauth.kakao.com/oauth/authorize?
response_type=code&client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}
요청 주소를 그대로 복사해서 주소 창에 붙여주면 된다.
REDIRECT_URI 는 아까 설정한 http://localhost:8080/auth/login/kakao 이며,
REST_API_KEY는 홈페이지에서 확인할 수 있다.
<이 KEY는 노출되면 안된다!!>
만약 프런트단을 구현하고 싶다면, thymeleaf로 간단히 버튼을 구현해보자.
https://developers.kakao.com/docs/latest/ko/kakaologin/design-guide
해당 링크를 참고하여, 로그인 버튼 하나 만들어 놓고
-> 로그인 버튼을 누르면 요청 주소로 넘어가게 하면 된다.
어찌 됐든, 해당 링크를 복사하여
이 링크로 이동해보면
친숙한 카카오 로그인 화면이 맞이한다.!!
테스트를 여러 번 하면, 캐시 때문에 자동 로그인 되니 >> 캐시를 삭제하거나 시크릿 모드에서 진행해보자~
해당 링크로 들어가 보면, 초기에 설정했던 동의항목과 함께 로그인을 진행할 수 있다.
로그인!!!!!!!!!
아까 '실제 URI에는 인증 코드(authorization code)가 쿼리 파라미터로 포함될 것이다!' 라고 했다.
보이는 것과 같이 이 Redirect 주소로 인가 코드가 오게 된다.
✒️ 2. 인가 코드로 Token 요청하기
💡 필터 단에서 url 허용해 주기
스프링 시큐리티를 사용하는 환경에서 OAuth 콜백 URL을 처리하기 위해서는 해당 URL에 대한 인증을 우회해야 한다.
그래야 filter단에서 요청이 막히지 않고 controller단까지 오기 때문에 allowUrl에 추가해주자!
public static final String[] allowUrls = {
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**",
"/api/v1/posts/**",
"/api/v1/replies/**",
"/login",
"/auth/login/kakao/**"
};
아래 경로별 인가에서 풀어주자.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 다른 설정 중략
http
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers(ALLOWED_URLS).permitAll() // 허용 URL 설정
.anyRequest().authenticated() // 그 외 모든 요청은 인증 필요
)
.csrf().disable() // CSRF 보호 비활성화 (API 서버의 경우)
.formLogin().disable() // 폼 로그인 비활성화
.httpBasic().disable(); // HTTP Basic 인증 비활성화
return http.build();
}
}
💡 AuthController (Redirect_URI을 처리하는 컨트롤러)
이제 컨트롤러단을 만들어보자.
일단, 앱에 등록된 Redirect URI에 전달된 code를 전달받아야 한다.
그렇기에 이렇게 코드를 받으면 된다.
[RequestParm인 "code" 값을 받아오면 된다!]
RestController
@RequiredArgsConstructor
@RequestMapping("")
public class AuthController {
@GetMapping("/auth/login/kakao")
public ResponseEntity<?> kakaoLogin(@RequestParam("code") String accessCode, HttpServletResponse httpServletResponse) {
}
}
일단 틀만 만들어 놓았다.
이제 받아온 인가 코드로 토큰을 요청하면 된다.
kakao developer의 문서를 보면
POST | https://kauth.kakao.com/oauth/token 주소로 다음과 같은 header와 body를 가지고 요청하라고 되어 있다.
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#request-token
이걸 참고해서 코드를 짜보자!
🚨 그러나 코드를 작성하기 전 clientId는 유출되면 안 되기 때문에, yml에 설정해주고 사용하자.
kakao:
auth:
client: dc3741c4aaad93067048db400f8fff38
redirect: http://localhost:8080/auth/login/kakao
일단, KakaoUtil 에 선언만 해두자!
@Component
@Slf4j
public class KakaoUtil {
@Value("${spring.kakao.auth.client}")
private String client;
@Value("${spring.kakao.auth.redirect}")
private String redirect;
본격적으로 구현을 시작해보자!
💡 AuthController
@GetMapping("/auth/login/kakao")
public BaseResponse<UserResponseDTO.JoinResultDTO> kakaoLogin(@RequestParam("code") String accessCode, HttpServletResponse httpServletResponse) {
User user = authService.oAuthLogin(accessCode, httpServletResponse);
return BaseResponse.onSuccess(UserConverter.toJoinResultDTO(user));
}
컨트롤러 단에는 Service의 메서드만 적어주고 자세히 구현해보자!
구체적인 로직은 Service와 Util로 위임한다.
*참고: 다음과 같은 기준으로 코드를 분리했다.
서비스 (Service)
- 비즈니스 로직을 포함
- 도메인 모델과 밀접하게 연관
- 트랜잭션 관리
- 다른 서비스나 리포지토리와 상호작용
유틸리티 (Utility)
- 재사용 가능한 헬퍼 메서드 제공
- 특정 도메인에 종속되지 않음
- 상태를 가지지 않는 정적 메서드가 주를 이룸
- 외부 API 호출이나 특정 기능에 특화된 로직 포함
💡 AuthService
@Service
@RequiredArgsConstructor
public class AuthService {
private final KakaoUtil kakaoUtil;
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
@Override
public User oAuthLogin(String accessCode, HttpServletResponse httpServletResponse) {
KakaoDTO.OAuthToken oAuthToken = kakaoUtil.requestToken(accessCode);
}
인가 코드로 Token 요청하는 자세한 로직은 Util에 작성하자.
💡 KakaoUtil
실제 인가 코드로 Token 요청하는 로직이 있는 메서드이다!
그 전에.
서버는 기본적으로 클라이언트의 요청을 받아 리소스에 접근하고, 작업을 수행하는 곳이다.
만약 서버에서 요청을 보내야 한다면 어떤 툴을 사용하면 좋을까??
RestTemplate과 WebClient는 모두 HTTP 요청을 보내는 데 사용될 수 있는 Spring의 클라이언트들이다.
OAuth 인증과 같은 간단한 HTTP 요청의 경우, RestTemplate으로도 충분히 구현 가능하기에
해당 포스팅에서는 RestTemplate으로 구현해보도록 하겠다.
하지만 애플리케이션의 다른 부분에서 비동기 처리가 필요하거나,
미래의 확장성을 고려한다면 WebClient를 선택하는 것이 좋을 수 있다.
서비스 요구사항에 맞춰서 사용하자.
요즘에는 '선언적 방식 (어노테이션 등 사용)', '간편한 사용' 등의 이유로
MSA환경에서는 Feign Client가 많이 쓰인다고 한다. (Spring Cloud 의존성 필요)
[RestTemplateConfig]
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
List<HttpMessageConverter<?>> messageConverters = new ArrayList<>();
messageConverters.add(new FormHttpMessageConverter());
restTemplate.setMessageConverters(messageConverters);
return restTemplate;
}
}
@Component
@Slf4j
public class KakaoUtil {
@Value("${spring.kakao.auth.client}")
private String client;
@Value("${spring.kakao.auth.redirect}")
private String redirect;
public KakaoDTO.OAuthToken requestToken(String accessCode) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
그 후 Kakao Developer에서 잘 명시되어 있던 header 형식과 body 형식에 맞추어 코드를 작성해주자.
public KakaoDTO.OAuthToken requestToken(String accessCode) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", client);
params.add("redirect_url", redirect);
params.add("code", accessCode);
만들어 놓은 body와 header를 연결하여 준다!
HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest = new HttpEntity<>(params, headers);
그 다음 restTemplate으로 아까 위에서 보았었던 요청들로 request를 보낸다.
ResponseEntity<String> response = restTemplate.exchange(
"https://kauth.kakao.com/oauth/token",
HttpMethod.POST,
kakaoTokenRequest,
String.class);
카카오 API가 반환하는 JSON 형식의 응답 데이터를 보다 쉽게 다루기 위해,
이 JSON 데이터 구조에 맞는 Java 클래스(DTO) 를 만들어야 한다!
이에 맞추어 아래와 같이 DTO를 만들어준다!
(이대로 쭉 쭉 따라가면 아주 쉽다~)
💡 KakaoDTO
public class KakaoDTO {
@Getter
public static class OAuthToken {
private String access_token;
private String token_type;
private String refresh_token;
private int expires_in;
private String scope;
private int refresh_token_expires_in;
}
[다시 이어서 KakaoUtil.requestToken]
그럼 이제 response로 받은 것을 ObjectMapper를 통해 해당 DTO 클래스로 옮겨 담아 보자.
ObjectMapper objectMapper = new ObjectMapper();
KakaoDTO.OAuthToken oAuthToken = null;
try {
oAuthToken = objectMapper.readValue(response.getBody(), KakaoDTO.OAuthToken.class);
log.info("oAuthToken : " + oAuthToken.getAccess_token());
} catch (JsonProcessingException e) {
throw new AuthHandler(ErrorStatus._PARSING_ERROR);
}
return oAuthToken;
}
여기까지 인가 코드로 Token 받아오는 "requestToken" 메서드 작성이 끝났다.
테스트용으로 로깅을 해놓았다.
토큰을 출력해보자.
log.info("oAuthToken : " + oAuthToken.getAccess_token());
토큰이 잘 오는 것을 알 수 있다.
✒️ 3. Token으로 사용자 정보 요청하기
아까 동의 항목으로 설정해 둔 "닉네임", "프로필 사진", "이메일"을 가져오자.
💡 Token으로 사용자 정보 요청하기
우리가 어디까지 왔는지 중간 점검을 해보자면,
이제 아래 그림 부분을 수행할 차례이다.
그렇다면 다시, 공식문서를 참고해보자.
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info
그리고 공식문서 내용에 "엑세스 토큰"이나 "서비스 앱 어드민 키"를 사용하여 정보를 가져올 수 있다고 한다.
우리는 엑세스 토큰 방식으로 구현해보자.
GET | https:// kapi.kakao.com/v2/user/me 을 통해 정보를 가져올 것이다.
헤더부터 차근차근 구현해보자.
💡 AuthService [추가]
@Service
@RequiredArgsConstructor
public class AuthService {
private final KakaoUtil kakaoUtil;
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
@Override
public User oAuthLogin(String accessCode, HttpServletResponse httpServletResponse) {
KakaoDTO.OAuthToken oAuthToken = kakaoUtil.requestToken(accessCode);
KakaoDTO.KakaoProfile kakaoProfile = kakaoUtil.requestProfile(oAuthToken);
requestToken 메서드 아래에 이번에는
reqeustProfile 메서드를 적고, 이를 Util에서 구현해보자.
💡 KakaoUtil [추가]
public KakaoDTO.KakaoProfile requestProfile(KakaoDTO.OAuthToken oAuthToken){
RestTemplate restTemplate2 = new RestTemplate();
HttpHeaders headers2 = new HttpHeaders();
headers2.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
headers2.add("Authorization","Bearer "+ oAuthToken.getAccess_token());
HttpEntity<MultiValueMap<String,String>> kakaoProfileRequest = new HttpEntity <>(headers2);
ResponseEntity<String> response2 = restTemplate2.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.GET,
kakaoProfileRequest,
String.class);
아까와 같이 공식 문서의 내용을 참고하여 요청을 보내주면 된다.
응답을 확인해보자.
🚨 [KakaoAccount와 Profile이 중첩구조로 되어있어 조금 헷갈릴 수 있으니 주의하자.]
아까와 같이
카카오 API가 반환하는 JSON 형식의 응답 데이터를 보다 쉽게 다루기 위해,
이 JSON 데이터 구조에 맞는 Java 클래스(DTO) 를 만들어 주자!
공식문서에 있는 모든 항목을 전부 DTO로 만들지는 않겠다.
문자열로 받아온 response2를 출력해 보고, 오는 정보들을 확인한 뒤 만들어보자.
response2를 logging 해보면 다음과 같은 정보들이 String으로 주루르르르륵~ 오게 된다.
보기 매우 불편하므로... JsonParser를 통해 확인해보자.
>> 이 내용들을 토대로 DTO를 만들어주자.
참고로 아까 만들었던 OAuthToken과 지금 만드는 KakaoProfile 모두 KakaoDTO 안에 만들었다.
지금은 응답을 정리하는 모습을 보여주기 위해서 모든 필드를 놔두었지만,
JsonIgnoreProperty를 사용하면 필요한 것만 받을 수도 있다.
public class KakaoDTO {
@Getter
public static class OAuthToken {
private String access_token;
private String token_type;
private String refresh_token;
private int expires_in;
private String scope;
private int refresh_token_expires_in;
}
@Getter
public static class KakaoProfile {
private Long id;
private String connected_at;
private Properties properties;
private KakaoAccount kakao_account;
@Getter
public class Properties {
private String nickname;
}
@Getter
public class KakaoAccount {
private String email;
private Boolean is_email_verified;
private Boolean has_email;
private Boolean profile_nickname_needs_agreement;
private Boolean email_needs_agreement;
private Boolean is_email_valid;
private Profile profile;
@Getter
public class Profile {
private String nickname;
private Boolean is_default_nickname;
}
}
}
}
그리고 이어서 마무리해주자.
try {
kakaoProfile = objectMapper.readValue(response2.getBody(), KakaoDTO.KakaoProfile.class);
} catch (JsonProcessingException e) {
log.info(Arrays.toString(e.getStackTrace()));
throw new AuthHandler(ErrorStatus._PARSING_ERROR);
}
return kakaoProfile;
}
로깅을 통해 kakaoProfile.getKakao_account()를 출력해보면 -> 로그인한 카카오 계정의 이메일이 출력될 것이다.
✒️ 4. 회원 가입 / 로그인
받아온 이메일로 기존에 있던 회원인지 확인한 뒤,
있다면 회원가입
아니라면 로그인을 시켜 보자.
먼저 userDB에 접근하기 위한 userRepository를 만들자.
이해하기에는 email이 존재하는 지 확인하는 [existsByEamil]를 한 뒤,
분기에 따라 로직을 작성해면 더 좋지만, 여러가지 이유로 Optional을 사용하는 방식으로 진행하겠다.
💡 UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
그 후 분기에 따라 로그인, 회원 가입 로직을 짜주면 된다.
💡 AuthService [추가]
@Override
public User oAuthLogin(String accessCode, HttpServletResponse httpServletResponse) {
KakaoDTO.OAuthToken oAuthToken = kakaoUtil.requestToken(accessCode);
KakaoDTO.KakaoProfile kakaoProfile = kakaoUtil.requestProfile(oAuthToken);
String email = kakaoProfile.getKakao_account().getEmail();
User user = userRepository.findByEmail(email)
.orElseGet(() -> createNewUser(kakaoProfile));
String token = jwtUtil.createAccessToken(user.getEmail(), user.getRole().toString());
httpServletResponse.setHeader("Authorization", token);
return user;
}
private User createNewUser(KakaoDTO.KakaoProfile kakaoProfile) {
User newUser = AuthConverter.toUser(
kakaoProfile.getKakao_account().getEmail(),
kakaoProfile.getKakao_account().getProfile().getNickname(),
null,
passwordEncoder
);
return userRepository.save(newUser);
}
회원가입 / 로그인 부분을 나누어 조금 더 자세히 설명해보겠다.
[회원가입]
OAuth를 통한 소셜 로그인을 사용할 때,
일반적으로 우리 서비스에서 직접적인 비밀번호는 필요하지 않다.
(어차피 로그인 처리는 카카오 쪽에서 해결)
그러나 일반 회원가입이 있다면, DB의 User 스키마에 password가 있을 것이므로,
OAuth를 통해 생성된 사용자일 경우 null값을 넣어주는 것이 일반적이다.
또한, 만약 추가 정보를 받을 필요가 있는 경우, 따로 추가 정보를 받는 페이지를 사용자에게 제공해주어야 한다.
💡 AuthConverter
public class AuthConverter {
public static User toUser(String email, String name, String password, PasswordEncoder passwordEncoder) {
return User.builder()
.email(email)
.role("ROLE_USER")
.password(passwordEncoder.encode(password))
.name(name)
.build();
}
}
[로그인]
이제 로그인 완료 >> JWT Return 만 해주면 완료가 된다.
보통 RefreshToken까지 활용할 경우 body로 보내주지만,
간단하게 header에 넣어서 보내 보겠다.
response의 헤더에 넣는 방법은 많은데, 이번에는 httpServletResponse를 써보겠다.
String token = jwtUtil.createAccessToken(user.getEmail(), user.getRole().toString());
httpServletResponse.setHeader("Authorization", token);
✒️ 5. 클라이언트 쪽에서 인가 코드를 보내는 방식으로 변경해보기
💡 Redirect URI 변경
클라이언트 URI 만 추가해준다! (줄바꿈으로 여러 주소를 써도 된다.)
💡 Web 플랫폼 도메인 수정
💡 yml Redirect 주소 변경
3000으로 변경
kakao:
auth:
client: dc3741c4aaad93067048db400f8fff38
redirect: http://localhost:3000/auth/login/kakao
>> 다시 테스트를 해보자.
방금 설정한 3000 포트로 변경해서 테스트해보자.
예상했던 대로 3000/ ~~ 뒤의 파라미터로 인가 코드가 오게되고
해당 인가 코드를 복사해서 postman으로 쏴주면 된다. [테스트]
(이 작업을 실제는 프런트가 진행하게 될 것!)
이전에는 redirect 주소가 localhost:8080/auth/login/kakao 으로 되어 있어서, 우리 서버쪽에서 받을 수 있었던 것!!!
✒️ [전체 코드]
💡 application.yml
kakao:
auth:
client: dc3741c4aaad93067048db400f8fff38
redirect: http://localhost:3000/auth/login/kakao
💡 AuthController
@RestController
@RequiredArgsConstructor
@RequestMapping("")
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ResponseEntity<?> join(@RequestBody UserRequestDTO.LoginRequestDTO loginRequestDTO) {
return null;
}
@GetMapping("/auth/login/kakao")
public BaseResponse<UserResponseDTO.JoinResultDTO> kakaoLogin(@RequestParam("code") String accessCode, HttpServletResponse httpServletResponse) {
User user = authService.oAuthLogin(accessCode, httpServletResponse);
return BaseResponse.onSuccess(UserConverter.toJoinResultDTO(user));
}
}
💡 AuthService
@Service
@RequiredArgsConstructor
public class AuthService {
private final KakaoUtil kakaoUtil;
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
@Override
public User oAuthLogin(String accessCode, HttpServletResponse httpServletResponse) {
KakaoDTO.OAuthToken oAuthToken = kakaoUtil.requestToken(accessCode);
KakaoDTO.KakaoProfile kakaoProfile = kakaoUtil.requestProfile(oAuthToken);
String email = kakaoProfile.getKakao_account().getEmail();
User user = userRepository.findByEmail(email)
.orElseGet(() -> createNewUser(kakaoProfile));
String token = jwtUtil.createAccessToken(user.getEmail(), user.getRole().toString());
httpServletResponse.setHeader("Authorization", token);
return user;
}
private User createNewUser(KakaoDTO.KakaoProfile kakaoProfile) {
User newUser = AuthConverter.toUser(
kakaoProfile.getKakao_account().getEmail(),
kakaoProfile.getKakao_account().getProfile().getNickname(),
null,
passwordEncoder
);
return userRepository.save(newUser);
}
}
💡 KakaoUtil
@Component
@Slf4j
public class KakaoUtil {
@Value("${spring.kakao.auth.client}")
private String client;
@Value("${spring.kakao.auth.redirect}")
private String redirect;
public KakaoDTO.OAuthToken requestToken(String accessCode) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", client);
params.add("redirect_url", redirect);
params.add("code", accessCode);
HttpEntity<MultiValueMap<String, String>> kakaoTokenRequest = new HttpEntity<>(params, headers);
ResponseEntity<String> response = restTemplate.exchange(
"https://kauth.kakao.com/oauth/token",
HttpMethod.POST,
kakaoTokenRequest,
String.class);
ObjectMapper objectMapper = new ObjectMapper();
KakaoDTO.OAuthToken oAuthToken = null;
try {
oAuthToken = objectMapper.readValue(response.getBody(), KakaoDTO.OAuthToken.class);
log.info("oAuthToken : " + oAuthToken.getAccess_token());
} catch (JsonProcessingException e) {
throw new AuthHandler(ErrorStatus._PARSING_ERROR);
}
return oAuthToken;
}
public KakaoDTO.KakaoProfile requestProfile(KakaoDTO.OAuthToken oAuthToken){
RestTemplate restTemplate2 = new RestTemplate();
HttpHeaders headers2 = new HttpHeaders();
headers2.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
headers2.add("Authorization","Bearer "+ oAuthToken.getAccess_token());
HttpEntity<MultiValueMap<String,String>> kakaoProfileRequest = new HttpEntity <>(headers2);
ResponseEntity<String> response2 = restTemplate2.exchange(
"https://kapi.kakao.com/v2/user/me",
HttpMethod.GET,
kakaoProfileRequest,
String.class);
ObjectMapper objectMapper = new ObjectMapper();
KakaoDTO.KakaoProfile kakaoProfile = null;
try {
kakaoProfile = objectMapper.readValue(response2.getBody(), KakaoDTO.KakaoProfile.class);
} catch (JsonProcessingException e) {
log.info(Arrays.toString(e.getStackTrace()));
throw new AuthHandler(ErrorStatus._PARSING_ERROR);
}
return kakaoProfile;
}
}
💡 KakaoDTO
public class KakaoDTO {
@Getter
public static class OAuthToken {
private String access_token;
private String token_type;
private String refresh_token;
private int expires_in;
private String scope;
private int refresh_token_expires_in;
}
@Getter
public static class KakaoProfile {
private Long id;
private String connected_at;
private Properties properties;
private KakaoAccount kakao_account;
@Getter
public class Properties {
private String nickname;
}
@Getter
public class KakaoAccount {
private String email;
private Boolean is_email_verified;
private Boolean has_email;
private Boolean profile_nickname_needs_agreement;
private Boolean email_needs_agreement;
private Boolean is_email_valid;
private Profile profile;
@Getter
public class Profile {
private String nickname;
private Boolean is_default_nickname;
}
}
}
}