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,12 @@
plugins {
kotlin("jvm")
id("io.spring.dependency-management") version "1.1.7"
}
dependencies {
implementation(kotlin("stdlib"))
implementation(project(":imagora-domain"))
implementation("org.springframework:spring-context")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("com.auth0:java-jwt:4.5.0")
}

View File

@@ -0,0 +1,12 @@
package org.takiguchi.imagora.application.core.configuration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
@Configuration
open class GlobalServicesConfiguration {
@Bean
open fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}

View File

@@ -0,0 +1,21 @@
package org.takiguchi.imagora.application.core.security.model
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.takiguchi.imagora.domain.user.model.User
import org.takiguchi.imagora.domain.user.model.UserRole
class CustomUserDetails(
val user: User,
) : UserDetails {
override fun getAuthorities(): Collection<GrantedAuthority?> =
user.roles
.map(UserRole::name)
.map { role -> "ROLE_$role" }
.map(::SimpleGrantedAuthority)
override fun getPassword(): String = user.encryptedPassword
override fun getUsername(): String = user.id.toString()
}

View File

@@ -0,0 +1,12 @@
package org.takiguchi.imagora.application.user
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
@Component
class AuthenticationFacade {
fun getAuthentication(): Authentication {
return SecurityContextHolder.getContext().authentication
}
}

View File

@@ -0,0 +1,90 @@
package org.takiguchi.imagora.application.user
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.exceptions.JWTVerificationException
import com.auth0.jwt.interfaces.Claim
import com.github.michaelbull.result.*
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.takiguchi.imagora.domain.core.error.TechnicalError
import org.takiguchi.imagora.domain.user.model.User
import org.takiguchi.imagora.domain.user.model.UserRole
import java.time.ZonedDateTime
import java.util.*
@Service
class JwtService(
@Value("\${application.security.jwt.secret-key}")
secretKey: String,
@param:Value("\${application.security.jwt.expiration-delay-in-minutes}")
private val tokenExpirationDelayInMinutes: Long
) {
private val algorithm: Algorithm = Algorithm.HMAC512(secretKey)
private val jwtVerifier: JWTVerifier = JWT.require(algorithm).build()
fun createJwt(user: User): Result<String, TechnicalError> =
ZonedDateTime.now().plusMinutes(tokenExpirationDelayInMinutes)
.let(ZonedDateTime::toInstant)
.let { expirationDateInstant ->
runCatching {
JWT.create()
.withSubject(user.id.toString())
.withExpiresAt(expirationDateInstant)
.withPayload(user.toJwtPayload())
.sign(algorithm)
}.mapError { throwable ->
TechnicalError("Technical error while JWT generation, caused by: ${throwable.message}.")
}
}
private fun User.toJwtPayload(): Map<String, Any> = mapOf(
"pseudo" to pseudo,
"email" to email,
"roles" to roles.joinToString(",")
)
fun isValid(token: String): Boolean = try {
jwtVerifier.verify(token)
true
} catch (error: JWTVerificationException) {
false
}
fun extractUser(token: String): Result<User, TechnicalError> = binding {
JWT.decode(token).claims
.let { claims ->
User(
id = (
claims[CLAIM_ID]?.asUuid()
?: Err(TechnicalError("User id is not defined in token."))
).bind(),
pseudo = claims[CLAIM_PSEUDO]?.asString()
?: Err(TechnicalError("User pseudo is not defined in token.")).bind(),
email = claims[CLAIM_EMAIL]?.asString()
?: Err(TechnicalError("User email is not defined in token.")).bind(),
encryptedPassword = "*****",
roles = (
claims[CLAIM_ROLES]?.asString()
?: Err(TechnicalError("User roles are not defined in token.")).bind()
)
.split(",")
.toList()
.map { role -> UserRole.fromString(role) ?: Err(TechnicalError("Unknown user role: $role.")).bind() }
)
}
}
private fun Claim.asUuid(): Result<UUID, TechnicalError> = runCatching {
UUID.fromString(this.asString())
}.mapError { throwable -> TechnicalError("User id deserialisation error.") }
companion object {
private const val CLAIM_ID = "sub"
private const val CLAIM_PSEUDO = "pseudo"
private const val CLAIM_EMAIL = "email"
private const val CLAIM_ROLES = "roles"
}
}

