-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
[FEAT] 애플 소셜 로그인 구현
- Loading branch information
Showing
12 changed files
with
333 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -41,3 +41,6 @@ out/ | |
|
||
## QClass ## | ||
src/main/generated/ | ||
|
||
## Apple Login Auth Key File ## | ||
*.p8 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
12 changes: 12 additions & 0 deletions
12
src/main/java/org/websoso/WSSServer/dto/auth/AuthResponse.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
package org.websoso.WSSServer.dto.auth; | ||
|
||
public record AuthResponse( | ||
String Authorization, | ||
String refreshToken, | ||
boolean isRegister | ||
) { | ||
|
||
public static AuthResponse of(String Authorization, String refreshToken, boolean isRegister) { | ||
return new AuthResponse(Authorization, refreshToken, isRegister); | ||
} | ||
} |
26 changes: 26 additions & 0 deletions
26
src/main/java/org/websoso/WSSServer/exception/error/CustomAppleLoginError.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
package org.websoso.WSSServer.exception.error; | ||
|
||
import static org.springframework.http.HttpStatus.BAD_REQUEST; | ||
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; | ||
import static org.springframework.http.HttpStatus.NOT_FOUND; | ||
|
||
import lombok.AllArgsConstructor; | ||
import lombok.Getter; | ||
import org.springframework.http.HttpStatus; | ||
import org.websoso.WSSServer.exception.common.ICustomError; | ||
|
||
@AllArgsConstructor | ||
@Getter | ||
public enum CustomAppleLoginError implements ICustomError { | ||
|
||
MISSING_AUTHORIZATION_CODE("APPLE-001", "인증 코드를 입력하지 않았습니다.", BAD_REQUEST), | ||
TOKEN_REQUEST_FAILED("APPLE-003", "Apple 서버로부터 토큰을 받아오지 못했습니다.", INTERNAL_SERVER_ERROR), | ||
ID_TOKEN_PARSE_FAILED("APPLE-004", "Apple ID 토큰을 파싱하지 못했습니다.", INTERNAL_SERVER_ERROR), | ||
USER_INFO_RETRIEVAL_FAILED("APPLE-005", "Apple에서 사용자 정보를 가져오지 못했습니다.", NOT_FOUND), | ||
CLIENT_SECRET_CREATION_FAILED("APPLE-006", "클라이언트 시크릿을 생성하는 데 실패했습니다.", INTERNAL_SERVER_ERROR), | ||
PRIVATE_KEY_READ_FAILED("APPLE-007", "프라이빗 키를 읽는 데 실패했습니다.", INTERNAL_SERVER_ERROR); | ||
|
||
private final String code; | ||
private final String description; | ||
private final HttpStatus statusCode; | ||
} |
13 changes: 13 additions & 0 deletions
13
src/main/java/org/websoso/WSSServer/exception/exception/CustomAppleLoginException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
package org.websoso.WSSServer.exception.exception; | ||
|
||
import lombok.Getter; | ||
import org.websoso.WSSServer.exception.common.AbstractCustomException; | ||
import org.websoso.WSSServer.exception.error.CustomAppleLoginError; | ||
|
||
@Getter | ||
public class CustomAppleLoginException extends AbstractCustomException { | ||
|
||
public CustomAppleLoginException(CustomAppleLoginError customAppleLoginError, String message) { | ||
super(customAppleLoginError, message); | ||
} | ||
} |
27 changes: 27 additions & 0 deletions
27
src/main/java/org/websoso/WSSServer/oauth2/controller/AppleController.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
package org.websoso.WSSServer.oauth2.controller; | ||
|
||
import static org.springframework.http.HttpStatus.OK; | ||
|
||
import jakarta.servlet.http.HttpServletRequest; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.http.ResponseEntity; | ||
import org.springframework.web.bind.annotation.PostMapping; | ||
import org.springframework.web.bind.annotation.RequestMapping; | ||
import org.springframework.web.bind.annotation.RestController; | ||
import org.websoso.WSSServer.dto.auth.AuthResponse; | ||
import org.websoso.WSSServer.oauth2.service.AppleService; | ||
|
||
@RestController | ||
@RequiredArgsConstructor | ||
@RequestMapping("/login") | ||
public class AppleController { | ||
|
||
private final AppleService appleService; | ||
|
||
@PostMapping("/callback") | ||
public ResponseEntity<AuthResponse> callback(HttpServletRequest request) { | ||
return ResponseEntity | ||
.status(OK) | ||
.body(appleService.getAppleUserInfo(request.getParameter("code"))); | ||
} | ||
} |
198 changes: 198 additions & 0 deletions
198
src/main/java/org/websoso/WSSServer/oauth2/service/AppleService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,198 @@ | ||
package org.websoso.WSSServer.oauth2.service; | ||
|
||
import static org.websoso.WSSServer.exception.error.CustomAppleLoginError.CLIENT_SECRET_CREATION_FAILED; | ||
import static org.websoso.WSSServer.exception.error.CustomAppleLoginError.ID_TOKEN_PARSE_FAILED; | ||
import static org.websoso.WSSServer.exception.error.CustomAppleLoginError.MISSING_AUTHORIZATION_CODE; | ||
import static org.websoso.WSSServer.exception.error.CustomAppleLoginError.PRIVATE_KEY_READ_FAILED; | ||
import static org.websoso.WSSServer.exception.error.CustomAppleLoginError.TOKEN_REQUEST_FAILED; | ||
import static org.websoso.WSSServer.exception.error.CustomAppleLoginError.USER_INFO_RETRIEVAL_FAILED; | ||
|
||
import com.fasterxml.jackson.databind.ObjectMapper; | ||
import com.nimbusds.jose.JWSAlgorithm; | ||
import com.nimbusds.jose.JWSHeader; | ||
import com.nimbusds.jose.JWSSigner; | ||
import com.nimbusds.jose.crypto.ECDSASigner; | ||
import com.nimbusds.jwt.JWTClaimsSet; | ||
import com.nimbusds.jwt.ReadOnlyJWTClaimsSet; | ||
import com.nimbusds.jwt.SignedJWT; | ||
import java.io.FileReader; | ||
import java.io.IOException; | ||
import java.security.KeyFactory; | ||
import java.security.interfaces.ECPrivateKey; | ||
import java.security.spec.PKCS8EncodedKeySpec; | ||
import java.util.Date; | ||
import lombok.RequiredArgsConstructor; | ||
import org.bouncycastle.util.io.pem.PemObject; | ||
import org.bouncycastle.util.io.pem.PemReader; | ||
import org.json.simple.JSONObject; | ||
import org.json.simple.parser.JSONParser; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.core.io.ClassPathResource; | ||
import org.springframework.core.io.Resource; | ||
import org.springframework.http.HttpEntity; | ||
import org.springframework.http.HttpHeaders; | ||
import org.springframework.http.HttpMethod; | ||
import org.springframework.http.ResponseEntity; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.util.LinkedMultiValueMap; | ||
import org.springframework.util.MultiValueMap; | ||
import org.springframework.web.client.RestTemplate; | ||
import org.websoso.WSSServer.dto.auth.AuthResponse; | ||
import org.websoso.WSSServer.exception.exception.CustomAppleLoginException; | ||
import org.websoso.WSSServer.service.UserService; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
public class AppleService { | ||
|
||
private static final String GRANT_TYPE = "authorization_code"; | ||
private static final String CONTENT_TYPE = "application/x-www-form-urlencoded"; | ||
private static final String APPLE_PREFIX = "Apple"; | ||
|
||
@Value("${apple.auth.expiration-time}") | ||
private long tokenExpirationTime; | ||
|
||
@Value("${apple.auth.team-id}") | ||
private String appleTeamId; | ||
|
||
@Value("${apple.auth.key.id}") | ||
private String appleLoginKey; | ||
|
||
@Value("${apple.auth.client-id}") | ||
private String appleClientId; | ||
|
||
@Value("${apple.auth.redirect-url}") | ||
private String appleRedirectUrl; | ||
|
||
@Value("${apple.auth.key.path}") | ||
private String appleKeyPath; | ||
|
||
@Value("${apple.auth.iss}") | ||
private String appleAuthUrl; | ||
|
||
private final UserService userService; | ||
|
||
public AuthResponse getAppleUserInfo(String authorizationCode) { | ||
validateAuthorizationCode(authorizationCode); | ||
String clientSecret = createClientSecret(); | ||
|
||
try { | ||
JSONObject tokenResponse = requestToken(authorizationCode, clientSecret); | ||
JSONObject payload = parseIdToken((String) tokenResponse.get("id_token")); | ||
|
||
String socialId = (String) payload.get("sub"); | ||
String email = (String) payload.get("email"); | ||
|
||
String customSocialId = APPLE_PREFIX + "_" + socialId; | ||
String defaultNickname = APPLE_PREFIX.charAt(0) + "*" + socialId.substring(2, 10); | ||
|
||
return userService.authenticateWithApple(customSocialId, email, defaultNickname); | ||
} catch (Exception e) { | ||
throw new CustomAppleLoginException(USER_INFO_RETRIEVAL_FAILED, | ||
"Failed to retrieve user information from Apple"); | ||
} | ||
} | ||
|
||
private void validateAuthorizationCode(String code) { | ||
if (code == null || code.isBlank()) { | ||
throw new CustomAppleLoginException(MISSING_AUTHORIZATION_CODE, "Authorization code is missing"); | ||
} | ||
} | ||
|
||
private JSONObject requestToken(String authorizationCode, String clientSecret) { | ||
try { | ||
HttpHeaders headers = createHttpHeaders(); | ||
MultiValueMap<String, String> params = createTokenRequestParams(authorizationCode, clientSecret); | ||
|
||
RestTemplate restTemplate = new RestTemplate(); | ||
HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(params, headers); | ||
ResponseEntity<String> response = restTemplate.exchange( | ||
appleAuthUrl + "/auth/token", | ||
HttpMethod.POST, | ||
httpEntity, | ||
String.class | ||
); | ||
|
||
JSONParser jsonParser = new JSONParser(); | ||
return (JSONObject) jsonParser.parse(response.getBody()); | ||
} catch (Exception e) { | ||
throw new CustomAppleLoginException(TOKEN_REQUEST_FAILED, "Failed to get token from Apple server"); | ||
} | ||
} | ||
|
||
private HttpHeaders createHttpHeaders() { | ||
HttpHeaders headers = new HttpHeaders(); | ||
headers.add("Content-Type", CONTENT_TYPE); | ||
return headers; | ||
} | ||
|
||
private MultiValueMap<String, String> createTokenRequestParams(String authorizationCode, String clientSecret) { | ||
MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); | ||
params.add("grant_type", GRANT_TYPE); | ||
params.add("client_id", appleClientId); | ||
params.add("client_secret", clientSecret); | ||
params.add("code", authorizationCode); | ||
params.add("redirect_uri", appleRedirectUrl); | ||
return params; | ||
} | ||
|
||
private JSONObject parseIdToken(String idToken) { | ||
try { | ||
SignedJWT signedJWT = SignedJWT.parse(idToken); | ||
ReadOnlyJWTClaimsSet claimsSet = signedJWT.getJWTClaimsSet(); | ||
ObjectMapper objectMapper = new ObjectMapper(); | ||
return objectMapper.readValue(claimsSet.toJSONObject().toJSONString(), JSONObject.class); | ||
} catch (Exception e) { | ||
throw new CustomAppleLoginException(ID_TOKEN_PARSE_FAILED, "Failed to parse Apple ID token"); | ||
} | ||
} | ||
|
||
private String createClientSecret() { | ||
try { | ||
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(appleLoginKey).build(); | ||
JWTClaimsSet claimsSet = buildJwtClaimsSet(); | ||
|
||
SignedJWT jwt = new SignedJWT(header, claimsSet); | ||
signJwt(jwt); | ||
|
||
return jwt.serialize(); | ||
} catch (Exception e) { | ||
throw new CustomAppleLoginException(CLIENT_SECRET_CREATION_FAILED, "Failed to generate client secret"); | ||
} | ||
} | ||
|
||
private JWTClaimsSet buildJwtClaimsSet() { | ||
Date now = new Date(); | ||
JWTClaimsSet claimsSet = new JWTClaimsSet(); | ||
|
||
claimsSet.setIssuer(appleTeamId); | ||
claimsSet.setIssueTime(now); | ||
claimsSet.setExpirationTime(new Date(now.getTime() + tokenExpirationTime)); | ||
claimsSet.setAudience(appleAuthUrl); | ||
claimsSet.setSubject(appleClientId); | ||
|
||
return claimsSet; | ||
} | ||
|
||
private void signJwt(SignedJWT jwt) { | ||
try { | ||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(readPrivateKey(appleKeyPath)); | ||
KeyFactory keyFactory = KeyFactory.getInstance("EC"); | ||
ECPrivateKey ecPrivateKey = (ECPrivateKey) keyFactory.generatePrivate(spec); | ||
JWSSigner signer = new ECDSASigner(ecPrivateKey.getS()); | ||
jwt.sign(signer); | ||
} catch (Exception e) { | ||
throw new CustomAppleLoginException(CLIENT_SECRET_CREATION_FAILED, "Failed to create client secret"); | ||
} | ||
} | ||
|
||
private byte[] readPrivateKey(String keyPath) { | ||
Resource resource = new ClassPathResource(keyPath); | ||
try (PemReader pemReader = new PemReader(new FileReader(resource.getFile()))) { | ||
PemObject pemObject = pemReader.readPemObject(); | ||
return pemObject.getContent(); | ||
} catch (IOException e) { | ||
throw new CustomAppleLoginException(PRIVATE_KEY_READ_FAILED, "Failed to read private key"); | ||
} | ||
} | ||
} |
Oops, something went wrong.