diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index 0d30638..215e602 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -72,7 +72,7 @@ jobs: - name: Deploy uses: appleboy/ssh-action@master with: - host: ${{ secrets.EC2_HOST }} + host: ${{ secrets.EC2_HOST_DEV }} username: ${{ secrets.EC2_USERNAME }} key: ${{ secrets.EC2_KEY }} script: | @@ -80,5 +80,5 @@ jobs: docker stop polabo-dev docker rm polabo-dev docker pull ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} - docker run -d -v /etc/localtime:/etc/localtime:ro -v /usr/share/zoneinfo/Asia/Seoul:/etc/timezone:ro -e ENVIRONMENT_VALUE=-Dspring.profiles.active=dev --name polabo-dev -p 8080:8080 --restart=always --network host ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + docker run -d -v /etc/localtime:/etc/localtime:ro -v /usr/share/zoneinfo/Asia/Seoul:/etc/timezone:ro -e ENVIRONMENT_VALUE=-Dspring.profiles.active=local --name polabo-dev -p 8080:8080 --restart=always --network host ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml new file mode 100644 index 0000000..78ea501 --- /dev/null +++ b/.github/workflows/cd-prod.yml @@ -0,0 +1,84 @@ +name: Java CI with Gradle + +on: + push: + branches: [ "main" ] + +jobs: + build: + ## checkout후 자바 21 버전으로 설정을 합니다 + runs-on: ubuntu-latest + env: + DB_URL: ${{ secrets.DB_URL }} + DB_USER: ${{ secrets.DB_USER }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + JASYPT_ENCRYPTOR_PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }} + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/gradle-build-action@v2 + + - name: Increase Gradle memory settings + run: | + echo "org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8" >> ~/.gradle/gradle.properties + echo "kotlin.daemon.jvmargs=-Xmx4g" >> ~/.gradle/gradle.properties + + ## gradlew 의 권한을 줍니다. + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + ## gradle build + - name: Build with Gradle + run: ./gradlew clean build -x test + + ## 이미지 태그에 시간 설정을 하기위해서 현재 시간을 가져옵니다. + - name: Get current time + uses: 1466587594/get-current-time@v2 + id: current-time + with: + format: YYYY-MM-DDTHH-mm-ss + utcOffset: "+09:00" + + - name: Show Current Time + run: echo "CurrentTime=${{steps.current-time.outputs.formattedTime}}" + ## AWS에 로그인. aws-region은 서울로 설정(ap-northeast-2) + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + ## ECR에 로그인 + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + ## sample라는 ECR 리파지터리에 현재 시간 태그를 생성하고, 푸쉬 + ## 앞의 스탭에서 ${{steps.current-time.outputs.formattedTime}}로 현재 시간을 가져옵니다. + - name: Build, tag, and push image to Amazon ECR + run: | + docker build --build-arg PASSWORD=$PASSWORD -t polabo:${{steps.current-time.outputs.formattedTime}} . + docker tag polabo:${{steps.current-time.outputs.formattedTime}} ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + docker push ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + env: + PASSWORD: ${{ secrets.JASYPT_ENCRYPTOR_PASSWORD }} + + - name: Deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_KEY }} + script: | + aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${{ steps.login-ecr.outputs.registry }}/${{ secrets.ECR_REPOSITORY }} + docker stop polabo-dev + docker rm polabo-dev + docker pull ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + docker run -d -v /etc/localtime:/etc/localtime:ro -v /usr/share/zoneinfo/Asia/Seoul:/etc/timezone:ro -e ENVIRONMENT_VALUE=-Dspring.profiles.active=dev --name polabo-dev -p 8080:8080 --restart=always --network host ${{ secrets.ECR_REGISTRY }}/polabo:${{steps.current-time.outputs.formattedTime}} + diff --git a/build.gradle.kts b/build.gradle.kts index 5ce6603..2058318 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -91,6 +91,10 @@ dependencies { implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5") implementation("org.springframework.boot:spring-boot-starter-security") implementation("io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64") + + implementation("io.jsonwebtoken:jjwt-api:0.11.2") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2") } tasks.withType { diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/BoardController.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/BoardController.kt index 31774de..e36c9a9 100644 --- a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/BoardController.kt +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/BoardController.kt @@ -3,9 +3,12 @@ package com.ddd.sonnypolabobe.domain.board.controller import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardCreateRequest import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardGetResponse import com.ddd.sonnypolabobe.domain.board.service.BoardService +import com.ddd.sonnypolabobe.domain.user.dto.UserDto import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import com.ddd.sonnypolabobe.logger import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -22,10 +25,16 @@ class BoardController( @Operation(summary = "보드 생성", description = """ 보드를 생성합니다. userId는 추후 회원가입 기능이 추가될 것을 대비한 것입니다. 지금은 null로 주세요. + + userId 타입을 UUID -> Long으로 변경하였습니다. - 2024.08.03 """) @PostMapping fun create(@RequestBody request : BoardCreateRequest) - = ApplicationResponse.ok(this.boardService.create(request)) + = run { + val user = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + request.userId = user.id + ApplicationResponse.ok(this.boardService.create(request)) + } @Operation(summary = "보드 조회", description = """ 보드를 조회합니다. diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/dto/BoardCreateRequest.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/dto/BoardCreateRequest.kt index e852b3d..422042b 100644 --- a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/dto/BoardCreateRequest.kt +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/controller/dto/BoardCreateRequest.kt @@ -11,5 +11,5 @@ data class BoardCreateRequest( @field:Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*()_+=-])(?=.*[ㄱ-ㅎㅏ-ㅣ가-힣]).{1,20}$", message = "제목은 국문, 영문, 숫자, 특수문자, 띄어쓰기를 포함한 20자 이내여야 합니다.") val title: String, @Schema(description = "작성자 아이디", example = "null", required = false) - val userId: UUID? = null + var userId: Long? = null ) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/controller/MyBoardController.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/controller/MyBoardController.kt new file mode 100644 index 0000000..89b06a6 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/controller/MyBoardController.kt @@ -0,0 +1,71 @@ +package com.ddd.sonnypolabobe.domain.board.my.controller + +import com.ddd.sonnypolabobe.domain.board.my.dto.MyBoardDto +import com.ddd.sonnypolabobe.domain.board.my.service.MyBoardService +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.global.entity.PageDto +import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import com.ddd.sonnypolabobe.global.util.DateConverter.convertToKst +import io.swagger.v3.oas.annotations.Operation +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.annotation.* +import java.time.LocalDateTime + +@RestController +@RequestMapping("/api/v1/my/boards") +class MyBoardController(private val myBoardService : MyBoardService) { + + @Operation(summary = "내 보드 목록 조회", description = """ + 내 보드 목록을 조회합니다. + """) + @GetMapping + fun getMyBoards( + @RequestParam page : Int, + @RequestParam size : Int + ) = ApplicationResponse.ok( + PageDto( + content = listOf( + MyBoardDto.Companion.PageListRes( + id = 1L, + title = "보드 제목", + createdAt = convertToKst(LocalDateTime.now()), + totalCount = 1L + ) + ), + page = page, + size = size, + total = 10 + ) + ) + + @Operation( + summary = "내 보드 이름 수정", + description = """ + 내 보드 이름을 수정합니다. + """ + ) + @PutMapping("/{id}") + fun updateMyBoard( + @PathVariable id : String, + @RequestBody request : MyBoardDto.Companion.MBUpdateReq + ) = run { + val userId = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + this.myBoardService.updateMyBoard(id, request, userId.id) + ApplicationResponse.ok() + } + + @Operation( + summary = "내 보드 삭제", + description = """ + 내 보드를 삭제합니다. + """ + ) + @DeleteMapping("/{id}") + fun deleteMyBoard( + @PathVariable id : String + ) = run { + val userId = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + this.myBoardService.deleteMyBoard(id, userId.id) + ApplicationResponse.ok() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/dto/MyBoardDto.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/dto/MyBoardDto.kt new file mode 100644 index 0000000..e4ec7d6 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/dto/MyBoardDto.kt @@ -0,0 +1,32 @@ +package com.ddd.sonnypolabobe.domain.board.my.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import org.springframework.format.annotation.DateTimeFormat +import java.time.LocalDateTime +import java.util.UUID + +class MyBoardDto { + companion object { + data class MBUpdateReq( + @JsonProperty("title") + val title: String + ) + + data class PageListRes( + val id: Long, + val title: String, + @DateTimeFormat(pattern = "yyyy-MM-dd", iso = DateTimeFormat.ISO.DATE) + val createdAt: LocalDateTime, + val totalCount: Long + ) + + data class GetOneRes( + val id: UUID, + val title: String, + @DateTimeFormat(pattern = "yyyy-MM-dd", iso = DateTimeFormat.ISO.DATE) + val createdAt: LocalDateTime, + val userId : Long? + ) + + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/service/MyBoardService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/service/MyBoardService.kt new file mode 100644 index 0000000..bdbc151 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/my/service/MyBoardService.kt @@ -0,0 +1,27 @@ +package com.ddd.sonnypolabobe.domain.board.my.service + +import com.ddd.sonnypolabobe.domain.board.my.dto.MyBoardDto +import com.ddd.sonnypolabobe.domain.board.repository.BoardJooqRepository +import com.ddd.sonnypolabobe.global.util.UuidConverter +import org.springframework.stereotype.Service + +@Service +class MyBoardService(private val boardJooqRepository: BoardJooqRepository) { + fun updateMyBoard(id: String, request: MyBoardDto.Companion.MBUpdateReq, userId: Long) { + val board = this.boardJooqRepository.findById(UuidConverter.stringToUUID(id)) + ?: throw IllegalArgumentException("해당 보드가 존재하지 않습니다.") + if (board.userId != userId) { + throw IllegalArgumentException("해당 보드에 대한 권한이 없습니다.") + } + this.boardJooqRepository.updateTitle(UuidConverter.stringToUUID(id), request.title) + } + + fun deleteMyBoard(id: String, userId: Long) { + val board = this.boardJooqRepository.findById(UuidConverter.stringToUUID(id)) + ?: throw IllegalArgumentException("해당 보드가 존재하지 않습니다.") + if (board.userId != userId) { + throw IllegalArgumentException("해당 보드에 대한 권한이 없습니다.") + } + this.boardJooqRepository.delete(UuidConverter.stringToUUID(id)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepository.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepository.kt index c927452..05082ad 100644 --- a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepository.kt +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepository.kt @@ -1,13 +1,18 @@ package com.ddd.sonnypolabobe.domain.board.repository import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardCreateRequest +import com.ddd.sonnypolabobe.domain.board.my.dto.MyBoardDto +import com.ddd.sonnypolabobe.jooq.polabo.tables.Board import org.jooq.Record6 import java.time.LocalDateTime import java.util.* interface BoardJooqRepository { fun insertOne(request: BoardCreateRequest): ByteArray? - fun selectOneById(id: UUID) : Array> + fun selectOneById(id: UUID) : Array> fun selectTotalCount(): Long fun selectTodayTotalCount(): Long + fun findById(id: UUID): MyBoardDto.Companion.GetOneRes? + fun updateTitle(id: UUID, title: String) + fun delete(id: UUID) } \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepositoryImpl.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepositoryImpl.kt index beb7cc1..31a0095 100644 --- a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepositoryImpl.kt +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/repository/BoardJooqRepositoryImpl.kt @@ -2,6 +2,7 @@ package com.ddd.sonnypolabobe.domain.board.repository import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardCreateRequest import com.ddd.sonnypolabobe.domain.board.controller.dto.BoardGetResponse +import com.ddd.sonnypolabobe.domain.board.my.dto.MyBoardDto import com.ddd.sonnypolabobe.global.util.DateConverter import com.ddd.sonnypolabobe.global.util.UuidConverter import com.ddd.sonnypolabobe.global.util.UuidGenerator @@ -34,7 +35,7 @@ class BoardJooqRepositoryImpl( return if (result == 1) id else null } - override fun selectOneById(id: UUID): Array> { + override fun selectOneById(id: UUID): Array> { val jBoard = Board.BOARD val jPolaroid = Polaroid.POLAROID return this.dslContext @@ -82,4 +83,34 @@ class BoardJooqRepositoryImpl( )) .fetchOne(0, Long::class.java) ?: 0L } + + override fun findById(id: UUID): MyBoardDto.Companion.GetOneRes? { + val jBoard = Board.BOARD + return this.dslContext.selectFrom(jBoard) + .where(jBoard.ID.eq(UuidConverter.uuidToByteArray(id))) + .fetchOne()?.map { + MyBoardDto.Companion.GetOneRes( + id = UuidConverter.byteArrayToUUID(it.get("id", ByteArray::class.java)!!), + title = it.get("title", String::class.java)!!, + createdAt = it.get("created_at", LocalDateTime::class.java)!!, + userId = it.get("user_id", Long::class.java), + ) + } + } + + override fun updateTitle(id: UUID, title: String) { + val jBoard = Board.BOARD + this.dslContext.update(jBoard) + .set(jBoard.TITLE, title) + .where(jBoard.ID.eq(UuidConverter.uuidToByteArray(id))) + .execute() + } + + override fun delete(id: UUID) { + val jBoard = Board.BOARD + this.dslContext.update(jBoard) + .set(jBoard.YN, 0) + .where(jBoard.ID.eq(UuidConverter.uuidToByteArray(id))) + .execute() + } } \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/service/BoardService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/service/BoardService.kt index 2ab0a5f..feef5f2 100644 --- a/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/service/BoardService.kt +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/board/service/BoardService.kt @@ -32,7 +32,7 @@ class BoardService( id = it.value2() ?: 0L, imageUrl = it.value3()?.let { it1 -> s3Util.getImgUrl(it1) } ?: "", oneLineMessage = it.value4() ?: "폴라보와의 추억 한 줄", - userId = it.value6()?.let { it1 -> UuidConverter.byteArrayToUUID(it1) } + userId = it.value6() ?: 0L ) }.filter { it.id != 0L } BoardGetResponse(title = title ?: "", items = polaroids) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/controller/OauthController.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/controller/OauthController.kt new file mode 100644 index 0000000..252876b --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/controller/OauthController.kt @@ -0,0 +1,27 @@ +package com.ddd.sonnypolabobe.domain.oauth.controller + +import com.ddd.sonnypolabobe.domain.oauth.service.OauthService +import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import io.swagger.v3.oas.annotations.Operation +import org.springframework.http.HttpHeaders +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/oauth") +class OauthController(private val oauthService: OauthService) { + + @Operation(summary = "카카오 소셜 로그인", description = """ + 카카오 소셜 로그인을 진행합니다. + 인가 코드를 파라미터로 넘겨주세요. + """) + @GetMapping("/sign-in") + fun login(@RequestParam(name = "code") code : String) = ApplicationResponse.ok(this.oauthService.signIn(code)) + + @PutMapping("/re-issue") + fun reIssue( + @RequestHeader(name = "Authorization", required = true) header: String + ) = run { + ApplicationResponse.ok(this.oauthService.reIssue(header)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/service/OauthService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/service/OauthService.kt new file mode 100644 index 0000000..ee770a5 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/oauth/service/OauthService.kt @@ -0,0 +1,157 @@ +package com.ddd.sonnypolabobe.domain.oauth.service + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.domain.user.repository.UserJooqRepository +import com.ddd.sonnypolabobe.domain.user.token.dto.UserTokenDto +import com.ddd.sonnypolabobe.domain.user.token.repository.UserTokenJooqRepository +import com.ddd.sonnypolabobe.global.security.JwtUtil +import com.ddd.sonnypolabobe.global.security.KakaoDto +import com.ddd.sonnypolabobe.global.util.DateConverter.dateToLocalDateTime +import com.ddd.sonnypolabobe.global.util.WebClientUtil +import com.ddd.sonnypolabobe.logger +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.reactive.function.BodyInserters + + +@Service +class OauthService( + private val webClient : WebClientUtil, + private val objectMapper: ObjectMapper, + @Value("\${spring.security.oauth2.client.registration.kakao.client-id}") private val clientId : String, + @Value("\${spring.security.oauth2.client.registration.kakao.redirect-uri}") private val redirectUri : String, + @Value("\${spring.security.oauth2.client.registration.kakao.client-secret}") private val clientSecret : String, + private val userRepository : UserJooqRepository, + private val jwtUtil: JwtUtil, + private val userTokenRepository: UserTokenJooqRepository + ) { + + fun getAccessToken(code: String) : KakaoDto.Companion.Token { + + //요청 본문 + val params: MultiValueMap = LinkedMultiValueMap() + params.add("grant_type", "authorization_code") + params.add("client_id", clientId) + params.add("redirect_uri", redirectUri) + params.add("code", code) + params.add("client_secret", clientSecret) + + logger().error("params : $params") + // 요청 보내기 및 응답 수신 + val response = webClient.create("https://kauth.kakao.com") + .post() + .uri("/oauth/token") + .header("Content-type", "application/x-www-form-urlencoded") + .body(BodyInserters.fromFormData(params)) + .retrieve() + .bodyToMono(String::class.java) + .block() + + + return try { + this.objectMapper.readValue(response, KakaoDto.Companion.Token::class.java) + } catch (e: Exception) { + throw RuntimeException(e) + } + } + + fun getKakaoInfo(accessToken: String): KakaoDto.Companion.UserInfo { + + // 요청 보내기 및 응답 수신 + val response = webClient.create("https://kapi.kakao.com") + .post() + .uri("/v2/user/me") + .header("Content-type", "application/x-www-form-urlencoded") + .header("Authorization", "Bearer $accessToken") + .retrieve() + .bodyToMono(String::class.java) + .block() + + return try { + this.objectMapper.readValue(response, KakaoDto.Companion.UserInfo::class.java) + } catch (e: Exception) { + throw RuntimeException(e) + } + } + + @Transactional + fun signIn(code: String): UserDto.Companion.TokenRes { + val token = getAccessToken(code) + val userInfo = getKakaoInfo(token.access_token) + + // DB에 저장 + val request = UserDto.Companion.CreateReq( + nickName = userInfo.kakao_account.profile.nickname, + email = userInfo.kakao_account.email + ) + + this.userRepository.findByEmail(request.email)?.let { + val tokenRequest = UserDto.Companion.CreateTokenReq( + id = it.id, + email = it.email, + nickName = it.nickName + ) + + val tokenRes = this.jwtUtil.generateAccessToken(tokenRequest) + + val userToken = UserTokenDto( + userId = it.id, + accessToken = tokenRes.accessToken, + expiredAt = dateToLocalDateTime(tokenRes.expiredDate) + ) + + this.userTokenRepository.updateByUserId(userToken) + return tokenRes + } ?: run { + val userId = this.userRepository.insertOne(request) + + // 토큰 생성 + val tokenRequest = UserDto.Companion.CreateTokenReq( + id = userId, + email = userInfo.kakao_account.email, + nickName = userInfo.kakao_account.profile.nickname + ) + + val tokenRes = this.jwtUtil.generateAccessToken(tokenRequest) + + val userToken = UserTokenDto( + userId = userId, + accessToken = tokenRes.accessToken, + expiredAt = dateToLocalDateTime(tokenRes.expiredDate) + ) + + this.userTokenRepository.insertOne(userToken) + return tokenRes + } + } + + fun reIssue(token: String?): UserDto.Companion.TokenRes{ + val tokenFromDB = token?.let { + val slicedToken = if(it.startsWith("Bearer ")) it.substring(7) else it + this.userTokenRepository.findByAccessToken(slicedToken) + } ?: throw RuntimeException("Token Not Found") + val user = this.userRepository.findById(tokenFromDB.userId) ?: throw RuntimeException("User Not Found") + + // 토큰 생성 + val tokenRequest = UserDto.Companion.CreateTokenReq( + id = user.id, + email = user.email, + nickName = user.nickName + ) + + val tokenRes = this.jwtUtil.generateAccessToken(tokenRequest) + + val userToken = UserTokenDto( + userId = user.id, + accessToken = tokenRes.accessToken, + expiredAt = dateToLocalDateTime(tokenRes.expiredDate) + ) + + this.userTokenRepository.updateByUserId(userToken) + return tokenRes + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/dto/PolaroidGetResponse.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/dto/PolaroidGetResponse.kt index 0b843b7..092882c 100644 --- a/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/dto/PolaroidGetResponse.kt +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/controller/dto/PolaroidGetResponse.kt @@ -11,5 +11,5 @@ data class PolaroidGetResponse( @Schema(description = "한 줄 문구", example = "한 줄 메시지입니다.") val oneLineMessage: String, @Schema(description = "작성자 ID", example = "userId") - val userId: UUID?, + val userId: Long?, ) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/service/PolaroidService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/service/PolaroidService.kt index 95bd0ec..01e540b 100644 --- a/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/service/PolaroidService.kt +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/polaroid/service/PolaroidService.kt @@ -24,7 +24,7 @@ class PolaroidService(private val polaroidJooqRepository: PolaroidJooqRepository id = it.id!!, imageUrl = s3Util.getImgUrl(it.imageKey!!), oneLineMessage = it.oneLineMessage ?: "", - userId = it.userId?.let { it1 -> UuidConverter.byteArrayToUUID(it1) } + userId = it.userId ) } diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/controller/UserController.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/controller/UserController.kt new file mode 100644 index 0000000..c753b06 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/controller/UserController.kt @@ -0,0 +1,55 @@ +package com.ddd.sonnypolabobe.domain.user.controller + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.domain.user.service.UserService +import com.ddd.sonnypolabobe.global.response.ApplicationResponse +import com.ddd.sonnypolabobe.global.util.DateConverter +import io.swagger.v3.oas.annotations.Operation +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDateTime + +@RestController +@RequestMapping("/api/v1/user") +class UserController( + private val userService: UserService +) { + + @Operation(summary = "닉네임 변경", description = """ + 닉네임을 변경합니다. + """) + @PutMapping("/nickname") + fun updateNickname(@RequestBody request: UserDto.Companion.UpdateReq) + = run { + val userInfoFromToken = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + this.userService.updateProfile(request, userInfoFromToken.id) + ApplicationResponse.ok() + } + + @Operation(summary = "프로필 조회", description = """ + 프로필을 조회합니다. + """) + @GetMapping("/profile") + fun getProfile() = run { + val userInfoFromToken = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + ApplicationResponse.ok(this.userService.findById(userInfoFromToken.id)) + } + + @Operation(summary = "회원 탈퇴", description = """ + 회원 탈퇴를 진행합니다. + 탈퇴 사유를 입력해주세요. + 사유가 '기타'인 경우에만 reason 필드를 채워주세요. + """) + @PutMapping("/withdraw") + fun withdraw(@RequestBody request: UserDto.Companion.WithdrawReq) = run { + val userInfoFromToken = SecurityContextHolder.getContext().authentication.principal as UserDto.Companion.Res + this.userService.withdraw(request, userInfoFromToken.id) + ApplicationResponse.ok() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/UserDto.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/UserDto.kt new file mode 100644 index 0000000..3921ee1 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/UserDto.kt @@ -0,0 +1,70 @@ +package com.ddd.sonnypolabobe.domain.user.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import java.time.LocalDateTime +import java.util.Date +import java.util.stream.Collectors + +class UserDto { + companion object { + data class CreateReq( + val email: String, + val nickName: String + ) + + data class UpdateReq( + @JsonProperty("nickName") + val nickName : String + ) + + data class CreateTokenReq( + val id : Long, + val email: String, + val nickName: String + ) + + data class TokenRes( + val accessToken: String, + val expiredDate: Date + ) + + data class ProfileRes( + val id: Long, + val email: String, + val nickName: String, + val createdAt: LocalDateTime, + ) + + data class WithdrawReq( + val type: WithdrawType, + val reason : String? + ) + + data class Res( + val id: Long, + val email: String, + val nickName: String, + val yn: Boolean, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime? + ) : UserDetails { + override fun getAuthorities(): MutableCollection { + val roles = mutableListOf("ROLE_USER") + return roles.stream() + .map { role -> SimpleGrantedAuthority(role) } + .collect(Collectors.toList()) + } + + override fun getPassword(): String { + return "" + } + + override fun getUsername(): String { + return id.toString() + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/WithdrawType.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/WithdrawType.kt new file mode 100644 index 0000000..2dbbeca --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/dto/WithdrawType.kt @@ -0,0 +1,9 @@ +package com.ddd.sonnypolabobe.domain.user.dto + +enum class WithdrawType(val description : String) { + NOT_USE("더이상 사용하지 않아요"), + WORRY_ABOUT_PERSONAL_INFO("개인정보 우려"), + DROP_MY_DATA("내 데이터 삭제"), + WANT_TO_NEW_ACCOUNT("새로운 계정을 만들고 싶어요"), + OTHER("기타") +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepository.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepository.kt new file mode 100644 index 0000000..0a7199e --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepository.kt @@ -0,0 +1,11 @@ +package com.ddd.sonnypolabobe.domain.user.repository + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto + +interface UserJooqRepository { + fun insertOne(request: UserDto.Companion.CreateReq): Long + + fun findById(id: Long): UserDto.Companion.Res? + fun findByEmail(email: String): UserDto.Companion.Res? + fun updateProfile(request: UserDto.Companion.UpdateReq, userId: Long) +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepositoryImpl.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepositoryImpl.kt new file mode 100644 index 0000000..1d65641 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/UserJooqRepositoryImpl.kt @@ -0,0 +1,80 @@ +package com.ddd.sonnypolabobe.domain.user.repository + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.global.util.DateConverter +import com.ddd.sonnypolabobe.jooq.polabo.tables.User +import org.jooq.DSLContext +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class UserJooqRepositoryImpl(private val dslContext: DSLContext) : UserJooqRepository{ + override fun insertOne(request: UserDto.Companion.CreateReq): Long { + val jUser = User.USER + val insertValue = jUser.newRecord().apply { + this.email = request.email + this.nickName = request.nickName + this.createdAt = DateConverter.convertToKst(LocalDateTime.now()) + this.yn = 1 + } + return this.dslContext.insertInto(jUser, + jUser.EMAIL, + jUser.NICK_NAME, + jUser.CREATED_AT, + jUser.YN + ) + .values( + insertValue.email, + insertValue.nickName, + insertValue.createdAt, + insertValue.yn + ) + .returningResult(jUser.ID) + .fetchOne(0, Long::class.java) ?: 0 + } + + override fun findById(id: Long): UserDto.Companion.Res? { + val jUser = User.USER + val record = this.dslContext.selectFrom(jUser) + .where(jUser.ID.eq(id)) + .fetchOne() + + return record?.let { + UserDto.Companion.Res( + id = it.id!!, + email = it.email!!, + nickName = it.nickName!!, + yn = it.yn?.toInt() == 1, + createdAt = it.createdAt!!, + updatedAt = it.updatedAt + ) + } + } + + override fun findByEmail(email: String): UserDto.Companion.Res? { + val jUser = User.USER + val record = this.dslContext.selectFrom(jUser) + .where(jUser.EMAIL.eq(email)) + .fetchOne() + + return record?.let { + UserDto.Companion.Res( + id = it.id!!, + email = it.email!!, + nickName = it.nickName!!, + yn = it.yn?.toInt() == 1, + createdAt = it.createdAt!!, + updatedAt = it.updatedAt + ) + } + } + + override fun updateProfile(request: UserDto.Companion.UpdateReq, userId: Long) { + val jUser = User.USER + this.dslContext.update(jUser) + .set(jUser.NICK_NAME, request.nickName) + .set(jUser.UPDATED_AT, DateConverter.convertToKst(LocalDateTime.now())) + .where(jUser.ID.eq(userId)) + .execute() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepository.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepository.kt new file mode 100644 index 0000000..ec9b0be --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepository.kt @@ -0,0 +1,7 @@ +package com.ddd.sonnypolabobe.domain.user.repository + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto + +interface WithdrawJooqRepository { + fun insertOne(request: UserDto.Companion.WithdrawReq, userId: Long) +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepositoryImpl.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepositoryImpl.kt new file mode 100644 index 0000000..a5c50d4 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/repository/WithdrawJooqRepositoryImpl.kt @@ -0,0 +1,30 @@ +package com.ddd.sonnypolabobe.domain.user.repository + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.global.util.DateConverter +import com.ddd.sonnypolabobe.jooq.polabo.enums.WithdrawType +import com.ddd.sonnypolabobe.jooq.polabo.tables.Withdraw +import org.jooq.DSLContext +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class WithdrawJooqRepositoryImpl(private val dslContext: DSLContext): WithdrawJooqRepository { + override fun insertOne(request: UserDto.Companion.WithdrawReq, userId: Long) { + val jWithdraw = Withdraw.WITHDRAW + + this.dslContext.insertInto(jWithdraw, + jWithdraw.USER_ID, + jWithdraw.TYPE, + jWithdraw.REASON, + jWithdraw.CREATED_AT + ) + .values( + userId, + WithdrawType.valueOf(request.type.name), + request.reason, + DateConverter.convertToKst(LocalDateTime.now()) + ) + .execute() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/service/UserService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/service/UserService.kt new file mode 100644 index 0000000..5185e10 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/service/UserService.kt @@ -0,0 +1,33 @@ +package com.ddd.sonnypolabobe.domain.user.service + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.domain.user.repository.UserJooqRepository +import com.ddd.sonnypolabobe.domain.user.repository.WithdrawJooqRepository +import org.springframework.stereotype.Service + +@Service +class UserService( + private val userJooqRepository: UserJooqRepository, + private val withdrawJooqRepository: WithdrawJooqRepository +) { + fun updateProfile(request: UserDto.Companion.UpdateReq, userId: Long) { + this.userJooqRepository.updateProfile(request, userId) + } + + fun findById(id: Long): UserDto.Companion.ProfileRes { + return this.userJooqRepository.findById(id).let { + UserDto.Companion.ProfileRes( + id = it!!.id, + nickName = it.nickName, + email = it.email, + createdAt = it.createdAt + ) + } + } + + fun withdraw(request: UserDto.Companion.WithdrawReq, id: Long) { + this.withdrawJooqRepository.insertOne(request, id) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/dto/UserTokenDto.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/dto/UserTokenDto.kt new file mode 100644 index 0000000..a076783 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/dto/UserTokenDto.kt @@ -0,0 +1,9 @@ +package com.ddd.sonnypolabobe.domain.user.token.dto + +import java.time.LocalDateTime + +data class UserTokenDto( + val userId: Long, + val accessToken: String, + val expiredAt: LocalDateTime +) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepository.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepository.kt new file mode 100644 index 0000000..b240ef3 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepository.kt @@ -0,0 +1,9 @@ +package com.ddd.sonnypolabobe.domain.user.token.repository + +import com.ddd.sonnypolabobe.domain.user.token.dto.UserTokenDto + +interface UserTokenJooqRepository { + fun insertOne(userToken: UserTokenDto) + fun findByAccessToken(token: String): UserTokenDto? + fun updateByUserId(userToken: UserTokenDto) +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepositoryImpl.kt b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepositoryImpl.kt new file mode 100644 index 0000000..9713c2e --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/domain/user/token/repository/UserTokenJooqRepositoryImpl.kt @@ -0,0 +1,62 @@ +package com.ddd.sonnypolabobe.domain.user.token.repository + +import com.ddd.sonnypolabobe.domain.user.token.dto.UserTokenDto +import com.ddd.sonnypolabobe.global.util.DateConverter +import com.ddd.sonnypolabobe.jooq.polabo.tables.UserToken +import org.jooq.DSLContext +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class UserTokenJooqRepositoryImpl(private val dslContext: DSLContext) : UserTokenJooqRepository { + override fun insertOne(userToken: UserTokenDto) { + val jUserToken = UserToken.USER_TOKEN + val insertValue = jUserToken.newRecord().apply { + this.userId = userToken.userId + this.accessToken = userToken.accessToken + this.accessExpiredAt = userToken.expiredAt + this.createdAt = DateConverter.convertToKst(LocalDateTime.now()) + this.updatedAt = DateConverter.convertToKst(LocalDateTime.now()) + } + this.dslContext.insertInto(jUserToken, + jUserToken.USER_ID, + jUserToken.ACCESS_TOKEN, + jUserToken.ACCESS_EXPIRED_AT, + jUserToken.CREATED_AT, + jUserToken.UPDATED_AT + ) + .values( + insertValue.userId, + insertValue.accessToken, + insertValue.accessExpiredAt, + insertValue.createdAt, + insertValue.updatedAt + ) + .onDuplicateKeyUpdate() + .set(jUserToken.USER_ID, insertValue.userId) + .execute() + } + + override fun findByAccessToken(token: String): UserTokenDto? { + val jUserToken = UserToken.USER_TOKEN + return this.dslContext.selectFrom(jUserToken) + .where(jUserToken.ACCESS_TOKEN.eq(token)) + .fetchOne()?.map { + UserTokenDto( + userId = it.get(jUserToken.USER_ID)!!, + accessToken = it.get(jUserToken.ACCESS_TOKEN)!!, + expiredAt = it.get(jUserToken.ACCESS_EXPIRED_AT)!! + ) + } + } + + override fun updateByUserId(userToken: UserTokenDto) { + val jUserToken = UserToken.USER_TOKEN + this.dslContext.update(jUserToken) + .set(jUserToken.ACCESS_TOKEN, userToken.accessToken) + .set(jUserToken.ACCESS_EXPIRED_AT, userToken.expiredAt) + .set(jUserToken.UPDATED_AT, DateConverter.convertToKst(LocalDateTime.now())) + .where(jUserToken.USER_ID.eq(userToken.userId)) + .execute() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SecurityConfig.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SecurityConfig.kt index cd7c435..b2eff81 100644 --- a/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SecurityConfig.kt @@ -1,12 +1,15 @@ package com.ddd.sonnypolabobe.global.config +import com.ddd.sonnypolabobe.global.security.JwtAuthenticationFilter import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.web.util.matcher.AntPathRequestMatcher import org.springframework.security.web.util.matcher.RequestMatcher import org.springframework.web.cors.CorsConfiguration @@ -15,7 +18,9 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource @Configuration @EnableMethodSecurity -class SecurityConfig() { +class SecurityConfig( + private val jwtAuthenticationFilter: JwtAuthenticationFilter, +) { @Bean fun filterChain(http: HttpSecurity): SecurityFilterChain { @@ -28,8 +33,23 @@ class SecurityConfig() { it.disable() } .formLogin { it.disable() } + .sessionManagement { sessionManagementConfig -> + sessionManagementConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) .authorizeHttpRequests { - it.anyRequest().permitAll() + it.requestMatchers("/api/v1/boards/create-available").permitAll() + it.requestMatchers("/api/v1/boards/total-count").permitAll() + it.requestMatchers("/api/v1/file/**").permitAll() + it.requestMatchers("/api/v1/oauth/sign-in", "/api/v1/oauth/re-issue").permitAll() + it.requestMatchers("/health", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + it.requestMatchers("/api/v1/boards/{id}").permitAll() + it.anyRequest().authenticated() + } + .exceptionHandling{ + it.authenticationEntryPoint { _, response, _ -> + response.sendError(401) + } } .build() } diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SwaggerConfig.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SwaggerConfig.kt new file mode 100644 index 0000000..cb0bfc2 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/config/SwaggerConfig.kt @@ -0,0 +1,32 @@ +package com.ddd.sonnypolabobe.global.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + + +@Configuration +class SwaggerConfig { + + @Bean + fun openAPI(): OpenAPI { + val securityScheme: SecurityScheme = getSecurityScheme() + val securityRequirement: SecurityRequirement = getSecurityRequireMent() + + return OpenAPI() + .components(Components().addSecuritySchemes("bearerAuth", securityScheme)) + .security(listOf(securityRequirement)) + } + + private fun getSecurityScheme(): SecurityScheme { + return SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("Bearer").bearerFormat("JWT") + .`in`(SecurityScheme.In.HEADER).name("Authorization") + } + + private fun getSecurityRequireMent(): SecurityRequirement { + return SecurityRequirement().addList("bearerAuth") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/entity/PageDto.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/entity/PageDto.kt new file mode 100644 index 0000000..f4abbe5 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/entity/PageDto.kt @@ -0,0 +1,8 @@ +package com.ddd.sonnypolabobe.global.entity + +data class PageDto( + val page: Int, + val size: Int, + val total: Int, + val content: List +) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/CustomErrorCode.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/CustomErrorCode.kt index e7c7ef4..72e93dd 100644 --- a/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/CustomErrorCode.kt +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/exception/CustomErrorCode.kt @@ -12,6 +12,14 @@ enum class CustomErrorCode( POLAROID_NOT_FOUND(HttpStatus.NOT_FOUND, "POL001", "폴라로이드를 찾을 수 없습니다."), - POLAROID_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "POL002", "보드당 폴라로이드는 50개까지만 생성 가능합니다.") + POLAROID_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "POL002", "보드당 폴라로이드는 50개까지만 생성 가능합니다."), + + // jwt + JWT_INVALID(HttpStatus.UNAUTHORIZED, "JWT001", "유효하지 않은 토큰입니다."), + JWT_EXPIRED(HttpStatus.UNAUTHORIZED, "JWT002", "만료된 토큰입니다."), + JWT_MALFORMED(HttpStatus.UNAUTHORIZED, "JWT003", "잘못된 토큰입니다."), + JWT_UNSUPPORTED(HttpStatus.UNAUTHORIZED, "JWT004", "지원되지 않는 토큰입니다."), + JWT_ILLEGAL_ARGUMENT(HttpStatus.UNAUTHORIZED, "JWT005", "잘못된 인자입니다."), + JWT_SIGNATURE(HttpStatus.UNAUTHORIZED, "JWT006", "잘못된 서명입니다."), } \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/AuthenticatedMember.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/AuthenticatedMember.kt new file mode 100644 index 0000000..7600fd9 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/AuthenticatedMember.kt @@ -0,0 +1,10 @@ +package com.ddd.sonnypolabobe.global.security + +import java.util.* + +data class AuthenticatedMember( + val id: String, + val email: String, + val nickname: String, + val expiredAt: Date +) diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/CustomUserDetailsService.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/CustomUserDetailsService.kt new file mode 100644 index 0000000..18af849 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/CustomUserDetailsService.kt @@ -0,0 +1,18 @@ +package com.ddd.sonnypolabobe.global.security + +import com.ddd.sonnypolabobe.domain.user.repository.UserJooqRepository +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class CustomUserDetailsService( + private val userJooqRepository: UserJooqRepository +) : UserDetailsService { + override fun loadUserByUsername(id: String): UserDetails { + return userJooqRepository.findById(id.toLong()) + ?: throw IllegalArgumentException("해당하는 사용자를 찾을 수 없습니다.") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..0d884d6 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtAuthenticationFilter.kt @@ -0,0 +1,47 @@ +package com.ddd.sonnypolabobe.global.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class JwtAuthenticationFilter( + private val jwtUtil: JwtUtil, + private val customUserDetailsService: CustomUserDetailsService +) : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val authorizationHeader = request.getHeader("Authorization") + + //JWT가 헤더에 있는 경우 + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + if(request.requestURI.contains("/api/v1/oauth")) { + filterChain.doFilter(request, response) + return + } + //JWT 유효성 검증 + if (jwtUtil.validateToken(authorizationHeader)) { + val userId = jwtUtil.getAuthenticatedMemberFromToken(authorizationHeader).id + + //유저와 토큰 일치 시 userDetails 생성 + val userDetails = customUserDetailsService.loadUserByUsername(userId) + + //UserDetsils, Password, Role -> 접근권한 인증 Token 생성 + val usernamePasswordAuthenticationToken = + UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities) + + //현재 Request의 Security Context에 접근권한 설정 + SecurityContextHolder.getContext().authentication = + usernamePasswordAuthenticationToken + } + } + filterChain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtUtil.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtUtil.kt new file mode 100644 index 0000000..1b1d658 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/JwtUtil.kt @@ -0,0 +1,92 @@ +package com.ddd.sonnypolabobe.global.security + +import com.ddd.sonnypolabobe.domain.user.dto.UserDto +import com.ddd.sonnypolabobe.global.exception.ApplicationException +import com.ddd.sonnypolabobe.global.exception.CustomErrorCode +import com.ddd.sonnypolabobe.logger +import io.jsonwebtoken.* +import io.jsonwebtoken.security.Keys +import jakarta.xml.bind.DatatypeConverter +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.security.Key +import java.util.* + +@Component +class JwtUtil( + @Value("\${jwt.access-key}") + private val accessSecretKey: String, + @Value("\${jwt.validity.access-seconds}") + private val accessTokenExpirationMs: Long, +) { + + fun generateAccessToken(request: UserDto.Companion.CreateTokenReq): UserDto.Companion.TokenRes { + val now = Date() + val expiredDate = Date(now.time + accessTokenExpirationMs) + val claims: MutableMap = HashMap() + claims["CLAIM_KEY_ID"] = request.id.toString() + claims["CLAIM_EMAIL"] = request.email + claims["CLAIM_NICKNAME"] = request.nickName + val accessToken = Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(getKey(accessSecretKey), SignatureAlgorithm.HS512) + .compact() + return UserDto.Companion.TokenRes(accessToken, expiredDate) + } + + fun getAuthenticatedMemberFromToken(accessToken: String): AuthenticatedMember { + val claims = getClaimsFromAccessToken(subPrefix(accessToken), accessSecretKey) + val id = claims["CLAIM_KEY_ID"].toString() + val email = claims["CLAIM_EMAIL"].toString() + val nickname = claims["CLAIM_NICKNAME"].toString() + return AuthenticatedMember(id, email, nickname, claims.expiration) + } + + fun validateToken(accessToken: String): Boolean { + try { + val key = getKey(accessSecretKey) + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(subPrefix(accessToken)) + return true + } catch (e: Exception) { + logger().error("error : $e") + throw ApplicationException(CustomErrorCode.JWT_INVALID) + } + } + + fun getClaimsFromAccessToken(token: String, secretKey: String): Claims { + try { + val key = getKey(secretKey) + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).body + } catch (e: io.jsonwebtoken.security.SecurityException) { + throw ApplicationException(CustomErrorCode.JWT_SIGNATURE) + } catch (e: MalformedJwtException) { + throw ApplicationException(CustomErrorCode.JWT_MALFORMED) + } catch (e: ExpiredJwtException) { + throw ApplicationException(CustomErrorCode.JWT_EXPIRED) + } catch (e: UnsupportedJwtException) { + throw ApplicationException(CustomErrorCode.JWT_UNSUPPORTED) + } catch (e: IllegalArgumentException) { + throw ApplicationException(CustomErrorCode.JWT_ILLEGAL_ARGUMENT) + } + } + + private fun subPrefix(token: String): String { + return if (token.isNotEmpty() && token.startsWith("Bearer ")) { + token.substring(7) + } else { + token + } + } + + private fun getKeyBytes(secretKey: String): ByteArray { + return DatatypeConverter.parseBase64Binary((secretKey)) + } + + private fun getKey(secretKey: String): Key { + val keyBytes = Base64.getDecoder().decode(secretKey) + return Keys.hmacShaKeyFor(keyBytes) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/security/KakaoDto.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/KakaoDto.kt new file mode 100644 index 0000000..9ae1626 --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/security/KakaoDto.kt @@ -0,0 +1,43 @@ +package com.ddd.sonnypolabobe.global.security + +import java.util.* + +class KakaoDto { + + companion object { + data class Token( + val access_token: String, + val refresh_token: String, + val token_type: String, + val expires_in: Int, + val refresh_token_expires_in: Int, + val scope: String + ) + + data class UserInfo( + val id: Long, + val connected_at: String, + val properties: Map, + val kakao_account: KakaoAccount + ) + + data class KakaoAccount( + val profile_nickname_needs_agreement: Boolean, + val profile_image_needs_agreement: Boolean, + val profile: Profile, + val has_email: Boolean, + val email_needs_agreement: Boolean, + val is_email_valid: Boolean, + val is_email_verified: Boolean, + val email: String + ) + + data class Profile( + val nickname: String, + val thumbnail_image_url: String, + val profile_image_url: String, + val is_default_image: Boolean, + val is_default_nickname: Boolean + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/util/DateConverter.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/DateConverter.kt index 5ca7b2a..7836126 100644 --- a/src/main/kotlin/com/ddd/sonnypolabobe/global/util/DateConverter.kt +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/DateConverter.kt @@ -1,6 +1,8 @@ package com.ddd.sonnypolabobe.global.util import java.time.LocalDateTime +import java.time.ZoneId +import java.util.Date object DateConverter { @@ -8,4 +10,8 @@ object DateConverter { return date.plusHours(9) } + fun dateToLocalDateTime(date: Date) : LocalDateTime { + return LocalDateTime.ofInstant(date.toInstant(), ZoneId.of("Asia/Seoul")) + } + } \ No newline at end of file diff --git a/src/main/kotlin/com/ddd/sonnypolabobe/global/util/WebClientUtil.kt b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/WebClientUtil.kt new file mode 100644 index 0000000..9a8709a --- /dev/null +++ b/src/main/kotlin/com/ddd/sonnypolabobe/global/util/WebClientUtil.kt @@ -0,0 +1,28 @@ +package com.ddd.sonnypolabobe.global.util + +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.http.client.reactive.ReactorClientHttpConnector +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import reactor.netty.http.client.HttpClient +import java.time.Duration + + +@Component +class WebClientUtil() { + + fun create(baseUrl: String): WebClient { + return WebClient.builder() + .baseUrl(baseUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .codecs { it.defaultCodecs().enableLoggingRequestDetails(true) } + .clientConnector( + ReactorClientHttpConnector( + HttpClient.create().responseTimeout(Duration.ofMillis(2500)) + ) + ) + .build() + } + +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index db2a54a..0c2a978 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -20,6 +20,28 @@ spring: jooq: sql-dialect: mysql + security: + oauth2: + client: + registration: + kakao: + client-id: ENC(BD9NHDqbUHDpLXYtua4QLLznXweUau5/N3dA1IQqKhQW2sWvniKSDTS3+Z9t/oct) + client-secret: ENC(dMTqjTdS4VJz/1Gduapvl1rDDXUUKkp0bilgqRWMI9X4DaAMVDXY13Fb7QMBDUkI) + scope: + - account_email + - profile_nickname + authorization-grant-type: authorization_code + redirect-uri: https://api.polabo.site/api/v1/oauth/sign-in + client-name: Kakao + client-authentication-method: client_secret_post + + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + cloud: aws: credentials: @@ -33,6 +55,11 @@ cloud: running: name: dev +jwt: + access-key: ENC(dMTqjTdS4VJz/1Gduapvl1rDDXUUKkp0bilgqRWMI9X4DaAMVDXY13Fb7QMBDUkI) + validity: + access-seconds: ENC(KeRwqvZAr0MfEVmxw8nBQQFEITQ0v/Fl) + logging: discord: webhook-uri: ENC(yfeX3WHXQdxkVtasNl5WLv6M/YlN+dVFUurjxGIddstjjipt+KryWKvLu1wDmdGjpuEhUHyaABg4gFWRMk9gNlxSQEE/G1twbuvkOvT0pyFWycVVJ6ryU/v9pDBOS1PSKJY7L3NP66gOGnam6nOvf0Y+F45zZvXj8/sdtR6N798U6fGjFDxOLQ==) \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index e58fda8..f8f405c 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -20,6 +20,28 @@ spring: jooq: sql-dialect: mysql + security: + oauth2: + client: + registration: + kakao: + client-id: ENC(BD9NHDqbUHDpLXYtua4QLLznXweUau5/N3dA1IQqKhQW2sWvniKSDTS3+Z9t/oct) + client-secret: ENC(dMTqjTdS4VJz/1Gduapvl1rDDXUUKkp0bilgqRWMI9X4DaAMVDXY13Fb7QMBDUkI) + scope: + - account_email + - profile_nickname + authorization-grant-type: authorization_code + redirect-uri: https://api-dev.polabo.site/api/v1/oauth/sign-in + client-name: Kakao + client-authentication-method: client_secret_post + + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + cloud: aws: credentials: @@ -33,6 +55,11 @@ cloud: running: name: local +jwt: + access-key: ENC(dMTqjTdS4VJz/1Gduapvl1rDDXUUKkp0bilgqRWMI9X4DaAMVDXY13Fb7QMBDUkI) + validity: + access-seconds: 86400000 + logging: discord: webhook-uri: ENC(yfeX3WHXQdxkVtasNl5WLv6M/YlN+dVFUurjxGIddstjjipt+KryWKvLu1wDmdGjpuEhUHyaABg4gFWRMk9gNlxSQEE/G1twbuvkOvT0pyFWycVVJ6ryU/v9pDBOS1PSKJY7L3NP66gOGnam6nOvf0Y+F45zZvXj8/sdtR6N798U6fGjFDxOLQ==) \ No newline at end of file