diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..4246d468 Binary files /dev/null and b/.DS_Store differ diff --git a/linkmind/build.gradle b/linkmind/build.gradle index 65e03632..6d4525f9 100644 --- a/linkmind/build.gradle +++ b/linkmind/build.gradle @@ -26,12 +26,21 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'mysql:mysql-connector-java:8.0.32' compileOnly 'org.projectlombok:lombok' - //implementation 'org.springframework.boot:spring-boot-starter-security' - //testImplementation 'org.springframework.security:spring-security-test' - implementation 'org.springframework.boot:spring-boot-starter-actuator' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + //JWT + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2' + implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2' + implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2' + + + //FCM + implementation group: 'com.google.firebase', name: 'firebase-admin', version: '6.8.1' + implementation 'com.squareup.okhttp3:okhttp:4.10.0' // Firebase 서버로 푸시 메시지 전송 시 필요 + implementation 'org.springframework.boot:spring-boot-starter-actuator' } diff --git a/linkmind/src/main/java/com/app/toaster/config/UserId.java b/linkmind/src/main/java/com/app/toaster/config/UserId.java new file mode 100644 index 00000000..88bc0e8f --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/config/UserId.java @@ -0,0 +1,11 @@ +package com.app.toaster.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface UserId { +} diff --git a/linkmind/src/main/java/com/app/toaster/config/UserIdResolver.java b/linkmind/src/main/java/com/app/toaster/config/UserIdResolver.java new file mode 100644 index 00000000..2f2566e5 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/config/UserIdResolver.java @@ -0,0 +1,45 @@ +package com.app.toaster.config; + +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.app.toaster.config.jwt.JwtService; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Component +public class UserIdResolver implements HandlerMethodArgumentResolver { + + private final JwtService jwtService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(UserId.class) && Long.class.equals(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(@NotNull MethodParameter parameter, ModelAndViewContainer modelAndViewContainer, @NotNull NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + final HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + final String token = request.getHeader("accessToken"); + + // 토큰 검증 + if (!jwtService.verifyToken(token)) { + throw new RuntimeException(String.format("USER_ID를 가져오지 못했습니다. (%s - %s)", parameter.getClass(), parameter.getMethod())); + } + + // 유저 아이디 반환 + final String tokenContents = jwtService.getJwtContents(token); + try { + return Long.parseLong(tokenContents); + } catch (NumberFormatException e) { + throw new RuntimeException(String.format("USER_ID를 가져오지 못했습니다. (%s - %s)", parameter.getClass(), parameter.getMethod())); + } + } +} diff --git a/linkmind/src/main/java/com/app/toaster/config/jwt/JwtService.java b/linkmind/src/main/java/com/app/toaster/config/jwt/JwtService.java new file mode 100644 index 00000000..e87d86e2 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/config/jwt/JwtService.java @@ -0,0 +1,88 @@ +package com.app.toaster.config.jwt; + + +import static io.jsonwebtoken.Jwts.*; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.NotFoundException; +import com.app.toaster.exception.model.UnauthorizedException; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; + +@Service +public class JwtService { + + @Value("${jwt.secret}") + private String jwtSecret; + + @PostConstruct + protected void init() { + jwtSecret = Base64.getEncoder() + .encodeToString(jwtSecret.getBytes(StandardCharsets.UTF_8)); + } + + // JWT 토큰 발급 + public String issuedToken(String userId, Long tokenExpirationTime) { + final Date now = new Date(); + + // 클레임 생성 + final Claims claims = claims() + .setSubject("token") + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + tokenExpirationTime)); + + //private claim 등록 + claims.put("userId", userId); + + return builder() + .setHeaderParam(Header.TYPE , Header.JWT_TYPE) + .setClaims(claims) + .signWith(getSigningKey()) + .compact(); + } + + private SecretKey getSigningKey() { + final byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + // JWT 토큰 검증 + public boolean verifyToken(String token) { + try { + final Claims claims = getBody(token); + return true; + } catch (RuntimeException e) { + if (e instanceof ExpiredJwtException) { + throw new UnauthorizedException(Error.TOKEN_TIME_EXPIRED_EXCEPTION, Error.TOKEN_TIME_EXPIRED_EXCEPTION.getMessage()); + } + throw new NotFoundException(Error.NOT_FOUND_USER_EXCEPTION, Error.NOT_FOUND_USER_EXCEPTION.getMessage()); + } + } + + private Claims getBody(final String token) { + return parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + // JWT 토큰 내용 확인 + public String getJwtContents(String token) { + final Claims claims = getBody(token); + return (String) claims.get("userId"); + } +} diff --git a/linkmind/src/main/java/com/app/toaster/controller/AuthController.java b/linkmind/src/main/java/com/app/toaster/controller/AuthController.java new file mode 100644 index 00000000..b8e68b0a --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/controller/AuthController.java @@ -0,0 +1,56 @@ +package com.app.toaster.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.app.toaster.common.dto.ApiResponse; +import com.app.toaster.config.UserId; +import com.app.toaster.controller.request.auth.SignInRequestDto; +import com.app.toaster.controller.response.auth.SignInResponseDto; +import com.app.toaster.controller.response.auth.TokenResponseDto; +import com.app.toaster.exception.Success; +import com.app.toaster.service.auth.AuthService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + private final AuthService authService; + + @PostMapping + @ResponseStatus(HttpStatus.OK) + public ApiResponse signIn( + @RequestHeader("Authorization") String socialAccessToken, + @RequestBody SignInRequestDto requestDto + ) { + return ApiResponse.success(Success.LOGIN_SUCCESS, authService.signIn(socialAccessToken, requestDto)); + } + + @PostMapping("/token") + @ResponseStatus(HttpStatus.OK) + public ApiResponse reissueToken(@RequestHeader String refreshToken) { + return ApiResponse.success(Success.RE_ISSUE_TOKEN_SUCCESS, authService.issueToken(refreshToken)); + } + + @PostMapping("/sign-out") + @ResponseStatus(HttpStatus.OK) + public ApiResponse signOut(@UserId Long userId) { + authService.signOut(userId); + return ApiResponse.success(Success.SIGNOUT_SUCCESS); + } + + @DeleteMapping("/withdraw") + @ResponseStatus(HttpStatus.OK) + public ApiResponse withdraw(@UserId Long userId){ + authService.withdraw(userId); + return ApiResponse.success(Success.DELETE_USER_SUCCESS); + } +} diff --git a/linkmind/src/main/java/com/app/toaster/controller/request/auth/SignInRequestDto.java b/linkmind/src/main/java/com/app/toaster/controller/request/auth/SignInRequestDto.java new file mode 100644 index 00000000..a8876bfe --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/controller/request/auth/SignInRequestDto.java @@ -0,0 +1,4 @@ +package com.app.toaster.controller.request.auth; + +public record SignInRequestDto(String socialType, String fcmToken) { +} diff --git a/linkmind/src/main/java/com/app/toaster/controller/response/auth/SignInResponseDto.java b/linkmind/src/main/java/com/app/toaster/controller/response/auth/SignInResponseDto.java new file mode 100644 index 00000000..165d8ca0 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/controller/response/auth/SignInResponseDto.java @@ -0,0 +1,8 @@ +package com.app.toaster.controller.response.auth; + +public record SignInResponseDto(Long userId, String accessToken, String refreshToken, String fcmToken, Boolean isRegistered,Boolean FcmIsAllowed) { + public static SignInResponseDto of(Long userId, String accessToken, String refreshToken, String fcmToken, + Boolean isRegistered, Boolean fcmIsAllowed){ + return new SignInResponseDto(userId,accessToken, refreshToken,fcmToken,isRegistered,fcmIsAllowed); + } +} diff --git a/linkmind/src/main/java/com/app/toaster/controller/response/auth/TokenResponseDto.java b/linkmind/src/main/java/com/app/toaster/controller/response/auth/TokenResponseDto.java new file mode 100644 index 00000000..37f2e926 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/controller/response/auth/TokenResponseDto.java @@ -0,0 +1,8 @@ +package com.app.toaster.controller.response.auth; + +public record TokenResponseDto(String accessToken, String refreshToken) { + public static TokenResponseDto of(String accessToken, String refreshToken){ + return new TokenResponseDto(accessToken,refreshToken); + } + +} diff --git a/linkmind/src/main/java/com/app/toaster/domain/Category.java b/linkmind/src/main/java/com/app/toaster/domain/Category.java index 4cdab557..ac315cde 100644 --- a/linkmind/src/main/java/com/app/toaster/domain/Category.java +++ b/linkmind/src/main/java/com/app/toaster/domain/Category.java @@ -5,7 +5,6 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/linkmind/src/main/java/com/app/toaster/domain/Toast.java b/linkmind/src/main/java/com/app/toaster/domain/Toast.java index bfa7e2c2..95d7492e 100644 --- a/linkmind/src/main/java/com/app/toaster/domain/Toast.java +++ b/linkmind/src/main/java/com/app/toaster/domain/Toast.java @@ -21,7 +21,7 @@ public class Toast { private Long Id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "toast_id") + @JoinColumn(name = "user_id") private User user; private String title; diff --git a/linkmind/src/main/java/com/app/toaster/exception/Error.java b/linkmind/src/main/java/com/app/toaster/exception/Error.java index c08a4b52..26c4f8b7 100644 --- a/linkmind/src/main/java/com/app/toaster/exception/Error.java +++ b/linkmind/src/main/java/com/app/toaster/exception/Error.java @@ -14,12 +14,24 @@ public enum Error { * 404 NOT FOUND */ DUMMY_NOT_FOUND(HttpStatus.NOT_FOUND, "더미에 데이터가 덜 들어간 것 같아요"), - RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "예약정보를 찾지 못했어요"), + NOT_FOUND_USER_EXCEPTION(HttpStatus.NOT_FOUND, "찾을 수 없는 유저입니다."), + + /** + * 401 UNAUTHORIZED EXCEPTION + */ + TOKEN_TIME_EXPIRED_EXCEPTION(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + INVALID_APPLE_PUBLIC_KEY(HttpStatus.UNAUTHORIZED, "유효하지않은 애플 퍼블릭 키 입니다."), + EXPIRED_APPLE_IDENTITY_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 아이덴티티 토큰입니다."), + INVALID_APPLE_IDENTITY_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 아이덴티티 토큰입니다."), + + UNPROCESSABLE_ENTITY_DELETE_EXCEPTION(HttpStatus.UNPROCESSABLE_ENTITY, "서버에서 요청을 이해해 삭제하려는 도중 문제가 생겼습니다."), /** * 500 INTERNAL_SERVER_ERROR */ - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 서버 에러가 발생했습니다") + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "알 수 없는 서버 에러가 발생했습니다"), + INVALID_ENCRYPT_COMMUNICATION(HttpStatus.INTERNAL_SERVER_ERROR, "ios 통신 증명 과정 중 문제가 발생했습니다."), + CREATE_PUBLIC_KEY_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "publickey 생성 과정 중 문제가 발생했습니다."), ; private final HttpStatus httpStatus; diff --git a/linkmind/src/main/java/com/app/toaster/exception/Success.java b/linkmind/src/main/java/com/app/toaster/exception/Success.java index 12a69957..f7ed97ea 100644 --- a/linkmind/src/main/java/com/app/toaster/exception/Success.java +++ b/linkmind/src/main/java/com/app/toaster/exception/Success.java @@ -23,6 +23,12 @@ public enum Success { GET_MAIN_SUCCESS(HttpStatus.OK, "메인 페이지 조회 성공"), GET_TICKET_SUCCESS(HttpStatus.OK, "티켓 선택 페이지 조회 성공"), + LOGIN_SUCCESS(HttpStatus.OK, "로그인 성공"), + RE_ISSUE_TOKEN_SUCCESS(HttpStatus.OK, "토큰 재발급 성공"), + SIGNOUT_SUCCESS(HttpStatus.OK, "로그아웃 성공"), + DELETE_USER_SUCCESS(HttpStatus.OK, "유저 삭제 성공"), + + /** * 204 NO_CONTENT */ diff --git a/linkmind/src/main/java/com/app/toaster/exception/model/UnauthorizedException.java b/linkmind/src/main/java/com/app/toaster/exception/model/UnauthorizedException.java new file mode 100644 index 00000000..5c6c4877 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/exception/model/UnauthorizedException.java @@ -0,0 +1,10 @@ +package com.app.toaster.exception.model; + +import com.app.toaster.exception.Error; + +public class UnauthorizedException extends CustomException{ + public UnauthorizedException(Error error, String message) { + super(error, message); + } + +} diff --git a/linkmind/src/main/java/com/app/toaster/exception/model/UnprocessableEntityException.java b/linkmind/src/main/java/com/app/toaster/exception/model/UnprocessableEntityException.java new file mode 100644 index 00000000..dd2490fe --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/exception/model/UnprocessableEntityException.java @@ -0,0 +1,9 @@ +package com.app.toaster.exception.model; + +import com.app.toaster.exception.Error; + +public class UnprocessableEntityException extends CustomException{ + public UnprocessableEntityException(Error error, String message) { + super(error, message); + } +} diff --git a/linkmind/src/main/java/com/app/toaster/external/client/aws/.gitkeep b/linkmind/src/main/java/com/app/toaster/external/client/aws/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/linkmind/src/main/java/com/app/toaster/infrastructure/UserRepository.java b/linkmind/src/main/java/com/app/toaster/infrastructure/UserRepository.java new file mode 100644 index 00000000..6c240e9e --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/infrastructure/UserRepository.java @@ -0,0 +1,22 @@ +package com.app.toaster.infrastructure; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.app.toaster.domain.SocialType; +import com.app.toaster.domain.User; + +public interface UserRepository extends JpaRepository { + Boolean existsBySocialIdAndSocialType(String socialId, SocialType socialType); + + Optional findByUserId(Long userId); + + Optional findBySocialIdAndSocialType(String socialId, SocialType socialType); + + Boolean existsByNickname(String s); + + Optional findByRefreshToken(String refreshToken); + + Long deleteByUserId(Long userId); +} diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/AuthService.java b/linkmind/src/main/java/com/app/toaster/service/auth/AuthService.java new file mode 100644 index 00000000..abec4048 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/service/auth/AuthService.java @@ -0,0 +1,115 @@ +package com.app.toaster.service.auth; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.app.toaster.config.jwt.JwtService; +import com.app.toaster.controller.request.auth.SignInRequestDto; +import com.app.toaster.controller.response.auth.SignInResponseDto; +import com.app.toaster.controller.response.auth.TokenResponseDto; +import com.app.toaster.domain.SocialType; +import com.app.toaster.domain.User; +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.NotFoundException; +import com.app.toaster.exception.model.UnprocessableEntityException; +import com.app.toaster.infrastructure.UserRepository; +import com.app.toaster.service.auth.apple.AppleSignInService; +import com.app.toaster.service.auth.kakao.KakaoSignInService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AuthService { + private final AppleSignInService appleSignInService; + private final KakaoSignInService kakaoSignInService; + private final JwtService jwtService; + + private final UserRepository userRepository; + + + private final Long TOKEN_EXPIRATION_TIME_ACCESS = 1 * 60 * 1000L; + private final Long TOKEN_EXPIRATION_TIME_REFRESH = 3 * 60 * 1000L; + + + @Transactional + public SignInResponseDto signIn(String socialAccessToken, SignInRequestDto requestDto) { + SocialType socialType = SocialType.valueOf(requestDto.socialType()); + String socialId = login(socialType, socialAccessToken); + + Boolean isRegistered = userRepository.existsBySocialIdAndSocialType(socialId, socialType); + + if (!isRegistered) { + User newUser = User.builder() + .nickname("토스터"+socialId) + .socialId(socialId) + .socialType(socialType).build(); + newUser.updateFcmIsAllowed(true); //신규 유저면 true박고 + userRepository.save(newUser); + } + + User user = userRepository.findBySocialIdAndSocialType(socialId, socialType) + .orElseThrow(() -> new NotFoundException(Error.NOT_FOUND_USER_EXCEPTION, Error.NOT_FOUND_USER_EXCEPTION.getMessage())); + + // jwt 발급 (액세스 토큰, 리프레쉬 토큰) + String accessToken = jwtService.issuedToken(String.valueOf(user.getUserId()), TOKEN_EXPIRATION_TIME_ACCESS); + String refreshToken = jwtService.issuedToken(String.valueOf(user.getUserId()), TOKEN_EXPIRATION_TIME_REFRESH); + String fcmToken = requestDto.fcmToken(); + + user.updateRefreshToken(refreshToken); + user.updateFcmToken(fcmToken); + + return SignInResponseDto.of(user.getUserId(), accessToken, refreshToken, fcmToken, isRegistered,user.getFcmIsAllowed()); + } + + @Transactional + public TokenResponseDto issueToken(String refreshToken) { + jwtService.verifyToken(refreshToken); + + User user = userRepository.findByRefreshToken(refreshToken) + .orElseThrow(() -> new NotFoundException(Error.NOT_FOUND_USER_EXCEPTION, Error.NOT_FOUND_USER_EXCEPTION.getMessage())); + + // jwt 발급 (액세스 토큰, 리프레쉬 토큰) + String newAccessToken = jwtService.issuedToken(String.valueOf(user.getUserId()), TOKEN_EXPIRATION_TIME_ACCESS); + String newRefreshToken = jwtService.issuedToken(String.valueOf(user.getUserId()), TOKEN_EXPIRATION_TIME_REFRESH); + + user.updateRefreshToken(newRefreshToken); + + return TokenResponseDto.of(newAccessToken, newRefreshToken); + } + + @Transactional + public void signOut(Long userId) { + User user = userRepository.findByUserId(userId) + .orElseThrow(() -> new NotFoundException(Error.NOT_FOUND_USER_EXCEPTION, Error.NOT_FOUND_USER_EXCEPTION.getMessage())); + user.updateRefreshToken(null); + user.updateFcmToken(null); + } + + private String login(SocialType socialType, String socialAccessToken) { + if (socialType.toString() == "APPLE") { + return appleSignInService.getAppleId(socialAccessToken); + } + else if (socialType.toString() == "KAKAO") { + return kakaoSignInService.getKaKaoId(socialAccessToken); + } + else{ + return "ads"; + } + } + + @Transactional + public void withdraw(Long userId){ + User user = userRepository.findByUserId(userId).orElse(null); + System.out.println(userId); + if (user == null) { + throw new NotFoundException(Error.NOT_FOUND_USER_EXCEPTION, Error.NOT_FOUND_USER_EXCEPTION.getMessage()); + } + + Long res = userRepository.deleteByUserId(userId); //res가 삭제된 컬럼의 개수 즉, 1이 아니면 뭔가 알 수 없는 에러. + System.out.println(res + "개의 컬럼이 삭제되었습니다."); + if (res!=1){ + throw new UnprocessableEntityException(Error.UNPROCESSABLE_ENTITY_DELETE_EXCEPTION, Error.UNPROCESSABLE_ENTITY_DELETE_EXCEPTION.getMessage()); + } + } +} diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/apple/AppleSignInService.java b/linkmind/src/main/java/com/app/toaster/service/auth/apple/AppleSignInService.java new file mode 100644 index 00000000..94f31d13 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/service/auth/apple/AppleSignInService.java @@ -0,0 +1,38 @@ +package com.app.toaster.service.auth.apple; + +import java.security.PublicKey; +import java.util.Map; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import com.app.toaster.service.auth.apple.response.ApplePublicKeys; +import com.app.toaster.service.auth.apple.verify.AppleJwtParser; +import com.app.toaster.service.auth.apple.verify.PublicKeyGenerator; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AppleSignInService { + private static final String APPLE_URI = "https://appleid.apple.com/auth"; + private static final RestClient restClient = RestClient.create(APPLE_URI); + private static AppleJwtParser appleJwtParser; + private static PublicKeyGenerator publicKeyGenerator; + + public String getAppleId(String identityToken) { + Map headers = appleJwtParser.parseHeaders(identityToken); + + ResponseEntity result = restClient.get() + .uri("/keys") + .retrieve() + .toEntity(ApplePublicKeys.class); + + PublicKey publicKey = publicKeyGenerator.generatePublicKey(headers, result.getBody()); + + Claims claims = appleJwtParser.parsePublicKeyAndGetClaims(identityToken, publicKey); + return claims.getSubject(); + } +} diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/apple/response/ApplePublicKey.java b/linkmind/src/main/java/com/app/toaster/service/auth/apple/response/ApplePublicKey.java new file mode 100644 index 00000000..808848bc --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/service/auth/apple/response/ApplePublicKey.java @@ -0,0 +1,11 @@ +package com.app.toaster.service.auth.apple.response; + +public record ApplePublicKey( + String kty, + String kid, + String use, + String alg, + String n, + String e +) { +} diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/apple/response/ApplePublicKeys.java b/linkmind/src/main/java/com/app/toaster/service/auth/apple/response/ApplePublicKeys.java new file mode 100644 index 00000000..aa8be947 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/service/auth/apple/response/ApplePublicKeys.java @@ -0,0 +1,17 @@ +package com.app.toaster.service.auth.apple.response; + +import java.util.List; + +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.CustomException; + +public record ApplePublicKeys(List keys) { + + public ApplePublicKey getMatchesKey(String alg, String kid) { + return this.keys + .stream() + .filter(k -> k.alg().equals(alg) && k.kid().equals(kid)) + .findFirst() + .orElseThrow(() -> new CustomException(Error.INVALID_APPLE_PUBLIC_KEY, Error.INVALID_APPLE_PUBLIC_KEY.getMessage())); + } +} diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/apple/verify/AppleJwtParser.java b/linkmind/src/main/java/com/app/toaster/service/auth/apple/verify/AppleJwtParser.java new file mode 100644 index 00000000..e2a65407 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/service/auth/apple/verify/AppleJwtParser.java @@ -0,0 +1,53 @@ +package com.app.toaster.service.auth.apple.verify; + +import java.security.PublicKey; +import java.util.Base64; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.CustomException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; + +@Component +public class AppleJwtParser { + + private static final String IDENTITY_TOKEN_VALUE_DELIMITER = "\\."; + private static final int HEADER_INDEX = 0; + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public Map parseHeaders(String identityToken) { + try { + String encodedHeader = identityToken.split(IDENTITY_TOKEN_VALUE_DELIMITER)[HEADER_INDEX]; + String decodedHeader = new String(Base64.getDecoder().decode(encodedHeader)); + return OBJECT_MAPPER.readValue(decodedHeader, Map.class); + + } catch (JsonProcessingException | ArrayIndexOutOfBoundsException e) { + throw new CustomException(Error.INVALID_APPLE_IDENTITY_TOKEN, Error.INVALID_APPLE_IDENTITY_TOKEN.getMessage()); + } + } + + public Claims parsePublicKeyAndGetClaims(String idToken, PublicKey publicKey) { + try { + return Jwts.parserBuilder() + .setSigningKey(publicKey) + .build() + .parseClaimsJws(idToken) + .getBody(); + + } catch (ExpiredJwtException e) { + throw new CustomException(Error.EXPIRED_APPLE_IDENTITY_TOKEN, Error.EXPIRED_APPLE_IDENTITY_TOKEN.getMessage()); + } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) { + throw new CustomException(Error.INVALID_APPLE_IDENTITY_TOKEN, Error.INVALID_APPLE_IDENTITY_TOKEN.getMessage()); + } + } +} \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/apple/verify/EncryptUtils.java b/linkmind/src/main/java/com/app/toaster/service/auth/apple/verify/EncryptUtils.java new file mode 100644 index 00000000..0635998e --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/service/auth/apple/verify/EncryptUtils.java @@ -0,0 +1,25 @@ +package com.app.toaster.service.auth.apple.verify; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.CustomException; + +public class EncryptUtils { + + public static String encrypt(String value) { + try { + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + byte[] digest = sha256.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : digest) { + hexString.append(String.format("%02x", b)); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new CustomException(Error.INVALID_ENCRYPT_COMMUNICATION, Error.INVALID_ENCRYPT_COMMUNICATION.getMessage()); + } + } +} diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/apple/verify/PublicKeyGenerator.java b/linkmind/src/main/java/com/app/toaster/service/auth/apple/verify/PublicKeyGenerator.java new file mode 100644 index 00000000..03795412 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/service/auth/apple/verify/PublicKeyGenerator.java @@ -0,0 +1,49 @@ +package com.app.toaster.service.auth.apple.verify; + +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; + +import org.springframework.stereotype.Component; + +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.CustomException; +import com.app.toaster.service.auth.apple.response.ApplePublicKey; +import com.app.toaster.service.auth.apple.response.ApplePublicKeys; + +@Component +public class PublicKeyGenerator { + + private static final String SIGN_ALGORITHM_HEADER_KEY = "alg"; + private static final String KEY_ID_HEADER_KEY = "kid"; + private static final int POSITIVE_SIGN_NUMBER = 1; + + public PublicKey generatePublicKey(Map headers, ApplePublicKeys applePublicKeys) { + ApplePublicKey applePublicKey = + applePublicKeys.getMatchesKey(headers.get(SIGN_ALGORITHM_HEADER_KEY), headers.get(KEY_ID_HEADER_KEY)); + + return generatePublicKeyWithApplePublicKey(applePublicKey); + } + + private PublicKey generatePublicKeyWithApplePublicKey(ApplePublicKey publicKey) { + byte[] nBytes = Base64.getDecoder().decode(publicKey.n()); + byte[] eBytes = Base64.getDecoder().decode(publicKey.e()); + + BigInteger n = new BigInteger(POSITIVE_SIGN_NUMBER, nBytes); + BigInteger e = new BigInteger(POSITIVE_SIGN_NUMBER, eBytes); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + + try { + KeyFactory keyFactory = KeyFactory.getInstance(publicKey.kty()); + return keyFactory.generatePublic(publicKeySpec); + } catch (NoSuchAlgorithmException | InvalidKeySpecException exception) { + throw new CustomException(Error.CREATE_PUBLIC_KEY_EXCEPTION, Error.CREATE_PUBLIC_KEY_EXCEPTION.getMessage()); + } + } +} diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/kakao/KakaoSignInService.java b/linkmind/src/main/java/com/app/toaster/service/auth/kakao/KakaoSignInService.java new file mode 100644 index 00000000..338691dc --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/service/auth/kakao/KakaoSignInService.java @@ -0,0 +1,33 @@ +package com.app.toaster.service.auth.kakao; + +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.JsonArray; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class KakaoSignInService { + @Value("${jwt.KAKAO_URL}") + private String KAKAO_URL; + + public String getKaKaoId(String accessToken) { + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization","Bearer "+ accessToken); + HttpEntity httpEntity = new HttpEntity<>(headers); + ResponseEntity responseData; + responseData = restTemplate.postForEntity(KAKAO_URL,httpEntity,Object.class); + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.convertValue(responseData.getBody(), Map.class).get("id").toString(); //소셜 id만 가져오는듯. + } +} diff --git a/linkmind/src/test/java/com/app/toaster/LinkmindApplicationTests.java b/linkmind/src/test/java/com/app/toaster/LinkmindApplicationTests.java new file mode 100644 index 00000000..43f0eec4 --- /dev/null +++ b/linkmind/src/test/java/com/app/toaster/LinkmindApplicationTests.java @@ -0,0 +1,13 @@ +package com.app.toaster; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class LinkmindApplicationTests { + + @Test + void contextLoads() { + } + +}