Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: social login & withdraw member #87

Merged
merged 7 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading