일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 전략패턴
- 테스트코드책
- 리팩토링2판4장
- git commit 협업
- 가상면접으로대규모시스템
- gitsquash
- formik react-query submitting not working
- FirebaseAnalytics
- react
- file not found Error
- 가상면접2장
- git commit merge
- s3이미지다운로드됨
- 헤드퍼스트전략패턴
- 시스템설계방법
- Git commit 합치기
- 리액트구글애널리틱스
- 시스템설계면접팁
- 리팩터링2판테스트
- 시스템설계
- react-ga
- awss3
- cypress React
- cypressBDD
- 시스템설계면접
- formik submitting not working
- 가상면접3장
- 시스템설계면접예시
- 디자인패턴
- git squash
- Today
- Total
mingg IT
[Spring Security Oauth2.0 Client] Apple 로그인 본문
드디어 올 것이 왔다. 미루고 미루다가 미룰 순 없어서 애플 로그인을 구현했다.
Rest-API 방식을 이용한 예시는 많은데, Spring Security Oauth2.0 Client를 이용한 레퍼런스가 다른 SNS 로그인에 비해 매우 적었다.
개발하는 기간동안 나름 고생을해서 이번에 정리해두려고 한다.
application.yml에 apple 정보 추가
security:
oauth2.client:
registration:
apple:
clientId: {애플로부터 받은clientId}
clientSecret: {애플로부터 받은 key 파일}.p8
redirectUri: "${backend-rooturl-origin}/login/oauth2/code/apple"
authorizationGrantType: authorization_code
clientAuthenticationMethod: POST
clientName: Apple
scope:
- name
- email
naver:
clientId: ${naver.client.key}
clientSecret: ${naver.secret.key}
clientAuthenticationMethod: post
authorizationGrantType: authorization_code
redirectUri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
scope:
- nickname
- email
- profile_image
clientName: naver
kakao:
clientId: ${kakao.client.key}
clientSecret: ${kakao.secret.key}
clientAuthenticationMethod: post
authorizationGrantType: authorization_code
redirectUri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
scope:
- profile_nickname
- profile_image
- account_email
clientName: kakao
provider:
apple:
authorizationUri: https://appleid.apple.com/auth/authorize?scope=name%20email&response_mode=form_post
tokenUri: https://appleid.apple.com/auth/token
naver:
authorizationUri: https://nid.naver.com/oauth2.0/authorize
tokenUri: https://nid.naver.com/oauth2.0/token
userInfoUri: https://openapi.naver.com/v1/nid/me
userNameAttribute: response
kakao:
authorizationUri: https://kauth.kakao.com/oauth/authorize
tokenUri: https://kauth.kakao.com/oauth/token
userInfoUri: https://kapi.kakao.com/v2/user/me
userNameAttribute: id
CustomRequestEntityConvert.java 작성
apple 같은 경우에는 네이버, 카카오와는 다르게 client_secret을 key파일과, teamId, clientId등을 이용하여 만들어야한다.
@Slf4j
public class CustomRequestEntityConverter implements Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {
private OAuth2AuthorizationCodeGrantRequestEntityConverter defaultConverter;
public CustomRequestEntityConverter() {
defaultConverter = new OAuth2AuthorizationCodeGrantRequestEntityConverter();
}
private final String APPLE_URL = "https://appleid.apple.com";
private final String APPLE_KEY_PATH = "static/apple/{apple로 부터 받은 key파일명}.p8";
private final String APPLE_CLIENT_ID = "{apple로 부터 받은 clientId";
private final String APPLE_TEAM_ID = "{apple로 부터 받은 teamId}";
private final String APPLE_KEY_ID = "{apple로 부터 받은 keyId}";
@Override
public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest req) {
RequestEntity<?> entity = defaultConverter.convert(req);
String registrationId = req.getClientRegistration().getRegistrationId();
MultiValueMap<String, String> params = (MultiValueMap<String, String>) entity.getBody();
if (registrationId.contains("apple")) {
try {
params.set("client_secret", createClientSecret());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
return new RequestEntity<>(params, entity.getHeaders(),
entity.getMethod(), entity.getUrl());
}
public PrivateKey getPrivateKey() throws IOException {
ClassPathResource resource = new ClassPathResource(APPLE_KEY_PATH);
// 배포시 jar 파일을 찾지 못함
//String privateKey = new String(Files.readAllBytes(Paths.get(resource.getURI())));
InputStream in = resource.getInputStream();
PEMParser pemParser = new PEMParser(new StringReader(IOUtils.toString(in, StandardCharsets.UTF_8)));
PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
return converter.getPrivateKey(object);
}
public String createClientSecret() throws IOException {
Date expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant());
Map<String, Object> jwtHeader = new HashMap<>();
jwtHeader.put("kid", APPLE_KEY_ID);
jwtHeader.put("alg", "ES256");
return Jwts.builder()
.setHeaderParams(jwtHeader)
.setIssuer(APPLE_TEAM_ID)
.setIssuedAt(new Date(System.currentTimeMillis())) // 발행 시간 - UNIX 시간
.setExpiration(expirationDate) // 만료 시간
.setAudience(APPLE_URL)
.setSubject(APPLE_CLIENT_ID)
.signWith(getPrivateKey())
.compact();
}
}
client_secret이 ey~ 토큰형식으로 변형되어야 한다.
SecurityConfig 내 Bean 등록 및 수정
SecurityConfig.java
애플의 clientSecret을 만들기위해 만들어주었던 CustomRequestEntityConvert를 SecurityConfig 내 Bean으로 등록해준다.
@Bean
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
accessTokenResponseClient.setRequestEntityConverter(new CustomRequestEntityConverter());
return accessTokenResponseClient;
}
.tokenEndpoint().accessTokenResponseClient(accessTokenResponseClient()) 추가해준다.
private HttpSecurity setOauth2ToHttpSecurity(HttpSecurity http) throws Exception {
http.oauth2Login()
.tokenEndpoint().accessTokenResponseClient(accessTokenResponseClient()).and()
.authorizationEndpoint()
.baseUri("/oauth2/authorization")
.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())
.and()
.redirectionEndpoint()
.baseUri("/*/oauth2/code/*")
.and()
.userInfoEndpoint().userService(oAuth2UserService).and()
.successHandler(oAuth2AuthenticationSuccessHandler())
.failureHandler(oAuth2AuthenticationFailureHandler())
.and()
.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http;
}
아마 해당 플로우는 각자 애플리케이션에서 이용하는 회원가입 및 로그인 플로우에 따라 다를 것이다.
DefaultAuthorizationCodeTokenResponseClient.java 내 getTokenResponse의 request 내 client_secret이 들어있어야 한다.
만약 client_secret 값이 비었다면 CustomRequestEntityConvert 잘 동작하는지 확인해봐라. 난 비어있어서 key 파일 유효기간이 다 된건가 한참 찾았는데 파싱을 잘못했었던게 원인 이였다.
DefaultAuthorizationCodeTokenResponseClient.java 내 getTokenResponse의 response 결과이다.
id_token 값이 들어있어야 한다.
순서가
Oauth2AccessTokenResponse 내 request ->
커스텀한 CustomRequestEntityConvert ->
Oauth2AccessTokenResponse 내 response 라고 생각하면 된다.
loadUser 함수 수정
loadUser 에서 애플과 다른 로그인간에 사용자 정보를 들고오는 방식이 달라서 분기 처리가 필요하다.
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private static final String APPLE_REGISTRATION_ID = "apple";
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User user;
Map<String, Object> attributes;
if (registrationId.contains(APPLE_REGISTRATION_ID)) {
String idToken = userRequest.getAdditionalParameters().get("id_token").toString();
attributes = decodeJwtTokenPayload(idToken);
attributes.put("id_token", idToken);
Map<String, Object> userAttributes = new HashMap<>();
userAttributes.put("resultcode", "00");
userAttributes.put("message", "success");
userAttributes.put("response", attributes);
user = new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), userAttributes, "response");
} else {
OAuth2User oAuth2User = delegate.loadUser(userRequest);
attributes = oAuth2User.getAttributes();
user = super.loadUser(userRequest);
}
}
public Map<String, Object> decodeJwtTokenPayload(String jwtToken) {
Map<String, Object> jwtClaims = new HashMap<>();
try {
String[] parts = jwtToken.split("\\.");
Base64.Decoder decoder = Base64.getUrlDecoder();
byte[] decodedBytes = decoder.decode(parts[1].getBytes(StandardCharsets.UTF_8));
String decodedString = new String(decodedBytes, StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> map = mapper.readValue(decodedString, Map.class);
jwtClaims.putAll(map);
} catch (JsonProcessingException e) {
// logger.error("decodeJwtToken: {}-{} / jwtToken : {}", e.getMessage(), e.getCause(), jwtToken);
}
return jwtClaims;
}
}
애플의 경우 네이버나 카카오와 방식이 다르기때문에 DefaultOauth2User를 커스텀하게 만들어주었다.
userRequest 내부이다.
id_token 값을 decode 하게되면 sub, email 내가 필요한 정보를 얻을 수 있다. (드디어 ..!)
user이름은 없더라.. 공식문서에도 id_token 내에서는 제공하지 않는다고 한다.
나를 고통스럽게 했던 문제와 해결 방안에 대해 작성해보도록 하겠다..
localhost, http 에서 테스트 불가능
네이버나, 카카오 로그인을 테스트할 경우에는 localhost 인 로컬로 테스트가 가능했는데
애플은 보안때문인지 무슨생각인지 이걸 막아뒀다.
나는 스테이징 우리 서버에 배포하고, Remote Dubug 붙여서 테스트 했다... ㅂㄷㅂㄷ
저기서 도메인은 물론 return url 에 localhost는 사용이 불가능 하다.
Client_Secret 정보가 비어있는 경우
이 문제는 처음에 apple develop 계정으로 만든 key파일인 p.8 의 기한인 6개월이 지나서 안되는건가 해맸다.
원인은 그냥 client_secret 파일을 파싱하는 로직이 잘못되어서 였다.
private final String APPLE_KEY_PATH = "static/apple/파일명.p8";
public String createClientSecret() throws IOException {
Date expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant());
Map<String, Object> jwtHeader = new HashMap<>();
jwtHeader.put("kid", APPLE_KEY_ID);
jwtHeader.put("alg", "ES256");
return Jwts.builder()
.setHeaderParams(jwtHeader)
.setIssuer(APPLE_TEAM_ID)
.setIssuedAt(new Date(System.currentTimeMillis())) // 발행 시간 - UNIX 시간
.setExpiration(expirationDate) // 만료 시간
.setAudience(APPLE_URL)
.setSubject(APPLE_CLIENT_ID)
.signWith(getPrivateKey())
.compact();
}
public PrivateKey getPrivateKey() throws IOException {
ClassPathResource resource = new ClassPathResource(APPLE_KEY_PATH);
// 배포시 jar 파일을 찾지 못함
//String privateKey = new String(Files.readAllBytes(Paths.get(resource.getURI())));
InputStream in = resource.getInputStream();
PEMParser pemParser = new PEMParser(new StringReader(IOUtils.toString(in, StandardCharsets.UTF_8)));
PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
return converter.getPrivateKey(object);
}
애플 로그인시 이메일 정보가 오지 않는 경우
scope에 name, email을 넣어주었음에도 오지 않았다. 다른 블로그 글에도 최초의 로그인시에만 온다는데 그러면 테스트 할때 어캐 하느냐?
아래에 접속하여 로그인한다.
https://appleid.apple.com/account/manage
접속해서 Apple로 로그인 사용 중단을 클릭한다.
id_token 값에 user 이름 정보가 없는 경우
https://appleid.apple.com/auth/authorize?scope=name%20email&response_mode=form_post
이렇게 보내면 user정보가 와야하는게 아닌가?
안온다...
공식문서보면 Id_token 값에 user정보가 없다고하는데 받아오는 방법을 아는 분은 제발 알려주면 좋겠다.
여기에도 안온다는데 .. 쩝
https://developer.apple.com/forums/thread/690339
loadUser 에서 터지는 경우
user = super.loadUser(userRequest);
네이버나 카카오 로그인을 할 경우에는 이런식으로 사용하면 바로 User 정보를 읽어왔다. (nickName 같은 것들)
허나 우리의 애플 같은 경우에는 방식이 다르다. (id_token 값을 decode 해서 읽어온다)
loadUser 내부 코드를 살펴보면
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Assert.notNull(userRequest, "userRequest cannot be null");
if (!StringUtils
.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
.getUserNameAttributeName();
if (!StringUtils.hasText(userNameAttributeName)) {
OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
+ userRequest.getClientRegistration().getRegistrationId(),
null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
Map<String, Object> userAttributes = response.getBody();
Set<GrantedAuthority> authorities = new LinkedHashSet<>();
authorities.add(new OAuth2UserAuthority(userAttributes));
OAuth2AccessToken token = userRequest.getAccessToken();
for (String authority : token.getScopes()) {
authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
}
return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
}
userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
이 부분에서 터질 것이다. 내가 정의한 application.yml 에도 정보가 없고..
그래서 loadUser에는 분기 처리를 해야했다.
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User user;
Map<String, Object> attributes;
if (registrationId.contains(APPLE_REGISTRATION_ID)) {
String idToken = userRequest.getAdditionalParameters().get("id_token").toString();
attributes = decodeJwtTokenPayload(idToken);
attributes.put("id_token", idToken);
Map<String, Object> userAttributes = new HashMap<>();
userAttributes.put("resultcode", "00");
userAttributes.put("message", "success");
userAttributes.put("response", attributes);
user = new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), userAttributes, "response");
} else {
OAuth2User oAuth2User = delegate.loadUser(userRequest);
attributes = oAuth2User.getAttributes();
user = super.loadUser(userRequest);
}
}
DefaultOAuth2User 를 만들어주었고, 형식을 맞추기위해서 userAttributes 를 만들어주었다.
요약
- 네이버랑 카카오 로그인에 비해 굉장히 오래걸렸고, 시큐리티 Oauth2 client를 이용하는 레퍼런스를 찾기가 힘들었다. (application.yml 에도 어떤식으로 작성해줘야할지도 모르겠더라.)
- 처음엔 포기하고 Rest-API 형식으로 구현했다가, 이후 시큐리티 방식으로 다시 수정하였다.
- 그래도 구현하면서 시큐리티 내부 DefaultAuthorizationCodeTokenReponseClient 로직을 직접 디버깅찍어 보면서 공부할 수 있었고, 전체적인 플로우를 이해할 수 있어서 뿌듯했다.
아래는 내가 많이 참고한 사이트들이다. (감사합니다)
https://shxrecord.tistory.com/289
https://devcheon.tistory.com/98?category=327541