Skip to content

Commit

Permalink
Merge pull request #87 from Central-MakeUs/dev
Browse files Browse the repository at this point in the history
[Feature] 애플 로그인 구현
  • Loading branch information
dainnida authored Feb 10, 2025
2 parents 18977d2 + 9c115ec commit f64e2ad
Show file tree
Hide file tree
Showing 11 changed files with 361 additions and 17 deletions.
7 changes: 7 additions & 0 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ jobs:
spring.security.oauth2.client.registration.google.client-id: ${{ secrets.OAUTH_GOOGLE_ID }}
spring.security.oauth2.client.registration.google.client-secret: ${{ secrets.OAUTH_GOOGLE_SECRET }}
spring.security.oauth2.client.registration.google.redirect-uri: ${{ secrets.SERVER_HTTPS_URL }}/login/oauth2/code/google
spring.security.oauth2.client.registration.apple.client-id: ${{ secrets.OAUTH_APPLE_CID }}
spring.security.oauth2.client.registration.apple.client-secret: ${{ secrets.OAUTH_APPLE_P8 }}
spring.security.oauth2.client.registration.apple.redirect-uri: ${{ secrets.SERVER_HTTPS_URL }}/login/oauth2/code/apple
apple.key-content: ${{ secrets.OAUTH_APPLE_P8_KEY_CONTENT }}
apple.cid: ${{ secrets.OAUTH_APPLE_CID }}
apple.tid: ${{ secrets.OAUTH_APPLE_TID }}
apple.kid: ${{ secrets.OAUTH_APPLE_KID }}


