저번에 Form Login을 구현하였고, JWT토큰과 Spring Security를 활용해 인증과 인가 처리를 구현하였다. 이번에는 여기에 Social Login인 Kakao Login을 구현하려고 한다.
[Spring Boot] Spring Security Form Login + JWT 인증 구현
해당 Form Login + JWT 인증에서 덧붙여 구현하므로 해당 페이지에서 구현한 클래스들을 사용할 예정이다.
사용자(클라이언트)로 하여금 소셜 네트워킹 계정을 이용해 다른 웹사이트나 서비스에 쉽게 로그인할 수 있도록 하는 인증 방식이다. OAuth2.0 프로토콜을 통해 구현되며, 대표적인 provider로 구글, 페이스북, 깃허브, 네이버, 카카오 등이 있다.
일반적인 클라이언트-서버 관계가 아닌 개발 쪽이 클라이언트가 되어 카카오 인증 서버에게 요청을 보내 사용자의 정보를 가져와야 하는 것이다.
구글, 네이버, 카카오 등의 여러 소셜 로그인을 구현 가능하지만 일단은 카카오를 먼저 구현하여 감을 잡아야겠다고 생각했다.
후에 여러 provider들을 추가하여 하나의 인터페이스와 이에 여러 개의 provider의 구현체를 생성하는 객체지향적으로 구현해볼 계획이다.
참고로 oauth2.0 프로토콜을 사용할 수 있는 Spring OAuth2 Client 구현체가 존재하지만 이를 사용하지 않고 구현할 예정이다.
Spring OAuth2 Client에서 제공하는 인증 구현체인 DefaultOAuth2UserService
를 상속하여 사용할 수도 있지만 현재 폼로그인을 JWT인증을 이용하여 인증을 자체적으로 진행하기 때문에 OAuth2방식으로 카카오 사용자의 정보를 가져오는 로직만 구현할 것이다.
후에 언급할 환경변수들의 경로들은 Spring OAuth2 Client를 준수하도록 설정하였다. 후에 Spring OAuth2 Client를 사용하는 것을 염두에 두고 있기 때문이다.
다음과 같이 Kakao Developers에는 다양한 제품에 대한 사용 방법을 설명한다.
그 중 카카오 로그인의 REST API를 살펴보면 된다.
로그인을 구현하기 전, 개발하는 애플리케이션을 카카오 내 애플리케이션에 등록해야 한다.
앱 설정 >> 앱 키에서 위와 같은 키를 확인할 수 있다.
애플리케이션을 식별하는 키이다.
Web 애플리케이션을 개발하므로 REST API 키를 식별 키로 사용할 것이다.
앱 설정 >> 플랫폼에서 Web에 대한 도메일을 설정한다.
현재 사용하는 http://localhost:8080을 입력한다.
서비스 사용자가 카카오 로그인을 진행한 후 카카오 인증 서버에서 localhost:8080의 특정 url로 인증 코드를 전송하여 로그인 로직을 진행해야 한다.
이를 로그인 후 해당 url로 리다이렉트 되므로 Redirect URI라고 부른다.
제품 설정 >> 카카오 로그인에서 위 사진과 같이 Redirect URI를 설정할 수 있다.
만약 이를 프론트에서 처리한다면 프론트와 협의하여 localhost:3000
로 시작하는 URI를 입력하면 된다.
해당 주소로는 localhost:8080/oauth/kakao/callback?code=
로 code를 담아 리다이렉트된다.
Controller에 해당 URI에 맞는 핸들러를 생성해야 한다.
제품 설정 >> 동의 항목에서 가져올 사용자의 정보를 설정할 수 있다.
사용자는 로그인 시 해당 항목들을 사용하는 것에 동의를 진행하게 된다.
Service Client는 Resource Owner, Service Server는 Client, Kakao Auth Server는 Auth Server이다. 3개의 객체의 상호작용으로 카카오 로그인이 진행된다.
위에서 언급했듯이, 소셜 로그인은 카카오 인증 서버에서 사용자의 정보를 가져오는 과정이다.
과정을 간단히 요약하자면 다음과 같다.
더욱더 요약하자면 아래와 같이 요약할 수 있겠다.
사용자 로그인 → 인가 코드 → accessToken → 사용자 정보 받기
카카오로 로그인하기
를 클릭한 상태라고 가정해보자해당 API는 카카오 로그인 동의 화면을 호출하고, 사용자의 정보 제공 동의 후 인가 코드를 발급한다. 이때 인가 코드는 위에서 등록한 Redirect URI로 전송된다.
아래와 같이 구현 가능하다.
@Controller
@RequestMapping("/api/v1/oauth")
public class OAuth2Controller {
@Value("${spring.security.oauth2.client.registration.kakao.client-id}")
private String client_id;
@Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
private String redirect_uri;
@Value("${spring.security.oauth2.client.provider.kakao.authorization-uri}")
private String url;
@GetMapping("/kakao-login")
public String kakaoLogin() {
String redirect = url
+ "?client_id="
+ client_id
+ "&redirect_uri="
+ redirect_uri
+ "&response_type=code";
return "redirect:" + redirect;
}
}
서비스 사용자가 카카오로 로그인하기
버튼을 클릭했을 때 실행된다고 보면 된다. 이에 로그인 화면이 호출되고 로그인했음을 알리는 인가코드가 개발 서버로 넘어가게 된다.
url
에 ?
를 사용하여 쿼리 파라미터를 입력한다. 해당 API에서 필요한 파라미터는 아래와 같다.
또한 application.properties
에서 client_id, redirect_uri, url에 대한 변수들을 설정해 주어야 한다.
client_id | 애플리케이션 등록할 때 발급받은 REST API 키 |
---|---|
redirect_uri | 애플리케이션 등록할 때 설정한 Redirect URI |
response_type | code로 고정 |
url을 설정하고 return "redirect:" + redirect;
로 해당 url로 redirect되도록 설정하였다.
https://kauth.kakao.com/oauth/authorize?client_id=123412341234123412341234&redirect_uri=http://localhost:8080/oauth/kakao/callback&response_type=code
와 같은 형식으로 redirect 된다.
로그인을 성공적으로 마쳤다면 설정한 Redirect 주소(http://localhost:8080/oauth/kakao/callback
)로 인가 코드가 전달된다.
하지만 해당 주소에 해당하는 컨트롤러를 생성하지 않았으므로 로그인 후에는 오류 페이지가 뜰 것이다.
인가 코드를 받는 컨트롤러는 아래와 같다.
@RestController
@Slf4j
@RequiredArgsConstructor
public class KakaoLoginController {
private final KakaoLoginService kakaoLoginService;
/**
* 카카오 로그인 후 리다이렉트 주소인 /oauth/kakao/callback으로 받아온 AuthCode를 처리
* @param code
* @return
*/
@GetMapping("/oauth/kakao/callback")
public ResponseEntity<JwtInfoDto> callback(String code) {
KakaoTokenDto.Response kakaoTokenResponseDto = kakaoLoginService.getKakaoToken(code);
JwtInfoDto jwtInfoDto = kakaoLoginService.kakaoLogin(kakaoTokenResponseDto.getAccess_token());
return ResponseEntity.ok(jwtInfoDto);
}
}
해당 핸들러에서는 String code
로 인가코드를 받아와 JwtInfoDto
를 반환한다.
여기서 JwtInfoDto
는 카카오 인증 서버에서 응답으로 받은 것이 아니라 자체적으로 JwtAuthenticationFilter
를 구현하여 해당 filter에서 인증을 진행하기 위해 만든 JWT토큰dto이다.
즉, 위 핸들러에서 KakaoToken을 받아오고, 해당 Token을 통해 사용자 정보를 받아와 로그인을 진행한다.
KakaoTokenDto.Response kakaoTokenResponseDto = kakaoLoginService.getKakaoToken(code);
위 코드는 인가 코드로 카카오토큰을 받아오는 메서드이다. 이는 KakaoLoginService
에서 구현되었다.
해당 API는 인가코드로 토큰을 받아오는 API이다.
요청으로 필요한 파라미터는 위 사진과 같다.
아래와 같이 RestTemplate
을 사용하여 요청을 전송하고 받아오는 코드를 구현하였다.
(뒤에서 메서드들을 합쳐 KakaoLoginService
를 적을 예정이다. 아래 코드로는 오류가 날 수 밖에 없다)
public KakaoTokenDto.Response getKakaoToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.set("Content-type", content_type);
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
String grant_type = "authorization_code";
params.add("grant_type", grant_type);
params.add("client_id", client_id);
params.add("redirect_uri", redirect_uri);
params.add("code", code);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers);
ResponseEntity<KakaoTokenDto.Response> response = restTemplate.exchange(url, HttpMethod.POST, entity, KakaoTokenDto.Response.class);
return response.getBody();
}
KakaoTokenDto.Response
가 인가코드로 응답을 담을 객체이므로 API문서에 맞게 생성하였다.
public class KakaoTokenDto {
@Builder
@Getter
public static class Request{
private String grant_type;
private String client_id;
private String redirect_uri;
private String code;
}
@Builder @Getter @ToString
public static class Response{
private String token_type;
private String access_token;
private Integer expires_in;
private String refresh_token;
private Integer refresh_token_expires_in;
private String scope;
}
}
이와 같이 카카오 토큰을 받아왔다. 다시 KakaoLoginController
를 보자
JwtInfoDto jwtInfoDto = kakaoLoginService.kakaoLogin(kakaoTokenResponseDto.getAccess_token());
return ResponseEntity.ok(jwtInfoDto);
accessToken으로 kakaoLogin을 호출하여 이를 JwtInfoDto
로 받는다.
kakaoLogin
에서 사용자 정보를 가져와 JwtInfoDto를 만든다고 예상할 수 있다.
public JwtInfoDto kakaoLogin(String accessToken) {
KakaoInfoResponseDto userInfoResponseDto = getKakaoUserInfo(accessToken);
KakaoInfoResponseDto.KakaoAccount kakaoAccount = userInfoResponseDto.getKakaoAccount();
String email = kakaoAccount.getEmail();
System.out.println("email = " + email);
Member member;
Optional<Member> optionalMember = memberRepository.findByEmail(email);
if(optionalMember.isEmpty()) {
member = Member.builder()
.email(email)
.password("dummy data")
.type(Type.KAKAO)
.role(Role.USER)
.build();
memberRepository.save(member);
} else {
member = optionalMember.get();
}
return jwtUtil.createToken(member.toMemberInfoDto());
}
private KakaoInfoResponseDto getKakaoUserInfo(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
headers.set("Content-type", content_type);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(headers);
ResponseEntity<KakaoInfoResponseDto> userInfoResponseDto = restTemplate.exchange(get_info_url, HttpMethod.GET, entity, KakaoInfoResponseDto.class);
return userInfoResponseDto.getBody();
}
kakaoLogin
과 getKakaoUserInfo
가 있다, 먼저 kakaoLogin
을 보자
getKakaoUserInfo
를 통해 사용자 정보를 가져온다. 좀 더 자세한 설명은 밑에서 할 예정이다.
userInfoResponseDto
에서 email을 추출하여 해당 email을 가진 멤버를 찾는다.
해당 email을 가진 멤버가 없으면, 새로 member를 생성한다. 이때 소셜로그인은 비밀번호를 사용하지 않으므로, 더미 값을 넣어 처리하는 것으로 결정하였다.
member가 있으면 해당 member를 가져온다.
이제 member를 MemberInfoDto로 변환하여 jwtUtil
의 createToken
메서드를 호출하여 JwtInfoDto
를 반환한다.
이제 accessToken
으로 카카오 사용자 정보를 받아오는 getKakaoUserInfo
메서드를 구현해야 한다.
필요 파라미터는 헤더에 Bearer ${accessToken}
을 넣으면 된다.
응답 형식은 위와 같다.
위에서 언급했듯이 provider마다 응답 형식이 다르므로 문서를 확인해야 한다.
필자가 필요로 하는 email은 KakaoAccount 안에 있으므로 이를 dto로 변환하여 사용하였다.
@Getter
public class KakaoInfoResponseDto {
private Long id;
@JsonProperty("kakao_account")
private KakaoAccount kakaoAccount;
@Getter
public static class KakaoAccount {
private String email;
@JsonProperty("profile")
private Profile profile;
@Getter
public static class Profile {
private String nickname;
}
}
}
구현 메서드는 아래와 같다.
private KakaoInfoResponseDto getKakaoUserInfo(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
headers.set("Content-type", content_type);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(headers);
ResponseEntity<KakaoInfoResponseDto> userInfoResponseDto = restTemplate.exchange(get_info_url, HttpMethod.GET, entity, KakaoInfoResponseDto.class);
return userInfoResponseDto.getBody();
}
토큰 받을 때와 마찬가지로 RestTemplate
을 사용하여 구현하였다.
@Service
@Slf4j
@RequiredArgsConstructor
public class KakaoLoginService {
private final RestTemplate restTemplate = new RestTemplate();
private final MemberRepository memberRepository;
private final JwtUtil jwtUtil;
private final String content_type = "application/x-www-form-urlencoded;charset=utf-8";
@Value("${spring.security.oauth2.client.registration.kakao.client-id}")
private String client_id;
@Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
private String redirect_uri;
@Value("${spring.security.oauth2.client.provider.kakao.token-uri}")
private String url;
@Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}")
private String get_info_url;
public KakaoTokenDto.Response getKakaoToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.set("Content-type", content_type);
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
String grant_type = "authorization_code";
params.add("grant_type", grant_type);
params.add("client_id", client_id);
params.add("redirect_uri", redirect_uri);
params.add("code", code);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers);
ResponseEntity<KakaoTokenDto.Response> response = restTemplate.exchange(url, HttpMethod.POST, entity, KakaoTokenDto.Response.class);
return response.getBody();
}
public JwtInfoDto kakaoLogin(String accessToken) {
KakaoInfoResponseDto userInfoResponseDto = getKakaoUserInfo(accessToken);
KakaoInfoResponseDto.KakaoAccount kakaoAccount = userInfoResponseDto.getKakaoAccount();
String email = kakaoAccount.getEmail();
System.out.println("email = " + email);
Member member;
Optional<Member> optionalMember = memberRepository.findByEmail(email);
if(optionalMember.isEmpty()) {
member = Member.builder()
.email(email)
.password("dummy data")
.type(Type.KAKAO)
.role(Role.USER)
.build();
memberRepository.save(member);
} else {
member = optionalMember.get();
}
return jwtUtil.createToken(member.toMemberInfoDto());
}
private KakaoInfoResponseDto getKakaoUserInfo(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
headers.set("Content-type", content_type);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(headers);
ResponseEntity<KakaoInfoResponseDto> userInfoResponseDto = restTemplate.exchange(get_info_url, HttpMethod.GET, entity, KakaoInfoResponseDto.class);
return userInfoResponseDto.getBody();
}
}
여기까지 카카오 인증 서버를 통해 사용자의 정보를 가져와 이를 member로 저장하고 JwtInfoDto를 생성하여 폼로그인과 같이 JWT인증을 사용할 수 있도록 완료하였다.
마지막으로 SecurityConfig
에서 호출할 url에 대한 permit처리를 하면 완료된다.
카카오로그인 url인 localhost:8080/api/v1/oauth/kakao-login
을 실행하였다.
미리 로그인이 되어있어서 앞서 구현한 일련의 API호출 완료 후 직접 만든 JWT토큰 형식의 JwtInfoDto
가 return되었다.
db에도 잘 들어갔음을 확인할 수 있다.
위에서 받은 JwtInfoDto
의 accessToken
으로 인증이 필요한 컨트롤러를 스웨거에서 호출하였더니 다음과 같이 authorized 페이지에 접근 허용됨을 확인할 수 있었다.
폼로그인에서 입력받은 정보로 member를 찾는 로직과,
소셜로그인에서 인증 서버에서 사용자의 정보를 가져와 이를 이용해 member를 생성하거나 찾는 로직은 다르지만,
이 찾은 member를 통해 사용자 정의 JWT토큰을 생성하고 인증을 진행하는 로직은 폼로그인이나 소셜로그인이나 동일한 로직을 통해 진행된다. (같은 member를 사용하기 때문)
앞으로 또 다른 소셜 인증 서버가 추가되더라도 이와 같은 로직을 통해 손쉽게 추가 가능할 것이다.
처음 spring boot에서 로그인 관련 로직을 배울 때는 아무것도 머리속에 들어오지 않고 이걸 어떻게 해야하나 막막함이 있었다.
그래서 구현을 해도 이게 맞나 싶게 되고 조금만 변형을 하려고 해도 어디를 어떻게 만져야 될지 감이 안왔었다. 아마 모든걸 어중간하게 알고 있어서 그랬다고 생각한다. 완전히 알지 못하는걸 안다고 생각했으므로 당연히 뭔가를 구현하려고 해도 이해가 떨어져 잘 되지 않았다.
이에 아예 모든걸 모른다고 생각하고 처음부터 하나씩 제대로 이해하며 정리하며 글을 써오니 어느새 Spring Security부터 Form Login JWT에 Social Login까지 할 수 있는 모습을 보게 되었다.
아직도 많이 부족하지만 뭐든지 급하지 않게 차근차근 배워나간다면 못할 건 없다고 생각한다.