Initial commit.
This commit is contained in:
14
imagora-exposition/build.gradle.kts
Normal file
14
imagora-exposition/build.gradle.kts
Normal file
@@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
implementation(project(":imagora-domain"))
|
||||
implementation(project(":imagora-application"))
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("tools.jackson.module:jackson-module-kotlin:3.0.2")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.3")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.takiguchi.imagora.exposition.core
|
||||
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.http.HttpStatus.BAD_REQUEST
|
||||
import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
import org.takiguchi.imagora.exposition.core.model.HttpResponse
|
||||
import org.takiguchi.imagora.exposition.core.model.HttpResponseError
|
||||
import org.takiguchi.imagora.exposition.core.resulterror.exception.ResultErrorException
|
||||
import org.takiguchi.imagora.exposition.core.resulterror.mapper.ResultErrorMapper
|
||||
|
||||
@Configuration
|
||||
@RestControllerAdvice
|
||||
open class ExceptionHandlingConfiguration(
|
||||
private val resultErrorMapper: ResultErrorMapper
|
||||
) {
|
||||
@ExceptionHandler(HttpMessageNotReadableException::class)
|
||||
fun handleHttpMessageNotReadableException(ex: HttpMessageNotReadableException): ResponseEntity<HttpResponse<Nothing>> {
|
||||
val errorMessage = "Invalid request payload."
|
||||
return ResponseEntity(HttpResponseError(errorMessage), BAD_REQUEST)
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResultErrorException::class)
|
||||
fun handleResultError(ex: ResultErrorException): ResponseEntity<HttpResponse<Nothing>> {
|
||||
return ResponseEntity(
|
||||
HttpResponseError(ex.error.message),
|
||||
resultErrorMapper.mapHttpStatus(ex.error)
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
fun handleException(ex: Exception): ResponseEntity<HttpResponse<Nothing>> {
|
||||
val errorMessage = "Internal Server Error with Cause: ${ex.message}"
|
||||
return ResponseEntity(HttpResponseError(errorMessage), INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.takiguchi.imagora.exposition.core.model
|
||||
|
||||
interface HttpResponse<T> {
|
||||
fun getError(): String?
|
||||
}
|
||||
|
||||
data class HttpResponseError<Nothing>(private val error: String) : HttpResponse<Nothing> {
|
||||
override fun getError(): String? = error
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.takiguchi.imagora.exposition.core.resulterror
|
||||
|
||||
import com.github.michaelbull.result.Result
|
||||
import com.github.michaelbull.result.annotation.UnsafeResultErrorAccess
|
||||
import com.github.michaelbull.result.annotation.UnsafeResultValueAccess
|
||||
import org.takiguchi.imagora.domain.core.error.DomainError
|
||||
import org.takiguchi.imagora.exposition.core.resulterror.exception.ResultErrorException
|
||||
|
||||
@OptIn(UnsafeResultValueAccess::class, UnsafeResultErrorAccess::class)
|
||||
inline fun <T> response(block: () -> Result<T, DomainError>): T {
|
||||
val result = block()
|
||||
return when (result.isOk) {
|
||||
true -> result.value
|
||||
false -> throw ResultErrorException(result.error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.takiguchi.imagora.exposition.core.resulterror.exception
|
||||
|
||||
import org.takiguchi.imagora.domain.core.error.DomainError
|
||||
|
||||
data class ResultErrorException(val error: DomainError) : RuntimeException()
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.takiguchi.imagora.exposition.core.resulterror.mapper
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR
|
||||
import org.springframework.stereotype.Component
|
||||
import org.takiguchi.imagora.domain.core.error.DomainError
|
||||
import org.takiguchi.imagora.domain.user.error.UserError
|
||||
import org.takiguchi.imagora.exposition.core.resulterror.mapper.errormapper.UserErrorMapper
|
||||
|
||||
@Component
|
||||
class ResultErrorMapper(
|
||||
private val userErrorMapper: UserErrorMapper
|
||||
) {
|
||||
fun mapHttpStatus(error: DomainError): HttpStatus = when(error) {
|
||||
is UserError -> userErrorMapper.map(error)
|
||||
else -> INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.takiguchi.imagora.exposition.core.resulterror.mapper.errormapper
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.takiguchi.imagora.domain.core.error.DomainError
|
||||
|
||||
interface HttpStatusErrorMapper<E : DomainError> {
|
||||
fun map(error: E): HttpStatus
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.takiguchi.imagora.exposition.core.resulterror.mapper.errormapper
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.HttpStatus.*
|
||||
import org.springframework.stereotype.Component
|
||||
import org.takiguchi.imagora.domain.user.error.AuthenticationRequiredError
|
||||
import org.takiguchi.imagora.domain.user.error.OperationNotPermittedError
|
||||
import org.takiguchi.imagora.domain.user.error.UserError
|
||||
|
||||
@Component
|
||||
class UserErrorMapper : HttpStatusErrorMapper<UserError> {
|
||||
override fun map(error: UserError): HttpStatus = when (error) {
|
||||
is AuthenticationRequiredError -> UNAUTHORIZED
|
||||
is OperationNotPermittedError -> FORBIDDEN
|
||||
else -> BAD_REQUEST
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.takiguchi.imagora.exposition.core.security
|
||||
|
||||
import com.github.michaelbull.result.Ok
|
||||
import com.github.michaelbull.result.flatMap
|
||||
import com.github.michaelbull.result.map
|
||||
import com.github.michaelbull.result.toErrorIfNull
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.security.core.userdetails.UserDetailsService
|
||||
import org.springframework.stereotype.Component
|
||||
import org.takiguchi.imagora.application.core.security.model.CustomUserDetails
|
||||
import org.takiguchi.imagora.domain.user.error.AuthenticationRequiredError
|
||||
import org.takiguchi.imagora.domain.user.inputport.UserInputPort
|
||||
import org.takiguchi.imagora.exposition.core.resulterror.response
|
||||
import java.util.*
|
||||
|
||||
@Component
|
||||
class CustomUserDetailsService(
|
||||
private val userInputPort: UserInputPort
|
||||
) : UserDetailsService {
|
||||
override fun loadUserByUsername(username: String?): UserDetails = response {
|
||||
username?.let(UUID::fromString)
|
||||
.let(::Ok)
|
||||
.toErrorIfNull { AuthenticationRequiredError() }
|
||||
.flatMap { userId -> userInputPort.getById(userId) }
|
||||
.toErrorIfNull { AuthenticationRequiredError() }
|
||||
.map(::CustomUserDetails)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.takiguchi.imagora.exposition.core.security
|
||||
|
||||
import com.github.michaelbull.result.*
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.http.HttpHeaders.AUTHORIZATION
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
import org.takiguchi.imagora.application.core.security.model.CustomUserDetails
|
||||
import org.takiguchi.imagora.application.user.JwtService
|
||||
import org.takiguchi.imagora.domain.core.error.DomainError
|
||||
import org.takiguchi.imagora.domain.user.error.AuthenticationRequiredError
|
||||
import org.takiguchi.imagora.domain.user.model.User
|
||||
|
||||
@Component
|
||||
class JwtAuthenticationFilter(
|
||||
private val jwtService: JwtService,
|
||||
) : OncePerRequestFilter() {
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
filterChain: FilterChain
|
||||
) {
|
||||
request.extractJwt()
|
||||
.toErrorIf({token -> !jwtService.isValid(token)}) {::AuthenticationRequiredError}
|
||||
.flatMap(jwtService::extractUser)
|
||||
.map { it.toUsernamePasswordAuthenticationToken() }
|
||||
.fold(
|
||||
success = { token ->
|
||||
token.details = WebAuthenticationDetailsSource().buildDetails(request)
|
||||
SecurityContextHolder.getContext().authentication = token
|
||||
},
|
||||
failure = {}
|
||||
)
|
||||
|
||||
filterChain.doFilter(request, response)
|
||||
}
|
||||
|
||||
private fun HttpServletRequest.extractJwt(): Result<String, DomainError> = getHeader(AUTHORIZATION)
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.takeIf { it.startsWith(BEARER_PREFIX) }
|
||||
?.substring(BEARER_PREFIX.length)
|
||||
.let(::Ok)
|
||||
.toErrorIfNull(::AuthenticationRequiredError)
|
||||
|
||||
private fun User.toUsernamePasswordAuthenticationToken(): UsernamePasswordAuthenticationToken =
|
||||
CustomUserDetails(this)
|
||||
.let { details -> UsernamePasswordAuthenticationToken(
|
||||
details,
|
||||
null,
|
||||
details.authorities
|
||||
)}
|
||||
|
||||
private companion object {
|
||||
private const val BEARER_PREFIX = "Bearer "
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.takiguchi.imagora.exposition.core.security
|
||||
|
||||
import jakarta.servlet.DispatcherType.FORWARD
|
||||
import jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN
|
||||
import jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.http.HttpMethod.OPTIONS
|
||||
import org.springframework.http.HttpMethod.POST
|
||||
import org.springframework.security.config.Customizer
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer
|
||||
import org.springframework.security.config.http.SessionCreationPolicy.STATELESS
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
open class SecurityConfiguration {
|
||||
@Bean
|
||||
open fun securityFilterChain(
|
||||
httpSecurity: HttpSecurity,
|
||||
jwtAuthenticationFilter: JwtAuthenticationFilter
|
||||
): SecurityFilterChain {
|
||||
httpSecurity
|
||||
.csrf(CsrfConfigurer<HttpSecurity>::disable)
|
||||
.httpBasic { Customizer.withDefaults<HttpSecurity>() }
|
||||
.configureUnauthorizedAndForbiddenErrors()
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
|
||||
.sessionManagement { customizer -> customizer.sessionCreationPolicy(STATELESS) }
|
||||
.authorizeHttpRequests { requests ->
|
||||
requests
|
||||
.dispatcherTypeMatchers(FORWARD).permitAll()
|
||||
.requestMatchers("/error").permitAll()
|
||||
.requestMatchers(
|
||||
POST,
|
||||
"/api/users",
|
||||
"/api/users/login",
|
||||
).permitAll()
|
||||
.requestMatchers(OPTIONS).permitAll()
|
||||
.anyRequest().authenticated()
|
||||
}
|
||||
|
||||
return httpSecurity.build()
|
||||
}
|
||||
|
||||
private fun HttpSecurity.configureUnauthorizedAndForbiddenErrors(): HttpSecurity = exceptionHandling { configurer ->
|
||||
configurer
|
||||
.authenticationEntryPoint { _, response, _ -> response.sendError(SC_UNAUTHORIZED) }
|
||||
.accessDeniedHandler { _, response, _ -> response.sendError(SC_FORBIDDEN) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.takiguchi.imagora.exposition.user
|
||||
|
||||
import com.github.michaelbull.result.map
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.takiguchi.imagora.domain.user.inputport.UserInputPort
|
||||
import org.takiguchi.imagora.exposition.core.resulterror.response
|
||||
import org.takiguchi.imagora.exposition.user.model.CreateUserRequest
|
||||
import org.takiguchi.imagora.exposition.user.model.LoginRequest
|
||||
import org.takiguchi.imagora.exposition.user.model.UserAuthenticationDataDto
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
class UserController(
|
||||
private val userInputPort: UserInputPort
|
||||
) {
|
||||
@PostMapping
|
||||
fun signup(@RequestBody request: CreateUserRequest): UserAuthenticationDataDto = response {
|
||||
userInputPort.create(request.pseudo, request.email, request.password)
|
||||
.map(::UserAuthenticationDataDto)
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
fun login(@RequestBody request: LoginRequest): UserAuthenticationDataDto = response {
|
||||
userInputPort.login(request.username, request.password)
|
||||
.map(::UserAuthenticationDataDto)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.takiguchi.imagora.exposition.user.model
|
||||
|
||||
data class CreateUserRequest(
|
||||
val pseudo: String,
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.takiguchi.imagora.exposition.user.model
|
||||
|
||||
data class LoginRequest(
|
||||
// Pseudo or email
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.takiguchi.imagora.exposition.user.model
|
||||
|
||||
import org.takiguchi.imagora.domain.user.model.RefreshToken
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.*
|
||||
|
||||
data class RefreshTokenDto(
|
||||
val userId: UUID,
|
||||
val value: UUID,
|
||||
val expirationDate: ZonedDateTime
|
||||
) {
|
||||
constructor(refreshToken: RefreshToken) : this(
|
||||
userId = refreshToken.userId,
|
||||
value = refreshToken.value,
|
||||
expirationDate = refreshToken.expirationDate
|
||||
)
|
||||
|
||||
fun toDomain() = RefreshToken(
|
||||
userId = userId,
|
||||
value = value,
|
||||
expirationDate = expirationDate
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.takiguchi.imagora.exposition.user.model
|
||||
|
||||
import org.takiguchi.imagora.domain.user.model.UserAuthenticationData
|
||||
|
||||
data class UserAuthenticationDataDto(
|
||||
val tokenType: String,
|
||||
val accessToken: String,
|
||||
val refreshToken: RefreshTokenDto
|
||||
) {
|
||||
constructor(data: UserAuthenticationData) : this(
|
||||
tokenType = data.tokenType,
|
||||
accessToken = data.accessToken,
|
||||
refreshToken = RefreshTokenDto(data.refreshToken)
|
||||
)
|
||||
|
||||
fun toDomain() = UserAuthenticationData(
|
||||
tokenType = tokenType,
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken.toDomain()
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user