commit af253dcbe3074c713613fffdf55686c7a89180b6 Author: Florian THIERRY Date: Fri Nov 14 14:22:56 2025 +0100 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4253ada --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/* +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Kotlin ### +.kotlin + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +### Postgresql data ### +imagora-infrastructure/docker/postgres_data \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a59f6e6 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# Summary +This project is a template for startup a new kotlin/gralde project with hexagonal architecture. + +It ships an entity which represent users, and all endpoints to signup, signin etc. with the PostgreSQL infrastructure implementation to store data. + +Just clone this project and rename gradle modules and packages as you need. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..681f6d2 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + kotlin("jvm") version "2.2.21" + kotlin("plugin.spring") version "2.2.21" + id("io.spring.dependency-management") version "1.1.7" +} + +group = "org.takiguchi.imagora" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +subprojects { + apply(plugin = "java") + apply(plugin = "org.jetbrains.kotlin.jvm") + + repositories { + mavenCentral() + } + + dependencies { + implementation(platform("org.springframework.boot:spring-boot-dependencies:3.5.7")) + implementation("com.michael-bull.kotlin-result:kotlin-result:2.1.0") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("io.mockk:mockk:1.14.6") + testImplementation("org.springframework.boot:spring-boot-starter-test:3.5.7") + testImplementation("org.assertj:assertj-core:3.27.6") + } + + tasks.withType { + useJUnitPlatform() + } +} + +dependencyManagement { + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:3.5.7") + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..b06a80a --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +# Enable the build cache to save time by reusing outputs produced by other successful builds. +# https://docs.gradle.org/current/userguide/build_cache.html +org.gradle.caching=true +# Enable the configuration cache to reuse the build configuration and enable parallel task execution. +# (Note that some plugins may not yet be compatible with the configuration cache.) +# https://docs.gradle.org/current/userguide/configuration_cache.html +org.gradle.configuration-cache=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..bcad97d --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,22 @@ +# Version catalog is a central place for you to declare and version dependencies +# https://docs.gradle.org/current/userguide/platforms.html#sub:version-catalog +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +kotlin = "2.2.20" +kotlinxDatetime = "0.6.2" +kotlinxSerializationJSON = "1.8.1" +kotlinxCoroutines = "1.10.2" + +[libraries] +kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlinxDatetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinxSerialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJSON" } +kotlinxCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } + +# Libraries can be bundled together for easier import +[bundles] +kotlinxEcosystem = ["kotlinxDatetime", "kotlinxSerialization", "kotlinxCoroutines"] + +[plugins] +kotlinPluginSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1ce9abe --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Nov 11 19:48:20 CET 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/imagora-application/build.gradle.kts b/imagora-application/build.gradle.kts new file mode 100644 index 0000000..23f6b34 --- /dev/null +++ b/imagora-application/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/core/configuration/GlobalServicesConfiguration.kt b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/core/configuration/GlobalServicesConfiguration.kt new file mode 100644 index 0000000..ec63c72 --- /dev/null +++ b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/core/configuration/GlobalServicesConfiguration.kt @@ -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() +} \ No newline at end of file diff --git a/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/core/security/model/CustomUserDetails.kt b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/core/security/model/CustomUserDetails.kt new file mode 100644 index 0000000..f5f799d --- /dev/null +++ b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/core/security/model/CustomUserDetails.kt @@ -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 = + user.roles + .map(UserRole::name) + .map { role -> "ROLE_$role" } + .map(::SimpleGrantedAuthority) + + override fun getPassword(): String = user.encryptedPassword + + override fun getUsername(): String = user.id.toString() +} \ No newline at end of file diff --git a/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/AuthenticationFacade.kt b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/AuthenticationFacade.kt new file mode 100644 index 0000000..050e23f --- /dev/null +++ b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/AuthenticationFacade.kt @@ -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 + } +} \ No newline at end of file diff --git a/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/JwtService.kt b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/JwtService.kt new file mode 100644 index 0000000..09486b8 --- /dev/null +++ b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/JwtService.kt @@ -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 = + 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 = 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 = 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 = 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" + } +} \ No newline at end of file diff --git a/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/PasswordService.kt b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/PasswordService.kt new file mode 100644 index 0000000..856a108 --- /dev/null +++ b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/PasswordService.kt @@ -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 = + Ok(passwordEncoder.encode("$password$passwordSalt")) + + override fun checkPasswordsAreMatching( + password: String, + encodedPassword: String + ): Result = + when (passwordEncoder.matches("$password$passwordSalt", encodedPassword)) { + true -> Ok(Unit) + else -> Err(UserError("Password does not match.")) + } +} \ No newline at end of file diff --git a/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/UserAuthenticationDataUseCases.kt b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/UserAuthenticationDataUseCases.kt new file mode 100644 index 0000000..bb2fe81 --- /dev/null +++ b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/UserAuthenticationDataUseCases.kt @@ -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 = 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" + } +} \ No newline at end of file diff --git a/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/UserEditionRequestValidator.kt b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/UserEditionRequestValidator.kt new file mode 100644 index 0000000..a481b7c --- /dev/null +++ b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/UserEditionRequestValidator.kt @@ -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 = 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 = when { + password.trim().isEmpty() -> Err(UserError("Password should not be empty not blank.")) + else -> Ok(Unit) + } +} \ No newline at end of file diff --git a/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/UserUseCases.kt b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/UserUseCases.kt new file mode 100644 index 0000000..c3616ef --- /dev/null +++ b/imagora-application/src/main/kotlin/org/takiguchi/imagora/application/user/UserUseCases.kt @@ -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 = + 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, UserError> = + userOutputPort.getAll() + .mapError { technicalError -> + UserError("Unknown error while retrieving all users: ${technicalError.message}") + } + + override fun getById(userId: UUID): Result = + userOutputPort.getById(userId) + .mapError { _ -> UserError("Unknown error while retrieving user by its id.") } + + override fun create( + pseudo: String, + email: String, + password: String + ): Result = 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 = 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 = 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 = 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 { + TODO("Not yet implemented") + } + + override fun update( + userId: UUID, + pseudo: String, + email: String + ): Result { + TODO("Not yet implemented") + } + + override fun changePassword( + userId: UUID, + actualPassword: String, + newPassword: String + ): Result { + TODO("Not yet implemented") + } + + override fun deleteById(userId: UUID): Result { + TODO("Not yet implemented") + } + + override fun login( + pseudoOrEmail: String, + password: String + ): Result = 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() + } +} \ No newline at end of file diff --git a/imagora-domain/build.gradle.kts b/imagora-domain/build.gradle.kts new file mode 100644 index 0000000..5bd54b4 --- /dev/null +++ b/imagora-domain/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + kotlin("jvm") +} + +dependencies { + implementation(kotlin("stdlib")) +} \ No newline at end of file diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/core/error/BusinessError.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/core/error/BusinessError.kt new file mode 100644 index 0000000..d788ab7 --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/core/error/BusinessError.kt @@ -0,0 +1,3 @@ +package org.takiguchi.imagora.domain.core.error + +open class BusinessError(message: String) : DomainError(message) diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/core/error/DomainError.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/core/error/DomainError.kt new file mode 100644 index 0000000..c9d071b --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/core/error/DomainError.kt @@ -0,0 +1,3 @@ +package org.takiguchi.imagora.domain.core.error + +open class DomainError(val message: String) \ No newline at end of file diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/core/error/TechnicalError.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/core/error/TechnicalError.kt new file mode 100644 index 0000000..dd872a0 --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/core/error/TechnicalError.kt @@ -0,0 +1,3 @@ +package org.takiguchi.imagora.domain.core.error + +open class TechnicalError(message: String) : DomainError(message) diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/error/AuthenticationRequiredError.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/error/AuthenticationRequiredError.kt new file mode 100644 index 0000000..fe068f6 --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/error/AuthenticationRequiredError.kt @@ -0,0 +1,3 @@ +package org.takiguchi.imagora.domain.user.error + +class AuthenticationRequiredError() : UserError("Authentication is required.") \ No newline at end of file diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/error/OperationNotPermittedError.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/error/OperationNotPermittedError.kt new file mode 100644 index 0000000..b478bc5 --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/error/OperationNotPermittedError.kt @@ -0,0 +1,3 @@ +package org.takiguchi.imagora.domain.user.error + +class OperationNotPermittedError : UserError("Operation not permitted: You do not have rights to perform this operation.") \ No newline at end of file diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/error/UserError.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/error/UserError.kt new file mode 100644 index 0000000..27460c6 --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/error/UserError.kt @@ -0,0 +1,5 @@ +package org.takiguchi.imagora.domain.user.error + +import org.takiguchi.imagora.domain.core.error.BusinessError + +open class UserError(message: String) : BusinessError(message) \ No newline at end of file diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/inputport/PasswordInputPort.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/inputport/PasswordInputPort.kt new file mode 100644 index 0000000..c9e3092 --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/inputport/PasswordInputPort.kt @@ -0,0 +1,11 @@ +package org.takiguchi.imagora.domain.user.inputport + +import com.github.michaelbull.result.Result +import org.takiguchi.imagora.domain.core.error.TechnicalError +import org.takiguchi.imagora.domain.user.error.UserError + +interface PasswordInputPort { + fun encode(password: String): Result + + fun checkPasswordsAreMatching(password: String, encodedPassword: String): Result +} \ No newline at end of file diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/inputport/UserInputPort.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/inputport/UserInputPort.kt new file mode 100644 index 0000000..04139ed --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/inputport/UserInputPort.kt @@ -0,0 +1,21 @@ +package org.takiguchi.imagora.domain.user.inputport + +import com.github.michaelbull.result.Result +import org.takiguchi.imagora.domain.user.error.AuthenticationRequiredError +import org.takiguchi.imagora.domain.user.error.UserError +import org.takiguchi.imagora.domain.user.model.User +import org.takiguchi.imagora.domain.user.model.UserAuthenticationData +import java.util.UUID + +interface UserInputPort { + fun getAuthenticatedUser(): Result + fun getAll(): Result, UserError> + fun getById(userId: UUID): Result + fun create(pseudo: String, email: String, password: String): Result + fun setUserAdmin(userId: UUID): Result + fun setUserStandard(userId: UUID): Result + fun update(userId: UUID, pseudo: String, email: String): Result + fun changePassword(userId: UUID, actualPassword: String, newPassword: String): Result + fun deleteById(userId: UUID): Result + fun login(pseudoOrEmail: String, password: String): Result +} \ No newline at end of file diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/RefreshToken.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/RefreshToken.kt new file mode 100644 index 0000000..3145e9e --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/RefreshToken.kt @@ -0,0 +1,10 @@ +package org.takiguchi.imagora.domain.user.model + +import java.time.ZonedDateTime +import java.util.* + +data class RefreshToken( + val userId: UUID, + val value: UUID, + val expirationDate: ZonedDateTime +) diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/User.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/User.kt new file mode 100644 index 0000000..4af3f23 --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/User.kt @@ -0,0 +1,18 @@ +package org.takiguchi.imagora.domain.user.model + +import org.takiguchi.imagora.domain.user.model.UserRole.* +import java.util.UUID + +data class User( + val id: UUID, + val pseudo: String, + val email: String, + val encryptedPassword: String, + val roles: List +) { + fun setAdmin(): User = copy(roles = listOf(STANDARD, ADMIN)) + + fun setStandard(): User = copy(roles = listOf(STANDARD)) + + fun isAdmin(): Boolean = roles.contains(ADMIN) +} diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/UserAuthenticationData.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/UserAuthenticationData.kt new file mode 100644 index 0000000..70407c7 --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/UserAuthenticationData.kt @@ -0,0 +1,7 @@ +package org.takiguchi.imagora.domain.user.model + +data class UserAuthenticationData( + val tokenType: String, + val accessToken: String, + val refreshToken: RefreshToken +) diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/UserRole.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/UserRole.kt new file mode 100644 index 0000000..8efb27c --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/model/UserRole.kt @@ -0,0 +1,14 @@ +package org.takiguchi.imagora.domain.user.model + +enum class UserRole { + STANDARD, + ADMIN; + + companion object { + fun fromString(roleAsString: String): UserRole? = when (roleAsString) { + "STANDARD" -> STANDARD + "ADMIN" -> ADMIN + else -> null + } + } +} \ No newline at end of file diff --git a/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/outputport/UserOutputPort.kt b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/outputport/UserOutputPort.kt new file mode 100644 index 0000000..a631402 --- /dev/null +++ b/imagora-domain/src/main/kotlin/org/takiguchi/imagora/domain/user/outputport/UserOutputPort.kt @@ -0,0 +1,16 @@ +package org.takiguchi.imagora.domain.user.outputport + +import com.github.michaelbull.result.Result +import org.takiguchi.imagora.domain.core.error.TechnicalError +import org.takiguchi.imagora.domain.user.model.User +import java.util.* + +interface UserOutputPort { + fun getAll(): Result, TechnicalError> + fun getById(userId: UUID): Result + fun save(user: User): Result + fun deleteById(user: User): Result + fun existsByEmail(email: String): Result + fun existsByPseudo(pseudo: String): Result + fun getByPseudoOrEmail(pseudoOrEmail: String): Result +} \ No newline at end of file diff --git a/imagora-exposition/build.gradle.kts b/imagora-exposition/build.gradle.kts new file mode 100644 index 0000000..858d34a --- /dev/null +++ b/imagora-exposition/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/ExceptionHandlingConfiguration.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/ExceptionHandlingConfiguration.kt new file mode 100644 index 0000000..12d450e --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/ExceptionHandlingConfiguration.kt @@ -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> { + val errorMessage = "Invalid request payload." + return ResponseEntity(HttpResponseError(errorMessage), BAD_REQUEST) + } + + @ExceptionHandler(ResultErrorException::class) + fun handleResultError(ex: ResultErrorException): ResponseEntity> { + return ResponseEntity( + HttpResponseError(ex.error.message), + resultErrorMapper.mapHttpStatus(ex.error) + ) + } + + @ExceptionHandler(Exception::class) + fun handleException(ex: Exception): ResponseEntity> { + val errorMessage = "Internal Server Error with Cause: ${ex.message}" + return ResponseEntity(HttpResponseError(errorMessage), INTERNAL_SERVER_ERROR) + } + +} \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/model/HttpResponse.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/model/HttpResponse.kt new file mode 100644 index 0000000..698c697 --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/model/HttpResponse.kt @@ -0,0 +1,9 @@ +package org.takiguchi.imagora.exposition.core.model + +interface HttpResponse { + fun getError(): String? +} + +data class HttpResponseError(private val error: String) : HttpResponse { + override fun getError(): String? = error +} \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/ResultErrorHelper.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/ResultErrorHelper.kt new file mode 100644 index 0000000..2ce05d5 --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/ResultErrorHelper.kt @@ -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 response(block: () -> Result): T { + val result = block() + return when (result.isOk) { + true -> result.value + false -> throw ResultErrorException(result.error) + } +} \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/exception/ResultErrorException.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/exception/ResultErrorException.kt new file mode 100644 index 0000000..9139841 --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/exception/ResultErrorException.kt @@ -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() \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/mapper/ResultErrorMapper.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/mapper/ResultErrorMapper.kt new file mode 100644 index 0000000..bf830af --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/mapper/ResultErrorMapper.kt @@ -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 + } +} \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/mapper/errormapper/HttpStatusErrorMapper.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/mapper/errormapper/HttpStatusErrorMapper.kt new file mode 100644 index 0000000..7e45f9c --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/mapper/errormapper/HttpStatusErrorMapper.kt @@ -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 { + fun map(error: E): HttpStatus +} \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/mapper/errormapper/UserErrorMapper.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/mapper/errormapper/UserErrorMapper.kt new file mode 100644 index 0000000..60dffad --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/resulterror/mapper/errormapper/UserErrorMapper.kt @@ -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 { + override fun map(error: UserError): HttpStatus = when (error) { + is AuthenticationRequiredError -> UNAUTHORIZED + is OperationNotPermittedError -> FORBIDDEN + else -> BAD_REQUEST + } +} \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/security/CustomUserDetailsService.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/security/CustomUserDetailsService.kt new file mode 100644 index 0000000..ce50d48 --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/security/CustomUserDetailsService.kt @@ -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) + } +} \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/security/JwtAuthenticationFilter.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/security/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..efaf202 --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/security/JwtAuthenticationFilter.kt @@ -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 = 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 " + } +} \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/security/SecurityConfiguration.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/security/SecurityConfiguration.kt new file mode 100644 index 0000000..0445784 --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/core/security/SecurityConfiguration.kt @@ -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::disable) + .httpBasic { Customizer.withDefaults() } + .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) } + } +} \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/UserController.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/UserController.kt new file mode 100644 index 0000000..77963b1 --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/UserController.kt @@ -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) + } +} \ No newline at end of file diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/CreateUserRequest.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/CreateUserRequest.kt new file mode 100644 index 0000000..fa65954 --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/CreateUserRequest.kt @@ -0,0 +1,7 @@ +package org.takiguchi.imagora.exposition.user.model + +data class CreateUserRequest( + val pseudo: String, + val email: String, + val password: String +) diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/LoginRequest.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/LoginRequest.kt new file mode 100644 index 0000000..37506fd --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/LoginRequest.kt @@ -0,0 +1,7 @@ +package org.takiguchi.imagora.exposition.user.model + +data class LoginRequest( + // Pseudo or email + val username: String, + val password: String +) diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/RefreshTokenDto.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/RefreshTokenDto.kt new file mode 100644 index 0000000..103a1ce --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/RefreshTokenDto.kt @@ -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 + ) +} diff --git a/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/UserAuthenticationDataDto.kt b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/UserAuthenticationDataDto.kt new file mode 100644 index 0000000..551da52 --- /dev/null +++ b/imagora-exposition/src/main/kotlin/org/takiguchi/imagora/exposition/user/model/UserAuthenticationDataDto.kt @@ -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() + ) +} \ No newline at end of file diff --git a/imagora-infrastructure/build.gradle.kts b/imagora-infrastructure/build.gradle.kts new file mode 100644 index 0000000..daf8ae6 --- /dev/null +++ b/imagora-infrastructure/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + kotlin("jvm") + id("io.spring.dependency-management") version "1.1.7" + kotlin("plugin.jpa") version "2.2.21" +} + +dependencies { + implementation(kotlin("stdlib")) + implementation(project(":imagora-domain")) + implementation(project(":imagora-application")) + implementation("org.springframework:spring-context") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.jetbrains.kotlin:kotlin-noarg:2.2.21") + implementation("org.postgresql:postgresql:42.7.8") +} \ No newline at end of file diff --git a/imagora-infrastructure/docker/docker-compose.yaml b/imagora-infrastructure/docker/docker-compose.yaml new file mode 100644 index 0000000..7f539c4 --- /dev/null +++ b/imagora-infrastructure/docker/docker-compose.yaml @@ -0,0 +1,15 @@ +services: + imagora_database: + image: postgres:17 + container_name: imagora_database + environment: + POSTGRES_USER: imagora_admin + POSTGRES_PASSWORD: P@ssword! + POSTGRES_DB: imagora_database + ports: + - "11979:5432" + volumes: + - "./postgres_data:/var/lib/postgresql/data" + +volumes: + postgres_data: \ No newline at end of file diff --git a/imagora-infrastructure/sql/000_database_initialisation.sql b/imagora-infrastructure/sql/000_database_initialisation.sql new file mode 100644 index 0000000..ff5d9b9 --- /dev/null +++ b/imagora-infrastructure/sql/000_database_initialisation.sql @@ -0,0 +1,10 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE USER imagora_user + WITH PASSWORD 'P@ssword!' + NOCREATEDB; + +GRANT SELECT, INSERT, UPDATE, DELETE + ON ALL TABLES + IN SCHEMA public + TO imagora_user; \ No newline at end of file diff --git a/imagora-infrastructure/sql/001_tables_creation.sql b/imagora-infrastructure/sql/001_tables_creation.sql new file mode 100644 index 0000000..332faa2 --- /dev/null +++ b/imagora-infrastructure/sql/001_tables_creation.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS "user" ( + id UUID NOT NULL, + pseudo VARCHAR(256) NOT NULL, + email VARCHAR(256) NOT NULL, + encrypted_password VARCHAR(512) NOT NULL, + CONSTRAINT user_pk PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS user_roles ( + user_id UUID NOT NULL, + role SMALLINT NOT NULL, + CONSTRAINT user_roles_pk PRIMARY KEY (user_id, role), + CONSTRAINT user_roles_user_id_fk FOREIGN KEY (user_id) REFERENCES "user" (id) +); +CREATE INDEX IF NOT EXISTS user_roles_user_id_idx ON user_roles (user_id); + diff --git a/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/core/configuration/JpaConfiguration.kt b/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/core/configuration/JpaConfiguration.kt new file mode 100644 index 0000000..ab2c14a --- /dev/null +++ b/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/core/configuration/JpaConfiguration.kt @@ -0,0 +1,10 @@ +package org.takiguchi.imagora.infrastructure.core.configuration + +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaRepositories + +@Configuration +@EnableJpaRepositories("org.takiguchi.imagora.infrastructure") +@EntityScan("org.takiguchi.imagora.infrastructure") +open class JpaConfiguration \ No newline at end of file diff --git a/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/user/UserJpaAdapter.kt b/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/user/UserJpaAdapter.kt new file mode 100644 index 0000000..433b25a --- /dev/null +++ b/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/user/UserJpaAdapter.kt @@ -0,0 +1,65 @@ +package org.takiguchi.imagora.infrastructure.user + +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.mapError +import com.github.michaelbull.result.runCatching +import org.springframework.stereotype.Component +import org.takiguchi.imagora.domain.core.error.TechnicalError +import org.takiguchi.imagora.domain.user.model.User +import org.takiguchi.imagora.domain.user.outputport.UserOutputPort +import org.takiguchi.imagora.infrastructure.user.model.UserEntity +import org.takiguchi.imagora.infrastructure.user.repository.UserJpaRepository +import java.util.* + +@Component +class UserJpaAdapter( + private val userJpaRepository: UserJpaRepository +) : UserOutputPort { + override fun getAll(): Result, TechnicalError> = runCatching { + userJpaRepository.findAll() + .map(UserEntity::toDomain) + }.mapError { throwable -> + TechnicalError(throwable.message ?: "Unknown error while retrieving all users.") + } + + override fun getById(userId: UUID): Result = runCatching { + userJpaRepository.findById(userId) + .map(UserEntity::toDomain) + .orElse(null) + }.mapError { throwable -> + TechnicalError(throwable.message ?: "Unknown error while retrieving user by its id.") + } + + override fun save(user: User): Result = runCatching { + UserEntity(user).also(userJpaRepository::save) + + Unit + }.mapError { throwable -> + TechnicalError(throwable.message ?: "Unknown error while saving user.") + } + + override fun deleteById(user: User): Result = runCatching { + userJpaRepository.deleteById(user.id) + }.mapError { throwable -> + TechnicalError(throwable.message ?: "Unknown error while deleting user.") + } + + override fun existsByEmail(email: String): Result = runCatching { + userJpaRepository.existsByEmail(email) + }.mapError { throwable -> + TechnicalError(throwable.message ?: "Unknown error while checking user existence by its email.") + } + + override fun existsByPseudo(pseudo: String): Result = runCatching { + userJpaRepository.existsByEmail(pseudo) + }.mapError { throwable -> + TechnicalError(throwable.message ?: "Unknown error while checking user existence by its pseudo.") + } + + override fun getByPseudoOrEmail(pseudoOrEmail: String): Result = runCatching { + userJpaRepository.getByPseudoOrEmail(pseudoOrEmail) + ?.toDomain() + }.mapError { throwable -> + TechnicalError(throwable.message ?: "Unknown error while retrieving user by its pseudo or email.") + } +} \ No newline at end of file diff --git a/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/user/model/UserEntity.kt b/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/user/model/UserEntity.kt new file mode 100644 index 0000000..dd2b509 --- /dev/null +++ b/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/user/model/UserEntity.kt @@ -0,0 +1,42 @@ +package org.takiguchi.imagora.infrastructure.user.model + +import jakarta.persistence.* +import org.takiguchi.imagora.domain.user.model.User +import org.takiguchi.imagora.domain.user.model.UserRole +import java.util.* + +@Entity +@Table(name = "`user`") +data class UserEntity( + @Id + val id: UUID, + @Column(nullable = false) + val pseudo: String, + @Column(nullable = false) + val email: String, + @Column(nullable = false) + val encryptedPassword: String, + @ElementCollection(targetClass = UserRole::class) + @CollectionTable( + name = "user_roles", + joinColumns = [JoinColumn(name = "user_id")] + ) + @Column(name = "role") + val roles: List +) { + constructor(user: User) : this ( + id = user.id, + pseudo = user.pseudo, + email = user.email, + encryptedPassword = user.encryptedPassword, + roles = user.roles, + ) + + fun toDomain() = User( + id = id, + pseudo = pseudo, + email = email, + encryptedPassword = encryptedPassword, + roles = roles + ) +} \ No newline at end of file diff --git a/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/user/repository/UserJpaRepository.kt b/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/user/repository/UserJpaRepository.kt new file mode 100644 index 0000000..c11120d --- /dev/null +++ b/imagora-infrastructure/src/main/kotlin/org/takiguchi/imagora/infrastructure/user/repository/UserJpaRepository.kt @@ -0,0 +1,22 @@ +package org.takiguchi.imagora.infrastructure.user.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository +import org.takiguchi.imagora.infrastructure.user.model.UserEntity +import java.util.* + +@Repository +interface UserJpaRepository : JpaRepository { + fun existsByEmail(email: String): Boolean + + @Query("SELECT u FROM UserEntity u JOIN FETCH u.roles WHERE u.pseudo = :pseudoOrEmail OR u.email = :pseudoOrEmail") + fun getByPseudoOrEmail(@Param("pseudoOrEmail") pseudoOrEmail: String): UserEntity? + + @Query("SELECT u FROM UserEntity u JOIN FETCH u.roles") + override fun findAll(): List + + @Query("SELECT u FROM UserEntity u JOIN FETCH u.roles WHERE u.id = :userId") + override fun findById(@Param("userId") userId: UUID): Optional +} \ No newline at end of file diff --git a/imagora-launcher/build.gradle.kts b/imagora-launcher/build.gradle.kts new file mode 100644 index 0000000..79798e6 --- /dev/null +++ b/imagora-launcher/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + kotlin("jvm") + id("org.springframework.boot") version "3.5.7" + id("io.spring.dependency-management") version "1.1.7" +} + +dependencies { + implementation(kotlin("stdlib")) + implementation(kotlin("reflect")) + implementation(project(":imagora-domain")) + implementation(project(":imagora-application")) + implementation(project(":imagora-infrastructure")) + implementation(project(":imagora-exposition")) + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-devtools") +} + +springBoot { + mainClass = "org.takiguchi.imagora.launcher.ApplicationLauncherKt" +} diff --git a/imagora-launcher/src/main/kotlin/org/takiguchi/imagora/launcher/ApplicationLauncher.kt b/imagora-launcher/src/main/kotlin/org/takiguchi/imagora/launcher/ApplicationLauncher.kt new file mode 100644 index 0000000..0effb9d --- /dev/null +++ b/imagora-launcher/src/main/kotlin/org/takiguchi/imagora/launcher/ApplicationLauncher.kt @@ -0,0 +1,22 @@ +package org.takiguchi.imagora.launcher + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.boot.runApplication + +@SpringBootApplication( + scanBasePackages = [ + "org.takiguchi.imagora.domain", + "org.takiguchi.imagora.application", + "org.takiguchi.imagora.infrastructure", + "org.takiguchi.imagora.exposition" + ], + exclude = [ + SecurityAutoConfiguration::class + ] +) +open class ImagoraApplication + +fun main(args: Array) { + runApplication(*args) +} \ No newline at end of file diff --git a/imagora-launcher/src/main/resources/application-local.yaml b/imagora-launcher/src/main/resources/application-local.yaml new file mode 100644 index 0000000..65c0076 --- /dev/null +++ b/imagora-launcher/src/main/resources/application-local.yaml @@ -0,0 +1,25 @@ +application: + security: + jwt: + secret-key: secret + expiration-delay-in-minutes: 5 + password: + salt: s@lT + +server: + error: + whitelabel: + enabled: false # Disable html error responses. + include-stacktrace: never + http2: + enabled: true + +spring: + datasource: + url: jdbc:postgresql://localhost:11979/imagora_database + username: imagora_user + password: P@ssword! + driver-class-name: org.postgresql.Driver + jpa: + show-sql: false + open-in-view: false \ No newline at end of file diff --git a/imagora-launcher/src/main/resources/application.yaml b/imagora-launcher/src/main/resources/application.yaml new file mode 100644 index 0000000..f8fe185 --- /dev/null +++ b/imagora-launcher/src/main/resources/application.yaml @@ -0,0 +1,21 @@ +application: + security: + jwt: + secret-key: + expiration-delay-in-minutes: + password: + salt: + +server: + error: + include-stacktrace: never + +spring: + datasource: + url: jdbc:postgresql://:/ + username: + password: + driver-class-name: org.postgresql.Driver + threads: + virtual: + enabled: true \ No newline at end of file diff --git a/rest-client-collection/Imagora/Users/Login as Admin.bru b/rest-client-collection/Imagora/Users/Login as Admin.bru new file mode 100644 index 0000000..10c6332 --- /dev/null +++ b/rest-client-collection/Imagora/Users/Login as Admin.bru @@ -0,0 +1,25 @@ +meta { + name: Login as Admin + type: http + seq: 4 +} + +post { + url: {{url}}/api/users/login + body: json + auth: inherit +} + +body:json { + { + "username": "Admin", + "password": "P@ssword!" + } +} + +script:post-response { + if (res.status === 200) { + bru.setEnvVar('userId', res.body.refreshToken.userId); + bru.setEnvVar('jwt', res.body.accessToken); + } +} diff --git a/rest-client-collection/Imagora/Users/Login as Florian.bru b/rest-client-collection/Imagora/Users/Login as Florian.bru new file mode 100644 index 0000000..9e4f94f --- /dev/null +++ b/rest-client-collection/Imagora/Users/Login as Florian.bru @@ -0,0 +1,25 @@ +meta { + name: Login as Florian + type: http + seq: 2 +} + +post { + url: {{url}}/api/users/login + body: json + auth: inherit +} + +body:json { + { + "username": "Florian", + "password": "P@ssword!" + } +} + +script:post-response { + if (res.status === 200) { + bru.setEnvVar('userId', res.body.refreshToken.userId); + bru.setEnvVar('jwt', res.body.accessToken); + } +} diff --git a/rest-client-collection/Imagora/Users/Login as Thomas.bru b/rest-client-collection/Imagora/Users/Login as Thomas.bru new file mode 100644 index 0000000..06d357d --- /dev/null +++ b/rest-client-collection/Imagora/Users/Login as Thomas.bru @@ -0,0 +1,25 @@ +meta { + name: Login as Thomas + type: http + seq: 3 +} + +post { + url: {{url}}/api/users/login + body: json + auth: inherit +} + +body:json { + { + "username": "Joux", + "password": "P@ssword!" + } +} + +script:post-response { + if (res.status === 200) { + bru.setEnvVar('userId', res.body.refreshToken.userId); + bru.setEnvVar('jwt', res.body.accessToken); + } +} diff --git a/rest-client-collection/Imagora/Users/Signup.bru b/rest-client-collection/Imagora/Users/Signup.bru new file mode 100644 index 0000000..8473e13 --- /dev/null +++ b/rest-client-collection/Imagora/Users/Signup.bru @@ -0,0 +1,26 @@ +meta { + name: Signup + type: http + seq: 1 +} + +post { + url: {{url}}/api/users + body: json + auth: none +} + +body:json { + { + "pseudo": "Admin", + "email": "admin@admin.com", + "password": "P@ssword!" + } +} + +script:post-response { + if (res.status === 200) { + bru.setEnvVar('userId', res.body.refreshToken.userId); + bru.setEnvVar('jwt', res.body.accessToken); + } +} diff --git a/rest-client-collection/Imagora/Users/folder.bru b/rest-client-collection/Imagora/Users/folder.bru new file mode 100644 index 0000000..99514e9 --- /dev/null +++ b/rest-client-collection/Imagora/Users/folder.bru @@ -0,0 +1,4 @@ +meta { + name: Users + seq: 6 +} diff --git a/rest-client-collection/Imagora/bruno.json b/rest-client-collection/Imagora/bruno.json new file mode 100644 index 0000000..af2f7ea --- /dev/null +++ b/rest-client-collection/Imagora/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "Imagora", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/rest-client-collection/Imagora/environments/Localhost.bru b/rest-client-collection/Imagora/environments/Localhost.bru new file mode 100644 index 0000000..14f57aa --- /dev/null +++ b/rest-client-collection/Imagora/environments/Localhost.bru @@ -0,0 +1,5 @@ +vars { + url: http://localhost:8080 + jwt: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2N2VkZWUzYS00NmJiLTQ2OWItODRhZi0zNDA0Zjg0NDA1MTMiLCJleHAiOjE3NDY5NTYwMzMsInBzZXVkbyI6IkpvdXgiLCJlbWFpbCI6InRob21hcy5qb3V4QGdtYWlsLmNvbSIsInJvbGVzIjoiU1RBTkRBUkQifQ.O1I-yQeU09GXakktvBMY3QpSJS_PnLgIVFiMiqPQJOjaJ5z852R90rPHc76qKUBTuUqSmR-YFBGPNYa37e1xyg + userId: 67edee3a-46bb-469b-84af-3404f8440513 +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..79a33ec --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,25 @@ +// The settings file is the entry point of every Gradle build. +// Its primary purpose is to define the subprojects. +// It is also used for some aspects of project-wide configuration, like managing plugins, dependencies, etc. +// https://docs.gradle.org/current/userguide/settings_file_basics.html + +dependencyResolutionManagement { + // Use Maven Central as the default repository (where Gradle will download dependencies) in all subprojects. + @Suppress("UnstableApiUsage") + repositories { + mavenCentral() + } +} + +plugins { + // Use the Foojay Toolchains plugin to automatically download JDKs required by subprojects. + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +include(":imagora-domain") +include(":imagora-application") +include(":imagora-exposition") +include(":imagora-infrastructure") +include(":imagora-launcher") + +rootProject.name = "imagora" \ No newline at end of file