diff --git a/src/main/java/leets/weeth/domain/user/application/dto/request/UserRequestDto.java b/src/main/java/leets/weeth/domain/user/application/dto/request/UserRequestDto.java index 2cfdb8cf..30ac0336 100644 --- a/src/main/java/leets/weeth/domain/user/application/dto/request/UserRequestDto.java +++ b/src/main/java/leets/weeth/domain/user/application/dto/request/UserRequestDto.java @@ -1,5 +1,6 @@ package leets.weeth.domain.user.application.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -27,7 +28,10 @@ public record SignUp( } public record Register( - @NotNull Long kakaoId, + @Schema(description = "kakao로 회원가입 하는 경우") + Long kakaoId, + @Schema(description = "애플로 회원가입 하는 경우 - Apple OAuth authCode") + String appleAuthCode, @NotBlank String name, @NotBlank String studentId, @NotBlank String email, diff --git a/src/main/java/leets/weeth/domain/user/application/dto/response/UserResponseDto.java b/src/main/java/leets/weeth/domain/user/application/dto/response/UserResponseDto.java index 8f38c0ed..5a76d645 100644 --- a/src/main/java/leets/weeth/domain/user/application/dto/response/UserResponseDto.java +++ b/src/main/java/leets/weeth/domain/user/application/dto/response/UserResponseDto.java @@ -13,6 +13,7 @@ public class UserResponseDto { public record SocialLoginResponse( Long id, Long kakaoId, + String appleIdToken, LoginStatus status, String accessToken, String refreshToken diff --git a/src/main/java/leets/weeth/domain/user/application/mapper/UserMapper.java b/src/main/java/leets/weeth/domain/user/application/mapper/UserMapper.java index e41866ec..046a0f5f 100644 --- a/src/main/java/leets/weeth/domain/user/application/mapper/UserMapper.java +++ b/src/main/java/leets/weeth/domain/user/application/mapper/UserMapper.java @@ -49,11 +49,15 @@ public interface UserMapper { @Mapping(target = "status", expression = "java(LoginStatus.LOGIN)"), @Mapping(target = "id", source = "user.id"), @Mapping(target = "kakaoId", source = "user.kakaoId"), + @Mapping(target = "appleIdToken", expression = "java(null)") }) SocialLoginResponse toLoginResponse(User user, JwtDto dto); @Mappings({ @Mapping(target = "status", expression = "java(LoginStatus.INTEGRATE)"), + @Mapping(target = "appleIdToken", expression = "java(null)"), + @Mapping(target = "accessToken", expression = "java(null)"), + @Mapping(target = "refreshToken", expression = "java(null)") }) SocialLoginResponse toIntegrateResponse(Long kakaoId); @@ -66,6 +70,24 @@ public interface UserMapper { @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") UserResponseDto.UserInfo toUserInfoDto(User user, List userCardinals); + @Mappings({ + @Mapping(target = "status", expression = "java(LoginStatus.LOGIN)"), + @Mapping(target = "id", source = "user.id"), + @Mapping(target = "appleIdToken", expression = "java(null)"), + @Mapping(target = "kakaoId", expression = "java(null)") + }) + SocialLoginResponse toAppleLoginResponse(User user, JwtDto dto); + + @Mappings({ + @Mapping(target = "status", expression = "java(LoginStatus.INTEGRATE)"), + @Mapping(target = "id", expression = "java(null)"), + @Mapping(target = "appleIdToken", source = "appleIdToken"), + @Mapping(target = "kakaoId", expression = "java(null)"), + @Mapping(target = "accessToken", expression = "java(null)"), + @Mapping(target = "refreshToken", expression = "java(null)") + }) + SocialLoginResponse toAppleIntegrateResponse(String appleIdToken); + default String toString(Department department) { return department.getValue(); } diff --git a/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCase.java b/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCase.java index ae71bf99..e1164291 100644 --- a/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCase.java +++ b/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCase.java @@ -37,4 +37,8 @@ public interface UserUseCase { List searchUser(String keyword); + SocialLoginResponse appleLogin(Login dto); + + void appleRegister(Register dto); + } diff --git a/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCaseImpl.java b/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCaseImpl.java index 2cbe3ecd..5a4ddab7 100644 --- a/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCaseImpl.java +++ b/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCaseImpl.java @@ -11,6 +11,8 @@ import leets.weeth.domain.user.domain.entity.User; import leets.weeth.domain.user.domain.entity.UserCardinal; import leets.weeth.domain.user.domain.service.*; +import leets.weeth.global.auth.apple.dto.AppleTokenResponse; +import leets.weeth.global.auth.apple.dto.AppleUserInfo; import leets.weeth.global.auth.jwt.application.dto.JwtDto; import leets.weeth.global.auth.jwt.application.usecase.JwtManageUseCase; import leets.weeth.global.auth.kakao.KakaoAuthService; @@ -18,6 +20,7 @@ import leets.weeth.global.auth.kakao.dto.KakaoUserInfoResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -25,10 +28,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import static leets.weeth.domain.user.application.dto.request.UserRequestDto.*; @@ -44,6 +44,7 @@ public class UserUseCaseImpl implements UserUseCase { private final UserGetService userGetService; private final UserUpdateService userUpdateService; private final KakaoAuthService kakaoAuthService; + private final leets.weeth.global.auth.apple.AppleAuthService appleAuthService; private final CardinalGetService cardinalGetService; private final UserCardinalSaveService userCardinalSaveService; private final UserCardinalGetService userCardinalGetService; @@ -51,6 +52,7 @@ public class UserUseCaseImpl implements UserUseCase { private final UserMapper mapper; private final CardinalMapper cardinalMapper; private final PasswordEncoder passwordEncoder; + private final Environment environment; @Override @Transactional(readOnly = true) @@ -238,4 +240,75 @@ private UserCardinalDto getUserCardinalDto(Long userId) { return cardinalMapper.toUserCardinalDto(user, userCardinals); } + + @Override + @Transactional(readOnly = true) + public SocialLoginResponse appleLogin(Login dto) { + // Apple Token 요청 및 유저 정보 요청 + AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.authCode()); + AppleUserInfo userInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.id_token()); + + String appleIdToken = tokenResponse.id_token(); + String appleId = userInfo.appleId(); + + Optional optionalUser = userGetService.findByAppleId(appleId); + + //todo: 추후 애플 로그인 연동을 위해 appleIdToken을 반환 + // 애플 로그인 연동 API 요청시 appleIdToken을 함께 넣어주면 그때 디코딩해서 appleId를 추출 + if (optionalUser.isEmpty()) { + return mapper.toAppleIntegrateResponse(appleIdToken); + } + + User user = optionalUser.get(); + if (user.isInactive()) { + throw new UserInActiveException(); + } + + JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); + return mapper.toAppleLoginResponse(user, token); + } + + @Override + @Transactional + public void appleRegister(Register dto) { + validate(dto); + + // Apple authCode로 토큰 교환 후 ID Token 검증 및 사용자 정보 추출 + AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.appleAuthCode()); + AppleUserInfo appleUserInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.id_token()); + + Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); + + User user = mapper.from(dto); + // Apple ID 설정 + user.addAppleId(appleUserInfo.appleId()); + + UserCardinal userCardinal = new UserCardinal(user, cardinal); + + userSaveService.save(user); + userCardinalSaveService.save(userCardinal); + + // dev 환경에서만 바로 ACTIVE 상태로 설정 + if (isDevEnvironment()) { + log.info("dev 환경 감지: 사용자 자동 승인 처리 (userId: {})", user.getId()); + user.accept(); + } + } + + /** + * 현재 환경이 dev 프로파일인지 확인 + * @return dev 프로파일이 활성화되어 있으면 true + */ + private boolean isDevEnvironment() { + String[] activeProfiles = environment.getActiveProfiles(); + for (String profile : activeProfiles) { + if ("dev".equals(profile)) { + return true; + } + if ("local".equals(profile)) { + return true; + } + } + return false; + } } diff --git a/src/main/java/leets/weeth/domain/user/domain/entity/User.java b/src/main/java/leets/weeth/domain/user/domain/entity/User.java index 22e020be..9b5a137e 100644 --- a/src/main/java/leets/weeth/domain/user/domain/entity/User.java +++ b/src/main/java/leets/weeth/domain/user/domain/entity/User.java @@ -1,20 +1,6 @@ package leets.weeth.domain.user.domain.entity; -import static leets.weeth.domain.user.application.dto.request.UserRequestDto.Update; - -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import jakarta.persistence.PrePersist; -import jakarta.persistence.Table; -import java.util.ArrayList; -import java.util.List; +import jakarta.persistence.*; import leets.weeth.domain.attendance.domain.entity.Attendance; import leets.weeth.domain.board.domain.entity.enums.Part; import leets.weeth.domain.user.domain.entity.enums.Department; @@ -29,6 +15,11 @@ import lombok.experimental.SuperBuilder; import org.springframework.security.crypto.password.PasswordEncoder; +import java.util.ArrayList; +import java.util.List; + +import static leets.weeth.domain.user.application.dto.request.UserRequestDto.Update; + @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -42,8 +33,12 @@ public class User extends BaseEntity { @Column(name = "user_id") private Long id; + @Column(unique = true) private Long kakaoId; + @Column(unique = true) + private String appleId; + private String name; private String email; @@ -94,6 +89,10 @@ public void addKakaoId(long kakaoId) { this.kakaoId = kakaoId; } + public void addAppleId(String appleId) { + this.appleId = appleId; + } + public void leave() { this.status = Status.LEFT; } diff --git a/src/main/java/leets/weeth/domain/user/domain/repository/UserRepository.java b/src/main/java/leets/weeth/domain/user/domain/repository/UserRepository.java index 23c61e5a..8881f0a4 100644 --- a/src/main/java/leets/weeth/domain/user/domain/repository/UserRepository.java +++ b/src/main/java/leets/weeth/domain/user/domain/repository/UserRepository.java @@ -18,6 +18,8 @@ public interface UserRepository extends JpaRepository { Optional findByKakaoId(long kakaoId); + Optional findByAppleId(String appleId); + ListfindAllByNameContainingAndStatus(String name, Status status); boolean existsByEmail(String email); diff --git a/src/main/java/leets/weeth/domain/user/domain/service/UserGetService.java b/src/main/java/leets/weeth/domain/user/domain/service/UserGetService.java index 15fe07f9..1498f0a6 100644 --- a/src/main/java/leets/weeth/domain/user/domain/service/UserGetService.java +++ b/src/main/java/leets/weeth/domain/user/domain/service/UserGetService.java @@ -33,6 +33,10 @@ public Optional find(long kakaoId){ return userRepository.findByKakaoId(kakaoId); } + public Optional findByAppleId(String appleId){ + return userRepository.findByAppleId(appleId); + } + public List search(String keyword) { return userRepository.findAllByNameContainingAndStatus(keyword, Status.ACTIVE); } diff --git a/src/main/java/leets/weeth/domain/user/presentation/UserController.java b/src/main/java/leets/weeth/domain/user/presentation/UserController.java index 269293cd..9c131428 100644 --- a/src/main/java/leets/weeth/domain/user/presentation/UserController.java +++ b/src/main/java/leets/weeth/domain/user/presentation/UserController.java @@ -68,6 +68,20 @@ public CommonResponse integrate(@RequestBody @Valid NormalL return CommonResponse.createSuccess(SOCIAL_INTEGRATE_SUCCESS.getMessage(), userUseCase.integrate(dto)); } + @PostMapping("/apple/login") + @Operation(summary = "애플 소셜 로그인 API") + public CommonResponse appleLogin(@RequestBody @Valid Login dto) { + SocialLoginResponse response = userUseCase.appleLogin(dto); + return CommonResponse.createSuccess(SOCIAL_LOGIN_SUCCESS.getMessage(), response); + } + + @PostMapping("/apple/register") + @Operation(summary = "애플 소셜 회원가입 (dev 전용 - 바로 ACTIVE)") + public CommonResponse appleRegister(@RequestBody @Valid Register dto) { + userUseCase.appleRegister(dto); + return CommonResponse.createSuccess(USER_APPLY_SUCCESS.getMessage()); + } + @GetMapping("/email") @Operation(summary = "이메일 중복 확인") public CommonResponse checkEmail(@RequestParam String email) { diff --git a/src/main/java/leets/weeth/global/auth/apple/AppleAuthService.java b/src/main/java/leets/weeth/global/auth/apple/AppleAuthService.java new file mode 100644 index 00000000..160788da --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/AppleAuthService.java @@ -0,0 +1,247 @@ +package leets.weeth.global.auth.apple; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import leets.weeth.global.auth.apple.dto.ApplePublicKey; +import leets.weeth.global.auth.apple.dto.ApplePublicKeys; +import leets.weeth.global.auth.apple.dto.AppleTokenResponse; +import leets.weeth.global.auth.apple.dto.AppleUserInfo; +import leets.weeth.global.auth.apple.exception.AppleAuthenticationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +@Service +@Slf4j +public class AppleAuthService { + + @Value("${auth.providers.apple.client_id}") + private String appleClientId; + + @Value("${auth.providers.apple.team_id}") + private String appleTeamId; + + @Value("${auth.providers.apple.key_id}") + private String appleKeyId; + + @Value("${auth.providers.apple.redirect_uri}") + private String redirectUri; + + @Value("${auth.providers.apple.token_uri}") + private String tokenUri; + + @Value("${auth.providers.apple.keys_uri}") + private String keysUri; + + @Value("${auth.providers.apple.private_key_path}") + private String privateKeyPath; + + private final RestClient restClient = RestClient.create(); + + /** + * Authorization code로 애플 토큰 요청 + * client_secret은 JWT로 생성 (ES256 알고리즘) + */ + public AppleTokenResponse getAppleToken(String authCode) { + String clientSecret = generateClientSecret(); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", appleClientId); + body.add("client_secret", clientSecret); + body.add("code", authCode); + body.add("redirect_uri", redirectUri); + + return restClient.post() + .uri(tokenUri) + .body(body) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .body(AppleTokenResponse.class); + } + + /** + * ID Token 검증 및 사용자 정보 추출 + * 애플은 별도 userInfo 엔드포인트가 없고 ID Token에 정보가 포함됨 + */ + public AppleUserInfo verifyAndDecodeIdToken(String idToken) { + try { + // 1. ID Token의 헤더에서 kid 추출 + String[] tokenParts = idToken.split("\\."); + String header = new String(Base64.getUrlDecoder().decode(tokenParts[0])); + Map headerMap = parseJson(header); + String kid = (String) headerMap.get("kid"); + + // 2. 애플 공개키 가져오기 + ApplePublicKeys publicKeys = restClient.get() + .uri(keysUri) + .retrieve() + .body(ApplePublicKeys.class); + + // 3. kid와 일치하는 공개키 찾기 + ApplePublicKey matchedKey = publicKeys.keys().stream() + .filter(key -> key.kid().equals(kid)) + .findFirst() + .orElseThrow(AppleAuthenticationException::new); + + // 4. 공개키로 ID Token 검증 + PublicKey publicKey = generatePublicKey(matchedKey); + Claims claims = Jwts.parserBuilder() + .setSigningKey(publicKey) + .build() + .parseClaimsJws(idToken) + .getBody(); + + // 5. Claims 검증 + validateClaims(claims); + + // 6. 사용자 정보 추출 + String appleId = claims.getSubject(); + String email = claims.get("email", String.class); + Boolean emailVerified = claims.get("email_verified", Boolean.class); + + return AppleUserInfo.builder() + .appleId(appleId) + .email(email) + .emailVerified(emailVerified != null ? emailVerified : false) + .build(); + + } catch (Exception e) { + log.error("애플 ID Token 검증 실패", e); + throw new AppleAuthenticationException(); + } + } + + /** + * 애플 로그인용 client_secret 생성 + * ES256 알고리즘으로 JWT 생성 (p8 키 파일 사용) + */ + private String generateClientSecret() { + try (InputStream inputStream = getInputStream(privateKeyPath)) { + // p8 파일에서 Private Key 읽기 + String privateKeyContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + + // PEM 형식의 헤더/푸터 제거 + privateKeyContent = privateKeyContent + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + // Private Key 객체 생성 + byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + PrivateKey privateKey = keyFactory.generatePrivate( + new java.security.spec.PKCS8EncodedKeySpec(keyBytes) + ); + + // JWT 생성 + LocalDateTime now = LocalDateTime.now(); + Date issuedAt = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); + Date expiration = Date.from(now.plusMonths(5).atZone(ZoneId.systemDefault()).toInstant()); + + return Jwts.builder() + .setHeaderParam("kid", appleKeyId) + .setHeaderParam("alg", "ES256") + .setIssuer(appleTeamId) + .setIssuedAt(issuedAt) + .setExpiration(expiration) + .setAudience("https://appleid.apple.com") + .setSubject(appleClientId) + .signWith(privateKey, SignatureAlgorithm.ES256) + .compact(); + + } catch (Exception e) { + log.error("애플 Client Secret 생성 실패", e); + throw new AppleAuthenticationException(); + } + } + + /** + * 파일 경로에서 InputStream 가져오기 + * 절대 경로면 파일 시스템에서, 상대 경로면 classpath에서 읽음 + */ + private InputStream getInputStream(String path) throws IOException { + // 절대 경로인 경우 파일 시스템에서 읽기 + if (path.startsWith("/") || path.matches("^[A-Za-z]:.*")) { + return new FileInputStream(path); + } + // 상대 경로는 classpath에서 읽기 + return new ClassPathResource(path).getInputStream(); + } + + /** + * 애플 공개키로부터 PublicKey 객체 생성 + */ + private PublicKey generatePublicKey(ApplePublicKey applePublicKey) { + try { + byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n()); + byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e()); + + BigInteger n = new BigInteger(1, nBytes); + BigInteger e = new BigInteger(1, eBytes); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + + return keyFactory.generatePublic(publicKeySpec); + } catch (Exception ex) { + log.error("애플 공개키 생성 실패", ex); + throw new AppleAuthenticationException(); + } + } + + /** + * ID Token의 Claims 검증 + */ + private void validateClaims(Claims claims) { + String iss = claims.getIssuer(); + String aud = claims.getAudience(); + + if (!iss.equals("https://appleid.apple.com")) { + throw new RuntimeException("유효하지 않은 발급자(issuer)입니다."); + } + + if (!aud.equals(appleClientId)) { + throw new RuntimeException("유효하지 않은 수신자(audience)입니다."); + } + + Date expiration = claims.getExpiration(); + if (expiration.before(new Date())) { + throw new RuntimeException("만료된 ID Token입니다."); + } + } + + /** + * JSON 문자열을 Map으로 파싱 + */ + @SuppressWarnings("unchecked") + private Map parseJson(String json) { + try { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + return objectMapper.readValue(json, Map.class); + } catch (Exception e) { + throw new RuntimeException("JSON 파싱 실패"); + } + } +} diff --git a/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKey.java b/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKey.java new file mode 100644 index 00000000..54d68729 --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKey.java @@ -0,0 +1,13 @@ +package leets.weeth.global.auth.apple.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record ApplePublicKey( + String kty, + String kid, + String use, + String alg, + String n, + String e +) { +} diff --git a/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKeys.java b/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKeys.java new file mode 100644 index 00000000..909d2fc5 --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKeys.java @@ -0,0 +1,8 @@ +package leets.weeth.global.auth.apple.dto; + +import java.util.List; + +public record ApplePublicKeys( + List keys +) { +} diff --git a/src/main/java/leets/weeth/global/auth/apple/dto/AppleTokenResponse.java b/src/main/java/leets/weeth/global/auth/apple/dto/AppleTokenResponse.java new file mode 100644 index 00000000..2c52a44a --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/dto/AppleTokenResponse.java @@ -0,0 +1,10 @@ +package leets.weeth.global.auth.apple.dto; + +public record AppleTokenResponse( + String access_token, + String token_type, + Long expires_in, + String refresh_token, + String id_token +) { +} diff --git a/src/main/java/leets/weeth/global/auth/apple/dto/AppleUserInfo.java b/src/main/java/leets/weeth/global/auth/apple/dto/AppleUserInfo.java new file mode 100644 index 00000000..8bec9569 --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/dto/AppleUserInfo.java @@ -0,0 +1,11 @@ +package leets.weeth.global.auth.apple.dto; + +import lombok.Builder; + +@Builder +public record AppleUserInfo( + String appleId, + String email, + Boolean emailVerified +) { +} diff --git a/src/main/java/leets/weeth/global/auth/apple/exception/AppleAuthenticationException.java b/src/main/java/leets/weeth/global/auth/apple/exception/AppleAuthenticationException.java new file mode 100644 index 00000000..29216523 --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/exception/AppleAuthenticationException.java @@ -0,0 +1,9 @@ +package leets.weeth.global.auth.apple.exception; + +import leets.weeth.global.common.exception.BusinessLogicException; + +public class AppleAuthenticationException extends BusinessLogicException { + public AppleAuthenticationException() { + super(401, "애플 로그인에 실패했습니다."); + } +} diff --git a/src/main/java/leets/weeth/global/config/SecurityConfig.java b/src/main/java/leets/weeth/global/config/SecurityConfig.java index 6f33c2a0..a0233f0d 100644 --- a/src/main/java/leets/weeth/global/config/SecurityConfig.java +++ b/src/main/java/leets/weeth/global/config/SecurityConfig.java @@ -74,9 +74,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests( authorize -> authorize - .requestMatchers("/api/v1/users/kakao/login", "api/v1/users/kakao/register", "api/v1/users/kakao/link", "/api/v1/users/apply", "/api/v1/users/email", "/api/v1/users/refresh").permitAll() + .requestMatchers("/api/v1/users/kakao/login", "api/v1/users/kakao/register", "api/v1/users/kakao/link", "/api/v1/users/apple/login", "/api/v1/users/apple/register", "/api/v1/users/apply", "/api/v1/users/email", "/api/v1/users/refresh").permitAll() .requestMatchers("/health-check").permitAll() - .requestMatchers("/oauth2/**", "/.well-known/**", "/kakao/oauth").permitAll() + .requestMatchers("/oauth2/**", "/.well-known/**", "/kakao/oauth", "/apple/oauth").permitAll() .requestMatchers("/admin", "/admin/login", "/admin/account", "/admin/meeting", "/admin/member", "/admin/penalty", "/js/**", "/img/**", "/scss/**", "/vendor/**").permitAll() // 스웨거 경로 diff --git a/src/main/java/leets/weeth/global/sas/application/exception/AppleLoginException.java b/src/main/java/leets/weeth/global/sas/application/exception/AppleLoginException.java new file mode 100644 index 00000000..6e3bd0aa --- /dev/null +++ b/src/main/java/leets/weeth/global/sas/application/exception/AppleLoginException.java @@ -0,0 +1,10 @@ +package leets.weeth.global.sas.application.exception; + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; + +public class AppleLoginException extends OAuth2AuthenticationException { + public AppleLoginException(String message) { + super(new OAuth2Error(ErrorMessage.APPLE_AUTH_ERROR.getCode(), message, null)); + } +} diff --git a/src/main/java/leets/weeth/global/sas/application/exception/ErrorMessage.java b/src/main/java/leets/weeth/global/sas/application/exception/ErrorMessage.java index 82d24c54..a897fa5d 100644 --- a/src/main/java/leets/weeth/global/sas/application/exception/ErrorMessage.java +++ b/src/main/java/leets/weeth/global/sas/application/exception/ErrorMessage.java @@ -9,7 +9,8 @@ public enum ErrorMessage { USER_INACTIVE("WAE-001", "가입 승인이 허가되지 않은 계정입니다."), USER_NOT_FOUND("WAE-002", "존재하지 않는 유저입니다."), - KAKAO_AUTH_ERROR("WAE-003", "카카오 로그인 예외입니다."); + KAKAO_AUTH_ERROR("WAE-003", "카카오 로그인 예외입니다."), + APPLE_AUTH_ERROR("WAE-004", "애플 로그인 예외입니다."); private final String code; private final String description; diff --git a/src/main/java/leets/weeth/global/sas/application/mapper/OAuth2AuthorizationConverter.java b/src/main/java/leets/weeth/global/sas/application/mapper/OAuth2AuthorizationConverter.java index 169d157e..1f42d123 100644 --- a/src/main/java/leets/weeth/global/sas/application/mapper/OAuth2AuthorizationConverter.java +++ b/src/main/java/leets/weeth/global/sas/application/mapper/OAuth2AuthorizationConverter.java @@ -1,6 +1,7 @@ package leets.weeth.global.sas.application.mapper; +import leets.weeth.global.sas.config.grant.AppleGrantType; import leets.weeth.global.sas.config.grant.KakaoGrantType; import leets.weeth.global.sas.domain.entity.*; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -33,6 +34,10 @@ public static OAuth2AuthorizationGrantAuthorization convertOAuth2AuthorizationGr return convertKakaoAuthorizationGrantAuthorization(authorization); } + if (AppleGrantType.APPLE_IDENTITY_TOKEN.equals(grantType)) { + return convertAppleAuthorizationGrantAuthorization(authorization); + } + return null; } @@ -55,6 +60,25 @@ public static OAuth2AuthorizationGrantAuthorization convertOAuth2AuthorizationGr .build(); } + private static OidcAuthorizationCodeGrantAuthorization + convertAppleAuthorizationGrantAuthorization(OAuth2Authorization authorization) { + + AccessToken accessToken = extractAccessToken(authorization); + RefreshToken refreshToken = extractRefreshToken(authorization); + IdToken idToken = extractIdToken(authorization); + + return OidcAuthorizationCodeGrantAuthorization.builder() + .id(authorization.getId()) + .registeredClientId(authorization.getRegisteredClientId()) + .principalName(authorization.getPrincipalName()) + .authorizedScopes(authorization.getAuthorizedScopes()) + .accessToken(accessToken) + .refreshToken(refreshToken) + .idToken(idToken) + .principal(authorization.getAttribute(Principal.class.getName())) + .build(); + } + static OidcAuthorizationCodeGrantAuthorization convertOidcAuthorizationCodeGrantAuthorization(OAuth2Authorization authorization) { AuthorizationCode authorizationCode = extractAuthorizationCode(authorization); AccessToken accessToken = extractAccessToken(authorization); diff --git a/src/main/java/leets/weeth/global/sas/application/usecase/AuthUsecase.java b/src/main/java/leets/weeth/global/sas/application/usecase/AuthUsecase.java index 22de81b8..a110df15 100644 --- a/src/main/java/leets/weeth/global/sas/application/usecase/AuthUsecase.java +++ b/src/main/java/leets/weeth/global/sas/application/usecase/AuthUsecase.java @@ -6,6 +6,9 @@ import leets.weeth.domain.user.domain.entity.User; import leets.weeth.domain.user.domain.service.UserCardinalGetService; import leets.weeth.domain.user.domain.service.UserGetService; +import leets.weeth.global.auth.apple.AppleAuthService; +import leets.weeth.global.auth.apple.dto.AppleTokenResponse; +import leets.weeth.global.auth.apple.dto.AppleUserInfo; import leets.weeth.global.auth.jwt.service.JwtService; import leets.weeth.global.auth.kakao.KakaoAuthService; import leets.weeth.global.auth.kakao.dto.KakaoTokenResponse; @@ -22,6 +25,7 @@ public class AuthUsecase { private final KakaoAuthService kakaoAuthService; + private final AppleAuthService appleAuthService; private final UserGetService userGetService; private final JwtService jwtService; private final UserCardinalGetService userCardinalGetService; @@ -51,6 +55,32 @@ public User login(String authCode) { return user; } + /* + 필요 없음 + */ + public User appleLogin(String authCode, String idToken) { + AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(authCode); + + // ID Token 사용 + String token = idToken != null ? idToken : tokenResponse.id_token(); + AppleUserInfo userInfo = appleAuthService.verifyAndDecodeIdToken(token); + + String appleId = userInfo.appleId(); + Optional optionalUser = userGetService.findByAppleId(appleId); + + if (optionalUser.isEmpty()) { + throw new UserNotFoundException(); // -> Weeth 회원가입 페이지로 리다이렉트 + } + + User user = optionalUser.get(); + + if (user.isInactive()) { + throw new UserInActiveException(); // -> 가입 승인 대기 + } + + return user; + } + public OauthUserInfoResponse userInfo(String accessToken) { String token = accessToken.substring(7); diff --git a/src/main/java/leets/weeth/global/sas/config/OAuth2AuthorizationServerConfig.java b/src/main/java/leets/weeth/global/sas/config/OAuth2AuthorizationServerConfig.java index 86183202..a1d39fa6 100644 --- a/src/main/java/leets/weeth/global/sas/config/OAuth2AuthorizationServerConfig.java +++ b/src/main/java/leets/weeth/global/sas/config/OAuth2AuthorizationServerConfig.java @@ -5,13 +5,12 @@ import com.nimbusds.jose.proc.SecurityContext; import leets.weeth.domain.user.domain.entity.SecurityUser; import leets.weeth.domain.user.domain.service.UserGetService; +import leets.weeth.global.auth.apple.AppleAuthService; import leets.weeth.global.auth.kakao.KakaoAuthService; import leets.weeth.global.sas.application.exception.Oauth2JwtTokenException; import leets.weeth.global.sas.application.property.OauthProperties; import leets.weeth.global.sas.config.authentication.ProviderAwareEntryPoint; -import leets.weeth.global.sas.config.grant.KakaoAccessTokenAuthenticationConverter; -import leets.weeth.global.sas.config.grant.KakaoAuthenticationProvider; -import leets.weeth.global.sas.config.grant.KakaoGrantType; +import leets.weeth.global.sas.config.grant.*; import leets.weeth.global.sas.domain.repository.OAuth2AuthorizationGrantAuthorizationRepository; import leets.weeth.global.sas.domain.service.RedisOAuth2AuthorizationService; import lombok.RequiredArgsConstructor; @@ -31,7 +30,6 @@ import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; -import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; @@ -56,6 +54,7 @@ public class OAuth2AuthorizationServerConfig { private final ProviderAwareEntryPoint entryPoint; private final KakaoAuthService kakaoAuthService; + private final AppleAuthService appleAuthService; private final UserGetService userGetService; private final OauthProperties props; @@ -68,25 +67,57 @@ public class OAuth2AuthorizationServerConfig { @Order(1) // 우선순위를 기본 filter보다 높게 설정 public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, KakaoAccessTokenAuthenticationConverter kakaoConverter, - KakaoAuthenticationProvider kakaoProvider) throws Exception { - OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + KakaoAuthenticationProvider kakaoProvider, + AppleIdentityTokenAuthenticationConverter appleConverter, + AppleAuthenticationProvider appleProvider) throws Exception { // entryPoint 주입 - http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) - .oidc(Customizer.withDefaults()) - .tokenEndpoint(token -> token - .accessTokenRequestConverters(c -> c.add(kakaoConverter)) - .authenticationProviders(p -> p.add(kakaoProvider)) - ); + // 1. Configurer 인스턴스 생성 (공식 템플릿 방식) + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = + OAuth2AuthorizationServerConfigurer.authorizationServer(); - http.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); + http + // 2. 이 필터체인이 적용될 엔드포인트를 명시적으로 지정 (템플릿 방식) + .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) - // 커스텀 EntryPoint (provider 파라미터 해석 → 302 /oauth2/authorization/{provider}) - http.exceptionHandling(e -> e.defaultAuthenticationEntryPointFor( - entryPoint, rq -> rq.getRequestURI().startsWith("/oauth2/authorize"))); + // 3. .with()를 사용하여 Configurer 적용 및 커스텀 (템플릿 방식) + .with(authorizationServerConfigurer, (authorizationServer) -> + authorizationServer + .oidc(Customizer.withDefaults()) // OIDC 활성화 - return http - .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**", "/.well-known/**")) - .build(); + // 4. [사용자 정의] 토큰 엔드포인트 커스텀 로직 삽입 + .tokenEndpoint(token -> token + .accessTokenRequestConverters(c -> { + c.add(kakaoConverter); + c.add(appleConverter); + }) + .authenticationProviders(p -> { + p.add(kakaoProvider); + p.add(appleProvider); + }) + ) + ) + + // 5. 엔드포인트에 대한 기본 인증 요구 (템플릿 방식) + .authorizeHttpRequests((authorize) -> + authorize.anyRequest().authenticated() + ) + + // 6. [사용자 정의] 리소스 서버 설정 (JWT 검증) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) + + // 7. [사용자 정의] 인증 실패 시 커스텀 EntryPoint 사용 (템플릿 구조 + 사용자 로직) + // (템플릿의 /login 리디렉션 대신, 기존의 provider 분기 로직을 사용) + .exceptionHandling((exceptions) -> exceptions + .defaultAuthenticationEntryPointFor( + entryPoint, // 사용자의 커스텀 EntryPoint + rq -> rq.getRequestURI().startsWith("/oauth2/authorize") // 사용자의 커스텀 Predicate + ) + ) + + // 8. [사용자 정의] CSRF 설정 + .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**", "/.well-known/**")); + + return http.build(); } @Bean @@ -104,6 +135,7 @@ public RegisteredClientRepository registeredClientRepository() { type.add(AuthorizationGrantType.AUTHORIZATION_CODE); type.add(AuthorizationGrantType.REFRESH_TOKEN); type.add(KakaoGrantType.KAKAO_ACCESS_TOKEN); + type.add(AppleGrantType.APPLE_IDENTITY_TOKEN); }) .redirectUris(uri -> { uri.addAll(leenk.getRedirectUris()); @@ -203,6 +235,20 @@ KakaoAuthenticationProvider kakaoProvider( kakaoAuthService, userGetService, authorizationService, tokenGenerator); } + @Bean + AppleIdentityTokenAuthenticationConverter appleConverter() { + return new AppleIdentityTokenAuthenticationConverter(); + } + + @Bean + AppleAuthenticationProvider appleProvider( + OAuth2AuthorizationService authorizationService, + OAuth2TokenGenerator tokenGenerator) { + + return new AppleAuthenticationProvider( + appleAuthService, userGetService, authorizationService, tokenGenerator); + } + private RSAKey loadRsaKeyFromString() { try { return new RSAKey.Builder(publicKey) diff --git a/src/main/java/leets/weeth/global/sas/config/grant/AppleAuthenticationProvider.java b/src/main/java/leets/weeth/global/sas/config/grant/AppleAuthenticationProvider.java new file mode 100644 index 00000000..d18db726 --- /dev/null +++ b/src/main/java/leets/weeth/global/sas/config/grant/AppleAuthenticationProvider.java @@ -0,0 +1,73 @@ +package leets.weeth.global.sas.config.grant; + +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.service.UserGetService; +import leets.weeth.global.auth.apple.AppleAuthService; +import leets.weeth.global.auth.apple.dto.AppleUserInfo; +import leets.weeth.global.sas.application.exception.AppleLoginException; +import leets.weeth.global.sas.application.exception.UserInActiveException; +import leets.weeth.global.sas.application.exception.UserNotFoundException; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.stereotype.Component; + +@Component +public class AppleAuthenticationProvider extends CustomAuthenticationProvider { + + private final AppleAuthService appleAuthService; + private final UserGetService userGetService; + + public AppleAuthenticationProvider( + AppleAuthService appleAuthService, + UserGetService userGetService, + OAuth2AuthorizationService authorizationService, + OAuth2TokenGenerator tokenGenerator + ) { + super(authorizationService, tokenGenerator); + this.appleAuthService = appleAuthService; + this.userGetService = userGetService; + } + + @Override + protected AuthorizationGrantType getGrantTokenType() { + return AppleGrantType.APPLE_IDENTITY_TOKEN; + } + + @Override + protected Class getAuthenticationClass() { + return AppleIdentityTokenAuthenticationToken.class; + } + + @Override + protected String extractAccessToken(Authentication authentication) { + AppleIdentityTokenAuthenticationToken grantAuth = + (AppleIdentityTokenAuthenticationToken) authentication; + return grantAuth.getAppleIdentityToken(); + } + + @Override + protected AppleUserInfo getUserInfo(String identityToken) { + try { + // Identity Token 검증 및 사용자 정보 추출 + return appleAuthService.verifyAndDecodeIdToken(identityToken); + } catch (Exception e) { + throw new AppleLoginException(e.getMessage()); + } + } + + @Override + protected User getOrLoadUser(AppleUserInfo userInfo) { + String appleId = userInfo.appleId(); + User user = userGetService.findByAppleId(appleId) + .orElseThrow(UserNotFoundException::new); + + if (user.isInactive()) { + throw new UserInActiveException(); + } + + return user; + } +} diff --git a/src/main/java/leets/weeth/global/sas/config/grant/AppleGrantType.java b/src/main/java/leets/weeth/global/sas/config/grant/AppleGrantType.java new file mode 100644 index 00000000..78155dfa --- /dev/null +++ b/src/main/java/leets/weeth/global/sas/config/grant/AppleGrantType.java @@ -0,0 +1,10 @@ +package leets.weeth.global.sas.config.grant; + +import org.springframework.security.oauth2.core.AuthorizationGrantType; + +public final class AppleGrantType { + public static final AuthorizationGrantType APPLE_IDENTITY_TOKEN = + new AuthorizationGrantType("apple_identity_token"); + + private AppleGrantType() {} +} diff --git a/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationConverter.java b/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationConverter.java new file mode 100644 index 00000000..dd9c5cdc --- /dev/null +++ b/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationConverter.java @@ -0,0 +1,37 @@ +package leets.weeth.global.sas.config.grant; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.StringUtils; + +import java.util.HashMap; + +public class AppleIdentityTokenAuthenticationConverter implements AuthenticationConverter { + + @Override + public Authentication convert(HttpServletRequest request) { + if (!AppleGrantType.APPLE_IDENTITY_TOKEN.getValue() + .equals(request.getParameter(OAuth2ParameterNames.GRANT_TYPE))) { + return null; + } + + String identityToken = request.getParameter("identity_token"); + if (!StringUtils.hasText(identityToken)) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + + Authentication clientPrincipal = (Authentication) request.getUserPrincipal(); + + var additional = new HashMap(); + request.getParameterMap().forEach((k, v) -> { + if (!OAuth2ParameterNames.GRANT_TYPE.equals(k) && !"identity_token".equals(k)) + additional.put(k, v[0]); + }); + + return new AppleIdentityTokenAuthenticationToken(identityToken, clientPrincipal, additional); + } +} diff --git a/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationToken.java b/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationToken.java new file mode 100644 index 00000000..02947407 --- /dev/null +++ b/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationToken.java @@ -0,0 +1,22 @@ +package leets.weeth.global.sas.config.grant; + +import lombok.Getter; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken; + +import java.util.Map; + +@Getter +public class AppleIdentityTokenAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken { + + private final String appleIdentityToken; + + public AppleIdentityTokenAuthenticationToken( + String appleIdentityToken, + Authentication clientPrincipal, + Map additionalParameters) { + super(new AuthorizationGrantType("apple_identity_token"), clientPrincipal, additionalParameters); + this.appleIdentityToken = appleIdentityToken; + } +} diff --git a/src/main/java/leets/weeth/global/sas/domain/service/RedisOAuth2AuthorizationService.java b/src/main/java/leets/weeth/global/sas/domain/service/RedisOAuth2AuthorizationService.java index 4909fc5a..a361ed0b 100644 --- a/src/main/java/leets/weeth/global/sas/domain/service/RedisOAuth2AuthorizationService.java +++ b/src/main/java/leets/weeth/global/sas/domain/service/RedisOAuth2AuthorizationService.java @@ -36,9 +36,15 @@ public void save(OAuth2Authorization authorization) { // ttl 설정 if (entity.getAccessToken() == null && entity instanceof OAuth2AuthorizationCodeGrantAuthorization codeGrant) { - entity.updateExpire(calculateTtlSeconds(((OAuth2AuthorizationCodeGrantAuthorization) entity).getAuthorizationCode().getExpiresAt())); - } else { + entity.updateExpire(calculateTtlSeconds(codeGrant.getAuthorizationCode().getExpiresAt())); + } else if (entity.getRefreshToken() != null) { entity.updateExpire(calculateTtlSeconds(entity.getRefreshToken().getExpiresAt())); + } else if (entity.getAccessToken() != null) { + // refresh token이 없으면 access token의 만료 시간 사용 + entity.updateExpire(calculateTtlSeconds(entity.getAccessToken().getExpiresAt())); + } else { + // access token도 없으면 기본값으로 1시간 설정 + entity.updateExpire(3600L); } this.authorizationGrantAuthorizationRepository.save(entity); diff --git a/src/main/java/leets/weeth/global/sas/presentation/AuthController.java b/src/main/java/leets/weeth/global/sas/presentation/AuthController.java index 6329292f..8627b09f 100644 --- a/src/main/java/leets/weeth/global/sas/presentation/AuthController.java +++ b/src/main/java/leets/weeth/global/sas/presentation/AuthController.java @@ -7,7 +7,6 @@ import leets.weeth.global.sas.application.dto.OauthUserInfoResponse; import leets.weeth.global.sas.application.usecase.AuthUsecase; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -47,6 +46,24 @@ public void kakaoCallback(@RequestParam String code, savedRequestHandler.onAuthenticationSuccess(request, response, auth); } + @GetMapping("/apple/oauth") + public void appleCallback(@RequestParam String code, + @RequestParam(required = false) String id_token, + HttpServletRequest request, + HttpServletResponse response) throws Exception { + + User findUser = authUsecase.appleLogin(code, id_token); + + Authentication auth = new UsernamePasswordAuthenticationToken(SecurityUser.from(findUser), null, List.of(new SimpleGrantedAuthority(findUser.getRole().name()))); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + securityContextRepository.saveContext(context, request, response); + + savedRequestHandler.onAuthenticationSuccess(request, response, auth); + } + @GetMapping("/user/me") public OauthUserInfoResponse userInfo(@RequestHeader("Authorization") String accessToken) { return authUsecase.userInfo(accessToken); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 82381f45..94b3fd9b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,6 +27,14 @@ auth: grant_type: ${KAKAO_GRANT_TYPE} token_uri: ${KAKAO_TOKEN_URI} user_info_uri: ${KAKAO_USER_INFO_URI} + apple: + client_id: ${APPLE_CLIENT_ID} + team_id: ${APPLE_TEAM_ID} + key_id: ${APPLE_KEY_ID} + redirect_uri: ${APPLE_REDIRECT_URI} + token_uri: https://appleid.apple.com/auth/token + keys_uri: https://appleid.apple.com/auth/keys + private_key_path: ${APPLE_PRIVATE_KEY_PATH} jwt: private-key: ${JWT_PRIVATE_KEY} public-key: ${JWT_PUBLIC_KEY}