Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ dependencies {
// flyway
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'

// Apple ID Token 검증
implementation 'com.nimbusds:nimbus-jose-jwt:10.0.2'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public enum ErrorCode {
NEED_SIGN_UP(HttpStatus.BAD_REQUEST, "회원가입이 필요합니다."),
ALREADY_REGISTERED_WITH_GOOGLE(HttpStatus.BAD_REQUEST, "해당 이메일은 구글로 가입된 계정입니다."),
ALREADY_REGISTERED_WITH_KAKAO(HttpStatus.BAD_REQUEST, "해당 이메일은 카카오로 가입된 계정입니다."),
ALREADY_REGISTERED_WITH_APPLE(HttpStatus.BAD_REQUEST, "해당 계정은 Apple로 이미 가입되어 있습니다."),
INVALID_ID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 ID TOKEN 입니다."),
INVALID_KAKAO_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 카카오 Access Token 입니다."),
ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "만료된 Access Token 입니다."),
Expand All @@ -22,6 +23,14 @@ public enum ErrorCode {
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증되지 않은 요청입니다."),
INVALID_NICKNAME(HttpStatus.BAD_REQUEST, "사용할 수 없는 닉네임입니다."),

APPLE_JWKS_FETCH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple 공개키 조회에 실패했습니다."),
APPLE_PUBLIC_KEY_NOT_FOUND(HttpStatus.UNAUTHORIZED, "Apple 공개키를 찾을 수 없습니다."),
APPLE_PUBLIC_KEY_BUILD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Apple 공개키 생성에 실패했습니다."),
INVALID_APPLE_ID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 Apple ID Token 입니다."),
INVALID_APPLE_ID_TOKEN_SIGNATURE(HttpStatus.UNAUTHORIZED, "Apple ID Token 서명 검증에 실패했습니다."),
INVALID_APPLE_ID_TOKEN_CLAIMS(HttpStatus.UNAUTHORIZED, "Apple ID Token 정보가 올바르지 않습니다."),
APPLE_ID_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "Apple ID Token이 만료되었습니다."),

CHAT_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "채팅방을 찾을 수 없습니다."),
CHAT_ROOM_ACCESS_DENIED(HttpStatus.FORBIDDEN, "채팅방에 접근할 수 없습니다."),
TICKET_NOT_ENOUGH(HttpStatus.BAD_REQUEST, "티켓이 부족하여 대화를 시작할 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.example.emotion_storage.user.auth.oauth.apple;

import com.example.emotion_storage.global.exception.BaseException;
import com.example.emotion_storage.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;

@Component
@RequiredArgsConstructor
public class AppleJwksClient {

public static final String APPLE_JWKS_URI = "https://appleid.apple.com/auth/keys";

private final RestClient appleRestClient;

public AppleJwksResponse fetchKeys() {
try {
AppleJwksResponse response = appleRestClient.get()
.uri(APPLE_JWKS_URI)
.retrieve()
.body(AppleJwksResponse.class);

if (response == null || response.keys() == null || response.keys().isEmpty()) {
throw new BaseException(ErrorCode.APPLE_JWKS_FETCH_FAILED);
}
return response;
} catch (RestClientResponseException e) {
throw new BaseException(ErrorCode.APPLE_JWKS_FETCH_FAILED);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.example.emotion_storage.user.auth.oauth.apple;

import java.util.List;

public record AppleJwksResponse(
List<AppleJwk> keys
) {
public record AppleJwk(
String kty,
String kid,
String use,
String alg,
String n,
String e
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.emotion_storage.user.auth.oauth.apple;

public record AppleLoginClaims (
String subject,
String email
){}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.example.emotion_storage.user.auth.oauth.apple;

import com.example.emotion_storage.global.exception.BaseException;
import com.example.emotion_storage.global.exception.ErrorCode;
import com.example.emotion_storage.user.auth.oauth.apple.AppleJwksResponse.AppleJwk;
import com.example.emotion_storage.user.auth.oauth.apple.config.AppleOauthProperties;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.text.ParseException;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class AppleTokenVerifier {

private static final String APPLE_ISSUER = "https://appleid.apple.com";

private final AppleJwksClient jwksClient;
private final AppleOauthProperties appleOauthProperties;

public AppleLoginClaims verifyLoginToken(String idToken) {
SignedJWT signedJWT = parse(idToken);

String kid = signedJWT.getHeader().getKeyID();
if (kid == null || kid.isBlank()) {
throw new BaseException(ErrorCode.INVALID_APPLE_ID_TOKEN);
}

AppleJwk jwk = jwksClient.fetchKeys().keys().stream()
.filter(k -> kid.equals(k.kid()))
.findFirst()
.orElseThrow(() -> new BaseException(ErrorCode.APPLE_PUBLIC_KEY_NOT_FOUND));

RSAPublicKey publicKey = toPublicKey(jwk);

verifySignature(signedJWT, publicKey);

JWTClaimsSet claims = getClaims(signedJWT);
validateClaims(claims);

return new AppleLoginClaims(
claims.getSubject(),
safeStringClaim(claims, "email")
);
}

private SignedJWT parse(String idToken) {
try {
return SignedJWT.parse(idToken);
} catch (ParseException e) {
throw new BaseException(ErrorCode.INVALID_APPLE_ID_TOKEN);
}
}

private void verifySignature(SignedJWT jwt, RSAPublicKey publicKey) {
try {
boolean ok = jwt.verify(new RSASSAVerifier(publicKey));
if (!ok) {
throw new BaseException(ErrorCode.INVALID_APPLE_ID_TOKEN_SIGNATURE);
}
} catch (JOSEException e) {
throw new BaseException(ErrorCode.INVALID_APPLE_ID_TOKEN_SIGNATURE);
}
}

private JWTClaimsSet getClaims(SignedJWT jwt) {
try {
return jwt.getJWTClaimsSet();
} catch (ParseException e) {
throw new BaseException(ErrorCode.INVALID_APPLE_ID_TOKEN);
}
}

private void validateClaims(JWTClaimsSet claims) {
// iss
if (!APPLE_ISSUER.equals(claims.getIssuer())) {
throw new BaseException(ErrorCode.INVALID_APPLE_ID_TOKEN_CLAIMS);
}

// aud(Bundle ID)
String audience = appleOauthProperties.audience();
if (claims.getAudience() == null || claims.getAudience().stream().noneMatch(audience::equals)) {
throw new BaseException(ErrorCode.INVALID_APPLE_ID_TOKEN_CLAIMS);
}

// exp
Date exp = claims.getExpirationTime();
if (exp == null || exp.toInstant().isBefore(Instant.now())) {
throw new BaseException(ErrorCode.APPLE_ID_TOKEN_EXPIRED);
}

// sub
String sub = claims.getSubject();
if (sub == null || sub.isBlank()) {
throw new BaseException(ErrorCode.INVALID_APPLE_ID_TOKEN_CLAIMS);
}
}

private RSAPublicKey toPublicKey(AppleJwk jwk) {
try {
BigInteger n = new BigInteger(1, Base64.getUrlDecoder().decode(jwk.n()));
BigInteger e = new BigInteger(1, Base64.getUrlDecoder().decode(jwk.e()));
RSAPublicKeySpec spec = new RSAPublicKeySpec(n, e);
return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec);
} catch (Exception e) {
throw new BaseException(ErrorCode.APPLE_PUBLIC_KEY_BUILD_FAILED);
}
}

private String safeStringClaim(JWTClaimsSet claims, String key) {
try {
return claims.getStringClaim(key);
} catch (Exception e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.emotion_storage.user.auth.oauth.apple.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "apple")
public record AppleOauthProperties(
String audience
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.emotion_storage.user.auth.oauth.apple.config;

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(AppleOauthProperties.class)
public class AppleOauthPropertiesConfig {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.example.emotion_storage.user.auth.oauth.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;

@Configuration
public class OauthConfig {

@Bean
public RestClient.Builder restClientBuilder() {
return RestClient.builder();
}

// Kakao 전용 RestClient
@Bean
public RestClient kakaoRestClient(RestClient.Builder builder) {
return builder.build();
}

// Apple 전용 RestClient
@Bean
public RestClient appleRestClient(RestClient.Builder builder) {
return builder.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.example.emotion_storage.global.exception.BaseException;
import com.example.emotion_storage.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
Expand All @@ -15,11 +16,11 @@ public class KakaoUserInfoClient {
private static final String KAKAO_USER_INFO_URI = "https://kapi.kakao.com/v2/user/me";
private static final String BEARER_HEADER_FORMAT = "Bearer %s";

private final RestClient restClient;
private final RestClient kakaoRestClient;

public KakaoUserInfo getKakaoUserInfo(String accessToken) {
try {
KakaoUserInfo userInfo = restClient.get()
KakaoUserInfo userInfo = kakaoRestClient.get()
.uri(KAKAO_USER_INFO_URI)
.header(HttpHeaders.AUTHORIZATION, makeBearerFormat(accessToken))
.retrieve()
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.example.emotion_storage.global.api.ApiResponse;
import com.example.emotion_storage.global.api.SuccessMessage;
import com.example.emotion_storage.user.dto.request.AppleLoginRequest;
import com.example.emotion_storage.user.dto.request.AppleSignUpRequest;
import com.example.emotion_storage.user.dto.request.GoogleLoginRequest;
import com.example.emotion_storage.user.dto.request.GoogleSignUpRequest;
import com.example.emotion_storage.user.dto.request.KakaoLoginRequest;
Expand Down Expand Up @@ -59,8 +61,8 @@ public ApiResponse<SignupResponse> signupWithGoogle(@RequestBody GoogleSignUpReq
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "로그인 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "회원가입 필요"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "유효하지 않은 액세스 토큰"),
//@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "다른 소셜로 이미 가입")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "유효하지 않은 ID 토큰"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "다른 소셜로 이미 가입")
})
@PostMapping("/login/kakao")
public ApiResponse<LoginResponse> loginWithKakao(
Expand All @@ -72,12 +74,38 @@ public ApiResponse<LoginResponse> loginWithKakao(
@Operation(summary = "카카오 회원가입", description = "카카오 액세스 토큰 검증 후 신규 회원을 생성합니다.")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "회원가입 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "유효하지 않은 액세스 토큰"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "유효하지 않은 ID 토큰"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 가입된 계정")
})
@PostMapping("/signup/kakao")
public ApiResponse<SignupResponse> signupWithKakao(@RequestBody KakaoSignUpRequest request) {
userService.kakaoSignUp(request);
return ApiResponse.success(HttpStatus.CREATED.value(), SuccessMessage.SIGNUP_SUCCESS.getMessage(), SignupResponse.ok());
}

@Operation(summary = "애플 로그인", description = "애플 ID 토큰으로 로그인합니다. 리프레시 토큰은 HttpOnly 쿠키로 설정됩니다.")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "로그인 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "회원가입 필요"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "유효하지 않은 액세스 토큰"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "다른 소셜로 이미 가입")
})
@PostMapping("/login/apple")
public ApiResponse<LoginResponse> loginWithApple(
@RequestBody AppleLoginRequest request, HttpServletResponse httpServletResponse) {
LoginResponse response = userService.appleLogin(request, httpServletResponse);
return ApiResponse.success(HttpStatus.CREATED.value(), SuccessMessage.LOGIN_SUCCESS.getMessage(), response);
}

@Operation(summary = "애플 회원가입", description = "애플 ID 토큰 검증 후 신규 회원을 생성합니다.")
@io.swagger.v3.oas.annotations.responses.ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "회원가입 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "유효하지 않은 액세스 토큰"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 가입된 계정")
})
@PostMapping("/signup/apple")
public ApiResponse<SignupResponse> signupWithApple(@RequestBody AppleSignUpRequest request) {
userService.appleSignUp(request);
return ApiResponse.success(HttpStatus.CREATED.value(), SuccessMessage.SIGNUP_SUCCESS.getMessage(), SignupResponse.ok());
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package com.example.emotion_storage.user.domain;

public enum SocialType {
GOOGLE, KAKAO
GOOGLE, KAKAO, APPLE
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@
@UniqueConstraint(
name = "uk_users_email_deleted_at",
columnNames = {"email", "deleted_at"}
),
@UniqueConstraint(
name = "uk_users_social_deleted_at",
columnNames = {"social_type", "social_id", "deleted_at"}
)
}
)
Expand All @@ -67,7 +71,6 @@ public class User extends BaseTimeEntity {
@Column(nullable = false)
private String socialId;

@Column(nullable = false)
private String email;

private String profileImageUrl;
Expand Down Expand Up @@ -238,6 +241,10 @@ public boolean isGoogleType() {
return socialType.equals(SocialType.GOOGLE);
}

public boolean isAppleType() {
return socialType.equals(SocialType.APPLE);
}

public void updateAttendanceStatus(int attendanceStreak, LocalDate lastAttendanceRewardDate) {
this.attendanceStreak = attendanceStreak;
this.lastAttendanceRewardDate = lastAttendanceRewardDate;
Expand Down
Loading