diff --git a/build.gradle.kts b/build.gradle.kts index 8e1c0f8..a521e99 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,7 +32,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-jdbc") implementation("org.springframework.boot:spring-boot-starter-web") -// implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-security") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.springframework.ai:spring-ai-openai-spring-boot-starter") diff --git a/src/main/kotlin/com/chillin/auth/AuthController.kt b/src/main/kotlin/com/chillin/auth/AuthController.kt index 01e8683..f306c4e 100644 --- a/src/main/kotlin/com/chillin/auth/AuthController.kt +++ b/src/main/kotlin/com/chillin/auth/AuthController.kt @@ -4,8 +4,12 @@ import com.chillin.auth.appleid.AppleIdService import com.chillin.auth.request.SignInWithAppleRequest import com.chillin.auth.response.TokenResponse import com.chillin.member.MemberService +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.RequestBody +import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -22,4 +26,13 @@ class AuthController( memberService.register(accountId, refreshToken) return authService.issueToken(accountId) } + + @PostMapping("/oauth2/refresh") + fun refreshToken(@RequestHeader(HttpHeaders.AUTHORIZATION) bearerToken: String): ResponseEntity { + val token = bearerToken.substringAfter("Bearer").trim() + val newToken = authService.reissueToken(token) + + return if (newToken != null) ResponseEntity.ok(newToken) + else ResponseEntity.status(HttpStatus.UNAUTHORIZED).build() + } } \ No newline at end of file diff --git a/src/main/kotlin/com/chillin/auth/AuthService.kt b/src/main/kotlin/com/chillin/auth/AuthService.kt index 1bd959f..a33dfe4 100644 --- a/src/main/kotlin/com/chillin/auth/AuthService.kt +++ b/src/main/kotlin/com/chillin/auth/AuthService.kt @@ -1,14 +1,52 @@ package com.chillin.auth import com.chillin.auth.response.TokenResponse +import com.chillin.redis.RedisKeyFactory +import org.slf4j.LoggerFactory +import org.springframework.data.redis.core.StringRedisTemplate import org.springframework.stereotype.Service @Service class AuthService( - private val jwtProvider: JwtProvider + private val jwtProvider: JwtProvider, + private val redisTemplate: StringRedisTemplate ) { fun issueToken(accountId: String): TokenResponse { - val token = jwtProvider.issueToken(accountId) - return TokenResponse(token, jwtProvider.expirationSeconds) + logger.info("Issuing token...") + val tokens = jwtProvider.issueToken(accountId) + + logger.info("Saving refresh token to redis...") + val tokenKeyName = RedisKeyFactory.create(accountId, "refresh-token") + redisTemplate.opsForValue().set(tokenKeyName, tokens.refreshToken) + + return tokens + } + + fun reissueToken(token: String): TokenResponse? { + logger.info("Validating token...") + val payload = jwtProvider.validate(token) + val accountId = payload.subject + + val tokenKeyName = RedisKeyFactory.create(accountId, "refresh-token") + val storedToken = + redisTemplate.opsForValue().get(tokenKeyName) ?: return null // if token is already invalidated + + return if (isReuseDetected(token, storedToken)) invalidateToken(tokenKeyName) + else issueToken(accountId) + } + + private fun isReuseDetected(token: String, storedToken: String): Boolean { + logger.info("Checking if token is reused...") + return token != storedToken + } + + private fun invalidateToken(tokenKeyName: String): TokenResponse? { + logger.info("Invalidating token...") + redisTemplate.delete(tokenKeyName) + return null + } + + companion object { + private val logger = LoggerFactory.getLogger(AuthService::class.java) } -} +} \ No newline at end of file diff --git a/src/main/kotlin/com/chillin/auth/JwtProvider.kt b/src/main/kotlin/com/chillin/auth/JwtProvider.kt index c7dcffc..e5fa18b 100644 --- a/src/main/kotlin/com/chillin/auth/JwtProvider.kt +++ b/src/main/kotlin/com/chillin/auth/JwtProvider.kt @@ -1,30 +1,61 @@ package com.chillin.auth +import com.chillin.auth.response.TokenResponse +import io.jsonwebtoken.Claims +import io.jsonwebtoken.JwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.NestedConfigurationProperty import java.util.* @ConfigurationProperties(prefix = "custom.jwt") class JwtProvider( - private val secretKey: String, + secretKey: String, private val issuer: String, - val expirationSeconds: Long + + @NestedConfigurationProperty + private val expiration: TokenExpiration ) { - fun issueToken(accountId: String): String { + private val sig = Keys.hmacShaKeyFor(secretKey.toByteArray()) + + fun issueToken(accountId: String): TokenResponse { val iat = Date() - val exp = Date(iat.toInstant().plusSeconds(expirationSeconds).toEpochMilli()) - val sig = Keys.hmacShaKeyFor(secretKey.toByteArray()) - return Jwts.builder() + val accessToken = Jwts.builder() .claims() .subject(accountId) .issuer(issuer) .issuedAt(iat) - .expiration(exp) + .expiration(Date(iat.toInstant().plusSeconds(expiration.accessToken).toEpochMilli())) .and() .signWith(sig, Jwts.SIG.HS256) .compact() + + val refreshToken = Jwts.builder() + .claims() + .subject(accountId) + .issuer(issuer) + .issuedAt(iat) + .expiration(Date(iat.toInstant().plusSeconds(expiration.refreshToken).toEpochMilli())) + .and() + .signWith(sig, Jwts.SIG.HS256) + .compact() + + return TokenResponse(accessToken, refreshToken) + } + + fun validate(token: String): Claims { + try { + return Jwts.parser() + .verifyWith(sig) + .requireIssuer(issuer) + .build() + .parseSignedClaims(token) + .payload + } catch (e: JwtException) { + throw JwtException("Failed to verify token", e) + } } } \ No newline at end of file diff --git a/src/main/kotlin/com/chillin/auth/TokenExpiration.kt b/src/main/kotlin/com/chillin/auth/TokenExpiration.kt new file mode 100644 index 0000000..52aac23 --- /dev/null +++ b/src/main/kotlin/com/chillin/auth/TokenExpiration.kt @@ -0,0 +1,6 @@ +package com.chillin.auth + +class TokenExpiration( + val accessToken: Long, + val refreshToken: Long +) diff --git a/src/main/kotlin/com/chillin/auth/response/TokenResponse.kt b/src/main/kotlin/com/chillin/auth/response/TokenResponse.kt index 1ffa81e..e829733 100644 --- a/src/main/kotlin/com/chillin/auth/response/TokenResponse.kt +++ b/src/main/kotlin/com/chillin/auth/response/TokenResponse.kt @@ -1,7 +1,7 @@ package com.chillin.auth.response data class TokenResponse( - val token: String, - val expiresIn: Long, + val accessToken: String, + val refreshToken: String, val grantType: String = "Bearer" ) diff --git a/src/main/kotlin/com/chillin/drawing/DrawingController.kt b/src/main/kotlin/com/chillin/drawing/DrawingController.kt index 294120c..82fd829 100644 --- a/src/main/kotlin/com/chillin/drawing/DrawingController.kt +++ b/src/main/kotlin/com/chillin/drawing/DrawingController.kt @@ -1,17 +1,19 @@ package com.chillin.drawing -import com.chillin.epson.EpsonConnectService -import com.chillin.epson.request.PrintSettingsRequest import com.chillin.drawing.request.ImageGenerationRequest import com.chillin.drawing.request.ImagePrintRequest import com.chillin.drawing.response.DrawingResponse import com.chillin.drawing.response.DrawingResponseWrapper +import com.chillin.epson.EpsonConnectService +import com.chillin.epson.request.PrintSettingsRequest +import com.chillin.member.MemberService import com.chillin.openai.DallEService import com.chillin.s3.S3Service import com.chillin.type.DrawingType import com.chillin.type.MediaSubtype import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -26,16 +28,22 @@ class DrawingController( private val dallEService: DallEService, private val s3Service: S3Service, private val drawingService: DrawingService, - private val epsonConnectService: EpsonConnectService + private val epsonConnectService: EpsonConnectService, + private val memberService: MemberService ) { @PostMapping("/gen") - fun generateDrawing(@RequestBody imageGenerationRequest: ImageGenerationRequest): ResponseEntity { + fun generateDrawing( + @RequestBody imageGenerationRequest: ImageGenerationRequest, + @AuthenticationPrincipal accountId: String + ): ResponseEntity { val rawPrompt = imageGenerationRequest.prompt val pathname = "generated/${UUID.randomUUID()}.${MediaSubtype.JPEG.value}" val (url, revisedPrompt) = dallEService.generateImage(rawPrompt) val presignedUrl = s3Service.uploadImage(pathname, url, revisedPrompt) - val savedImage = drawingService.save(pathname, DrawingType.GENERATED, rawPrompt, revisedPrompt) + + val member = memberService.findMemberByAccountId(accountId) + val savedImage = drawingService.save(member, pathname, DrawingType.GENERATED, rawPrompt, revisedPrompt) val responseBody = DrawingResponse(savedImage.drawingId, presignedUrl, rawPrompt) return ResponseEntity.status(HttpStatus.CREATED).body(responseBody) @@ -53,8 +61,12 @@ class DrawingController( } @GetMapping - fun getDrawings(@RequestParam type: DrawingType): DrawingResponseWrapper { - val data = drawingService.getAllByType(type).map { drawing -> + fun getDrawings( + @RequestParam type: DrawingType, + @AuthenticationPrincipal accountId: String + ): DrawingResponseWrapper { + val member = memberService.findMemberByAccountId(accountId) + val data = drawingService.getMyDrawingsByType(type, member).map { drawing -> val url = s3Service.getImageUrl(drawing.pathname) DrawingResponse(drawing.drawingId, url, drawing.rawPrompt) } diff --git a/src/main/kotlin/com/chillin/drawing/DrawingRepository.kt b/src/main/kotlin/com/chillin/drawing/DrawingRepository.kt index 6e366f0..4447bef 100644 --- a/src/main/kotlin/com/chillin/drawing/DrawingRepository.kt +++ b/src/main/kotlin/com/chillin/drawing/DrawingRepository.kt @@ -1,9 +1,12 @@ package com.chillin.drawing; import com.chillin.drawing.domain.Drawing +import com.chillin.member.Member import com.chillin.type.DrawingType import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query interface DrawingRepository : JpaRepository { - fun findAllByTypeOrderByCreatedAtDesc(type: DrawingType): List + @Query("SELECT d FROM Drawing d WHERE d.type = :type AND d.member = :member ORDER BY d.createdAt DESC") + fun findAllByType(type: DrawingType, member: Member): List } \ No newline at end of file diff --git a/src/main/kotlin/com/chillin/drawing/DrawingService.kt b/src/main/kotlin/com/chillin/drawing/DrawingService.kt index 1531f0d..4da8dd7 100644 --- a/src/main/kotlin/com/chillin/drawing/DrawingService.kt +++ b/src/main/kotlin/com/chillin/drawing/DrawingService.kt @@ -1,6 +1,7 @@ package com.chillin.drawing import com.chillin.drawing.domain.Drawing +import com.chillin.member.Member import com.chillin.type.DrawingType import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -12,13 +13,14 @@ class DrawingService( private val logger = LoggerFactory.getLogger(DrawingService::class.java) fun save( + member: Member, pathname: String, drawingType: DrawingType, rawPrompt: String? = null, revisedPrompt: String? = null ): Drawing { logger.info("Saving drawing to db...") - val drawing = Drawing(pathname, drawingType, rawPrompt, revisedPrompt) + val drawing = Drawing(member, pathname, drawingType, rawPrompt, revisedPrompt) return drawingRepository.save(drawing).apply { logger.info("Saved drawing to db: drawingId=${drawingId}, pathname=$pathname, drawingType=$drawingType, rawPrompt=$rawPrompt, revisedPrompt=$revisedPrompt") @@ -36,5 +38,6 @@ class DrawingService( } } - fun getAllByType(type: DrawingType) = drawingRepository.findAllByTypeOrderByCreatedAtDesc(type) + fun getMyDrawingsByType(type: DrawingType, member: Member) = + drawingRepository.findAllByType(type, member) } \ No newline at end of file diff --git a/src/main/kotlin/com/chillin/drawing/domain/Drawing.kt b/src/main/kotlin/com/chillin/drawing/domain/Drawing.kt index 4b226ff..840b8cc 100644 --- a/src/main/kotlin/com/chillin/drawing/domain/Drawing.kt +++ b/src/main/kotlin/com/chillin/drawing/domain/Drawing.kt @@ -1,14 +1,18 @@ package com.chillin.drawing.domain +import com.chillin.member.Member import com.chillin.type.DrawingType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EntityListeners import jakarta.persistence.EnumType import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne import org.springframework.data.annotation.CreatedDate import org.springframework.data.annotation.LastModifiedDate import org.springframework.data.jpa.domain.support.AuditingEntityListener @@ -19,6 +23,10 @@ import java.time.LocalDateTime @EntityListeners(AuditingEntityListener::class) class Drawing( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + val member: Member, + @Column val pathname: String, diff --git a/src/main/kotlin/com/chillin/epson/EpsonConnectController.kt b/src/main/kotlin/com/chillin/epson/EpsonConnectController.kt index 8230584..f4ef212 100644 --- a/src/main/kotlin/com/chillin/epson/EpsonConnectController.kt +++ b/src/main/kotlin/com/chillin/epson/EpsonConnectController.kt @@ -2,10 +2,12 @@ package com.chillin.epson import com.chillin.adobe.AdobeService import com.chillin.drawing.DrawingService +import com.chillin.member.MemberService import com.chillin.s3.S3Service import com.chillin.type.DrawingType import com.chillin.type.MediaSubtype import org.slf4j.LoggerFactory +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -16,13 +18,15 @@ import java.util.* class EpsonConnectController( private val drawingService: DrawingService, private val s3Service: S3Service, - private val adobeService: AdobeService + private val adobeService: AdobeService, + private val memberService: MemberService ) { private val logger = LoggerFactory.getLogger(EpsonConnectController::class.java) - @PostMapping("/scan") - fun receiveScanData(@RequestParam files: Map) { + @PostMapping("/scan/{accountId}") + fun receiveScanData(@RequestParam files: Map, @PathVariable accountId: String) { logger.info("Received {} files={}", files.values.size, files.values.map(MultipartFile::getOriginalFilename)) + val member = memberService.findMemberByAccountId(accountId) files.values.forEach { file -> val mediaSubtype = MediaSubtype.parse(file.contentType) @@ -32,7 +36,7 @@ class EpsonConnectController( val uploadUrl = s3Service.getImageUrlForPOST(pathname) adobeService.cutout(downloadUrl, uploadUrl) - drawingService.save(pathname, DrawingType.SCANNED) + drawingService.save(member, pathname, DrawingType.SCANNED) } } } \ No newline at end of file diff --git a/src/main/kotlin/com/chillin/exception/ExceptionController.kt b/src/main/kotlin/com/chillin/exception/ExceptionController.kt new file mode 100644 index 0000000..286c168 --- /dev/null +++ b/src/main/kotlin/com/chillin/exception/ExceptionController.kt @@ -0,0 +1,18 @@ +package com.chillin.exception + +import io.jsonwebtoken.JwtException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class ExceptionController { + + @ExceptionHandler(JwtException::class) + fun handleJwtException(e: JwtException): ResponseEntity { + val response = + ExceptionResponse(401, HttpStatus.UNAUTHORIZED.reasonPhrase, e.message ?: "Failed to verify token") + return ResponseEntity.status(401).body(response) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/chillin/exception/ExceptionResponse.kt b/src/main/kotlin/com/chillin/exception/ExceptionResponse.kt new file mode 100644 index 0000000..e3d479b --- /dev/null +++ b/src/main/kotlin/com/chillin/exception/ExceptionResponse.kt @@ -0,0 +1,7 @@ +package com.chillin.exception + +class ExceptionResponse( + val code: Int, + val description: String, + val message: String, +) \ No newline at end of file diff --git a/src/main/kotlin/com/chillin/member/MemberRepository.kt b/src/main/kotlin/com/chillin/member/MemberRepository.kt index 86ddc08..dd46d75 100644 --- a/src/main/kotlin/com/chillin/member/MemberRepository.kt +++ b/src/main/kotlin/com/chillin/member/MemberRepository.kt @@ -5,4 +5,5 @@ import org.springframework.stereotype.Repository @Repository interface MemberRepository : JpaRepository { + fun findByAccountId(accountId: String): Member? } diff --git a/src/main/kotlin/com/chillin/member/MemberService.kt b/src/main/kotlin/com/chillin/member/MemberService.kt index 8e36269..d8c154d 100644 --- a/src/main/kotlin/com/chillin/member/MemberService.kt +++ b/src/main/kotlin/com/chillin/member/MemberService.kt @@ -14,6 +14,10 @@ class MemberService( memberRepository.save(member) } + fun findMemberByAccountId(accountId: String): Member { + return memberRepository.findByAccountId(accountId) ?: throw RuntimeException("Member not found") + } + companion object { private val logger = LoggerFactory.getLogger(MemberService::class.java) } diff --git a/src/main/kotlin/com/chillin/motion/MotionController.kt b/src/main/kotlin/com/chillin/motion/MotionController.kt index d75e784..7d4e7d1 100644 --- a/src/main/kotlin/com/chillin/motion/MotionController.kt +++ b/src/main/kotlin/com/chillin/motion/MotionController.kt @@ -2,12 +2,14 @@ package com.chillin.motion import com.chillin.drawing.DrawingService import com.chillin.drawing.response.DrawingResponse +import com.chillin.member.MemberService import com.chillin.motion.request.MotionRequest import com.chillin.s3.S3Service import com.chillin.type.DrawingType import com.chillin.type.MediaSubtype import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -20,17 +22,23 @@ import java.util.* class MotionController( private val drawingService: DrawingService, private val s3Service: S3Service, - private val motionService: MotionService + private val motionService: MotionService, + private val memberService: MemberService ) { @PostMapping("/{drawingId}") - fun addMotion(@PathVariable drawingId: Long, @RequestBody request: MotionRequest): ResponseEntity { + fun addMotion( + @PathVariable drawingId: Long, + @RequestBody request: MotionRequest, + @AuthenticationPrincipal accountId: String + ): ResponseEntity { val srcPathname = drawingService.getNameById(drawingId) val (jpegBytes, _) = s3Service.getImageData(srcPathname) val gifBytes = motionService.addMotion(srcPathname, jpegBytes, request.motionType) val targetPathname = "animated/${UUID.randomUUID()}.${MediaSubtype.GIF.value}" val (uploadedFilePathname, url) = s3Service.uploadImage(targetPathname, gifBytes) - val savedImage = drawingService.save(uploadedFilePathname, DrawingType.ANIMATED) + val member = memberService.findMemberByAccountId(accountId) + val savedImage = drawingService.save(member, uploadedFilePathname, DrawingType.ANIMATED) val response = DrawingResponse(savedImage.drawingId, url) return ResponseEntity.status(HttpStatus.CREATED).body(response) diff --git a/src/main/kotlin/com/chillin/security/JwtAuthenticationFilter.kt b/src/main/kotlin/com/chillin/security/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..77dce28 --- /dev/null +++ b/src/main/kotlin/com/chillin/security/JwtAuthenticationFilter.kt @@ -0,0 +1,37 @@ +package com.chillin.security + +import com.chillin.auth.JwtProvider +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpHeaders +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.OncePerRequestFilter + +class JwtAuthenticationFilter( + private val jwtProvider: JwtProvider +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val token = request.getHeader(HttpHeaders.AUTHORIZATION) + ?.substringAfter(BEARER) + ?.trim() + + if (token != null) { + val accountId = jwtProvider.validate(token).subject + val authentication = UsernamePasswordAuthenticationToken(accountId, null, emptyList()) + + SecurityContextHolder.getContext().authentication = authentication + } + filterChain.doFilter(request, response) + } + + companion object { + private const val BEARER = "Bearer" + } +} diff --git a/src/main/kotlin/com/chillin/security/SecurityConfig.kt b/src/main/kotlin/com/chillin/security/SecurityConfig.kt new file mode 100644 index 0000000..0ba2aaf --- /dev/null +++ b/src/main/kotlin/com/chillin/security/SecurityConfig.kt @@ -0,0 +1,35 @@ +package com.chillin.security + +import com.chillin.auth.JwtProvider +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val jwtProvider: JwtProvider +) { + + @Bean + fun filterChain(http: HttpSecurity): SecurityFilterChain { + return http + .authorizeHttpRequests { auth -> + auth + .requestMatchers("/auth/oauth2/**", "/scan/**").permitAll() + .anyRequest().authenticated() + } + .addFilterBefore(JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter::class.java) + .sessionManagement { session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + .csrf { it.disable() } + .httpBasic { it.disable() } + .formLogin { it.disable() } + .build() + } +} \ No newline at end of file