Security, JWT, OAuth2에 대한 지식이 하나도 없는 상태에서 카카오 로그인 기능을 담당하게 됐다. 언젠가 security를 직접 구현해보고 싶다는 생각이 있었는데 이번 기회에 구체적으로 알아가려 한다! 기본적인 지식이 없었기에 헤매고 헤맸지만, 모든트러블 슈팅들을 자세히 적어뒀으므로 해당 글을 읽고 나와 같은 개발자분들이 도움이 됐으면 좋겠습니다 🌱
⚠️ 확인해주세요 ⚠️
전체코드 및 서비스 정리로 나눠서 작성해뒀습니다. 제 글만 확인하시기 보단, 직접 Security가 제공하는 기본 구현체들을 확인하시면서 어떤 기능을 사용하고 계신지 확인해보시는걸 추천드립니다! 저 또한 상속받은 구현체들을 하나씩 확인해가면서 공부한 덕분에 구현에 도움을 받았습니다 🙂 (security 가 꽤.. 친절해요)
1. 카카오 설정
1. 카카오 개발자 센터 접속 : https://developers.kakao.com
Kakao Developers
카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.
developers.kakao.com
2. 애플리케이션 추가
3. 플랫폼 등록 > 사이트 도메인 : localhost & 배포서버
4. Redirect URI 등록
이때, 백엔드용 Redirect URI와 프론트용 Redirect URI를 동시에 작성해야합니다.
Redirect 받는 방법
kauth.kakao.com/oauth/authorize?client_id={보안때문에 안올려요!} &redirect_uri=http://localhost:8080/login/oauth2/code/kakao&response_type=code
🚀 Trouble Shooting : Redirect URI 로 Redirect 되지 않음
문제
Redirect URI를 내가 원하는대로 http://localhost:8080/v1/member/login/oauth2/kakao 로 지정했다가 redirect가 되지 않는 상황을 겪었다.
문제해석
YML에 적은 Redirect URI도 위의 URI였으며, 따로 정해진 규칙이 없다고 생각하여 원하는대로 적었던 것이 문제였다.
트러블 슈팅을 하며 여러 레퍼런스를 찾아본 결과 OAuth2 Client 라이브러리의 규칙을 잘 몰라 발생한 문제였음을 깨닫게 됐다.
(🔗 https://bpeach.tistory.com/54)
해결방안
OAuth2 Client 라이브러리를 사용시, Redirect URI는 OAuth2.0에서 사용자를 인증 서버로 리다이렉션을 시키고, 사용자의 동의를 받은 후에, 애플리케이션으로 다시 보내는데 사용된다. 그래서 Redirect URI는 기본적으로 */localhost:8080/oauth2/code/{registrationId}* 로 리다이렉트를 해준다는 점 !!
→ registrationId는 OAuth2.0 공급자의 아이디 Google, Kakao 등이다.
결국 http://localhost:8080/v1/oauth/kakao 로 변경해주니 redirection이 잘 되는 것을 확인할 수 있었다 !
5. 왼쪽 메뉴바 > 동의항목 > 닉네임 , 카카오계정 이메일 등록
카카오는 최근 이메일을 받기 위해서는 BIZ 앱으로 전환해야하기에, 개인정보 동의항목 심사 신청을 한 뒤, 이메일을 선택할 수 있다.
6. 왼쪽 메뉴바 > 보안 > client secret 생성
2. 인가코드 받기
인가코드를 구현하기 전, '세션 기반 인증', '토큰 기반 인증', 'JWT'에 대한 기본적인 지식에 대해 알아야한다.
해당 정보는 아래 포스팅 링크로 대체한다.
🔗 https://dev-if.notion.site/JWT-99e31ed203f4411db4deb2f6399c8a4e
세션 기반 인증, 토큰 기반 인증 → JWT | Notion
인증
dev-if.notion.site
목적 : 카카오 로그인 동의 화면 호출, 사용자 동의를 거쳐 인가 코드를 발급
이전의 카카오 설정 중 "동의 항목에 대해서 동의화면에서 사용자에게 '인가'를 구한다.
인가 코드는 동의 화면을 통해 인가받은 동의항목 정보를 갖는다. ⇒ 인가코드를 사용해 토큰받기 요청
동의 화면이란?
사용자와 앱이 처음 연결될 때만 나타난다.
이미 완료했다면, 카카오 로그인 시에 바로 인가 코드가 발급된다.
추후에 또 동의를 요청하려면 “추가항목동의받기”로 동의화면을 재호출할 수 있다.
가입이 올바르지 않다면, “연결끊기” 후 다시 인가코드 받기를 요청할 수 있다.
카카오 인증 서버(3)는 선택에 따라 요청 처리 결과를 담은 쿼리 스트링을 redirect_uri로 리다이렉트함 (HTTP 302)
redirect_uri = http://localhost:8080/v1/oauth/kakao (필자의 개인 프로젝트 URI)
3. Spring Security Flow
전반적인 Flow 방향
1. OAuth2LoginAuthenticationFilter에서 OAuth2 로그인 과정이 수행된다.
2. OAuth2 Filter 단에서 직접 커스텀한 OAuth2 Service의 “loadUser” 메소드가 실행된다.
3. 로그인 성공시, Success Handler의 “onAuthenticationSuccess” 메소드가 실행된다.
4. Success Handler에서 최초 로그인 확인 및 JWT 생성 및 응답 과정이 실행된다.
3.1 OAuth2.yml
필자는 프로젝트 개발시, application-local, application-prod를 따로 생성하여 application.yml이 이를 참조하는 형식으로 개발을 진행했다.
그러므로, oAuth 설정을 -local,과 -prod에 설정해주도록 한다.
security:
oauth2:
client:
registration:
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_CLIENT_SECRET}
redirect-uri: ${KAKAO_REDIRECT_URI}
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
client-name: Kakao
scope:
- profile_nickname
- account_email
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
SpringBoot에서 제공하는 OAuth 2.0 인증 기능에는 naver, kakao에 대한 설정이 되어 있지 않아서, 직접 작성해야합니다.
필자는 보안이 필요한 데이터는 따로 .env파일을 생성하여 주입하는 방식을 채택했습니다.
3.1.1 Registration
- client-id : 카카오 애플리케이션에서 제공해준 REST-API
- client-secret : 카카오 애플리케이션의 보안 > client secret 생성 부분
- redirect-uri : 카카오 애플리케이션의 redirect uri ⇒ http://localhost:8080/v1/oauth/kakao
- authorization-grant-type : authorization_code (카카오에서 인가코드를 전달받겠다는 의미)
- client-authentication-method : client_secret_post
🚀 Trouble Shooting : POST로 할경우 발생한 401 no body 에러
문제
처음에는 해당 변수의 값을 POST로 전달했다. 이는 요청방식을 post로 하겠다는 의미였다. 하지만, 카카오측에서 401 no body 에러가 났다.
문제해석
Spring Security 5.6 이후로 POST → client_secret_post로 변경되었다.
🔗 https://devtalk.kakao.com/t/spring-security-oauth2-401-no-body/127960/5
해결방안
해당 부분을 client_secret_post로 변경하니 문제가 해결됐다.
3.1.2 Provider
카카오가 제공해주는 기능들에 필요한 uri들을 적어두는 공간이다.
이 부분은 카카오 레퍼런스에서 제공하고 있기에 해당 부분을 확인하면 된다.
3.2 OAuth2Service
OAuth 2.0 인증을 통해 사용자 정보를 가져오는 역할을 담당하며, 이를 우리 앱 상황에 맞춰 커스텀해 제작하는 서비스
👩🏻💻전체 코드
🛎️ 서비스 로직 설명
(전체 코드에 대한 구체적인 설명입니다. 꼭 코드와 함께 보시는 것을 추천드립니다.)
1. extends DefaultOAuth2UserService
OAuthService는 OAuth2UserRequest에서 제공하는 getClientRegistration 를 사용해서 요청(OAuth2UserRequest)이 들어온 서비스의 registrationId, userNameAttributeName 이 필요하다.
위 사진을 보면, OAuth2UserRequest가 implements된 곳에서 타고타고 올라가기 때문에 결과적으로 DefaultOAuth2UserService 만 extends를 받아도 필요한 기능들을 사용할 수 있다.
2. OAuth 로그인을 진행한 사용자의 정보 가져오기
//(1) oAuth2User 정보를 가져온다.
OAuth2User oAuth2User = super.loadUser(userRequest);
//(2) RegistrationId 가져오기 (third-party id - kakao)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
//(3) userNameAttributeName 가져오기 (yml - id)
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
- DefaultOAuth2UserService 클래스의 loadUser() 메서드가 구현되어 있어 super로 조회 가능
- 현재 사용자의 클라이언트 등록 ID 가져오기 ⇒ OAuth2 클라이언트 애플리케이션을 식별하는데 사용
ex) third-party id ⇒ kakao
- 사용자 정보 엔드포인트에서 사용자 이름 속성의 이름을 가져오기 ⇒ OAuth2 제공 회사 (카카오)로부터 사용자 정보를 가져오는데 사용된다.
ex) yml에 적어둔내용 : (카카오는) id
3. 가져온 정보를 바탕으로 OAuthAttribute 생성
(OAuthAttribute는 3.3에 구체적으로 작성해뒀습니다.)
//(4) 유저 정보 oAuth2Attribute 객체 생성
OAuthAttribute oAuth2Attribute = OAuthAttribute.of(registrationId, userNameAttributeName,
oAuth2User.getAttributes());
//(5) oAuth2Attribute의 속성 값들을 map으로 반환받는다.
Map<String, Object> memberAttribute = oAuth2Attribute.convertToMap();
4. 회원 정보 업데이트
(필자의 프로젝트는 v2 개발 중, '회원가입'을 따로 구현했으므로 이부분은 각자의 프로젝트에 맞춰 참고해주시면 좋을 것 같습니다.)
//(6) 회원 save 또는 update
Member member = saveOrUpdate(oAuth2Attribute);
private Member saveOrUpdate(OAuthAttribute oAuth2Attribute) {
Member member = memberRepository.findByUserEmail(oAuth2Attribute.getEmail())
// 회원 정보 업데이트
.map(entity -> entity.update(oAuth2Attribute.getNickname(), oAuth2Attribute.getEmail()))
// 가입되지 않은 사용자 => Member Entity 생성
.orElse(oAuth2Attribute.toEntity());
return memberRepository.save(member);
}
현재 개발하고 있는 애플리케이션은, 처음 로그인한 사용자로부터 별다른 회원가입을 받지 않는다.
그렇기 때문에 기존 회원이라면 회원 정보 변경을 대비하여 업데이트를, 새로운 회원이라면 새로운 member Entity를 생성합니다.
이후 이걸 memberRepository를 통해 member Database에 저장합니다.
5. DefaultOauth2User 또는 SecurityUser로 반환
return new DefaultOAuth2User(
Collections.singleton(
new SimpleGrantedAuthority(member.getUserRole().toString())),
memberAttribute, "email"); //nameAttributeKey : OAuth2 로그인 진행시 키가 되는 필드값 (PK)
이부분이 가장 헷갈렸던 부분이다. 어떤 레퍼런스에선 PrincipalDetail로, 어떤 곳에서는 DefuaultOAuth2User로 반환하기 때문이다.
이에 이 둘의 차이점을 먼저 알고가야 한다. (PrincipalDetail ↔ DefaultOAuth2User)
→ 이번 서비스에서는 Security에서 제공해주는 DefaultOAuth2User를 사용해보고자 한다.
SecurityUser securityUser = null;
if (findMember.isEmpty()) {
securityUser = new SecurityUser(email, nickname, "ROLE_USER", provider, false);
} else {
memberAttribute.put("exist", true);
securityUser = new SecurityUser(email, nickname, "ROLE_USER", provider, true);
}
return new CustomOAuth2User(securityUser);
}
필자는 v2에서 이 부분을 직접 dto를 생성해서 구현하게 됐다. (SecurityUser)
기존 라이브러리보단, 각 User마다 받아야하는 정보들을 record로 구현해서 프로젝트에 맞춰 구현하는 것을 더욱 추천한다.
3.3 OAuthAttribute
여러 OAuth 인증(카카오, 네이버 등등) 을 통해 얻어온 사용자의 정보와 속성들을 서비스에 따라 Entity 형태로 반환하기 위해 사용하는 Builder 클래스이다.
👩🏻💻전체 코드
🛎️ 서비스 로직 설명
(전체 코드에 대한 구체적인 설명입니다. 꼭 코드와 함께 보시는 것을 추천드립니다.)
1. Build에 필요한 변수들 설정
private String attributeKey; //사용자 속성의 키
private Map<String, Object> attributes; //사용자 속성 정보를 담는 Map
private String nickname;
private String email;
private String provider; //제공자 정보
필자는 카카오 로그인에서 nickname과 email만 사용했으므로 두개의 정보만 가져온다.
2. 외부 서비스에 따라 OAuthAttribute 객체 생성
OAuthService에서 of함수를 사용해서 사용자 정보를 객체화 시킨 OAuthAttribute 를 만들었다. 이 부분에 대해 구체적으로 확인하자면
//서비스에 따라 OAuth2Attribute 객체를 생성하는 메서드
static OAuthAttribute of(String provider, String attributeKey,
Map<String, Object> attributes) {
switch (provider) {
case "kakao":
return ofKakao(provider, attributeKey, attributes);
case "naver":
return ofNaver(provider, attributeKey, attributes);
default:
throw new RuntimeException();
}
}
- provider = registrationId (즉, third-party id인 kakao, naver 등)
- attributeKey = yml에서 작성한 ‘user-name-attribute: id’ 이부분
- attributes = 사용자 속성 정보를 담은 map
ex) provider가 kakao일 경우, ofKakao라는 메소드를 호출합니다.
3. ofKakao 메소드로 카카오로그인을 통한 사용자 객체 생성 (추후, 같은 방법으로 Naver도 가능)
//카카오 로그인은 kakaoAccount -> kakaoProfile로 2번 감싸져 있으므로 두번의 get() 메서드가 필요하다.
private static OAuthAttribute ofKakao(String provider, String attributeKey,
Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
return OAuthAttribute.builder()
.nickname((String) kakaoProfile.get("nickname"))
.email((String) kakaoAccount.get("email"))
.attributes(kakaoAccount)
.attributeKey(attributeKey)
.provider(provider)
.build();
}
provider가 kakao이기 때문에, 카카오 레퍼런스에서 명시해준 대로 데이터를 가져와서 개발하는 앱에 필요한 데이터에 맞춰 객체를 빌드해준다. (🔗 카카오 rest api 문서)
문서에서 명시해둔대로 데이터를 map으로 가져온뒤, 카카오 로그인 사용자 정보 객체 OAuthAttribute를 빌드해서 return 한다.
4. OAuthAttribute 객체가 갖고 있는 내부속성을 map으로 반환 하여 OAuthUser의 속성값을 채워준다.
Map<String, Object> memberAttribute = oAuth2Attribute.convertToMap();
OAuthService에서 oAuth2Attribute의 속성 값들을 map으로 반환받는 로직이 존재한다.
위에서 생성한 OAuthAttribute의 속성값을 DefaultOAuth2User에 넣어주기 위해 map으로 반환하는 메소드를 생성한다.
//OAuth2User 객체에 넣어주기 위해 Map으로 값들을반환한다.
Map<String, Object> convertToMap() {
Map<String, Object> map = new HashMap<>();
map.put("id", attributeKey);
map.put("provider", provider);
map.put("nickname", nickname);
map.put("email", email);
return map;
}
5. 카카오 로그인 정보로 생성한 OAuthAttribute를 활용해 Member 객체를 만든다.
Service에서 가입되지 않은 사용자는 Member 객체를 생성한다고 했는데, 이는 카카오 로그인 정보로 생성한 OAuthAttribute를 활용해 생성된다.
//Member 객체를 만든다.
public Member toEntity() {
return Member.builder()
.userNickName(nickname)
.userEmail(email)
.userRole(RoleType.USER)
.build();
}
3.4 OAuth2AuthenticationSuccessHandler
외부 서비스 로그인 성공시, 해당 Thread를 처리하는 Handler로, JWT Token을 발급받아 프론트에게 Access Token과 함께 리다이렉트를 한다.
👩🏻💻전체 코드
🛎️ 서비스 로직 설명
(전체 코드에 대한 구체적인 설명입니다. 꼭 코드와 함께 보시는 것을 추천드립니다.)
1. extends SimpleUrlAuthenticationSuccessHandler
Spring Security는 기본 구현체인 SimpleUrlAuthenticationSuccessHandler 를 제공해준다.
우리는 이를 상속받아서 커스텀한 SuccessHandler를 생성해야한다.
SimpleUrlAuthenticationSuccessHandler 를 상속받으면 onAuthenticationSuccess 를 override 받아 커스텀해야한다.
2. 로그인에 성공한 인증된 사용자의 정보를 가져온다.
//1. oAuth2User로 캐스팅하여 인증된 사용자의 정보를 가져온다.
CustomOAuth2User customOAuth2User = (CustomOAuth2User) authentication.getPrincipal();
//2. oAuth2User의 정보들을 가져온다.
String email = customOAuth2User.getEmail();
String provider = customOAuth2User.getProvider();
String nickname = customOAuth2User.getName();
boolean isExist = customOAuth2User.getExist();
//3. 로그인한 회원 존재의 여부를 가져온다.
String role = authentication.getAuthorities().stream()
.findFirst()
.orElseThrow(IllegalAccessError::new)
.getAuthority();
여기서 궁금증이 폭발했다... 참고하던 블로그들이 extend를 다르게 받아와서 너무 헷갈렸습니다ㅠㅠ
그래서 전..... 끝까지 찾아봤습니다.... ㅎㅎ.... 👍👍
혹시 이것까지 궁금하지 않으시다면 넘기셔도 코드 작성에는 문제 없을거에요 !!!!
onAuthenticationSuccess 는 authentication을 매개변수로 가져오기에 해당 구현체의 getPrincipal을 활용하면 oAuth2User로 캐스팅하여 인증된 사용자의 정보를 가져올 수 있습니다.
(2) (3)에서 getAttribute 와 getAuthorities 를 사용하는데, 이 역시 security 가 제공해주는 구현체에 담긴 메소드이다.
getAttribute : 우리는 앞서, OAuthService에서 사용자 속성들을 map으로 제공해줬다. 여기에서 key값이 email의 value를 갖고오는 것이 getAttribute이다.
getAuthorities : Authorities도 마찬가지로, user role을 string화 하여 넣어줬으니, role은 해당 방법으로 가져온다.
3. 사용자 정보를 바탕으로 JWT Token을 발급한다.
isExist : 회원 가입이 된 사용자들만 JWT 토큰을 발급받으며 회원가입이 필요할 경우 회원가입 페이지로 리다이렉트 되도록 구현했습니다.
if (isExist) {
GeneratedToken generatedToken = jwtUtil.generateToken(email, role);
String loginRedirectUrl = UriComponentsBuilder.fromUriString(LOGIN_URI)
.queryParam("accessToken", generatedToken.accessToken())
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
log.info(provider + "회원 access Token redirect 준비");
getRedirectStrategy().sendRedirect(request, response, loginRedirectUrl);
} else {
String signupRedirectUrl = UriComponentsBuilder.fromUriString(SIGNUP_URI)
.queryParam("email", email)
.queryParam("nickname", nickname)
.queryParam("provider", provider)
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
log.info(provider + "회원 회원가입 준비");
getRedirectStrategy().sendRedirect(request, response, signupRedirectUrl);
}
JWT TOKEN 중 ACCESS TOKEN을 담은 URL을 만들어 프론트로 리다이렉트를 하면, 프론트는 해당 ACCESS TOKEN을 매 API HEADER에 붙여서 사용자가 서비스를 사용할 수 있도록 구현한다.
원래는, 여기서 회원가입이 필요하면, 사용자 정보가 기존 회원인지, 또는 새로운 회원인지에 대한 정보를 조회해야하나, 현재 서비스에서는 회원가입이 없으므로 바로 프론트로 향할 수 있도록 구현했다.
4. 글을 마무리 하며 ...
Spring Security를 활용하여 어플리케이션의 인증과 인가를 진행하는 로직의 이해도가 없었기에, 이를 이해하는 과정이 어려웠습니다. 이를 해결하기 위해 강의 및 Spring Security 공식 문서를 참고하며 OAuth2와 함께 소셜 로그인을 구현했습니다. 주로 API 개발만을 담당했기에 Component 및 ServletContext및 각 Filter들의 과정이 어렵게 느껴졌지만 공식문서의 코드들을 하나씩 이해하가면서, 저희의 어플리케이션에서 요구하는 흐름대로 코드를 작성했고 개인적으로 Filter에 대해 구체적으로 이해할 수 있었으며 문서 가독 능력을 기를 수 있었습니다.
결과적으로 카카오 및 네이버 소셜 로그인을 모두 마무리할 수 있었고 클라이언트와의 request, response에서 filter들의 어떻게 거쳐가는지에 대한 흐름을 구체적으로 파악할 수 있었습니다.
이번 프로젝트 경험을 통해 새로운 기술을 사용할 때는 사용할 라이브러리에 대한 충분한 이해가 필요함을 깨닫게 되었고, 이는 공식 문서 및 해당 기술의 예제 코드들로부터 차근차근 이해하는 과정으로 해결할 수 있음을 깨닫게 되었습니다. 또한, 해당 부분을 같이 개발하는 클라이언트와 지속적인 소통으로 요청, 응답 그리고 리다이렉트의 과정을 구체적으로 합의하는 과정의 필요성도 배우고, 그 과정에서 각 분야에서 진행하는 방법을 이해하고, 배우는 시간의 중요성을 깨닫게 되었습니다.
회원도메인을 개발하며 정말 많은 공식 문서와 외부 문서를 참고했지만.. 구체적으로 정리해준 글이 없어서 많이 헤맸던 기억이 납니다. 제 글이 누군가에게 조금이라도 도움이 되길 바랍니다 👍🍀
'BACKEND > JAVA & SPRING' 카테고리의 다른 글
MultipartFile와 Ajax를 활용한 파일 업로드 (0) | 2024.08.01 |
---|---|
Transaction의 전파 (feat. REQUIRES_NEW의 문제점) (1) | 2024.07.31 |
HttpURLConnection을 이용한 API 데이터 받아오기 (2) | 2024.04.25 |
Call By Value & Call By Reference 정리 (0) | 2024.03.11 |
장바구니 api - Create 추가 #2 (Feat. SpringBoot) (2) | 2023.12.21 |