From 2bf3e159b53ef6c8d4ae34c1a230ddc2a26734e7 Mon Sep 17 00:00:00 2001 From: BEMELON Date: Sun, 5 Mar 2023 22:08:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20(#1)=20AppleLogin=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../tune/server/config/SecurityConfig.java | 2 + .../server/dto/apple/AppleAuthTokenDto.java | 19 ++ .../server/dto/request/AppleLoginRequest.java | 13 ++ .../tune/server/filter/AppleLoginFilter.java | 167 ++++++++++++++++++ .../server/service/member/MemberService.java | 7 +- .../service/member/MemberServiceImpl.java | 38 ++++ src/main/resources/application-server.yml | 5 + src/main/resources/application.yml | 5 + 9 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/tune/server/dto/apple/AppleAuthTokenDto.java create mode 100644 src/main/java/com/tune/server/dto/request/AppleLoginRequest.java create mode 100644 src/main/java/com/tune/server/filter/AppleLoginFilter.java diff --git a/build.gradle b/build.gradle index acee808..c58a674 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.2' // JWT implementation 'io.jsonwebtoken:jjwt-impl:0.11.2' // JWT implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2' // JWT + compileOnly group: 'org.bouncycastle', name: 'bcpkix-jdk14', version: '1.72' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/src/main/java/com/tune/server/config/SecurityConfig.java b/src/main/java/com/tune/server/config/SecurityConfig.java index b25f556..b663944 100644 --- a/src/main/java/com/tune/server/config/SecurityConfig.java +++ b/src/main/java/com/tune/server/config/SecurityConfig.java @@ -1,6 +1,7 @@ package com.tune.server.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.tune.server.filter.AppleLoginFilter; import com.tune.server.filter.ExceptionHandlerFilter; import com.tune.server.filter.JwtAuthenticationFilter; import com.tune.server.filter.KakaoLoginFilter; @@ -47,6 +48,7 @@ protected DefaultSecurityFilterChain configure(HttpSecurity http) throws Excepti .antMatchers("/member/**").authenticated() .and() .addFilterBefore(new KakaoLoginFilter("/login/kakao", objectMapper, memberService), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new AppleLoginFilter("/login/apple", objectMapper, memberService), KakaoLoginFilter.class) .addFilterBefore(new ExceptionHandlerFilter(), KakaoLoginFilter.class) .addFilterAfter(new JwtAuthenticationFilter(objectMapper, memberService), KakaoLoginFilter.class) .build(); diff --git a/src/main/java/com/tune/server/dto/apple/AppleAuthTokenDto.java b/src/main/java/com/tune/server/dto/apple/AppleAuthTokenDto.java new file mode 100644 index 0000000..2a7f9d5 --- /dev/null +++ b/src/main/java/com/tune/server/dto/apple/AppleAuthTokenDto.java @@ -0,0 +1,19 @@ +package com.tune.server.dto.apple; + +import lombok.*; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString +public class AppleAuthTokenDto { + private String access_token; + private String token_type; + private String expires_in; + private String refresh_token; + private String id_token; + + @Setter + private String user_id; +} diff --git a/src/main/java/com/tune/server/dto/request/AppleLoginRequest.java b/src/main/java/com/tune/server/dto/request/AppleLoginRequest.java new file mode 100644 index 0000000..2c724c4 --- /dev/null +++ b/src/main/java/com/tune/server/dto/request/AppleLoginRequest.java @@ -0,0 +1,13 @@ +package com.tune.server.dto.request; + +import lombok.*; + +@Getter +@Builder +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class AppleLoginRequest { + private String authorizationCode; + private String user; +} diff --git a/src/main/java/com/tune/server/filter/AppleLoginFilter.java b/src/main/java/com/tune/server/filter/AppleLoginFilter.java new file mode 100644 index 0000000..6d982f7 --- /dev/null +++ b/src/main/java/com/tune/server/filter/AppleLoginFilter.java @@ -0,0 +1,167 @@ +package com.tune.server.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tune.server.domain.Member; +import com.tune.server.dto.apple.AppleAuthTokenDto; +import com.tune.server.dto.request.AppleLoginRequest; +import com.tune.server.dto.response.FullTokenResponse; +import com.tune.server.exceptions.login.TokenNotFoundException; +import com.tune.server.service.member.MemberService; +import com.tune.server.util.JwtUtil; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +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.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.security.PrivateKey; +import java.util.Collections; +import java.util.Date; + +public class AppleLoginFilter extends OncePerRequestFilter { + private final String SIGNUP_URI; + private final String APPLE_TOKEN_URI = "https://appleid.apple.com/auth/token"; + private final String APPLE_AUDIENCE_URI = "https://appleid.apple.com"; + private final ObjectMapper objectMapper; + private final MemberService memberService; + + @Value("${external.apple.clientId}") + private String clientId; + + @Value("${external.apple.teamId}") + private String teamId; + + @Value("${external.apple.keyId}") + private String keyId; + + @Value("${external.apple.privateKey}") + private String appleSignKey; + + public AppleLoginFilter(String signupUri, ObjectMapper objectMapper, MemberService memberService) { + this.SIGNUP_URI = signupUri; + this.objectMapper = objectMapper; + this.memberService = memberService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + if (request.getRequestURI().equals(SIGNUP_URI) && request.getMethod().equals("POST")) { + // 1. AppleLoginRequest 파싱 + AppleLoginRequest appleLoginRequest = getAppleLoginRequest(request); + + // 2. Apple JWT 생성 및 검증 + AppleAuthTokenDto appleAuthTokenDto = getAppleAuthTokenDto(appleLoginRequest); + appleAuthTokenDto.setUser_id(appleAuthTokenDto.getUser_id()); + + // 3. 회원정보로 로그인/회원가입 + if (!memberService.isExistMember(appleAuthTokenDto)) { + // 회원가입 + if (!memberService.signUp(appleAuthTokenDto)) { + throw new InternalError("회원가입에 실패했습니다."); + } + } + + // 4. JWT 토큰 발급 + Member member = memberService.getMember(appleAuthTokenDto); + FullTokenResponse fullTokenResponse = FullTokenResponse + .builder() + .access_token(JwtUtil.generateJwt(member)) + .refresh_token(member.getRefreshToken()) + .build(); + + // 5. 응답 + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(fullTokenResponse)); + return; + } + + filterChain.doFilter(request, response); + } + + private AppleAuthTokenDto getAppleAuthTokenDto(AppleLoginRequest appleLoginRequest) { + RestTemplate restTemplate = new RestTemplateBuilder().build(); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("code", appleLoginRequest.getAuthorizationCode()); + params.add("client_id", teamId); + params.add("client_secret", generateAppleSignKey()); + params.add("grant_type", "authorization_code"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + HttpEntity> httpEntity = new HttpEntity<>(params, headers); + + try { + ResponseEntity response = restTemplate.postForEntity(APPLE_TOKEN_URI, httpEntity, AppleAuthTokenDto.class); + return response.getBody(); + } catch (HttpClientErrorException e) { + throw new IllegalArgumentException("Apple Auth Token Error"); + } + } + + private String generateAppleSignKey() { + try { + PrivateKey privateKey = getPrivateKey(); + return Jwts.builder() + .setHeaderParam("kid", keyId) + .setHeaderParam("alg", "ES256") + .setIssuer(teamId) + .setIssuedAt(new Date()) + .setAudience(APPLE_AUDIENCE_URI) + .setExpiration(new Date(System.currentTimeMillis() + 86400000)) + .setSubject(clientId) + .signWith(privateKey, SignatureAlgorithm.ES256) + .compact(); + } catch (Exception e) { + throw new IllegalArgumentException("Apple Sign Key Error"); + } + } + + private AppleLoginRequest getAppleLoginRequest(HttpServletRequest request) { + try { + AppleLoginRequest appleLoginRequest = objectMapper.readValue(request.getInputStream(), AppleLoginRequest.class); + if (appleLoginRequest.getAuthorizationCode() == null) { + throw new TokenNotFoundException("Autorization Code is null"); + } + + return appleLoginRequest; + } catch (Exception e) { + throw new TokenNotFoundException("Apple Token Parse Error"); + } + } + + private PrivateKey getPrivateKey() throws IOException { + // PRIVATE_KEY 렌더링 + appleSignKey = appleSignKey.replace(' ', '\n'); + String SSH_SUFFIX = "\n-----END PRIVATE KEY-----"; + String SSH_PREFIX = "-----BEGIN PRIVATE KEY-----\n"; + appleSignKey = SSH_PREFIX + appleSignKey + SSH_SUFFIX; + + Reader pemReader = new StringReader(appleSignKey); + PEMParser pemParser = new PEMParser(pemReader); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject(); + return converter.getPrivateKey(object); + } + +} diff --git a/src/main/java/com/tune/server/service/member/MemberService.java b/src/main/java/com/tune/server/service/member/MemberService.java index 6736762..21c2eb3 100644 --- a/src/main/java/com/tune/server/service/member/MemberService.java +++ b/src/main/java/com/tune/server/service/member/MemberService.java @@ -2,6 +2,7 @@ import com.tune.server.domain.Member; import com.tune.server.dto.MemberAuthDto; +import com.tune.server.dto.apple.AppleAuthTokenDto; import com.tune.server.dto.kakao.KakaoUserInfo; import com.tune.server.dto.request.MemberAgreementRequest; import com.tune.server.dto.request.MemberNameRequest; @@ -15,11 +16,12 @@ @Service public interface MemberService { boolean isExistMember(KakaoUserInfo kakaoUserInfo); - + boolean isExistMember(AppleAuthTokenDto appleAuthTokenDto); boolean signUp(KakaoUserInfo kakaoUserInfo); + boolean signUp(AppleAuthTokenDto appleAuthTokenDto); Member getMember(KakaoUserInfo kakaoUserInfo); - + Member getMember(AppleAuthTokenDto appleAuthTokenDto); Map refresh(String refreshToken); Member updateAgreement(MemberAuthDto member, MemberAgreementRequest request); @@ -35,4 +37,5 @@ public interface MemberService { Member updateWorkspacePurpose(MemberAuthDto principal, MemberPurposeRequest request); MemberOnboardingResponse getOnboardingStatus(MemberAuthDto principal); + } diff --git a/src/main/java/com/tune/server/service/member/MemberServiceImpl.java b/src/main/java/com/tune/server/service/member/MemberServiceImpl.java index a326c8d..c0fc95b 100644 --- a/src/main/java/com/tune/server/service/member/MemberServiceImpl.java +++ b/src/main/java/com/tune/server/service/member/MemberServiceImpl.java @@ -2,6 +2,7 @@ import com.tune.server.domain.*; import com.tune.server.dto.MemberAuthDto; +import com.tune.server.dto.apple.AppleAuthTokenDto; import com.tune.server.dto.kakao.KakaoUserInfo; import com.tune.server.dto.request.MemberAgreementRequest; import com.tune.server.dto.request.MemberNameRequest; @@ -39,6 +40,11 @@ public boolean isExistMember(KakaoUserInfo id) { return memberProvider.isPresent(); } + @Override + public boolean isExistMember(AppleAuthTokenDto appleAuthTokenDto) { + return memberProviderRepository.findByProviderIdAndAndProvider(appleAuthTokenDto.getUser_id(), "APPLE").isPresent(); + } + @Override @Transactional public boolean signUp(KakaoUserInfo kakaoUserInfo) { @@ -65,12 +71,44 @@ public boolean signUp(KakaoUserInfo kakaoUserInfo) { } } + @Override + public boolean signUp(AppleAuthTokenDto appleAuthTokenDto) { + try { + Member member = Member + .builder() + .refreshToken(JwtUtil.generateRefreshToken()) + .refreshTokenExpiresAt(LocalDateTime.now().plusMonths(JwtUtil.REFRESH_TOKEN_EXPIRES_MONTH)) + .build(); + + MemberProvider memberProvider = MemberProvider.builder() + .providerId(appleAuthTokenDto.getUser_id()) + .refreshToken(appleAuthTokenDto.getRefresh_token()) + .member(member) + .provider("APPLE") + .build(); + + memberRepository.save(member); + memberProviderRepository.save(memberProvider); + return true; + } catch (Exception e) { + log.error("회원가입 실패", e); + return false; + } + } + @Override public Member getMember(KakaoUserInfo kakaoUserInfo) { Optional memberProvider = memberProviderRepository.findByProviderIdAndAndProvider(kakaoUserInfo.getId().toString(), "KAKAO"); return memberProvider.map(MemberProvider::getMember).orElseThrow(() -> new MemberNotFoundException("해당하는 회원이 없습니다.")); } + @Override + public Member getMember(AppleAuthTokenDto appleAuthTokenDto) { + return memberProviderRepository.findByProviderIdAndAndProvider(appleAuthTokenDto.getUser_id(), "APPLE") + .map(MemberProvider::getMember) + .orElseThrow(() -> new MemberNotFoundException("해당하는 회원이 없습니다.")); + } + @Override @Transactional public Map refresh(String refreshToken) { diff --git a/src/main/resources/application-server.yml b/src/main/resources/application-server.yml index 2a61d03..14fe78f 100644 --- a/src/main/resources/application-server.yml +++ b/src/main/resources/application-server.yml @@ -34,3 +34,8 @@ server: external: jwt: secret: ${JWT_SECRET} + apple: + client-id: ${APPLE_CLIENT_ID} + team-id: ${APPLE_TEAM_ID} + bundle-id: ${APPLE_TEAM_ID} + private-key: ${APPLE_PRIVATE_KEY} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2d3fcfb..eb3a806 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,3 +39,8 @@ server: external: jwt: secret: ${JWT_SECRET} + apple: + client-id: ${APPLE_CLIENT_ID} + team-id: ${APPLE_TEAM_ID} + bundle-id: ${APPLE_TEAM_ID} + private-key: ${APPLE_PRIVATE_KEY} \ No newline at end of file