프로젝트에서 애플 로그인을 맡아서 글을 이것저것 찾아보았다. 근데... 진짜 처음볼 때 무슨 말인지 하나도 못 알아들었었다. 소셜 로그인 자체가 처음이어서 감도 안 잡혔고 외계어 그 자체여서 머리속에서 계속 튕겨냈다. 다들 구현 방법이 조금씩 다르기도 해서 더 헷갈렸던 것 같다. 그래도 글을 계속 읽다보니 좀 이해가 되고 흐름도 알게되었다.
참조 쪽으로 가면, 내가 참고한 글들에서 어느 부분을 중심으로 보았는지를 적어놓았으니 좀 도움이 될 것이다!
흐름
위 사진의 흐름을 그대로 따라가면 된다.
내가 진행한 프로젝트에서는 프론트 쪽에서 `Authorization code`를 전달하기로 하여 해당 부분의 코드는 작성하지 않았다. 백엔드에서는 프론트에서 받은 `Authorization code`을 이용해 `Client Secret`을 생성한 뒤, Apple의 설정 정보와 함깨 전송하여 `id_token`을 받았다. 발급된 토큰을 검증하고 필요한 정보를 추출한 후, 모든 검증 과정을 통과하면 사용자에게 `access token`을 응답으로 반환하는 방식으로 구현했다.
구현 코드
Controller
@PostMapping("/signup")
public BaseResponse<AppleSignUpResponseDto> appleSignUp(@Valid @RequestBody AppleSignUpRequestDto requestDto) {
AppleSignUpResponseDto responseDto = authService.appleSignUp(requestDto);
return new BaseResponse<>(responseDto);
}
@PostMapping("/signin")
public BaseResponse<AppleSignInResponseDto> appleSignIn(@Valid @RequestBody AppleSignInRequestDto requestDto) {
AppleSignInResponseDto responseDto = authService.appleSignIn(requestDto);
return new BaseResponse<>(responseDto);
}
RequestDto
public record AppleSignUpRequestDto(
@NotBlank(message = "Authorization code는 필수 입력값입니다.")
String authorizationCode,
@NotBlank(message = "닉네임은 필수 입력값입니다.")
String nickName,
@NotBlank(message = "한마디는 필수 입력값입니다.")
String description,
) {}
- 회원가입에서는 `Authorizaton code`와 프로젝트 회원가입에 필요한 데이터를 전달받는다.
public record AppleSignInRequestDto(
@NotBlank(message = "Authorization code는 필수 입력값입니다.")
String authorizationCode) {
}
- 로그인 시에는 `Authorizaton code`만 전달받으면 된다.
AuthService
public AppleSignUpResponseDto appleSignUp(
AppleSignUpRequestDto requestDto) {
AppleUserInfoResponseDto appleUserInfo = appleService.getAppleUserProfile(requestDto.authorizationCode());
userRepository.findByOauthId(appleUserInfo.getSubject())
.ifPresent(user -> {
throw new IllegalStateException("해당 OAuth ID의 사용자가 이미 존재합니다.");
});
User user = User.builder()
.uuid(UUID.randomUUID())
.oauthId(appleUserInfo.getSubject())
.nickname(requestDto.nickName())
.description(requestDto.description())
.build();
User newUser = userRepository.save(user);
UserAuthentication authentication = new UserAuthentication(newUser.getUserId(), null, null);
String jwtToken = jwtTokenProvider.generateToken(authentication);
log.info("[AuthService - AppleSignUp] JWT Token: {}", jwtToken);
return AppleSignUpResponseDto.builder()
.accessToken(jwtToken)
.build();
}
public AppleSignInResponseDto appleSignIn(AppleSignInRequestDto requestDto) {
AppleUserInfoResponseDto appleUserInfo = appleService.getAppleUserProfile(requestDto.authorizationCode());
User user = userRepository.findByOauthId(appleUserInfo.getSubject())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED,
"해당 Apple 계정 사용자가 존재하지 않습니다."));
UserAuthentication authentication = new UserAuthentication(user.getUserId(), null, null);
String jwtToken = jwtTokenProvider.generateToken(authentication);
log.info("[AuthService - AppleSignIn] JWT Token: {}", jwtToken);
return AppleSignInResponseDto.builder()
.accessToken(jwtToken)
.build();
}
Service 계층의 코드는 위와 같다. 애플 로그인에서 중요한 부분은 아래 부분이다.
AppleUserInfoResponseDto appleUserInfo =
appleService.getAppleUserProfile(requestDto.authorizationCode());
이후 코드는 사용자의 존재 여부를 확인한 뒤 access token을 발급하는 부분이다. 따라서, 상단의 한 줄만 다른 소셜 프로필 조회 메서드로 교체하면 바로 다른 소셜 로그인 코드가 된다.
AppleService
public AppleUserInfoResponseDto getAppleUserProfile(String authorizationCode) {
// 1) Apple 서버에서 토큰 교환
AppleSocialTokenInfoResponseDto tokenInfo = requestToken(authorizationCode);
// 2) 토큰 검증
verifyIdentityToken(tokenInfo.idToken());
// 3) ID 토큰에서 사용자 정보 추출
return parseUserInfo(tokenInfo.idToken());
}
외부에서는 이 함수 하나만 사용하면 된다. 흐름은 주석을 달아놓은 것과 같다.
- 토큰 교환: Apple 서버에 `Authorization code`와 `Client Secret` 등 필요한 정보를 전송하여, `id_token` 을 포함한 응답을 수신한다.
- 토큰 검증: 수신한 `id_token`의 만료 시간, Issuer 등 주요 클레임을 검증한다.
- 사용자 정보 파싱: `id_token`에서 `sub`, `email` claim을 꺼내서 반환한다.
AppleProperties
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "apple")
public class AppleProperties {
private Auth auth = new Auth();
private String redirectUri;
private String iss;
private String aud;
private String teamId;
private String grantType;
private Key key = new Key();
@Getter
@Setter
public static class Auth {
private String tokenUrl;
private String publicKeyUrl;
}
@Getter
@Setter
public static class Key {
private String id;
private String path;
}
}
- application.yml에 정의된 애플 관련 설정을 바인딩한다.
apple:
auth:
token-url: https://appleid.apple.com/auth/token
public-key-url: https://appleid.apple.com/auth/keys
redirect-uri: ${redirect-uri}
iss: https://appleid.apple.com
aud: ${Bundle ID}
team-id: ${Team ID}
grant-type: authorization_code
key:
id: ${Key ID}
path: ${key 파일의 위치}
- `aud`: 말 그대로 Bundle ID를 넣으면 된다. com.으로 시작하는 그거
- `team-id`: App ID Prefix 아래에 있는 걸 가져오면 된다.
- `key`, `path`: key를 만들고 나오는 id랑 파일을 다운에서 프로젝트 안에 넣고 그 파일의 경로를 넣으면 된다.
SocialConfig
@Component
public class SocialConfig {
private final RestTemplate restTemplate = new RestTemplate();
public RestTemplate restTemplate() {
return restTemplate;
}
}
requestToken
private AppleSocialTokenInfoResponseDto requestToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.valueOf(APPLICATION_FORM_URLENCODED_VALUE));
String requestBody = "client_id=" + appleProperties.getAud()
+ "&client_secret=" + generateClientSecret()
+ "&grant_type=" + appleProperties.getGrantType()
+ "&code=" + code;
HttpEntity<String> request = new HttpEntity<>(requestBody, headers);
ResponseEntity<AppleSocialTokenInfoResponseDto> response = socialConfig.restTemplate().exchange(
appleProperties.getAuth().getTokenUrl(),
HttpMethod.POST,
request,
AppleSocialTokenInfoResponseDto.class);
return response.getBody();
}
- `client_id` : Apple 개발자 계정에서 발급받은 서비스 식별자(`aud`)
- `client_secret` : `generateClientSecret()` 메서드로 생성한 JWT
- `grant_type` : Apple OAuth에서 사용하는 인증 방식 (`authorization_code`)
- `code` : 프론트로부터 받은 인가 코드
위 함수를 통해 `id_token`, `access_token`, `refresh_token` 등을 담고 있는 데이터를 응답으로 받을 수 있다. 근데 나는 여기서 `id_token`만 사용했다.
public record AppleSocialTokenInfoResponseDto(
@JsonProperty("access_token") String accessToken,
@JsonProperty("token_type") String tokenType,
@JsonProperty("expires_in") Long expiresIn,
@JsonProperty("refresh_token")String refreshToken,
@JsonProperty("id_token") String idToken
) {}
private String generateClientSecret() {
// 유효기간 설정
LocalDateTime expiration = LocalDateTime.now().plusMinutes(5);
return Jwts.builder()
.setHeaderParam(JwsHeader.KEY_ID, appleProperties.getKey().getId())
.setIssuer(appleProperties.getTeamId())
.setAudience(appleProperties.getIss())
.setSubject(appleProperties.getAud())
.setExpiration(Date.from(expiration.atZone(ZoneId.systemDefault()).toInstant()))
.setIssuedAt(new Date())
.signWith(getPrivateKey(), SignatureAlgorithm.ES256)
.compact();
}
verifyIdentityToken
public void verifyIdentityToken(String idToken) {
SignedJWT signedJWT = parseToken(idToken);
JWTClaimsSet claims = extractClaims(signedJWT);
verifyExpiration(claims.getExpirationTime());
verifyIssuer(claims.getIssuer());
verifyAudience(claims.getAudience());
verifySignature(signedJWT);
}
- `id_token`을 파싱 → 클레임 추출 → 유효성 검사
private SignedJWT parseToken(String idToken) {
try {
return SignedJWT.parse(idToken);
} catch (ParseException e) {
throw new BaseException(BaseResponseStatus.WRONG_JWT_TOKEN,
"토큰 디코딩 실패: " + idToken + " - " + e.getMessage());
}
}
private JWTClaimsSet extractClaims(SignedJWT signedJWT) {
try {
return signedJWT.getJWTClaimsSet();
} catch (ParseException e) {
throw new BaseException(BaseResponseStatus.WRONG_JWT_TOKEN,
"토큰 클레임 추출 실패: " + e.getMessage());
}
}
private void verifyExpiration(Date expirationTime) {
Date currentTime = new Date();
if (expirationTime == null || expirationTime.before(currentTime)) {
throw new BaseException(BaseResponseStatus.WRONG_JWT_TOKEN,
"토큰 만료됨: 만료 시간은 " + expirationTime + "입니다.");
}
}
private void verifyIssuer(String issuer) {
if (!appleProperties.getIss().equals(issuer)) {
throw new BaseException(BaseResponseStatus.WRONG_JWT_TOKEN,
"Issuer 불일치: 기대값 " + appleProperties.getIss() + ", 실제 " + issuer);
}
}
private void verifyAudience(List<String> audience) {
if (audience == null || audience.isEmpty() || !appleProperties.getAud().equals(audience.get(0))) {
String actual = (audience != null && !audience.isEmpty()) ? audience.get(0) : "null";
throw new BaseException(BaseResponseStatus.WRONG_JWT_TOKEN,
"Audience 불일치: 기대값 " + appleProperties.getAud() + ", 실제 " + actual);
}
}
private void verifySignature(SignedJWT signedJWT) {
String keyId = signedJWT.getHeader().getKeyID();
try {
URL publicKeyUrl = new URL(appleProperties.getAuth().getPublicKeyUrl());
JWKSet jwkSet = JWKSet.load(publicKeyUrl);
JWK jwk = jwkSet.getKeyByKeyId(keyId);
if (jwk == null) {
throw new BaseException(BaseResponseStatus.WRONG_JWT_TOKEN, "키 아이디를 찾을 수 없음: " + keyId);
}
RSAPublicKey publicKey = ((RSAKey) jwk).toRSAPublicKey();
JWSVerifier verifier = new RSASSAVerifier(publicKey);
if (!signedJWT.verify(verifier)) {
throw new BaseException(BaseResponseStatus.WRONG_JWT_TOKEN, "서명 검증 실패");
}
} catch (Exception e) {
throw new BaseException(BaseResponseStatus.WRONG_JWT_TOKEN,
"서명 검증 중 오류 발생: " + e.getMessage());
}
}
parseUserInfo
private AppleUserInfoResponseDto parseUserInfo(String idToken) {
DecodedJWT jwt = JWT.decode(idToken);
return AppleUserInfoResponseDto.builder()
.subject(jwt.getClaim("sub").asString())
.email(jwt.getClaim("email").asString())
.build();
}
- 검증이 끝난 `id_token`에서 `sub`(사용자 고유 ID)와 `email`을 꺼내서 `AppleUserInfoResponseDto`로 매핑하면 끝!
@Builder
@NoArgsConstructor(access = AccessLevel.PUBLIC)
@AllArgsConstructor
@Data
public class AppleUserInfoResponseDto {
// 고유ID
@JsonProperty("sub")
private String subject;
@JsonProperty("email")
private String email;
}
트러블 슈팅
애플 로그인을 구현하기 위해 블로그 글들을 엄청 많이 찾아보았고, 코드들을 한 곳이 아니라 다 조금씩 조금씩 참고하고 다듬었다. 그래서 나중에 `client_secret`을 생성할 때 오류가 발생했었다.
코드를 모두 다듬고 실행을 했는데...? `invalid_client`가 떴고 나는 절망을 했다. 왜 그런 걸까...? 구글링을 해보아도 해당 에러는 별로 없었고 나와있는 해결책을 적용해도 에러는 계속 떴다... 오히려 `Invalid_grant` 에러가 훨씬 많이 떠서 처음에는 헷갈리기도 했었다.
근데 그냥 위에서 말했던 코드를 여기저기 가져온 문제였다. yml 파일 형식을 가져온 블로그 따로, clientScret 생성하는 코드 따로....
private String generateClientSecret() {
LocalDateTime expiration = LocalDateTime.now().plusMinutes(5);
String jwt = Jwts.builder()
.setHeaderParam(JwsHeader.KEY_ID, appleProperties.getKey().getId())
.setIssuer(appleProperties.getTeamId())
.setAudience(appleProperties.getIss())
.setSubject(appleProperties.getAud())
.setExpiration(Date.from(expiration.atZone(ZoneId.systemDefault()).toInstant()))
.setIssuedAt(new Date())
.signWith(getPrivateKey(), SignatureAlgorithm.ES256)
.compact();
return jwt;
}
- 처음에는 그냥 다 같은 곳에 넣으면 되겠지... 하고는 `setAudience(appleProperties.getAud())`, `setIssuer(appleProperties.getIss())` 이런 식으로 넣었었다... 근데 잘못된 거였고요... ㅎㅎ
다른 분들은 이런 실수하지 말고, 공식 문서를 참고하는 게 더 좋을 것 같다.
- JWT header 생성
- alg : 토큰을 서명하는 데 사용되는 알고리즘으로 Apple로 로그인하는 경우 ES256을 사용
- kid : 개발자 계정과 연결된 Apple 개인 키로 로그인하기 위해 생성된 10자 키 식별자이다.
- JWT payload 생성
- iss : 개발자 계정과 연결된 10자리의 팀 ID
- iat : 발급자 등록 클레임은 클라이언트 비밀을 생성한 시간을 UTC 기준 에포크 이후 초 단위로 나타낸다.
- exp : 만료 시간 등록 클레임은 클라이언트 비밀이 만료되는 시간 또는 그 이후를 식별합니다. 이 값은 서버의 현재 UNIX 시간에서 15777000(초 단위로 6개월) 보다 크지 않아야 한다.
- aud : client-secret을 검증하기 위한 서버 (https://appleid.apple.com)
- sub : client_id 사용
- JWT 서명
- SHA-256 알고리즘으로 서명
원래였으면 하나의 잘 정리된 블로그 글을 메인으로 두고 개발을 진행했을 텐데 다른 팀원이 이미 카카오 로그인을 구현해 두어서 그 형식에 맞추다 보니 그러지 못했다. 근데 그 덕분에 로직을 확실히 이해하기 위해 공부를 더 많이 하게 되어 학습 쪽으로는 도움이 된 것 같다ㅎㅎ
참조
- 이 글 계속 읽다보니 이해가 되었다.
'Spring' 카테고리의 다른 글
TDD (Test-Driven-Development) (1) | 2024.10.24 |
---|---|
[Spring] @Controller vs RestController (0) | 2024.07.11 |
RESTful API (0) | 2024.07.09 |
[Spring] JdbcTemplate (0) | 2024.07.09 |
[Spring] JDBC (0) | 2024.07.09 |