Skip to content

Commit

Permalink
feat: #86 애플 소셜 로그인 구현
Browse files Browse the repository at this point in the history
  • Loading branch information
seungikLeee authored and eun0901 committed Jan 12, 2025
1 parent 3a0dd81 commit 2333ff7
Show file tree
Hide file tree
Showing 13 changed files with 492 additions and 2 deletions.
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,11 @@ dependencies {

// websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// implementation 'org.webjars:sockjs-client:1.0.2'
// implementation 'org.webjars:sockjs-client:1.0.2'
implementation 'org.webjars:stomp-websocket:2.3.3'

// Apple id_token signature verify
implementation 'com.nimbusds:nimbus-jose-jwt:9.31'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Comment;
import org.hibernate.annotations.DynamicUpdate;
import org.jullaene.walkmong_back.api.member.domain.enums.Provider;
import org.jullaene.walkmong_back.api.member.domain.enums.Role;
import org.jullaene.walkmong_back.api.member.dto.req.MemberCreateReq;
import org.jullaene.walkmong_back.api.member.dto.req.MemberReqDto;
Expand Down Expand Up @@ -66,6 +67,12 @@ public class Member extends BaseEntity {
@Comment("산책 가능 반려동물 크기")
private String availabilityWithSize;

@Comment("소셜 로그인 공급자 ID")
private String providerId;

@Comment("소셜 로그인 제공자")
@Enumerated(EnumType.STRING)
private Provider provider;

@Builder
public Member (MemberCreateReq memberCreateReq, String profileUrl, String password) {
Expand All @@ -84,6 +91,17 @@ public Long getMemberId() {
return memberId;
}

public Member(String email, String providerId, Provider provider) {
this.email = email;
this.providerId = providerId;
this.provider = provider;
}

public void linkSocialAccount(String providerId, Provider provider) {
this.providerId = providerId;
this.provider = provider;
}

public void addWalkingExperience(WalkExperienceReq walkExperienceReq) {
this.dogOwnership = walkExperienceReq.getDogOwnershipYn();
this.dogWalkingExperienceYn = walkExperienceReq.getDogWalkingExperienceYn();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.jullaene.walkmong_back.api.member.domain.enums;

public enum Provider {
APPLE,
GOOGLE,
NAVER,
;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.jullaene.walkmong_back.api.member.dto.res;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class OAuthLoginResponseDto {
private String accessToken;
private String refreshToken;
private boolean isNewUser; // 새로운 필드 추가
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.jullaene.walkmong_back.api.member.dto.res;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* 소셜 로그인 유저 정보 DTO
*/
@Builder
@NoArgsConstructor(access = AccessLevel.PUBLIC)
@AllArgsConstructor
@Data
public class OAuthUserInfoResponseDto {
// 고유ID
@JsonProperty("sub")
private String subject;

@JsonProperty("email")
private String email;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.util.Optional;
import org.jullaene.walkmong_back.api.member.domain.Member;
import org.jullaene.walkmong_back.api.member.domain.enums.Provider;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
Expand All @@ -20,4 +21,8 @@ public interface MemberRepository extends JpaRepository<Member, Long>, MemberRep
Optional<Member> findByMemberIdAndDelYn(Long memberId, String delYn);

boolean existsByEmailOrNicknameAndDelYn(String email, String nickname, String delYn);

Optional<Member> findByProviderIdAndProviderAndDelYn(String providerId, Provider provider, String delYn);

Optional<Member> findByEmailAndDelYn(String email, String delYn);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.jullaene.walkmong_back.api.oauth.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* 애플 소셜 토큰 응답 정보 DTO
*/
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Data
public class AppleTokenInfoResponseDto {
@JsonProperty("access_token")
private String accessToken;

@JsonProperty("token_type")
private String tokenType;

@JsonProperty("expires_in")
private Long expiresIn;

@JsonProperty("refresh_token")
private String refreshToken;

@JsonProperty("id_token")
private String idToken;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.jullaene.walkmong_back.api.oauth.rest;

import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.jullaene.walkmong_back.api.member.domain.enums.Provider;
import org.jullaene.walkmong_back.api.member.dto.res.OAuthLoginResponseDto;
import org.jullaene.walkmong_back.api.member.dto.res.OAuthUserInfoResponseDto;
import org.jullaene.walkmong_back.api.oauth.service.OAuthService;
import org.jullaene.walkmong_back.common.BasicResponse;
import org.jullaene.walkmong_back.api.oauth.service.AppleTokenService;
import org.jullaene.walkmong_back.common.exception.CustomException;
import org.jullaene.walkmong_back.common.exception.ErrorType;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "OAuth", description = "OAuth 인증 관련 api 입니다.")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/oauth")
public class OAuthController {

private final AppleTokenService appleTokenService;
private final OAuthService oAuthService;

// redirect url로 authorizaion code 획득
@PostMapping("/apple/callback")
public ResponseEntity<BasicResponse<OAuthLoginResponseDto>> handleAppleCallback(@RequestParam Map<String, String> params) {
String authorizationCode = params.get("code");

if (authorizationCode == null || authorizationCode.isEmpty()) {
throw new CustomException(HttpStatus.BAD_REQUEST, ErrorType.MISSING_AUTHORIZATION_CODE);
}
// 1. Apple 토큰 API 호출 및 검증
OAuthUserInfoResponseDto userInfo = appleTokenService.processToken(authorizationCode);

// 2. 로그인 or 회원 가입 진행
OAuthLoginResponseDto loginResponse = oAuthService.handleSocialLogin(userInfo, Provider.APPLE);

// 3. 응답 반환
return ResponseEntity.ok(BasicResponse.ofSuccess(loginResponse));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package org.jullaene.walkmong_back.api.oauth.service;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.BadJOSEException;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import java.io.IOException;
import java.net.URL;
import java.text.ParseException;
import java.util.Date;
import org.jullaene.walkmong_back.api.member.dto.res.OAuthUserInfoResponseDto;
import org.jullaene.walkmong_back.api.oauth.dto.AppleTokenInfoResponseDto;
import org.jullaene.walkmong_back.api.oauth.utils.AppleClientSecretGenerator;
import org.jullaene.walkmong_back.common.exception.CustomException;
import org.jullaene.walkmong_back.common.exception.ErrorType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

@Service
public class AppleTokenService {

@Value("${apple.auth.client-id}")
private String clientId;

@Value("${apple.auth.token-url}")
private String tokenUrl;

@Value("${apple.auth.key-url}")
private String APPLE_PUBLIC_KEYS_URL;

@Value("${apple.auth.url}")
private String ISSUER;

@Value("${apple.auth.redirect-uri}")
private String redirectUri;

private final AppleClientSecretGenerator appleClientSecretGenerator;

public AppleTokenService(AppleClientSecretGenerator appleClientSecretGenerator) {
this.appleClientSecretGenerator = appleClientSecretGenerator;
}

public OAuthUserInfoResponseDto processToken(String authorizationCode) {
// 1. Client secret 생성
String clientSecret = appleClientSecretGenerator.generateClientSecret();

// 2. Apple 토큰 API 호출
AppleTokenInfoResponseDto tokenResponse = getIdToken(clientSecret, authorizationCode);

// 3. 토큰 검증 및 유저 정보 추출
return validateToken(tokenResponse);
}

private AppleTokenInfoResponseDto getIdToken(String clientSecret, String authorizationCode) {
RestTemplate restTemplate = new RestTemplate();

MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
body.add("code", authorizationCode);
body.add("grant_type", "authorization_code");
body.add("redirect_uri", redirectUri);

// HTTP 요청 생성
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(body, headers);

try {
// Apple의 /auth/token API 호출
ResponseEntity<AppleTokenInfoResponseDto> response = restTemplate.exchange(
tokenUrl, HttpMethod.POST, requestEntity, AppleTokenInfoResponseDto.class
);

if (response.getStatusCode() == HttpStatus.OK) {
return response.getBody();
} else {
throw new CustomException(HttpStatus.BAD_REQUEST, ErrorType.INVALID_GRANT);
}
} catch (HttpClientErrorException e) {
throw new CustomException(HttpStatus.BAD_REQUEST, ErrorType.INVALID_GRANT);
} catch (Exception e) {
throw new CustomException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorType.INTERNAL_SERVER);
}
}

private OAuthUserInfoResponseDto validateToken(AppleTokenInfoResponseDto tokenResponse) {
try {
// 1. JWT Processor 설정
String idToken = tokenResponse.getIdToken();

ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();

// 2. Apple 공개 키 가져오기
JWKSet jwkSet = JWKSet.load(new URL(APPLE_PUBLIC_KEYS_URL));

// 3. JWS 키 선택자 설정
JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(jwkSet);
JWSVerificationKeySelector<SecurityContext> keySelector =
new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource);

// 4. JWT Processor에 Key Selector 연결
jwtProcessor.setJWSKeySelector(keySelector);

// 5. ID Token 검증
JWTClaimsSet claims = jwtProcessor.process(idToken, null);

// 6. Claim 검증
if (!ISSUER.equals(claims.getIssuer())) {
throw new CustomException(HttpStatus.UNAUTHORIZED, ErrorType.INVALID_TOKEN_ISSUER);
}

if (!claims.getAudience().contains(clientId)) {
throw new CustomException(HttpStatus.UNAUTHORIZED, ErrorType.INVALID_TOKEN_AUDIENCE);
}

Date expiration = claims.getExpirationTime();
if (expiration == null || expiration.before(new Date())) {
throw new CustomException(HttpStatus.UNAUTHORIZED, ErrorType.EXPIRED_APPLE_ID_TOKEN);
}

// 7. DTO로 변환하여 반환
return OAuthUserInfoResponseDto.builder()
.subject(claims.getSubject())
.email((String) claims.getClaim("email"))
.build();

} catch (IOException e) {
throw new CustomException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorType.APPLE_PUBLIC_KEY_LOAD_ERROR);
} catch (ParseException e) {
throw new CustomException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorType.TOKEN_PARSE_ERROR);
} catch (JOSEException | BadJOSEException e) {
throw new CustomException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorType.TOKEN_PROCESSING_ERROR);
} catch (IllegalArgumentException e) {
throw new CustomException(HttpStatus.UNAUTHORIZED, ErrorType.INVALID_TOKEN);
}
}
}
Loading

0 comments on commit 2333ff7

Please sign in to comment.