Initial commit.

This commit is contained in:
Florian THIERRY
2025-11-14 14:22:56 +01:00
commit af253dcbe3
68 changed files with 1757 additions and 0 deletions

View 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")
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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 "
}
}

View File

@@ -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) }
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,7 @@
package org.takiguchi.imagora.exposition.user.model
data class CreateUserRequest(
val pseudo: String,
val email: String,
val password: String
)

View File

@@ -0,0 +1,7 @@
package org.takiguchi.imagora.exposition.user.model
data class LoginRequest(
// Pseudo or email
val username: String,
val password: String
)

View File

@@ -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
)
}

View File

@@ -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()
)
}