diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 83f68ef..68d3dfc 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -116,4 +116,5 @@ jobs: cd ~/bitta-project docker-compose down docker-compose up -d --no-deps - sudo nginx -s reload \ No newline at end of file + sudo nginx -s reload + diff --git a/Dockerfile b/Dockerfile index d9d1fe5..423e277 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,4 +9,4 @@ COPY build/libs/*.jar /app.jar # Set the entry point to run the application EXPOSE 8080 -CMD ["java", "-jar", "-Dspring.profiles.active=prod", "/app.jar"] \ No newline at end of file +CMD ["java", "-jar", "-Dspring.profiles.active=prod", "/app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml index e82540b..1f5209d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,4 +26,4 @@ services: ports: - "8082:8000" environment: - - SPRING_PROFILES_ACTIVE=prod + - SPRING_PROFILES_ACTIVE=prod \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/feed/controller/FeedController.kt b/src/main/kotlin/org/tenten/bittakotlin/feed/controller/FeedController.kt index 064d109..b348773 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feed/controller/FeedController.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feed/controller/FeedController.kt @@ -46,7 +46,7 @@ class FeedController ( } @PostMapping - fun create(requestDto: FeedRequestDto.Create): ResponseEntity> { + fun create(@RequestBody requestDto: FeedRequestDto.Create): ResponseEntity> { return ResponseEntity.ok(mapOf( "message" to "피드를 성공적으로 등록했습니다.", "result" to feedService.save(requestDto) diff --git a/src/main/kotlin/org/tenten/bittakotlin/feed/entity/Feed.kt b/src/main/kotlin/org/tenten/bittakotlin/feed/entity/Feed.kt index 1b86fe8..364d6bf 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feed/entity/Feed.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feed/entity/Feed.kt @@ -30,7 +30,7 @@ data class Feed( @CreatedDate @Column(updatable = false, nullable = false) - val createdAt: LocalDateTime? = null, + var createdAt: LocalDateTime? = null, @LastModifiedDate @Column(updatable = true, nullable = false) diff --git a/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedMediaServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedMediaServiceImpl.kt index 658428e..7850761 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedMediaServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedMediaServiceImpl.kt @@ -4,6 +4,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.tenten.bittakotlin.feed.entity.Feed import org.tenten.bittakotlin.feed.entity.FeedMedia +import org.tenten.bittakotlin.feed.entity.key.FeedMediaId import org.tenten.bittakotlin.feed.repository.FeedMediaRepository import org.tenten.bittakotlin.media.dto.MediaRequestDto import org.tenten.bittakotlin.media.dto.MediaResponseDto @@ -22,10 +23,12 @@ class FeedMediaServiceImpl( uploadRequestDtos.forEach { uploadRequestDto -> val mediaResponseDto: MediaResponseDto.Upload = mediaService.upload(uploadRequestDto, profile) + val media = mediaResponseDto.media feedMediaRepository.save(FeedMedia( + id = FeedMediaId(feedId = feed.id!!, mediaId = media.id!!), feed = feed, - media = mediaResponseDto.media + media = media )) responseDto.add(MediaResponseDto.Read( diff --git a/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedServiceImpl.kt index e63d104..f8c2d37 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/feed/service/FeedServiceImpl.kt @@ -16,6 +16,7 @@ import org.tenten.bittakotlin.feed.dto.FeedRequestDto import org.tenten.bittakotlin.feed.dto.FeedResponseDto import org.tenten.bittakotlin.profile.entity.Profile import org.tenten.bittakotlin.profile.service.ProfileService +import java.time.LocalDateTime @Service @RequiredArgsConstructor @@ -80,7 +81,9 @@ class FeedServiceImpl( val feed: Feed = feedRepository.save(Feed( title = requestDto.title, content = requestDto.content, - profile = profile + profile = profile, + createdAt = LocalDateTime.now(), + updatedAt = LocalDateTime.now() )) return if (requestDto.medias != null) { diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/dto/MediaResponseDto.kt b/src/main/kotlin/org/tenten/bittakotlin/media/dto/MediaResponseDto.kt index 3e0f7a9..a013f6b 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/media/dto/MediaResponseDto.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/media/dto/MediaResponseDto.kt @@ -14,4 +14,10 @@ class MediaResponseDto { val media: Media ) + + data class PublicUpload ( + val uploadUrl: String, + + val readUrl: String + ) } \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/entity/Media.kt b/src/main/kotlin/org/tenten/bittakotlin/media/entity/Media.kt index 5e2499d..2b21679 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/media/entity/Media.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/media/entity/Media.kt @@ -28,7 +28,7 @@ data class Media ( @CreatedDate @Column(nullable = false, updatable = false) - val savedAt: LocalDateTime? = null, + var savedAt: LocalDateTime? = null, @ManyToOne @JoinColumn diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/service/MediaServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/media/service/MediaServiceImpl.kt index ab79c19..6f7b141 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/media/service/MediaServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/media/service/MediaServiceImpl.kt @@ -42,8 +42,8 @@ class MediaServiceImpl( } override fun upload(requestDto: MediaRequestDto.Upload, profile: Profile): MediaResponseDto.Upload { - val filename: String = UUID.randomUUID().toString() val filetype: MediaType = checkMimetype(requestDto.mimetype) + val filename: String = UUID.randomUUID().toString() + getExtension(requestDto.mimetype) val filesize: Int = checkFileSize(requestDto.filesize, filetype) val media: Media = mediaRepository.save(Media( @@ -94,6 +94,23 @@ class MediaServiceImpl( throw MediaException(MediaError.WRONG_MIME_TYPE) } + private fun getExtension(mimetype: String): String? { + val mimetypeMap = mapOf( + "image/jpeg" to "jpg", + "image/png" to "png", + "image/gif" to "gif", + "image/bmp" to "bmp", + "image/webp" to "webp", + "image/svg+xml" to "svg", + "video/mp4" to "mp4", + "video/webm" to "webm", + "video/ogg" to "ogg", + "video/x-msvideo" to "avi", + "video/x-matroska" to "mkv" + ) + return mimetypeMap[mimetype] + } + private fun checkFileSize(filesize: Int, type: MediaType): Int { val maxSize: Int = if (type == MediaType.IMAGE) { imageMaxSize diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageService.kt b/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageService.kt new file mode 100644 index 0000000..f9000fb --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageService.kt @@ -0,0 +1,11 @@ +package org.tenten.bittakotlin.media.service + +import org.tenten.bittakotlin.media.dto.MediaRequestDto +import org.tenten.bittakotlin.media.dto.MediaResponseDto +import org.tenten.bittakotlin.profile.entity.Profile + +interface ProfileImageService { + fun upload(requestDto: MediaRequestDto.Upload, profile: Profile): MediaResponseDto.PublicUpload + + fun delete(requestDto: MediaRequestDto.Delete) +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageServiceImpl.kt new file mode 100644 index 0000000..99b8b4c --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/media/service/ProfileImageServiceImpl.kt @@ -0,0 +1,81 @@ +package org.tenten.bittakotlin.media.service + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.tenten.bittakotlin.media.constant.MediaError +import org.tenten.bittakotlin.media.constant.MediaType +import org.tenten.bittakotlin.media.dto.MediaRequestDto +import org.tenten.bittakotlin.media.dto.MediaResponseDto +import org.tenten.bittakotlin.media.entity.Media +import org.tenten.bittakotlin.media.exception.MediaException +import org.tenten.bittakotlin.media.repository.MediaRepository +import org.tenten.bittakotlin.profile.entity.Profile +import java.util.* + +@Service +class ProfileImageServiceImpl ( + private val mediaRepository: MediaRepository, + + private val s3Service: S3Service, + + @Value("\${file.max.size.image}") + private val maxsize: Int +) : ProfileImageService { + companion object { + private val logger: Logger = LoggerFactory.getLogger(ProfileImageServiceImpl::class.java) + } + + @Transactional + override fun upload(requestDto: MediaRequestDto.Upload, profile: Profile): MediaResponseDto.PublicUpload { + val filetype: MediaType = checkMimetype(requestDto.mimetype) + val filename: String = UUID.randomUUID().toString() + getExtension(requestDto.mimetype) + val filesize: Int = checkFileSize(requestDto.filesize) + + mediaRepository.save(Media( + filename = filename, + filetype = filetype, + filesize = filesize, + profile = profile + )) + + return s3Service.getPublicUploadUrl(filename, requestDto.mimetype) + } + + @Transactional + override fun delete(requestDto: MediaRequestDto.Delete) { + val filename: String = requestDto.filename + + mediaRepository.findByFilename(filename).orElseThrow { MediaException(MediaError.CANNOT_FOUND) } + } + + private fun getExtension(mimetype: String): String? { + val mimetypeMap = mapOf( + "image/jpeg" to "jpg", + "image/png" to "png", + "image/gif" to "gif", + "image/bmp" to "bmp", + "image/webp" to "webp", + "image/svg+xml" to "svg", + ) + return mimetypeMap[mimetype] + } + + private fun checkMimetype(mimetype: String): MediaType { + if (mimetype.matches(Regex("image/(jpeg|png|gif|bmp|webp|svg\\+xml)"))) { + return MediaType.IMAGE + } + + throw MediaException(MediaError.WRONG_MIME_TYPE) + } + + private fun checkFileSize(filesize: Int): Int { + if (filesize > maxsize) { + throw MediaException(MediaError.WRONG_FILE_SIZE) + } + + return filesize + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/service/S3Service.kt b/src/main/kotlin/org/tenten/bittakotlin/media/service/S3Service.kt index dbcc5b4..3b0ba7a 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/media/service/S3Service.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/media/service/S3Service.kt @@ -1,9 +1,13 @@ package org.tenten.bittakotlin.media.service +import org.tenten.bittakotlin.media.dto.MediaResponseDto + interface S3Service { fun getReadUrl(name: String): String fun getUploadUrl(name: String, contentType: String): String + fun getPublicUploadUrl(name: String, contentType: String): MediaResponseDto.PublicUpload + fun delete(filename: String): Unit } \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/media/service/S3ServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/media/service/S3ServiceImpl.kt index 0396fe9..9fa765a 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/media/service/S3ServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/media/service/S3ServiceImpl.kt @@ -5,7 +5,9 @@ import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.tenten.bittakotlin.media.constant.MediaError +import org.tenten.bittakotlin.media.dto.MediaResponseDto import org.tenten.bittakotlin.media.exception.MediaException +import software.amazon.awssdk.regions.Region import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.HeadObjectRequest import software.amazon.awssdk.services.s3.model.NoSuchKeyException @@ -56,6 +58,13 @@ class S3ServiceImpl( return presignedPutRequest.url().toString() } + override fun getPublicUploadUrl(name: String, contentType: String): MediaResponseDto.PublicUpload { + return MediaResponseDto.PublicUpload( + uploadUrl = getUploadUrl(name, contentType), + readUrl = "https://${s3Bucket}.s3.${Region.AP_NORTHEAST_2}.amazonaws.com/$name" + ) + } + override fun delete(name: String): Unit { existsInBucket(name) diff --git a/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt b/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt index bc9f56e..0dc0a16 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/member/controller/MemberController.kt @@ -5,6 +5,9 @@ import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.access.AccessDeniedException @@ -14,17 +17,55 @@ import org.tenten.bittakotlin.member.dto.MemberResponseDTO import org.tenten.bittakotlin.member.exception.MemberException import org.tenten.bittakotlin.member.repository.MemberRepository import org.tenten.bittakotlin.member.service.MemberService -import org.tenten.bittakotlin.security.jwt.JWTUtil +import org.tenten.bittakotlin.security.util.JwtTokenUtil @Tag(name = "회원관리 API 컨트롤러", description = "회원과 관련된 RestAPI 제공 컨트롤러") @RestController @RequestMapping("/api/v1/member") class MemberController( private val memberService: MemberService, - private val jwtUtil: JWTUtil, - private val memberRepository: MemberRepository + private val memberRepository: MemberRepository, + private val jwtTokenUtil: JwtTokenUtil ) { + @PostMapping("/login") + fun login(@RequestBody requestDto: MemberRequestDTO.Login): ResponseEntity> { + val responseDto: MemberResponseDTO.Login = memberService.login(requestDto) + + val headers = HttpHeaders().apply { + add(HttpHeaders.SET_COOKIE, getCookieString("accessToken", responseDto.accessToken, 3600, false)) + add(HttpHeaders.SET_COOKIE, getCookieString("refreshToken", responseDto.refreshToken, 604800, false)) + add(HttpHeaders.SET_COOKIE, getCookieString("profileId", responseDto.profileId.toString(), 604800, true)) + add(HttpHeaders.SET_COOKIE, getCookieString("profileUrl", responseDto.profileUrl, 604800, true)) + } + + val body = mapOf("message" to "로그인이 성공했습니다.") + + return ResponseEntity(body, headers, HttpStatus.OK) + } + + @PostMapping("/logout") + fun logout(request: HttpServletRequest, response: HttpServletResponse): ResponseEntity> { + val headers = HttpHeaders().apply { + add(HttpHeaders.SET_COOKIE, getCookieString("accessToken", null, 0, false)) + add(HttpHeaders.SET_COOKIE, getCookieString("refreshToken", null, 0, false)) + add(HttpHeaders.SET_COOKIE, getCookieString("profileId", null, 0, true)) + add(HttpHeaders.SET_COOKIE, getCookieString("profileUrl", null, 0, true)) + } + + val body = mapOf("message" to "로그아웃이 성공했습니다.") + + return ResponseEntity(body, headers, HttpStatus.OK) + } + + + private fun getCookieString(name: String, token: String?, ageMax: Int, isPublic: Boolean): String { + return buildString { + append("$name=$token; Path=/; Max-Age=$ageMax;") + if (!isPublic) append(" HttpOnly;") + } + } + // 회원가입 @Operation( @@ -77,7 +118,7 @@ class MemberController( @RequestHeader("access") token: String // JWT 토큰을 헤더에서 추출 ): ResponseEntity { // 현재 로그인한 사용자 username 추출 - val usernameFromToken = jwtUtil.getUsername(token) + val usernameFromToken = jwtTokenUtil.getUsername(token) // id로 회원 정보 조회 val member = memberRepository.findById(id) @@ -96,7 +137,7 @@ class MemberController( @DeleteMapping("/{id}") fun remove(@PathVariable id: Long, @RequestHeader("access") token: String): ResponseEntity { - val username = jwtUtil.getUsername(token) + val username = jwtTokenUtil.getUsername(token) val member = memberRepository.findById(id) .orElseThrow { IllegalArgumentException("Member not Found.") } if(member.username != username) { diff --git a/src/main/kotlin/org/tenten/bittakotlin/member/dto/MemberResponseDTO.kt b/src/main/kotlin/org/tenten/bittakotlin/member/dto/MemberResponseDTO.kt index c43ccb7..fd1628b 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/member/dto/MemberResponseDTO.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/member/dto/MemberResponseDTO.kt @@ -20,4 +20,13 @@ class MemberResponseDTO { val address: String ) + data class Login( + val accessToken: String, + + val refreshToken: String, + + val profileId: Long, + + val profileUrl: String + ) } \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/member/entity/Member.kt b/src/main/kotlin/org/tenten/bittakotlin/member/entity/Member.kt index e0f7559..6feec6d 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/member/entity/Member.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/member/entity/Member.kt @@ -36,6 +36,4 @@ data class Member( @Column(nullable = false) var role: String? = null - - ) \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberService.kt b/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberService.kt index 7507dac..05fd731 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberService.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberService.kt @@ -4,6 +4,7 @@ import org.tenten.bittakotlin.member.dto.MemberRequestDTO import org.tenten.bittakotlin.member.dto.MemberResponseDTO interface MemberService { + fun login(requestDto: MemberRequestDTO.Login): MemberResponseDTO.Login fun join(joinDTO: MemberRequestDTO.Join) // Join 기능 병합 diff --git a/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberServiceImpl.kt index 016d298..1850547 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/member/service/MemberServiceImpl.kt @@ -10,7 +10,11 @@ import org.tenten.bittakotlin.member.dto.MemberResponseDTO import org.tenten.bittakotlin.member.entity.Member import org.tenten.bittakotlin.member.exception.MemberException import org.tenten.bittakotlin.member.repository.MemberRepository +import org.tenten.bittakotlin.profile.entity.Profile import org.tenten.bittakotlin.profile.service.ProfileService +import org.tenten.bittakotlin.security.dto.TokenRequestDto +import org.tenten.bittakotlin.security.service.TokenService +import org.tenten.bittakotlin.security.util.JwtTokenUtil @Service @RequiredArgsConstructor @@ -18,9 +22,31 @@ import org.tenten.bittakotlin.profile.service.ProfileService class MemberServiceImpl ( private val memberRepository: MemberRepository, private val bCryptPasswordEncoder: BCryptPasswordEncoder, - private val profileService: ProfileService - + private val profileService: ProfileService, + private val tokenService: TokenService ): MemberService { + override fun login(requestDto: MemberRequestDTO.Login): MemberResponseDTO.Login { + val member = memberRepository.findByUsername(requestDto.username) + ?: throw MemberException.BAD_CREDENTIAL.get() + + if (!bCryptPasswordEncoder.matches(requestDto.password, member.password)) { + throw MemberException.BAD_CREDENTIAL.get() + } + + val tokenResponseDto = tokenService.create(TokenRequestDto.Create( + username = member.username, + role = member.role!! + )) + + val profile: Profile = profileService.getByUsername(member.username) + + return MemberResponseDTO.Login( + accessToken = tokenResponseDto.accessToken, + refreshToken = tokenResponseDto.refreshToken, + profileId = profile.id!!, + profileUrl = profile.profileUrl!! + ) + } override fun join(joinDTO: MemberRequestDTO.Join) { val username = joinDTO.username diff --git a/src/main/kotlin/org/tenten/bittakotlin/profile/controller/ProfileController.kt b/src/main/kotlin/org/tenten/bittakotlin/profile/controller/ProfileController.kt index 791e40b..74f5bb1 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/profile/controller/ProfileController.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/profile/controller/ProfileController.kt @@ -4,6 +4,7 @@ import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import org.tenten.bittakotlin.media.dto.MediaRequestDto import org.tenten.bittakotlin.profile.dto.ProfileDTO import org.tenten.bittakotlin.profile.service.ProfileServiceImpl @@ -31,6 +32,25 @@ class ProfileController( return ResponseEntity.ok(updatedProfile) } + @PutMapping("/image") + fun updateProfileImage(@RequestBody requestDto: MediaRequestDto.Upload): ResponseEntity> { + val uploadUrl = profileService.updateProfileImage(requestDto) + + return ResponseEntity.ok(mapOf( + "message" to "프로필 이미지를 수정했습니다.", + "uploadUrl" to uploadUrl + )) + } + + @DeleteMapping("/image") + fun deleteProfileImage(): ResponseEntity> { + profileService.deleteProfileImage() + + return ResponseEntity.ok(mapOf( + "message" to "프로필 이미지를 삭제했습니다." + )) + } + companion object { private val logger: Logger = LoggerFactory.getLogger(ProfileController::class.java) } diff --git a/src/main/kotlin/org/tenten/bittakotlin/profile/entity/Profile.kt b/src/main/kotlin/org/tenten/bittakotlin/profile/entity/Profile.kt index 78075f6..3ddcd0b 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/profile/entity/Profile.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/profile/entity/Profile.kt @@ -6,7 +6,6 @@ import org.tenten.bittakotlin.apply.entity.Apply import org.tenten.bittakotlin.like.entity.Like import org.tenten.bittakotlin.member.entity.Member import org.tenten.bittakotlin.profile.constant.Job -import org.tenten.bittakotlin.scout.entity.ScoutRequest //data class 로 변경 @Entity @@ -39,10 +38,4 @@ class Profile( @OneToMany(mappedBy = "profile", fetch = FetchType.EAGER, cascade = [CascadeType.REMOVE], orphanRemoval = true) val like: List = mutableListOf(), - - @OneToMany(mappedBy = "sender", cascade = [CascadeType.ALL], orphanRemoval = true) - val sentScoutRequests: List = mutableListOf(), - - @OneToMany(mappedBy = "receiver", cascade = [CascadeType.ALL], orphanRemoval = true) - val receivedScoutRequests: List = mutableListOf() ) \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt index 4072f04..549ba5f 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileService.kt @@ -1,5 +1,6 @@ package org.tenten.bittakotlin.profile.service +import org.tenten.bittakotlin.media.dto.MediaRequestDto import org.tenten.bittakotlin.member.entity.Member import org.tenten.bittakotlin.profile.dto.ProfileDTO import org.tenten.bittakotlin.profile.entity.Profile @@ -12,4 +13,10 @@ interface ProfileService { fun getByNickname(nickname: String): Profile fun getByPrincipal(): Profile + + fun getByUsername(username: String): Profile + + fun updateProfileImage(requestDto: MediaRequestDto.Upload): String + + fun deleteProfileImage() } \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt index 6586ae0..ec27b58 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/profile/service/ProfileServiceImpl.kt @@ -3,8 +3,12 @@ package org.tenten.bittakotlin.profile.service import jakarta.persistence.EntityNotFoundException import org.slf4j.LoggerFactory import org.slf4j.Logger +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.tenten.bittakotlin.media.dto.MediaRequestDto +import org.tenten.bittakotlin.media.dto.MediaResponseDto +import org.tenten.bittakotlin.media.service.ProfileImageService import org.tenten.bittakotlin.member.entity.Member import org.tenten.bittakotlin.member.repository.MemberRepository import org.tenten.bittakotlin.member.service.MemberService @@ -18,8 +22,13 @@ import org.tenten.bittakotlin.security.service.PrincipalProvider @Service class ProfileServiceImpl( private val profileRepository: ProfileRepository, - private val memberRepository: MemberRepository, - private val principalProvider: PrincipalProvider + + private val principalProvider: PrincipalProvider, + + private val profileImageService: ProfileImageService, + + @Value("\${default.profile.image}") + private val defaultProfileImageUrl: String ) : ProfileService { //Member 생성시 Profile 도 같이 생성 @@ -30,7 +39,7 @@ class ProfileServiceImpl( val profile = Profile( member = member, nickname = nickname, - profileUrl = null, + profileUrl = defaultProfileImageUrl, description = "This is a default profile.", job = null, socialMedia = null @@ -75,7 +84,6 @@ class ProfileServiceImpl( profile.description = profileDTO.description ?: profile.description profile.socialMedia = profileDTO.socialMedia ?: profile.socialMedia - profile.profileUrl = profileDTO.profileUrl ?: profile.profileUrl profile.job = profileDTO.job?.let { Job.valueOf(it) } ?: profile.job @@ -83,12 +91,39 @@ class ProfileServiceImpl( return toDto(updatedProfile) } + override fun updateProfileImage(requestDto: MediaRequestDto.Upload): String { + val profile = getByPrincipal() + + val responseDto: MediaResponseDto.PublicUpload = profileImageService.upload(requestDto, profile) + + profile.profileUrl = responseDto.readUrl + profileRepository.save(profile) + + return responseDto.uploadUrl + } + + override fun deleteProfileImage() { + val profile = getByPrincipal() + + if (profile.profileUrl == defaultProfileImageUrl) { + throw NoSuchElementException("기본 이미지는 삭제할 수 없습니다.") + } + + profileImageService.delete(MediaRequestDto.Delete( + filename = profile.profileUrl!!.substringAfterLast("/") + )) + + profile.profileUrl = defaultProfileImageUrl + profileRepository.save(profile) + } + private fun toDto(profile: Profile): ProfileDTO { return ProfileDTO( profileId = profile.id, nickname = profile.nickname, description = profile.description, - socialMedia = profile.socialMedia + socialMedia = profile.socialMedia, + profileUrl = profile.profileUrl ) } @@ -102,6 +137,11 @@ class ProfileServiceImpl( .orElseThrow { NoSuchElementException() } } + override fun getByUsername(username: String): Profile { + return profileRepository.findByUsername(username) + .orElseThrow { NoSuchElementException() } + } + companion object { private val logger: Logger = LoggerFactory.getLogger(ProfileServiceImpl::class.java) } diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt b/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt index beee2e0..43e5e43 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/config/SecurityConfig.kt @@ -1,10 +1,8 @@ package org.tenten.bittakotlin.security.config -import JWTFilter import jakarta.servlet.http.HttpServletRequest import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.http.HttpMethod import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration import org.springframework.security.config.annotation.web.builders.HttpSecurity @@ -12,23 +10,23 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.web.SecurityFilterChain -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter -import org.springframework.security.web.authentication.logout.LogoutFilter +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.CorsConfigurationSource -import org.tenten.bittakotlin.member.repository.MemberRepository -import org.tenten.bittakotlin.security.jwt.CustomLogoutFilter -import org.tenten.bittakotlin.security.jwt.JWTUtil -import org.tenten.bittakotlin.security.jwt.LoginFilter -import org.tenten.bittakotlin.security.repository.RefreshRepository +import org.tenten.bittakotlin.security.filter.JwtAuthenticationFilter @Configuration @EnableWebSecurity class SecurityConfig( + private val jwtAuthenticationFilter: JwtAuthenticationFilter, + private val authenticationConfiguration: AuthenticationConfiguration, - private val jwtUtil: JWTUtil, - private val refreshRepository: RefreshRepository ) { + private val publicUrls = arrayOf("/", "/api/v1/member/login", "/api/v1/member/logout", "/api/v1/member/join", "/api/v1/token", + "/swagger", "/swagger-ui.html", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/v3/api-docs/**") + + private val signedUrls = arrayOf("/api/v1/member/{id}**", "/api/v1/job-post/**", "/job-post/**", "/api/v1/like/**", + "/api/v1/chat/**", "/api/v1/feed") @Bean @Throws(Exception::class) @@ -59,60 +57,29 @@ class SecurityConfig( }) } - // CSRF disable http.csrf { it.disable() } + .formLogin { it.disable() } + .httpBasic { it.disable() } + .authorizeHttpRequests { auth -> + auth + .requestMatchers(*publicUrls).permitAll() + .requestMatchers(*signedUrls).hasRole("USER") + .anyRequest().authenticated() + } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .addFilterBefore(jwtAuthenticationFilter, BasicAuthenticationFilter::class.java) - // Form 로그인 방식 disable - http.formLogin { it.disable() } - - // HTTP Basic 인증 방식 disable - http.httpBasic { it.disable() } - - http.authorizeHttpRequests { auth -> - auth - .requestMatchers( - "/", - "/api/v1/member/login", - "/member/login", - "/api/v1/member/join", - "/member/join", - "/api/v1/member/reissue").permitAll() - .requestMatchers( - "/swagger", - "/swagger-ui.html", - "/swagger-ui/**", - "/api-docs", - "/api-docs/**", - "/v3/api-docs/**").permitAll() - - .requestMatchers( - "/api/v1/member/{id}/**", - "member/{id}/**", - "/api/v1/job-post/**", - "/job-post/**", - "/api/v1/like/**").hasRole("USER") - - .requestMatchers(HttpMethod.DELETE,"/api/v1/member/{id}").authenticated() - .requestMatchers(HttpMethod.PUT,"/api/v1/member/{id}").authenticated() - .requestMatchers("/api/v1/chat/**").authenticated() - - .anyRequest().authenticated() - } - - http.addFilterBefore(JWTFilter(jwtUtil), LoginFilter::class.java) + /*http.addFilterAfter(JWTFilter(jwtUtil), LoginFilter::class.java) val loginFilter = LoginFilter(authenticationManager(), jwtUtil, refreshRepository) + loginFilter.setFilterProcessesUrl("/api/v1/member/login") - http.addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter::class.java) - http.addFilterBefore(CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter::class.java) + http.addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter::class.java) - // 세션 설정 - http.sessionManagement { session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) - } + http.addFilterBefore(CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter::class.java)*/ return http.build() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/constant/TokenError.kt b/src/main/kotlin/org/tenten/bittakotlin/security/constant/TokenError.kt new file mode 100644 index 0000000..14b534a --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/constant/TokenError.kt @@ -0,0 +1,8 @@ +package org.tenten.bittakotlin.security.constant + +import org.springframework.http.HttpStatus + +enum class TokenError(val code: Int, val message: String) { + REFRESH_EXPIRED(HttpStatus.BAD_REQUEST.value(), "리프레시 토큰이 만료되었습니다."), + REFRESH_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "리프레시 토큰의 인증정보가 존재하지 않습니다.") +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/controller/ReissueController.kt b/src/main/kotlin/org/tenten/bittakotlin/security/controller/ReissueController.kt deleted file mode 100644 index 75b3cb2..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/controller/ReissueController.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.tenten.bittakotlin.security.controller - -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RestController -import org.tenten.bittakotlin.security.service.ReissueService - - -@RestController -class ReissueController(private val reissueService: ReissueService) { - - @PostMapping("/api/member/reissue") - fun reissue(request: HttpServletRequest, response: HttpServletResponse): ResponseEntity<*> { - return reissueService.reissueTokens(request, response) - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/controller/TokenController.kt b/src/main/kotlin/org/tenten/bittakotlin/security/controller/TokenController.kt new file mode 100644 index 0000000..5949ee0 --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/controller/TokenController.kt @@ -0,0 +1,36 @@ +package org.tenten.bittakotlin.security.controller + +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.tenten.bittakotlin.security.service.TokenService + +@RestController +@RequestMapping("/api/v1/token") +class TokenController( + private val tokenService: TokenService +) { + @PostMapping + fun reissue(request: HttpServletRequest, response: HttpServletResponse): ResponseEntity> { + val refreshToken = request.getHeader("Authority") + + val headers = HttpHeaders().apply { + add(HttpHeaders.SET_COOKIE, getCookieString("accessToken", tokenService.reissue(refreshToken) + , 3600)) + } + + val body = mapOf("message" to "토큰을 재발급했습니다.") + + return ResponseEntity(body, headers, HttpStatus.OK) + } + + private fun getCookieString(name: String, token: String?, ageMax: Int): String { + return "$name=$token; Path=/; Max-Age=$ageMax; HttpOnly;" + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenRequestDto.kt b/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenRequestDto.kt new file mode 100644 index 0000000..523074c --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenRequestDto.kt @@ -0,0 +1,9 @@ +package org.tenten.bittakotlin.security.dto + +class TokenRequestDto { + data class Create( + val username: String, + + val role: String + ) +} diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenResponseDto.kt b/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenResponseDto.kt new file mode 100644 index 0000000..4ee4e3b --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/dto/TokenResponseDto.kt @@ -0,0 +1,8 @@ +package org.tenten.bittakotlin.security.dto + +class TokenResponseDto { + data class Create( + val accessToken: String, + val refreshToken: String + ) +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/entity/RefreshEntity.kt b/src/main/kotlin/org/tenten/bittakotlin/security/entity/RefreshEntity.kt index 5a1d54c..c6bd166 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/entity/RefreshEntity.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/entity/RefreshEntity.kt @@ -12,6 +12,8 @@ data class RefreshEntity( val id: Long? = null, val username: String, + val refresh: String, + val expiration: String ) \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/exception/TokenException.kt b/src/main/kotlin/org/tenten/bittakotlin/security/exception/TokenException.kt new file mode 100644 index 0000000..dc76be4 --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/exception/TokenException.kt @@ -0,0 +1,14 @@ +package org.tenten.bittakotlin.security.exception + +import org.tenten.bittakotlin.security.constant.TokenError + +class TokenException ( + val code: Int, + + override val message: String +) : RuntimeException(message) { + constructor(tokenError: TokenError) : this( + code = tokenError.code, + message = tokenError.message + ) +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/filter/JwtAuthenticationFilter.kt b/src/main/kotlin/org/tenten/bittakotlin/security/filter/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..728506d --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/filter/JwtAuthenticationFilter.kt @@ -0,0 +1,84 @@ +package org.tenten.bittakotlin.security.filter + +import io.jsonwebtoken.ExpiredJwtException +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.core.annotation.Order +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.stereotype.Component +import org.springframework.util.AntPathMatcher +import org.springframework.web.filter.OncePerRequestFilter +import org.tenten.bittakotlin.security.service.CustomUserDetailsService +import org.tenten.bittakotlin.security.util.JwtTokenUtil +import java.io.IOException +import java.lang.Exception + +@Order(0) +@Component +class JwtAuthenticationFilter( + private val jwtTokenUtil: JwtTokenUtil, + + private val customUserDetailsService: CustomUserDetailsService +) : OncePerRequestFilter() { + private val publicUrls = arrayOf("/", "/api/v1/member/login", "/api/v1/member/join", "/api/v1/token", + "/swagger", "/swagger-ui.html", "/swagger-ui/**", "/api-docs", "/api-docs/**", "/v3/api-docs/**") + + private val pathMatcher = AntPathMatcher() + + @Throws(ServletException::class, IOException::class) + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { + val requestUri = request.requestURI + + if (publicUrls.any { pathMatcher.match(it, requestUri) }) { + filterChain.doFilter(request, response) + return + } + + val accessToken: String? = request.getHeader("Authority") + + if (accessToken != null) { + try { + if (jwtTokenUtil.isExpired(accessToken)) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print("Access token has expired.") + return + } + + val username: String = jwtTokenUtil.getUsername(accessToken) + val userDetails: UserDetails? = customUserDetailsService.loadUserByUsername(username) + + if (userDetails == null) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print("No matching member exists.") + return + } + + logger.info("username=${userDetails.username}, authority${userDetails.authorities}") + + val authentication: Authentication = UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities) + SecurityContextHolder.getContext().authentication = authentication + + logger.info("adsgassdgasdgasdg") + } catch (e: ExpiredJwtException) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print("Access token has expired.") + return + } catch (e: Exception) { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print("Token authentication failed.") + return + } + } else { + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.print("The access token does not exist.") + return + } + + filterChain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/CustomLogoutFilter.kt b/src/main/kotlin/org/tenten/bittakotlin/security/jwt/CustomLogoutFilter.kt deleted file mode 100644 index 470f716..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/CustomLogoutFilter.kt +++ /dev/null @@ -1,90 +0,0 @@ -package org.tenten.bittakotlin.security.jwt - -import io.jsonwebtoken.ExpiredJwtException -import jakarta.servlet.FilterChain -import jakarta.servlet.ServletException -import jakarta.servlet.ServletRequest -import jakarta.servlet.ServletResponse -import jakarta.servlet.http.Cookie -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.aspectj.weaver.tools.cache.SimpleCacheFactory.path -import org.slf4j.LoggerFactory -import org.springframework.web.filter.GenericFilterBean -import org.tenten.bittakotlin.security.repository.RefreshRepository -import java.io.IOException - -class CustomLogoutFilter( - private val jwtUtil: JWTUtil, - private val refreshRepository: RefreshRepository -) : GenericFilterBean() { - - private val logger = LoggerFactory.getLogger(CustomLogoutFilter::class.java) - - @Throws(IOException::class, ServletException::class) - override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { - doFilter(request as HttpServletRequest, response as HttpServletResponse, chain) - } - - @Throws(IOException::class, ServletException::class) - private fun doFilter(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { - val requestUri = request.requestURI - logger.info("Incoming request URI: $requestUri") - - if (!requestUri.matches("^/api/v1/member/logout$".toRegex())) { - logger.warn("Invalid logout request URI: $requestUri") - filterChain.doFilter(request, response) - return - } - - val requestMethod = request.method - if (requestMethod != "POST") { - logger.warn("Invalid request method: $requestMethod") - filterChain.doFilter(request, response) - return - } - - val refresh = request.cookies?.firstOrNull { it.name == "refresh" }?.value - if (refresh == null) { - logger.error("Refresh token is missing") - response.status = HttpServletResponse.SC_BAD_REQUEST - return - } - - try { - if (jwtUtil.isExpired(refresh)) { - logger.error("Refresh token is expired") - response.status = HttpServletResponse.SC_BAD_REQUEST - return - } - } catch (e: ExpiredJwtException) { - logger.error("ExpiredJwtException: ${e.message}") - response.status = HttpServletResponse.SC_BAD_REQUEST - return - } - - val category = jwtUtil.getCategory(refresh) - if (category != "refresh") { - logger.error("Invalid token category: $category") - response.status = HttpServletResponse.SC_BAD_REQUEST - return - } - - val isExist = refreshRepository.existsByRefresh(refresh) - if (!isExist) { - logger.error("Refresh token does not exist in the database") - response.status = HttpServletResponse.SC_BAD_REQUEST - return - } - - // Proceed with logout - refreshRepository.deleteByRefresh(refresh) - val cookie = Cookie("refresh", null).apply { - maxAge = 0 - path = "/" - } - response.addCookie(cookie) - logger.info("Successfully logged out and deleted refresh token") - response.status = HttpServletResponse.SC_OK - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTFilter.kt b/src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTFilter.kt deleted file mode 100644 index c62d706..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTFilter.kt +++ /dev/null @@ -1,72 +0,0 @@ -import io.jsonwebtoken.ExpiredJwtException -import jakarta.servlet.FilterChain -import jakarta.servlet.ServletException -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.Authentication -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.web.filter.OncePerRequestFilter -import org.tenten.bittakotlin.member.entity.Member -import org.tenten.bittakotlin.member.repository.MemberRepository -import org.tenten.bittakotlin.security.dto.CustomUserDetails -import org.tenten.bittakotlin.security.jwt.JWTUtil -import java.io.IOException -import java.io.PrintWriter - -class JWTFilter(private val jwtUtil: JWTUtil) : OncePerRequestFilter() { - - @Throws(ServletException::class, IOException::class) - override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { - // 헤더에서 access키에 담긴 토큰을 꺼냄 - val accessToken = request.getHeader("access") - - // 토큰이 없다면 다음 필터로 넘김 - if (accessToken == null) { - filterChain.doFilter(request, response) - return - } - - // 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음 - try { - jwtUtil.isExpired(accessToken) - } catch (e: ExpiredJwtException) { - // response body - val writer: PrintWriter = response.writer - writer.print("access token expired") - - // response status code - response.status = HttpServletResponse.SC_UNAUTHORIZED - return - } - - // 토큰이 access인지 확인 (발급시 페이로드에 명시) - val category = jwtUtil.getCategory(accessToken) - - if (category != "access") { - // response body - val writer: PrintWriter = response.writer - writer.print("invalid access token") - - // response status code - response.status = HttpServletResponse.SC_UNAUTHORIZED - return - } - - // username, role 값을 획득 - val username = jwtUtil.getUsername(accessToken) - val role = jwtUtil.getRole(accessToken) - - val member = Member().apply { - this.username = username - this.role = role - } - - val customUserDetails = CustomUserDetails(member) - - val authToken = UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.authorities) - SecurityContextHolder.getContext().authentication = authToken - - filterChain.doFilter(request, response) - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/LoginFilter.kt b/src/main/kotlin/org/tenten/bittakotlin/security/jwt/LoginFilter.kt deleted file mode 100644 index bb6139c..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/LoginFilter.kt +++ /dev/null @@ -1,104 +0,0 @@ -package org.tenten.bittakotlin.security.jwt - -import com.fasterxml.jackson.databind.ObjectMapper -import jakarta.servlet.FilterChain -import jakarta.servlet.http.Cookie -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.HttpStatus -import org.springframework.security.authentication.AuthenticationManager -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.Authentication -import org.springframework.security.core.AuthenticationException -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter -import org.springframework.util.StreamUtils -import org.tenten.bittakotlin.member.dto.MemberRequestDTO -import org.tenten.bittakotlin.member.dto.MemberResponseDTO -import org.tenten.bittakotlin.security.entity.RefreshEntity -import org.tenten.bittakotlin.security.repository.RefreshRepository -import java.io.IOException -import java.nio.charset.StandardCharsets -import java.util.* - -class LoginFilter( - private val authenticationManager: AuthenticationManager, - private val jwtUtil: JWTUtil, - private val refreshRepository: RefreshRepository -) : UsernamePasswordAuthenticationFilter() { - - override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { - val loginDTO = try { - val objectMapper = ObjectMapper() - val inputStream = request.inputStream - val messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8) - println(messageBody) - objectMapper.readValue(messageBody, MemberRequestDTO.Login::class.java) - } catch (e: IOException) { - throw RuntimeException(e) - } - - val username = loginDTO.username - val password = loginDTO.password - - println(username) - - val authToken = UsernamePasswordAuthenticationToken(username, password) - - return authenticationManager.authenticate(authToken) - } - - override fun successfulAuthentication( - request: HttpServletRequest, - response: HttpServletResponse, - chain: FilterChain, - authentication: Authentication - ) { - - // 유저 정보 - val username = authentication.name - val authorities = authentication.authorities - val role = authorities.iterator().next().authority - - // 토큰 생성 - val access = jwtUtil.createJwt("access", username, role, 600000L) - val refresh = jwtUtil.createJwt("refresh", username, role, 86400000L) - - // Refresh 토큰 저장 - addRefreshEntity(username, refresh, 86400000L) - - // 응답 설정 - response.setHeader("access", access) - response.addCookie(createCookie("refresh", refresh)) - response.status = HttpStatus.OK.value() - } - - override fun unsuccessfulAuthentication( - request: HttpServletRequest, - response: HttpServletResponse, - failed: AuthenticationException - ) { - response.status = 401 - } - - /**이 코드 손봐야할 수도 있음 */ - private fun addRefreshEntity(username: String, refresh: String, expiredMs: Long) { - val date = Date(System.currentTimeMillis() + expiredMs) - - val refreshEntity = RefreshEntity( - username = username, - refresh = refresh, - expiration = date.toString() - ) - - refreshRepository.save(refreshEntity) - } - - private fun createCookie(key: String, value: String): Cookie { - return Cookie(key, value).apply { - maxAge = 24 * 60 * 60 - // secure = true - // path = "/" - isHttpOnly = true - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/repository/RefreshRepository.kt b/src/main/kotlin/org/tenten/bittakotlin/security/repository/RefreshRepository.kt index d5a7628..b09824a 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/repository/RefreshRepository.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/repository/RefreshRepository.kt @@ -2,14 +2,18 @@ package org.tenten.bittakotlin.security.repository import jakarta.transaction.Transactional import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository import org.tenten.bittakotlin.security.entity.RefreshEntity +import java.util.Optional @Repository interface RefreshRepository : JpaRepository { - fun existsByRefresh(refresh: String): Boolean @Transactional fun deleteByRefresh(refresh: String) + + @Query("SELECT r FROM RefreshEntity r WHERE r.refresh = :refresh") + fun findByRefresh(refresh: String): Optional } \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueService.kt b/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueService.kt deleted file mode 100644 index 69c9589..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueService.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.tenten.bittakotlin.security.service - -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.ResponseEntity - -interface ReissueService { - fun reissueTokens(request: HttpServletRequest, response: HttpServletResponse): ResponseEntity<*> -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueServiceImpl.kt deleted file mode 100644 index 27fd911..0000000 --- a/src/main/kotlin/org/tenten/bittakotlin/security/service/ReissueServiceImpl.kt +++ /dev/null @@ -1,84 +0,0 @@ -package org.tenten.bittakotlin.security.service - -import io.jsonwebtoken.ExpiredJwtException -import jakarta.servlet.http.Cookie -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.stereotype.Service -import org.tenten.bittakotlin.security.entity.RefreshEntity -import org.tenten.bittakotlin.security.jwt.JWTUtil -import org.tenten.bittakotlin.security.repository.RefreshRepository -import java.util.Date - -@Service -class ReissueServiceImpl( - private val jwtUtil: JWTUtil, - private val refreshRepository: RefreshRepository -) : ReissueService { - - override fun reissueTokens(request: HttpServletRequest, response: HttpServletResponse): ResponseEntity { - val refresh = extractRefreshToken(request) - if (refresh == null) { - return ResponseEntity("refresh token null", HttpStatus.BAD_REQUEST) - } - - try { - jwtUtil.isExpired(refresh) - } catch (e: ExpiredJwtException) { - return ResponseEntity("refresh token expired", HttpStatus.BAD_REQUEST) - } - - if (!isValidRefreshToken(refresh)) { - return ResponseEntity("invalid refresh token", HttpStatus.BAD_REQUEST) - } - - val username = jwtUtil.getUsername(refresh) - val role = jwtUtil.getRole(refresh) - - val newAccess = jwtUtil.createJwt("access", username, role, 600000L) - val newRefresh = jwtUtil.createJwt("refresh", username, role, 86400000L) - - updateRefreshToken(refresh, username, newRefresh) - setResponseTokens(response, newAccess, newRefresh) - - return ResponseEntity(HttpStatus.OK) - } - - private fun extractRefreshToken(request: HttpServletRequest): String? { - return request.cookies?.firstOrNull { it.name == "refresh" }?.value - } - - private fun isValidRefreshToken(refresh: String): Boolean { - val category = jwtUtil.getCategory(refresh) - return category == "refresh" && refreshRepository.existsByRefresh(refresh) - } - - private fun updateRefreshToken(oldRefresh: String, username: String, newRefresh: String) { - refreshRepository.deleteByRefresh(oldRefresh) - addRefreshEntity(username, newRefresh, 86400000L) - } - - private fun addRefreshEntity(username: String, refresh: String, expiredMs: Long) { - val date = Date(System.currentTimeMillis() + expiredMs) - val refreshEntity = RefreshEntity( - expiration = date.toString(), - refresh = refresh, - username = username - ) - refreshRepository.save(refreshEntity) - } - - private fun setResponseTokens(response: HttpServletResponse, access: String, refresh: String) { - response.setHeader("access", access) - response.addCookie(createCookie("refresh", refresh)) - } - - private fun createCookie(key: String, value: String): Cookie { - return Cookie(key, value).apply { - maxAge = 24 * 60 * 60 - isHttpOnly = true - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenService.kt b/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenService.kt new file mode 100644 index 0000000..0e006a8 --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenService.kt @@ -0,0 +1,10 @@ +package org.tenten.bittakotlin.security.service + +import org.tenten.bittakotlin.security.dto.TokenRequestDto +import org.tenten.bittakotlin.security.dto.TokenResponseDto + +interface TokenService { + fun create(requestDto: TokenRequestDto.Create): TokenResponseDto.Create + + fun reissue(refreshToken: String): String +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenServiceImpl.kt b/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenServiceImpl.kt new file mode 100644 index 0000000..5553493 --- /dev/null +++ b/src/main/kotlin/org/tenten/bittakotlin/security/service/TokenServiceImpl.kt @@ -0,0 +1,50 @@ +package org.tenten.bittakotlin.security.service + +import org.springframework.stereotype.Service +import org.tenten.bittakotlin.security.constant.TokenError +import org.tenten.bittakotlin.security.dto.TokenRequestDto +import org.tenten.bittakotlin.security.dto.TokenResponseDto +import org.tenten.bittakotlin.security.entity.RefreshEntity +import org.tenten.bittakotlin.security.exception.TokenException +import org.tenten.bittakotlin.security.repository.RefreshRepository +import org.tenten.bittakotlin.security.util.JwtTokenUtil +import java.util.Date + +@Service +class TokenServiceImpl( + private val refreshRepository: RefreshRepository, + + private val jwtTokenUtil: JwtTokenUtil +) : TokenService { + override fun create(requestDto: TokenRequestDto.Create): TokenResponseDto.Create { + val currentMills = System.currentTimeMillis() + + val sevenDaysAfter = Date(currentMills + 604800000) + + val accessToken = jwtTokenUtil.generateAccessToken(requestDto.username, requestDto.role, currentMills) + + val refreshToken = jwtTokenUtil.generateRefreshToken(currentMills) + + refreshRepository.save(RefreshEntity( + username = requestDto.username, + refresh = refreshToken, + expiration = sevenDaysAfter.toString() + )) + + return TokenResponseDto.Create( + accessToken = accessToken, + refreshToken = refreshToken + ) + } + + override fun reissue(refreshToken: String): String { + if (jwtTokenUtil.isExpired(refreshToken)) { + throw TokenException(TokenError.REFRESH_EXPIRED) + } + + val refresh = refreshRepository.findByRefresh(refreshToken) + .orElseThrow { TokenException(TokenError.REFRESH_NOT_FOUND) } + + return jwtTokenUtil.generateAccessToken(refresh.username, "ROLE_USER", System.currentTimeMillis()) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTUtil.kt b/src/main/kotlin/org/tenten/bittakotlin/security/util/JwtTokenUtil.kt similarity index 64% rename from src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTUtil.kt rename to src/main/kotlin/org/tenten/bittakotlin/security/util/JwtTokenUtil.kt index 581b374..439bbaa 100644 --- a/src/main/kotlin/org/tenten/bittakotlin/security/jwt/JWTUtil.kt +++ b/src/main/kotlin/org/tenten/bittakotlin/security/util/JwtTokenUtil.kt @@ -1,4 +1,4 @@ -package org.tenten.bittakotlin.security.jwt +package org.tenten.bittakotlin.security.util import io.jsonwebtoken.Jwts import org.springframework.beans.factory.annotation.Value @@ -8,58 +8,57 @@ import java.util.* import javax.crypto.SecretKey import javax.crypto.spec.SecretKeySpec - @Component -class JWTUtil(@Value("\${spring.jwt.secret}") secret: String) { +class JwtTokenUtil (@Value("\${spring.jwt.secret}") secret: String) { private val secretKey: SecretKey = SecretKeySpec( secret.toByteArray(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().algorithm ) - fun getUsername(token: String): String { + fun isExpired(token: String): Boolean { return Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .payload - .get("username", String::class.java) + .expiration + .before(Date()) } - fun getRole(token: String): String { + fun getUsername(token: String): String { return Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .payload - .get("role", String::class.java) + .get("username", String::class.java) } - fun getCategory(token: String): String { + fun getRole(token: String): String { return Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .payload - .get("category", String::class.java) + .get("role", String::class.java) } - fun isExpired(token: String): Boolean { - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .payload - .expiration - .before(Date()) + fun generateAccessToken(username: String, role: String, currentMills: Long): String { + return Jwts.builder() + .claims(mapOf( + "username" to username, + "role" to role + )) + .issuedAt(Date(currentMills)) + .expiration(Date(currentMills + 3600000)) + .signWith(secretKey) + .compact() } - fun createJwt(category: String, username: String, role: String, expiredMs: Long): String { + fun generateRefreshToken(currentMills: Long): String { return Jwts.builder() - .claim("category", category) - .claim("username", username) - .claim("role", role) - .issuedAt(Date(System.currentTimeMillis())) - .expiration(Date(System.currentTimeMillis() + expiredMs)) + .issuedAt(Date(currentMills)) + .expiration(Date(currentMills + 604800000)) .signWith(secretKey) .compact() }