diff --git a/build.gradle b/build.gradle index cac3648..eec0bff 100644 --- a/build.gradle +++ b/build.gradle @@ -62,6 +62,21 @@ dependencies { implementation 'net.coobird:thumbnailator:0.4.20' implementation 'software.amazon.awssdk:s3' + + // WebClient + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // Netty MacOS + implementation('io.netty:netty-resolver-dns-native-macos') { + artifact { + classifier = "osx-aarch_64" // Apple Silicon + } + } } tasks.named('test') { diff --git a/src/main/java/kr/kro/photoliner/common/dto/response/JwtResponse.java b/src/main/java/kr/kro/photoliner/common/dto/response/JwtResponse.java new file mode 100644 index 0000000..da49bd6 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/common/dto/response/JwtResponse.java @@ -0,0 +1,7 @@ +package kr.kro.photoliner.common.dto.response; + +public record JwtResponse( + String accessToken +) { + +} diff --git a/src/main/java/kr/kro/photoliner/domain/album/controller/AlbumController.java b/src/main/java/kr/kro/photoliner/domain/album/controller/AlbumController.java index 09249d7..2d6089d 100644 --- a/src/main/java/kr/kro/photoliner/domain/album/controller/AlbumController.java +++ b/src/main/java/kr/kro/photoliner/domain/album/controller/AlbumController.java @@ -12,6 +12,7 @@ import kr.kro.photoliner.domain.album.dto.response.AlbumPhotoMarkersResponse; import kr.kro.photoliner.domain.album.dto.response.AlbumsResponse; import kr.kro.photoliner.domain.album.service.AlbumService; +import kr.kro.photoliner.global.auth.Auth; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -25,7 +26,6 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -37,16 +37,17 @@ public class AlbumController { @PostMapping public ResponseEntity createAlbum( - @Valid @RequestBody AlbumCreateRequest request + @Valid @RequestBody AlbumCreateRequest request, + @Auth Long userId ) { - AlbumCreateResponse response = albumService.createAlbum(request); + AlbumCreateResponse response = albumService.createAlbum(userId, request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @GetMapping public ResponseEntity getAlbums( - @RequestParam Long userId, - @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @Auth Long userId ) { return ResponseEntity.ok(albumService.getAlbums(userId, pageable)); } @@ -54,7 +55,8 @@ public ResponseEntity getAlbums( @PatchMapping("/{albumId}/title") public ResponseEntity updateAlbumTitle( @PathVariable Long albumId, - @RequestBody @Valid AlbumTitleUpdateRequest request + @RequestBody @Valid AlbumTitleUpdateRequest request, + @Auth Long userId ) { albumService.updateAlbumTitle(albumId, request); return ResponseEntity.noContent().build(); @@ -62,7 +64,8 @@ public ResponseEntity updateAlbumTitle( @DeleteMapping public ResponseEntity deletePhoto( - @Valid @RequestBody AlbumDeleteRequest request + @Valid @RequestBody AlbumDeleteRequest request, + @Auth Long userId ) { albumService.deleteAlbums(request); return ResponseEntity.noContent().build(); @@ -70,7 +73,8 @@ public ResponseEntity deletePhoto( @GetMapping("/{albumId}/photos") public ResponseEntity getAlbumItems( - @PathVariable Long albumId + @PathVariable Long albumId, + @Auth Long userId ) { return ResponseEntity.ok(albumService.getAlbumPhotoItems(albumId)); } @@ -78,7 +82,8 @@ public ResponseEntity getAlbumItems( @PostMapping("/{albumId}/photos") public ResponseEntity createAlbumItems( @PathVariable Long albumId, - @RequestBody @Valid AlbumItemCreateRequest request + @RequestBody @Valid AlbumItemCreateRequest request, + @Auth Long userId ) { albumService.createAlbumItems(albumId, request); return ResponseEntity.noContent().build(); @@ -87,7 +92,8 @@ public ResponseEntity createAlbumItems( @DeleteMapping("/{albumId}/photos") public ResponseEntity deleteAlbumItems( @PathVariable Long albumId, - @RequestBody @Valid AlbumItemDeleteRequest request + @RequestBody @Valid AlbumItemDeleteRequest request, + @Auth Long userId ) { albumService.deleteAlbumItems(albumId, request); return ResponseEntity.noContent().build(); @@ -96,7 +102,8 @@ public ResponseEntity deleteAlbumItems( @GetMapping("/{albumId}/markers") public ResponseEntity getAlbumPhotoMarkers( @PathVariable Long albumId, - @Valid AlbumPhotoMarkersRequest request + @Valid AlbumPhotoMarkersRequest request, + @Auth Long userId ) { return ResponseEntity.ok(albumService.getAlbumPhotoMarkers(albumId, request)); } diff --git a/src/main/java/kr/kro/photoliner/domain/album/dto/request/AlbumCreateRequest.java b/src/main/java/kr/kro/photoliner/domain/album/dto/request/AlbumCreateRequest.java index 953c4dd..d69e65f 100644 --- a/src/main/java/kr/kro/photoliner/domain/album/dto/request/AlbumCreateRequest.java +++ b/src/main/java/kr/kro/photoliner/domain/album/dto/request/AlbumCreateRequest.java @@ -1,11 +1,8 @@ package kr.kro.photoliner.domain.album.dto.request; import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; public record AlbumCreateRequest( - @NotNull - Long userId, @NotEmpty String title ) { diff --git a/src/main/java/kr/kro/photoliner/domain/album/service/AlbumService.java b/src/main/java/kr/kro/photoliner/domain/album/service/AlbumService.java index 4b25eca..5c15e90 100644 --- a/src/main/java/kr/kro/photoliner/domain/album/service/AlbumService.java +++ b/src/main/java/kr/kro/photoliner/domain/album/service/AlbumService.java @@ -38,9 +38,9 @@ public class AlbumService { private final GeometryFactory geometryFactory; @Transactional - public AlbumCreateResponse createAlbum(AlbumCreateRequest request) { - User user = userRepository.findUserById(request.userId()) - .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user id: " + request.userId())); + public AlbumCreateResponse createAlbum(Long userId, AlbumCreateRequest request) { + User user = userRepository.findUserById(userId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user id: " + userId)); Album album = Album.builder() .title(request.title()) .user(user) diff --git a/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java b/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java index 59f8c8c..0e733eb 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/controller/PhotoController.java @@ -13,6 +13,7 @@ import kr.kro.photoliner.domain.photo.dto.response.PresignedUrlResponse; import kr.kro.photoliner.domain.photo.infra.S3CustomClient; import kr.kro.photoliner.domain.photo.service.PhotoService; +import kr.kro.photoliner.global.auth.Auth; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -39,7 +40,7 @@ public class PhotoController { @GetMapping public ResponseEntity getPhotos( - @RequestParam Long userId, + @Auth Long userId, @RequestParam(required = false) Boolean hasLocation, @RequestParam(required = false) Boolean hasCapturedDate, @PageableDefault(sort = "capturedDt", direction = Sort.Direction.DESC) Pageable pageable @@ -48,8 +49,11 @@ public ResponseEntity getPhotos( } @GetMapping("/markers") - public ResponseEntity getPhotoMarkers(@Valid PhotoMarkersRequest request) { - return ResponseEntity.ok(photoService.getPhotoMarkers(request)); + public ResponseEntity getPhotoMarkers( + @Valid PhotoMarkersRequest request, + @Auth Long userId + ) { + return ResponseEntity.ok(photoService.getPhotoMarkers(userId, request)); } @PostMapping("/presigned-urls") @@ -62,14 +66,16 @@ public ResponseEntity> getPresignedUrls( @PostMapping public ResponseEntity createPhotos( - @Valid @RequestBody CreatePhotosRequest request + @Valid @RequestBody CreatePhotosRequest request, + @Auth Long userId ) { - photoService.createPhotos(request); + photoService.createPhotos(userId, request); return ResponseEntity.status(HttpStatus.CREATED).build(); } @PatchMapping("/{photoId}/captured-date") public ResponseEntity updatePhotoCapturedDate( + @Auth Long userId, @PathVariable Long photoId, @Valid @RequestBody PhotoCapturedDateUpdateRequest request ) { @@ -80,7 +86,8 @@ public ResponseEntity updatePhotoCapturedDate( @PatchMapping("/{photoId}/location") public ResponseEntity updatePhotoLocation( @PathVariable Long photoId, - @Valid @RequestBody PhotoLocationUpdateRequest request + @Valid @RequestBody PhotoLocationUpdateRequest request, + @Auth Long userId ) { photoService.updatePhotoLocation(photoId, request); return ResponseEntity.noContent().build(); @@ -88,7 +95,8 @@ public ResponseEntity updatePhotoLocation( @DeleteMapping public ResponseEntity deletePhoto( - @Valid @RequestBody DeletePhotosRequest request + @Valid @RequestBody DeletePhotosRequest request, + @Auth Long userId ) { photoService.deletePhotos(request); return ResponseEntity.noContent().build(); diff --git a/src/main/java/kr/kro/photoliner/domain/photo/dto/request/CreatePhotosRequest.java b/src/main/java/kr/kro/photoliner/domain/photo/dto/request/CreatePhotosRequest.java index 06e3a43..72d975a 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/dto/request/CreatePhotosRequest.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/dto/request/CreatePhotosRequest.java @@ -11,10 +11,6 @@ import org.locationtech.jts.geom.Coordinate; public record CreatePhotosRequest( - @NotNull - Long userId, - - @NotNull @NotEmpty List photos ) { diff --git a/src/main/java/kr/kro/photoliner/domain/photo/dto/request/PhotoMarkersRequest.java b/src/main/java/kr/kro/photoliner/domain/photo/dto/request/PhotoMarkersRequest.java index 8dab443..ac18d4d 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/dto/request/PhotoMarkersRequest.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/dto/request/PhotoMarkersRequest.java @@ -2,13 +2,9 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; import org.locationtech.jts.geom.Coordinate; public record PhotoMarkersRequest( - @NotNull @Min(0) - Long userId, - @Min(0) @Max(90) double swLat, diff --git a/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java b/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java index fbc520a..9ef2e64 100644 --- a/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java +++ b/src/main/java/kr/kro/photoliner/domain/photo/service/PhotoService.java @@ -44,20 +44,20 @@ public PhotosResponse getPhotosByIds(Long userId, Boolean hasLocation, Boolean h } @Transactional(readOnly = true) - public PhotoMarkersResponse getPhotoMarkers(PhotoMarkersRequest request) { + public PhotoMarkersResponse getPhotoMarkers(Long userId, PhotoMarkersRequest request) { Point sw = geometryFactory.createPoint(request.getSouthWestCoordinate()); Point ne = geometryFactory.createPoint(request.getNorthEastCoordinate()); - Photos photos = photoRepository.getByUserIdInBox(request.userId(), sw, ne); + Photos photos = photoRepository.getByUserIdInBox(userId, sw, ne); return PhotoMarkersResponse.from(photos); } @Transactional - public void createPhotos(CreatePhotosRequest request) { + public void createPhotos(Long userId, CreatePhotosRequest request) { List photos = request.photos().stream() .map(photo -> Photo.builder() - .userId(request.userId()) + .userId(userId) .fileName(photo.fileName()) .filePath(cdnURL + ORIGINAL_BASE_PATH + photo.uploadFileName()) .thumbnailPath(cdnURL + THUMBNAIL_BASE_PATH + photo.uploadFileName()) diff --git a/src/main/java/kr/kro/photoliner/domain/user/controller/UserController.java b/src/main/java/kr/kro/photoliner/domain/user/controller/UserController.java index 86e5c1c..4ac50dc 100644 --- a/src/main/java/kr/kro/photoliner/domain/user/controller/UserController.java +++ b/src/main/java/kr/kro/photoliner/domain/user/controller/UserController.java @@ -1,12 +1,52 @@ package kr.kro.photoliner.domain.user.controller; +import java.net.URI; +import kr.kro.photoliner.common.dto.response.JwtResponse; +import kr.kro.photoliner.domain.user.dto.response.UserInfoResponse; import kr.kro.photoliner.domain.user.service.UserService; +import kr.kro.photoliner.global.auth.Auth; +import kr.kro.photoliner.global.kakao.login.service.KakaoAuthService; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor +@RequestMapping("/api/v1/users") public class UserController { private final UserService userService; + private final KakaoAuthService kakaoAuthService; + private static final String LOGIN_REDIRECT_URL = "http://localhost:5173/login/kakao"; + + @GetMapping("/login/kakao") + public ResponseEntity login(@RequestParam(value = "code") String authorizationCode) { + JwtResponse jwtResponse = userService.oAuthLogin(authorizationCode); + + String redirectUrl = LOGIN_REDIRECT_URL + "#accessToken=" + jwtResponse.accessToken(); + + return ResponseEntity + .status(HttpStatus.FOUND) + .location(URI.create(redirectUrl)) + .build(); + } + + @GetMapping("/login/kakao/authorization") + public ResponseEntity authorize() { + return ResponseEntity + .status(HttpStatus.FOUND) + .location(URI.create(kakaoAuthService.getAuthorizationRedirectUrl())) + .build(); + } + + @GetMapping("/info") + public ResponseEntity getUserInfo( + @Auth Long userId + ) { + return ResponseEntity.ok(userService.getUserInfo(userId)); + } } diff --git a/src/main/java/kr/kro/photoliner/domain/user/dto/request/UserRefreshTokenRequest.java b/src/main/java/kr/kro/photoliner/domain/user/dto/request/UserRefreshTokenRequest.java new file mode 100644 index 0000000..88b91a2 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/user/dto/request/UserRefreshTokenRequest.java @@ -0,0 +1,12 @@ +package kr.kro.photoliner.domain.user.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import jakarta.validation.constraints.NotNull; + +@JsonNaming(value = SnakeCaseStrategy.class) +public record UserRefreshTokenRequest( + @NotNull(message = "refresh token 을 입력해주세요.") + String refreshToken +) { +} diff --git a/src/main/java/kr/kro/photoliner/domain/user/dto/response/UserInfoResponse.java b/src/main/java/kr/kro/photoliner/domain/user/dto/response/UserInfoResponse.java new file mode 100644 index 0000000..29f77f5 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/domain/user/dto/response/UserInfoResponse.java @@ -0,0 +1,16 @@ +package kr.kro.photoliner.domain.user.dto.response; + +import kr.kro.photoliner.domain.user.model.User; + +public record UserInfoResponse( + String name, + String email +) { + + public static UserInfoResponse from(User user) { + return new UserInfoResponse( + user.getName(), + user.getEmail() + ); + } +} diff --git a/src/main/java/kr/kro/photoliner/domain/user/model/User.java b/src/main/java/kr/kro/photoliner/domain/user/model/User.java index 1689729..be795b6 100644 --- a/src/main/java/kr/kro/photoliner/domain/user/model/User.java +++ b/src/main/java/kr/kro/photoliner/domain/user/model/User.java @@ -7,6 +7,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import kr.kro.photoliner.common.model.BaseEntity; +import kr.kro.photoliner.global.kakao.login.dto.response.KakaoProfileResponse; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -30,4 +31,21 @@ public class User extends BaseEntity { @Column(name = "name", nullable = false) private String name; + @Column(name = "email", nullable = false) + private String email; + + public User(String username, String name, String email) { + this.username = username; + this.name = name; + this.email = email; + } + + public static User from(KakaoProfileResponse profileResponse) { + return new User( + profileResponse.id().toString(), + profileResponse.kakaoAccount().profile().nickname(), + profileResponse.kakaoAccount().email() + ); + } + } diff --git a/src/main/java/kr/kro/photoliner/domain/user/repository/UserRepository.java b/src/main/java/kr/kro/photoliner/domain/user/repository/UserRepository.java index ab06bd9..ac2062c 100644 --- a/src/main/java/kr/kro/photoliner/domain/user/repository/UserRepository.java +++ b/src/main/java/kr/kro/photoliner/domain/user/repository/UserRepository.java @@ -5,6 +5,11 @@ import org.springframework.data.repository.Repository; public interface UserRepository extends Repository { + boolean existsByEmail(String email); + + User save(User user); Optional findUserById(Long userId); + + Optional findUserByEmail(String email); } diff --git a/src/main/java/kr/kro/photoliner/domain/user/service/UserService.java b/src/main/java/kr/kro/photoliner/domain/user/service/UserService.java index 792b38c..68b8b10 100644 --- a/src/main/java/kr/kro/photoliner/domain/user/service/UserService.java +++ b/src/main/java/kr/kro/photoliner/domain/user/service/UserService.java @@ -1,10 +1,43 @@ package kr.kro.photoliner.domain.user.service; +import kr.kro.photoliner.common.dto.response.JwtResponse; +import kr.kro.photoliner.domain.user.dto.response.UserInfoResponse; +import kr.kro.photoliner.domain.user.model.User; +import kr.kro.photoliner.domain.user.repository.UserRepository; +import kr.kro.photoliner.global.auth.JwtProvider; +import kr.kro.photoliner.global.code.ApiResponseCode; +import kr.kro.photoliner.global.exception.CustomException; +import kr.kro.photoliner.global.kakao.login.dto.response.KakaoOauthTokenResponse; +import kr.kro.photoliner.global.kakao.login.dto.response.KakaoProfileResponse; +import kr.kro.photoliner.global.kakao.login.service.KakaoAuthService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class UserService { + private final UserRepository userRepository; + private final KakaoAuthService kakaoAuthService; + private final JwtProvider jwtProvider; + public UserInfoResponse getUserInfo(Long userId) { + User user = userRepository.findUserById(userId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user id: " + userId)); + return UserInfoResponse.from(user); + } + + public JwtResponse oAuthLogin(String authorizationCode) { + KakaoOauthTokenResponse tokenResponse = kakaoAuthService.getTokenByAuthorizationCode(authorizationCode); + String accessToken = tokenResponse.accessToken(); + KakaoProfileResponse profileResponse = kakaoAuthService.getKakaoUserProfile(accessToken); + + User user = userRepository.findUserByEmail(profileResponse.kakaoAccount().email()) + .orElseGet(() -> signup(User.from(profileResponse))); + + return new JwtResponse(jwtProvider.createAccessToken(user)); + } + + private User signup(User user) { + return userRepository.save(user); + } } diff --git a/src/main/java/kr/kro/photoliner/global/auth/Auth.java b/src/main/java/kr/kro/photoliner/global/auth/Auth.java new file mode 100644 index 0000000..325453a --- /dev/null +++ b/src/main/java/kr/kro/photoliner/global/auth/Auth.java @@ -0,0 +1,13 @@ +package kr.kro.photoliner.global.auth; + +import io.swagger.v3.oas.annotations.Hidden; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Hidden +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Auth { +} diff --git a/src/main/java/kr/kro/photoliner/global/auth/JwtProvider.java b/src/main/java/kr/kro/photoliner/global/auth/JwtProvider.java new file mode 100644 index 0000000..5d36443 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/global/auth/JwtProvider.java @@ -0,0 +1,101 @@ +package kr.kro.photoliner.global.auth; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.WeakKeyException; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import javax.crypto.SecretKey; +import kr.kro.photoliner.domain.user.model.User; +import kr.kro.photoliner.global.code.ApiResponseCode; +import kr.kro.photoliner.global.exception.CustomException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.WebRequest; + +@Component +public class JwtProvider { + + private static final String BEARER_TYPE = "Bearer "; + private static final int BEARER_TYPE_LEN = 7; + + private final String secretKey; + private final Long accessTokenExpirationTime; + + public JwtProvider( + @Value("${jwt.secret-key}") String secretKey, + @Value("${jwt.access-token.expiration-time}") Long accessTokenExpirationTime + ) { + this.secretKey = secretKey; + this.accessTokenExpirationTime = accessTokenExpirationTime; + } + + public String extractAccessToken(WebRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) { + return bearerToken.substring(BEARER_TYPE_LEN); + } + return null; + } + + public Long getUserId(String token) { + try { + String userId = Jwts.parser() + .verifyWith(getSecretKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .get("id") + .toString(); + return Long.parseLong(userId); + } catch (JwtException e) { + throw CustomException.of(ApiResponseCode.TOKEN_PARSE_ERROR); + } + } + + public void validateToken(String token) { + try { + Jwts.parser() + .verifyWith(getSecretKey()) + .build() + .parseSignedClaims(token); + } catch (io.jsonwebtoken.security.SignatureException | MalformedJwtException | WeakKeyException e) { + throw CustomException.of(ApiResponseCode.INVALID_JWT_TOKEN, e.getMessage(), e); + } catch (ExpiredJwtException e) { + throw CustomException.of(ApiResponseCode.EXPIRED_JWT_TOKEN, e.getMessage(), e); + } catch (UnsupportedJwtException | IllegalArgumentException e) { + throw CustomException.of(ApiResponseCode.TOKEN_PARSE_ERROR, e.getMessage(), e); + } catch (Exception e) { + throw e; + } + } + + public String createAccessToken(User user) { + if (user == null) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user: " + null); + } + SecretKey key = getSecretKey(); + return Jwts.builder() + .signWith(key) + .header() + .add("typ", "JWT") + .add("alg", key.getAlgorithm()) + .and() + .claim("id", user.getId()) + .expiration(Date.from(Instant.now().plusMillis(accessTokenExpirationTime))) + .compact(); + } + + private SecretKey getSecretKey() { + String encoded = Base64.getEncoder().encodeToString(secretKey.getBytes()); + return Keys.hmacShaKeyFor(encoded.getBytes()); + } +} diff --git a/src/main/java/kr/kro/photoliner/global/auth/UserArgumentResolver.java b/src/main/java/kr/kro/photoliner/global/auth/UserArgumentResolver.java new file mode 100644 index 0000000..da20406 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/global/auth/UserArgumentResolver.java @@ -0,0 +1,47 @@ +package kr.kro.photoliner.global.auth; + +import java.util.Objects; +import kr.kro.photoliner.domain.user.model.User; +import kr.kro.photoliner.domain.user.repository.UserRepository; +import kr.kro.photoliner.global.code.ApiResponseCode; +import kr.kro.photoliner.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +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; + +@Component +@RequiredArgsConstructor +public class UserArgumentResolver implements HandlerMethodArgumentResolver { + private final UserRepository userRepository; + private final JwtProvider jwtProvider; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Auth.class); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + Auth authAt = parameter.getParameterAnnotation(Auth.class); + Objects.requireNonNull(authAt); + + String token = jwtProvider.extractAccessToken(webRequest); + System.out.println("resolver: " + token); + jwtProvider.validateToken(token); + Long userId = jwtProvider.getUserId(token); + + User user = userRepository.findUserById(userId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_USER, "user id: " + userId)); + + return user.getId(); + } +} diff --git a/src/main/java/kr/kro/photoliner/global/code/ApiResponseCode.java b/src/main/java/kr/kro/photoliner/global/code/ApiResponseCode.java index f783982..11ee644 100644 --- a/src/main/java/kr/kro/photoliner/global/code/ApiResponseCode.java +++ b/src/main/java/kr/kro/photoliner/global/code/ApiResponseCode.java @@ -29,6 +29,8 @@ public enum ApiResponseCode { * 401 Unauthorized (인증 필요) */ WITHDRAWN_USER(HttpStatus.UNAUTHORIZED, "탈퇴한 계정입니다."), + EXPIRED_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다."), + INVALID_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰 형식입니다."), /** * 403 Forbidden (인가 필요) @@ -63,7 +65,8 @@ public enum ApiResponseCode { FILE_STORE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 저장 중 오류가 발생했습니다."), FILE_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 생성 중 오류가 발생했습니다."), DIRECTORY_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "폴더 생성 중 오류가 발생했습니다."), - FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제 중 오류가 발생했습니다."); + FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제 중 오류가 발생했습니다."), + TOKEN_PARSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "토큰 파싱 중 오류가 발생했습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/kr/kro/photoliner/global/config/WebMvcConfig.java b/src/main/java/kr/kro/photoliner/global/config/WebMvcConfig.java index f75222f..efab5a9 100644 --- a/src/main/java/kr/kro/photoliner/global/config/WebMvcConfig.java +++ b/src/main/java/kr/kro/photoliner/global/config/WebMvcConfig.java @@ -1,8 +1,11 @@ package kr.kro.photoliner.global.config; +import java.util.List; +import kr.kro.photoliner.global.auth.UserArgumentResolver; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -18,6 +21,8 @@ public class WebMvcConfig implements WebMvcConfigurer { @Value("${photo.upload.base-dir}") private String baseDir; + private final UserArgumentResolver userArgumentResolver; + @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") @@ -36,4 +41,9 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { .resourceChain(true) .addResolver(new PathResourceResolver()); } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(userArgumentResolver); + } } diff --git a/src/main/java/kr/kro/photoliner/global/kakao/login/client/KakaoAuthClient.java b/src/main/java/kr/kro/photoliner/global/kakao/login/client/KakaoAuthClient.java new file mode 100644 index 0000000..8569770 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/global/kakao/login/client/KakaoAuthClient.java @@ -0,0 +1,51 @@ +package kr.kro.photoliner.global.kakao.login.client; + +import java.nio.charset.StandardCharsets; +import kr.kro.photoliner.global.kakao.login.constant.KakaoApiUrlConstant; +import kr.kro.photoliner.global.kakao.login.dto.request.KakaoOauthTokenRequest; +import kr.kro.photoliner.global.kakao.login.dto.response.KakaoOauthTokenResponse; +import kr.kro.photoliner.global.kakao.login.dto.response.KakaoProfileResponse; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +public class KakaoAuthClient { + private static final String BEARER_TOKEN_PREFIX = "Bearer "; + + public KakaoOauthTokenResponse getOauthToken(KakaoOauthTokenRequest request) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("grant_type", request.grantType()); + formData.add("client_id", request.clientId()); + formData.add("redirect_uri", request.redirectUri()); + formData.add("code", request.code()); + formData.add("client_secret", request.clientSecret()); + + return getWebClient(KakaoApiUrlConstant.OAUTH_TOKEN) + .post() + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .acceptCharset(StandardCharsets.UTF_8) + .body(BodyInserters.fromFormData(formData)) + .retrieve() + .bodyToMono(KakaoOauthTokenResponse.class) + .block(); + } + + public KakaoProfileResponse getKakaoUserProfile(String accessToken) { + return getWebClient(KakaoApiUrlConstant.GET_USER_PROFILE) + .get() + .header("Authorization", BEARER_TOKEN_PREFIX + accessToken) + .retrieve() + .bodyToMono(KakaoProfileResponse.class) + .block(); + } + + private WebClient getWebClient(String baseUrl) { + return WebClient.builder() + .baseUrl(baseUrl) + .build(); + } +} diff --git a/src/main/java/kr/kro/photoliner/global/kakao/login/constant/KakaoApiUrlConstant.java b/src/main/java/kr/kro/photoliner/global/kakao/login/constant/KakaoApiUrlConstant.java new file mode 100644 index 0000000..bae4c7e --- /dev/null +++ b/src/main/java/kr/kro/photoliner/global/kakao/login/constant/KakaoApiUrlConstant.java @@ -0,0 +1,7 @@ +package kr.kro.photoliner.global.kakao.login.constant; + +public final class KakaoApiUrlConstant { + public static final String AUTHORIZATION_REDIRECT = "https://kauth.kakao.com/oauth/authorize"; + public static final String OAUTH_TOKEN = "https://kauth.kakao.com/oauth/token"; + public static final String GET_USER_PROFILE = "https://kapi.kakao.com/v2/user/me"; +} diff --git a/src/main/java/kr/kro/photoliner/global/kakao/login/dto/request/KakaoOauthTokenRequest.java b/src/main/java/kr/kro/photoliner/global/kakao/login/dto/request/KakaoOauthTokenRequest.java new file mode 100644 index 0000000..f7d2588 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/global/kakao/login/dto/request/KakaoOauthTokenRequest.java @@ -0,0 +1,14 @@ +package kr.kro.photoliner.global.kakao.login.dto.request; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(SnakeCaseStrategy.class) +public record KakaoOauthTokenRequest( + String grantType, + String clientId, + String redirectUri, + String code, + String clientSecret +) { +} diff --git a/src/main/java/kr/kro/photoliner/global/kakao/login/dto/response/KakaoOauthTokenResponse.java b/src/main/java/kr/kro/photoliner/global/kakao/login/dto/response/KakaoOauthTokenResponse.java new file mode 100644 index 0000000..e8ffbf0 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/global/kakao/login/dto/response/KakaoOauthTokenResponse.java @@ -0,0 +1,15 @@ +package kr.kro.photoliner.global.kakao.login.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(SnakeCaseStrategy.class) +public record KakaoOauthTokenResponse( + String tokenType, + String accessToken, + String idToken, + Integer expiresIn, + String refreshToken, + String scope +) { +} diff --git a/src/main/java/kr/kro/photoliner/global/kakao/login/dto/response/KakaoProfileResponse.java b/src/main/java/kr/kro/photoliner/global/kakao/login/dto/response/KakaoProfileResponse.java new file mode 100644 index 0000000..883620a --- /dev/null +++ b/src/main/java/kr/kro/photoliner/global/kakao/login/dto/response/KakaoProfileResponse.java @@ -0,0 +1,27 @@ +package kr.kro.photoliner.global.kakao.login.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(SnakeCaseStrategy.class) +public record KakaoProfileResponse( + Long id, + InnerKakaoAccount kakaoAccount +) { + public record InnerKakaoAccount( + InnerKakaoProfile profile, + String name, + Boolean isEmailValid, + Boolean isEmailVerified, + String email + ) { + public record InnerKakaoProfile( + String nickname, + String thumbnailImageUrl, + String profileImageUrl, + Boolean isDefaultImage + ) { + + } + } +} diff --git a/src/main/java/kr/kro/photoliner/global/kakao/login/service/KakaoAuthService.java b/src/main/java/kr/kro/photoliner/global/kakao/login/service/KakaoAuthService.java new file mode 100644 index 0000000..e5149c1 --- /dev/null +++ b/src/main/java/kr/kro/photoliner/global/kakao/login/service/KakaoAuthService.java @@ -0,0 +1,50 @@ +package kr.kro.photoliner.global.kakao.login.service; + +import kr.kro.photoliner.global.kakao.login.client.KakaoAuthClient; +import kr.kro.photoliner.global.kakao.login.constant.KakaoApiUrlConstant; +import kr.kro.photoliner.global.kakao.login.dto.request.KakaoOauthTokenRequest; +import kr.kro.photoliner.global.kakao.login.dto.response.KakaoOauthTokenResponse; +import kr.kro.photoliner.global.kakao.login.dto.response.KakaoProfileResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class KakaoAuthService { + private final static String DEFAULT_GRANT_TYPE = "authorization_code"; + private final KakaoAuthClient kakaoAuthClient; + + @Value("${kakao.api.rest-api-key}") + private String restApiKey; + @Value("${kakao.api.login-oauth.redirect-url}") + private String redirectUri; + + public String getAuthorizationRedirectUrl() { + return createAuthorizationRedirectUri(restApiKey, redirectUri); + } + + public KakaoOauthTokenResponse getTokenByAuthorizationCode(String authorizationCode) { + return kakaoAuthClient.getOauthToken( + new KakaoOauthTokenRequest( + DEFAULT_GRANT_TYPE, + restApiKey, + redirectUri, + authorizationCode, + null + ) + ); + } + + public KakaoProfileResponse getKakaoUserProfile(String accessToken) { + return kakaoAuthClient + .getKakaoUserProfile(accessToken); + } + + private String createAuthorizationRedirectUri(String restApiKey, String redirectUrlWhenComplete) { + return KakaoApiUrlConstant.AUTHORIZATION_REDIRECT + + "?response_type=code" + + "&client_id=" + restApiKey + + "&redirect_uri=" + redirectUrlWhenComplete; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 8cefaea..00b665f 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -31,3 +31,14 @@ cloud: bucket: ${BUCKET_NAME} cdn: base-url: ${CDN_URL} + +kakao: + api: + rest-api-key: ${REST_API_KEY} + login-oauth: + redirect-url: ${REDIRECT_URL} + +jwt: + secret-key: ${SECRET_KEY} + access-token: + expiration-time: ${EXPIRATION_TIME} diff --git a/src/main/resources/application-local-example.yml b/src/main/resources/application-local-example.yml index a724c0a..7928171 100644 --- a/src/main/resources/application-local-example.yml +++ b/src/main/resources/application-local-example.yml @@ -18,4 +18,15 @@ cloud: s3: bucket: name cdn: - base-url: url \ No newline at end of file + base-url: url + +kakao: + api: + rest-api-key: rest api key + login-oauth: + redirect-url: http://localhost:8080/api/v1/login/kakao + +jwt: + secret-key: secret key + access-token: + expiration-time: time diff --git a/src/main/resources/db/migration/V9__alter_users_table.sql b/src/main/resources/db/migration/V9__alter_users_table.sql new file mode 100644 index 0000000..4496cb7 --- /dev/null +++ b/src/main/resources/db/migration/V9__alter_users_table.sql @@ -0,0 +1,2 @@ +alter table users + add column email varchar(30) not null