View File

@@ -0,0 +1,30 @@
package org.takiguchi.imagora.application.user
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.takiguchi.imagora.domain.core.error.TechnicalError
import org.takiguchi.imagora.domain.user.error.UserError
import org.takiguchi.imagora.domain.user.inputport.PasswordInputPort
@Service
open class PasswordService(
private val passwordEncoder: PasswordEncoder,
@param:Value("\${application.security.password.salt}")
private val passwordSalt: String
) : PasswordInputPort {
override fun encode(password: String): Result<String, TechnicalError> =
Ok(passwordEncoder.encode("$password$passwordSalt"))
override fun checkPasswordsAreMatching(
password: String,
encodedPassword: String
): Result<Unit, UserError> =
when (passwordEncoder.matches("$password$passwordSalt", encodedPassword)) {
true -> Ok(Unit)
else -> Err(UserError("Password does not match."))
}
}

View File

@@ -0,0 +1,40 @@
package org.takiguchi.imagora.application.user
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.binding
import com.github.michaelbull.result.mapError
import org.springframework.stereotype.Service
import org.takiguchi.imagora.domain.user.error.UserError
import org.takiguchi.imagora.domain.user.model.RefreshToken
import org.takiguchi.imagora.domain.user.model.User
import org.takiguchi.imagora.domain.user.model.UserAuthenticationData
import java.time.ZonedDateTime
import java.util.*
@Service
class UserAuthenticationDataUseCases(
private val jwtService: JwtService
) {
fun createFromUser(user: User): Result<UserAuthenticationData, UserError> = binding {
val accessToken = jwtService.createJwt(user)
.mapError { technicalError ->
UserError("Technical error while generating JWT, caused by: ${technicalError.message}")
}.bind()
val refreshToken = RefreshToken(
userId = user.id,
value = UUID.randomUUID(),
expirationDate = ZonedDateTime.now()
)
UserAuthenticationData(
tokenType = TOKEN_TYPE,
accessToken = accessToken,
refreshToken = refreshToken
)
}
companion object {
private const val TOKEN_TYPE = "Bearer"
}
}

View File

@@ -0,0 +1,23 @@
package org.takiguchi.imagora.application.user
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import org.springframework.stereotype.Component
import org.takiguchi.imagora.domain.user.error.UserError
@Component
class UserEditionRequestValidator {
fun validate(pseudo: String, email: String, password: String): Result<Unit, UserError> = when {
pseudo.trim().isEmpty() -> Err(UserError("User pseudo should not be empty nor blank."))
email.trim().isEmpty() -> Err(UserError("User email should not be empty nor blank."))
else -> validatePassword(password)
}
fun validatePassword(password: String): Result<Unit, UserError> = when {
password.trim().isEmpty() -> Err(UserError("Password should not be empty not blank."))
else -> Ok(Unit)
}
}

View File

