diff --git a/build.gradle b/build.gradle index 43e6087..36dd49e 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,8 @@ dependencies { implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5' // 비밀번호 BCrypt 해싱 (전체 Spring Security 미도입, crypto 모듈만 사용) implementation 'org.springframework.security:spring-security-crypto' + // Apple 로그인 ID 토큰(JWT) 서명 검증 — Apple JWKS(공개키) 기반 + implementation 'com.nimbusds:nimbus-jose-jwt:9.40' compileOnly 'org.projectlombok:lombok' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/sb/web/auth/controller/AuthController.java b/src/main/java/com/sb/web/auth/controller/AuthController.java index c527c9c..ac9ebea 100644 --- a/src/main/java/com/sb/web/auth/controller/AuthController.java +++ b/src/main/java/com/sb/web/auth/controller/AuthController.java @@ -69,6 +69,12 @@ public class AuthController { return authService.googleLogin(req.getIdToken(), req.isRememberMe()); } + /** 애플 로그인/가입 (비보호) — Sign in with Apple identity token 검증 후 세션 발급 */ + @PostMapping("/apple") + public LoginResponse appleLogin(@Valid @RequestBody com.sb.web.auth.dto.AppleLoginRequest req) { + return authService.appleLogin(req.getIdentityToken(), req.getName(), req.isRememberMe()); + } + @PostMapping("/logout") public ResponseEntity logout(HttpServletRequest request) { authService.logout(AuthInterceptor.resolveToken(request)); diff --git a/src/main/java/com/sb/web/auth/domain/Member.java b/src/main/java/com/sb/web/auth/domain/Member.java index 6e0fd75..dc38d12 100644 --- a/src/main/java/com/sb/web/auth/domain/Member.java +++ b/src/main/java/com/sb/web/auth/domain/Member.java @@ -26,6 +26,7 @@ public class Member implements Serializable { private String provider; // LOCAL / NAVER / GOOGLE private String providerId; // 소셜 제공자 측 고유 ID private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지) + private String appleId; // 애플 sub (계정 연결용 — 구글과 동일) private String googlePicture; // 구글 아바타 URL (로그인 시 동기화) private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜 private String role; // USER / ADMIN diff --git a/src/main/java/com/sb/web/auth/dto/AppleLoginRequest.java b/src/main/java/com/sb/web/auth/dto/AppleLoginRequest.java new file mode 100644 index 0000000..42d23c7 --- /dev/null +++ b/src/main/java/com/sb/web/auth/dto/AppleLoginRequest.java @@ -0,0 +1,19 @@ +package com.sb.web.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 애플 로그인 요청 — 프론트(Sign in with Apple)에서 받은 identity token(JWT). + * 이름은 애플이 최초 인증 시에만 클라이언트에 주므로, 신규 가입 시 채우도록 함께 전달받는다. + */ +@Data +public class AppleLoginRequest { + + @NotBlank(message = "토큰이 없습니다.") + private String identityToken; + + private String name; // 최초 로그인 시에만 제공될 수 있음(이후 null) + + private boolean rememberMe; +} diff --git a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java index 3f94235..331336e 100644 --- a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java +++ b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java @@ -31,6 +31,15 @@ public interface MemberMapper { /** 기존 계정에 구글 sub 연결 (provider 유지). */ int linkGoogle(@Param("id") Long id, @Param("googleId") String googleId); + /** 애플 sub 로 회원 조회 (연결된 LOCAL 계정 + 순수 애플 계정 모두). */ + Member findByAppleId(@Param("appleId") String appleId); + + /** 애플 로그인 시 연결 대상이 될 같은 이메일의 기존 계정(LOCAL 우선, 애플 미연결만). */ + Member findByEmailForAppleLink(@Param("email") String email); + + /** 기존 계정에 애플 sub 연결 (provider 유지). */ + int linkApple(@Param("id") Long id, @Param("appleId") String appleId); + int countByLoginId(@Param("loginId") String loginId); int insert(Member member); diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java index 381cddd..2ac2a85 100644 --- a/src/main/java/com/sb/web/auth/service/AuthService.java +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -45,6 +45,13 @@ public class AuthService { @org.springframework.beans.factory.annotation.Value("${app.google-client-id:}") private String googleClientId; + /** 애플 로그인 aud(=iOS 앱 번들 ID). 기본값은 앱 패키지명. */ + @org.springframework.beans.factory.annotation.Value("${app.apple-client-id:kr.sblog.slimbudget}") + private String appleClientId; + + /** 애플 ID 토큰 검증기(JWKS 캐시 포함). 최초 사용 시 lazy 초기화. */ + private volatile com.nimbusds.jwt.proc.ConfigurableJWTProcessor appleJwtProcessor; + private static final String SESSION_PREFIX = "session:"; private static final Duration SESSION_TTL = Duration.ofMinutes(60); // 일반 세션 private static final Duration REMEMBER_TTL = Duration.ofDays(30); // 자동 로그인(로그인 상태 유지) @@ -241,6 +248,110 @@ public class AuthService { return googleClientId == null ? "" : googleClientId; } + /** + * 애플 로그인. Sign in with Apple identity token(JWT)을 애플 공개키(JWKS)로 검증한 뒤, + * 구글과 동일한 규칙으로 계정을 조회/연결/생성한다. + */ + public LoginResponse appleLogin(String identityToken, String providedName, boolean rememberMe) { + if (identityToken == null || identityToken.isBlank()) { + throw new ApiException(HttpStatus.BAD_REQUEST, "토큰이 없습니다."); + } + com.nimbusds.jwt.JWTClaimsSet claims; + try { + claims = verifyAppleToken(identityToken); + } catch (Exception e) { + log.warn("[apple] token verify failed: {}", e.toString()); + throw new ApiException(HttpStatus.UNAUTHORIZED, "애플 인증에 실패했습니다."); + } + // 발급자·대상(aud=번들 ID)·만료 검증 + java.util.Date now = new java.util.Date(); + if (claims.getExpirationTime() == null || claims.getExpirationTime().before(now) + || !"https://appleid.apple.com".equals(claims.getIssuer()) + || claims.getAudience() == null || !claims.getAudience().contains(appleClientId)) { + throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 애플 토큰입니다."); + } + String sub = claims.getSubject(); + if (sub == null || sub.isBlank()) { + throw new ApiException(HttpStatus.UNAUTHORIZED, "유효하지 않은 애플 토큰입니다."); + } + String email; + boolean emailVerified; + try { + email = claims.getStringClaim("email"); + Object ev = claims.getClaim("email_verified"); // 문자열("true")/불리언 모두 대응 + emailVerified = ev != null && "true".equals(String.valueOf(ev)); + } catch (Exception e) { + email = null; + emailVerified = false; + } + String name = (providedName != null && !providedName.isBlank()) ? providedName + : (email != null ? email.split("@")[0] : "사용자"); + + // 1) 애플 sub 로 이미 연결/가입된 계정 조회 + Member member = memberMapper.findByAppleId(sub); + + // 2) 없으면 같은 (검증된)이메일의 기존 계정에 애플 연결 — 중복 계정/데이터 분리 방지 + if (member == null && email != null && !email.isBlank() && emailVerified) { + Member existing = memberMapper.findByEmailForAppleLink(email); + if (existing != null) { + if (!"ACTIVE".equals(existing.getStatus())) { + throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다."); + } + memberMapper.linkApple(existing.getId(), sub); + existing.setAppleId(sub); + member = existing; + log.info("[apple] linked to existing member id={} provider={} email={}", + member.getId(), member.getProvider(), email); + } + } + + // 3) 그래도 없으면 신규 애플 계정 생성(최초 로그인 = 가입). 가입 제한 시 차단. + if (member == null) { + if (!appSettingService.isSignupEnabled()) { + throw new ApiException(HttpStatus.FORBIDDEN, "현재 회원가입이 제한되어 있습니다."); + } + member = Member.builder() + .name(name) + .email(email) + .provider("APPLE") + .providerId(sub) + .appleId(sub) + .role("USER") + .status("ACTIVE") + .build(); + memberMapper.insert(member); + log.info("[apple] new member id={} email={}", member.getId(), email); + } + if (!"ACTIVE".equals(member.getStatus())) { + throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다."); + } + return issueSession(member, rememberMe); + } + + /** 애플 identity token 을 애플 JWKS(공개키)로 서명 검증하고 클레임을 반환. */ + private com.nimbusds.jwt.JWTClaimsSet verifyAppleToken(String idToken) throws Exception { + com.nimbusds.jwt.proc.ConfigurableJWTProcessor proc = appleJwtProcessor; + if (proc == null) { + synchronized (this) { + if (appleJwtProcessor == null) { + com.nimbusds.jose.jwk.source.JWKSource keySource = + com.nimbusds.jose.jwk.source.JWKSourceBuilder + .create(new java.net.URL("https://appleid.apple.com/auth/keys")) + .retrying(true) + .build(); + com.nimbusds.jwt.proc.DefaultJWTProcessor p = + new com.nimbusds.jwt.proc.DefaultJWTProcessor<>(); + p.setJWSKeySelector(new com.nimbusds.jose.proc.JWSVerificationKeySelector<>( + com.nimbusds.jose.JWSAlgorithm.RS256, keySource)); + appleJwtProcessor = p; + } + proc = appleJwtProcessor; + } + } + // 서명 검증(iss/aud/exp 는 호출부에서 확인) + return proc.process(idToken, null); + } + /** * 토큰으로 세션을 조회하고, 유효하면 TTL 을 갱신(슬라이딩 만료)한다. * Redis 에 없으면(재시작/유실/장애) DB 백업(auth_session)에서 복원하고 Redis 를 재수화한다. diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8936685..acf6282 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -79,6 +79,8 @@ app: public-url: ${PUBLIC_URL:https://app.sblog.kr} # 구글 OAuth 웹 클라이언트 ID (Google Cloud Console 발급). 미설정 시 구글 로그인 비활성. google-client-id: ${GOOGLE_CLIENT_ID:} + # 애플 로그인 aud(=iOS 앱 번들 ID). 네이티브 Sign in with Apple 의 identity token 대상값. + apple-client-id: ${APPLE_CLIENT_ID:kr.sblog.slimbudget} # ===== Logging ===== logging: diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql index 6a5eb26..bf44ec8 100644 --- a/src/main/resources/db/member.sql +++ b/src/main/resources/db/member.sql @@ -27,6 +27,10 @@ ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_google_id (google_id); -- 기존 구글 계정(provider=GOOGLE)의 sub 를 google_id 로 백필(멱등 — 이미 채워졌으면 아무 것도 안 함). UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_id IS NULL AND provider_id IS NOT NULL; +-- 애플 로그인: 애플 sub 를 apple_id 로 저장(구글과 동일 — LOCAL 계정에 연결해도 provider 유지). 멱등. +ALTER TABLE member ADD COLUMN IF NOT EXISTS apple_id VARCHAR(100) NULL COMMENT '애플 sub (계정 연결용)' AFTER google_id; +ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_apple_id (apple_id); + -- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등). ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role; diff --git a/src/main/resources/mapper/MemberMapper.xml b/src/main/resources/mapper/MemberMapper.xml index ff83fa5..843c771 100644 --- a/src/main/resources/mapper/MemberMapper.xml +++ b/src/main/resources/mapper/MemberMapper.xml @@ -12,6 +12,7 @@ + @@ -26,7 +27,7 @@ - id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, plan_expires_at, plan_product, plan_auto_renew, points, status, created_at, updated_at + id, login_id, password, name, email, provider, provider_id, google_id, apple_id, google_picture, profile_image, role, plan, plan_expires_at, plan_product, plan_auto_renew, points, status, created_at, updated_at + SELECT FROM member WHERE apple_id = #{appleId} + + + + + + + + UPDATE member SET apple_id = #{appleId}, updated_at = NOW() WHERE id = #{id} + + - INSERT INTO member (login_id, password, name, email, provider, provider_id, google_id, google_picture, role, status, created_at, updated_at) + INSERT INTO member (login_id, password, name, email, provider, provider_id, google_id, apple_id, google_picture, role, status, created_at, updated_at) VALUES (#{loginId}, #{password}, #{name}, #{email}, - #{provider}, #{providerId}, #{googleId}, #{googlePicture}, #{role}, #{status}, NOW(), NOW()) + #{provider}, #{providerId}, #{googleId}, #{appleId}, #{googlePicture}, #{role}, #{status}, NOW(), NOW())