Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] 애플 소셜 로그인 구현 #181

Merged
merged 19 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
ChaeAg marked this conversation as resolved.
Show resolved Hide resolved
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");
}
}
ChaeAg marked this conversation as resolved.
Show resolved Hide resolved

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");
}
}
ChaeAg marked this conversation as resolved.
Show resolved Hide resolved

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
Loading