Skip to content

Commit

Permalink
Merge pull request #87 from YAPP-Github/feat/ISSUE-77
Browse files Browse the repository at this point in the history
feat: social login & withdraw member
  • Loading branch information
Seokyeong237 authored Feb 12, 2024
2 parents 96e6a5a + f7c4da6 commit e474bd5
Show file tree
Hide file tree
Showing 26 changed files with 374 additions and 99 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ jobs:
cd ./resources
touch ./application.yml
echo "${{ secrets.APPLICATION_YML }}" | base64 --decode >> application.yml
touch ./private_key.p8
echo "${{ secrets.PRIVATE_KEY }}" | base64 --decode >> private_key.p8
mkdir firebase
cd ./firebase
touch ./firebase_key.json
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ out/

application.yml
firebase_key.json
private_key.p8
6 changes: 5 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,18 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.projectlombok:lombok:1.18.26'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.4'

// auth 설정
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation group: 'org.bouncycastle', name: 'bcprov-jdk15to18', version: '1.71'
implementation group: 'org.bouncycastle', name: 'bcpkix-jdk15to18', version: '1.71'

// Database 설정
runtimeOnly 'com.mysql:mysql-connector-j'

Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/fullcar/core/response/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public enum ErrorCode {

/* 400 BAD REQUEST */
FAILED_TO_GENERATE_PUBLIC_KEY(BAD_REQUEST, "애플 공개키 생성 중 문제 발생"),
FAILED_TO_GENERATE_APPLE_TOKEN(BAD_REQUEST, "애플 access Token 생성 중 문제 발생"),
EMAIL_ADDRESS_IN_BLACKLIST(BAD_REQUEST, "블랙리스트에 있는 이메일 주소입니다."),
CANNOT_SEND_TO_OWN_CARPOOL(BAD_REQUEST, "자기자신의 카풀에는 신청할 수 없습니다."),
DUPLICATED_FORM(BAD_REQUEST, "이미 요청을 보낸 카풀입니다."),
Expand All @@ -22,6 +23,7 @@ public enum ErrorCode {
INVALID_FORM_STATE(BAD_REQUEST, "유효하지 않은 신청서 상태입니다."),
EXISTED_CODE_IN_MAIL(BAD_REQUEST, "이미 인증번호를 보냈습니다."),
NOT_MATCHED_CODE(BAD_REQUEST, "인증번호가 일치하지 않습니다."),
INVALID_SOCIAL_TYPE(BAD_REQUEST, "유효하지 않은 소셜 로그인 타입 입니다."),

/* 401 UNAUTHORIZED */
UNAUTHORIZED_KAKAO_TOKEN(UNAUTHORIZED, "유효하지 않은 카카오 토큰"),
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/com/fullcar/core/response/SuccessCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ public enum SuccessCode {
REGISTER_SUCCESS(CREATED, "등록 성공"),

/* 200 OK */
SIGNIN_SUCCESS(OK, "소셜로그인 성공"),
APPLE_LOGIN_SUCCESS(OK, "애플 소셜 로그인 성공"),
KAKAO_LOGIN_SUCCESS(OK, "카카오 소셜 로그인 성공"),
GET_NEW_TOKEN_SUCCESS(OK, "토큰 재발급 성공"),
READ_SUCCESS(OK, "조회 성공"),
EMAIL_SENT_SUCCESS(OK, "인증메일 발송 성공"),
LOGOUT_SUCCESS(OK, "로그아웃 성공"),
AVAILABLE_NICKNAME(OK, "사용 가능한 닉네임"),
UPDATE_SUCCESS(OK, "수정 성공"),
CODE_VERIFICATION_SUCCESS(OK, "인증 성공");
CODE_VERIFICATION_SUCCESS(OK, "인증 성공"),
WITHDRAW_SUCCESS(OK, "탈퇴 성공");

private final HttpStatus status;
private final String message;
Expand Down
132 changes: 111 additions & 21 deletions src/main/java/com/fullcar/member/application/auth/AppleAuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,75 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fullcar.core.config.jwt.JwtTokenProvider;
import com.fullcar.core.exception.BadRequestException;
import com.fullcar.core.exception.CustomException;
import com.fullcar.core.exception.UnauthorizedException;
import com.fullcar.core.response.ErrorCode;
import com.fullcar.member.application.member.MemberMapper;
import com.fullcar.member.domain.auth.SocialId;
import com.fullcar.member.domain.auth.service.SocialIdService;
import com.fullcar.member.domain.member.Member;
import com.fullcar.member.domain.member.MemberRepository;
import com.fullcar.member.presentation.auth.dto.request.AuthRequestDto;
import com.fullcar.member.presentation.auth.dto.request.AppleAuthRequestDto;
import com.fullcar.member.presentation.auth.dto.response.AppleAuthTokenResponseDto;
import com.fullcar.member.presentation.auth.dto.response.SocialInfoResponseDto;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.bouncycastle.openssl.PEMParser;

import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
import java.util.Map;
import java.time.ZonedDateTime;
import java.util.*;


@Service
@RequiredArgsConstructor
public class AppleAuthService implements AuthService {
public class AppleAuthService {

@Value("${apple.team-id}")
private String teamId;

@Value("${apple.key-id}")
private String keyId;

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

@Value("${apple.iss}")
private String iss;
private static final String REQUEST_TOKEN_URL = "https://appleid.apple.com/auth/oauth2/v2/token";
private static final String REVOKE_TOKEN_URL = "https://appleid.apple.com/auth/oauth2/v2/revoke";

private final ObjectMapper objectMapper;
private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;
private final MemberMapper memberMapper;
private final SocialIdService socialIdService;

@Override
@Transactional
public SocialInfoResponseDto getMemberInfo(AuthRequestDto authRequestDto) {
String deviceToken = authRequestDto.getDeviceToken();
String idToken = authRequestDto.getToken();
public SocialInfoResponseDto getMemberInfo(AppleAuthRequestDto appleAuthRequestDto) throws IOException {
String deviceToken = appleAuthRequestDto.getDeviceToken();
String idToken = appleAuthRequestDto.getIdToken();
String appleRefreshToken = requestAppleAuthToken(appleAuthRequestDto.getAuthCode()).getRefreshToken();

Map<String, String> headers = parseHeaders(idToken);
ApplePublicKeyList applePublicKeys = getApplePublicKeyList();
PublicKey publicKey = generatePublicKey(headers, applePublicKeys);
Expand All @@ -67,9 +85,11 @@ public SocialInfoResponseDto getMemberInfo(AuthRequestDto authRequestDto) {
String refreshToken = jwtTokenProvider.generateRefreshToken();

if (memberRepository.existsBySocialId(socialId)) {
memberRepository.findBySocialIdAndIsDeleted(socialId, false).loginMember(deviceToken, refreshToken);
Member member = memberRepository.findBySocialId(socialId);
member.saveAppleRefreshToken(appleRefreshToken);
member.loginMember(deviceToken, refreshToken);
}
else createMember(socialId, deviceToken, refreshToken);
else createMember(socialId, appleRefreshToken, deviceToken, refreshToken);

return SocialInfoResponseDto.builder()
.socialId(socialId)
Expand All @@ -78,14 +98,14 @@ public SocialInfoResponseDto getMemberInfo(AuthRequestDto authRequestDto) {
}

// 새로운 멤버 생성
private void createMember(SocialId socialId, String deviceToken, String refreshToken) {
Member member = memberMapper.toLoginEntity(socialId, deviceToken, refreshToken);
private void createMember(SocialId socialId, String authCode, String deviceToken, String refreshToken) {
Member member = memberMapper.toAppleLoginEntity(socialId, authCode, deviceToken, refreshToken);
memberRepository.saveAndFlush(member);
}

// Claim 검증
private void validateClaims(Claims claims) {
if (!claims.getIssuer().contains(iss) || !claims.getAudience().equals(clientId)) {
if (!claims.getIssuer().contains("https://appleid.apple.com") || !claims.getAudience().equals(clientId)) {
throw new UnauthorizedException(ErrorCode.INVALID_CLAIMS);
}
}
Expand Down Expand Up @@ -117,9 +137,9 @@ private Claims extractClaims(String idToken, PublicKey publicKey) {

// apple public key 정보 가져오기
private ApplePublicKeyList getApplePublicKeyList() {
try {
RestTemplate restTemplate = new RestTemplate();
RestTemplate restTemplate = new RestTemplate();

try {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/json");

Expand Down Expand Up @@ -159,4 +179,74 @@ private PublicKey generatePublicKey(Map<String, String> header, ApplePublicKeyLi
throw new BadRequestException(ErrorCode.FAILED_TO_GENERATE_PUBLIC_KEY);
}
}

private String createClientSecret() throws IOException {
Date expirationDate = Date.from(ZonedDateTime.now().plusDays(30).toInstant());
Map<String, Object> jwtHeader = new HashMap<>();
jwtHeader.put("kid", keyId);
jwtHeader.put("alg", "ES256");

return Jwts.builder()
.setHeaderParams(jwtHeader)
.setIssuer(teamId)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(expirationDate)
.setAudience("https://appleid.apple.com")
.setSubject("com.fullcar.app")
.signWith(SignatureAlgorithm.ES256, getPrivateKey())
.compact();
}

private PrivateKey getPrivateKey() throws IOException {
ClassPathResource resource = new ClassPathResource("private_key.p8");
String privateKey = new String(Files.readAllBytes(Paths.get(resource.getURI())));
Reader pemReader = new StringReader(privateKey);
PEMParser pemParser = new PEMParser(pemReader);
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();
return converter.getPrivateKey(object);
}

public AppleAuthTokenResponseDto requestAppleAuthToken(String code) throws IOException {
String secret = createClientSecret();
System.out.println(secret);

RestTemplate restTemplate = new RestTemplateBuilder().build();
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("code", code);
params.add("client_id", clientId);
params.add("client_secret", createClientSecret());
params.add("grant_type", "authorization_code");

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(params, headers);

try {
ResponseEntity<AppleAuthTokenResponseDto> response = restTemplate.postForEntity(REQUEST_TOKEN_URL, httpEntity, AppleAuthTokenResponseDto.class);
System.out.println(response.getBody());
return response.getBody();
} catch (Exception e) {
System.out.println(e);
throw new IllegalArgumentException("Apple token error");
//throw new CustomException(ErrorCode.FAILED_TO_GENERATE_APPLE_TOKEN);
}
}

// 회원 탈퇴
public void revoke(Member member) throws IOException {
RestTemplate restTemplate = new RestTemplateBuilder().build();

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", clientId);
params.add("client_secret", createClientSecret());
params.add("token", member.getAppleRefreshToken());

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(params, headers);
restTemplate.postForEntity(REVOKE_TOKEN_URL, httpEntity, AppleAuthTokenResponseDto.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ public class AppleProperties {
private String keyId;
private String clientId;
private String audience;
private String iss;
private String privateKey;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.fullcar.member.application.auth;

import com.fullcar.member.presentation.auth.dto.request.AuthRequestDto;
import com.fullcar.member.presentation.auth.dto.response.SocialInfoResponseDto;

import com.fullcar.member.domain.member.Member;

public interface AuthService {
SocialInfoResponseDto getMemberInfo(AuthRequestDto authRequestDto);
void deleteUser(Member member);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,33 @@
import com.fullcar.core.config.jwt.JwtTokenProvider;
import com.fullcar.core.exception.CustomException;
import com.fullcar.core.response.ErrorCode;
import com.fullcar.member.application.car.CarService;
import com.fullcar.member.domain.member.Member;
import com.fullcar.member.domain.member.MemberRepository;
import com.fullcar.member.domain.member.MemberSocialType;
import com.fullcar.member.domain.member.service.MailService;
import com.fullcar.member.presentation.auth.dto.response.AuthResponseDto;
import com.fullcar.member.presentation.auth.dto.response.AuthTokenResponseDto;
import com.fullcar.member.presentation.auth.dto.response.SocialInfoResponseDto;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import com.fullcar.member.presentation.auth.dto.request.WithdrawRequestDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.HashMap;
import java.util.Map;
import java.io.IOException;

@Component
@RequiredArgsConstructor
public class AuthServiceProvider {
private static final Map<MemberSocialType, AuthService> authServiceMap = new HashMap<>();

private final KakaoAuthService kakaoAuthService;
private final AppleAuthService appleAuthService;
private final JwtTokenProvider jwtTokenProvider;
private final MemberRepository memberRepository;

@PostConstruct
void initializeAuthServicesMap() {
authServiceMap.put(MemberSocialType.KAKAO, kakaoAuthService);
authServiceMap.put(MemberSocialType.APPLE, appleAuthService);
}

public AuthService getAuthService(MemberSocialType socialType) {
return authServiceMap.get(socialType);
}
private final AppleAuthService appleAuthService;
private final KakaoAuthService kakaoAuthService;
private final CarService carService;
private final MailService mailService;

public AuthResponseDto socialLogin(SocialInfoResponseDto socialResponseDto) {

Member member = memberRepository.findBySocialIdAndIsDeleted(socialResponseDto.getSocialId(), false);
Member member = memberRepository.findBySocialId(socialResponseDto.getSocialId());
String accessToken = jwtTokenProvider.generateAccessToken(member);

return AuthResponseDto.builder()
Expand Down Expand Up @@ -71,4 +60,23 @@ public void socialLogout(Member member) {
memberRepository.findByIdAndIsDeletedOrThrow(member.getId(), false).clearRefreshTokenAndDeviceToken();
memberRepository.flush();
}

@Transactional
public void withdrawMember(Member member, WithdrawRequestDto withdrawRequestDto) throws IOException {
if (withdrawRequestDto.getSocialType() == MemberSocialType.APPLE) {
appleAuthService.revoke(member);
}
else if (withdrawRequestDto.getSocialType() == MemberSocialType.KAKAO) {
kakaoAuthService.revoke(member);
}
else {
throw new CustomException(ErrorCode.INVALID_SOCIAL_TYPE);
}

carService.deleteCar(member.getCarId());
mailService.deleteMail(member.getId());
memberRepository.saveAndFlush(member.deleted());

// TODO: 이벤트 기반으로 게시글 및 요청 처리
}
}
Loading

0 comments on commit e474bd5

Please sign in to comment.