Initial commit.
This commit is contained in:
12
imagora-application/build.gradle.kts
Normal file
12
imagora-application/build.gradle.kts
Normal 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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user