Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.retoday.api.domain.auth.dto.request.LoginRequest
import com.retoday.api.domain.auth.dto.request.RefreshRequest
import com.retoday.api.domain.auth.dto.response.LoginResponse
import com.retoday.api.domain.auth.dto.response.RefreshResponse
import com.retoday.api.global.annotation.AuthenticationId
import com.retoday.core.domain.auth.service.AuthService
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.PostMapping
Expand Down Expand Up @@ -35,4 +36,12 @@ class AuthController(
authService
.refresh(request.toCommand())
.let { RefreshResponse.from(it) }

@PostMapping("/logout")
fun logout(
@AuthenticationId
userId: Long
) {
authService.logout(userId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.retoday.api.global.annotation

import org.springframework.security.core.annotation.AuthenticationPrincipal

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@AuthenticationPrincipal
annotation class AuthenticationId
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@Configuration
@EnableWebSecurity
class SecurityConfiguration {
private companion object {
const val ADMIN_ENDPOINT_PREFIX = "/api/v1/admin"
val PERMITTED_AUTH_ENDPOINTS =
arrayOf(
"/api/v1/auth/login",
"/api/v1/auth/refresh"
)
val PERMITTED_ACTUATOR_ENDPOINTS = arrayOf("/actuator/health")
}

@Bean
fun securityFilterChain(
http: HttpSecurity,
Expand All @@ -35,10 +45,12 @@ class SecurityConfiguration {
}
authorizeHttpRequests {
it
.requestMatchers("/api/v1/admin/**")
.requestMatchers("$ADMIN_ENDPOINT_PREFIX/**")
.hasRole(Role.ADMIN.name)
.requestMatchers("/api/v1/auth/**", "/actuator/**")
.permitAll()
.requestMatchers(
*PERMITTED_AUTH_ENDPOINTS,
*PERMITTED_ACTUATOR_ENDPOINTS
).permitAll()
.anyRequest()
.authenticated()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ import com.retoday.api.domain.auth.dto.response.RefreshResponse
import com.retoday.api.fixture.createLoginRequest
import com.retoday.api.fixture.createRefreshRequest
import com.retoday.api.snippet.*
import com.retoday.api.util.document
import com.retoday.api.util.expectBody
import com.retoday.api.util.expectError
import com.retoday.api.util.expectStatus
import com.retoday.api.util.*
import com.retoday.core.domain.auth.exception.InvalidAuthenticationException
import com.retoday.core.domain.auth.exception.InvalidOAuthTokenException
import com.retoday.core.domain.auth.exception.RefreshTokenNotFoundException
Expand All @@ -20,7 +17,10 @@ import com.retoday.core.domain.user.exception.UserNotFoundException
import com.retoday.core.fixture.createLoginResult
import com.retoday.core.fixture.createRefreshResult
import io.mockk.every
import io.mockk.just
import io.mockk.runs
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.web.reactive.server.expectBody

@WebMvcTest(AuthController::class)
class AuthControllerTest : ControllerTest() {
Expand Down Expand Up @@ -137,5 +137,39 @@ class AuthControllerTest : ControllerTest() {
}
}
}

describe("logout()은") {
val request =
webClient
.post()
.uri("/auth/logout")
.withAuthentication()

context("유효한 요청이 주어진 경우") {
every { authService.logout(any()) } just runs

it("상태 코드 200을 반환한다.") {
request
.exchange()
.expectStatus(200)
.expectBody<Void>()
.document("로그아웃 성공(200)")
}
}

context("로그아웃한 사용자의 요청이 주어진 경우") {
every { authService.logout(any()) } throws RefreshTokenNotFoundException()

it("상태 코드 404와 ErrorResponse를 반환한다.") {
request
.exchange()
.expectStatus(404)
.expectError()
.document("로그아웃 실패(404)") {
responseBody(errorResponseFields)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ package com.retoday.api.fixture

import com.retoday.api.domain.auth.dto.request.LoginRequest
import com.retoday.api.domain.auth.dto.request.RefreshRequest
import com.retoday.api.global.security.RetodayAuthentication
import com.retoday.core.domain.user.entity.Provider
import com.retoday.core.domain.user.entity.Role
import com.retoday.core.fixture.ID
import com.retoday.core.fixture.PROVIDER
import com.retoday.core.fixture.ROLES
import com.retoday.core.fixture.TOKEN

fun createRetodayAuthentication(
id: Long = ID,
roles: Set<Role> = ROLES
): RetodayAuthentication =
RetodayAuthentication(
id = id,
roles = roles
)

fun createLoginRequest(
oAuthToken: String = TOKEN,
provider: Provider = PROVIDER
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.retoday.api.util

import com.retoday.api.fixture.createRetodayAuthentication
import com.retoday.api.global.dto.ErrorResponse
import io.kotest.matchers.shouldBe
import org.springframework.test.web.reactive.server.WebTestClient.BodySpec
import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.test.web.reactive.server.WebTestClient.*
import org.springframework.test.web.reactive.server.expectBody

fun ResponseSpec.expectStatus(status: Int): ResponseSpec =
Expand All @@ -15,3 +17,7 @@ inline fun <reified T : Any> ResponseSpec.expectBody(body: T): BodySpec<T, *> =
.consumeWith { it.responseBody shouldBe body }

fun ResponseSpec.expectError(): BodySpec<ErrorResponse, *> = expectBody<ErrorResponse>()

fun RequestHeadersSpec<*>.withAuthentication(
authentication: Authentication = createRetodayAuthentication()
): RequestHeadersSpec<*> = also { SecurityContextHolder.getContext().authentication = authentication }
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ class AuthService(
)
}

fun logout(userId: Long) {
refreshTokenRepository
.findByIdOrNull(userId)
?.let { refreshTokenRepository.delete(it) }
?: throw RefreshTokenNotFoundException()
}

private fun User.createTokens(): Pair<String, String> {
val accessToken = jwtProvider.createToken(jwtProperties.accessTokenExpiration, this)
val refreshToken =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class AuthServiceTest : BehaviorSpec() {
)

init {
Given("가입한 사용자가 유효한 OAuth2 토큰을 가지고 있고") {
Given("가입한 사용자가") {
val command = createLoginCommand()
val user = createUser()
val refreshToken = createRefreshToken()
Expand Down Expand Up @@ -83,7 +83,7 @@ class AuthServiceTest : BehaviorSpec() {
}
}

Given("가입하지 않은 사용자가 유효한 OAuth2 토큰을 가지고 있는 경우") {
Given("가입하지 않은 사용자가") {
val command = createLoginCommand()
val user = createUser()
val refreshToken = createRefreshToken()
Expand All @@ -106,7 +106,7 @@ class AuthServiceTest : BehaviorSpec() {
}
}

Given("사용자가 유효하지 않은 OAuth2 토큰을 가진 경우") {
Given("사용자가 유효하지 않은 OAuth2 토큰을 가지고") {
val command = createLoginCommand()

every { oAuthClient.provider } returns command.provider
Expand All @@ -119,17 +119,17 @@ class AuthServiceTest : BehaviorSpec() {
}
}

Given("사용자가 유효한 리프레시 토큰을 가지고 있고") {
Given("로그인한 사용자가") {
val command = createRefreshCommand()
val user = createUser()
val refreshToken = createRefreshToken()

every { userRepository.findByIdOrNull(any()) } returns user
every { refreshTokenRepository.findByIdOrNull(any()) } returns refreshToken
every { refreshTokenRepository.save(any()) } returns refreshToken
every { refreshTokenRepository.delete(any()) } just runs

And("리프레시 토큰이 로그인 시점에 발급된 리프레시 토큰과 같은 경우") {
every { refreshTokenRepository.findByIdOrNull(any()) } returns refreshToken

And("로그인 시점에 발급된 리프레시 토큰을 가지고") {
When("토큰 리프레시를 시도하면") {
val result = authService.refresh(command)

Expand All @@ -139,7 +139,7 @@ class AuthServiceTest : BehaviorSpec() {
}
}

And("리프레시 토큰이 로그인 시점에 발급된 리프레시 토큰과 다른 경우") {
And("로그인 시점에 발급된 리프레시 토큰과 다른 리프레시 토큰을 가지고") {
val storedRefreshToken = "dsadadasdsdsdsdsdsdsadsadads"

every { refreshTokenRepository.findByIdOrNull(any()) } returns
Expand All @@ -153,37 +153,58 @@ class AuthServiceTest : BehaviorSpec() {
}
}
}
}

Given("탈퇴한 사용자가 유효한 리프레시 토큰을 가진 경우") {
val command = createRefreshCommand()
val refreshToken = createRefreshToken()

every { userRepository.findByIdOrNull(any()) } returns null
every { refreshTokenRepository.findByIdOrNull(any()) } returns refreshToken
When("로그아웃을 시도하면") {
authService.logout(user.id!!)

When("토큰 리프레시를 시도하면") {
Then("예외가 발생한다.") {
shouldThrow<UserNotFoundException> { authService.refresh(command) }
Then("로그아웃 처리가 된다.") {
verify { refreshTokenRepository.delete(any()) }
}
}
}

Given("로그아웃한 사용자가 유효한 리프레시 토큰을 가진 경우") {
val command = createRefreshCommand()
Given("로그아웃한 사용자가") {
val user = createUser()

every { userRepository.findByIdOrNull(any()) } returns user
every { refreshTokenRepository.findByIdOrNull(any()) } returns null

When("토큰 리프레시를 시도하면") {
And("유효한 리프레시 토큰을 가지고") {
val command = createRefreshCommand()

When("토큰 리프레시를 시도하면") {
Then("예외가 발생한다.") {
shouldThrow<RefreshTokenNotFoundException> { authService.refresh(command) }
}
}
}

When("로그아웃을 시도하면") {
Then("예외가 발생한다.") {
shouldThrow<RefreshTokenNotFoundException> { authService.refresh(command) }
shouldThrow<RefreshTokenNotFoundException> { authService.logout(user.id!!) }
}
}
}

Given("탈퇴한 사용자가") {
val command = createRefreshCommand()

every { userRepository.findByIdOrNull(any()) } returns null

And("유효한 리프레시 토큰을 가지고") {
val refreshToken = createRefreshToken()

every { refreshTokenRepository.findByIdOrNull(any()) } returns refreshToken

When("토큰 리프레시를 시도하면") {
Then("예외가 발생한다.") {
shouldThrow<UserNotFoundException> { authService.refresh(command) }
}
}
}
}

Given("사용자가 유효하지 않은 리프레시 토큰을 가진 경우") {
Given("사용자가 유효하지 않은 리프레시 토큰을 가지고") {
val command = createRefreshCommand()

every { jwtProvider.extractUserId(any()) } throws InvalidAuthenticationException()
Expand Down