Skip to content
6 changes: 6 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
testImplementation("org.springframework.security:spring-security-test")

// Jasypt
implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:3.0.5")
Expand Down Expand Up @@ -93,6 +95,10 @@ sourceSets {
}
}

tasks.test {
useJUnitPlatform()
}

tasks.named("clean") {
doLast {
file(querydslDir).deleteRecursively()
Expand Down
32 changes: 25 additions & 7 deletions src/main/kotlin/com/study/core/auth/application/AuthService.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.study.core.auth.application

import com.study.core.auth.dto.response.TokenResponse
import com.study.core.auth.infrastructure.dto.response.AccessTokenResponse
import com.study.core.auth.infrastructure.dto.response.TokenResponse
import com.study.core.auth.infrastructure.support.RefreshTokenCookieSupporter
import com.study.core.global.enums.AuthProvider
import com.study.core.global.exceptions.CustomException
import com.study.core.global.exceptions.auth.AuthExceptionType
Expand All @@ -9,14 +11,16 @@ import com.study.core.user.domain.User
import com.study.core.user.domain.UserOAuth
import com.study.core.user.infrastructure.UserOAuthRepository
import com.study.core.user.infrastructure.UserRepository
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class AuthService(
private val userRepository: UserRepository,
private val userOAuthRepository: UserOAuthRepository,
private val jwtProvider: JwtProvider
private val jwtProvider: JwtProvider,
private val refreshTokenCookieSupporter: RefreshTokenCookieSupporter
) {

@Transactional
Expand All @@ -28,7 +32,7 @@ class AuthService(
?: run {
// 이메일 기준 기존 회원 조회
val user: User = userRepository.findByEmail(email)
// 메일이 없는 경우 신규 사용자 저장
// 메일이 없는 경우 신규 사용자 저장
?: userRepository.save(User.of(email = email))

// 소셜 계정 매핑 저장
Expand All @@ -52,8 +56,8 @@ class AuthService(

// 액세스 토큰 재발급
@Transactional(readOnly = true)
fun reissue(refreshToken: String?): TokenResponse {
if (refreshToken.isNullOrBlank()) {
fun reissue(refreshToken: String, response: HttpServletResponse): AccessTokenResponse {
if (refreshToken.isBlank()) {
throw CustomException(AuthExceptionType.UNAUTHENTICATED)
}

Expand All @@ -62,10 +66,24 @@ class AuthService(
}

val userId = jwtProvider.getSubjectAsUserId(refreshToken)

val user = userRepository.findById(userId)
.orElseThrow { CustomException(AuthExceptionType.USER_NOT_FOUND) }

return jwtProvider.generateToken(user.id, user.role)
// accessToken, refreshToken 재발급
val tokenResponse = jwtProvider.generateToken(user.id, user.role)

// 새 refreshToken로 갱신
refreshTokenCookieSupporter.addRefreshTokenCookie(response, tokenResponse.refreshToken)

return AccessTokenResponse(
grantType = tokenResponse.grantType,
accessToken = tokenResponse.accessToken,
accessTokenExpiresIn = tokenResponse.accessTokenExpiresIn
)
}

@Transactional(readOnly = true)
fun logout(response: HttpServletResponse) {
refreshTokenCookieSupporter.expireRefreshTokenCookie(response)
Comment on lines +86 to +87
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HttpServletResponse에 값을 심어주는 건 Controller에서 하는게 좋지 않을까요?

}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.study.core.auth.dto.response
package com.study.core.auth.infrastructure.dto.response

data class TokenResponse(
data class AccessTokenResponse(
val grantType: String,
val accessToken: String,
val refreshToken: String,
val accessTokenExpiresIn: Long
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.study.core.auth.infrastructure.dto.response

data class TokenResponse(
val grantType: String,
val accessToken: String,
val accessTokenExpiresIn: Long,
val refreshToken: String,
val refreshTokenExpiresIn: Long
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.study.core.auth.infrastructure.support

import com.study.core.auth.infrastructure.dto.response.TokenResponse
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseCookie
import org.springframework.stereotype.Component

@Component
class RefreshTokenCookieSupporter {

fun addRefreshTokenCookie(
response: HttpServletResponse,
refreshToken: String
) {
// refreshToken은 HttpOnly + Secure cookie로 전송
val refreshCookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.secure(true)
.path("/")
.sameSite("Strict")
.maxAge(60L * 60 * 24 * 14) // 14일
.build()
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString())
}

fun expireRefreshTokenCookie(response: HttpServletResponse) {
val expiredCookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.secure(true)
.path("/")
.sameSite("Strict")
.maxAge(0)
.build()
response.addHeader(HttpHeaders.SET_COOKIE, expiredCookie.toString())
}
}
43 changes: 10 additions & 33 deletions src/main/kotlin/com/study/core/auth/oauth/OAuth2SuccessHandler.kt
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
package com.study.core.auth.oauth

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.study.core.auth.application.AuthService
import com.study.core.auth.dto.response.TokenResponse
import com.study.core.auth.infrastructure.support.RefreshTokenCookieSupporter
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseCookie
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.Authentication
import org.springframework.security.web.authentication.AuthenticationSuccessHandler
import org.springframework.stereotype.Component

@Component
class OAuth2SuccessHandler(
private val authService: AuthService
private val authService: AuthService,
private val refreshTokenCookieSupporter: RefreshTokenCookieSupporter,
@Value("\${app.oauth2.redirect-uri}")
private val frontRedirectUri: String
) : AuthenticationSuccessHandler {

private val objectMapper = jacksonObjectMapper()

override fun onAuthenticationSuccess(
request: HttpServletRequest,
response: HttpServletResponse,
Expand All @@ -30,32 +29,10 @@ class OAuth2SuccessHandler(
// (provider, providerId, email)로 로그인 / 없으면 신규 가입
val tokenResponse = authService.login(provider, userInfo.id, userInfo.email)

tokenToResponse(response, tokenResponse)
}
refreshTokenCookieSupporter.addRefreshTokenCookie(response, tokenResponse.refreshToken)

private fun tokenToResponse(
response: HttpServletResponse,
tokenResponse: TokenResponse
) {
// refreshToken은 HttpOnly + Secure cookie로 전송
val refreshCookie = ResponseCookie.from("refreshToken", tokenResponse.refreshToken)
.httpOnly(true)
.secure(true)
.path("/")
.sameSite("Strict")
.maxAge(60L * 60 * 24 * 14) // 14일
.build()
response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString())

// accessToken은 body로 전송
val accessTokenBody = mapOf(
"grantType" to tokenResponse.grantType,
"accessToken" to tokenResponse.accessToken,
"accessTokenExpireIn" to tokenResponse.accessTokenExpiresIn
)

response.contentType = "application/json"
response.characterEncoding = "UTF-8"
response.writer.write(objectMapper.writeValueAsString(accessTokenBody))
response.sendRedirect(frontRedirectUri)
}


}
15 changes: 11 additions & 4 deletions src/main/kotlin/com/study/core/auth/ui/AuthController.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.study.core.auth.ui

import com.study.core.auth.dto.response.TokenResponse
import com.study.core.auth.application.AuthService
import com.study.core.auth.infrastructure.dto.response.AccessTokenResponse
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.CookieValue
Expand All @@ -23,8 +23,15 @@ class AuthController(

@PostMapping("/reissue")
fun reissue(
@CookieValue(value = "refreshToken", required = false) refreshToken: String?
): ResponseEntity<TokenResponse> {
return ResponseEntity.ok(authService.reissue(refreshToken))
@CookieValue(value = "refreshToken", required = false) refreshToken: String,
response: HttpServletResponse
): ResponseEntity<AccessTokenResponse> {
return ResponseEntity.ok(authService.reissue(refreshToken, response))
}

@PostMapping("/logout")
fun logout(response: HttpServletResponse): ResponseEntity<Void> {
authService.logout(response)
return ResponseEntity.ok().build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ class SecurityConfig(
it.requestMatchers(
"/auth/login", // 로그인 시작점
"/auth/reissue",
"/auth/logout",
"/oauth2/**",
"/login/oauth2/**" // 콜백
"/auth/callback" // 콜백
).permitAll()
.anyRequest().authenticated()
}
Expand Down
26 changes: 20 additions & 6 deletions src/main/kotlin/com/study/core/global/security/JwtProvider.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
package com.study.core.global.security

import com.study.core.auth.dto.response.TokenResponse
import com.study.core.auth.infrastructure.dto.response.TokenResponse
import com.study.core.global.enums.UserRole
import io.jsonwebtoken.*
import io.jsonwebtoken.io.Decoders
import io.jsonwebtoken.security.Keys
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
Expand All @@ -16,9 +17,15 @@ import java.security.Key
import java.util.Date

@Component
class JwtProvider (
@Value("\${jwt.secret}") private val secretKey: String
class JwtProvider(
@Value("\${jwt.secret}")
private val secretKey: String,
@Value("\${jwt.log-token:false}")
private val logToken: Boolean
){

private val log = LoggerFactory.getLogger(JwtProvider::class.java)

// key 초기화
private val key : Key by lazy {
Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey))
Expand All @@ -28,7 +35,7 @@ class JwtProvider (
fun generateToken(userId: Long, role: UserRole) : TokenResponse {
val now = Date().time
val accessTokenExpiresIn: Date = Date(now + ACCESS_TOKEN_EXPIRE_TIME)

val refreshTokenExpiresIn: Date = Date(now + REFRESH_TOKEN_EXPIRE_TIME)
// accessToken 생성
val accessToken = Jwts.builder()
.setSubject(userId.toString()) // 토큰 주체
Expand All @@ -40,15 +47,22 @@ class JwtProvider (
// refreshToken 생성
val refreshToken = Jwts.builder()
.setSubject(userId.toString())
.setExpiration(Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.setExpiration(refreshTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS512)
.compact()

// 로컬에서만 로그 노출
if (logToken) {
log.debug("accessToken: $accessToken")
log.debug("refreshToken: $refreshToken")
}

return TokenResponse(
grantType = BEARER_TYPE,
accessToken = accessToken,
accessTokenExpiresIn = accessTokenExpiresIn.time,
refreshToken = refreshToken,
accessTokenExpiresIn = accessTokenExpiresIn.time
refreshTokenExpiresIn = refreshTokenExpiresIn.time
)
}

Expand Down
7 changes: 6 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ spring:
- email
- profile

app:
oauth2:
redirect-uri: "http://localhost:3000/auth/callback"

jwt:
secret: ENC(f2XSsyj/+tUsSlxV7tM8Rk5LEdp4mCX3BStVto1KOYYoAGpyeJg/+oJf6W76loaVM0ZSQ2l/MKpq9CVyZ4De5AzFGNM5HA1BviCg4ZEcH2Fyg0eNqvFC8KsUOA5mGm6P995uVtBMk6V6yNBOOujwFFk9CmmH/ykRtkI3s/t+SVST93tqUYc7IgbnV+7LfMln)
log-token: false

jasypt:
encryptor:
password: ${ENCRYPT_KEY} # 환경변수 주입
password: ${ENCRYPT_KEY} # 환경변수 주입
2 changes: 2 additions & 0 deletions src/test/kotlin/com/study/core/DokiApplicationTests.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package com.study.core

import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles

@SpringBootTest
@ActiveProfiles("test")
class DokiApplicationTests {

@Test
Expand Down
Loading
Loading