# 실행 속도 향상을 위한 Gradle 종속성 캐싱
Expand Down
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ dependencies {
// 소셜로그인
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'com.auth0:java-jwt:4.4.0'
implementation 'org.bouncycastle:bcpkix-jdk18on:1.77'
implementation 'org.bouncycastle:bcprov-jdk18on:1.77'
// implementation 'commons-io:commons-io:2.14.0' // IOUtils 사용을 위해
// implementation 'com.nimbusds:nimbus-jose-jwt:9.31'
implementation 'com.auth0:jwks-rsa:0.20.2'

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand Down
23 changes: 21 additions & 2 deletions src/main/java/com/cmc/mercury/global/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.cmc.mercury.domain.user.repository.UserRepository;
import com.cmc.mercury.global.jwt.JwtAuthenticationFilter;
import com.cmc.mercury.global.jwt.JwtProvider;
import com.cmc.mercury.global.oauth.apple.AppleClientSecretService;
import com.cmc.mercury.global.oauth.apple.CustomRequestEntityConverter;
import com.cmc.mercury.global.oauth.handler.OAuth2FailureHandler;
import com.cmc.mercury.global.oauth.handler.OAuth2SuccessHandler;
import com.cmc.mercury.global.oauth.service.CustomOAuth2UserService;
Expand All @@ -12,6 +14,9 @@
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
Expand All @@ -31,6 +36,16 @@ public class SecurityConfig {
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final OAuth2FailureHandler oAuth2FailureHandler;
private final CustomOAuth2UserService customOAuth2UserService;
private final AppleClientSecretService appleClientSecretService;

@Bean
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {

DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient();
client.setRequestEntityConverter(new CustomRequestEntityConverter(appleClientSecretService));

return client;
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
Expand All @@ -40,17 +55,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.formLogin(formLogin -> formLogin.disable())
.httpBasic(httpBasic -> httpBasic.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
// Spring Security는 OAuth2 로그인 플로우에서 인증 요청 시 생성된 OAuth2AuthorizationRequest 객체를 HTTP 세션에 저장해두는데,
// STATELESS 설정 시 OAuth2AuthorizationRequest가 저장되지 않으므로 콜백 요청 시 이를 찾지 못해 authorization_request_not_found 오류가 발생
.authorizeHttpRequests(authorize -> authorize
// Swagger UI 관련 경로 허용
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/api-docs/**", "/swagger-resources/**").permitAll()
// OAuth2 관련 경로 허용
.requestMatchers("/login/**", "/oauth2/**").permitAll()
// 도서 검색
// 도서 검색, 사용자 api
.requestMatchers("/books/search", "/users/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.tokenEndpoint(token -> token
.accessTokenResponseClient(accessTokenResponseClient()))
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService))
.successHandler(oAuth2SuccessHandler)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ public enum ErrorCode {
OAUTH2_PROCESSING_ERROR(HttpStatus.UNAUTHORIZED, "OAuth401", "소셜 로그인 처리 중 오류가 발생했습니다."),
OAUTH2_LOGIN_FAIL(HttpStatus.UNAUTHORIZED, "OAuth401", "소셜 로그인에 실패했습니다."),

// Apple
APPLE_CLIENT_SECRET_ERROR(HttpStatus.BAD_REQUEST, "Apple400", "Apple client secret 생성 실패"),
APPLE_PRIVATE_KEY_ERROR(HttpStatus.BAD_REQUEST, "Apple400", "Apple private key 생성 실패"),
APPLE_TOKEN_VALIDATION_ERROR(HttpStatus.UNAUTHORIZED, "Apple401", "Apple ID 토큰 검증 실패"),

// JWT
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "Jwt401", "유효하지 않은 토큰입니다."),
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "Jwt401", "만료된 토큰입니다."),
Expand All @@ -63,4 +68,4 @@ public enum ErrorCode {
private final HttpStatus httpStatus;
private final String code;
private final String message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.cmc.mercury.global.oauth.apple;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.cmc.mercury.global.exception.CustomException;
import com.cmc.mercury.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
//import org.apache.commons.io.IOUtils;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.springframework.beans.factory.annotation.Value;
//import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;

//import java.io.IOException;
//import java.io.InputStream;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
import java.security.interfaces.ECPrivateKey;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Service
@RequiredArgsConstructor
@Slf4j
public class AppleClientSecretService {

// @Value("${apple.key-path}")
// private String keyPath;
@Value("${apple.key-content}")
private String appleKeyContent;

@Value("${apple.kid}")
private String keyId;

@Value("${apple.tid}")
private String teamId;

@Value("${apple.cid}")
private String clientId;

public String createClientSecret() {
try {
// 직접 파일 업로드 시 사용
// log.info("Reading private key from path: {}", keyPath);
// PrivateKey privateKey = getPrivateKey();

// Base64 디코딩
byte[] decoded = Base64.getDecoder().decode(appleKeyContent);
// PEM 파싱 → PrivateKey
PrivateKey privateKey = getPrivateKey(new String(decoded, StandardCharsets.UTF_8));
log.info("Private key successfully created");

Map<String, Object> headerClaims = new HashMap<>();
headerClaims.put("kid", keyId);
headerClaims.put("alg", "ES256");

Date expirationDate = Date.from(LocalDateTime.now().plusDays(30)
.atZone(ZoneId.systemDefault()).toInstant());
// code/token 교환 직전에 항상 client secret 새로 생성
String clientSecret = JWT.create()
.withHeader(headerClaims)
.withKeyId(keyId)
.withIssuer(teamId)
.withAudience("https://appleid.apple.com")
.withSubject(clientId)
.withExpiresAt(expirationDate)
.withIssuedAt(new Date(System.currentTimeMillis()))
.sign(Algorithm.ECDSA256(null, (ECPrivateKey) privateKey));

log.info("Generated client secret: {}", clientSecret);

// JWT 디코딩해서 내용 확인
DecodedJWT jwt = JWT.decode(clientSecret);
log.info("Decoded JWT - Header: {}", jwt.getHeader());
log.info("Decoded JWT - Payload: {}", jwt.getPayload());

return clientSecret;

} catch (Exception e) {
throw new CustomException(ErrorCode.APPLE_CLIENT_SECRET_ERROR);
}
}

public PrivateKey getPrivateKey(String keyContent) throws IOException {

try {
// 직접 파일 업로드 시 사용
// ClassPathResource resource = new ClassPathResource(keyPath);
// InputStream in = resource.getInputStream();

// PEMParser pemParser = new PEMParser(new StringReader(IOUtils.toString(in, StandardCharsets.UTF_8)));
PEMParser pemParser = new PEMParser(new StringReader(keyContent));
PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();
log.info("PEMParser created");
log.info("PEM object read, type: {}", object != null ? object.getClass().getName() : "null");
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();

return converter.getPrivateKey(object);

} catch (IOException e) {
throw new CustomException(ErrorCode.APPLE_PRIVATE_KEY_ERROR);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.cmc.mercury.global.oauth.apple;


import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.UrlJwkProvider;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.cmc.mercury.global.exception.CustomException;
import com.cmc.mercury.global.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.net.URL;
import java.security.PublicKey;
import java.security.interfaces.ECPublicKey;
import java.security.interfaces.RSAPublicKey;

@Service
@Slf4j
public class AppleIdTokenVerifier {

@Value("${apple.cid}")
private String clientId;

public DecodedJWT verify(String idToken) {
try {

// id_token 헤더 파싱 (kid, alg 등 확인)
DecodedJWT unverifiedJWT = JWT.decode(idToken);
String keyId = unverifiedJWT.getKeyId();
String algFromToken = unverifiedJWT.getAlgorithm();
log.info("Apple id_token의 kid = {}, alg = {}", keyId, algFromToken);

// 애플의 공개키 엔드포인트 URL
URL appleJwkUrl = new URL("https://appleid.apple.com/auth/keys");
JwkProvider jwkProvider = new UrlJwkProvider(appleJwkUrl);
Jwk jwk = jwkProvider.get(keyId);
PublicKey publicKey = jwk.getPublicKey();
log.info("애플 공개키 성공적으로 가져옴, key type: {}", publicKey.getAlgorithm());

// 공개키가 RSA인지 EC인지 구분하여 Algorithm 인스턴스 생성
Algorithm algorithm;
if ("EC".equalsIgnoreCase(publicKey.getAlgorithm())) {
// EC 키인 경우
algorithm = Algorithm.ECDSA256((ECPublicKey) publicKey, null);
} else if ("RSA".equalsIgnoreCase(publicKey.getAlgorithm())) {
// RSA 키인 경우
algorithm = Algorithm.RSA256((RSAPublicKey) publicKey, null);
} else {
throw new IllegalArgumentException("지원하지 않는 공개키 타입: " + publicKey.getAlgorithm());
}

// JWTVerifier를 생성하여 서명, issuer, audience 등의 클레임 검증
JWTVerifier verifier = JWT.require(algorithm)
.withIssuer("https://appleid.apple.com")
.withAudience(clientId)
.build();

// 서명 및 필수 클레임 검증
DecodedJWT verifiedJwt = verifier.verify(idToken);
log.info("id_token 검증 성공: subject={}, issuer={}", verifiedJwt.getSubject(), verifiedJwt.getIssuer());

return verifiedJwt;

} catch (Exception e) {
log.error("id_token 검증 실패", e);
throw new CustomException(ErrorCode.APPLE_TOKEN_VALIDATION_ERROR);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.cmc.mercury.global.oauth.apple;

import com.cmc.mercury.global.exception.CustomException;
import com.cmc.mercury.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.RequestEntity;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;

@Component
@RequiredArgsConstructor
@Slf4j
public class CustomRequestEntityConverter implements Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {

private final AppleClientSecretService appleClientSecretService;
private final OAuth2AuthorizationCodeGrantRequestEntityConverter defaultConverter =
new OAuth2AuthorizationCodeGrantRequestEntityConverter();

@Override
public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest request) {

log.info("Converting OAuth2 request for provider: {}", request.getClientRegistration().getRegistrationId());
RequestEntity<?> entity = defaultConverter.convert(request);
String registrationId = request.getClientRegistration().getRegistrationId();

MultiValueMap<String, String> params = (MultiValueMap<String, String>) entity.getBody();

if ("apple".equals(registrationId)) {
try {
// client_secret JWT 생성 및 설정
String clientSecret = appleClientSecretService.createClientSecret();
params.set("client_secret", clientSecret);

} catch (Exception e) {
throw new CustomException(ErrorCode.APPLE_CLIENT_SECRET_ERROR);
}
}

log.info("Converter의 param: {}", params);

return new RequestEntity<>(
params,
entity.getHeaders(),
entity.getMethod(),
entity.getUrl()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo

log.error("Social Login Failed: {}", exception.getMessage());

String targetUrl = UriComponentsBuilder.fromUriString("/login/fail")
.queryParam("redirect_url", "https://www.mercuryplanet.co.kr/")
String targetUrl = UriComponentsBuilder.fromUriString("https://www.mercuryplanet.co.kr/login/fail")
.build(true).toUriString();

getRedirectStrategy().sendRedirect(request, response, targetUrl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,24 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
// JWT 토큰 생성
String accessToken = jwtProvider.createAccessToken(user.getId(), user.getEmail());
String refreshToken = jwtProvider.createRefreshToken(user.getId(), user.getEmail());
log.info("accessToken, refreshToken: {}, {}", accessToken, refreshToken);

// Access Token은 Authorization 헤더에 추가
response.setHeader("Authorization", "Bearer " + accessToken);
log.info("Header에 설정은 성공");

// Refresh Token은 보안을 위해 HttpOnly 쿠키로 설정
Cookie refreshTokenCookie = new Cookie("refresh_token", refreshToken);
refreshTokenCookie.setHttpOnly(true); // JavaScript에서 접근 방지
refreshTokenCookie.setSecure(true); // HTTPS만 허용
// refreshTokenCookie.setSecure(true); // HTTPS만 허용
refreshTokenCookie.setPath("/"); // 모든 경로에서 접근 가능
refreshTokenCookie.setDomain("mercuryplanet.co.kr"); // 도메인 간 쿠키 공유
// refreshTokenCookie.setDomain("mercuryplanet.co.kr"); // 도메인 간 쿠키 공유
refreshTokenCookie.setMaxAge((int) refreshTokenValidity / 1000); // ms를 초 단위로 변환
response.addCookie(refreshTokenCookie);

// 리다이렉트 URL에 토큰 포함하여 이동
String targetUrl = UriComponentsBuilder.fromUriString("/login/success")
String targetUrl = UriComponentsBuilder.fromUriString("https://www.mercuryplanet.co.kr/login/success")
.queryParam("access_token", accessToken)
.queryParam("redirect_url", "https://www.mercuryplanet.co.kr/home")
.build(true).toUriString();

getRedirectStrategy().sendRedirect(request, response, targetUrl);
Expand Down
Loading

0 comments on commit f64e2ad

Please sign in to comment.