Skip to content

Commit

Permalink
[MERGE] Merge pull request #181 from Team-WSS/feat/#159
Browse files Browse the repository at this point in the history
[FEAT] 애플 소셜 로그인 구현
  • Loading branch information
ChaeAg authored Oct 2, 2024
2 parents 6c070e0 + 99b2c07 commit 0d95783
Show file tree
Hide file tree
Showing 12 changed files with 333 additions and 10 deletions.
7 changes: 7 additions & 0 deletions .github/workflows/CD.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ jobs:
echo "${{ secrets.APPLICATION }}" > ./application.yml
echo "${{ secrets.APPLICATION_PROD }}" > ./application-prod.yml
- name: Create apple login key file
env:
API_KEY: ${{ secrets.APPSTORE_API_KEY_ID }}
run: |
mkdir -p src/main/resources/static
echo "${{ secrets.APPLE_AUTH_KEY }}" > src/main/resources/static/AuthKey_$API_KEY.p8
- name: Grant execute permission for gradlew
run: chmod +x gradlew
shell: bash
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ jobs:
echo "${{ secrets.APPLICATION }}" > ./application.yml
echo "${{ secrets.APPLICATION_PROD }}" > ./application-prod.yml
- name: Create apple login key file
env:
API_KEY: ${{ secrets.APPSTORE_API_KEY_ID }}
run: |
mkdir -p src/main/resources/static
echo "${{ secrets.APPLE_AUTH_KEY }}" > src/main/resources/static/AuthKey_$API_KEY.p8
- name: Grant execute permission for gradlew
run: chmod +x gradlew
shell: bash
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ out/

## QClass ##
src/main/generated/

## Apple Login Auth Key File ##
*.p8
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ dependencies {

// spring data redis
implementation 'org.springframework.data:spring-data-redis:3.3.2'

//Apple Login
implementation 'com.nimbusds:nimbus-jose-jwt:3.10'

//Json
implementation 'com.googlecode.json-simple:json-simple:1.1.1'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ public class SecurityConfig {
"/users/{userId}/feeds",
"/users/profile/{userId}",
"/{userId}/preferences/genres",
"/reissue"
"/reissue",
"/login/callback",
"/login/apple"
};

@Bean
Expand Down Expand Up @@ -82,5 +84,4 @@ public void addCorsMappings(CorsRegistry registry) {
}
};
}

}
13 changes: 7 additions & 6 deletions src/main/java/org/websoso/WSSServer/domain/User.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.websoso.WSSServer.domain;

import static org.websoso.WSSServer.domain.common.Gender.M;
import static org.websoso.WSSServer.domain.common.Role.USER;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
Expand Down Expand Up @@ -98,19 +99,14 @@ public void updateUserInfo(RegisterUserInfoRequest registerUserInfoRequest) {
public UserBasicInfo getUserBasicInfo(String avatarImage) {
return UserBasicInfo.of(this.getUserId(), this.getNickname(), avatarImage);
}

public void editMyInfo(EditMyInfoRequest editMyInfoRequest) {
this.gender = Gender.valueOf(editMyInfoRequest.gender());
this.birth = Year.of(editMyInfoRequest.birth());
}

private User(String socialId, String nickname, String email) {
this.intro = "안녕하세요";
this.gender = M;
this.birth = Year.now();
this.avatarId = 1;
this.isProfilePublic = true;
this.role = Role.USER;
this.role = USER;
this.socialId = socialId;
this.nickname = nickname;
this.email = email;
Expand All @@ -119,4 +115,9 @@ private User(String socialId, String nickname, String email) {
public static User createBySocial(String socialId, String nickname, String email) {
return new User(socialId, nickname, email);
}

public void editMyInfo(EditMyInfoRequest editMyInfoRequest) {
this.gender = Gender.valueOf(editMyInfoRequest.gender());
this.birth = Year.of(editMyInfoRequest.birth());
}
}
12 changes: 12 additions & 0 deletions src/main/java/org/websoso/WSSServer/dto/auth/AuthResponse.java
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);
}
}
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;
}
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);
}
}
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 src/main/java/org/websoso/WSSServer/oauth2/service/AppleService.java
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");
}
}
}
Loading

0 comments on commit 0d95783

Please sign in to comment.