diff --git a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java index 8617c3fa..f0f341d1 100644 --- a/src/main/java/gg/agit/konect/domain/user/controller/UserController.java +++ b/src/main/java/gg/agit/konect/domain/user/controller/UserController.java @@ -33,12 +33,13 @@ public ResponseEntity signup( ) { String email = (String)session.getAttribute("email"); Provider provider = (Provider)session.getAttribute("provider"); + String providerId = (String)session.getAttribute("providerId"); if (email == null || provider == null) { throw CustomException.of(ApiResponseCode.INVALID_SESSION); } - Integer userId = userService.signup(email, provider, request); + Integer userId = userService.signup(email, providerId, provider, request); session.invalidate(); diff --git a/src/main/java/gg/agit/konect/domain/user/enums/Provider.java b/src/main/java/gg/agit/konect/domain/user/enums/Provider.java index 92c5b061..b4d8e76d 100644 --- a/src/main/java/gg/agit/konect/domain/user/enums/Provider.java +++ b/src/main/java/gg/agit/konect/domain/user/enums/Provider.java @@ -4,7 +4,8 @@ public enum Provider { GOOGLE("email"), NAVER("response.email"), - KAKAO("kakao_account.email"); + KAKAO("kakao_account.email"), + APPLE("email"); private final String emailPath; diff --git a/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java b/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java index b0bd2374..20e70a10 100644 --- a/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java +++ b/src/main/java/gg/agit/konect/domain/user/model/UnRegisteredUser.java @@ -25,6 +25,10 @@ @UniqueConstraint( name = "uq_unregistered_user_email_provider", columnNames = {"email", "provider"} + ), + @UniqueConstraint( + name = "uq_unregistered_user_provider_provider_id", + columnNames = {"provider", "provider_id"} ) } ) @@ -43,10 +47,14 @@ public class UnRegisteredUser extends BaseEntity { @Enumerated(EnumType.STRING) private Provider provider; + @Column(name = "provider_id", length = 255) + private String providerId; + @Builder - private UnRegisteredUser(Integer id, String email, Provider provider) { + private UnRegisteredUser(Integer id, String email, Provider provider, String providerId) { this.id = id; this.email = email; this.provider = provider; + this.providerId = providerId; } } diff --git a/src/main/java/gg/agit/konect/domain/user/model/User.java b/src/main/java/gg/agit/konect/domain/user/model/User.java index df3398da..9d1bb9e8 100644 --- a/src/main/java/gg/agit/konect/domain/user/model/User.java +++ b/src/main/java/gg/agit/konect/domain/user/model/User.java @@ -36,6 +36,10 @@ @UniqueConstraint( name = "uq_users_university_id_student_number", columnNames = {"university_id", "student_number"} + ), + @UniqueConstraint( + name = "uq_users_provider_provider_id", + columnNames = {"provider", "provider_id"} ) } ) @@ -69,6 +73,9 @@ public class User extends BaseEntity { @Enumerated(EnumType.STRING) private Provider provider; + @Column(name = "provider_id", length = 255) + private String providerId; + @Column(name = "is_marketing_agreement", nullable = false) private Boolean isMarketingAgreement; @@ -84,6 +91,7 @@ private User( String phoneNumber, String studentNumber, Provider provider, + String providerId, Boolean isMarketingAgreement, String imageUrl ) { @@ -94,10 +102,31 @@ private User( this.phoneNumber = phoneNumber; this.studentNumber = studentNumber; this.provider = provider; + this.providerId = providerId; this.isMarketingAgreement = isMarketingAgreement; this.imageUrl = imageUrl; } + public static User of( + University university, + UnRegisteredUser tempUser, + String name, + String studentNumber, + Boolean isMarketingAgreement, + String imageUrl + ) { + return User.builder() + .university(university) + .email(tempUser.getEmail()) + .name(name) + .studentNumber(studentNumber) + .provider(tempUser.getProvider()) + .providerId(tempUser.getProviderId()) + .isMarketingAgreement(isMarketingAgreement) + .imageUrl(imageUrl) + .build(); + } + public void updateInfo(String name, String studentNumber, String phoneNumber) { this.name = name; this.studentNumber = studentNumber; diff --git a/src/main/java/gg/agit/konect/domain/user/repository/UnRegisteredUserRepository.java b/src/main/java/gg/agit/konect/domain/user/repository/UnRegisteredUserRepository.java index 4362a0dc..da0ca880 100644 --- a/src/main/java/gg/agit/konect/domain/user/repository/UnRegisteredUserRepository.java +++ b/src/main/java/gg/agit/konect/domain/user/repository/UnRegisteredUserRepository.java @@ -6,11 +6,27 @@ import gg.agit.konect.domain.user.enums.Provider; import gg.agit.konect.domain.user.model.UnRegisteredUser; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; public interface UnRegisteredUserRepository extends Repository { Optional findByEmailAndProvider(String email, Provider provider); + Optional findByProviderIdAndProvider(String providerId, Provider provider); + + boolean existsByProviderIdAndProvider(String providerId, Provider provider); + + default UnRegisteredUser getByEmailAndProvider(String email, Provider provider) { + return findByEmailAndProvider(email, provider) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_UNREGISTERED_USER)); + } + + default UnRegisteredUser getByProviderIdAndProvider(String providerId, Provider provider) { + return findByProviderIdAndProvider(providerId, provider) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_UNREGISTERED_USER)); + } + void save(UnRegisteredUser user); void delete(UnRegisteredUser user); diff --git a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java index 13beaf1b..72c0719c 100644 --- a/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java +++ b/src/main/java/gg/agit/konect/domain/user/repository/UserRepository.java @@ -16,6 +16,10 @@ public interface UserRepository extends Repository { Optional findByEmailAndProvider(String email, Provider provider); + Optional findByProviderIdAndProvider(String providerId, Provider provider); + + boolean existsByProviderIdAndProvider(String providerId, Provider provider); + Optional findById(Integer id); default User getById(Integer id) { @@ -23,6 +27,11 @@ default User getById(Integer id) { CustomException.of(ApiResponseCode.NOT_FOUND_USER)); } + default User getByProviderIdAndProvider(String providerId, Provider provider) { + return findByProviderIdAndProvider(providerId, provider) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_USER)); + } + boolean existsByUniversityIdAndStudentNumberAndIdNot(Integer universityId, String studentNumber, Integer id); boolean existsByUniversityIdAndStudentNumber(Integer universityId, String studentNumber); diff --git a/src/main/java/gg/agit/konect/domain/user/service/UserService.java b/src/main/java/gg/agit/konect/domain/user/service/UserService.java index fd1baf3c..3e497bac 100644 --- a/src/main/java/gg/agit/konect/domain/user/service/UserService.java +++ b/src/main/java/gg/agit/konect/domain/user/service/UserService.java @@ -45,30 +45,38 @@ public class UserService { private final StudyTimeQueryService studyTimeQueryService; @Transactional - public Integer signup(String email, Provider provider, SignupRequest request) { + public Integer signup(String email, String providerId, Provider provider, SignupRequest request) { + if (provider == Provider.APPLE && !StringUtils.hasText(providerId)) { + throw CustomException.of(ApiResponseCode.INVALID_SESSION); + } + + if (StringUtils.hasText(providerId)) { + userRepository.findByProviderIdAndProvider(providerId, provider) + .ifPresent(u -> { + throw CustomException.of(ApiResponseCode.ALREADY_REGISTERED_USER); + }); + } + userRepository.findByEmailAndProvider(email, provider) .ifPresent(u -> { throw CustomException.of(ApiResponseCode.ALREADY_REGISTERED_USER); }); - UnRegisteredUser tempUser = unRegisteredUserRepository - .findByEmailAndProvider(email, provider) - .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_UNREGISTERED_USER)); + UnRegisteredUser tempUser = findUnregisteredUser(email, providerId, provider); University university = universityRepository.findById(request.universityId()) .orElseThrow(() -> CustomException.of(ApiResponseCode.UNIVERSITY_NOT_FOUND)); validateStudentNumberDuplicationOnSignup(university.getId(), request.studentNumber()); - User newUser = User.builder() - .university(university) - .email(tempUser.getEmail()) - .name(request.name()) - .studentNumber(request.studentNumber()) - .provider(tempUser.getProvider()) - .isMarketingAgreement(request.isMarketingAgreement()) - .imageUrl("https://stage-static.koreatech.in/konect/User_02.png") - .build(); + User newUser = User.of( + university, + tempUser, + request.name(), + request.studentNumber(), + request.isMarketingAgreement(), + "https://stage-static.koreatech.in/konect/User_02.png" + ); User savedUser = userRepository.save(newUser); @@ -77,11 +85,20 @@ public Integer signup(String email, Provider provider, SignupRequest request) { return savedUser.getId(); } + private UnRegisteredUser findUnregisteredUser(String email, String providerId, Provider provider) { + if (StringUtils.hasText(providerId)) { + if (unRegisteredUserRepository.existsByProviderIdAndProvider(providerId, provider)) { + return unRegisteredUserRepository.getByProviderIdAndProvider(providerId, provider); + } + } + + return unRegisteredUserRepository.getByEmailAndProvider(email, provider); + } + public UserInfoResponse getUserInfo(Integer userId) { User user = userRepository.getById(userId); List clubMembers = clubMemberRepository.findAllByUserId(user.getId()); - boolean isClubManager = clubMembers.stream() - .anyMatch(ClubMember::isPresident); + boolean isClubManager = clubMembers.stream().anyMatch(ClubMember::isPresident); int joinedClubCount = clubMembers.size(); Long unreadCouncilNoticeCount = councilNoticeReadRepository.countUnreadNoticesByUserId(user.getId()); Long studyTime = studyTimeQueryService.getTotalStudyTime(userId); diff --git a/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java b/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java index 99ec0f35..8346e309 100644 --- a/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/gg/agit/konect/global/auth/handler/OAuth2LoginSuccessHandler.java @@ -12,9 +12,11 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import gg.agit.konect.domain.user.enums.Provider; import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; import gg.agit.konect.domain.user.repository.UserRepository; import gg.agit.konect.global.code.ApiResponseCode; import gg.agit.konect.global.config.SecurityProperties; @@ -34,6 +36,7 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private static final int TEMP_SESSION_EXPIRATION_SECONDS = 600; private final UserRepository userRepository; + private final UnRegisteredUserRepository unRegisteredUserRepository; private final SecurityProperties securityProperties; @Override @@ -44,14 +47,32 @@ public void onAuthenticationSuccess( ) throws IOException { OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken)authentication; Provider provider = Provider.valueOf(oauthToken.getAuthorizedClientRegistrationId().toUpperCase()); - OAuth2User oauthUser = (OAuth2User)authentication.getPrincipal(); + + String providerId = null; String email = extractEmail(oauthUser, provider); + Optional user; - Optional user = userRepository.findByEmailAndProvider(email, provider); + if (provider == Provider.APPLE) { + providerId = extractProviderId(oauthUser); + + if (!StringUtils.hasText(providerId)) { + throw CustomException.of(ApiResponseCode.FAILED_EXTRACT_PROVIDER_ID); + } + } + + user = findUserByProvider(provider, email, providerId); if (user.isEmpty()) { - sendAdditionalInfoRequiredResponse(request, response, email, provider); + if (provider == Provider.APPLE && !StringUtils.hasText(email)) { + email = resolveAppleEmail(providerId); + + if (!StringUtils.hasText(email)) { + throw CustomException.of(ApiResponseCode.FAILED_EXTRACT_EMAIL); + } + } + + sendAdditionalInfoRequiredResponse(request, response, email, provider, providerId); return; } @@ -62,11 +83,17 @@ private void sendAdditionalInfoRequiredResponse( HttpServletRequest request, HttpServletResponse response, String email, - Provider provider + Provider provider, + String providerId ) throws IOException { HttpSession session = request.getSession(true); session.setAttribute("email", email); session.setAttribute("provider", provider); + + if (StringUtils.hasText(providerId)) { + session.setAttribute("providerId", providerId); + } + session.setMaxInactiveInterval(TEMP_SESSION_EXPIRATION_SECONDS); response.sendRedirect(frontendBaseUrl + "/signup"); @@ -88,18 +115,55 @@ private void sendLoginSuccessResponse( private String extractEmail(OAuth2User oauthUser, Provider provider) { Object current = oauthUser.getAttributes(); + boolean allowMissing = provider == Provider.APPLE; for (String key : provider.getEmailPath().split("\\.")) { if (!(current instanceof Map map)) { + if (allowMissing) { + return null; + } + throw CustomException.of(ApiResponseCode.FAILED_EXTRACT_EMAIL); } current = map.get(key); } + if (current == null && allowMissing) { + return null; + } + return (String)current; } + private String extractProviderId(OAuth2User oauthUser) { + String providerId = oauthUser.getAttribute("sub"); + + if (!StringUtils.hasText(providerId)) { + providerId = oauthUser.getName(); + } + + return providerId; + } + + private Optional findUserByProvider(Provider provider, String email, String providerId) { + if (provider == Provider.APPLE) { + return userRepository.findByProviderIdAndProvider(providerId, provider); + } + + return userRepository.findByEmailAndProvider(email, provider); + } + + private String resolveAppleEmail(String providerId) { + if (!StringUtils.hasText(providerId)) { + return null; + } + + return unRegisteredUserRepository.findByProviderIdAndProvider(providerId, Provider.APPLE) + .map(unRegisteredUser -> unRegisteredUser.getEmail()) + .orElse(null); + } + private String resolveSafeRedirect(String redirectUri) { if (redirectUri == null || redirectUri.isBlank()) { return frontendBaseUrl + "/home"; diff --git a/src/main/java/gg/agit/konect/global/auth/oauth/AppleClientSecretProvider.java b/src/main/java/gg/agit/konect/global/auth/oauth/AppleClientSecretProvider.java new file mode 100644 index 00000000..069b9206 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/oauth/AppleClientSecretProvider.java @@ -0,0 +1,154 @@ +package gg.agit.konect.global.auth.oauth; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.interfaces.ECPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AppleClientSecretProvider { + + private static final String APPLE_AUDIENCE = "https://appleid.apple.com"; + private static final int DEFAULT_TOKEN_VALIDITY_DAYS = 180; + private static final int DEFAULT_REFRESH_BEFORE_DAYS = 7; + + private final AppleOAuthProperties properties; + private volatile CachedSecret cachedSecret; + + public String getClientSecret() { + Instant now = Instant.now(); + CachedSecret current = cachedSecret; + + if (current != null && !current.shouldRefresh(now, resolveRefreshBeforeDays())) { + return current.token(); + } + + synchronized (this) { + current = cachedSecret; + if (current == null || current.shouldRefresh(now, resolveRefreshBeforeDays())) { + cachedSecret = createSecret(now); + } + } + + return cachedSecret.token(); + } + + private CachedSecret createSecret(Instant now) { + int tokenValidityDays = resolveTokenValidityDays(); + String token = generateToken(now, tokenValidityDays); + Instant expiresAt = now.plus(tokenValidityDays, ChronoUnit.DAYS); + return new CachedSecret(token, expiresAt); + } + + private String generateToken(Instant issuedAt, int tokenValidityDays) { + validateRequiredProperties(); + ECPrivateKey privateKey = parsePrivateKey(readPrivateKeyFromPath(properties.getPrivateKeyPath())); + + JWTClaimsSet claims = new JWTClaimsSet.Builder() + .issuer(properties.getTeamId()) + .subject(properties.getClientId()) + .audience(APPLE_AUDIENCE) + .issueTime(java.util.Date.from(issuedAt)) + .expirationTime(java.util.Date.from(issuedAt.plus(tokenValidityDays, ChronoUnit.DAYS))) + .build(); + + JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256) + .keyID(properties.getKeyId()) + .build(); + + SignedJWT signedJWT = new SignedJWT(header, claims); + + try { + signedJWT.sign(new ECDSASigner(privateKey)); + } catch (JOSEException e) { + throw new IllegalStateException("Failed to sign Apple client secret.", e); + } + + return signedJWT.serialize(); + } + + private ECPrivateKey parsePrivateKey(String rawKey) { + String normalized = normalizePrivateKey(rawKey); + byte[] decoded = Base64.getDecoder().decode(normalized); + + try { + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decoded); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return (ECPrivateKey)keyFactory.generatePrivate(keySpec); + } catch (Exception e) { + throw new IllegalStateException("Failed to parse Apple private key.", e); + } + } + + private String normalizePrivateKey(String rawKey) { + if (!StringUtils.hasText(rawKey)) { + throw new IllegalStateException("Apple private key is missing."); + } + + String key = rawKey.replace("\\n", "\n"); + key = key.replace("-----BEGIN PRIVATE KEY-----", ""); + key = key.replace("-----END PRIVATE KEY-----", ""); + return key.replaceAll("\\s", ""); + } + + private String readPrivateKeyFromPath(String keyPath) { + try { + return Files.readString(Path.of(keyPath), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalStateException("Failed to read Apple private key file.", e); + } + } + + private void validateRequiredProperties() { + if (!StringUtils.hasText(properties.getTeamId())) { + throw new IllegalStateException("Apple teamId is missing."); + } + + if (!StringUtils.hasText(properties.getClientId())) { + throw new IllegalStateException("Apple clientId is missing."); + } + + if (!StringUtils.hasText(properties.getKeyId())) { + throw new IllegalStateException("Apple keyId is missing."); + } + + if (!StringUtils.hasText(properties.getPrivateKeyPath())) { + throw new IllegalStateException("Apple private key path is missing."); + } + } + + private int resolveTokenValidityDays() { + int days = properties.getTokenValidityDays(); + return days > 0 ? days : DEFAULT_TOKEN_VALIDITY_DAYS; + } + + private int resolveRefreshBeforeDays() { + int days = properties.getRefreshBeforeDays(); + return days > 0 ? days : DEFAULT_REFRESH_BEFORE_DAYS; + } + + private record CachedSecret(String token, Instant expiresAt) { + private boolean shouldRefresh(Instant now, int refreshBeforeDays) { + Instant refreshAt = expiresAt.minus(refreshBeforeDays, ChronoUnit.DAYS); + return now.isAfter(refreshAt); + } + } +} diff --git a/src/main/java/gg/agit/konect/global/auth/oauth/AppleOAuthProperties.java b/src/main/java/gg/agit/konect/global/auth/oauth/AppleOAuthProperties.java new file mode 100644 index 00000000..05e76e94 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/oauth/AppleOAuthProperties.java @@ -0,0 +1,21 @@ +package gg.agit.konect.global.auth.oauth; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.apple.oauth") +public class AppleOAuthProperties { + + private String teamId; + private String clientId; + private String keyId; + private String privateKeyPath; + private int tokenValidityDays; + private int refreshBeforeDays; +} diff --git a/src/main/java/gg/agit/konect/global/auth/oauth/AppleOAuthServiceImpl.java b/src/main/java/gg/agit/konect/global/auth/oauth/AppleOAuthServiceImpl.java new file mode 100644 index 00000000..fc5807cd --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/oauth/AppleOAuthServiceImpl.java @@ -0,0 +1,69 @@ +package gg.agit.konect.global.auth.oauth; + +import java.util.Optional; + +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import gg.agit.konect.domain.user.enums.Provider; +import gg.agit.konect.domain.user.model.UnRegisteredUser; +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.domain.user.repository.UnRegisteredUserRepository; +import gg.agit.konect.domain.user.repository.UserRepository; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service("apple") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AppleOAuthServiceImpl extends OidcUserService { + + private final UserRepository userRepository; + private final UnRegisteredUserRepository unRegisteredUserRepository; + + @Transactional + @Override + public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + OidcUser oidcUser = super.loadUser(userRequest); + + String email = oidcUser.getAttribute("email"); + String providerId = oidcUser.getSubject(); + + String registrationId = userRequest.getClientRegistration().getRegistrationId().toUpperCase(); + Provider provider = Provider.valueOf(registrationId); + + if (userRepository.existsByProviderIdAndProvider(providerId, provider)) { + return oidcUser; + } + + if (StringUtils.hasText(email)) { + Optional registeredByEmail = userRepository.findByEmailAndProvider(email, provider); + + if (registeredByEmail.isPresent()) { + return oidcUser; + } + } + + if (!unRegisteredUserRepository.existsByProviderIdAndProvider(providerId, provider)) { + if (!StringUtils.hasText(email)) { + throw CustomException.of(ApiResponseCode.FAILED_EXTRACT_EMAIL); + } + + UnRegisteredUser newUser = UnRegisteredUser.builder() + .email(email) + .provider(provider) + .providerId(providerId) + .build(); + + unRegisteredUserRepository.save(newUser); + } + + return oidcUser; + } +} diff --git a/src/main/java/gg/agit/konect/global/auth/oauth/CustomRequestEntityConverter.java b/src/main/java/gg/agit/konect/global/auth/oauth/CustomRequestEntityConverter.java new file mode 100644 index 00000000..a755bc8d --- /dev/null +++ b/src/main/java/gg/agit/konect/global/auth/oauth/CustomRequestEntityConverter.java @@ -0,0 +1,38 @@ +package gg.agit.konect.global.auth.oauth; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.oauth2.client.endpoint.DefaultOAuth2TokenRequestParametersConverter; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.MultiValueMap; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CustomRequestEntityConverter + implements Converter> { + + private static final String APPLE_REGISTRATION_ID = "apple"; + + private final DefaultOAuth2TokenRequestParametersConverter delegate = + new DefaultOAuth2TokenRequestParametersConverter<>(); + private final AppleClientSecretProvider clientSecretProvider; + + @Override + public MultiValueMap convert(OAuth2AuthorizationCodeGrantRequest request) { + MultiValueMap parameters = delegate.convert(request); + + if (!isApple(request)) { + return parameters; + } + + parameters.set(OAuth2ParameterNames.CLIENT_SECRET, clientSecretProvider.getClientSecret()); + + return parameters; + } + + private boolean isApple(OAuth2AuthorizationCodeGrantRequest request) { + return APPLE_REGISTRATION_ID.equals(request.getClientRegistration().getRegistrationId()); + } + +} diff --git a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java index 5d05aabb..2419c7d2 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -19,6 +19,7 @@ public enum ApiResponseCode { INVALID_JSON_FORMAT(HttpStatus.BAD_REQUEST, "요청 본문의 JSON 형식이 잘못되었습니다."), MISSING_REQUIRED_PARAMETER(HttpStatus.BAD_REQUEST, "필수 요청 파라미터가 누락되었습니다."), FAILED_EXTRACT_EMAIL(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 이메일 정보를 가져올 수 없습니다."), + FAILED_EXTRACT_PROVIDER_ID(HttpStatus.BAD_REQUEST, "OAuth 로그인 과정에서 제공자 식별자를 가져올 수 없습니다."), CANNOT_CREATE_CHAT_ROOM_WITH_SELF(HttpStatus.BAD_REQUEST, "자기 자신과는 채팅방을 만들 수 없습니다."), REQUIRED_CLUB_APPLY_ANSWER_MISSING(HttpStatus.BAD_REQUEST, "필수 가입 답변이 누락되었습니다."), CANNOT_DELETE_CLUB_PRESIDENT(HttpStatus.BAD_REQUEST, "동아리 회장인 경우 회장을 양도하고 탈퇴해야 합니다."), diff --git a/src/main/java/gg/agit/konect/global/config/SecurityConfig.java b/src/main/java/gg/agit/konect/global/config/SecurityConfig.java index c7d45d51..a8f2ff08 100644 --- a/src/main/java/gg/agit/konect/global/config/SecurityConfig.java +++ b/src/main/java/gg/agit/konect/global/config/SecurityConfig.java @@ -12,11 +12,17 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.RestClientAuthorizationCodeTokenResponseClient; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter; import org.springframework.security.web.SecurityFilterChain; import gg.agit.konect.global.auth.filter.OAuth2RedirectUriSaveFilter; import gg.agit.konect.global.auth.handler.OAuth2LoginSuccessHandler; +import gg.agit.konect.global.auth.oauth.AppleClientSecretProvider; +import gg.agit.konect.global.auth.oauth.AppleOAuthServiceImpl; +import gg.agit.konect.global.auth.oauth.CustomRequestEntityConverter; import gg.agit.konect.global.auth.oauth.SocialOAuthService; import lombok.RequiredArgsConstructor; @@ -38,7 +44,11 @@ public class SecurityConfig { private OAuth2RedirectUriSaveFilter redirectUriSaveFilter; @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain( + HttpSecurity http, + OAuth2AccessTokenResponseClient appleAccessTokenResponseClient, + AppleOAuthServiceImpl appleOAuthService + ) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) @@ -54,11 +64,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { }) ) .oauth2Login(oauth2 -> oauth2 + .tokenEndpoint(token -> token + .accessTokenResponseClient(appleAccessTokenResponseClient) + ) .userInfoEndpoint(userInfo -> userInfo .userService(userRequest -> { String registrationId = userRequest.getClientRegistration().getRegistrationId(); return oAuthServices.get(registrationId).loadUser(userRequest); }) + .oidcUserService(appleOAuthService) ) .successHandler(oAuth2LoginSuccessHandler) .failureHandler((request, response, exception) -> { @@ -70,4 +84,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } + + @Bean + public OAuth2AccessTokenResponseClient appleAccessTokenResponseClient( + AppleClientSecretProvider appleClientSecretProvider + ) { + RestClientAuthorizationCodeTokenResponseClient client = new RestClientAuthorizationCodeTokenResponseClient(); + client.setParametersConverter(new CustomRequestEntityConverter(appleClientSecretProvider)); + return client; + } } diff --git a/src/main/resources/config b/src/main/resources/config index bef49114..db6efe45 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit bef49114523ea91253677495e24dba28b808d007 +Subproject commit db6efe4587ad8074b807b0623417180e9411c0eb diff --git a/src/main/resources/db/migration/V10__add_provider_id_to_user_tables.sql b/src/main/resources/db/migration/V10__add_provider_id_to_user_tables.sql new file mode 100644 index 00000000..a8a706b6 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_provider_id_to_user_tables.sql @@ -0,0 +1,9 @@ +ALTER TABLE users + ADD COLUMN provider_id VARCHAR(255) NULL AFTER provider, + MODIFY COLUMN provider ENUM ('GOOGLE', 'KAKAO', 'NAVER', 'APPLE') NOT NULL, + ADD CONSTRAINT uq_users_provider_provider_id UNIQUE (provider, provider_id); + +ALTER TABLE unregistered_user + ADD COLUMN provider_id VARCHAR(255) NULL AFTER provider, + MODIFY COLUMN provider ENUM ('GOOGLE', 'KAKAO', 'NAVER', 'APPLE') NOT NULL, + ADD CONSTRAINT uq_unregistered_user_provider_provider_id UNIQUE (provider, provider_id); diff --git a/src/main/resources/static/login.html b/src/main/resources/static/login.html index eee942f8..a1efe851 100644 --- a/src/main/resources/static/login.html +++ b/src/main/resources/static/login.html @@ -45,6 +45,9 @@ .kakao-btn { background: #FEE500; } + .apple-btn { + background: #000000; + } @@ -54,6 +57,7 @@

로그인

Google로 로그인 Naver로 로그인 Kakao로 로그인 + Apple로 로그인