diff --git a/src/main/java/com/onnoff/onnoff/apiPayload/code/status/SuccessStatus.java b/src/main/java/com/onnoff/onnoff/apiPayload/code/status/SuccessStatus.java index 2e768ee..82afe51 100644 --- a/src/main/java/com/onnoff/onnoff/apiPayload/code/status/SuccessStatus.java +++ b/src/main/java/com/onnoff/onnoff/apiPayload/code/status/SuccessStatus.java @@ -10,10 +10,8 @@ @AllArgsConstructor public enum SuccessStatus implements BaseCode { // 일반적인 응답 - _OK(HttpStatus.OK, "COMMON200", "성공입니다."), + _OK(HttpStatus.OK, "COMMON200", "성공입니다."); - // 로그인 응답 - NEED_USER_DETAIL(HttpStatus.OK, "LOGIN200", "토큰이 유효하고 유저의 추가정보가 필요합니다."); private final HttpStatus httpStatus; private final String code; private final String message; diff --git a/src/main/java/com/onnoff/onnoff/auth/controller/LoginController.java b/src/main/java/com/onnoff/onnoff/auth/controller/LoginController.java index 0d47f5a..897b47d 100644 --- a/src/main/java/com/onnoff/onnoff/auth/controller/LoginController.java +++ b/src/main/java/com/onnoff/onnoff/auth/controller/LoginController.java @@ -3,20 +3,19 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.onnoff.onnoff.apiPayload.ApiResponse; -import com.onnoff.onnoff.apiPayload.code.status.SuccessStatus; import com.onnoff.onnoff.auth.UserContext; -import com.onnoff.onnoff.auth.feignClient.dto.KakaoOauth2DTO; +import com.onnoff.onnoff.auth.dto.LoginRequestDTO; +import com.onnoff.onnoff.auth.feignClient.dto.TokenResponse; +import com.onnoff.onnoff.auth.feignClient.dto.kakao.KakaoOauth2DTO; import com.onnoff.onnoff.auth.jwt.dto.JwtToken; import com.onnoff.onnoff.auth.jwt.service.JwtUtil; -import com.onnoff.onnoff.auth.service.LoginService; +import com.onnoff.onnoff.auth.service.AppleLoginService; +import com.onnoff.onnoff.auth.service.KakaoLoginService; import com.onnoff.onnoff.domain.user.User; import com.onnoff.onnoff.domain.user.converter.UserConverter; import com.onnoff.onnoff.domain.user.dto.UserResponseDTO; import com.onnoff.onnoff.domain.user.service.UserService; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -29,11 +28,13 @@ @Controller @RequiredArgsConstructor public class LoginController { - private final LoginService loginService; + private final KakaoLoginService kakaoLoginService; + private final AppleLoginService appleLoginService; private final UserService userService; private final JwtUtil jwtUtil; + /* 테스트용 API, CORS 때문에 직접 호출하지 않고 redirect */ @@ -51,60 +52,71 @@ public String login(){ */ @GetMapping("/oauth2/login/kakao") public ResponseEntity getAccessToken(@RequestParam(name = "code") String code){ - String accessToken = loginService.getAccessToken(code); - return ResponseEntity.ok("http://localhost:8080/oauth2/kakao/token/validate?accessToken="+accessToken); + TokenResponse tokenResponse = kakaoLoginService.getAccessTokenByCode(code); + return ResponseEntity.ok("http://localhost:8080/oauth2/kakao/token/validate?accessToken="+ tokenResponse.getAccessToken()); } /* - 1. 토큰 유효성 검증 + 1. ID 토큰 유효성 검증 2. 사용자 정보 얻어오기 3. DB 조회 및 추가 4. 응답 헤더에 Jwt 토큰 추가 */ @Operation(summary = "토큰 검증 API",description = "토큰을 검증 하고 이에 대한 결과를 응답합니다. 추가 정보 입력 여부도 같이 응답 합니다.") - @ApiResponses(value = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "토큰 검증 성공," + " 추가 정보 기입이 필요합니다.", - content = @Content(schema = @Schema(implementation = UserResponseDTO.ApiResponseLoginDTO.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "토큰 검증 성공", - content = @Content(schema = @Schema(implementation = UserResponseDTO.ApiResponseUserDetailDTO.class))) - }) @ResponseBody @PostMapping("/oauth2/kakao/token/validate") - public ApiResponse validateToken(HttpServletResponse response, @RequestBody String accessToken) { - // 토큰 검증 - loginService.validate(accessToken); + public ApiResponse validateKakoToken(HttpServletResponse response, @RequestBody LoginRequestDTO.KakaoTokenValidateDTO requestDTO) { + // identity 토큰 검증 + kakaoLoginService.validate(requestDTO.getIdentityToken()); // ok -> 유저 정보 가져오기 - KakaoOauth2DTO.UserInfoResponseDTO userInfoResponseDTO = null; + KakaoOauth2DTO.UserInfoResponseDTO userInfo; try { - userInfoResponseDTO = loginService.getUserInfo(accessToken); + userInfo = kakaoLoginService.getUserInfo(requestDTO.getAccessToken()); } catch (JsonProcessingException e) { e.printStackTrace(); throw new RuntimeException(e); } // 유저 정보에 DB 조회하고 정보 있으면 응답만, 없으면 저장까지, 추가정보 입력 여부에 따라서 응답 다르게 - Long oauthId = userInfoResponseDTO.getId(); + String oauthId = userInfo.getSub(); + User user; if( userService.isExistByOauthId(oauthId)){ - User user = userService.getUserByOauthId(oauthId); - // 응답헤더에 토큰 추가 - JwtToken token = jwtUtil.generateToken(String.valueOf(user.getId())); - response.addHeader("Access-Token", token.getAccessToken()); - response.addHeader("Refresh-Token", token.getRefreshToken()); - if(user.isInfoSet()){ - return ApiResponse.onSuccess(UserConverter.toUserDetailDTO(user)); - } - return ApiResponse.of(SuccessStatus.NEED_USER_DETAIL, UserConverter.toLoginDTO(user)); + user = userService.getUserByOauthId(oauthId); } else{ - User user = UserConverter.toUser(userInfoResponseDTO); - Long id = userService.create(user); - // 응답헤더에 토큰 추가 - JwtToken token = jwtUtil.generateToken(String.valueOf(id)); - response.addHeader("Access-Token", token.getAccessToken()); - response.addHeader("Refresh-Token", token.getRefreshToken()); - return ApiResponse.of(SuccessStatus.NEED_USER_DETAIL, UserConverter.toLoginDTO(user)); + user = UserConverter.toUser(userInfo); + user = userService.create(user); } + // 응답헤더에 토큰 추가 + JwtToken token = jwtUtil.generateToken(String.valueOf(user.getId())); + response.addHeader("Access-Token", token.getAccessToken()); + response.addHeader("Refresh-Token", token.getRefreshToken()); + return ApiResponse.onSuccess(UserConverter.toLoginDTO(user)); } + @ResponseBody + @PostMapping("/oauth2/apple/token/validate") + public ApiResponse validateAppleToken(HttpServletResponse response, @RequestBody LoginRequestDTO.AppleTokenValidateDTO requestDTO) { + // 검증하기 + appleLoginService.validate(requestDTO.getIdentityToken()); + // 검증 성공 시 리프레시 토큰 발급받아 저장(기한 무제한, 회원탈퇴 시 필요) + TokenResponse tokenResponse = appleLoginService.getAccessTokenByCode(requestDTO.getAuthorizationCode()); + // 유저 정보 조회 및 저장 + String oauthId = requestDTO.getOauthId(); + User user; + if( userService.isExistByOauthId(oauthId)){ + user = userService.getUserByOauthId(oauthId); + } + else{ + user = UserConverter.toUser(requestDTO); + user.setAppleRefreshToken(tokenResponse.getRefreshToken()); + user = userService.create(user); + } + // 응답헤더에 토큰 추가 + JwtToken token = jwtUtil.generateToken(String.valueOf(user.getId())); + response.addHeader("Access-Token", token.getAccessToken()); + response.addHeader("Refresh-Token", token.getRefreshToken()); + return ApiResponse.onSuccess(UserConverter.toLoginDTO(user)); + } /* 테스트용 API */ diff --git a/src/main/java/com/onnoff/onnoff/auth/dto/LoginRequestDTO.java b/src/main/java/com/onnoff/onnoff/auth/dto/LoginRequestDTO.java new file mode 100644 index 0000000..347bdf3 --- /dev/null +++ b/src/main/java/com/onnoff/onnoff/auth/dto/LoginRequestDTO.java @@ -0,0 +1,26 @@ +package com.onnoff.onnoff.auth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +public class LoginRequestDTO { + @Getter + public static class AppleTokenValidateDTO{ + @JsonProperty("user") + private String oauthId; + @JsonProperty("full_name") + private String fullName; + private String email; + @JsonProperty("identity_token") + private String identityToken; + @JsonProperty("authorization_code") + private String authorizationCode; + } + @Getter + public static class KakaoTokenValidateDTO{ + @JsonProperty("identity_token") + private String identityToken; + @JsonProperty("access_token") + private String accessToken; + } +} diff --git a/src/main/java/com/onnoff/onnoff/auth/feignClient/CustomErrorDecoder.java b/src/main/java/com/onnoff/onnoff/auth/feignClient/CustomErrorDecoder.java index 0c6f43e..dfefe80 100644 --- a/src/main/java/com/onnoff/onnoff/auth/feignClient/CustomErrorDecoder.java +++ b/src/main/java/com/onnoff/onnoff/auth/feignClient/CustomErrorDecoder.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.onnoff.onnoff.apiPayload.code.status.ErrorStatus; import com.onnoff.onnoff.apiPayload.exception.GeneralException; -import com.onnoff.onnoff.auth.feignClient.dto.ErrorResponseDTO; +import com.onnoff.onnoff.auth.feignClient.dto.kakao.ErrorResponseDTO; import feign.Response; import feign.codec.ErrorDecoder; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/onnoff/onnoff/auth/feignClient/client/AppleAuthClient.java b/src/main/java/com/onnoff/onnoff/auth/feignClient/client/AppleAuthClient.java new file mode 100644 index 0000000..58d30c3 --- /dev/null +++ b/src/main/java/com/onnoff/onnoff/auth/feignClient/client/AppleAuthClient.java @@ -0,0 +1,21 @@ +package com.onnoff.onnoff.auth.feignClient.client; + + +import com.onnoff.onnoff.auth.feignClient.dto.JwkResponse; +import com.onnoff.onnoff.auth.feignClient.dto.TokenResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient(name = "apple-auth-client",url = "https://appleid.apple.com/auth") +public interface AppleAuthClient{ + @GetMapping("/keys") + JwkResponse.JwkSet getKeys(); + + @GetMapping(value = "/token", consumes = "application/x-www-form-urlencoded") + TokenResponse getToken(MultiValueMap requestBody); + + //회원 탈퇴 메서드 +// @GetMapping("/revoke") +// KakaoOauth2DTO.TokenValidateResponseDTO getTokenValidate(@RequestHeader("Authorization") String accessToken); +} diff --git a/src/main/java/com/onnoff/onnoff/auth/feignClient/client/KakaoApiClient.java b/src/main/java/com/onnoff/onnoff/auth/feignClient/client/KakaoApiClient.java index f837d51..7a5a304 100644 --- a/src/main/java/com/onnoff/onnoff/auth/feignClient/client/KakaoApiClient.java +++ b/src/main/java/com/onnoff/onnoff/auth/feignClient/client/KakaoApiClient.java @@ -1,20 +1,19 @@ package com.onnoff.onnoff.auth.feignClient.client; import com.onnoff.onnoff.auth.feignClient.config.FeignConfig; -import com.onnoff.onnoff.auth.feignClient.dto.KakaoOauth2DTO; -import feign.Headers; +import com.onnoff.onnoff.auth.feignClient.dto.kakao.KakaoOauth2DTO; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.*; /* - 토큰 유효성 검증하고 사용자 정보 가져오는 client + 토큰 유효성 검증 하고 사용자 정보 가져오는 client */ @FeignClient(name = "kakao-api-client", url = "https://kapi.kakao.com", configuration = FeignConfig.class) public interface KakaoApiClient { @GetMapping("v1/user/access_token_info") KakaoOauth2DTO.TokenValidateResponseDTO getTokenValidate(@RequestHeader("Authorization") String accessToken); - @GetMapping(value = "/v2/user/me") - KakaoOauth2DTO.UserInfoResponseDTO getUserInfo(@RequestHeader("Authorization") String accessToken, @RequestParam(name = "property_keys") String propertyKeys); + @GetMapping(value = "/v1/oidc/userinfo") + KakaoOauth2DTO.UserInfoResponseDTO getUserInfo(@RequestHeader("Authorization") String accessToken); } diff --git a/src/main/java/com/onnoff/onnoff/auth/feignClient/client/KakaoOauth2Client.java b/src/main/java/com/onnoff/onnoff/auth/feignClient/client/KakaoOauth2Client.java index 6eed05e..577a305 100644 --- a/src/main/java/com/onnoff/onnoff/auth/feignClient/client/KakaoOauth2Client.java +++ b/src/main/java/com/onnoff/onnoff/auth/feignClient/client/KakaoOauth2Client.java @@ -2,7 +2,9 @@ import com.onnoff.onnoff.auth.feignClient.config.FeignConfig; -import com.onnoff.onnoff.auth.feignClient.dto.KakaoOauth2DTO; +import com.onnoff.onnoff.auth.feignClient.dto.JwkResponse; +import com.onnoff.onnoff.auth.feignClient.dto.TokenResponse; +import com.onnoff.onnoff.auth.feignClient.dto.kakao.KakaoOauth2DTO; import feign.Headers; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.*; @@ -11,10 +13,11 @@ public interface KakaoOauth2Client { @Headers("Content-Type: application/x-www-form-urlencoded") @PostMapping("/oauth/token") - KakaoOauth2DTO.TokenResponseDTO getAccessToken(@RequestParam(name = "grant_type") String grantType, - @RequestParam(name = "client_id") String clientId, - @RequestParam(name = "redirect_uri") String redirectUri, - @RequestParam(name = "code") String code + TokenResponse getAccessToken(@RequestParam(name = "grant_type") String grantType, + @RequestParam(name = "client_id") String clientId, + @RequestParam(name = "redirect_uri") String redirectUri, + @RequestParam(name = "code") String code ); - + @GetMapping("/.well-known/jwks.json") + JwkResponse.JwkSet getKeys(); } diff --git a/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/JwkResponse.java b/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/JwkResponse.java new file mode 100644 index 0000000..f06353c --- /dev/null +++ b/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/JwkResponse.java @@ -0,0 +1,30 @@ +package com.onnoff.onnoff.auth.feignClient.dto; + + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +public class JwkResponse { + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class Jwk { + private String alg; + private String e; + private String kid; + private String kty; + private String n; + private String use; + } + @Getter + public static class JwkSet{ + @JsonProperty("keys") + private List jwkList; + } +} diff --git a/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/KakaoOauth2DTO.java b/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/KakaoOauth2DTO.java deleted file mode 100644 index e9214a7..0000000 --- a/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/KakaoOauth2DTO.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.onnoff.onnoff.auth.feignClient.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.onnoff.onnoff.domain.user.enums.SocialType; -import lombok.Getter; -import lombok.ToString; - -import java.time.LocalDateTime; - -public class KakaoOauth2DTO { - - @Getter - public static class TokenResponseDTO{ - @JsonProperty("token_type") - private String tokenType; - @JsonProperty("access_token") - private String accessToken; - } - - @Getter - public static class TokenValidateResponseDTO{ - private Long id; - @JsonProperty("expires_in") - private Integer expiresIn; - @JsonProperty("app_id") - private Integer appId; - } - - @Getter - @ToString - public static class UserInfoResponseDTO{ - private Long id; - @JsonProperty("connected_at") - private LocalDateTime connectedAt; - @JsonProperty("kakao_account") - private KakaoAccountDTO kakaoAccount; - private SocialType socialType; - } - @Getter - @ToString - public static class KakaoAccountDTO{ - // 이메일 동의항목 -// private boolean has_email; -// private boolean email_needs_agreement; -// private boolean is_email_valid; -// private boolean is_email_verified; - private String email; - - // 이름 동의 항목 -// private boolean name_needs_agreement; - private String name; - - - } -} diff --git a/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/TokenResponse.java b/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/TokenResponse.java new file mode 100644 index 0000000..cdb336f --- /dev/null +++ b/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/TokenResponse.java @@ -0,0 +1,18 @@ +package com.onnoff.onnoff.auth.feignClient.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class TokenResponse { + @JsonProperty("access_token") + private String accessToken; + @JsonProperty("expires_in") + private Integer expiresIn; + @JsonProperty("id_token") + private String idToken; + @JsonProperty("refresh_token") + private String refreshToken; + @JsonProperty("token_type") + private String tokenType; +} diff --git a/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/apple/TokenRequest.java b/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/apple/TokenRequest.java new file mode 100644 index 0000000..3acbec7 --- /dev/null +++ b/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/apple/TokenRequest.java @@ -0,0 +1,32 @@ +package com.onnoff.onnoff.auth.feignClient.dto.apple; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +@Builder +@AllArgsConstructor +public class TokenRequest { + @JsonProperty("client_id") + private String clientId; + @JsonProperty("client_secret") + private String clientSecret; + private String code; + @JsonProperty("grant_type") + private String grantType; + @JsonProperty("redirect_uri") + private String redirectUri; + @JsonProperty("refresh_token") + private String refreshToken; + public MultiValueMap toUrlEncoded(){ + LinkedMultiValueMap urlEncoded = new LinkedMultiValueMap<>(); + urlEncoded.add("client_id", clientId); + urlEncoded.add("client_secret", clientSecret); + urlEncoded.add("code", code); + urlEncoded.add("grant_type", grantType); + urlEncoded.add("redirect_uri", redirectUri); + return urlEncoded; + } +} diff --git a/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/ErrorResponseDTO.java b/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/kakao/ErrorResponseDTO.java similarity index 67% rename from src/main/java/com/onnoff/onnoff/auth/feignClient/dto/ErrorResponseDTO.java rename to src/main/java/com/onnoff/onnoff/auth/feignClient/dto/kakao/ErrorResponseDTO.java index 562134d..7d8acea 100644 --- a/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/ErrorResponseDTO.java +++ b/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/kakao/ErrorResponseDTO.java @@ -1,4 +1,4 @@ -package com.onnoff.onnoff.auth.feignClient.dto; +package com.onnoff.onnoff.auth.feignClient.dto.kakao; import lombok.Getter; diff --git a/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/kakao/KakaoOauth2DTO.java b/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/kakao/KakaoOauth2DTO.java new file mode 100644 index 0000000..20186ce --- /dev/null +++ b/src/main/java/com/onnoff/onnoff/auth/feignClient/dto/kakao/KakaoOauth2DTO.java @@ -0,0 +1,26 @@ +package com.onnoff.onnoff.auth.feignClient.dto.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.ToString; + + +public class KakaoOauth2DTO { + + @Getter + public static class TokenValidateResponseDTO{ + private Long id; + @JsonProperty("expires_in") + private Integer expiresIn; + @JsonProperty("app_id") + private Integer appId; + } + + @Getter + @ToString + public static class UserInfoResponseDTO{ + private String sub; + private String name; + private String email; + } +} diff --git a/src/main/java/com/onnoff/onnoff/auth/service/AppleLoginService.java b/src/main/java/com/onnoff/onnoff/auth/service/AppleLoginService.java new file mode 100644 index 0000000..045358c --- /dev/null +++ b/src/main/java/com/onnoff/onnoff/auth/service/AppleLoginService.java @@ -0,0 +1,119 @@ +package com.onnoff.onnoff.auth.service; + + +import com.onnoff.onnoff.auth.UserContext; +import com.onnoff.onnoff.auth.feignClient.client.AppleAuthClient; +import com.onnoff.onnoff.auth.feignClient.dto.apple.TokenRequest; +import com.onnoff.onnoff.auth.feignClient.dto.TokenResponse; +import com.onnoff.onnoff.auth.service.tokenValidator.SocialTokenValidator; +import com.onnoff.onnoff.domain.user.User; +import com.onnoff.onnoff.domain.user.enums.SocialType; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +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.core.io.ClassPathResource; +import org.springframework.stereotype.Service; +import org.springframework.util.MultiValueMap; + +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.PrivateKey; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AppleLoginService implements LoginService{ + private final AppleAuthClient appleAuthClient; + private final SocialTokenValidator validator; + @Value("${apple.key.id}") + private String kid; + @Value("${apple.key.path}") + private String keyPath; + @Value("${apple.client-id}") + private String clientId; + @Value("${apple.iss}") + private String iss; + @Value("${apple.team-id}") + private String teamId; + @Value("${apple.redirect-uri}") + private String redirectUri; + @Override + public TokenResponse getAccessTokenByCode(String code) { + // client secret 만들기 + String clientSecret = createClientSecret(); + // 요청 + MultiValueMap urlEncoded = TokenRequest.builder() + .clientId(clientId) + .clientSecret(clientSecret) + .code("authorization_code_value") + .grantType("authorization_code") + .redirectUri(redirectUri) + .build().toUrlEncoded(); + return appleAuthClient.getToken(urlEncoded); + } + private String createClientSecret() { + Date expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant()); + Map jwtHeader = new HashMap<>(); + jwtHeader.put("kid", kid); + jwtHeader.put("alg", "ES256"); + + try { + return Jwts.builder() + .setHeaderParams(jwtHeader) + .setIssuer(teamId) // 토큰 발행자 = 우리 팀 + .setIssuedAt(new Date(System.currentTimeMillis())) // 발행 시간 - UNIX 시간 + .setExpiration(expirationDate) // 만료 시간 + .setAudience(iss) // 애플이 수신자 + .setSubject(clientId) // 토큰의 주체 = 우리 앱 + .signWith(SignatureAlgorithm.ES256, getPrivateKey()) + .compact(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + public String getAccessTokenByRfToken(String code) { + // client secret 만들기 + String clientSecret = createClientSecret(); + // refreshToken 가져오기 + User user = UserContext.getUser(); + String appleRefreshToken = user.getAppleRefreshToken(); + // 요청 + MultiValueMap urlEncoded = TokenRequest.builder() + .clientId(clientId) + .clientSecret(clientSecret) + .refreshToken(appleRefreshToken) + .grantType("refresh_token") + .redirectUri(redirectUri) + .build().toUrlEncoded(); + TokenResponse response = appleAuthClient.getToken(urlEncoded); + return null; + } + private PrivateKey getPrivateKey() throws IOException { + ClassPathResource resource = new ClassPathResource(keyPath); + 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); + } + + + @Override + public void validate(String identityToken){ + String cleanedIdentityToken = cleanToken(identityToken); + validator.validate(cleanedIdentityToken, SocialType.APPLE); + } +} diff --git a/src/main/java/com/onnoff/onnoff/auth/service/KakaoLoginService.java b/src/main/java/com/onnoff/onnoff/auth/service/KakaoLoginService.java new file mode 100644 index 0000000..7d3fa77 --- /dev/null +++ b/src/main/java/com/onnoff/onnoff/auth/service/KakaoLoginService.java @@ -0,0 +1,53 @@ +package com.onnoff.onnoff.auth.service; + + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.onnoff.onnoff.auth.feignClient.client.KakaoApiClient; +import com.onnoff.onnoff.auth.feignClient.client.KakaoOauth2Client; +import com.onnoff.onnoff.auth.feignClient.dto.TokenResponse; +import com.onnoff.onnoff.auth.feignClient.dto.kakao.KakaoOauth2DTO; +import com.onnoff.onnoff.auth.service.tokenValidator.SocialTokenValidator; +import com.onnoff.onnoff.domain.user.enums.SocialType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + + +@Service +@RequiredArgsConstructor +@Slf4j +public class KakaoLoginService implements LoginService{ + private final KakaoOauth2Client kakaoOauth2Client; + private final KakaoApiClient kakaoApiClient; + private final SocialTokenValidator validator; + @Value("${kakao.client-id}") + private String clientId; + @Value("${kakao.redirect-uri}") + private String redirectUri; + /* + 테스트 용으로 만든거, 실제로는 프론트에서 처리해서 액세스 토큰만 가져다 줌 + */ + @Override + public TokenResponse getAccessTokenByCode(String code){ + return kakaoOauth2Client.getAccessToken("authorization_code", + clientId, + redirectUri, + code); + } + // id 토큰 유효성 검증 + @Override + public void validate(String idToken){ + String cleanedAccessToken = cleanToken(idToken); + validator.validate(cleanedAccessToken, SocialType.KAKAO); + } + /* + 토큰으로 유저정보를 가져오는 메서드 + */ + public KakaoOauth2DTO.UserInfoResponseDTO getUserInfo(String accessToken) throws JsonProcessingException { + String cleanedAccessToken = cleanToken(accessToken); + accessToken = "bearer " + cleanedAccessToken; + KakaoOauth2DTO.UserInfoResponseDTO userInfo = kakaoApiClient.getUserInfo(accessToken); + return userInfo; + } +} diff --git a/src/main/java/com/onnoff/onnoff/auth/service/LoginService.java b/src/main/java/com/onnoff/onnoff/auth/service/LoginService.java index 6edfc1a..4a5691c 100644 --- a/src/main/java/com/onnoff/onnoff/auth/service/LoginService.java +++ b/src/main/java/com/onnoff/onnoff/auth/service/LoginService.java @@ -1,63 +1,13 @@ package com.onnoff.onnoff.auth.service; +import com.onnoff.onnoff.auth.feignClient.dto.TokenResponse; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.onnoff.onnoff.auth.feignClient.client.KakaoApiClient; -import com.onnoff.onnoff.auth.feignClient.client.KakaoOauth2Client; -import com.onnoff.onnoff.auth.feignClient.dto.KakaoOauth2DTO; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Arrays; - -@Service -@RequiredArgsConstructor -@Slf4j -public class LoginService { - private final KakaoOauth2Client kakaoOauth2Client; - private final KakaoApiClient kakaoApiClient; - - /* - 테스트 용으로 만든거, 실제로는 프론트에서 처리해서 액세스 토큰만 가져다 줌 - */ - public String getAccessToken(String code){ - KakaoOauth2DTO.TokenResponseDTO tokenResponseDTO = kakaoOauth2Client.getAccessToken("authorization_code", - "32c0787d1b1e9fcabcc24af247903ba8", - "http://localhost:8080/oauth2/login/kakao", - code); - return tokenResponseDTO.getAccessToken(); - } - /* - 토큰 유효성 검증, 유효하지 않으면 예외를 발생시키도록 처리, 예외는 CustomErrorDecoder에서 처리 - */ - public void validate(String accessToken){ - String cleanedAccessToken = cleanAccessToken(accessToken); - kakaoApiClient.getTokenValidate(cleanedAccessToken); - } - - private String cleanAccessToken(String accessToken){ - accessToken = accessToken.replaceAll("[\u0000-\u001F\u007F-\u00FF:]", ""); // 헤더에 있으면 안되는 값 대체 - accessToken = accessToken.trim(); // 앞뒤 공백 제거 - accessToken = "Bearer " + accessToken; // 토큰 기반 인증 형식 - return accessToken; - } - /* - 토큰으로 유저정보를 가져오는 메서드 - */ - public KakaoOauth2DTO.UserInfoResponseDTO getUserInfo(String accessToken) throws JsonProcessingException { - String cleanedAccessToken = cleanAccessToken(accessToken); - String emailProperty = "kakao_account.email"; - String nameProperty = "kakao_account.name"; - List propertyKeysList = Arrays.asList(emailProperty, nameProperty); - - // List -> JSON 형식으로 바꾸어 전달 - ObjectMapper objectMapper = new ObjectMapper(); - String propertyKeys = objectMapper.writeValueAsString(propertyKeysList); - KakaoOauth2DTO.UserInfoResponseDTO userInfoResponseDTO = kakaoApiClient.getUserInfo(cleanedAccessToken, propertyKeys); - return userInfoResponseDTO; +public interface LoginService { + default String cleanToken(String token){ + token = token.replaceAll("[\u0000-\u001F\u007F-\u00FF:]", ""); // 헤더에 있으면 안되는 값 대체 + token = token.trim(); // 앞뒤 공백 제거 + return token; } + public TokenResponse getAccessTokenByCode(String code); + public void validate(String accessToken); } diff --git a/src/main/java/com/onnoff/onnoff/auth/service/tokenValidator/SocialTokenValidator.java b/src/main/java/com/onnoff/onnoff/auth/service/tokenValidator/SocialTokenValidator.java new file mode 100644 index 0000000..1aef7e8 --- /dev/null +++ b/src/main/java/com/onnoff/onnoff/auth/service/tokenValidator/SocialTokenValidator.java @@ -0,0 +1,10 @@ +package com.onnoff.onnoff.auth.service.tokenValidator; + +import com.onnoff.onnoff.domain.user.enums.SocialType; + +/** + * 토큰 검증을 처리하는 역할 + */ +public interface SocialTokenValidator { + void validate(String token, SocialType socialType); +} diff --git a/src/main/java/com/onnoff/onnoff/auth/service/tokenValidator/SocialTokenValidatorImpl.java b/src/main/java/com/onnoff/onnoff/auth/service/tokenValidator/SocialTokenValidatorImpl.java new file mode 100644 index 0000000..08ae6da --- /dev/null +++ b/src/main/java/com/onnoff/onnoff/auth/service/tokenValidator/SocialTokenValidatorImpl.java @@ -0,0 +1,118 @@ +package com.onnoff.onnoff.auth.service.tokenValidator; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.onnoff.onnoff.apiPayload.code.status.ErrorStatus; +import com.onnoff.onnoff.apiPayload.exception.GeneralException; +import com.onnoff.onnoff.auth.feignClient.client.AppleAuthClient; +import com.onnoff.onnoff.auth.feignClient.client.KakaoOauth2Client; +import com.onnoff.onnoff.auth.feignClient.dto.JwkResponse; +import com.onnoff.onnoff.domain.user.enums.SocialType; +import io.jsonwebtoken.*; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class SocialTokenValidatorImpl implements SocialTokenValidator{ + private final KakaoOauth2Client kakaoOauth2Client; + private final AppleAuthClient appleAuthClient; + @Value("${kakao.iss}") + private String kakaoIss; + @Value("${kakao.client-id}") + private String kakaoAud; + @Value("${apple.iss}") + private String appleIss; + @Value("${apple.client-id}") + private String appleAud; + + /** + * 1. 공개키(JWK) 목록 조회하여 맞는 공개키 정보 획득 + * 2. 검증에 사용할 수 있는 공개키로 변환 + * 3. 공개키로 ID토큰 검증 + */ + @Override + public void validate(String token, SocialType socialType) { + JwkResponse.Jwk matchingJwk = getMatchingJwk(token, socialType); + PublicKey publicKey = jwkToPublickey(matchingJwk); + if( socialType.equals(SocialType.KAKAO)){ + verifyToken(token, kakaoIss, kakaoAud, publicKey); + } + else { + verifyToken(token, appleIss, appleAud, publicKey); + } + } + + // 발급자, 수신자, 만료 기한, 시그니처를 검증 + private Jwt verifyToken(String token, String iss, String aud, PublicKey publicKey) { + try { + return (Jwt) Jwts.parser() + .requireAudience(aud) //수신자 검증 + .requireIssuer(iss) // 발급자 검증 + .verifyWith(publicKey) // 시그니처 검증 + .build() + .parse(token); + } + catch (MalformedJwtException | UnsupportedJwtException parseEx){ + throw new GeneralException(ErrorStatus.INVALID_ARGUMENT_ERROR); + } + catch (InvalidClaimException | ExpiredJwtException validateEx){ + throw new GeneralException(ErrorStatus.INVALID_TOKEN_ERROR); + } + } + private JwkResponse.Jwk getMatchingJwk(String token, SocialType socialType){ + // header 부분만 디코드 해서 kid 가져오기 + String keyId = getKeyId(token); + JwkResponse.JwkSet keys; + if( socialType.equals(SocialType.KAKAO)) { + keys = kakaoOauth2Client.getKeys(); + } + else{ + keys = appleAuthClient.getKeys(); + } + return keys.getJwkList().stream() + .filter(jwk -> jwk.getKid().equals(keyId)) + .findFirst() + .orElseThrow(); + + } + private String getKeyId(String token){ + Base64.Decoder decoder = Base64.getUrlDecoder(); + String[] splitToken = token.split("\\."); + String header = splitToken[0]; + String headerJson = new String(decoder.decode(header)); + + ObjectMapper mapper = new ObjectMapper(); + Map headerMap = null; + try { + headerMap = mapper.readValue(headerJson, Map.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + return headerMap.get("kid"); + } + private PublicKey jwkToPublickey(JwkResponse.Jwk jwk){ + Base64.Decoder decoder = Base64.getUrlDecoder(); + byte[] decodeN = decoder.decode(jwk.getN()); + byte[] decodeE = decoder.decode(jwk.getE()); + BigInteger n = new BigInteger(1, decodeN); + BigInteger e = new BigInteger(1, decodeE); + RSAPublicKeySpec keySpec = new RSAPublicKeySpec(n, e); + try { + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePublic(keySpec); + } catch (NoSuchAlgorithmException | InvalidKeySpecException ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/src/main/java/com/onnoff/onnoff/domain/user/User.java b/src/main/java/com/onnoff/onnoff/domain/user/User.java index 7dc6203..3272116 100644 --- a/src/main/java/com/onnoff/onnoff/domain/user/User.java +++ b/src/main/java/com/onnoff/onnoff/domain/user/User.java @@ -31,7 +31,7 @@ public class User extends BaseEntity { private Long id; @Column(nullable = false) - private Long oauthId; //토큰으로 얻어온 정보로 유저를 조회할 때 사용 + private String oauthId; //토큰으로 얻어온 정보로 유저를 조회할 때 사용 @Column(columnDefinition = "TINYINT(1) DEFAULT 0") private boolean infoSet; //추가 정보 기입 여부 @@ -67,6 +67,8 @@ public class User extends BaseEntity { private String fcmToken; + private String appleRefreshToken; + @Enumerated(EnumType.STRING) private SocialType socialType; @@ -84,4 +86,8 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) private List worklogList = new ArrayList<>(); + + public void setAppleRefreshToken(String appleRefreshToken) { + this.appleRefreshToken = appleRefreshToken; + } } diff --git a/src/main/java/com/onnoff/onnoff/domain/user/converter/UserConverter.java b/src/main/java/com/onnoff/onnoff/domain/user/converter/UserConverter.java index 64b850d..327ebc2 100644 --- a/src/main/java/com/onnoff/onnoff/domain/user/converter/UserConverter.java +++ b/src/main/java/com/onnoff/onnoff/domain/user/converter/UserConverter.java @@ -1,21 +1,28 @@ package com.onnoff.onnoff.domain.user.converter; -import com.onnoff.onnoff.auth.feignClient.dto.KakaoOauth2DTO; +import com.onnoff.onnoff.auth.dto.LoginRequestDTO; +import com.onnoff.onnoff.auth.feignClient.dto.kakao.KakaoOauth2DTO; import com.onnoff.onnoff.domain.user.User; import com.onnoff.onnoff.domain.user.dto.UserResponseDTO; import com.onnoff.onnoff.domain.user.enums.SocialType; public class UserConverter { public static User toUser(KakaoOauth2DTO.UserInfoResponseDTO response){ - KakaoOauth2DTO.KakaoAccountDTO kakaoAccount = response.getKakaoAccount(); return User.builder() - .oauthId(response.getId()) - .email(kakaoAccount.getEmail()) - .name(kakaoAccount.getName()) + .oauthId(response.getSub()) + .email(response.getEmail()) + .name(response.getName()) .socialType(SocialType.KAKAO) .build(); } - + public static User toUser(LoginRequestDTO.AppleTokenValidateDTO request){ + return User.builder() + .oauthId(request.getOauthId()) + .email(request.getEmail()) + .name(request.getFullName()) + .socialType(SocialType.APPLE) + .build(); + } public static UserResponseDTO.LoginDTO toLoginDTO(User user){ return UserResponseDTO.LoginDTO.builder() .id(user.getId()) diff --git a/src/main/java/com/onnoff/onnoff/domain/user/repository/UserRepository.java b/src/main/java/com/onnoff/onnoff/domain/user/repository/UserRepository.java index 09246a5..36eb01e 100644 --- a/src/main/java/com/onnoff/onnoff/domain/user/repository/UserRepository.java +++ b/src/main/java/com/onnoff/onnoff/domain/user/repository/UserRepository.java @@ -6,5 +6,5 @@ import java.util.Optional; public interface UserRepository extends JpaRepository { - Optional findByOauthId(Long oauthId); + Optional findByOauthId(String oauthId); } diff --git a/src/main/java/com/onnoff/onnoff/domain/user/service/UserService.java b/src/main/java/com/onnoff/onnoff/domain/user/service/UserService.java index d970991..b0cf32a 100644 --- a/src/main/java/com/onnoff/onnoff/domain/user/service/UserService.java +++ b/src/main/java/com/onnoff/onnoff/domain/user/service/UserService.java @@ -5,13 +5,13 @@ import java.util.List; public interface UserService { - public Long create(User user); + public User create(User user); public List getUserList(); public User getUser(Long id); - public boolean isExistByOauthId(Long oauthId); + public boolean isExistByOauthId(String oauthId); - public User getUserByOauthId(Long oauthId); + public User getUserByOauthId(String oauthId); } diff --git a/src/main/java/com/onnoff/onnoff/domain/user/service/UserServiceImpl.java b/src/main/java/com/onnoff/onnoff/domain/user/service/UserServiceImpl.java index 74be39a..76ab25f 100644 --- a/src/main/java/com/onnoff/onnoff/domain/user/service/UserServiceImpl.java +++ b/src/main/java/com/onnoff/onnoff/domain/user/service/UserServiceImpl.java @@ -16,8 +16,8 @@ public class UserServiceImpl implements UserService{ private final UserRepository userRepository; @Transactional @Override - public Long create(User user) { - return userRepository.save(user).getId(); + public User create(User user) { + return userRepository.save(user); } @Transactional(readOnly = true) @@ -36,13 +36,13 @@ public User getUser(Long id) { } @Transactional(readOnly = true) @Override - public boolean isExistByOauthId(Long oauthId) { - return userRepository.findById(oauthId).isPresent(); + public boolean isExistByOauthId(String oauthId) { + return userRepository.findByOauthId(oauthId).isPresent(); } @Transactional(readOnly = true) @Override - public User getUserByOauthId(Long oauthId) { + public User getUserByOauthId(String oauthId) { User user = userRepository.findByOauthId(oauthId).orElseThrow( () -> new GeneralException(ErrorStatus.USER_NOT_FOUND) ); diff --git a/src/test/java/com/onnoff/onnoff/domain/user/service/UserServiceImplTest.java b/src/test/java/com/onnoff/onnoff/domain/user/service/UserServiceImplTest.java index dadbfa8..76268a5 100644 --- a/src/test/java/com/onnoff/onnoff/domain/user/service/UserServiceImplTest.java +++ b/src/test/java/com/onnoff/onnoff/domain/user/service/UserServiceImplTest.java @@ -1,17 +1,12 @@ package com.onnoff.onnoff.domain.user.service; import com.onnoff.onnoff.domain.user.User; -import com.onnoff.onnoff.domain.user.repository.UserRepository; import jakarta.transaction.Transactional; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import static org.junit.jupiter.api.Assertions.*; @SpringBootTest @@ -23,9 +18,9 @@ class UserServiceImplTest { void 생성_및_조회() { User user = User.builder().name("우성").nickname("우스").build(); - Long userId = userService.create(user); + User createdUser = userService.create(user); - User findUser = userService.getUser(userId); + User findUser = userService.getUser(createdUser.getId()); Assertions.assertThat(user.getName()).isEqualTo(findUser.getName()); }