@@ -0,0 +1,144 @@
package org.takiguchi.imagora.application.user
import com.github.michaelbull.result.*
import org.springframework.stereotype.Service
import org.takiguchi.imagora.application.core.security.model.CustomUserDetails
import org.takiguchi.imagora.domain.user.error.AuthenticationRequiredError
import org.takiguchi.imagora.domain.user.error.UserError
import org.takiguchi.imagora.domain.user.inputport.UserInputPort
import org.takiguchi.imagora.domain.user.model.User
import org.takiguchi.imagora.domain.user.model.UserAuthenticationData
import org.takiguchi.imagora.domain.user.model.UserRole.STANDARD
import org.takiguchi.imagora.domain.user.outputport.UserOutputPort
import java.util.*
@Service
class UserUseCases(
private val authenticationFacade: AuthenticationFacade,
private val passwordService: PasswordService,
private val userAuthenticationDataUseCases: UserAuthenticationDataUseCases,
private val userEditionRequestValidator: UserEditionRequestValidator,
private val userOutputPort: UserOutputPort
) : UserInputPort {
override fun getAuthenticatedUser(): Result<User, AuthenticationRequiredError> =
Ok(authenticationFacade.getAuthentication())
.map { it.principal }
.toErrorIfNull { AuthenticationRequiredError() }
.toErrorIf({customerUserDetails ->
!CustomUserDetails::class.java.isInstance(customerUserDetails)
}) { AuthenticationRequiredError() }
.map { customerUserDetails -> CustomUserDetails::class.java.cast(customerUserDetails) }
.map(CustomUserDetails::user)
override fun getAll(): Result<List<User>, UserError> =
userOutputPort.getAll()
.mapError { technicalError ->
UserError("Unknown error while retrieving all users: ${technicalError.message}")
}
override fun getById(userId: UUID): Result<User?, UserError> =
userOutputPort.getById(userId)
.mapError { _ -> UserError("Unknown error while retrieving user by its id.") }
override fun create(
pseudo: String,
email: String,
password: String
): Result<UserAuthenticationData, UserError> = binding {
userEditionRequestValidator.validate(pseudo, email, password).bind()
checkPseudoAlreadyExists(pseudo).bind()
checkEmailAlreadyExists(email).bind()
val encodedPassword = passwordService.encode(password)
.mapError { technicalError -> UserError("Technical error while encoding password, caused by: ${technicalError.message}") }
.bind()
val newUser = User(
id = UUID.randomUUID(),
pseudo = pseudo,
email = email,
encryptedPassword = encodedPassword,
roles = listOf(STANDARD),
)
userOutputPort.save(newUser)
.mapError { technicalError ->
UserError("Technical error while creating new user, caused by: ${technicalError.message}")
}
.bind()
userAuthenticationDataUseCases.createFromUser(newUser).bind()
}
private fun checkPseudoAlreadyExists(pseudo: String): Result<Boolean, UserError> = userOutputPort.existsByPseudo(pseudo)
.mapError { technicalError ->
UserError("Technical error while checking if pseudo already exists or not.")
}
.toErrorIf({ emailAlreadyExists -> emailAlreadyExists }) {
UserError("User pseudo already exists.")
}
private fun checkEmailAlreadyExists(email: String): Result<Boolean, UserError> = userOutputPort.existsByEmail(email)
.mapError { technicalError ->
UserError("Technical error while checking if email already exists or not.")
}
.toErrorIf({ emailAlreadyExists -> emailAlreadyExists }) {
UserError("User email already exists.")
}
override fun setUserAdmin(userId: UUID): Result<Unit, UserError> = binding {
val existingUser = userOutputPort.getById(userId)
.mapError { technicalError ->
UserError("Technical error while setting user admin, caused by: ${technicalError.message}")
}
.toErrorIfNull { UserError("User with id \"$userId\" does not exist.") }
.bind()
val updatedUser = existingUser.setAdmin()
userOutputPort.save(updatedUser)
.mapError { technicalError ->
UserError("Technical error while saving user, caused by: ${technicalError.message}")
}
.bind()
}
override fun setUserStandard(userId: UUID): Result<Unit, UserError> {
TODO("Not yet implemented")
}
override fun update(
userId: UUID,
pseudo: String,
email: String
): Result<Unit, UserError> {
TODO("Not yet implemented")
}
override fun changePassword(
userId: UUID,
actualPassword: String,
newPassword: String
): Result<Unit, UserError> {
TODO("Not yet implemented")
}
override fun deleteById(userId: UUID): Result<Unit, UserError> {
TODO("Not yet implemented")
}
override fun login(
pseudoOrEmail: String,
password: String
): Result<UserAuthenticationData, UserError> = binding {
val user = userOutputPort.getByPseudoOrEmail(pseudoOrEmail)
.mapError { technicalError -> UserError("Technical error while retrieving user, caused by: ${technicalError.message}") }
.toErrorIfNull { UserError("User does not exist.") }
.bind()
passwordService.checkPasswordsAreMatching(password, user.encryptedPassword).bind()
userAuthenticationDataUseCases.createFromUser(user).bind()
}
}