From cfcdceecdbf9d21fdc02c8ccc9228c7247899b96 Mon Sep 17 00:00:00 2001 From: Rodrigo Prestes Machado Date: Thu, 9 May 2024 12:01:10 -0300 Subject: [PATCH 1/3] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 38 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/custom.md | 10 ++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/custom.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..dd84ea7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..48d5f81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,10 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From e5301ceef15d68262eb382d3f73b6ffb8f3cd545 Mon Sep 17 00:00:00 2001 From: Rodrigo Prestes Machado Date: Sun, 5 Apr 2026 11:57:43 -0300 Subject: [PATCH 2/3] Users dashboard console Made-with: Cursor --- .devcontainer/devcontainer.json | 2 +- .gitignore | 4 + CHANGELOG.md | 6 + Dockerfile | 48 + README.md | 12 +- docker-compose.yml | 43 + docs/running/Running.md | 35 +- pom.xml | 67 +- .../adapters/controllers/BasicController.kt | 41 + .../adapters/controllers/UserController.kt | 825 +++++++----------- .../adapters/gateways/entities/RoleEntity.kt | 5 +- .../adapters/gateways/entities/UserEntity.kt | 8 +- .../gateways/repository/UserRepository.kt | 114 --- .../gateways/repository/UserRepositoryImpl.kt | 357 -------- .../out/persistence/UserEntityMapper.kt | 56 ++ .../out/persistence/UserPersistenceAdapter.kt | 283 ++++++ .../application/interfaces/AuthenticateUCI.kt | 49 -- .../application/interfaces/CreateUserUCI.kt | 42 - .../application/interfaces/DeleteUser.kt | 29 - .../application/interfaces/SocialAuthUCI.kt | 33 - .../interfaces/TwoFactorAuthUCI.kt | 43 - .../application/interfaces/UpdateUser.kt | 36 - .../application/interfaces/WebAuthnUCI.kt | 59 -- .../application/port/in/AuthenticateUCI.kt | 20 + .../application/port/in/CreateUserUCI.kt | 13 + .../users/application/port/in/DeleteUser.kt | 9 + .../application/port/in/SocialAuthUCI.kt | 11 + .../application/port/in/TwoFactorAuthUCI.kt | 13 + .../users/application/port/in/UpdateUser.kt | 11 + .../users/application/port/in/WebAuthnUCI.kt | 15 + .../port/out/UserPersistencePort.kt | 34 + .../application/usecases/AuthenticateUC.kt | 56 +- .../application/usecases/CreateUserUC.kt | 65 +- .../application/usecases/DeleteUserImpl.kt | 30 +- .../application/usecases/SocialAuthUC.kt | 63 +- .../application/usecases/TwoFactorAuthUC.kt | 56 +- .../application/usecases/UpdateUserImpl.kt | 69 +- .../users/application/usecases/WebAuthnUC.kt | 12 +- .../{enterprise => domain}/model/Role.kt | 5 +- .../{enterprise => domain}/model/User.kt | 34 +- .../rest/DashboardSpaRootResource.kt | 27 + .../rest/authentication/AuthenticationWS.kt | 27 +- .../rest/authentication/SocialAuthWS.kt | 5 +- .../META-INF/resources/admin/README.md | 8 +- .../META-INF/resources/admin/index.html | 2 +- .../META-INF/resources/admin/src/App.vue | 13 +- .../admin/src/components/DeleteUserDialog.vue | 14 +- .../resources/admin/src/router/index.js | 6 +- .../resources/admin/src/services/api.js | 4 +- .../resources/admin/src/stores/users.js | 16 +- .../admin/src/views/CreateUserView.vue | 38 +- .../admin/src/views/EditUserView.vue | 50 +- .../resources/admin/src/views/LoginView.vue | 38 +- .../admin/src/views/UserDetailView.vue | 44 +- .../admin/src/views/UsersListView.vue | 48 +- .../META-INF/resources/admin/vite.config.js | 9 +- src/main/resources/application.properties | 27 +- src/main/resources/import.sql | 17 +- .../users/usecases/AuthenticateUCTest.kt | 4 +- .../orion/users/usecases/CreateUserUCTest.kt | 4 +- 60 files changed, 1279 insertions(+), 1835 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml delete mode 100644 src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepository.kt delete mode 100644 src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepositoryImpl.kt create mode 100644 src/main/kotlin/dev/orion/users/adapters/out/persistence/UserEntityMapper.kt create mode 100644 src/main/kotlin/dev/orion/users/adapters/out/persistence/UserPersistenceAdapter.kt delete mode 100644 src/main/kotlin/dev/orion/users/application/interfaces/AuthenticateUCI.kt delete mode 100644 src/main/kotlin/dev/orion/users/application/interfaces/CreateUserUCI.kt delete mode 100644 src/main/kotlin/dev/orion/users/application/interfaces/DeleteUser.kt delete mode 100644 src/main/kotlin/dev/orion/users/application/interfaces/SocialAuthUCI.kt delete mode 100644 src/main/kotlin/dev/orion/users/application/interfaces/TwoFactorAuthUCI.kt delete mode 100644 src/main/kotlin/dev/orion/users/application/interfaces/UpdateUser.kt delete mode 100644 src/main/kotlin/dev/orion/users/application/interfaces/WebAuthnUCI.kt create mode 100644 src/main/kotlin/dev/orion/users/application/port/in/AuthenticateUCI.kt create mode 100644 src/main/kotlin/dev/orion/users/application/port/in/CreateUserUCI.kt create mode 100644 src/main/kotlin/dev/orion/users/application/port/in/DeleteUser.kt create mode 100644 src/main/kotlin/dev/orion/users/application/port/in/SocialAuthUCI.kt create mode 100644 src/main/kotlin/dev/orion/users/application/port/in/TwoFactorAuthUCI.kt create mode 100644 src/main/kotlin/dev/orion/users/application/port/in/UpdateUser.kt create mode 100644 src/main/kotlin/dev/orion/users/application/port/in/WebAuthnUCI.kt create mode 100644 src/main/kotlin/dev/orion/users/application/port/out/UserPersistencePort.kt rename src/main/kotlin/dev/orion/users/{enterprise => domain}/model/Role.kt (86%) rename src/main/kotlin/dev/orion/users/{enterprise => domain}/model/User.kt (77%) create mode 100644 src/main/kotlin/dev/orion/users/frameworks/rest/DashboardSpaRootResource.kt diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 151204a..465b14b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -36,7 +36,7 @@ "GitHub.copilot-chat", "GitHub.vscode-github-actions", "GitHub.vscode-pull-request-github", - "cweijan.vscode-mysql-client2", + "ckolkman.vscode-postgres", "cracrayol.java-pmd", "SonarSource.sonarlint-vscode", "streetsidesoftware.code-spell-checker-portuguese-brazilian" diff --git a/.gitignore b/.gitignore index 2a2a691..d2eb9bd 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ nb-configuration.xml # Local environment .env + +# Admin Vite (gerado por Maven: npm ci + npm run build) +src/main/resources/META-INF/resources/dashboard/ +src/main/resources/META-INF/resources/admin/node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 402d6f3..e5a61b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Users change Log +## 0.0.7 + +- Switch to Postgres +- Modify to support hexagonal architecture +- Create Dockerfile and docker-compose + ## 0.0.6 - LoginResponseDTO as a standard response object from all endpoints diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f97e3e4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +#### +# Imagem JVM (Java 25) com o frontend admin (Vite/Vue) incluído. +# +# O admin em src/main/resources/META-INF/resources/admin gera artefatos em +# META-INF/resources/dashboard/ (URL /dashboard), servidos pelo Quarkus. +# +# A partir da raiz do repositório: +# +# docker build -t quarkus/users-jvm-admin . +# +# docker run --rm -p 8080:8080 quarkus/users-jvm-admin +# +# Requisitos: apenas Docker (Node e Maven correm dentro do build). +#### + +# --- 1) Build do admin (npm) +FROM node:22-alpine AS admin-build +WORKDIR /app/admin +COPY src/main/resources/META-INF/resources/admin/package.json \ + src/main/resources/META-INF/resources/admin/package-lock.json ./ +RUN npm ci +COPY src/main/resources/META-INF/resources/admin/ ./ +RUN npm run build +# vite.config.js: outDir ../dashboard -> /app/dashboard + +# --- 2) Build Quarkus (fast-jar), alinhado a maven.compiler.release=25 +# Imagem Maven oficial (evita depender do wrapper: maven-wrapper.jar está em .gitignore) +FROM maven:3-eclipse-temurin-25 AS maven-build +WORKDIR /build +COPY pom.xml . +COPY src ./src +COPY --from=admin-build /app/dashboard ./src/main/resources/META-INF/resources/dashboard +RUN mvn -B -DskipTests package + +# --- 3) Runtime (JRE 25) +FROM eclipse-temurin:25-jre-noble +ENV LANG='en_US.UTF-8' \ + LANGUAGE='en_US:en' \ + JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" + +WORKDIR /deployments +COPY --from=maven-build /build/target/quarkus-app/lib/ ./lib/ +COPY --from=maven-build /build/target/quarkus-app/*.jar ./ +COPY --from=maven-build /build/target/quarkus-app/app/ ./app/ +COPY --from=maven-build /build/target/quarkus-app/quarkus/ ./quarkus/ + +EXPOSE 8080 +ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS_APPEND} -jar quarkus-run.jar"] diff --git a/README.md b/README.md index 52f39e4..6979c1d 100755 --- a/README.md +++ b/README.md @@ -49,15 +49,15 @@ The playground includes: For detailed information about the playground, including how to run it in development and production modes, social login configuration, and user guide, see the [Playground Documentation](docs/playground/Playground.md). -## Admin Console +## Admin dashboard -The project includes a Vue 3 admin console application built with Vuetify that provides an administrative interface for managing users in the Orion Users service. +The project includes a Vue 3 admin application built with Vuetify that provides an administrative interface for managing users in the Orion Users service. -**Access the admin console**: After starting the application, navigate to `http://localhost:8080/console` +**Access the admin UI**: After starting the application, navigate to `http://localhost:8080/dashboard` -**Authentication**: The admin console requires authentication with a JWT token that includes the `admin` role. Only users with admin privileges can access this interface. +**Authentication**: The admin UI requires authentication with a JWT token that includes the `admin` role. Only users with admin privileges can access this interface. -The admin console includes: +The admin dashboard includes: - User authentication with admin role verification - User listing with filters and search functionality - User detail view with complete user information @@ -65,7 +65,7 @@ The admin console includes: - User editing (update email and password) - User deletion (delete users with confirmation) -For detailed information about the admin console, including development setup and features, see the [Admin Console README](src/main/resources/META-INF/resources/admin/README.md). +For detailed information about the admin UI, including development setup and features, see the [Admin README](src/main/resources/META-INF/resources/admin/README.md). ## Packaging and running the application diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5bc661e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +# Orion Users — API Quarkus + PostgreSQL +# +# docker compose up --build +# Abre: http://localhost:8080 (admin: http://localhost:8080/dashboard) +# +# Variáveis podem ser sobrescritas com ficheiro .env na mesma pasta. + +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-orion} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-orion} + POSTGRES_DB: ${POSTGRES_DB:-users} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-orion} -d ${POSTGRES_DB:-users}"] + interval: 5s + timeout: 5s + retries: 10 + + users: + build: + context: . + dockerfile: Dockerfile + ports: + - "${HTTP_PORT:-8080}:8080" + environment: + QUARKUS_DEVSERVICES_ENABLED: "false" + QUARKUS_DATASOURCE_REACTIVE_URL: postgresql://${POSTGRES_USER:-orion}:${POSTGRES_PASSWORD:-orion}@postgres:5432/${POSTGRES_DB:-users} + QUARKUS_DATASOURCE_USERNAME: ${POSTGRES_USER:-orion} + QUARKUS_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-orion} + # Evita dependência de SMTP real ao subir com compose (sobrescreve perfil prod) + QUARKUS_MAILER_MOCK: "${QUARKUS_MAILER_MOCK:-true}" + depends_on: + postgres: + condition: service_healthy + +volumes: + postgres_data: diff --git a/docs/running/Running.md b/docs/running/Running.md index 0ddb8cb..5754f5f 100644 --- a/docs/running/Running.md +++ b/docs/running/Running.md @@ -14,7 +14,7 @@ This document provides comprehensive instructions for running the Orion Users se - **Java 21** or higher - **Maven** (or use the included `mvnw` wrapper) -- **Database**: MySQL/MariaDB (or compatible database) +- **Database**: PostgreSQL - **Node.js 18+** (for Playground frontend development) ### Running in Dev Mode @@ -47,10 +47,10 @@ src/main/resources/application.properties ```properties # Database Configuration -quarkus.datasource.db-kind=mysql +quarkus.datasource.db-kind=postgresql quarkus.datasource.username=your-username quarkus.datasource.password=your-password -quarkus.datasource.reactive.url=mysql://localhost:3306/users +quarkus.datasource.reactive.url=postgresql://localhost:5432/users # JWT Configuration mp.jwt.verify.publickey.location=classpath:publicKey.pem @@ -77,13 +77,13 @@ You can use Quarkus profiles for different environments: ```properties # Development -%dev.quarkus.datasource.reactive.url=mysql://localhost:3306/users_dev +%dev.quarkus.datasource.reactive.url=postgresql://localhost:5432/users_dev # Test -%test.quarkus.datasource.reactive.url=mysql://localhost:3306/users_test +%test.quarkus.datasource.reactive.url=postgresql://localhost:5433/users_test # Production -%prod.quarkus.datasource.reactive.url=mysql://production-host:3306/users_prod +%prod.quarkus.datasource.reactive.url=postgresql://production-host:5432/users_prod ``` ## Production with Containers @@ -112,7 +112,7 @@ docker build -f src/main/docker/Dockerfile.jvm -t orion-users:jvm . ```bash docker run -i --rm -p 8080:8080 \ - -e QUARKUS_DATASOURCE_REACTIVE_URL=mysql://host.docker.internal:3306/users \ + -e QUARKUS_DATASOURCE_REACTIVE_URL=postgresql://host.docker.internal:5432/users \ -e QUARKUS_DATASOURCE_USERNAME=your-username \ -e QUARKUS_DATASOURCE_PASSWORD=your-password \ orion-users:jvm @@ -124,7 +124,7 @@ You can override configuration using environment variables: ```bash docker run -i --rm -p 8080:8080 \ - -e QUARKUS_DATASOURCE_REACTIVE_URL=mysql://db-host:3306/users \ + -e QUARKUS_DATASOURCE_REACTIVE_URL=postgresql://db-host:5432/users \ -e QUARKUS_DATASOURCE_USERNAME=dbuser \ -e QUARKUS_DATASOURCE_PASSWORD=dbpass \ -e QUARKUS_MAILER_HOST=smtp.example.com \ @@ -192,7 +192,7 @@ docker build -f src/main/docker/Dockerfile.native -t orion-users:native . ```bash docker run -i --rm -p 8080:8080 \ - -e QUARKUS_DATASOURCE_REACTIVE_URL=mysql://host.docker.internal:3306/users \ + -e QUARKUS_DATASOURCE_REACTIVE_URL=postgresql://host.docker.internal:5432/users \ -e QUARKUS_DATASOURCE_USERNAME=your-username \ -e QUARKUS_DATASOURCE_PASSWORD=your-password \ orion-users:native @@ -227,16 +227,15 @@ version: '3.8' services: database: - image: mysql:8.0 + image: postgres:16 environment: - MYSQL_ROOT_PASSWORD: rootpassword - MYSQL_DATABASE: users - MYSQL_USER: users - MYSQL_PASSWORD: userspassword + POSTGRES_DB: users + POSTGRES_USER: users + POSTGRES_PASSWORD: userspassword ports: - - "3306:3306" + - "5432:5432" volumes: - - mysql_data:/var/lib/mysql + - postgres_data:/var/lib/postgresql/data users-service: build: @@ -245,14 +244,14 @@ services: ports: - "8080:8080" environment: - QUARKUS_DATASOURCE_REACTIVE_URL: mysql://database:3306/users + QUARKUS_DATASOURCE_REACTIVE_URL: postgresql://users:userspassword@database:5432/users QUARKUS_DATASOURCE_USERNAME: users QUARKUS_DATASOURCE_PASSWORD: userspassword depends_on: - database volumes: - mysql_data: + postgres_data: ``` Run with: diff --git a/pom.xml b/pom.xml index 12eea31..2319921 100755 --- a/pom.xml +++ b/pom.xml @@ -5,18 +5,20 @@ 4.0.0 dev.orion users - 0.0.6 + 0.0.7 3.12.1 - 21 + 25 UTF-8 UTF-8 quarkus-bom io.quarkus.platform - 3.29.0 + 3.34.1 3.2.5 - 2.1.0 + 2.3.0 21 + + false @@ -67,7 +69,7 @@ io.quarkus - quarkus-reactive-mysql-client + quarkus-reactive-pg-client io.quarkus @@ -103,6 +105,14 @@ org.jetbrains.kotlin kotlin-stdlib-jdk8 + + org.jetbrains.kotlinx + kotlinx-coroutines-core + + + io.smallrye.reactive + mutiny-kotlin + commons-codec commons-codec @@ -132,7 +142,7 @@ io.quarkus - quarkus-junit5 + quarkus-junit test @@ -165,6 +175,45 @@ + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + admin-dashboard-npm-ci + generate-resources + + exec + + + ${admin.frontend.skip} + ${project.basedir}/src/main/resources/META-INF/resources/admin + npm + + ci + + + + + admin-dashboard-npm-build + generate-resources + + exec + + + ${admin.frontend.skip} + ${project.basedir}/src/main/resources/META-INF/resources/admin + npm + + run + build + + + + + ${quarkus.platform.group-id} quarkus-maven-plugin @@ -206,7 +255,7 @@ ${kotlin.compiler.jvmTarget} - -Xjvm-default=all + -jvm-default=enable @@ -222,7 +271,7 @@ ${kotlin.compiler.jvmTarget} - -Xjvm-default=all + -jvm-default=enable @@ -230,7 +279,7 @@ ${kotlin.compiler.jvmTarget} - -Xjvm-default=all + -jvm-default=enable -Xno-param-assertions diff --git a/src/main/kotlin/dev/orion/users/adapters/controllers/BasicController.kt b/src/main/kotlin/dev/orion/users/adapters/controllers/BasicController.kt index 2035bf3..797e613 100644 --- a/src/main/kotlin/dev/orion/users/adapters/controllers/BasicController.kt +++ b/src/main/kotlin/dev/orion/users/adapters/controllers/BasicController.kt @@ -39,17 +39,48 @@ import com.google.zxing.common.BitMatrix import de.taimos.totp.TOTP import dev.orion.users.adapters.gateways.entities.UserEntity +import dev.orion.users.domain.model.User as DomainUser import dev.orion.users.frameworks.mail.MailTemplate import dev.orion.users.frameworks.rest.ServiceException import io.smallrye.jwt.build.Jwt import io.smallrye.mutiny.Uni import jakarta.ws.rs.core.Response +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * The controller class. */ open class BasicController { + /** + * Bridges a suspend block into a [Uni], keeping the Vert.x event-loop + * context via [Dispatchers.Unconfined]. + */ + protected fun toUni(block: suspend () -> T): Uni = + Uni.createFrom().emitter { em -> + CoroutineScope(Dispatchers.Unconfined).launch { + try { + em.complete(block()) + } catch (e: Throwable) { + em.fail(e) + } + } + } + + protected fun toUniVoid(block: suspend () -> Unit): Uni = + Uni.createFrom().emitter { em -> + CoroutineScope(Dispatchers.Unconfined).launch { + try { + block() + em.complete(null) + } catch (e: Throwable) { + em.fail(e) + } + } + } + /** The encoding used in the QR code. */ private val UTF_8 = "UTF-8" @@ -79,6 +110,16 @@ open class BasicController { .sign() } + /** JWT from domain user (same claims as entity). */ + fun generateJWT(user: DomainUser): String { + return Jwt.issuer(issuer) + .upn(user.email) + .groups(user.getRoleList().toSet()) + .claim(Claims.c_hash, user.hash) + .claim(Claims.email, user.email) + .sign() + } + /** * Verifies if the e-mail from the jwt is the same from request. * diff --git a/src/main/kotlin/dev/orion/users/adapters/controllers/UserController.kt b/src/main/kotlin/dev/orion/users/adapters/controllers/UserController.kt index 2c898c3..f3ffdaa 100644 --- a/src/main/kotlin/dev/orion/users/adapters/controllers/UserController.kt +++ b/src/main/kotlin/dev/orion/users/adapters/controllers/UserController.kt @@ -17,24 +17,19 @@ package dev.orion.users.adapters.controllers import dev.orion.users.adapters.gateways.entities.UserEntity -import dev.orion.users.adapters.gateways.repository.UserRepository -import dev.orion.users.adapters.presenters.AuthenticationDTO -import dev.orion.users.adapters.presenters.LoginResponseDTO import dev.orion.users.adapters.gateways.entities.WebAuthnCredentialEntity import dev.orion.users.adapters.gateways.repository.WebAuthnCredentialRepository -import dev.orion.users.application.interfaces.AuthenticateUCI -import dev.orion.users.application.interfaces.CreateUserUCI -import dev.orion.users.application.interfaces.SocialAuthUCI -import dev.orion.users.application.interfaces.TwoFactorAuthUCI -import dev.orion.users.application.interfaces.UpdateUser -import dev.orion.users.application.interfaces.WebAuthnUCI -import dev.orion.users.application.usecases.AuthenticateUC -import dev.orion.users.application.usecases.CreateUserUC -import dev.orion.users.application.usecases.SocialAuthUC -import dev.orion.users.application.usecases.TwoFactorAuthUC -import dev.orion.users.application.usecases.UpdateUserImpl -import dev.orion.users.application.usecases.WebAuthnUC -import dev.orion.users.enterprise.model.User +import dev.orion.users.adapters.out.persistence.UserEntityMapper +import dev.orion.users.adapters.presenters.AuthenticationDTO +import dev.orion.users.adapters.presenters.LoginResponseDTO +import dev.orion.users.application.port.`in`.AuthenticateUCI +import dev.orion.users.application.port.`in`.CreateUserUCI +import dev.orion.users.application.port.`in`.SocialAuthUCI +import dev.orion.users.application.port.`in`.TwoFactorAuthUCI +import dev.orion.users.application.port.`in`.UpdateUser +import dev.orion.users.application.port.`in`.WebAuthnUCI +import dev.orion.users.application.port.out.UserPersistencePort +import dev.orion.users.domain.model.User import dev.orion.users.frameworks.mail.MailTemplate import com.fasterxml.jackson.databind.ObjectMapper import java.security.SecureRandom @@ -42,6 +37,7 @@ import java.util.* import java.util.Base64 import io.quarkus.hibernate.reactive.panache.common.WithSession import io.smallrye.mutiny.Uni +import io.smallrye.mutiny.coroutines.awaitSuspending import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import org.apache.commons.codec.digest.DigestUtils @@ -53,33 +49,33 @@ import org.apache.commons.codec.digest.DigestUtils @ApplicationScoped class UserController : BasicController() { - /** Use cases for users. */ - private val createUC: CreateUserUCI = CreateUserUC() + @Inject + lateinit var createUC: CreateUserUCI + + @Inject + lateinit var authenticationUC: AuthenticateUCI - /** Use cases for authentication. */ - private val authenticationUC: AuthenticateUCI = AuthenticateUC() + @Inject + lateinit var socialAuthUC: SocialAuthUCI - /** Use cases for social authentication. */ - private val socialAuthUC: SocialAuthUCI = SocialAuthUC() + @Inject + lateinit var twoFactorAuthUC: TwoFactorAuthUCI - /** Use cases for two factor authentication. */ - private val twoFactorAuthUC: TwoFactorAuthUCI = TwoFactorAuthUC() + @Inject + lateinit var webAuthnUC: WebAuthnUCI - /** Use cases for WebAuthn. */ - private val webAuthnUC: WebAuthnUCI = WebAuthnUC() + @Inject + lateinit var updateUserUC: UpdateUser - /** Use cases for updating user. */ - private val updateUserUC: UpdateUser = UpdateUserImpl() + @Inject + lateinit var userPersistence: UserPersistencePort - /** Persistence layer. */ @Inject - lateinit var userRepository: UserRepository + lateinit var userEntityMapper: UserEntityMapper - /** WebAuthn credential repository. */ @Inject lateinit var webAuthnCredentialRepository: WebAuthnCredentialRepository - /** Object mapper for JSON. */ private val objectMapper = ObjectMapper() /** @@ -91,14 +87,12 @@ class UserController : BasicController() { * @param password : The user password * @return : Returns a Uni object */ - fun createUser(name: String, email: String, password: String): Uni { + fun createUser(name: String, email: String, password: String): Uni = toUni { val user: User = createUC.createUser(name, email, password) - val entity: UserEntity = mapper.map(user, UserEntity::class.java) - // Allow the ID to be null for auto-generation by the database - entity.id = null - return userRepository.createUser(entity) - .onItem().ifNotNull().transform { u -> u } - .onItem().ifNotNull().call { user -> this.sendValidationEmail(user) } + val created = userPersistence.createUser(user) + val entity = userEntityMapper.toEntity(created) + sendValidationEmail(entity).awaitSuspending() + entity } /** @@ -108,12 +102,10 @@ class UserController : BasicController() { * @param code : The validation code * @return : Returns a Uni object */ - fun validateEmail(email: String, code: String): Uni? { - var result: Uni? = null - if (authenticationUC.validateEmail(email, code) == true) { - result = userRepository.validateEmail(email, code) - } - return result + fun validateEmail(email: String, code: String): Uni = toUni { + authenticationUC.requireEmailValidationParams(email, code) + val validated = userPersistence.validateEmail(email, code) + userEntityMapper.toEntity(validated) } /** @@ -123,21 +115,11 @@ class UserController : BasicController() { * @param password : The user password * @return : Returns a JSON Web Token (JWT) */ - fun authenticate(email: String, password: String): Uni { - // Creates a user in the model to encrypts the password and - // converts it to an entity - val entity: UserEntity = mapper.map( - authenticationUC.authenticate(email, password), - UserEntity::class.java - ) - - // Finds the user in the service through email and password and - // generates a JWT - return userRepository.authenticate(entity) - .onItem().ifNotNull() - .transform { - this.generateJWT(it) - } + fun authenticate(email: String, password: String): Uni = toUni { + val auth = authenticationUC.authenticate(email, password) + val domain = userPersistence.authenticate(auth.email!!, auth.password!!) + ?: throw IllegalArgumentException("Invalid credentials") + generateJWT(domain) } /** @@ -148,32 +130,23 @@ class UserController : BasicController() { * @param password the password of the user * @return a Uni object that emits a LoginResponseDTO */ - fun login(email: String, password: String): Uni { - // Creates a user in the model to encrypts the password and - // converts it to an entity - val entity: UserEntity = mapper.map( - authenticationUC.authenticate(email, password), - UserEntity::class.java - ) - - return userRepository.authenticate(entity) - .onItem().ifNotNull().transform { user -> - val response = LoginResponseDTO() - - // Check if user has 2FA enabled AND requires it for basic login - if (user.isUsing2FA && user.require2FAForBasicLogin) { - response.requires2FA = true - response.message = "2FA code required" - } else { - // Normal login without 2FA requirement - val dto = AuthenticationDTO() - dto.token = this.generateJWT(user) - dto.user = user - response.authentication = dto - response.requires2FA = false - } - response - } + fun login(email: String, password: String): Uni = toUni { + val auth = authenticationUC.authenticate(email, password) + val domain = userPersistence.authenticate(auth.email!!, auth.password!!) + ?: throw IllegalArgumentException("Invalid credentials") + + val response = LoginResponseDTO() + if (domain.using2FA && domain.require2FAForBasicLogin) { + response.requires2FA = true + response.message = "2FA code required" + } else { + val dto = AuthenticationDTO() + dto.token = generateJWT(domain) + dto.user = userEntityMapper.toEntity(domain) + response.authentication = dto + response.requires2FA = false + } + response } /** @@ -185,73 +158,63 @@ class UserController : BasicController() { * @param password : The user password * @return A Uni object */ - fun createAuthenticate(name: String, email: String, password: String): Uni { - return this.createUser(name, email, password) - .onItem().ifNotNull().transform { user -> - val authDto = AuthenticationDTO() - authDto.token = this.generateJWT(user) - authDto.user = user - - val response = LoginResponseDTO() - response.authentication = authDto - response.requires2FA = false - - response - } + fun createAuthenticate(name: String, email: String, password: String): Uni = toUni { + val entity = this@UserController.createUser(name, email, password).awaitSuspending() + + val authDto = AuthenticationDTO() + authDto.token = generateJWT(entity) + authDto.user = entity + + val response = LoginResponseDTO() + response.authentication = authDto + response.requires2FA = false + response } /** * Authenticates a user with a social provider (Google). * If the user doesn't exist, creates it automatically. - * If the user has 2FA enabled and requires it for social login, returns a response indicating that 2FA code is required. + * If the user has 2FA enabled and requires it for social login, returns a response + * indicating that 2FA code is required. * * @param email The email from the social provider * @param name The name from the social provider * @param provider The provider name ("google") * @return A Uni object (may contain JWT or indicate 2FA is required) */ - fun loginWithSocialProvider(email: String, name: String, provider: String): Uni { - // Validate social auth data using use case - val user: User = socialAuthUC.validateSocialAuth(email, name, provider) - val entity: UserEntity = mapper.map(user, UserEntity::class.java) - - // Try to find existing user by email - return userRepository.findUserByEmail(email) - .onItem().ifNotNull().transform { existingUser -> - val response = LoginResponseDTO() - - // Check if user has 2FA enabled AND requires it for social login - if (existingUser.isUsing2FA && existingUser.require2FAForSocialLogin) { - response.requires2FA = true - response.message = "2FA code required" - } else { - // Normal login without 2FA requirement - val dto = AuthenticationDTO() - dto.token = this.generateJWT(existingUser) - dto.user = existingUser - response.authentication = dto - response.requires2FA = false - } - - response - } - .onItem().ifNull().switchTo { - // User doesn't exist, create it - // Generate a secure password (user won't use it, but DB requires it) - entity.password = DigestUtils.sha256Hex(UUID.randomUUID().toString()) - // Garantir que o ID seja null para permitir geração automática pelo banco - entity.id = null - userRepository.createUser(entity) - .onItem().ifNotNull().transform { newUser -> - val response = LoginResponseDTO() - val dto = AuthenticationDTO() - dto.token = this.generateJWT(newUser) - dto.user = newUser - response.authentication = dto - response.requires2FA = false - response - } + fun loginWithSocialProvider(email: String, name: String, provider: String): Uni = toUni { + val socialUser: User = socialAuthUC.validateSocialAuth(email, name, provider) + val existingDomain = userPersistence.findUserByEmail(email) + + if (existingDomain != null) { + val response = LoginResponseDTO() + if (existingDomain.using2FA && existingDomain.require2FAForSocialLogin) { + response.requires2FA = true + response.message = "2FA code required" + } else { + val dto = AuthenticationDTO() + dto.token = generateJWT(existingDomain) + dto.user = userEntityMapper.toEntity(existingDomain) + response.authentication = dto + response.requires2FA = false } + response + } else { + val newUser = User() + newUser.name = socialUser.name + newUser.email = socialUser.email + newUser.emailValid = socialUser.emailValid + newUser.password = DigestUtils.sha256Hex(UUID.randomUUID().toString()) + val newDomain = userPersistence.createUser(newUser) + + val response = LoginResponseDTO() + val dto = AuthenticationDTO() + dto.token = generateJWT(newDomain) + dto.user = userEntityMapper.toEntity(newDomain) + response.authentication = dto + response.requires2FA = false + response + } } /** @@ -260,8 +223,8 @@ class UserController : BasicController() { * @param email The user's e-mail * @return A Uni object */ - fun deleteUser(email: String): Uni { - return userRepository.deleteUser(email) + fun deleteUser(email: String): Uni = toUniVoid { + userPersistence.deleteUser(email) } /** @@ -273,34 +236,18 @@ class UserController : BasicController() { * @param password The password of the user * @return A Uni that emits a ByteArray containing the QR code image */ - fun generate2FAQRCode(email: String, password: String): Uni { - // Validate credentials using the use case + fun generate2FAQRCode(email: String, password: String): Uni = toUni { val user: User = twoFactorAuthUC.generateQRCode(email, password) - val entity: UserEntity = mapper.map(user, UserEntity::class.java) - - // Authenticate user first - return userRepository.authenticate(entity) - .onItem().ifNotNull().transformToUni { authenticatedUser -> - // Generate secret key - val secretKey = generateSecretKey() - - // Update user with 2FA secret - authenticatedUser.isUsing2FA = true - authenticatedUser.secret2FA = secretKey - - // Persist the updated user - userRepository.updateUser(authenticatedUser) - .onItem().transform { updatedUser -> - // Generate QR code - val issuer = issuer - val barCodeData = getAuthenticatorBarCode( - secretKey, - updatedUser.email ?: email, - issuer - ) - createQrCode(barCodeData) - } - } + val authenticatedDomain = userPersistence.authenticate(user.email!!, user.password!!) + ?: throw IllegalArgumentException("Invalid credentials") + + val secretKey = generateSecretKey() + authenticatedDomain.using2FA = true + authenticatedDomain.secret2FA = secretKey + + val updatedDomain = userPersistence.updateUser(authenticatedDomain) + val barCodeData = getAuthenticatorBarCode(secretKey, updatedDomain.email ?: email, issuer) + createQrCode(barCodeData) } /** @@ -310,48 +257,23 @@ class UserController : BasicController() { * @param code The TOTP code to validate * @return A Uni that emits a LoginResponseDTO with JWT if validation succeeds */ - fun validateSocialLogin2FA(email: String, code: String): Uni { - // Validate code format using use case - val user: User = twoFactorAuthUC.validateCode(email, code) - - // Find user by email - return userRepository.findUserByEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("User not found")) - .onItem().ifNotNull().transformToUni { userEntity -> - // Check if 2FA is enabled - if (!userEntity.isUsing2FA) { - throw IllegalArgumentException("2FA is not enabled for this user") - } - - // Check if user requires 2FA for social login - if (!userEntity.require2FAForSocialLogin) { - throw IllegalArgumentException("2FA is not required for social login for this user") - } - - // Get secret from user - val secret = userEntity.secret2FA - if (secret == null) { - throw IllegalArgumentException("2FA secret not found") - } - - // Validate TOTP code - val expectedCode = getTOTPCode(secret) - if (code != expectedCode) { - throw IllegalArgumentException("Invalid TOTP code") - } - - // Generate JWT and return DTO - val authDto = AuthenticationDTO() - authDto.token = generateJWT(userEntity) - authDto.user = userEntity - - val response = LoginResponseDTO() - response.authentication = authDto - response.requires2FA = false - - Uni.createFrom().item(response) - } + fun validateSocialLogin2FA(email: String, code: String): Uni = toUni { + twoFactorAuthUC.validateCode(email, code) + + val d = userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + if (!d.using2FA) throw IllegalArgumentException("2FA is not enabled for this user") + if (!d.require2FAForSocialLogin) throw IllegalArgumentException("2FA is not required for social login for this user") + val secret = d.secret2FA ?: throw IllegalArgumentException("2FA secret not found") + if (code != getTOTPCode(secret)) throw IllegalArgumentException("Invalid TOTP code") + + val authDto = AuthenticationDTO() + authDto.token = generateJWT(d) + authDto.user = userEntityMapper.toEntity(d) + val response = LoginResponseDTO() + response.authentication = authDto + response.requires2FA = false + response } /** @@ -361,43 +283,22 @@ class UserController : BasicController() { * @param code The TOTP code to validate * @return A Uni that emits a LoginResponseDTO with JWT if validation succeeds */ - fun validate2FACode(email: String, code: String): Uni { - // Validate code format using use case - val user: User = twoFactorAuthUC.validateCode(email, code) - - // Find user by email - return userRepository.findUserByEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("User not found")) - .onItem().ifNotNull().transformToUni { userEntity -> - // Check if 2FA is enabled - if (!userEntity.isUsing2FA) { - throw IllegalArgumentException("2FA is not enabled for this user") - } - - // Get secret from user - val secret = userEntity.secret2FA - if (secret == null) { - throw IllegalArgumentException("2FA secret not found") - } - - // Validate TOTP code - val expectedCode = getTOTPCode(secret) - if (code != expectedCode) { - throw IllegalArgumentException("Invalid TOTP code") - } - - // Generate JWT and return DTO - val authDto = AuthenticationDTO() - authDto.token = generateJWT(userEntity) - authDto.user = userEntity - - val response = LoginResponseDTO() - response.authentication = authDto - response.requires2FA = false - - Uni.createFrom().item(response) - } + fun validate2FACode(email: String, code: String): Uni = toUni { + twoFactorAuthUC.validateCode(email, code) + + val d = userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + if (!d.using2FA) throw IllegalArgumentException("2FA is not enabled for this user") + val secret = d.secret2FA ?: throw IllegalArgumentException("2FA secret not found") + if (code != getTOTPCode(secret)) throw IllegalArgumentException("Invalid TOTP code") + + val authDto = AuthenticationDTO() + authDto.token = generateJWT(d) + authDto.user = userEntityMapper.toEntity(d) + val response = LoginResponseDTO() + response.authentication = authDto + response.requires2FA = false + response } /** @@ -407,57 +308,40 @@ class UserController : BasicController() { * @param origin Optional origin URL to extract rpId from * @return A JSON string containing PublicKeyCredentialCreationOptions */ - fun startWebAuthnRegistration(email: String, origin: String? = null): Uni { - // Validate email using use case + fun startWebAuthnRegistration(email: String, origin: String? = null): Uni = toUni { webAuthnUC.startRegistration(email) - return userRepository.findUserByEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("User not found")) - .onItem().ifNotNull().transform { user -> - // Generate challenge (base64url encoded) - val challengeBytes = ByteArray(32) - SecureRandom().nextBytes(challengeBytes) - val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeBytes) - - // Create user ID (base64url encoded email) - val userId = Base64.getUrlEncoder().withoutPadding().encodeToString((user.email ?: email).toByteArray()) - - // Create PublicKeyCredentialCreationOptions as JSON - val rpName = issuer - val rpId = origin?.let { extractRpIdFromOrigin(it) } ?: "localhost" - val userName = user.email ?: email - val userDisplayName = user.name ?: user.email ?: email - - val options = mapOf( - "rp" to mapOf( - "name" to rpName, - "id" to rpId - ), - "user" to mapOf( - "id" to userId, - "name" to userName, - "displayName" to userDisplayName - ), - "challenge" to challenge, - "pubKeyCredParams" to listOf( - mapOf("type" to "public-key", "alg" to -7), // ES256 - mapOf("type" to "public-key", "alg" to -257) // RS256 - ), - "authenticatorSelection" to mapOf( - "authenticatorAttachment" to "platform", - "userVerification" to "preferred" - ), - "timeout" to 60000L, - "attestation" to "none" - ) - - val response = mapOf( - "options" to options, - "challenge" to challenge - ) - objectMapper.writeValueAsString(response) - } + val user = userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + + val challengeBytes = ByteArray(32) + SecureRandom().nextBytes(challengeBytes) + val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeBytes) + val userId = Base64.getUrlEncoder().withoutPadding() + .encodeToString((user.email ?: email).toByteArray()) + + val rpName = issuer + val rpId = origin?.let { extractRpIdFromOrigin(it) } ?: "localhost" + val userName = user.email ?: email + val userDisplayName = user.name ?: user.email ?: email + + val options = mapOf( + "rp" to mapOf("name" to rpName, "id" to rpId), + "user" to mapOf("id" to userId, "name" to userName, "displayName" to userDisplayName), + "challenge" to challenge, + "pubKeyCredParams" to listOf( + mapOf("type" to "public-key", "alg" to -7), + mapOf("type" to "public-key", "alg" to -257) + ), + "authenticatorSelection" to mapOf( + "authenticatorAttachment" to "platform", + "userVerification" to "preferred" + ), + "timeout" to 60000L, + "attestation" to "none" + ) + + objectMapper.writeValueAsString(mapOf("options" to options, "challenge" to challenge)) } /** @@ -469,32 +353,24 @@ class UserController : BasicController() { * @param deviceName Optional name for the device * @return true if registration was successful */ - fun finishWebAuthnRegistration(email: String, response: String, origin: String, deviceName: String?): Uni { - // Validate using use case + fun finishWebAuthnRegistration( + email: String, response: String, origin: String, deviceName: String? + ): Uni = toUni { webAuthnUC.finishRegistration(email, response, origin, deviceName) - - return userRepository.findUserByEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("User not found")) - .onItem().ifNotNull().transformToUni { user -> - try { - // Parse the response (simplified - actual implementation would parse JSON properly) - // In production, this would properly parse and validate the WebAuthn response - val credentialEntity = WebAuthnCredentialEntity() - credentialEntity.userEmail = email - credentialEntity.credentialId = UUID.randomUUID().toString() // Should be from actual response - credentialEntity.publicKey = response // Should be properly extracted and stored - credentialEntity.counter = 0 - credentialEntity.origin = origin - credentialEntity.notes = deviceName ?: "Unknown Device" - credentialEntity.deviceName = deviceName ?: "Unknown Device" // Keep for compatibility - - webAuthnCredentialRepository.saveCredential(credentialEntity) - .onItem().transform { true } - } catch (e: Exception) { - throw IllegalArgumentException("Failed to process WebAuthn registration: ${e.message}") - } - } + userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + + val credentialEntity = WebAuthnCredentialEntity() + credentialEntity.userEmail = email + credentialEntity.credentialId = UUID.randomUUID().toString() + credentialEntity.publicKey = response + credentialEntity.counter = 0 + credentialEntity.origin = origin + credentialEntity.notes = deviceName ?: "Unknown Device" + credentialEntity.deviceName = deviceName ?: "Unknown Device" + + webAuthnCredentialRepository.saveCredential(credentialEntity).awaitSuspending() + true } /** @@ -503,56 +379,34 @@ class UserController : BasicController() { * @param email The email of the user * @return A JSON string containing PublicKeyCredentialRequestOptions */ - fun startWebAuthnAuthentication(email: String): Uni { - // Validate email using use case + fun startWebAuthnAuthentication(email: String): Uni = toUni { webAuthnUC.startAuthentication(email) + userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") - return userRepository.findUserByEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("User not found")) - .onItem().ifNotNull().transformToUni { user -> - webAuthnCredentialRepository.findByUserEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("No WebAuthn credentials found for user")) - .onItem().ifNotNull().transform { credentials -> - if (credentials.isEmpty()) { - throw IllegalArgumentException("No WebAuthn credentials found for user") - } - - // Generate challenge (base64url encoded) - val challengeBytes = ByteArray(32) - SecureRandom().nextBytes(challengeBytes) - val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeBytes) - - // Create allowCredentials list - val allowCredentials = credentials.mapNotNull { cred -> - cred.credentialId?.let { id -> - mapOf( - "type" to "public-key", - "id" to id - ) - } - } - - // Extract rpId from stored origin to ensure consistency - val rpId = credentials.firstOrNull()?.origin?.let { extractRpIdFromOrigin(it) } ?: "localhost" - - // Create PublicKeyCredentialRequestOptions as JSON - val options = mapOf( - "challenge" to challenge, - "rpId" to rpId, - "allowCredentials" to allowCredentials, - "userVerification" to "preferred", - "timeout" to 60000L - ) - - val response = mapOf( - "options" to options, - "challenge" to challenge - ) - objectMapper.writeValueAsString(response) - } - } + val credentials = webAuthnCredentialRepository.findByUserEmail(email).awaitSuspending() + if (credentials.isNullOrEmpty()) { + throw IllegalArgumentException("No WebAuthn credentials found for user") + } + + val challengeBytes = ByteArray(32) + SecureRandom().nextBytes(challengeBytes) + val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeBytes) + + val allowCredentials = credentials.mapNotNull { cred -> + cred.credentialId?.let { id -> mapOf("type" to "public-key", "id" to id) } + } + val rpId = credentials.firstOrNull()?.origin?.let { extractRpIdFromOrigin(it) } ?: "localhost" + + val options = mapOf( + "challenge" to challenge, + "rpId" to rpId, + "allowCredentials" to allowCredentials, + "userVerification" to "preferred", + "timeout" to 60000L + ) + + objectMapper.writeValueAsString(mapOf("options" to options, "challenge" to challenge)) } /** @@ -562,41 +416,29 @@ class UserController : BasicController() { * @param response The authentication response from the client (JSON string) * @return A LoginResponseDTO with JWT if authentication succeeds */ - fun finishWebAuthnAuthentication(email: String, response: String): Uni { - // Validate using use case + fun finishWebAuthnAuthentication(email: String, response: String): Uni = toUni { webAuthnUC.finishAuthentication(email, response) - return userRepository.findUserByEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("User not found")) - .onItem().ifNotNull().transformToUni { user -> - // In production, this would properly parse and validate the WebAuthn response - // For now, we'll do a simplified validation - webAuthnCredentialRepository.findByUserEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("No WebAuthn credentials found")) - .onItem().ifNotNull().transform { credentials -> - if (credentials.isEmpty()) { - throw IllegalArgumentException("No WebAuthn credentials found") - } - - // Update counter (simplified - actual implementation would validate signature) - val credential = credentials.first() - credential.counter++ - webAuthnCredentialRepository.saveCredential(credential) - - // Generate JWT and return DTO - val authDto = AuthenticationDTO() - authDto.token = generateJWT(user) - authDto.user = user - - val loginResponse = LoginResponseDTO() - loginResponse.authentication = authDto - loginResponse.requires2FA = false - - loginResponse - } - } + val user = userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + + val credentials = webAuthnCredentialRepository.findByUserEmail(email).awaitSuspending() + if (credentials.isNullOrEmpty()) { + throw IllegalArgumentException("No WebAuthn credentials found") + } + + val credential = credentials.first() + credential.counter++ + webAuthnCredentialRepository.saveCredential(credential).awaitSuspending() + + val authDto = AuthenticationDTO() + authDto.token = generateJWT(user) + authDto.user = userEntityMapper.toEntity(user) + + val loginResponse = LoginResponseDTO() + loginResponse.authentication = authDto + loginResponse.requires2FA = false + loginResponse } /** @@ -606,25 +448,14 @@ class UserController : BasicController() { * @param email : The e-mail of the user * @return A Uni that completes when the password is recovered and email is sent */ - fun recoverPassword(email: String): Uni { - // Validate email using use case + fun recoverPassword(email: String): Uni = toUniVoid { authenticationUC.recoverPassword(email) - - // Generate new password and update user in repository - return userRepository.recoverPassword(email) - .onItem().ifNotNull().call { newPassword -> - // Send email with new password - sendRecoveryEmail(email, newPassword) - } - .onItem().transform { null } + val newPassword = userPersistence.recoverPassword(email) + sendRecoveryEmail(email, newPassword).awaitSuspending() } /** * Sends a recovery password email to the user. - * - * @param email : The user's email - * @param password : The new password - * @return A Uni that completes when the email is sent */ private fun sendRecoveryEmail(email: String, password: String): Uni { return MailTemplate.recoverPwd(password) @@ -654,98 +485,53 @@ class UserController : BasicController() { newPassword: String?, jwtEmail: String, isAdmin: Boolean = false - ): Uni { - // Validate using use case - val user: User = updateUserUC.updateUser(email, name, newEmail, password, newPassword) + ): Uni = toUni { + updateUserUC.updateUser(email, name, newEmail, password, newPassword) + if (!isAdmin) checkTokenEmail(email, jwtEmail) - // Validate that JWT email matches the current email (unless user is admin) - if (!isAdmin) { - checkTokenEmail(email, jwtEmail) - } - - // Capture variables for use in lambdas val nameUpdated = !name.isNullOrBlank() val emailUpdated = !newEmail.isNullOrBlank() val passwordUpdate = !newPassword.isNullOrBlank() && !password.isNullOrBlank() - val currentEmail = email - val newNameValue = name - val newEmailValue = newEmail - val currentPassword = password - val newPasswordValue = newPassword - - // Start with finding the user - return userRepository.findUserByEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("User not found")) - .onItem().ifNotNull().transformToUni { userEntity -> - // If updating password, validate current password - if (passwordUpdate) { - val encryptedPassword = DigestUtils.sha256Hex(currentPassword!!) - if (encryptedPassword != userEntity.password) { - throw IllegalArgumentException("Current password is incorrect") - } - } - - // Update name if provided - if (nameUpdated) { - userEntity.name = newNameValue - } - - // First update email if provided - if (emailUpdated) { - userRepository.updateEmail(currentEmail, newEmailValue!!) - .onItem().ifNotNull().call { updatedUser -> - // Send validation email to the new email address - sendValidationEmail(updatedUser) - } - } else { - // If only name was updated, persist the user - if (nameUpdated) { - userRepository.updateUser(userEntity) - } else { - Uni.createFrom().item(userEntity) - } - } - } - .onItem().ifNotNull().transformToUni { updatedUser -> - // Update name if email was also updated (to ensure name is saved) - if (emailUpdated && nameUpdated) { - updatedUser.name = newNameValue - userRepository.updateUser(updatedUser) - } else { - Uni.createFrom().item(updatedUser) - } - } - .onItem().ifNotNull().transformToUni { updatedUser -> - // Then update password if provided - if (passwordUpdate) { - val encryptedPassword = DigestUtils.sha256Hex(currentPassword!!) - val encryptedNewPassword = DigestUtils.sha256Hex(newPasswordValue!!) - // Use the updated email if email was changed, otherwise use original email - val emailForPasswordUpdate = if (emailUpdated) updatedUser.email ?: currentEmail else currentEmail - userRepository.changePassword(encryptedPassword, encryptedNewPassword, emailForPasswordUpdate) - } else { - Uni.createFrom().item(updatedUser) - } - } - .onItem().ifNotNull().transform { updatedUser -> - // Create LoginResponseDTO with AuthenticationDTO - val response = LoginResponseDTO() - val dto = AuthenticationDTO() - // Always generate a new JWT token - dto.token = generateJWT(updatedUser) - dto.user = updatedUser - response.authentication = dto - response.requires2FA = false + var userDomain = userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") - response + if (passwordUpdate) { + val encryptedPassword = DigestUtils.sha256Hex(password) + if (encryptedPassword != userDomain.password) { + throw IllegalArgumentException("Current password is incorrect") } + } + + if (emailUpdated) { + val updated = userPersistence.updateEmail(email, newEmail) + sendValidationEmail(userEntityMapper.toEntity(updated)).awaitSuspending() + userDomain = updated + } + + if (nameUpdated) { + userDomain.name = name + userDomain = userPersistence.updateUser(userDomain) + } + + if (passwordUpdate) { + val encryptedPassword = DigestUtils.sha256Hex(password) + val encryptedNewPassword = DigestUtils.sha256Hex(newPassword) + val emailForPwdUpdate = userDomain.email ?: email + userDomain = userPersistence.changePassword(encryptedPassword, encryptedNewPassword, emailForPwdUpdate) + } + + val response = LoginResponseDTO() + val dto = AuthenticationDTO() + dto.token = generateJWT(userDomain) + dto.user = userEntityMapper.toEntity(userDomain) + response.authentication = dto + response.requires2FA = false + response } /** * Updates 2FA settings for a user. - * Allows the user to configure if 2FA is required for basic login and/or social login. * * @param email The email of the user (from JWT) * @param require2FAForBasicLogin Whether 2FA is required for basic login @@ -758,38 +544,24 @@ class UserController : BasicController() { require2FAForBasicLogin: Boolean, require2FAForSocialLogin: Boolean, jwtEmail: String - ): Uni { - // Validate that JWT email matches the current email + ): Uni = toUni { checkTokenEmail(email, jwtEmail) - - // Find user and update settings - return userRepository.findUserByEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("User not found")) - .onItem().ifNotNull().transformToUni { userEntity -> - // Update 2FA settings - userEntity.require2FAForBasicLogin = require2FAForBasicLogin - userEntity.require2FAForSocialLogin = require2FAForSocialLogin - - // Persist changes - userRepository.updateUser(userEntity) - } + val domain = userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + domain.require2FAForBasicLogin = require2FAForBasicLogin + domain.require2FAForSocialLogin = require2FAForSocialLogin + val updated = userPersistence.updateUser(domain) + userEntityMapper.toEntity(updated) } /** * Extracts the rpId (Relying Party ID) from an origin URL. - * The rpId is the hostname without protocol and port. - * This ensures consistency between registration and authentication. - * - * @param origin The origin URL (e.g., "http://localhost:8080" or "https://example.com") - * @return The rpId (e.g., "localhost" or "example.com") */ private fun extractRpIdFromOrigin(origin: String): String { return try { val uri = java.net.URI(origin) uri.host ?: "localhost" } catch (e: Exception) { - // If parsing fails, try to extract manually origin.replace(Regex("^https?://"), "") .replace(Regex(":\\d+$"), "") .takeIf { it.isNotBlank() } ?: "localhost" @@ -801,8 +573,8 @@ class UserController : BasicController() { * * @return A Uni> containing all users */ - fun listAllUsers(): Uni> { - return userRepository.listAllUsers() + fun listAllUsers(): Uni> = toUni { + userPersistence.listAllUsers().map { userEntityMapper.toEntity(it) } } /** @@ -811,10 +583,9 @@ class UserController : BasicController() { * @param email The email of the user * @return A Uni containing the user if found */ - fun getUserByEmail(email: String): Uni { - return userRepository.findUserByEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("User not found")) + fun getUserByEmail(email: String): Uni = toUni { + val domain = userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + userEntityMapper.toEntity(domain) } - } diff --git a/src/main/kotlin/dev/orion/users/adapters/gateways/entities/RoleEntity.kt b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/RoleEntity.kt index dec8804..9b5511e 100644 --- a/src/main/kotlin/dev/orion/users/adapters/gateways/entities/RoleEntity.kt +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/RoleEntity.kt @@ -20,6 +20,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore import io.quarkus.hibernate.reactive.panache.PanacheEntityBase import jakarta.persistence.Entity import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Table import jakarta.validation.constraints.NotNull @@ -28,12 +29,12 @@ import jakarta.validation.constraints.NotNull * Role Entity. */ @Entity -@Table(name = "Role") +@Table(name = "\"Role\"") open class RoleEntity : PanacheEntityBase() { /** Primary key. */ @Id - @GeneratedValue + @GeneratedValue(strategy = GenerationType.IDENTITY) @JsonIgnore var id: Long? = null diff --git a/src/main/kotlin/dev/orion/users/adapters/gateways/entities/UserEntity.kt b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/UserEntity.kt index 155fa34..2a74e30 100644 --- a/src/main/kotlin/dev/orion/users/adapters/gateways/entities/UserEntity.kt +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/UserEntity.kt @@ -36,7 +36,7 @@ import java.util.UUID * User Entity. */ @Entity -@Table(name = "User") +@Table(name = "\"User\"") class UserEntity : PanacheEntityBase() { /** Default size for column. */ @@ -72,9 +72,9 @@ class UserEntity : PanacheEntityBase() { @JsonIgnore @ManyToMany(fetch = FetchType.EAGER) @JoinTable( - name = "User_Role", - joinColumns = [JoinColumn(name = "User_id")], - inverseJoinColumns = [JoinColumn(name = "roles_id")] + name = "\"User_Role\"", + joinColumns = [JoinColumn(name = "\"User_id\"")], + inverseJoinColumns = [JoinColumn(name = "\"roles_id\"")] ) var roles: MutableList = mutableListOf() diff --git a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepository.kt b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepository.kt deleted file mode 100644 index 9a7edb4..0000000 --- a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepository.kt +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @License - * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. - */ -package dev.orion.users.adapters.gateways.repository - -import dev.orion.users.adapters.gateways.entities.UserEntity -import io.quarkus.hibernate.reactive.panache.PanacheRepository -import io.smallrye.mutiny.Uni -import jakarta.enterprise.context.ApplicationScoped - -/** - * User repository interface. - */ -@ApplicationScoped -interface UserRepository : PanacheRepository { - - /** - * Creates a UserEntity in the service. - * - * @param user : An UserEntity object - * @return A Uni object - */ - fun createUser(user: UserEntity): Uni - - /** - * Returns a user searching for email. - * - * @param email : The user e-mail - * @return A Uni object - */ - fun findUserByEmail(email: String): Uni - - /** - * Returns a user searching for email and password. - * - * @param user : The user object - * @return A Uni object - */ - fun authenticate(user: UserEntity): Uni - - /** - * Updates the e-mail of the user. - * - * @param email : Current user's e-mail - * @param newEmail : New e-mail - * - * @return A Uni object - */ - fun updateEmail(email: String, newEmail: String): Uni - - /** - * Updates the user. - * @param user : The user object - * @return A Uni object - */ - fun updateUser(user: UserEntity): Uni - - /** - * Validates an e-mail of a user. - * - * @param email : The e-mail of a user - * @param code : The validation code - * @return true if the validation code is correct for the respective e-mail - */ - fun validateEmail(email: String, code: String): Uni - - /** - * Changes User password. - * - * @param password : Actual password - * @param newPassword : New Password - * @param email : User's email - * @return A Uni object - */ - fun changePassword(password: String, newPassword: String, email: String): Uni - - /** - * Generates a new password of a user. - * - * @param email : The e-mail of the user - * @return A new password - * @throws IllegalArgumentException if the user informs a wrong e-mail - */ - fun recoverPassword(email: String): Uni - - /** - * Deletes a User from the service. - * - * @param email : User e-mail - * @return Returns a Long 1 if user was deleted - */ - fun deleteUser(email: String): Uni - - /** - * Lists all users in the service. - * - * @return A Uni> containing all users - */ - fun listAllUsers(): Uni> -} - diff --git a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepositoryImpl.kt b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepositoryImpl.kt deleted file mode 100644 index 8cd3ae0..0000000 --- a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepositoryImpl.kt +++ /dev/null @@ -1,357 +0,0 @@ -/** - * @License - * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. - */ -package dev.orion.users.adapters.gateways.repository - -import dev.orion.users.adapters.gateways.entities.RoleEntity -import dev.orion.users.adapters.gateways.entities.UserEntity -import io.quarkus.hibernate.reactive.panache.Panache -import io.quarkus.panache.common.Parameters -import io.smallrye.mutiny.Uni -import jakarta.enterprise.context.ApplicationScoped -import jakarta.inject.Inject -import org.apache.commons.codec.digest.DigestUtils -import org.passay.CharacterData -import org.passay.CharacterRule -import org.passay.EnglishCharacterData -import org.passay.PasswordGenerator -import java.io.IOException - -/** - * Implementation of the UserRepository interface that provides methods for - * creating, authenticating, updating, and deleting user entities in the - * service. - */ -@ApplicationScoped -class UserRepositoryImpl @Inject constructor( - private val roleRepository: RoleRepository -) : UserRepository { - - /** Setting the default role name. */ - private val DEFAULT_ROLE_NAME = "user" - - /** Default password length. */ - private val PASSWORD_LENGTH = 8 - - /** Default user not found message. */ - private val USER_NOT_FOUND_ERROR = "Error: user not found" - - /** E-mail column. */ - private val EMAIL = "email" - - /** Password column. */ - private val PASSWORD = "password" - - /** - * Creates a user in the service. - * - * @param u : A user object - * @return Returns a user asynchronously - */ - override fun createUser(u: UserEntity): Uni { - return checkEmail(u.email ?: "") - .onItem().ifNotNull().transform { user -> user } - .onItem().ifNull().switchTo { - checkName(u.name ?: "") - .onItem().ifNotNull() - .failWith(IllegalArgumentException("The name already existis")) - .onItem().ifNull().switchTo { - checkHash(u.hash) - .onItem().ifNotNull() - .failWith(IllegalArgumentException("The hash already existis")) - .onItem().ifNull().switchTo { - if ((u.password ?: "").isBlank()) { - u.password = generateSecurePassword() - } - persistUser(u) - } - } - } - } - - /** - * Returns a user searching for e-mail and password. - * - * @param user : A user object - * @return Uni object - */ - override fun authenticate(user: UserEntity): Uni { - val params = Parameters.with(EMAIL, user.email) - .and(PASSWORD, user.password).map() - val repo = this as io.quarkus.hibernate.reactive.panache.PanacheRepository - return repo.find("email = :email and password = :password", params) - .firstResult() - .onItem().ifNotNull().transform { loadedUser: UserEntity -> loadedUser } - } - - /** - * Updates the user's e-mail. - * - * @param email : User's email - * @param newEmail : New User's Email - * @return Uni object - */ - override fun updateEmail(email: String, newEmail: String): Uni { - return checkEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) - .onItem().ifNotNull() - .transformToUni { user -> - checkEmail(newEmail) - .onItem().ifNotNull() - .failWith(IllegalArgumentException("Email already in use")) - .onItem().ifNull() - .switchTo { - user.setEmailValidationCode() - user.emailValid = false - user.email = newEmail - Panache.withTransaction { user.persist() } - .onItem().transform { user } - } - } - } - - /** - * Validates the user's e-mail, change the emailValid property to true - * if the code is correct. - * - * @param email : User's email - * @param code : The validation code - * @return Uni object - */ - override fun validateEmail(email: String, code: String): Uni { - val params = Parameters.with(EMAIL, email).and("code", code).map() - val repo = this as io.quarkus.hibernate.reactive.panache.PanacheRepository - return repo.find("email = :email and emailValidationCode = :code", params) - .firstResult() - .onItem().ifNotNull().transformToUni { user: UserEntity -> - user.emailValid = true - Panache.withTransaction { user.persist() } - .onItem().transform { user } - } - .onItem().ifNull() - .failWith(IllegalArgumentException("Invalid e-mail or code")) - } - - /** - * Changes User password. - * - * @param password : Actual password - * @param newPassword : New Password - * @param email : User's email - * @return Uni object - */ - override fun changePassword(password: String, newPassword: String, email: String): Uni { - return checkEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) - .onItem().ifNotNull() - .transformToUni { user -> - if (password == user.password) { - user.password = newPassword - } else { - throw IllegalArgumentException("Passwords doesn't match") - } - Panache.withTransaction { user.persist() } - .onItem().transform { user } - } - } - - /** - * Generates a new password of a user. - * - * @param email : The e-mail of the user - * @return A new password - * @throws IllegalArgumentException if the user informs a wrong e-mail - */ - override fun recoverPassword(email: String): Uni { - val password = generateSecurePassword() - val hashedPassword = DigestUtils.sha256Hex(password) - return checkEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("E-mail not found")) - .onItem().ifNotNull() - .transformToUni { user -> - user.password = hashedPassword - Panache.withTransaction { user.persist() } - .onItem().transform { password } - } - } - - /** - * Deletes a User from the service. - * - * @param email : User email - * @return Return 1 if user was deleted - */ - override fun deleteUser(email: String): Uni { - return checkEmail(email) - .onItem().ifNull().failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) - .onItem().ifNotNull().transformToUni { user -> - Panache.withTransaction { user.delete() } - } - } - - /** - * Verifies if the e-mail already exists in the database. - * - * @param email : An e-mail address - * - * @return Returns true if the e-mail already exists - */ - private fun checkEmail(email: String): Uni { - return (this as io.quarkus.hibernate.reactive.panache.PanacheRepository) - .find(EMAIL, email).firstResult() - } - - /** - * Verifies if the e-mail already exists in the database. - * - * @param email : An e-mail address - * @return Returns true if the e-mail already exists - */ - private fun checkName(email: String): Uni { - return (this as io.quarkus.hibernate.reactive.panache.PanacheRepository) - .find("name", email).firstResult() - } - - /** - * Verifies if the hash already exists in the database. - * - * @param hash : A hash to identify an user - * @return Returns true if the hash already exists - */ - private fun checkHash(hash: String): Uni { - return (this as io.quarkus.hibernate.reactive.panache.PanacheRepository) - .find("hash", hash).firstResult() - } - - /** - * Persists a user in the service with a default role (user). - * - * @param user : The user object - * @return Uni object - */ - private fun persistUser(user: UserEntity): Uni { - return getDefaultRole() - .onItem().ifNull() - .failWith(IOException("Role not found")) - .onItem().ifNotNull() - .transformToUni { role -> - // Allow the ID to be null for auto-generation by the database - user.id = null - user.addRole(role) - Panache.withTransaction { user.persist() } - .onItem().transform { user } - } - } - - /** - * Gets the default role "user" from the database. - * - * @return The Uni object of "user" role. - */ - private fun getDefaultRole(): Uni { - return roleRepository.findByName(DEFAULT_ROLE_NAME) - } - - /** - * Generates a new Secure Password String. - * - * @return A new password - */ - private fun generateSecurePassword(): String { - // Character rule for lower case characters - val lcr = CharacterRule(EnglishCharacterData.LowerCase) - // Set the number of lower case characters (at least 1 required by frontend validation) - lcr.numberOfCharacters = 1 - // Character rule for uppercase characters. - val ucr = CharacterRule(EnglishCharacterData.UpperCase) - // Set the number of upper case characters (at least 1 required by frontend validation) - ucr.numberOfCharacters = 1 - - // Character rule for digit characters - val dr = CharacterRule(EnglishCharacterData.Digit) - // Set the number of digit characters (at least 1 required by frontend validation) - dr.numberOfCharacters = 1 - - // Character rule for special characters - // Using the same special characters accepted by frontend validation: !@#$%^&*()_+\-=\[\]{};':"\\|,.<>/? - // Escaping special characters for Kotlin string literal - val specialChars = "!@#\$%^&*()_+-=\\[\\]{};':\"\\\\|,.<>/?" - val special = defineSpecialChar(specialChars) - val sr = CharacterRule(special) - // Set the number of special characters (at least 1 required by frontend validation) - sr.numberOfCharacters = 1 - - val passGen = PasswordGenerator() - // Generate password with minimum 8 characters (as required by frontend validation) - // The generator will ensure at least the specified number of each character type - return passGen.generatePassword(PASSWORD_LENGTH, sr, lcr, ucr, dr) - } - - /** - * Define the Special Characters of the password. - * - * @param character : Special Characters String - * @return CharacterData class of the Characters - */ - private fun defineSpecialChar(character: String): CharacterData { - return object : CharacterData { - override fun getErrorCode(): String { - return "Error" - } - - override fun getCharacters(): String { - return character - } - } - } - - /** - * Finds a user by their email address. - * - * @param email the email address of the user to find - * @return a Uni that emits the user entity if found, or completes empty if - * not found - */ - override fun findUserByEmail(email: String): Uni { - return (this as io.quarkus.hibernate.reactive.panache.PanacheRepository) - .find(EMAIL, email).firstResult() - } - - /** - * Updates a user entity in the repository. - * - * @param user The user entity to be updated. - * @return A Uni that emits the updated user entity. - */ - override fun updateUser(user: UserEntity): Uni { - return Panache.withTransaction { user.persist() } - .onItem().transform { user } - } - - /** - * Lists all users in the service. - * - * @return A Uni> containing all users - */ - override fun listAllUsers(): Uni> { - return (this as io.quarkus.hibernate.reactive.panache.PanacheRepository) - .listAll() - } -} - diff --git a/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserEntityMapper.kt b/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserEntityMapper.kt new file mode 100644 index 0000000..6b91015 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserEntityMapper.kt @@ -0,0 +1,56 @@ +/** + * @License + * Copyright 2025 Orion Services @ https://orion-services.dev + */ +package dev.orion.users.adapters.out.persistence + +import dev.orion.users.adapters.gateways.entities.UserEntity +import dev.orion.users.domain.model.Role +import dev.orion.users.domain.model.User +import jakarta.enterprise.context.ApplicationScoped + +/** + * Maps between JPA [UserEntity] and domain [User]. + */ +@ApplicationScoped +class UserEntityMapper { + + fun toDomain(entity: UserEntity): User { + val u = User() + u.id = entity.id + u.hash = entity.hash + u.name = entity.name + u.email = entity.email + u.password = entity.password + u.emailValid = entity.emailValid + u.emailValidationCode = entity.emailValidationCode + u.using2FA = entity.isUsing2FA + u.secret2FA = entity.secret2FA + u.require2FAForBasicLogin = entity.require2FAForBasicLogin + u.require2FAForSocialLogin = entity.require2FAForSocialLogin + u.roles.clear() + entity.roles.forEach { re -> u.addRole(Role(name = re.name)) } + return u + } + + /** + * Maps domain user to a new entity for create/update flows. + * Role assignment for new users is handled by persistence (default role). + */ + fun toEntity(domain: User): UserEntity { + val e = UserEntity() + e.id = domain.id + e.hash = domain.hash + e.name = domain.name + e.email = domain.email + e.password = domain.password + e.emailValid = domain.emailValid + e.emailValidationCode = domain.emailValidationCode + e.isUsing2FA = domain.using2FA + e.secret2FA = domain.secret2FA + e.require2FAForBasicLogin = domain.require2FAForBasicLogin + e.require2FAForSocialLogin = domain.require2FAForSocialLogin + e.roles = mutableListOf() + return e + } +} diff --git a/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserPersistenceAdapter.kt b/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserPersistenceAdapter.kt new file mode 100644 index 0000000..47091e6 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserPersistenceAdapter.kt @@ -0,0 +1,283 @@ +/** + * @License + * Copyright 2025 Orion Services @ https://orion-services.dev + */ +package dev.orion.users.adapters.out.persistence + +import dev.orion.users.adapters.gateways.entities.RoleEntity +import dev.orion.users.adapters.gateways.entities.UserEntity +import dev.orion.users.adapters.gateways.repository.RoleRepository +import dev.orion.users.application.port.out.UserPersistencePort +import dev.orion.users.domain.model.User +import io.quarkus.hibernate.reactive.panache.Panache +import io.quarkus.hibernate.reactive.panache.PanacheRepository +import io.smallrye.mutiny.Uni +import io.smallrye.mutiny.coroutines.awaitSuspending +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import org.apache.commons.codec.digest.DigestUtils +import org.passay.CharacterData +import org.passay.CharacterRule +import org.passay.EnglishCharacterData +import org.passay.PasswordGenerator +import java.io.IOException + +/** + * Panache implementation of [UserPersistencePort]. + * Suspend overrides delegate to internal Uni chains via [awaitSuspending]. + */ +@ApplicationScoped +class UserPersistenceAdapter @Inject constructor( + private val roleRepository: RoleRepository, + private val mapper: UserEntityMapper +) : PanacheRepository, UserPersistencePort { + + private val DEFAULT_ROLE_NAME = "user" + private val PASSWORD_LENGTH = 8 + private val USER_NOT_FOUND_ERROR = "Error: user not found" + private val EMAIL = "email" + private val PASSWORD = "password" + + // ---- suspend overrides (port contract) ---- + + override suspend fun createUser(user: User): User { + val entity = mapper.toEntity(user) + entity.id = null + val persisted = createUserEntity(entity).awaitSuspending() + return mapper.toDomain(persisted) + } + + override suspend fun findUserByEmail(email: String): User? { + val entity = findUserEntityByEmail(email).awaitSuspending() + return entity?.let { mapper.toDomain(it) } + } + + override suspend fun authenticate(email: String, passwordHash: String): User? { + val entity = UserEntity() + entity.email = email + entity.password = passwordHash + val result = authenticateEntity(entity).awaitSuspending() + return result?.let { mapper.toDomain(it) } + } + + override suspend fun updateEmail(email: String, newEmail: String): User { + val result = updateEmailEntity(email, newEmail).awaitSuspending() + return mapper.toDomain(result) + } + + override suspend fun validateEmail(email: String, code: String): User { + val result = validateEmailEntity(email, code).awaitSuspending() + return mapper.toDomain(result) + } + + override suspend fun changePassword(password: String, newPassword: String, email: String): User { + val result = changePasswordEntity(password, newPassword, email).awaitSuspending() + return mapper.toDomain(result) + } + + override suspend fun recoverPassword(email: String): String { + return recoverPasswordEntity(email).awaitSuspending() + } + + override suspend fun deleteUser(email: String) { + deleteUserEntity(email).awaitSuspending() + } + + override suspend fun updateUser(user: User): User { + val id = user.id + if (id != null) { + val entity = findById(id) + .onItem().ifNull().failWith(IllegalArgumentException("User not found")) + .awaitSuspending()!! + applyDomainScalarsToEntity(user, entity) + val updated = updateUserEntity(entity).awaitSuspending() + return mapper.toDomain(updated) + } + val entity = mapper.toEntity(user) + val updated = updateUserEntity(entity).awaitSuspending() + return mapper.toDomain(updated) + } + + override suspend fun listAllUsers(): List { + val entities = listAllEntities().awaitSuspending() + return entities.map { mapper.toDomain(it) } + } + + // ---- private helpers (stay as Uni — Panache internals) ---- + + private fun applyDomainScalarsToEntity(domain: User, entity: UserEntity) { + entity.hash = domain.hash + entity.name = domain.name + entity.email = domain.email + entity.password = domain.password + entity.emailValid = domain.emailValid + entity.emailValidationCode = domain.emailValidationCode + entity.isUsing2FA = domain.using2FA + entity.secret2FA = domain.secret2FA + entity.require2FAForBasicLogin = domain.require2FAForBasicLogin + entity.require2FAForSocialLogin = domain.require2FAForSocialLogin + } + + private fun createUserEntity(u: UserEntity): Uni { + return checkEmail(u.email ?: "") + .onItem().ifNotNull().transform { user -> user!! } + .onItem().ifNull().switchTo { + checkName(u.name ?: "") + .onItem().ifNotNull() + .failWith(IllegalArgumentException("The name already existis")) + .onItem().ifNull().switchTo { + checkHash(u.hash) + .onItem().ifNotNull() + .failWith(IllegalArgumentException("The hash already existis")) + .onItem().ifNull().switchTo { + if ((u.password ?: "").isBlank()) { + u.password = generateSecurePassword() + } + persistUser(u) + } + } + } + } + + private fun authenticateEntity(user: UserEntity): Uni { + return find("email = :email and password = :password", mapOf(EMAIL to user.email, PASSWORD to user.password)) + .firstResult() + } + + private fun updateEmailEntity(email: String, newEmail: String): Uni { + return checkEmail(email) + .onItem().ifNull() + .failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) + .onItem().ifNotNull() + .transformToUni { user -> + val u = user!! + checkEmail(newEmail) + .onItem().ifNotNull() + .failWith(IllegalArgumentException("Email already in use")) + .onItem().ifNull() + .switchTo { + u.setEmailValidationCode() + u.emailValid = false + u.email = newEmail + Panache.withTransaction { u.persist() } + .onItem().transform { u } + } + } + } + + private fun validateEmailEntity(email: String, code: String): Uni { + return find("email = :email and emailValidationCode = :code", mapOf(EMAIL to email, "code" to code)) + .firstResult() + .onItem().ifNotNull().transformToUni { user: UserEntity -> + user.emailValid = true + Panache.withTransaction { user.persist() } + .onItem().transform { user } + } + .onItem().ifNull() + .failWith(IllegalArgumentException("Invalid e-mail or code")) + } + + private fun changePasswordEntity(password: String, newPassword: String, email: String): Uni { + return checkEmail(email) + .onItem().ifNull() + .failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) + .onItem().ifNotNull() + .transformToUni { user -> + val u = user!! + if (password == u.password) { + u.password = newPassword + } else { + throw IllegalArgumentException("Passwords doesn't match") + } + Panache.withTransaction { u.persist() } + .onItem().transform { u } + } + } + + private fun recoverPasswordEntity(email: String): Uni { + val password = generateSecurePassword() + val hashedPassword = DigestUtils.sha256Hex(password) + return checkEmail(email) + .onItem().ifNull() + .failWith(IllegalArgumentException("E-mail not found")) + .onItem().ifNotNull() + .transformToUni { user -> + val u = user!! + u.password = hashedPassword + Panache.withTransaction { u.persist() } + .onItem().transform { password } + } + } + + private fun deleteUserEntity(email: String): Uni { + return checkEmail(email) + .onItem().ifNull().failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) + .onItem().ifNotNull().transformToUni { user -> + Panache.withTransaction { user!!.delete() } + } + } + + private fun updateUserEntity(user: UserEntity): Uni { + return Panache.withTransaction { user.persist() } + .onItem().transform { user } + } + + private fun findUserEntityByEmail(email: String): Uni { + return find(EMAIL, email).firstResult() + } + + private fun listAllEntities(): Uni> { + return listAll() + } + + private fun checkEmail(email: String): Uni { + return find(EMAIL, email).firstResult() + } + + private fun checkName(name: String): Uni { + return find("name", name).firstResult() + } + + private fun checkHash(hash: String): Uni { + return find("hash", hash).firstResult() + } + + private fun persistUser(user: UserEntity): Uni { + return getDefaultRole() + .onItem().ifNull() + .failWith(IOException("Role not found")) + .onItem().ifNotNull() + .transformToUni { role -> + user.id = null + user.addRole(role) + Panache.withTransaction { user.persist() } + .onItem().transform { user } + } + } + + private fun getDefaultRole(): Uni { + return roleRepository.findByName(DEFAULT_ROLE_NAME) + } + + private fun generateSecurePassword(): String { + val lcr = CharacterRule(EnglishCharacterData.LowerCase) + lcr.numberOfCharacters = 1 + val ucr = CharacterRule(EnglishCharacterData.UpperCase) + ucr.numberOfCharacters = 1 + val dr = CharacterRule(EnglishCharacterData.Digit) + dr.numberOfCharacters = 1 + val specialChars = "!@#\$%^&*()_+-=\\[\\]{};':\"\\\\|,.<>/?" + val special = defineSpecialChar(specialChars) + val sr = CharacterRule(special) + sr.numberOfCharacters = 1 + val passGen = PasswordGenerator() + return passGen.generatePassword(PASSWORD_LENGTH, sr, lcr, ucr, dr) + } + + private fun defineSpecialChar(character: String): CharacterData { + return object : CharacterData { + override fun getErrorCode(): String = "Error" + override fun getCharacters(): String = character + } + } +} diff --git a/src/main/kotlin/dev/orion/users/application/interfaces/AuthenticateUCI.kt b/src/main/kotlin/dev/orion/users/application/interfaces/AuthenticateUCI.kt deleted file mode 100644 index 928d34a..0000000 --- a/src/main/kotlin/dev/orion/users/application/interfaces/AuthenticateUCI.kt +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @License - * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. - */ -package dev.orion.users.application.interfaces - -import dev.orion.users.enterprise.model.User - -interface AuthenticateUCI { - /** - * Authenticates the user in the service (UC: Authenticate). - * - * @param email : The email of the user - * @param password : The password of the user - * @return An User object - */ - fun authenticate(email: String, password: String): User - - /** - * Validates an e-mail of a user. (UC: Validate e-mail) - * - * @param email : The e-mail of a user - * @param code : The validation code - * @return The User object - */ - fun validateEmail(email: String, code: String): Boolean - - /** - * Generates a new password of a user. - * - * @param email : The e-mail of the user - * @return A new password - * @throws IllegalArgumentException if the user informs a blank e-mail - */ - fun recoverPassword(email: String): String? -} - diff --git a/src/main/kotlin/dev/orion/users/application/interfaces/CreateUserUCI.kt b/src/main/kotlin/dev/orion/users/application/interfaces/CreateUserUCI.kt deleted file mode 100644 index cf9caf3..0000000 --- a/src/main/kotlin/dev/orion/users/application/interfaces/CreateUserUCI.kt +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @License - * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. - */ -package dev.orion.users.application.interfaces - -import dev.orion.users.enterprise.model.User - -interface CreateUserUCI { - /** - * Creates a user in the service (UC: Create). - * - * @param name : The name of the user - * @param email : The e-mail of the user - * @param password : The password of the user - * @return A User object - */ - fun createUser(name: String, email: String, password: String): User - - /** - * Creates a user in the service (UC: Create). - * - * @param name : The name of the user - * @param email : The e-mail of the user - * @param isEmailValid : Confirm if the e-mail is valid or not - * @return A User object - */ - fun createUser(name: String, email: String, isEmailValid: Boolean): User -} - diff --git a/src/main/kotlin/dev/orion/users/application/interfaces/DeleteUser.kt b/src/main/kotlin/dev/orion/users/application/interfaces/DeleteUser.kt deleted file mode 100644 index c163394..0000000 --- a/src/main/kotlin/dev/orion/users/application/interfaces/DeleteUser.kt +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @License - * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. - */ -package dev.orion.users.application.interfaces - -interface DeleteUser { - /** - * Deletes a User from the service. - * - * @param email : User email - * - * @return Return true if user was deleted - */ - fun deleteUser(email: String): Boolean -} - diff --git a/src/main/kotlin/dev/orion/users/application/interfaces/SocialAuthUCI.kt b/src/main/kotlin/dev/orion/users/application/interfaces/SocialAuthUCI.kt deleted file mode 100644 index 3ca50cd..0000000 --- a/src/main/kotlin/dev/orion/users/application/interfaces/SocialAuthUCI.kt +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @License - * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. - */ -package dev.orion.users.application.interfaces - -import dev.orion.users.enterprise.model.User - -interface SocialAuthUCI { - /** - * Validates social authentication data and creates a User object. - * - * @param email The email from the social provider - * @param name The name from the social provider - * @param provider The provider name (e.g., "google", "apple") - * @return A User object with validated data - * @throws IllegalArgumentException if the data is invalid - */ - fun validateSocialAuth(email: String, name: String, provider: String): User -} - diff --git a/src/main/kotlin/dev/orion/users/application/interfaces/TwoFactorAuthUCI.kt b/src/main/kotlin/dev/orion/users/application/interfaces/TwoFactorAuthUCI.kt deleted file mode 100644 index 1871cd8..0000000 --- a/src/main/kotlin/dev/orion/users/application/interfaces/TwoFactorAuthUCI.kt +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @License - * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. - */ -package dev.orion.users.application.interfaces - -import dev.orion.users.enterprise.model.User - -/** - * Interface for Two Factor Authentication use cases. - */ -interface TwoFactorAuthUCI { - /** - * Generates a QR code for 2FA setup. - * - * @param email The email of the user - * @param password The password of the user - * @return A User object with secret2FA set - */ - fun generateQRCode(email: String, password: String): User - - /** - * Validates a TOTP code for 2FA authentication. - * - * @param email The email of the user - * @param code The TOTP code to validate - * @return A User object if validation succeeds - */ - fun validateCode(email: String, code: String): User -} - diff --git a/src/main/kotlin/dev/orion/users/application/interfaces/UpdateUser.kt b/src/main/kotlin/dev/orion/users/application/interfaces/UpdateUser.kt deleted file mode 100644 index d98f613..0000000 --- a/src/main/kotlin/dev/orion/users/application/interfaces/UpdateUser.kt +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @License - * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. - */ -package dev.orion.users.application.interfaces - -import dev.orion.users.enterprise.model.User - -interface UpdateUser { - /** - * Updates user information (name, email and/or password). - * At least one field must be provided for update. - * - * @param email : Current user's email - * @param name : New name (optional) - * @param newEmail : New email (optional) - * @param password : Current password (required if updating password) - * @param newPassword : New password (optional) - * @return An User object with updated fields - * @throws IllegalArgumentException if no fields are provided for update or validation fails - */ - fun updateUser(email: String, name: String?, newEmail: String?, password: String?, newPassword: String?): User -} - diff --git a/src/main/kotlin/dev/orion/users/application/interfaces/WebAuthnUCI.kt b/src/main/kotlin/dev/orion/users/application/interfaces/WebAuthnUCI.kt deleted file mode 100644 index 506e3ab..0000000 --- a/src/main/kotlin/dev/orion/users/application/interfaces/WebAuthnUCI.kt +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @License - * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. - */ -package dev.orion.users.application.interfaces - -/** - * Interface for WebAuthn use cases. - */ -interface WebAuthnUCI { - /** - * Starts the WebAuthn registration process. - * - * @param email The email of the user - * @return A JSON string containing PublicKeyCredentialCreationOptions - */ - fun startRegistration(email: String): String - - /** - * Finishes the WebAuthn registration process. - * - * @param email The email of the user - * @param response The registration response from the client (JSON string) - * @param origin The origin (complete site address) where the device was registered - * @param deviceName Optional name for the device - * @return true if registration was successful - */ - fun finishRegistration(email: String, response: String, origin: String, deviceName: String?): Boolean - - /** - * Starts the WebAuthn authentication process. - * - * @param email The email of the user - * @return A JSON string containing PublicKeyCredentialRequestOptions - */ - fun startAuthentication(email: String): String - - /** - * Finishes the WebAuthn authentication process. - * - * @param email The email of the user - * @param response The authentication response from the client (JSON string) - * @return true if authentication was successful - */ - fun finishAuthentication(email: String, response: String): Boolean -} - diff --git a/src/main/kotlin/dev/orion/users/application/port/in/AuthenticateUCI.kt b/src/main/kotlin/dev/orion/users/application/port/in/AuthenticateUCI.kt new file mode 100644 index 0000000..a26726e --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/port/in/AuthenticateUCI.kt @@ -0,0 +1,20 @@ +/** + * @License + * Copyright 2025 Orion Services @ https://orion-services.dev + */ +package dev.orion.users.application.port.`in` + +import dev.orion.users.domain.model.User + +/** Inbound port: authentication use cases. */ +interface AuthenticateUCI { + fun authenticate(email: String, password: String): User + + /** + * Precondition for email validation: non-blank email and code. + * @throws IllegalArgumentException if invalid + */ + fun requireEmailValidationParams(email: String, code: String) + + fun recoverPassword(email: String): String? +} diff --git a/src/main/kotlin/dev/orion/users/application/port/in/CreateUserUCI.kt b/src/main/kotlin/dev/orion/users/application/port/in/CreateUserUCI.kt new file mode 100644 index 0000000..7740e39 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/port/in/CreateUserUCI.kt @@ -0,0 +1,13 @@ +/** + * @License + * Copyright 2025 Orion Services @ https://orion-services.dev + */ +package dev.orion.users.application.port.`in` + +import dev.orion.users.domain.model.User + +interface CreateUserUCI { + fun createUser(name: String, email: String, password: String): User + + fun createUser(name: String, email: String, isEmailValid: Boolean): User +} diff --git a/src/main/kotlin/dev/orion/users/application/port/in/DeleteUser.kt b/src/main/kotlin/dev/orion/users/application/port/in/DeleteUser.kt new file mode 100644 index 0000000..1141505 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/port/in/DeleteUser.kt @@ -0,0 +1,9 @@ +/** + * @License + * Copyright 2025 Orion Services @ https://orion-services.dev + */ +package dev.orion.users.application.port.`in` + +interface DeleteUser { + fun deleteUser(email: String): Boolean +} diff --git a/src/main/kotlin/dev/orion/users/application/port/in/SocialAuthUCI.kt b/src/main/kotlin/dev/orion/users/application/port/in/SocialAuthUCI.kt new file mode 100644 index 0000000..b99b97c --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/port/in/SocialAuthUCI.kt @@ -0,0 +1,11 @@ +/** + * @License + * Copyright 2025 Orion Services @ https://orion-services.dev + */ +package dev.orion.users.application.port.`in` + +import dev.orion.users.domain.model.User + +interface SocialAuthUCI { + fun validateSocialAuth(email: String, name: String, provider: String): User +} diff --git a/src/main/kotlin/dev/orion/users/application/port/in/TwoFactorAuthUCI.kt b/src/main/kotlin/dev/orion/users/application/port/in/TwoFactorAuthUCI.kt new file mode 100644 index 0000000..f4b6a81 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/port/in/TwoFactorAuthUCI.kt @@ -0,0 +1,13 @@ +/** + * @License + * Copyright 2025 Orion Services @ https://orion-services.dev + */ +package dev.orion.users.application.port.`in` + +import dev.orion.users.domain.model.User + +interface TwoFactorAuthUCI { + fun generateQRCode(email: String, password: String): User + + fun validateCode(email: String, code: String): User +} diff --git a/src/main/kotlin/dev/orion/users/application/port/in/UpdateUser.kt b/src/main/kotlin/dev/orion/users/application/port/in/UpdateUser.kt new file mode 100644 index 0000000..e4ef4f8 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/port/in/UpdateUser.kt @@ -0,0 +1,11 @@ +/** + * @License + * Copyright 2025 Orion Services @ https://orion-services.dev + */ +package dev.orion.users.application.port.`in` + +import dev.orion.users.domain.model.User + +interface UpdateUser { + fun updateUser(email: String, name: String?, newEmail: String?, password: String?, newPassword: String?): User +} diff --git a/src/main/kotlin/dev/orion/users/application/port/in/WebAuthnUCI.kt b/src/main/kotlin/dev/orion/users/application/port/in/WebAuthnUCI.kt new file mode 100644 index 0000000..99453a2 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/port/in/WebAuthnUCI.kt @@ -0,0 +1,15 @@ +/** + * @License + * Copyright 2025 Orion Services @ https://orion-services.dev + */ +package dev.orion.users.application.port.`in` + +interface WebAuthnUCI { + fun startRegistration(email: String): String + + fun finishRegistration(email: String, response: String, origin: String, deviceName: String?): Boolean + + fun startAuthentication(email: String): String + + fun finishAuthentication(email: String, response: String): Boolean +} diff --git a/src/main/kotlin/dev/orion/users/application/port/out/UserPersistencePort.kt b/src/main/kotlin/dev/orion/users/application/port/out/UserPersistencePort.kt new file mode 100644 index 0000000..f745fb9 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/port/out/UserPersistencePort.kt @@ -0,0 +1,34 @@ +/** + * @License + * Copyright 2025 Orion Services @ https://orion-services.dev + */ +package dev.orion.users.application.port.out + +import dev.orion.users.domain.model.User + +/** + * Outbound port: persistence for users. + * Pure Kotlin — no framework types in the contract. + */ +interface UserPersistencePort { + + suspend fun createUser(user: User): User + + suspend fun findUserByEmail(email: String): User? + + suspend fun authenticate(email: String, passwordHash: String): User? + + suspend fun updateEmail(email: String, newEmail: String): User + + suspend fun validateEmail(email: String, code: String): User + + suspend fun changePassword(password: String, newPassword: String, email: String): User + + suspend fun recoverPassword(email: String): String + + suspend fun deleteUser(email: String) + + suspend fun updateUser(user: User): User + + suspend fun listAllUsers(): List +} diff --git a/src/main/kotlin/dev/orion/users/application/usecases/AuthenticateUC.kt b/src/main/kotlin/dev/orion/users/application/usecases/AuthenticateUC.kt index 47bea02..ee11a6a 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/AuthenticateUC.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/AuthenticateUC.kt @@ -1,44 +1,22 @@ /** * @License * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. */ package dev.orion.users.application.usecases +import dev.orion.users.application.port.`in`.AuthenticateUCI +import dev.orion.users.domain.model.User +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject import org.apache.commons.codec.digest.DigestUtils -import dev.orion.users.application.interfaces.AuthenticateUCI -import dev.orion.users.enterprise.model.User +@ApplicationScoped +open class AuthenticateUC @Inject constructor() : AuthenticateUCI { -class AuthenticateUC : AuthenticateUCI { - - /** Default blank arguments message. */ private val BLANK = "Blank arguments" - - /** Default invalid arguments message. */ private val INVALID = "Invalid arguments" - /** - * Authenticates the user in the service (UC: Authenticate). - * - * @param email : The email of the user - * @param password : The password of the user - * @return An User object - */ override fun authenticate(email: String, password: String): User { - // Check if the email and password are not null and bigger than 8 - // characters if (email.isNotEmpty() && password.isNotEmpty() && password.length >= 8) { val user = User() user.email = email @@ -49,34 +27,16 @@ class AuthenticateUC : AuthenticateUCI { } } - /** - * Validates an e-mail of a user. (UC: Validate e-mail) - * - * @param email : The e-mail of a user - * @param code : The validation code - * @return true if the validation code is correct for the respective e-mail - */ - override fun validateEmail(email: String, code: String): Boolean { + override fun requireEmailValidationParams(email: String, code: String) { if (email.isBlank() || code.isBlank()) { throw IllegalArgumentException(BLANK) - } else { - return true } } - /** - * Generates a new password of a user. - * - * @param email : The e-mail of the user - * @return A new password - * @throws IllegalArgumentException if the user informs a blank e-mail - */ override fun recoverPassword(email: String): String? { if (email.isBlank()) { throw IllegalArgumentException(BLANK) - } else { - return null } + return null } } - diff --git a/src/main/kotlin/dev/orion/users/application/usecases/CreateUserUC.kt b/src/main/kotlin/dev/orion/users/application/usecases/CreateUserUC.kt index 6438bd3..286c8cd 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/CreateUserUC.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/CreateUserUC.kt @@ -1,46 +1,25 @@ /** * @License * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. */ package dev.orion.users.application.usecases +import dev.orion.users.application.port.`in`.CreateUserUCI +import dev.orion.users.application.utils.PasswordValidator +import dev.orion.users.domain.model.User +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.validator.routines.EmailValidator -import dev.orion.users.application.interfaces.CreateUserUCI -import dev.orion.users.application.utils.PasswordValidator -import dev.orion.users.enterprise.model.User - -class CreateUserUC : CreateUserUCI { +@ApplicationScoped +open class CreateUserUC @Inject constructor() : CreateUserUCI { - /** - * Creates a user in the service (UC: Create the user). - * - * @param name : The name of the user - * @param email : The e-mail of the user - * @param password : The password of the user - * @return An User object - */ override fun createUser(name: String, email: String, password: String): User { if (name.isEmpty() || !EmailValidator.getInstance().isValid(email) || password.isEmpty()) { throw IllegalArgumentException("Blank arguments or invalid e-mail") } - - // Validate password requirements PasswordValidator.validatePasswordOrThrow(password) - val user = User() user.name = name user.email = email @@ -49,34 +28,16 @@ class CreateUserUC : CreateUserUCI { return user } - /** - * Creates a user in the service (UC: Authenticate With Google). - * - * @param name : The name of the user - * @param email : The e-mail of the user - * @param isEmailValid : Informs if the e-mail is valid - * @return An User object - */ override fun createUser(name: String, email: String, isEmailValid: Boolean): User { if (name.isBlank() || !EmailValidator.getInstance().isValid(email)) { throw IllegalArgumentException("Blank arguments or invalid e-mail") - } else { - val user = User() - user.name = name - user.email = email - user.emailValid = isEmailValid - return user } + val user = User() + user.name = name + user.email = email + user.emailValid = isEmailValid + return user } - /** - * Encrypts the password with SHA-256. - * - * @param password : The password to be encrypted - * @return The encrypted password - */ - private fun encryptPassword(password: String): String { - return DigestUtils.sha256Hex(password) - } + private fun encryptPassword(password: String): String = DigestUtils.sha256Hex(password) } - diff --git a/src/main/kotlin/dev/orion/users/application/usecases/DeleteUserImpl.kt b/src/main/kotlin/dev/orion/users/application/usecases/DeleteUserImpl.kt index 57120ec..62fb72d 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/DeleteUserImpl.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/DeleteUserImpl.kt @@ -1,38 +1,20 @@ /** * @License * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. */ package dev.orion.users.application.usecases -import dev.orion.users.application.interfaces.DeleteUser +import dev.orion.users.application.port.`in`.DeleteUser +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject -class DeleteUserImpl : DeleteUser { +@ApplicationScoped +open class DeleteUserImpl @Inject constructor() : DeleteUser { - /** - * Deletes a User from the service. - * - * @param email : User email - * - * @return Return true if user was deleted - */ override fun deleteUser(email: String): Boolean { if (email.isBlank()) { throw IllegalArgumentException("Email can not be blank") - } else { - return true } + return true } } - diff --git a/src/main/kotlin/dev/orion/users/application/usecases/SocialAuthUC.kt b/src/main/kotlin/dev/orion/users/application/usecases/SocialAuthUC.kt index 079c0ed..afb271b 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/SocialAuthUC.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/SocialAuthUC.kt @@ -1,71 +1,32 @@ /** * @License * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. */ package dev.orion.users.application.usecases -import dev.orion.users.application.interfaces.SocialAuthUCI -import dev.orion.users.enterprise.model.User +import dev.orion.users.application.port.`in`.SocialAuthUCI +import dev.orion.users.domain.model.User +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject import org.apache.commons.validator.routines.EmailValidator -class SocialAuthUC : SocialAuthUCI { +@ApplicationScoped +open class SocialAuthUC @Inject constructor() : SocialAuthUCI { - /** Default blank arguments message. */ - private val BLANK = "Blank arguments" - - /** Default invalid arguments message. */ - private val INVALID = "Invalid arguments" - - /** - * Validates social authentication data and creates a User object. - * - * @param email The email from the social provider - * @param name The name from the social provider - * @param provider The provider name (e.g., "google") - * @return A User object with validated data - * @throws IllegalArgumentException if the data is invalid - */ override fun validateSocialAuth(email: String, name: String, provider: String): User { - // Validate email is not blank - if (email.isBlank()) { - throw IllegalArgumentException("$BLANK: email cannot be blank") + if (email.isBlank() || name.isBlank() || provider.isBlank()) { + throw IllegalArgumentException("Email, name and provider cannot be blank") } - - // Validate email format if (!EmailValidator.getInstance().isValid(email)) { - throw IllegalArgumentException("$INVALID: invalid email format") + throw IllegalArgumentException("Invalid email format") } - - // Validate name is not blank - if (name.isBlank()) { - throw IllegalArgumentException("$BLANK: name cannot be blank") + if (provider.lowercase() != "google") { + throw IllegalArgumentException("Unsupported provider: $provider") } - - // Validate provider - if (provider.isBlank() || provider != "google") { - throw IllegalArgumentException("$INVALID: provider must be 'google'") - } - - // Create user object (password will be null for social auth users) val user = User() user.email = email user.name = name - user.password = null // Social auth users don't have passwords - user.emailValid = true // Social providers already validated the email - + user.emailValid = true return user } } - diff --git a/src/main/kotlin/dev/orion/users/application/usecases/TwoFactorAuthUC.kt b/src/main/kotlin/dev/orion/users/application/usecases/TwoFactorAuthUC.kt index 50a9999..0d73726 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/TwoFactorAuthUC.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/TwoFactorAuthUC.kt @@ -1,48 +1,23 @@ /** * @License * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. */ package dev.orion.users.application.usecases +import dev.orion.users.application.port.`in`.TwoFactorAuthUCI +import dev.orion.users.application.utils.PasswordValidator +import dev.orion.users.domain.model.User +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.validator.routines.EmailValidator -import dev.orion.users.application.interfaces.TwoFactorAuthUCI -import dev.orion.users.application.utils.PasswordValidator -import dev.orion.users.enterprise.model.User - -/** - * Use case implementation for Two Factor Authentication. - */ -class TwoFactorAuthUC : TwoFactorAuthUCI { +@ApplicationScoped +open class TwoFactorAuthUC @Inject constructor() : TwoFactorAuthUCI { - /** Default blank arguments message. */ private val BLANK = "Blank arguments" - - /** Default invalid arguments message. */ private val INVALID = "Invalid arguments" - /** - * Generates a QR code for 2FA setup. - * This method validates the user credentials and prepares the user for 2FA setup. - * - * @param email The email of the user - * @param password The password of the user - * @return A User object with secret2FA set - * @throws IllegalArgumentException if arguments are invalid - */ override fun generateQRCode(email: String, password: String): User { if (email.isBlank() || password.isBlank()) { throw IllegalArgumentException(BLANK) @@ -50,27 +25,13 @@ class TwoFactorAuthUC : TwoFactorAuthUCI { if (!EmailValidator.getInstance().isValid(email)) { throw IllegalArgumentException(INVALID) } - - // Validate password requirements PasswordValidator.validatePasswordOrThrow(password) - val user = User() user.email = email user.password = DigestUtils.sha256Hex(password) - // The secret will be generated in the controller layer return user } - /** - * Validates a TOTP code for 2FA authentication. - * This method validates the format of the code but actual TOTP validation - * happens in the controller layer where we have access to the secret. - * - * @param email The email of the user - * @param code The TOTP code to validate (6 digits) - * @return A User object if validation succeeds - * @throws IllegalArgumentException if arguments are invalid - */ override fun validateCode(email: String, code: String): User { if (email.isBlank() || code.isBlank()) { throw IllegalArgumentException(BLANK) @@ -78,14 +39,11 @@ class TwoFactorAuthUC : TwoFactorAuthUCI { if (!EmailValidator.getInstance().isValid(email)) { throw IllegalArgumentException(INVALID) } - // TOTP codes are 6 digits if (!code.matches(Regex("\\d{6}"))) { throw IllegalArgumentException("Invalid TOTP code format") } - val user = User() user.email = email return user } } - diff --git a/src/main/kotlin/dev/orion/users/application/usecases/UpdateUserImpl.kt b/src/main/kotlin/dev/orion/users/application/usecases/UpdateUserImpl.kt index 62b7c80..d6728e4 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/UpdateUserImpl.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/UpdateUserImpl.kt @@ -1,72 +1,46 @@ /** * @License * Copyright 2025 Orion Services @ https://orion-services.dev - * - * 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 - * - * http://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. */ package dev.orion.users.application.usecases +import dev.orion.users.application.port.`in`.UpdateUser +import dev.orion.users.application.utils.PasswordValidator +import dev.orion.users.domain.model.User +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.validator.routines.EmailValidator -import dev.orion.users.application.interfaces.UpdateUser -import dev.orion.users.application.utils.PasswordValidator -import dev.orion.users.enterprise.model.User - -class UpdateUserImpl : UpdateUser { +@ApplicationScoped +open class UpdateUserImpl @Inject constructor() : UpdateUser { - /** Default blank arguments message. */ private val BLANK = "Blank Arguments" - /** - * Updates user information (name, email and/or password). - * At least one field must be provided for update. - * - * @param email : Current user's email - * @param name : New name (optional) - * @param newEmail : New email (optional) - * @param password : Current password (required if updating password) - * @param newPassword : New password (optional) - * @return An User object with updated fields - * @throws IllegalArgumentException if no fields are provided for update or validation fails - */ - override fun updateUser(email: String, name: String?, newEmail: String?, password: String?, newPassword: String?): User { + override fun updateUser( + email: String, + name: String?, + newEmail: String?, + password: String?, + newPassword: String? + ): User { if (email.isBlank()) { throw IllegalArgumentException(BLANK) } - - // Validate that at least one field is being updated if (name.isNullOrBlank() && newEmail.isNullOrBlank() && newPassword.isNullOrBlank()) { throw IllegalArgumentException("At least one field (name, newEmail or newPassword) must be provided for update") } - - // Validate current email format if (!EmailValidator.getInstance().isValid(email)) { throw IllegalArgumentException("Invalid current email format") } - val user = User() user.email = email - - // Update name if provided if (!name.isNullOrBlank()) { if (name.trim().isEmpty()) { throw IllegalArgumentException("Name cannot be empty") } user.name = name.trim() } - - // Update email if provided if (!newEmail.isNullOrBlank()) { if (!EmailValidator.getInstance().isValid(newEmail)) { throw IllegalArgumentException("Invalid new email format") @@ -74,28 +48,15 @@ class UpdateUserImpl : UpdateUser { user.email = newEmail user.emailValid = false } - - // Update password if provided if (!newPassword.isNullOrBlank()) { if (password.isNullOrBlank()) { throw IllegalArgumentException("Current password is required when updating password") } - // Validate new password requirements PasswordValidator.validatePasswordOrThrow(newPassword) user.password = encryptPassword(newPassword) } - return user } - /** - * Encrypts the password with SHA-256. - * - * @param password : The password to be encrypted - * @return The encrypted password - */ - private fun encryptPassword(password: String): String { - return DigestUtils.sha256Hex(password) - } + private fun encryptPassword(password: String): String = DigestUtils.sha256Hex(password) } - diff --git a/src/main/kotlin/dev/orion/users/application/usecases/WebAuthnUC.kt b/src/main/kotlin/dev/orion/users/application/usecases/WebAuthnUC.kt index e4766a3..be92050 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/WebAuthnUC.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/WebAuthnUC.kt @@ -18,15 +18,15 @@ package dev.orion.users.application.usecases import org.apache.commons.validator.routines.EmailValidator -import dev.orion.users.application.interfaces.WebAuthnUCI +import dev.orion.users.application.port.`in`.WebAuthnUCI +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject /** - * Use case implementation for WebAuthn. - * This is a basic implementation that validates input. - * The actual WebAuthn processing will be done in the controller layer - * where we have access to webauthn4j library. + * Use case implementation for WebAuthn (input validation; protocol details stay in adapters). */ -class WebAuthnUC : WebAuthnUCI { +@ApplicationScoped +open class WebAuthnUC @Inject constructor() : WebAuthnUCI { /** Default blank arguments message. */ private val BLANK = "Blank arguments" diff --git a/src/main/kotlin/dev/orion/users/enterprise/model/Role.kt b/src/main/kotlin/dev/orion/users/domain/model/Role.kt similarity index 86% rename from src/main/kotlin/dev/orion/users/enterprise/model/Role.kt rename to src/main/kotlin/dev/orion/users/domain/model/Role.kt index 3052e22..b394795 100644 --- a/src/main/kotlin/dev/orion/users/enterprise/model/Role.kt +++ b/src/main/kotlin/dev/orion/users/domain/model/Role.kt @@ -14,13 +14,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.orion.users.enterprise.model +package dev.orion.users.domain.model /** - * Represents a role in the system. + * Represents a role in the system (domain — no framework dependencies). */ data class Role( /** The name of the role. */ var name: String? = null ) - diff --git a/src/main/kotlin/dev/orion/users/enterprise/model/User.kt b/src/main/kotlin/dev/orion/users/domain/model/User.kt similarity index 77% rename from src/main/kotlin/dev/orion/users/enterprise/model/User.kt rename to src/main/kotlin/dev/orion/users/domain/model/User.kt index d815e95..8b5a33f 100644 --- a/src/main/kotlin/dev/orion/users/enterprise/model/User.kt +++ b/src/main/kotlin/dev/orion/users/domain/model/User.kt @@ -14,15 +14,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.orion.users.enterprise.model +package dev.orion.users.domain.model -import com.fasterxml.jackson.annotation.JsonIgnore import java.util.UUID /** - * Represents a user in the system. + * Represents a user in the system (domain — no framework dependencies). */ class User { + /** Database id when loaded from persistence. */ + var id: Long? = null + /** The hash used to identify the user. */ var hash: String = UUID.randomUUID().toString() @@ -32,7 +34,7 @@ class User { /** The e-mail of the user. */ var email: String? = null - /** The password of the user. */ + /** The password of the user (hash). */ var password: String? = null /** Role list. */ @@ -56,31 +58,20 @@ class User { /** Controls if 2FA is required for social login (Google OAuth). */ var require2FAForSocialLogin: Boolean = false - /** - * User constructor. Initializes the user with a unique hash, an empty role - * list, and a random email validation code. - */ init { this.hash = UUID.randomUUID().toString() this.roles = mutableListOf() this.emailValidationCode = UUID.randomUUID().toString() } - /** - * Add a role to the user. - * - * @param role The role to be added. - */ + /** Add a role to the user. */ fun addRole(role: Role) { roles.add(role) } /** - * Get the list of roles assigned to the user. - * - * @return A list of roles in String format. + * Role names for JWT / authorization (default "user" when empty). */ - @JsonIgnore fun getRoleList(): List { val strRoles = mutableListOf() if (this.roles.isEmpty()) { @@ -93,18 +84,13 @@ class User { return strRoles } - /** - * Generates a new email validation code for the user. - */ + /** Generates a new email validation code for the user. */ fun setEmailValidationCode() { this.emailValidationCode = UUID.randomUUID().toString() } - /** - * Removes all roles assigned to the user. - */ + /** Removes all roles assigned to the user. */ fun removeRoles() { this.roles.clear() } } - diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/DashboardSpaRootResource.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/DashboardSpaRootResource.kt new file mode 100644 index 0000000..868161b --- /dev/null +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/DashboardSpaRootResource.kt @@ -0,0 +1,27 @@ +/** + * Serve o index.html do admin em GET /dashboard (sem barra final). + * Ficheiros estáticos (/dashboard/assets/...) são servidos pelo Quarkus a partir de META-INF/resources/dashboard/. + */ +package dev.orion.users.frameworks.rest + +import jakarta.enterprise.context.ApplicationScoped +import jakarta.ws.rs.GET +import jakarta.ws.rs.Path +import jakarta.ws.rs.Produces +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response + +@ApplicationScoped +@Path("/dashboard") +class DashboardSpaRootResource { + + @GET + @Produces(MediaType.TEXT_HTML) + fun index(): Response { + val bytes = javaClass.classLoader + .getResourceAsStream("META-INF/resources/dashboard/index.html") + ?.use { it.readAllBytes() } + ?: return Response.status(Response.Status.NOT_FOUND).build() + return Response.ok(bytes, MediaType.TEXT_HTML).build() + } +} diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.kt index 257f94c..dfbaed8 100644 --- a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.kt +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.kt @@ -189,23 +189,16 @@ class AuthenticationWS { @QueryParam("email") @NotEmpty email: String, @QueryParam("code") @NotEmpty code: String ): Uni { - val result = controller.validateEmail(email, code) - return if (result != null) { - result - .onItem().ifNotNull().transform { user -> - Response.ok(true).build() - } - .onItem().ifNull().continueWith { - val message = "Invalid e-mail or code" - throw ServiceException(message, Response.Status.BAD_REQUEST) - } - .onFailure().transform { e -> - val message = e.message ?: "Unknown error" - throw ServiceException(message, Response.Status.BAD_REQUEST) - } - } else { - Uni.createFrom().item(Response.status(Response.Status.BAD_REQUEST).build()) - } + return controller.validateEmail(email, code) + .onItem().ifNotNull().transform { Response.ok(true).build() } + .onItem().ifNull().continueWith { + val message = "Invalid e-mail or code" + throw ServiceException(message, Response.Status.BAD_REQUEST) + } + .onFailure().transform { e -> + val message = e.message ?: "Unknown error" + throw ServiceException(message, Response.Status.BAD_REQUEST) + } } /** diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthWS.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthWS.kt index 52b8356..0de6c5b 100644 --- a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthWS.kt +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthWS.kt @@ -197,9 +197,8 @@ class SocialAuthWS { val email = json.get("email")?.asText() ?: return null // No email in token, might be an access_token - // Extract name (try name, then given_name + family_name, fallback to email) val name = json.get("name")?.asText() - ?: json.get("given_name")?.asText()?.plus(" ").plus(json.get("family_name")?.asText() ?: "") + ?: json.get("given_name")?.asText()?.let { "$it ${json.get("family_name")?.asText() ?: ""}" } ?: email Uni.createFrom().item(Pair(email, name)) @@ -239,7 +238,7 @@ class SocialAuthWS { ?: throw IllegalArgumentException("Email not found in Google API response") val name = json.get("name")?.asText() - ?: json.get("given_name")?.asText()?.plus(" ").plus(json.get("family_name")?.asText() ?: "") + ?: json.get("given_name")?.asText()?.let { "$it ${json.get("family_name")?.asText() ?: ""}" } ?: email Pair(email, name) diff --git a/src/main/resources/META-INF/resources/admin/README.md b/src/main/resources/META-INF/resources/admin/README.md index d2ad03a..8e878ce 100644 --- a/src/main/resources/META-INF/resources/admin/README.md +++ b/src/main/resources/META-INF/resources/admin/README.md @@ -56,7 +56,7 @@ npm install npm run dev ``` -A aplicação estará disponível em `http://localhost:3001/console/` +A aplicação estará disponível em `http://localhost:3001/dashboard/` (base Vite alinhada ao Quarkus) ## Build @@ -64,7 +64,7 @@ A aplicação estará disponível em `http://localhost:3001/console/` npm run build ``` -Os arquivos serão gerados em `target/classes/META-INF/resources/console/` +Os arquivos serão gerados em `target/classes/META-INF/resources/dashboard/` ## Endpoints da API Utilizados @@ -81,7 +81,7 @@ A aplicação requer autenticação com role `admin` no JWT token. O token é ar ## Notas -- A aplicação está configurada para ser servida em `/console/` pelo Quarkus +- A aplicação é servida em `/dashboard` pelo Quarkus - Em desenvolvimento, o Vite roda na porta 3001 com proxy para a API -- O build gera os arquivos diretamente no diretório de recursos do Quarkus (`target/classes/META-INF/resources/console/`) +- O build gera os ficheiros em `target/classes/META-INF/resources/dashboard/` (também via `mvn generate-resources`) diff --git a/src/main/resources/META-INF/resources/admin/index.html b/src/main/resources/META-INF/resources/admin/index.html index 838e934..ab9f538 100644 --- a/src/main/resources/META-INF/resources/admin/index.html +++ b/src/main/resources/META-INF/resources/admin/index.html @@ -1,5 +1,5 @@ - + diff --git a/src/main/resources/META-INF/resources/admin/src/App.vue b/src/main/resources/META-INF/resources/admin/src/App.vue index 5f134d7..f2f6947 100644 --- a/src/main/resources/META-INF/resources/admin/src/App.vue +++ b/src/main/resources/META-INF/resources/admin/src/App.vue @@ -32,13 +32,13 @@ {{ currentUser?.email || 'Admin' }} - Administrador + Administrator mdi-logout - Sair + Sign out @@ -53,12 +53,12 @@ @@ -83,7 +83,7 @@ variant="text" @click="snackbar.show = false" > - Fechar + Close @@ -111,8 +111,7 @@ const snackbar = ref({ const logout = () => { usersStore.logout() - // Força o redirecionamento para /console após logout - window.location.href = '/console' + window.location.href = '/dashboard' } // Listen for error messages from store diff --git a/src/main/resources/META-INF/resources/admin/src/components/DeleteUserDialog.vue b/src/main/resources/META-INF/resources/admin/src/components/DeleteUserDialog.vue index 339ae9d..e6c1318 100644 --- a/src/main/resources/META-INF/resources/admin/src/components/DeleteUserDialog.vue +++ b/src/main/resources/META-INF/resources/admin/src/components/DeleteUserDialog.vue @@ -7,22 +7,22 @@ mdi-alert - Confirmar Exclusão + Confirm deletion

- Tem certeza que deseja deletar o usuário abaixo? + Are you sure you want to delete the user below?

- Nome: {{ user?.name || 'N/A' }} + Name: {{ user?.name || 'N/A' }}
- E-mail: {{ user?.email || 'N/A' }} + Email: {{ user?.email || 'N/A' }}
Hash: {{ user?.hash || 'N/A' }} @@ -30,7 +30,7 @@ - Atenção: Esta ação não pode ser desfeita! + Warning: This action cannot be undone! @@ -42,7 +42,7 @@ variant="text" @click="$emit('update:modelValue', false)" > - Cancelar + Cancel - Deletar + Delete diff --git a/src/main/resources/META-INF/resources/admin/src/router/index.js b/src/main/resources/META-INF/resources/admin/src/router/index.js index 57d5c31..50299ce 100644 --- a/src/main/resources/META-INF/resources/admin/src/router/index.js +++ b/src/main/resources/META-INF/resources/admin/src/router/index.js @@ -2,7 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router' import { useUsersStore } from '../stores/users' const router = createRouter({ - history: createWebHistory('/console/'), + history: createWebHistory('/dashboard'), routes: [ { path: '/', @@ -84,13 +84,13 @@ router.beforeEach((to, from, next) => { if (!groups.includes('admin')) { // User is not admin, redirect to login usersStore.logout() - next({ name: 'Login', query: { error: 'Acesso negado. Apenas administradores podem acessar esta área.' } }) + next({ name: 'Login', query: { error: 'Access denied. Only administrators can access this area.' } }) return } } catch (e) { // Invalid token, logout and redirect to login usersStore.logout() - next({ name: 'Login', query: { error: 'Token inválido.' } }) + next({ name: 'Login', query: { error: 'Invalid token.' } }) return } } else { diff --git a/src/main/resources/META-INF/resources/admin/src/services/api.js b/src/main/resources/META-INF/resources/admin/src/services/api.js index abb2834..0711dde 100644 --- a/src/main/resources/META-INF/resources/admin/src/services/api.js +++ b/src/main/resources/META-INF/resources/admin/src/services/api.js @@ -33,8 +33,8 @@ api.interceptors.response.use( localStorage.removeItem('auth_token') localStorage.removeItem('user') // Redirect to login if not already there - if (window.location.pathname !== '/console/login') { - window.location.href = '/console/login' + if (window.location.pathname !== '/dashboard/login') { + window.location.href = '/dashboard/login' } } return Promise.reject(error) diff --git a/src/main/resources/META-INF/resources/admin/src/stores/users.js b/src/main/resources/META-INF/resources/admin/src/stores/users.js index d99c83c..46ef791 100644 --- a/src/main/resources/META-INF/resources/admin/src/stores/users.js +++ b/src/main/resources/META-INF/resources/admin/src/stores/users.js @@ -65,7 +65,7 @@ export const useUsersStore = defineStore('users', { const response = await userApi.listUsers() this.users = response.data || [] } catch (error) { - this.error = error.response?.data?.message || error.message || 'Erro ao carregar usuários' + this.error = error.response?.data?.message || error.message || 'Failed to load users' throw error } finally { this.loading = false @@ -80,7 +80,7 @@ export const useUsersStore = defineStore('users', { this.currentUser = response.data return response.data } catch (error) { - this.error = error.response?.data?.message || error.message || 'Erro ao carregar usuário' + this.error = error.response?.data?.message || error.message || 'Failed to load user' throw error } finally { this.loading = false @@ -100,7 +100,7 @@ export const useUsersStore = defineStore('users', { await this.fetchUsers() return response.data } catch (error) { - this.error = error.response?.data?.message || error.message || 'Erro ao criar usuário' + this.error = error.response?.data?.message || error.message || 'Failed to create user' throw error } finally { this.loading = false @@ -128,7 +128,7 @@ export const useUsersStore = defineStore('users', { await this.fetchUsers() return response.data } catch (error) { - this.error = error.response?.data?.message || error.message || 'Erro ao atualizar usuário' + this.error = error.response?.data?.message || error.message || 'Failed to update user' throw error } finally { this.loading = false @@ -143,7 +143,7 @@ export const useUsersStore = defineStore('users', { // Refresh users list await this.fetchUsers() } catch (error) { - this.error = error.response?.data?.message || error.message || 'Erro ao deletar usuário' + this.error = error.response?.data?.message || error.message || 'Failed to delete user' throw error } finally { this.loading = false @@ -163,12 +163,12 @@ export const useUsersStore = defineStore('users', { return authData } else if (response.data?.requires2FA) { // Handle 2FA requirement - throw new Error('2FA é necessário para este usuário') + throw new Error('2FA is required for this user') } else { - throw new Error('Resposta de autenticação inválida') + throw new Error('Invalid authentication response') } } catch (error) { - this.error = error.response?.data?.message || error.message || 'Erro ao fazer login' + this.error = error.response?.data?.message || error.message || 'Sign-in failed' throw error } finally { this.loading = false diff --git a/src/main/resources/META-INF/resources/admin/src/views/CreateUserView.vue b/src/main/resources/META-INF/resources/admin/src/views/CreateUserView.vue index 01a3f95..6164def 100644 --- a/src/main/resources/META-INF/resources/admin/src/views/CreateUserView.vue +++ b/src/main/resources/META-INF/resources/admin/src/views/CreateUserView.vue @@ -5,7 +5,7 @@ mdi-account-plus - Criar Novo Usuário + Create new user mdi-arrow-left - Voltar + Back @@ -23,7 +23,7 @@ - Informação: O usuário receberá um e-mail com o código de validação após a criação. + Note: The user will receive an email with the validation code after creation. - Criar Usuário + Create user @@ -97,7 +97,7 @@ variant="text" @click="snackbar.show = false" > - Fechar + Close @@ -130,23 +130,23 @@ const snackbar = ref({ }) const nameRules = [ - v => !!v || 'Nome é obrigatório', - v => (v && v.trim().length > 0) || 'Nome não pode estar vazio' + v => !!v || 'Name is required', + v => (v && v.trim().length > 0) || 'Name cannot be empty' ] const emailRules = [ - v => !!v || 'E-mail é obrigatório', - v => /.+@.+\..+/.test(v) || 'E-mail deve ser válido' + v => !!v || 'Email is required', + v => /.+@.+\..+/.test(v) || 'Email must be valid' ] const passwordRules = [ - v => !!v || 'Senha é obrigatória', - v => (v && v.length >= 8) || 'Senha deve ter pelo menos 8 caracteres' + v => !!v || 'Password is required', + v => (v && v.length >= 8) || 'Password must be at least 8 characters' ] const confirmPasswordRules = [ - v => !!v || 'Confirmação de senha é obrigatória', - v => v === formData.value.password || 'As senhas não coincidem' + v => !!v || 'Password confirmation is required', + v => v === formData.value.password || 'Passwords do not match' ] const handleSubmit = async () => { @@ -161,7 +161,7 @@ const handleSubmit = async () => { password: formData.value.password }) - showMessage('Usuário criado com sucesso!', 'success') + showMessage('User created successfully.', 'success') // Reset form formData.value = { @@ -177,7 +177,7 @@ const handleSubmit = async () => { router.push({ name: 'UsersList' }) }, 1500) } catch (error) { - showMessage(error.message || 'Erro ao criar usuário', 'error') + showMessage(error.message || 'Failed to create user', 'error') } finally { loading.value = false } diff --git a/src/main/resources/META-INF/resources/admin/src/views/EditUserView.vue b/src/main/resources/META-INF/resources/admin/src/views/EditUserView.vue index e8b68dd..d847c68 100644 --- a/src/main/resources/META-INF/resources/admin/src/views/EditUserView.vue +++ b/src/main/resources/META-INF/resources/admin/src/views/EditUserView.vue @@ -5,7 +5,7 @@ mdi-account-edit - Editar Usuário + Edit user mdi-arrow-left - Voltar + Back @@ -30,7 +30,7 @@ - Para alterar a senha, preencha ambos os campos abaixo. Caso contrário, deixe em branco. + To change your password, fill in both fields below. Otherwise leave them blank. - Atualizar Usuário + Update user @@ -129,7 +129,7 @@ variant="text" @click="snackbar.show = false" > - Fechar + Close @@ -166,18 +166,18 @@ const snackbar = ref({ }) const nameRules = [ - v => !!v || 'Nome é obrigatório', - v => (v && v.trim().length > 0) || 'Nome não pode estar vazio' + v => !!v || 'Name is required', + v => (v && v.trim().length > 0) || 'Name cannot be empty' ] const newEmailRules = [ - v => !v || /.+@.+\..+/.test(v) || 'E-mail deve ser válido' + v => !v || /.+@.+\..+/.test(v) || 'Email must be valid' ] const passwordRules = [ v => { if (formData.value.newPassword && !v) { - return 'Senha atual é obrigatória quando alterar senha' + return 'Current password is required when changing password' } return true } @@ -186,7 +186,7 @@ const passwordRules = [ const newPasswordRules = [ v => { if (!v) return true // Optional - if (v.length < 8) return 'Senha deve ter pelo menos 8 caracteres' + if (v.length < 8) return 'Password must be at least 8 characters' return true } ] @@ -194,8 +194,8 @@ const newPasswordRules = [ const confirmPasswordRules = [ v => { if (!formData.value.newPassword) return true - if (!v) return 'Confirmação de senha é obrigatória' - if (v !== formData.value.newPassword) return 'As senhas não coincidem' + if (!v) return 'Password confirmation is required' + if (v !== formData.value.newPassword) return 'Passwords do not match' return true } ] @@ -205,7 +205,7 @@ const handleSubmit = async () => { // Validate that if newPassword is provided, password must also be provided if (formData.value.newPassword && !formData.value.password) { - showMessage('A senha atual é obrigatória quando alterar a senha', 'error') + showMessage('Current password is required when changing password', 'error') return } @@ -220,14 +220,14 @@ const handleSubmit = async () => { newPassword: formData.value.newPassword || null }) - showMessage('Usuário atualizado com sucesso!', 'success') + showMessage('User updated successfully.', 'success') // Redirect to users list after a short delay setTimeout(() => { router.push({ name: 'UsersList' }) }, 1500) } catch (error) { - showMessage(error.message || 'Erro ao atualizar usuário', 'error') + showMessage(error.message || 'Failed to update user', 'error') } finally { loading.value = false } @@ -250,7 +250,7 @@ onMounted(async () => { formData.value.name = user.name || '' formData.value.currentEmail = user.email || email } catch (error) { - showMessage(error.message || 'Erro ao carregar usuário', 'error') + showMessage(error.message || 'Failed to load user', 'error') setTimeout(() => { router.push({ name: 'UsersList' }) }, 2000) diff --git a/src/main/resources/META-INF/resources/admin/src/views/LoginView.vue b/src/main/resources/META-INF/resources/admin/src/views/LoginView.vue index a1f76b0..7e76282 100644 --- a/src/main/resources/META-INF/resources/admin/src/views/LoginView.vue +++ b/src/main/resources/META-INF/resources/admin/src/views/LoginView.vue @@ -4,7 +4,7 @@ mdi-shield-account - Orion Users Console + Orion Users @@ -23,7 +23,7 @@ - Entrar + Sign in - Atenção: Apenas usuários com role "admin" podem acessar esta área. + Notice: Only users with the "admin" role can access this area. @@ -81,13 +81,13 @@ const loading = ref(false) const errorMessage = ref('') const emailRules = [ - v => !!v || 'E-mail é obrigatório', - v => /.+@.+\..+/.test(v) || 'E-mail deve ser válido' + v => !!v || 'Email is required', + v => /.+@.+\..+/.test(v) || 'Email must be valid' ] const passwordRules = [ - v => !!v || 'Senha é obrigatória', - v => (v && v.length >= 8) || 'Senha deve ter pelo menos 8 caracteres' + v => !!v || 'Password is required', + v => (v && v.length >= 8) || 'Password must be at least 8 characters' ] const handleLogin = async () => { @@ -102,7 +102,7 @@ const handleLogin = async () => { // Verifica se recebemos o token com sucesso if (!authData || !authData.token) { - errorMessage.value = 'Erro ao fazer login. Token não recebido.' + errorMessage.value = 'Sign-in failed. No token received.' loading.value = false return } @@ -115,32 +115,28 @@ const handleLogin = async () => { if (!groups.includes('admin')) { // Usuário não é admin, faz logout e mostra erro usersStore.logout() - errorMessage.value = 'Acesso negado. Apenas administradores podem acessar esta área.' + errorMessage.value = 'Access denied. Only administrators can access this area.' loading.value = false return } - // Login bem-sucedido e usuário é admin - redireciona para /console - // Força o redirecionamento usando window.location para garantir que funcione - // mesmo se o router guard estiver bloqueando const redirectPath = route.query.redirect ? decodeURIComponent(route.query.redirect) - : '/console' + : '/dashboard' - // Usa window.location para garantir o redirecionamento - window.location.href = redirectPath.startsWith('/console') + window.location.href = redirectPath.startsWith('/dashboard') ? redirectPath - : `/console${redirectPath}` + : `/dashboard${redirectPath}` } catch (e) { // Erro ao decodificar token - console.error('Erro ao decodificar token:', e) + console.error('Failed to decode token:', e) usersStore.logout() - errorMessage.value = 'Token inválido. Tente fazer login novamente.' + errorMessage.value = 'Invalid token. Please sign in again.' loading.value = false } } catch (error) { // Erro no login (credenciais inválidas, etc) - errorMessage.value = error.response?.data?.message || error.message || 'Erro ao fazer login. Verifique suas credenciais.' + errorMessage.value = error.response?.data?.message || error.message || 'Sign-in failed. Check your credentials.' loading.value = false } } diff --git a/src/main/resources/META-INF/resources/admin/src/views/UserDetailView.vue b/src/main/resources/META-INF/resources/admin/src/views/UserDetailView.vue index 2b3ff49..768af20 100644 --- a/src/main/resources/META-INF/resources/admin/src/views/UserDetailView.vue +++ b/src/main/resources/META-INF/resources/admin/src/views/UserDetailView.vue @@ -5,7 +5,7 @@ mdi-account-details - Detalhes do Usuário + User details mdi-arrow-left - Voltar + Back @@ -32,18 +32,18 @@ mdi-information - Informações Básicas + Basic information
- Nome: {{ user.name || 'N/A' }} + Name: {{ user.name || 'N/A' }}
- E-mail: {{ user.email || 'N/A' }} + Email: {{ user.email || 'N/A' }}
@@ -54,13 +54,13 @@
- E-mail Validado: + Email validated: - {{ user.emailValid ? 'Sim' : 'Não' }} + {{ user.emailValid ? 'Yes' : 'No' }}
@@ -72,43 +72,43 @@ mdi-shield-lock - Autenticação de Dois Fatores (2FA) + Two-factor authentication (2FA)
- 2FA Ativado: + 2FA enabled: - {{ user.using2FA ? 'Sim' : 'Não' }} + {{ user.using2FA ? 'Yes' : 'No' }}
- Requer 2FA para Login Básico: + Require 2FA for basic login: - {{ user.require2FAForBasicLogin ? 'Sim' : 'Não' }} + {{ user.require2FAForBasicLogin ? 'Yes' : 'No' }}
- Requer 2FA para Login Social: + Require 2FA for social login: - {{ user.require2FAForSocialLogin ? 'Sim' : 'Não' }} + {{ user.require2FAForSocialLogin ? 'Yes' : 'No' }}
@@ -140,7 +140,7 @@ mdi-cog - Ações + Actions - Editar Usuário + Edit user - Deletar Usuário + Delete user
- Usuário não encontrado + User not found
@@ -191,7 +191,7 @@ variant="text" @click="snackbar.show = false" > - Fechar + Close @@ -234,12 +234,12 @@ const confirmDelete = () => { const handleDelete = async (email) => { try { await usersStore.deleteUser(email) - showMessage('Usuário deletado com sucesso!', 'success') + showMessage('User deleted successfully.', 'success') setTimeout(() => { router.push({ name: 'UsersList' }) }, 1500) } catch (error) { - showMessage(error.message || 'Erro ao deletar usuário', 'error') + showMessage(error.message || 'Failed to delete user', 'error') } finally { deleteDialog.value.show = false } @@ -260,7 +260,7 @@ onMounted(async () => { try { user.value = await usersStore.fetchUserByEmail(email) } catch (error) { - showMessage(error.message || 'Erro ao carregar usuário', 'error') + showMessage(error.message || 'Failed to load user', 'error') setTimeout(() => { router.push({ name: 'UsersList' }) }, 2000) diff --git a/src/main/resources/META-INF/resources/admin/src/views/UsersListView.vue b/src/main/resources/META-INF/resources/admin/src/views/UsersListView.vue index 7e67c36..f6f862b 100644 --- a/src/main/resources/META-INF/resources/admin/src/views/UsersListView.vue +++ b/src/main/resources/META-INF/resources/admin/src/views/UsersListView.vue @@ -5,14 +5,14 @@ mdi-account-group - Gerenciamento de Usuários + User management - Criar Usuário + Create user @@ -24,7 +24,7 @@ - {{ item.emailValid ? 'Sim' : 'Não' }} + {{ item.emailValid ? 'Yes' : 'No' }} @@ -78,7 +78,7 @@ :color="item.using2FA ? 'success' : 'default'" size="small" > - {{ item.using2FA ? 'Sim' : 'Não' }} + {{ item.using2FA ? 'Yes' : 'No' }} @@ -90,7 +90,7 @@ @click="viewUser(item.email)" > mdi-eye - Visualizar + View mdi-pencil - Editar + Edit mdi-delete - Deletar + Delete @@ -145,7 +145,7 @@ variant="text" @click="snackbar.show = false" > - Fechar + Close @@ -177,22 +177,22 @@ const snackbar = ref({ }) const headers = [ - { title: 'Nome', key: 'name', sortable: true }, - { title: 'E-mail', key: 'email', sortable: true }, - { title: 'E-mail Validado', key: 'emailValid', sortable: true }, - { title: '2FA Ativado', key: 'using2FA', sortable: true }, + { title: 'Name', key: 'name', sortable: true }, + { title: 'Email', key: 'email', sortable: true }, + { title: 'Email validated', key: 'emailValid', sortable: true }, + { title: '2FA enabled', key: 'using2FA', sortable: true }, { title: 'Hash', key: 'hash', sortable: false }, - { title: 'Ações', key: 'actions', sortable: false, align: 'end' } + { title: 'Actions', key: 'actions', sortable: false, align: 'end' } ] const emailValidOptions = [ - { title: 'Validado', value: true }, - { title: 'Não Validado', value: false } + { title: 'Validated', value: true }, + { title: 'Not validated', value: false } ] const twoFAOptions = [ - { title: 'Ativado', value: true }, - { title: 'Desativado', value: false } + { title: 'Enabled', value: true }, + { title: 'Disabled', value: false } ] const filteredUsers = computed(() => usersStore.filteredUsers) @@ -213,9 +213,9 @@ const confirmDelete = (user) => { const handleDelete = async (email) => { try { await usersStore.deleteUser(email) - showMessage('Usuário deletado com sucesso!', 'success') + showMessage('User deleted successfully.', 'success') } catch (error) { - showMessage(error.message || 'Erro ao deletar usuário', 'error') + showMessage(error.message || 'Failed to delete user', 'error') } finally { deleteDialog.value.show = false deleteDialog.value.user = null @@ -234,7 +234,7 @@ onMounted(async () => { try { await usersStore.fetchUsers() } catch (error) { - showMessage(error.message || 'Erro ao carregar usuários', 'error') + showMessage(error.message || 'Failed to load users', 'error') } }) diff --git a/src/main/resources/META-INF/resources/admin/vite.config.js b/src/main/resources/META-INF/resources/admin/vite.config.js index e3dd67c..0401731 100644 --- a/src/main/resources/META-INF/resources/admin/vite.config.js +++ b/src/main/resources/META-INF/resources/admin/vite.config.js @@ -4,8 +4,8 @@ import vuetify from 'vite-plugin-vuetify' import { fileURLToPath, URL } from 'node:url' export default defineConfig({ - // Configurar base para servir em /console/ tanto em desenvolvimento quanto em produção - base: '/console/', + // Base sem barra final na URL: http://host/dashboard + base: '/dashboard', plugins: [ vue(), vuetify({ autoImport: true }) @@ -16,9 +16,8 @@ export default defineConfig({ } }, build: { - // Gerar arquivos diretamente em META-INF/resources/console/ - // para serem servidos pelo Quarkus em http://localhost:8080/console/ - outDir: '../console', + // Saída em META-INF/resources/dashboard/ — URL: http://localhost:8080/dashboard + outDir: '../dashboard', emptyOutDir: false, // Não esvaziar o diretório pois está fora do projeto root rollupOptions: { output: { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 72085c4..f8fa9c0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,19 +1,19 @@ -#MySQL -quarkus.datasource.db-kind=mysql -quarkus.datasource.devservices.port=3306 -%test.quarkus.datasource.devservices.port=3307 -quarkus.hibernate-orm.database.generation=drop-and-create +# PostgreSQL +quarkus.datasource.db-kind=postgresql +quarkus.datasource.devservices.port=5432 +%test.quarkus.datasource.devservices.port=5433 +quarkus.hibernate-orm.schema-management.strategy=drop-and-create quarkus.datasource.username=orion quarkus.datasource.password=orion #Edit the datasource for native compilation -%prod.quarkus.datasource.reactive.url=mysql://address:port/database +%prod.quarkus.datasource.reactive.url=postgresql://address:port/database quarkus.hibernate-orm.sql-load-script=import.sql #JWT Build users.issuer = orion-users -smallrye.jwt.expiration.grace = 604800 +mp.jwt.verify.clock.skew = 604800 # Configuration to sign the token smallrye.jwt.sign.key.location=privateKey.pem # smallrye.jwt.encrypt.key.location=publicKey.pem @@ -47,29 +47,28 @@ quarkus.mailer.auth-methods=DIGEST-MD5 CRAM-SHA256 CRAM-SHA1 CRAM-MD5 PLAIN LOGI quarkus.mailer.from=devoriontest@gmail.com quarkus.mailer.host=smtp.gmail.com quarkus.mailer.port=465 -quarkus.mailer.ssl=true +quarkus.mailer.tls=true #Edit the email and api password to work quarkus.mailer.username=devoriontest@gmail.com -quarkus.mailer.password=pcznyscuuqtzmogn +quarkus.mailer.password=${QUARKUS_MAILER_PASSWORD:} %dev.quarkus.mailer.mock=false %test.quarkus.mailer.mock=true # Email validation users.email.validation.url=http://localhost:8080/users/validateEmail -# Google Openid Provider +# Google Openid Provider (definir QUARKUS_OIDC_* / SOCIAL_AUTH_GOOGLE_CLIENT_ID no ambiente) quarkus.oidc.enabled=false quarkus.oidc.provider=GOOGLE -quarkus.oidc.client-id=[Google Client ID] -quarkus.oidc.credentials.secret=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 +quarkus.oidc.client-id=${QUARKUS_OIDC_CLIENT_ID:} +quarkus.oidc.credentials.secret=${QUARKUS_OIDC_CREDENTIALS_SECRET:} quarkus.oidc.token.allow-opaque-token-introspection=true # Social Auth Configuration # Google OAuth2 Client ID (for token validation) -social.auth.google.client-id=[Google Client ID] +social.auth.google.client-id=${SOCIAL_AUTH_GOOGLE_CLIENT_ID:} quarkus.log.level=INFO - #Swagger %dev.quarkus.swagger-ui.always-include=true diff --git a/src/main/resources/import.sql b/src/main/resources/import.sql index 6ea847f..efa5668 100644 --- a/src/main/resources/import.sql +++ b/src/main/resources/import.sql @@ -1,16 +1,15 @@ -INSERT INTO Role (id, name) VALUES (1, 'admin'); -INSERT INTO Role (id, name) VALUES (2, 'user'); +INSERT INTO "Role" (id, name) VALUES (1, 'admin'); +INSERT INTO "Role" (id, name) VALUES (2, 'user'); -- Admin user: admin@orion.dev / orionadmin -- Password hash (SHA256): 24febcc27e4a5762911a4481a941a3563cc4bf5e5f61f0ea3799333871d2a89b -INSERT INTO User (id, hash, name, email, password, emailValid, emailValidationCode, isUsing2FA, secret2FA, require2FAForBasicLogin, require2FAForSocialLogin) +INSERT INTO "User" (id, hash, name, email, password, emailvalid, emailvalidationcode, isusing2fa, secret2fa, require2faforbasiclogin, require2faforsociallogin) VALUES (1, '00000000-0000-0000-0000-000000000001', 'Administrator', 'admin@orion.dev', '24febcc27e4a5762911a4481a941a3563cc4bf5e5f61f0ea3799333871d2a89b', true, '00000000-0000-0000-0000-000000000001', false, NULL, false, false); -- Associate roles to admin user (admin and user) --- The junction table is explicitly defined in the UserEntity as "User_Role" -INSERT INTO User_Role (User_id, roles_id) VALUES (1, 1); -- admin -INSERT INTO User_Role (User_id, roles_id) VALUES (1, 2); -- user +INSERT INTO "User_Role" ("User_id", "roles_id") VALUES (1, 1); +INSERT INTO "User_Role" ("User_id", "roles_id") VALUES (1, 2); --- Update the auto-increment of the User table to avoid primary key conflict --- This ensures that the next automatically generated ID will be 2 -ALTER TABLE User AUTO_INCREMENT = 2; +-- Align identity sequences so the next generated keys do not collide with seeded rows +SELECT setval(pg_get_serial_sequence('public."User"', 'id'), (SELECT COALESCE(MAX(id), 1) FROM "User")); +SELECT setval(pg_get_serial_sequence('public."Role"', 'id'), (SELECT COALESCE(MAX(id), 1) FROM "Role")); diff --git a/src/test/kotlin/dev/orion/users/usecases/AuthenticateUCTest.kt b/src/test/kotlin/dev/orion/users/usecases/AuthenticateUCTest.kt index 7250873..83075c4 100644 --- a/src/test/kotlin/dev/orion/users/usecases/AuthenticateUCTest.kt +++ b/src/test/kotlin/dev/orion/users/usecases/AuthenticateUCTest.kt @@ -21,9 +21,9 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Order import org.junit.jupiter.api.Test -import dev.orion.users.application.interfaces.AuthenticateUCI +import dev.orion.users.application.port.`in`.AuthenticateUCI import dev.orion.users.application.usecases.AuthenticateUC -import dev.orion.users.enterprise.model.User +import dev.orion.users.domain.model.User import io.smallrye.common.constraint.Assert /** diff --git a/src/test/kotlin/dev/orion/users/usecases/CreateUserUCTest.kt b/src/test/kotlin/dev/orion/users/usecases/CreateUserUCTest.kt index c767c75..5213d90 100644 --- a/src/test/kotlin/dev/orion/users/usecases/CreateUserUCTest.kt +++ b/src/test/kotlin/dev/orion/users/usecases/CreateUserUCTest.kt @@ -21,9 +21,9 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Order import org.junit.jupiter.api.Test -import dev.orion.users.application.interfaces.CreateUserUCI +import dev.orion.users.application.port.`in`.CreateUserUCI import dev.orion.users.application.usecases.CreateUserUC -import dev.orion.users.enterprise.model.User +import dev.orion.users.domain.model.User import io.smallrye.common.constraint.Assert /** From 6ed9cd16441012a81b14cacd9e3280485501bcbf Mon Sep 17 00:00:00 2001 From: Rodrigo Prestes Machado Date: Sun, 5 Apr 2026 12:44:43 -0300 Subject: [PATCH 3/3] refactor: apply consistent code formatting and style improvements across application and web layers --- .editorconfig | 5 + pom.xml | 22 + .../adapters/controllers/BasicController.kt | 77 +- .../adapters/controllers/UserController.kt | 681 ++++++++++-------- .../adapters/gateways/entities/RoleEntity.kt | 1 - .../adapters/gateways/entities/UserEntity.kt | 4 +- .../entities/WebAuthnCredentialEntity.kt | 2 - .../gateways/repository/RoleRepository.kt | 1 - .../gateways/repository/RoleRepositoryImpl.kt | 5 +- .../WebAuthnCredentialRepository.kt | 2 - .../WebAuthnCredentialRepositoryImpl.kt | 32 +- .../out/persistence/UserEntityMapper.kt | 1 - .../out/persistence/UserPersistenceAdapter.kt | 491 +++++++------ .../adapters/presenters/AuthenticationDTO.kt | 3 +- .../adapters/presenters/LoginResponseDTO.kt | 3 +- .../application/port/in/AuthenticateUCI.kt | 10 +- .../application/port/in/CreateUserUCI.kt | 12 +- .../application/port/in/SocialAuthUCI.kt | 6 +- .../application/port/in/TwoFactorAuthUCI.kt | 10 +- .../users/application/port/in/UpdateUser.kt | 8 +- .../users/application/port/in/WebAuthnUCI.kt | 12 +- .../port/out/UserPersistencePort.kt | 28 +- .../application/usecases/AuthenticateUC.kt | 51 +- .../application/usecases/CreateUserUC.kt | 57 +- .../application/usecases/DeleteUserImpl.kt | 15 +- .../application/usecases/SocialAuthUC.kt | 39 +- .../application/usecases/TwoFactorAuthUC.kt | 65 +- .../application/usecases/UpdateUserImpl.kt | 83 +-- .../users/application/usecases/WebAuthnUC.kt | 163 +++-- .../application/utils/PasswordValidator.kt | 4 +- .../dev/orion/users/domain/model/Role.kt | 2 +- .../rest/DashboardSpaRootResource.kt | 10 +- .../users/frameworks/rest/ServiceException.kt | 12 +- .../rest/authentication/AuthenticationWS.kt | 111 +-- .../rest/authentication/SocialAuthWS.kt | 142 ++-- .../authentication/SocialAuthenticationWS.kt | 1 - .../rest/authentication/TwoFactorAuth.kt | 38 +- .../rest/authentication/WebAuthnWS.kt | 65 +- .../users/frameworks/rest/users/UserWS.kt | 182 +++-- src/main/resources/application.properties | 24 +- .../kotlin/dev/orion/users/rest/UsersIT.kt | 140 ++-- .../users/usecases/AuthenticateUCTest.kt | 11 +- .../orion/users/usecases/CreateUserUCTest.kt | 11 +- 43 files changed, 1467 insertions(+), 1175 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7578c37 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*.{kt,kts}] +ktlint_standard_package-name = disabled +ktlint_package_name = disabled diff --git a/pom.xml b/pom.xml index 2319921..683a79a 100755 --- a/pom.xml +++ b/pom.xml @@ -419,6 +419,28 @@
+ + com.github.gantsign.maven + ktlint-maven-plugin + 3.5.0 + + + ${project.basedir}/src/main/kotlin + + + ${project.basedir}/src/test/kotlin + + + + + check + verify + + check + + + +
diff --git a/src/main/kotlin/dev/orion/users/adapters/controllers/BasicController.kt b/src/main/kotlin/dev/orion/users/adapters/controllers/BasicController.kt index 797e613..720e9bf 100644 --- a/src/main/kotlin/dev/orion/users/adapters/controllers/BasicController.kt +++ b/src/main/kotlin/dev/orion/users/adapters/controllers/BasicController.kt @@ -16,30 +16,12 @@ */ package dev.orion.users.adapters.controllers -import java.awt.image.BufferedImage -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.io.UnsupportedEncodingException -import java.net.URLEncoder -import java.security.SecureRandom - -import javax.imageio.ImageIO - -import org.apache.commons.codec.binary.Base32 -import org.apache.commons.codec.binary.Hex -import org.eclipse.microprofile.config.inject.ConfigProperty -import org.eclipse.microprofile.jwt.Claims -import org.modelmapper.ModelMapper - import com.google.zxing.BarcodeFormat import com.google.zxing.MultiFormatWriter import com.google.zxing.WriterException import com.google.zxing.client.j2se.MatrixToImageWriter -import com.google.zxing.common.BitMatrix - import de.taimos.totp.TOTP import dev.orion.users.adapters.gateways.entities.UserEntity -import dev.orion.users.domain.model.User as DomainUser import dev.orion.users.frameworks.mail.MailTemplate import dev.orion.users.frameworks.rest.ServiceException import io.smallrye.jwt.build.Jwt @@ -48,12 +30,24 @@ import jakarta.ws.rs.core.Response import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import org.apache.commons.codec.binary.Base32 +import org.apache.commons.codec.binary.Hex +import org.eclipse.microprofile.config.inject.ConfigProperty +import org.eclipse.microprofile.jwt.Claims +import org.modelmapper.ModelMapper +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.UnsupportedEncodingException +import java.net.URLEncoder +import java.security.SecureRandom +import javax.imageio.ImageIO +import dev.orion.users.domain.model.User as DomainUser /** * The controller class. */ open class BasicController { - /** * Bridges a suspend block into a [Uni], keeping the Vert.x event-loop * context via [Dispatchers.Unconfined]. @@ -82,7 +76,7 @@ open class BasicController { } /** The encoding used in the QR code. */ - private val UTF_8 = "UTF-8" + private val utf8 = "UTF-8" /** Configure the issuer for JWT generation. */ @ConfigProperty(name = "users.issuer", defaultValue = "orion-users") @@ -101,24 +95,26 @@ open class BasicController { * @param user : The user object * @return Returns the JWT */ - fun generateJWT(user: UserEntity): String { - return Jwt.issuer(issuer) + fun generateJWT(user: UserEntity): String = + Jwt + .issuer(issuer) .upn(user.email) .groups(user.getRoleList().toSet()) .claim(Claims.c_hash, user.hash) + .claim(Claims.c_name, user.name) .claim(Claims.email, user.email) .sign() - } /** JWT from domain user (same claims as entity). */ - fun generateJWT(user: DomainUser): String { - return Jwt.issuer(issuer) + fun generateJWT(user: DomainUser): String = + Jwt + .issuer(issuer) .upn(user.email) .groups(user.getRoleList().toSet()) .claim(Claims.c_hash, user.hash) + .claim(Claims.c_name, user.name) .claim(Claims.email, user.email) .sign() - } /** * Verifies if the e-mail from the jwt is the same from request. @@ -130,7 +126,10 @@ open class BasicController { * different, indicating that possibly the JWT is * outdated. */ - fun checkTokenEmail(email: String, jwtEmail: String): Boolean { + fun checkTokenEmail( + email: String, + jwtEmail: String, + ): Boolean { if (email != jwtEmail) { throw ServiceException("JWT outdated", Response.Status.BAD_REQUEST) } @@ -149,11 +148,13 @@ open class BasicController { url.append("?code=" + user.emailValidationCode) url.append("&email=" + user.email) - return MailTemplate.validateEmail(url.toString()) + return MailTemplate + .validateEmail(url.toString()) .to(user.email ?: "") .subject("E-mail confirmation") .send() - .onItem().ifNotNull() + .onItem() + .ifNotNull() .transform { user } } @@ -184,14 +185,23 @@ open class BasicController { * @return The Google Bar Code in String format * @throws IllegalArgumentException */ - fun getAuthenticatorBarCode(secretKey: String, account: String, issuer: String): String { + fun getAuthenticatorBarCode( + secretKey: String, + account: String, + issuer: String, + ): String { try { return "otpauth://totp/" + - URLEncoder.encode("$issuer:$account", UTF_8) + URLEncoder + .encode("$issuer:$account", UTF_8) .replace("+", "%20") + - "?secret=" + URLEncoder.encode(secretKey, UTF_8) + "?secret=" + + URLEncoder + .encode(secretKey, UTF_8) .replace("+", "%20") + - "&issuer=" + URLEncoder.encode(issuer, UTF_8) + "&issuer=" + + URLEncoder + .encode(issuer, UTF_8) .replace("+", "%20") } catch (e: UnsupportedEncodingException) { throw IllegalStateException(e) @@ -237,4 +247,3 @@ open class BasicController { return base32.encodeToString(bytes) } } - diff --git a/src/main/kotlin/dev/orion/users/adapters/controllers/UserController.kt b/src/main/kotlin/dev/orion/users/adapters/controllers/UserController.kt index f3ffdaa..6f89426 100644 --- a/src/main/kotlin/dev/orion/users/adapters/controllers/UserController.kt +++ b/src/main/kotlin/dev/orion/users/adapters/controllers/UserController.kt @@ -16,6 +16,7 @@ */ package dev.orion.users.adapters.controllers +import com.fasterxml.jackson.databind.ObjectMapper import dev.orion.users.adapters.gateways.entities.UserEntity import dev.orion.users.adapters.gateways.entities.WebAuthnCredentialEntity import dev.orion.users.adapters.gateways.repository.WebAuthnCredentialRepository @@ -31,16 +32,15 @@ import dev.orion.users.application.port.`in`.WebAuthnUCI import dev.orion.users.application.port.out.UserPersistencePort import dev.orion.users.domain.model.User import dev.orion.users.frameworks.mail.MailTemplate -import com.fasterxml.jackson.databind.ObjectMapper -import java.security.SecureRandom -import java.util.* -import java.util.Base64 import io.quarkus.hibernate.reactive.panache.common.WithSession import io.smallrye.mutiny.Uni import io.smallrye.mutiny.coroutines.awaitSuspending import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import org.apache.commons.codec.digest.DigestUtils +import java.security.SecureRandom +import java.util.Base64 +import java.util.UUID /** * The controller class. @@ -48,7 +48,6 @@ import org.apache.commons.codec.digest.DigestUtils @WithSession @ApplicationScoped class UserController : BasicController() { - @Inject lateinit var createUC: CreateUserUCI @@ -87,13 +86,18 @@ class UserController : BasicController() { * @param password : The user password * @return : Returns a Uni object */ - fun createUser(name: String, email: String, password: String): Uni = toUni { - val user: User = createUC.createUser(name, email, password) - val created = userPersistence.createUser(user) - val entity = userEntityMapper.toEntity(created) - sendValidationEmail(entity).awaitSuspending() - entity - } + fun createUser( + name: String, + email: String, + password: String, + ): Uni = + toUni { + val user: User = createUC.createUser(name, email, password) + val created = userPersistence.createUser(user) + val entity = userEntityMapper.toEntity(created) + sendValidationEmail(entity).awaitSuspending() + entity + } /** * Validates the e-mail of a user. @@ -102,11 +106,15 @@ class UserController : BasicController() { * @param code : The validation code * @return : Returns a Uni object */ - fun validateEmail(email: String, code: String): Uni = toUni { - authenticationUC.requireEmailValidationParams(email, code) - val validated = userPersistence.validateEmail(email, code) - userEntityMapper.toEntity(validated) - } + fun validateEmail( + email: String, + code: String, + ): Uni = + toUni { + authenticationUC.requireEmailValidationParams(email, code) + val validated = userPersistence.validateEmail(email, code) + userEntityMapper.toEntity(validated) + } /** * Authenticates the user in the service. @@ -115,12 +123,17 @@ class UserController : BasicController() { * @param password : The user password * @return : Returns a JSON Web Token (JWT) */ - fun authenticate(email: String, password: String): Uni = toUni { - val auth = authenticationUC.authenticate(email, password) - val domain = userPersistence.authenticate(auth.email!!, auth.password!!) - ?: throw IllegalArgumentException("Invalid credentials") - generateJWT(domain) - } + fun authenticate( + email: String, + password: String, + ): Uni = + toUni { + val auth = authenticationUC.authenticate(email, password) + val domain = + userPersistence.authenticate(auth.email!!, auth.password!!) + ?: throw IllegalArgumentException("Invalid credentials") + generateJWT(domain) + } /** * Authenticates a user with the provided email and password. @@ -130,24 +143,29 @@ class UserController : BasicController() { * @param password the password of the user * @return a Uni object that emits a LoginResponseDTO */ - fun login(email: String, password: String): Uni = toUni { - val auth = authenticationUC.authenticate(email, password) - val domain = userPersistence.authenticate(auth.email!!, auth.password!!) - ?: throw IllegalArgumentException("Invalid credentials") - - val response = LoginResponseDTO() - if (domain.using2FA && domain.require2FAForBasicLogin) { - response.requires2FA = true - response.message = "2FA code required" - } else { - val dto = AuthenticationDTO() - dto.token = generateJWT(domain) - dto.user = userEntityMapper.toEntity(domain) - response.authentication = dto - response.requires2FA = false + fun login( + email: String, + password: String, + ): Uni = + toUni { + val auth = authenticationUC.authenticate(email, password) + val domain = + userPersistence.authenticate(auth.email!!, auth.password!!) + ?: throw IllegalArgumentException("Invalid credentials") + + val response = LoginResponseDTO() + if (domain.using2FA && domain.require2FAForBasicLogin) { + response.requires2FA = true + response.message = "2FA code required" + } else { + val dto = AuthenticationDTO() + dto.token = generateJWT(domain) + dto.user = userEntityMapper.toEntity(domain) + response.authentication = dto + response.requires2FA = false + } + response } - response - } /** * Creates a user, generates a Json Web Token and returns a @@ -158,18 +176,23 @@ class UserController : BasicController() { * @param password : The user password * @return A Uni object */ - fun createAuthenticate(name: String, email: String, password: String): Uni = toUni { - val entity = this@UserController.createUser(name, email, password).awaitSuspending() + fun createAuthenticate( + name: String, + email: String, + password: String, + ): Uni = + toUni { + val entity = this@UserController.createUser(name, email, password).awaitSuspending() - val authDto = AuthenticationDTO() - authDto.token = generateJWT(entity) - authDto.user = entity + val authDto = AuthenticationDTO() + authDto.token = generateJWT(entity) + authDto.user = entity - val response = LoginResponseDTO() - response.authentication = authDto - response.requires2FA = false - response - } + val response = LoginResponseDTO() + response.authentication = authDto + response.requires2FA = false + response + } /** * Authenticates a user with a social provider (Google). @@ -182,40 +205,45 @@ class UserController : BasicController() { * @param provider The provider name ("google") * @return A Uni object (may contain JWT or indicate 2FA is required) */ - fun loginWithSocialProvider(email: String, name: String, provider: String): Uni = toUni { - val socialUser: User = socialAuthUC.validateSocialAuth(email, name, provider) - val existingDomain = userPersistence.findUserByEmail(email) - - if (existingDomain != null) { - val response = LoginResponseDTO() - if (existingDomain.using2FA && existingDomain.require2FAForSocialLogin) { - response.requires2FA = true - response.message = "2FA code required" + fun loginWithSocialProvider( + email: String, + name: String, + provider: String, + ): Uni = + toUni { + val socialUser: User = socialAuthUC.validateSocialAuth(email, name, provider) + val existingDomain = userPersistence.findUserByEmail(email) + + if (existingDomain != null) { + val response = LoginResponseDTO() + if (existingDomain.using2FA && existingDomain.require2FAForSocialLogin) { + response.requires2FA = true + response.message = "2FA code required" + } else { + val dto = AuthenticationDTO() + dto.token = generateJWT(existingDomain) + dto.user = userEntityMapper.toEntity(existingDomain) + response.authentication = dto + response.requires2FA = false + } + response } else { + val newUser = User() + newUser.name = socialUser.name + newUser.email = socialUser.email + newUser.emailValid = socialUser.emailValid + newUser.password = DigestUtils.sha256Hex(UUID.randomUUID().toString()) + val newDomain = userPersistence.createUser(newUser) + + val response = LoginResponseDTO() val dto = AuthenticationDTO() - dto.token = generateJWT(existingDomain) - dto.user = userEntityMapper.toEntity(existingDomain) + dto.token = generateJWT(newDomain) + dto.user = userEntityMapper.toEntity(newDomain) response.authentication = dto response.requires2FA = false + response } - response - } else { - val newUser = User() - newUser.name = socialUser.name - newUser.email = socialUser.email - newUser.emailValid = socialUser.emailValid - newUser.password = DigestUtils.sha256Hex(UUID.randomUUID().toString()) - val newDomain = userPersistence.createUser(newUser) - - val response = LoginResponseDTO() - val dto = AuthenticationDTO() - dto.token = generateJWT(newDomain) - dto.user = userEntityMapper.toEntity(newDomain) - response.authentication = dto - response.requires2FA = false - response } - } /** * Delete a user from the service. @@ -223,9 +251,10 @@ class UserController : BasicController() { * @param email The user's e-mail * @return A Uni object */ - fun deleteUser(email: String): Uni = toUniVoid { - userPersistence.deleteUser(email) - } + fun deleteUser(email: String): Uni = + toUniVoid { + userPersistence.deleteUser(email) + } /** * Generates a QR code for 2FA setup. @@ -236,19 +265,24 @@ class UserController : BasicController() { * @param password The password of the user * @return A Uni that emits a ByteArray containing the QR code image */ - fun generate2FAQRCode(email: String, password: String): Uni = toUni { - val user: User = twoFactorAuthUC.generateQRCode(email, password) - val authenticatedDomain = userPersistence.authenticate(user.email!!, user.password!!) - ?: throw IllegalArgumentException("Invalid credentials") - - val secretKey = generateSecretKey() - authenticatedDomain.using2FA = true - authenticatedDomain.secret2FA = secretKey - - val updatedDomain = userPersistence.updateUser(authenticatedDomain) - val barCodeData = getAuthenticatorBarCode(secretKey, updatedDomain.email ?: email, issuer) - createQrCode(barCodeData) - } + fun generate2FAQRCode( + email: String, + password: String, + ): Uni = + toUni { + val user: User = twoFactorAuthUC.generateQRCode(email, password) + val authenticatedDomain = + userPersistence.authenticate(user.email!!, user.password!!) + ?: throw IllegalArgumentException("Invalid credentials") + + val secretKey = generateSecretKey() + authenticatedDomain.using2FA = true + authenticatedDomain.secret2FA = secretKey + + val updatedDomain = userPersistence.updateUser(authenticatedDomain) + val barCodeData = getAuthenticatorBarCode(secretKey, updatedDomain.email ?: email, issuer) + createQrCode(barCodeData) + } /** * Validates a TOTP code for 2FA authentication after social login. @@ -257,24 +291,29 @@ class UserController : BasicController() { * @param code The TOTP code to validate * @return A Uni that emits a LoginResponseDTO with JWT if validation succeeds */ - fun validateSocialLogin2FA(email: String, code: String): Uni = toUni { - twoFactorAuthUC.validateCode(email, code) - - val d = userPersistence.findUserByEmail(email) - ?: throw IllegalArgumentException("User not found") - if (!d.using2FA) throw IllegalArgumentException("2FA is not enabled for this user") - if (!d.require2FAForSocialLogin) throw IllegalArgumentException("2FA is not required for social login for this user") - val secret = d.secret2FA ?: throw IllegalArgumentException("2FA secret not found") - if (code != getTOTPCode(secret)) throw IllegalArgumentException("Invalid TOTP code") - - val authDto = AuthenticationDTO() - authDto.token = generateJWT(d) - authDto.user = userEntityMapper.toEntity(d) - val response = LoginResponseDTO() - response.authentication = authDto - response.requires2FA = false - response - } + fun validateSocialLogin2FA( + email: String, + code: String, + ): Uni = + toUni { + twoFactorAuthUC.validateCode(email, code) + + val d = + userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + if (!d.using2FA) throw IllegalArgumentException("2FA is not enabled for this user") + if (!d.require2FAForSocialLogin) throw IllegalArgumentException("2FA is not required for social login for this user") + val secret = d.secret2FA ?: throw IllegalArgumentException("2FA secret not found") + if (code != getTOTPCode(secret)) throw IllegalArgumentException("Invalid TOTP code") + + val authDto = AuthenticationDTO() + authDto.token = generateJWT(d) + authDto.user = userEntityMapper.toEntity(d) + val response = LoginResponseDTO() + response.authentication = authDto + response.requires2FA = false + response + } /** * Validates a TOTP code for 2FA authentication. @@ -283,23 +322,28 @@ class UserController : BasicController() { * @param code The TOTP code to validate * @return A Uni that emits a LoginResponseDTO with JWT if validation succeeds */ - fun validate2FACode(email: String, code: String): Uni = toUni { - twoFactorAuthUC.validateCode(email, code) - - val d = userPersistence.findUserByEmail(email) - ?: throw IllegalArgumentException("User not found") - if (!d.using2FA) throw IllegalArgumentException("2FA is not enabled for this user") - val secret = d.secret2FA ?: throw IllegalArgumentException("2FA secret not found") - if (code != getTOTPCode(secret)) throw IllegalArgumentException("Invalid TOTP code") - - val authDto = AuthenticationDTO() - authDto.token = generateJWT(d) - authDto.user = userEntityMapper.toEntity(d) - val response = LoginResponseDTO() - response.authentication = authDto - response.requires2FA = false - response - } + fun validate2FACode( + email: String, + code: String, + ): Uni = + toUni { + twoFactorAuthUC.validateCode(email, code) + + val d = + userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + if (!d.using2FA) throw IllegalArgumentException("2FA is not enabled for this user") + val secret = d.secret2FA ?: throw IllegalArgumentException("2FA secret not found") + if (code != getTOTPCode(secret)) throw IllegalArgumentException("Invalid TOTP code") + + val authDto = AuthenticationDTO() + authDto.token = generateJWT(d) + authDto.user = userEntityMapper.toEntity(d) + val response = LoginResponseDTO() + response.authentication = authDto + response.requires2FA = false + response + } /** * Starts WebAuthn registration process. @@ -308,41 +352,52 @@ class UserController : BasicController() { * @param origin Optional origin URL to extract rpId from * @return A JSON string containing PublicKeyCredentialCreationOptions */ - fun startWebAuthnRegistration(email: String, origin: String? = null): Uni = toUni { - webAuthnUC.startRegistration(email) - - val user = userPersistence.findUserByEmail(email) - ?: throw IllegalArgumentException("User not found") - - val challengeBytes = ByteArray(32) - SecureRandom().nextBytes(challengeBytes) - val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeBytes) - val userId = Base64.getUrlEncoder().withoutPadding() - .encodeToString((user.email ?: email).toByteArray()) - - val rpName = issuer - val rpId = origin?.let { extractRpIdFromOrigin(it) } ?: "localhost" - val userName = user.email ?: email - val userDisplayName = user.name ?: user.email ?: email - - val options = mapOf( - "rp" to mapOf("name" to rpName, "id" to rpId), - "user" to mapOf("id" to userId, "name" to userName, "displayName" to userDisplayName), - "challenge" to challenge, - "pubKeyCredParams" to listOf( - mapOf("type" to "public-key", "alg" to -7), - mapOf("type" to "public-key", "alg" to -257) - ), - "authenticatorSelection" to mapOf( - "authenticatorAttachment" to "platform", - "userVerification" to "preferred" - ), - "timeout" to 60000L, - "attestation" to "none" - ) - - objectMapper.writeValueAsString(mapOf("options" to options, "challenge" to challenge)) - } + fun startWebAuthnRegistration( + email: String, + origin: String? = null, + ): Uni = + toUni { + webAuthnUC.startRegistration(email) + + val user = + userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + + val challengeBytes = ByteArray(32) + SecureRandom().nextBytes(challengeBytes) + val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeBytes) + val userId = + Base64 + .getUrlEncoder() + .withoutPadding() + .encodeToString((user.email ?: email).toByteArray()) + + val rpName = issuer + val rpId = origin?.let { extractRpIdFromOrigin(it) } ?: "localhost" + val userName = user.email ?: email + val userDisplayName = user.name ?: user.email ?: email + + val options = + mapOf( + "rp" to mapOf("name" to rpName, "id" to rpId), + "user" to mapOf("id" to userId, "name" to userName, "displayName" to userDisplayName), + "challenge" to challenge, + "pubKeyCredParams" to + listOf( + mapOf("type" to "public-key", "alg" to -7), + mapOf("type" to "public-key", "alg" to -257), + ), + "authenticatorSelection" to + mapOf( + "authenticatorAttachment" to "platform", + "userVerification" to "preferred", + ), + "timeout" to 60000L, + "attestation" to "none", + ) + + objectMapper.writeValueAsString(mapOf("options" to options, "challenge" to challenge)) + } /** * Finishes WebAuthn registration process. @@ -354,24 +409,28 @@ class UserController : BasicController() { * @return true if registration was successful */ fun finishWebAuthnRegistration( - email: String, response: String, origin: String, deviceName: String? - ): Uni = toUni { - webAuthnUC.finishRegistration(email, response, origin, deviceName) - userPersistence.findUserByEmail(email) - ?: throw IllegalArgumentException("User not found") - - val credentialEntity = WebAuthnCredentialEntity() - credentialEntity.userEmail = email - credentialEntity.credentialId = UUID.randomUUID().toString() - credentialEntity.publicKey = response - credentialEntity.counter = 0 - credentialEntity.origin = origin - credentialEntity.notes = deviceName ?: "Unknown Device" - credentialEntity.deviceName = deviceName ?: "Unknown Device" - - webAuthnCredentialRepository.saveCredential(credentialEntity).awaitSuspending() - true - } + email: String, + response: String, + origin: String, + deviceName: String?, + ): Uni = + toUni { + webAuthnUC.finishRegistration(email, response, origin, deviceName) + userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + + val credentialEntity = WebAuthnCredentialEntity() + credentialEntity.userEmail = email + credentialEntity.credentialId = UUID.randomUUID().toString() + credentialEntity.publicKey = response + credentialEntity.counter = 0 + credentialEntity.origin = origin + credentialEntity.notes = deviceName ?: "Unknown Device" + credentialEntity.deviceName = deviceName ?: "Unknown Device" + + webAuthnCredentialRepository.saveCredential(credentialEntity).awaitSuspending() + true + } /** * Starts WebAuthn authentication process. @@ -379,35 +438,38 @@ class UserController : BasicController() { * @param email The email of the user * @return A JSON string containing PublicKeyCredentialRequestOptions */ - fun startWebAuthnAuthentication(email: String): Uni = toUni { - webAuthnUC.startAuthentication(email) - userPersistence.findUserByEmail(email) - ?: throw IllegalArgumentException("User not found") - - val credentials = webAuthnCredentialRepository.findByUserEmail(email).awaitSuspending() - if (credentials.isNullOrEmpty()) { - throw IllegalArgumentException("No WebAuthn credentials found for user") - } - - val challengeBytes = ByteArray(32) - SecureRandom().nextBytes(challengeBytes) - val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeBytes) + fun startWebAuthnAuthentication(email: String): Uni = + toUni { + webAuthnUC.startAuthentication(email) + userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + + val credentials = webAuthnCredentialRepository.findByUserEmail(email).awaitSuspending() + if (credentials.isNullOrEmpty()) { + throw IllegalArgumentException("No WebAuthn credentials found for user") + } - val allowCredentials = credentials.mapNotNull { cred -> - cred.credentialId?.let { id -> mapOf("type" to "public-key", "id" to id) } + val challengeBytes = ByteArray(32) + SecureRandom().nextBytes(challengeBytes) + val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(challengeBytes) + + val allowCredentials = + credentials.mapNotNull { cred -> + cred.credentialId?.let { id -> mapOf("type" to "public-key", "id" to id) } + } + val rpId = credentials.firstOrNull()?.origin?.let { extractRpIdFromOrigin(it) } ?: "localhost" + + val options = + mapOf( + "challenge" to challenge, + "rpId" to rpId, + "allowCredentials" to allowCredentials, + "userVerification" to "preferred", + "timeout" to 60000L, + ) + + objectMapper.writeValueAsString(mapOf("options" to options, "challenge" to challenge)) } - val rpId = credentials.firstOrNull()?.origin?.let { extractRpIdFromOrigin(it) } ?: "localhost" - - val options = mapOf( - "challenge" to challenge, - "rpId" to rpId, - "allowCredentials" to allowCredentials, - "userVerification" to "preferred", - "timeout" to 60000L - ) - - objectMapper.writeValueAsString(mapOf("options" to options, "challenge" to challenge)) - } /** * Finishes WebAuthn authentication process. @@ -416,30 +478,35 @@ class UserController : BasicController() { * @param response The authentication response from the client (JSON string) * @return A LoginResponseDTO with JWT if authentication succeeds */ - fun finishWebAuthnAuthentication(email: String, response: String): Uni = toUni { - webAuthnUC.finishAuthentication(email, response) - - val user = userPersistence.findUserByEmail(email) - ?: throw IllegalArgumentException("User not found") - - val credentials = webAuthnCredentialRepository.findByUserEmail(email).awaitSuspending() - if (credentials.isNullOrEmpty()) { - throw IllegalArgumentException("No WebAuthn credentials found") - } + fun finishWebAuthnAuthentication( + email: String, + response: String, + ): Uni = + toUni { + webAuthnUC.finishAuthentication(email, response) + + val user = + userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + + val credentials = webAuthnCredentialRepository.findByUserEmail(email).awaitSuspending() + if (credentials.isNullOrEmpty()) { + throw IllegalArgumentException("No WebAuthn credentials found") + } - val credential = credentials.first() - credential.counter++ - webAuthnCredentialRepository.saveCredential(credential).awaitSuspending() + val credential = credentials.first() + credential.counter++ + webAuthnCredentialRepository.saveCredential(credential).awaitSuspending() - val authDto = AuthenticationDTO() - authDto.token = generateJWT(user) - authDto.user = userEntityMapper.toEntity(user) + val authDto = AuthenticationDTO() + authDto.token = generateJWT(user) + authDto.user = userEntityMapper.toEntity(user) - val loginResponse = LoginResponseDTO() - loginResponse.authentication = authDto - loginResponse.requires2FA = false - loginResponse - } + val loginResponse = LoginResponseDTO() + loginResponse.authentication = authDto + loginResponse.requires2FA = false + loginResponse + } /** * Recovers the password of a user. Generates a new password, updates it in the database, @@ -448,22 +515,27 @@ class UserController : BasicController() { * @param email : The e-mail of the user * @return A Uni that completes when the password is recovered and email is sent */ - fun recoverPassword(email: String): Uni = toUniVoid { - authenticationUC.recoverPassword(email) - val newPassword = userPersistence.recoverPassword(email) - sendRecoveryEmail(email, newPassword).awaitSuspending() - } + fun recoverPassword(email: String): Uni = + toUniVoid { + authenticationUC.recoverPassword(email) + val newPassword = userPersistence.recoverPassword(email) + sendRecoveryEmail(email, newPassword).awaitSuspending() + } /** * Sends a recovery password email to the user. */ - private fun sendRecoveryEmail(email: String, password: String): Uni { - return MailTemplate.recoverPwd(password) + private fun sendRecoveryEmail( + email: String, + password: String, + ): Uni = + MailTemplate + .recoverPwd(password) .to(email) .subject("Recuperação de senha") .send() - .onItem().transform { null } - } + .onItem() + .transform { null } /** * Updates user information (name, email and/or password). Validates the token, @@ -484,51 +556,53 @@ class UserController : BasicController() { password: String?, newPassword: String?, jwtEmail: String, - isAdmin: Boolean = false - ): Uni = toUni { - updateUserUC.updateUser(email, name, newEmail, password, newPassword) - if (!isAdmin) checkTokenEmail(email, jwtEmail) - - val nameUpdated = !name.isNullOrBlank() - val emailUpdated = !newEmail.isNullOrBlank() - val passwordUpdate = !newPassword.isNullOrBlank() && !password.isNullOrBlank() - - var userDomain = userPersistence.findUserByEmail(email) - ?: throw IllegalArgumentException("User not found") - - if (passwordUpdate) { - val encryptedPassword = DigestUtils.sha256Hex(password) - if (encryptedPassword != userDomain.password) { - throw IllegalArgumentException("Current password is incorrect") + isAdmin: Boolean = false, + ): Uni = + toUni { + updateUserUC.updateUser(email, name, newEmail, password, newPassword) + if (!isAdmin) checkTokenEmail(email, jwtEmail) + + val nameUpdated = !name.isNullOrBlank() + val emailUpdated = !newEmail.isNullOrBlank() + val passwordUpdate = !newPassword.isNullOrBlank() && !password.isNullOrBlank() + + var userDomain = + userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + + if (passwordUpdate) { + val encryptedPassword = DigestUtils.sha256Hex(password) + if (encryptedPassword != userDomain.password) { + throw IllegalArgumentException("Current password is incorrect") + } } - } - if (emailUpdated) { - val updated = userPersistence.updateEmail(email, newEmail) - sendValidationEmail(userEntityMapper.toEntity(updated)).awaitSuspending() - userDomain = updated - } + if (emailUpdated) { + val updated = userPersistence.updateEmail(email, newEmail) + sendValidationEmail(userEntityMapper.toEntity(updated)).awaitSuspending() + userDomain = updated + } - if (nameUpdated) { - userDomain.name = name - userDomain = userPersistence.updateUser(userDomain) - } + if (nameUpdated) { + userDomain.name = name + userDomain = userPersistence.updateUser(userDomain) + } - if (passwordUpdate) { - val encryptedPassword = DigestUtils.sha256Hex(password) - val encryptedNewPassword = DigestUtils.sha256Hex(newPassword) - val emailForPwdUpdate = userDomain.email ?: email - userDomain = userPersistence.changePassword(encryptedPassword, encryptedNewPassword, emailForPwdUpdate) - } + if (passwordUpdate) { + val encryptedPassword = DigestUtils.sha256Hex(password) + val encryptedNewPassword = DigestUtils.sha256Hex(newPassword) + val emailForPwdUpdate = userDomain.email ?: email + userDomain = userPersistence.changePassword(encryptedPassword, encryptedNewPassword, emailForPwdUpdate) + } - val response = LoginResponseDTO() - val dto = AuthenticationDTO() - dto.token = generateJWT(userDomain) - dto.user = userEntityMapper.toEntity(userDomain) - response.authentication = dto - response.requires2FA = false - response - } + val response = LoginResponseDTO() + val dto = AuthenticationDTO() + dto.token = generateJWT(userDomain) + dto.user = userEntityMapper.toEntity(userDomain) + response.authentication = dto + response.requires2FA = false + response + } /** * Updates 2FA settings for a user. @@ -543,39 +617,42 @@ class UserController : BasicController() { email: String, require2FAForBasicLogin: Boolean, require2FAForSocialLogin: Boolean, - jwtEmail: String - ): Uni = toUni { - checkTokenEmail(email, jwtEmail) - val domain = userPersistence.findUserByEmail(email) - ?: throw IllegalArgumentException("User not found") - domain.require2FAForBasicLogin = require2FAForBasicLogin - domain.require2FAForSocialLogin = require2FAForSocialLogin - val updated = userPersistence.updateUser(domain) - userEntityMapper.toEntity(updated) - } + jwtEmail: String, + ): Uni = + toUni { + checkTokenEmail(email, jwtEmail) + val domain = + userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + domain.require2FAForBasicLogin = require2FAForBasicLogin + domain.require2FAForSocialLogin = require2FAForSocialLogin + val updated = userPersistence.updateUser(domain) + userEntityMapper.toEntity(updated) + } /** * Extracts the rpId (Relying Party ID) from an origin URL. */ - private fun extractRpIdFromOrigin(origin: String): String { - return try { + private fun extractRpIdFromOrigin(origin: String): String = + try { val uri = java.net.URI(origin) uri.host ?: "localhost" } catch (e: Exception) { - origin.replace(Regex("^https?://"), "") + origin + .replace(Regex("^https?://"), "") .replace(Regex(":\\d+$"), "") .takeIf { it.isNotBlank() } ?: "localhost" } - } /** * Lists all users in the service. * * @return A Uni> containing all users */ - fun listAllUsers(): Uni> = toUni { - userPersistence.listAllUsers().map { userEntityMapper.toEntity(it) } - } + fun listAllUsers(): Uni> = + toUni { + userPersistence.listAllUsers().map { userEntityMapper.toEntity(it) } + } /** * Gets a user by email. @@ -583,9 +660,11 @@ class UserController : BasicController() { * @param email The email of the user * @return A Uni containing the user if found */ - fun getUserByEmail(email: String): Uni = toUni { - val domain = userPersistence.findUserByEmail(email) - ?: throw IllegalArgumentException("User not found") - userEntityMapper.toEntity(domain) - } + fun getUserByEmail(email: String): Uni = + toUni { + val domain = + userPersistence.findUserByEmail(email) + ?: throw IllegalArgumentException("User not found") + userEntityMapper.toEntity(domain) + } } diff --git a/src/main/kotlin/dev/orion/users/adapters/gateways/entities/RoleEntity.kt b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/RoleEntity.kt index 9b5511e..22272e7 100644 --- a/src/main/kotlin/dev/orion/users/adapters/gateways/entities/RoleEntity.kt +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/RoleEntity.kt @@ -31,7 +31,6 @@ import jakarta.validation.constraints.NotNull @Entity @Table(name = "\"Role\"") open class RoleEntity : PanacheEntityBase() { - /** Primary key. */ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/kotlin/dev/orion/users/adapters/gateways/entities/UserEntity.kt b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/UserEntity.kt index 2a74e30..d54f8e5 100644 --- a/src/main/kotlin/dev/orion/users/adapters/gateways/entities/UserEntity.kt +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/UserEntity.kt @@ -38,7 +38,6 @@ import java.util.UUID @Entity @Table(name = "\"User\"") class UserEntity : PanacheEntityBase() { - /** Default size for column. */ companion object { private const val COLUMN_LENGTH = 256 @@ -74,7 +73,7 @@ class UserEntity : PanacheEntityBase() { @JoinTable( name = "\"User_Role\"", joinColumns = [JoinColumn(name = "\"User_id\"")], - inverseJoinColumns = [JoinColumn(name = "\"roles_id\"")] + inverseJoinColumns = [JoinColumn(name = "\"roles_id\"")], ) var roles: MutableList = mutableListOf() @@ -148,4 +147,3 @@ class UserEntity : PanacheEntityBase() { this.roles.clear() } } - diff --git a/src/main/kotlin/dev/orion/users/adapters/gateways/entities/WebAuthnCredentialEntity.kt b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/WebAuthnCredentialEntity.kt index f236868..c7c1af3 100644 --- a/src/main/kotlin/dev/orion/users/adapters/gateways/entities/WebAuthnCredentialEntity.kt +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/WebAuthnCredentialEntity.kt @@ -32,7 +32,6 @@ import jakarta.validation.constraints.NotNull @Entity @Table(name = "WebAuthnCredential") class WebAuthnCredentialEntity : PanacheEntityBase() { - /** Primary key. */ @Id @GeneratedValue @@ -71,4 +70,3 @@ class WebAuthnCredentialEntity : PanacheEntityBase() { @Column(name = "notes", length = 512) var notes: String? = null } - diff --git a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/RoleRepository.kt b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/RoleRepository.kt index f6bc561..420cb5c 100644 --- a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/RoleRepository.kt +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/RoleRepository.kt @@ -34,4 +34,3 @@ interface RoleRepository : PanacheRepository { */ fun findByName(name: String): Uni } - diff --git a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/RoleRepositoryImpl.kt b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/RoleRepositoryImpl.kt index cb2253a..f94945c 100644 --- a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/RoleRepositoryImpl.kt +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/RoleRepositoryImpl.kt @@ -25,8 +25,5 @@ import jakarta.enterprise.context.ApplicationScoped */ @ApplicationScoped class RoleRepositoryImpl : RoleRepository { - override fun findByName(name: String): Uni { - return find("name", name).firstResult() - } + override fun findByName(name: String): Uni = find("name", name).firstResult() } - diff --git a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/WebAuthnCredentialRepository.kt b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/WebAuthnCredentialRepository.kt index ffb6b1d..8701a28 100644 --- a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/WebAuthnCredentialRepository.kt +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/WebAuthnCredentialRepository.kt @@ -24,7 +24,6 @@ import io.smallrye.mutiny.Uni * WebAuthn Credential repository interface. */ interface WebAuthnCredentialRepository : PanacheRepository { - /** * Finds a credential by credential ID. * @@ -57,4 +56,3 @@ interface WebAuthnCredentialRepository : PanacheRepository } - diff --git a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/WebAuthnCredentialRepositoryImpl.kt b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/WebAuthnCredentialRepositoryImpl.kt index 2394435..211881c 100644 --- a/src/main/kotlin/dev/orion/users/adapters/gateways/repository/WebAuthnCredentialRepositoryImpl.kt +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/WebAuthnCredentialRepositoryImpl.kt @@ -26,18 +26,16 @@ import jakarta.enterprise.context.ApplicationScoped */ @ApplicationScoped class WebAuthnCredentialRepositoryImpl : WebAuthnCredentialRepository { - /** * Finds a credential by credential ID. * * @param credentialId The credential ID * @return A Uni object */ - override fun findByCredentialId(credentialId: String): Uni { - return (this as io.quarkus.hibernate.reactive.panache.PanacheRepository) + override fun findByCredentialId(credentialId: String): Uni = + (this as io.quarkus.hibernate.reactive.panache.PanacheRepository) .find("credentialId", credentialId) .firstResult() - } /** * Finds all credentials for a user. @@ -45,11 +43,10 @@ class WebAuthnCredentialRepositoryImpl : WebAuthnCredentialRepository { * @param userEmail The user's email * @return A Uni> object */ - override fun findByUserEmail(userEmail: String): Uni> { - return (this as io.quarkus.hibernate.reactive.panache.PanacheRepository) + override fun findByUserEmail(userEmail: String): Uni> = + (this as io.quarkus.hibernate.reactive.panache.PanacheRepository) .find("userEmail", userEmail) .list() - } /** * Saves or updates a credential. @@ -57,10 +54,11 @@ class WebAuthnCredentialRepositoryImpl : WebAuthnCredentialRepository { * @param credential The credential entity * @return A Uni object */ - override fun saveCredential(credential: WebAuthnCredentialEntity): Uni { - return Panache.withTransaction { credential.persist() } - .onItem().transform { credential } - } + override fun saveCredential(credential: WebAuthnCredentialEntity): Uni = + Panache + .withTransaction { credential.persist() } + .onItem() + .transform { credential } /** * Deletes a credential. @@ -68,14 +66,14 @@ class WebAuthnCredentialRepositoryImpl : WebAuthnCredentialRepository { * @param credentialId The credential ID * @return A Uni object */ - override fun deleteCredential(credentialId: String): Uni { - return findByCredentialId(credentialId) - .onItem().ifNull() + override fun deleteCredential(credentialId: String): Uni = + findByCredentialId(credentialId) + .onItem() + .ifNull() .failWith(IllegalArgumentException("Credential not found")) - .onItem().ifNotNull() + .onItem() + .ifNotNull() .transformToUni { credential -> Panache.withTransaction { credential.delete() } } - } } - diff --git a/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserEntityMapper.kt b/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserEntityMapper.kt index 6b91015..1f74758 100644 --- a/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserEntityMapper.kt +++ b/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserEntityMapper.kt @@ -14,7 +14,6 @@ import jakarta.enterprise.context.ApplicationScoped */ @ApplicationScoped class UserEntityMapper { - fun toDomain(entity: UserEntity): User { val u = User() u.id = entity.id diff --git a/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserPersistenceAdapter.kt b/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserPersistenceAdapter.kt index 47091e6..afdd514 100644 --- a/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserPersistenceAdapter.kt +++ b/src/main/kotlin/dev/orion/users/adapters/out/persistence/UserPersistenceAdapter.kt @@ -27,257 +27,304 @@ import java.io.IOException * Suspend overrides delegate to internal Uni chains via [awaitSuspending]. */ @ApplicationScoped -class UserPersistenceAdapter @Inject constructor( - private val roleRepository: RoleRepository, - private val mapper: UserEntityMapper -) : PanacheRepository, UserPersistencePort { - - private val DEFAULT_ROLE_NAME = "user" - private val PASSWORD_LENGTH = 8 - private val USER_NOT_FOUND_ERROR = "Error: user not found" - private val EMAIL = "email" - private val PASSWORD = "password" - - // ---- suspend overrides (port contract) ---- - - override suspend fun createUser(user: User): User { - val entity = mapper.toEntity(user) - entity.id = null - val persisted = createUserEntity(entity).awaitSuspending() - return mapper.toDomain(persisted) - } +class UserPersistenceAdapter + @Inject + constructor( + private val roleRepository: RoleRepository, + private val mapper: UserEntityMapper, + ) : PanacheRepository, + UserPersistencePort { + private val defaultRoleName = "user" + private val passwordLength = 8 + private val userNotFoundError = "Error: user not found" + private val email = "email" + private val password = "password" + + // ---- suspend overrides (port contract) ---- + + override suspend fun createUser(user: User): User { + val entity = mapper.toEntity(user) + entity.id = null + val persisted = createUserEntity(entity).awaitSuspending() + return mapper.toDomain(persisted) + } - override suspend fun findUserByEmail(email: String): User? { - val entity = findUserEntityByEmail(email).awaitSuspending() - return entity?.let { mapper.toDomain(it) } - } + override suspend fun findUserByEmail(email: String): User? { + val entity = findUserEntityByEmail(email).awaitSuspending() + return entity?.let { mapper.toDomain(it) } + } - override suspend fun authenticate(email: String, passwordHash: String): User? { - val entity = UserEntity() - entity.email = email - entity.password = passwordHash - val result = authenticateEntity(entity).awaitSuspending() - return result?.let { mapper.toDomain(it) } - } + override suspend fun authenticate( + email: String, + passwordHash: String, + ): User? { + val entity = UserEntity() + entity.email = email + entity.password = passwordHash + val result = authenticateEntity(entity).awaitSuspending() + return result?.let { mapper.toDomain(it) } + } - override suspend fun updateEmail(email: String, newEmail: String): User { - val result = updateEmailEntity(email, newEmail).awaitSuspending() - return mapper.toDomain(result) - } + override suspend fun updateEmail( + email: String, + newEmail: String, + ): User { + val result = updateEmailEntity(email, newEmail).awaitSuspending() + return mapper.toDomain(result) + } - override suspend fun validateEmail(email: String, code: String): User { - val result = validateEmailEntity(email, code).awaitSuspending() - return mapper.toDomain(result) - } + override suspend fun validateEmail( + email: String, + code: String, + ): User { + val result = validateEmailEntity(email, code).awaitSuspending() + return mapper.toDomain(result) + } - override suspend fun changePassword(password: String, newPassword: String, email: String): User { - val result = changePasswordEntity(password, newPassword, email).awaitSuspending() - return mapper.toDomain(result) - } + override suspend fun changePassword( + password: String, + newPassword: String, + email: String, + ): User { + val result = changePasswordEntity(password, newPassword, email).awaitSuspending() + return mapper.toDomain(result) + } - override suspend fun recoverPassword(email: String): String { - return recoverPasswordEntity(email).awaitSuspending() - } + override suspend fun recoverPassword(email: String): String = recoverPasswordEntity(email).awaitSuspending() - override suspend fun deleteUser(email: String) { - deleteUserEntity(email).awaitSuspending() - } + override suspend fun deleteUser(email: String) { + deleteUserEntity(email).awaitSuspending() + } - override suspend fun updateUser(user: User): User { - val id = user.id - if (id != null) { - val entity = findById(id) - .onItem().ifNull().failWith(IllegalArgumentException("User not found")) - .awaitSuspending()!! - applyDomainScalarsToEntity(user, entity) + override suspend fun updateUser(user: User): User { + val id = user.id + if (id != null) { + val entity = + findById(id) + .onItem() + .ifNull() + .failWith(IllegalArgumentException("User not found")) + .awaitSuspending()!! + applyDomainScalarsToEntity(user, entity) + val updated = updateUserEntity(entity).awaitSuspending() + return mapper.toDomain(updated) + } + val entity = mapper.toEntity(user) val updated = updateUserEntity(entity).awaitSuspending() return mapper.toDomain(updated) } - val entity = mapper.toEntity(user) - val updated = updateUserEntity(entity).awaitSuspending() - return mapper.toDomain(updated) - } - override suspend fun listAllUsers(): List { - val entities = listAllEntities().awaitSuspending() - return entities.map { mapper.toDomain(it) } - } + override suspend fun listAllUsers(): List { + val entities = listAllEntities().awaitSuspending() + return entities.map { mapper.toDomain(it) } + } - // ---- private helpers (stay as Uni — Panache internals) ---- - - private fun applyDomainScalarsToEntity(domain: User, entity: UserEntity) { - entity.hash = domain.hash - entity.name = domain.name - entity.email = domain.email - entity.password = domain.password - entity.emailValid = domain.emailValid - entity.emailValidationCode = domain.emailValidationCode - entity.isUsing2FA = domain.using2FA - entity.secret2FA = domain.secret2FA - entity.require2FAForBasicLogin = domain.require2FAForBasicLogin - entity.require2FAForSocialLogin = domain.require2FAForSocialLogin - } + // ---- private helpers (stay as Uni — Panache internals) ---- + + private fun applyDomainScalarsToEntity( + domain: User, + entity: UserEntity, + ) { + entity.hash = domain.hash + entity.name = domain.name + entity.email = domain.email + entity.password = domain.password + entity.emailValid = domain.emailValid + entity.emailValidationCode = domain.emailValidationCode + entity.isUsing2FA = domain.using2FA + entity.secret2FA = domain.secret2FA + entity.require2FAForBasicLogin = domain.require2FAForBasicLogin + entity.require2FAForSocialLogin = domain.require2FAForSocialLogin + } - private fun createUserEntity(u: UserEntity): Uni { - return checkEmail(u.email ?: "") - .onItem().ifNotNull().transform { user -> user!! } - .onItem().ifNull().switchTo { - checkName(u.name ?: "") - .onItem().ifNotNull() - .failWith(IllegalArgumentException("The name already existis")) - .onItem().ifNull().switchTo { - checkHash(u.hash) - .onItem().ifNotNull() - .failWith(IllegalArgumentException("The hash already existis")) - .onItem().ifNull().switchTo { - if ((u.password ?: "").isBlank()) { - u.password = generateSecurePassword() + private fun createUserEntity(u: UserEntity): Uni = + checkEmail(u.email ?: "") + .onItem() + .ifNotNull() + .transform { user -> user!! } + .onItem() + .ifNull() + .switchTo { + checkName(u.name ?: "") + .onItem() + .ifNotNull() + .failWith(IllegalArgumentException("The name already existis")) + .onItem() + .ifNull() + .switchTo { + checkHash(u.hash) + .onItem() + .ifNotNull() + .failWith(IllegalArgumentException("The hash already existis")) + .onItem() + .ifNull() + .switchTo { + if ((u.password ?: "").isBlank()) { + u.password = generateSecurePassword() + } + persistUser(u) } - persistUser(u) - } - } - } - } + } + } - private fun authenticateEntity(user: UserEntity): Uni { - return find("email = :email and password = :password", mapOf(EMAIL to user.email, PASSWORD to user.password)) - .firstResult() - } + private fun authenticateEntity(user: UserEntity): Uni = + find("email = :email and password = :password", mapOf(EMAIL to user.email, PASSWORD to user.password)) + .firstResult() + + private fun updateEmailEntity( + email: String, + newEmail: String, + ): Uni = + checkEmail(email) + .onItem() + .ifNull() + .failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) + .onItem() + .ifNotNull() + .transformToUni { user -> + val u = user!! + checkEmail(newEmail) + .onItem() + .ifNotNull() + .failWith(IllegalArgumentException("Email already in use")) + .onItem() + .ifNull() + .switchTo { + u.setEmailValidationCode() + u.emailValid = false + u.email = newEmail + Panache + .withTransaction { u.persist() } + .onItem() + .transform { u } + } + } - private fun updateEmailEntity(email: String, newEmail: String): Uni { - return checkEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) - .onItem().ifNotNull() - .transformToUni { user -> - val u = user!! - checkEmail(newEmail) - .onItem().ifNotNull() - .failWith(IllegalArgumentException("Email already in use")) - .onItem().ifNull() - .switchTo { - u.setEmailValidationCode() - u.emailValid = false - u.email = newEmail - Panache.withTransaction { u.persist() } - .onItem().transform { u } + private fun validateEmailEntity( + email: String, + code: String, + ): Uni = + find("email = :email and emailValidationCode = :code", mapOf(EMAIL to email, "code" to code)) + .firstResult() + .onItem() + .ifNotNull() + .transformToUni { user: UserEntity -> + user.emailValid = true + Panache + .withTransaction { user.persist() } + .onItem() + .transform { user } + }.onItem() + .ifNull() + .failWith(IllegalArgumentException("Invalid e-mail or code")) + + private fun changePasswordEntity( + password: String, + newPassword: String, + email: String, + ): Uni = + checkEmail(email) + .onItem() + .ifNull() + .failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) + .onItem() + .ifNotNull() + .transformToUni { user -> + val u = user!! + if (password == u.password) { + u.password = newPassword + } else { + throw IllegalArgumentException("Passwords doesn't match") } - } - } - - private fun validateEmailEntity(email: String, code: String): Uni { - return find("email = :email and emailValidationCode = :code", mapOf(EMAIL to email, "code" to code)) - .firstResult() - .onItem().ifNotNull().transformToUni { user: UserEntity -> - user.emailValid = true - Panache.withTransaction { user.persist() } - .onItem().transform { user } - } - .onItem().ifNull() - .failWith(IllegalArgumentException("Invalid e-mail or code")) - } - - private fun changePasswordEntity(password: String, newPassword: String, email: String): Uni { - return checkEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) - .onItem().ifNotNull() - .transformToUni { user -> - val u = user!! - if (password == u.password) { - u.password = newPassword - } else { - throw IllegalArgumentException("Passwords doesn't match") + Panache + .withTransaction { u.persist() } + .onItem() + .transform { u } } - Panache.withTransaction { u.persist() } - .onItem().transform { u } - } - } - private fun recoverPasswordEntity(email: String): Uni { - val password = generateSecurePassword() - val hashedPassword = DigestUtils.sha256Hex(password) - return checkEmail(email) - .onItem().ifNull() - .failWith(IllegalArgumentException("E-mail not found")) - .onItem().ifNotNull() - .transformToUni { user -> - val u = user!! - u.password = hashedPassword - Panache.withTransaction { u.persist() } - .onItem().transform { password } - } - } + private fun recoverPasswordEntity(email: String): Uni { + val password = generateSecurePassword() + val hashedPassword = DigestUtils.sha256Hex(password) + return checkEmail(email) + .onItem() + .ifNull() + .failWith(IllegalArgumentException("E-mail not found")) + .onItem() + .ifNotNull() + .transformToUni { user -> + val u = user!! + u.password = hashedPassword + Panache + .withTransaction { u.persist() } + .onItem() + .transform { password } + } + } - private fun deleteUserEntity(email: String): Uni { - return checkEmail(email) - .onItem().ifNull().failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) - .onItem().ifNotNull().transformToUni { user -> - Panache.withTransaction { user!!.delete() } - } - } + private fun deleteUserEntity(email: String): Uni = + checkEmail(email) + .onItem() + .ifNull() + .failWith(IllegalArgumentException(USER_NOT_FOUND_ERROR)) + .onItem() + .ifNotNull() + .transformToUni { user -> + Panache.withTransaction { user!!.delete() } + } - private fun updateUserEntity(user: UserEntity): Uni { - return Panache.withTransaction { user.persist() } - .onItem().transform { user } - } + private fun updateUserEntity(user: UserEntity): Uni = + Panache + .withTransaction { user.persist() } + .onItem() + .transform { user } - private fun findUserEntityByEmail(email: String): Uni { - return find(EMAIL, email).firstResult() - } + private fun findUserEntityByEmail(email: String): Uni = find(EMAIL, email).firstResult() - private fun listAllEntities(): Uni> { - return listAll() - } + private fun listAllEntities(): Uni> = listAll() - private fun checkEmail(email: String): Uni { - return find(EMAIL, email).firstResult() - } + private fun checkEmail(email: String): Uni = find(EMAIL, email).firstResult() - private fun checkName(name: String): Uni { - return find("name", name).firstResult() - } + private fun checkName(name: String): Uni = find("name", name).firstResult() - private fun checkHash(hash: String): Uni { - return find("hash", hash).firstResult() - } + private fun checkHash(hash: String): Uni = find("hash", hash).firstResult() - private fun persistUser(user: UserEntity): Uni { - return getDefaultRole() - .onItem().ifNull() - .failWith(IOException("Role not found")) - .onItem().ifNotNull() - .transformToUni { role -> - user.id = null - user.addRole(role) - Panache.withTransaction { user.persist() } - .onItem().transform { user } - } - } + private fun persistUser(user: UserEntity): Uni = + getDefaultRole() + .onItem() + .ifNull() + .failWith(IOException("Role not found")) + .onItem() + .ifNotNull() + .transformToUni { role -> + user.id = null + user.addRole(role) + Panache + .withTransaction { user.persist() } + .onItem() + .transform { user } + } - private fun getDefaultRole(): Uni { - return roleRepository.findByName(DEFAULT_ROLE_NAME) - } + private fun getDefaultRole(): Uni = roleRepository.findByName(DEFAULT_ROLE_NAME) + + private fun generateSecurePassword(): String { + val lcr = CharacterRule(EnglishCharacterData.LowerCase) + lcr.numberOfCharacters = 1 + val ucr = CharacterRule(EnglishCharacterData.UpperCase) + ucr.numberOfCharacters = 1 + val dr = CharacterRule(EnglishCharacterData.Digit) + dr.numberOfCharacters = 1 + val specialChars = "!@#\$%^&*()_+-=\\[\\]{};':\"\\\\|,.<>/?" + val special = defineSpecialChar(specialChars) + val sr = CharacterRule(special) + sr.numberOfCharacters = 1 + val passGen = PasswordGenerator() + return passGen.generatePassword(PASSWORD_LENGTH, sr, lcr, ucr, dr) + } - private fun generateSecurePassword(): String { - val lcr = CharacterRule(EnglishCharacterData.LowerCase) - lcr.numberOfCharacters = 1 - val ucr = CharacterRule(EnglishCharacterData.UpperCase) - ucr.numberOfCharacters = 1 - val dr = CharacterRule(EnglishCharacterData.Digit) - dr.numberOfCharacters = 1 - val specialChars = "!@#\$%^&*()_+-=\\[\\]{};':\"\\\\|,.<>/?" - val special = defineSpecialChar(specialChars) - val sr = CharacterRule(special) - sr.numberOfCharacters = 1 - val passGen = PasswordGenerator() - return passGen.generatePassword(PASSWORD_LENGTH, sr, lcr, ucr, dr) - } + private fun defineSpecialChar(character: String): CharacterData = + object : CharacterData { + override fun getErrorCode(): String = "Error" - private fun defineSpecialChar(character: String): CharacterData { - return object : CharacterData { - override fun getErrorCode(): String = "Error" - override fun getCharacters(): String = character - } + override fun getCharacters(): String = character + } } -} diff --git a/src/main/kotlin/dev/orion/users/adapters/presenters/AuthenticationDTO.kt b/src/main/kotlin/dev/orion/users/adapters/presenters/AuthenticationDTO.kt index 03b6408..4fca049 100644 --- a/src/main/kotlin/dev/orion/users/adapters/presenters/AuthenticationDTO.kt +++ b/src/main/kotlin/dev/orion/users/adapters/presenters/AuthenticationDTO.kt @@ -25,6 +25,5 @@ data class AuthenticationDTO( /** The user object. */ var user: UserEntity? = null, /** The authentication token (jwt). */ - var token: String? = null + var token: String? = null, ) - diff --git a/src/main/kotlin/dev/orion/users/adapters/presenters/LoginResponseDTO.kt b/src/main/kotlin/dev/orion/users/adapters/presenters/LoginResponseDTO.kt index a1ab52c..7e5fad7 100644 --- a/src/main/kotlin/dev/orion/users/adapters/presenters/LoginResponseDTO.kt +++ b/src/main/kotlin/dev/orion/users/adapters/presenters/LoginResponseDTO.kt @@ -25,6 +25,5 @@ data class LoginResponseDTO( /** Indicates if 2FA is required. */ var requires2FA: Boolean = false, /** Message for the client. */ - var message: String? = null + var message: String? = null, ) - diff --git a/src/main/kotlin/dev/orion/users/application/port/in/AuthenticateUCI.kt b/src/main/kotlin/dev/orion/users/application/port/in/AuthenticateUCI.kt index a26726e..cdd369b 100644 --- a/src/main/kotlin/dev/orion/users/application/port/in/AuthenticateUCI.kt +++ b/src/main/kotlin/dev/orion/users/application/port/in/AuthenticateUCI.kt @@ -8,13 +8,19 @@ import dev.orion.users.domain.model.User /** Inbound port: authentication use cases. */ interface AuthenticateUCI { - fun authenticate(email: String, password: String): User + fun authenticate( + email: String, + password: String, + ): User /** * Precondition for email validation: non-blank email and code. * @throws IllegalArgumentException if invalid */ - fun requireEmailValidationParams(email: String, code: String) + fun requireEmailValidationParams( + email: String, + code: String, + ) fun recoverPassword(email: String): String? } diff --git a/src/main/kotlin/dev/orion/users/application/port/in/CreateUserUCI.kt b/src/main/kotlin/dev/orion/users/application/port/in/CreateUserUCI.kt index 7740e39..e60111c 100644 --- a/src/main/kotlin/dev/orion/users/application/port/in/CreateUserUCI.kt +++ b/src/main/kotlin/dev/orion/users/application/port/in/CreateUserUCI.kt @@ -7,7 +7,15 @@ package dev.orion.users.application.port.`in` import dev.orion.users.domain.model.User interface CreateUserUCI { - fun createUser(name: String, email: String, password: String): User + fun createUser( + name: String, + email: String, + password: String, + ): User - fun createUser(name: String, email: String, isEmailValid: Boolean): User + fun createUser( + name: String, + email: String, + isEmailValid: Boolean, + ): User } diff --git a/src/main/kotlin/dev/orion/users/application/port/in/SocialAuthUCI.kt b/src/main/kotlin/dev/orion/users/application/port/in/SocialAuthUCI.kt index b99b97c..2998d05 100644 --- a/src/main/kotlin/dev/orion/users/application/port/in/SocialAuthUCI.kt +++ b/src/main/kotlin/dev/orion/users/application/port/in/SocialAuthUCI.kt @@ -7,5 +7,9 @@ package dev.orion.users.application.port.`in` import dev.orion.users.domain.model.User interface SocialAuthUCI { - fun validateSocialAuth(email: String, name: String, provider: String): User + fun validateSocialAuth( + email: String, + name: String, + provider: String, + ): User } diff --git a/src/main/kotlin/dev/orion/users/application/port/in/TwoFactorAuthUCI.kt b/src/main/kotlin/dev/orion/users/application/port/in/TwoFactorAuthUCI.kt index f4b6a81..cff122d 100644 --- a/src/main/kotlin/dev/orion/users/application/port/in/TwoFactorAuthUCI.kt +++ b/src/main/kotlin/dev/orion/users/application/port/in/TwoFactorAuthUCI.kt @@ -7,7 +7,13 @@ package dev.orion.users.application.port.`in` import dev.orion.users.domain.model.User interface TwoFactorAuthUCI { - fun generateQRCode(email: String, password: String): User + fun generateQRCode( + email: String, + password: String, + ): User - fun validateCode(email: String, code: String): User + fun validateCode( + email: String, + code: String, + ): User } diff --git a/src/main/kotlin/dev/orion/users/application/port/in/UpdateUser.kt b/src/main/kotlin/dev/orion/users/application/port/in/UpdateUser.kt index e4ef4f8..e86665d 100644 --- a/src/main/kotlin/dev/orion/users/application/port/in/UpdateUser.kt +++ b/src/main/kotlin/dev/orion/users/application/port/in/UpdateUser.kt @@ -7,5 +7,11 @@ package dev.orion.users.application.port.`in` import dev.orion.users.domain.model.User interface UpdateUser { - fun updateUser(email: String, name: String?, newEmail: String?, password: String?, newPassword: String?): User + fun updateUser( + email: String, + name: String?, + newEmail: String?, + password: String?, + newPassword: String?, + ): User } diff --git a/src/main/kotlin/dev/orion/users/application/port/in/WebAuthnUCI.kt b/src/main/kotlin/dev/orion/users/application/port/in/WebAuthnUCI.kt index 99453a2..c0a7634 100644 --- a/src/main/kotlin/dev/orion/users/application/port/in/WebAuthnUCI.kt +++ b/src/main/kotlin/dev/orion/users/application/port/in/WebAuthnUCI.kt @@ -7,9 +7,17 @@ package dev.orion.users.application.port.`in` interface WebAuthnUCI { fun startRegistration(email: String): String - fun finishRegistration(email: String, response: String, origin: String, deviceName: String?): Boolean + fun finishRegistration( + email: String, + response: String, + origin: String, + deviceName: String?, + ): Boolean fun startAuthentication(email: String): String - fun finishAuthentication(email: String, response: String): Boolean + fun finishAuthentication( + email: String, + response: String, + ): Boolean } diff --git a/src/main/kotlin/dev/orion/users/application/port/out/UserPersistencePort.kt b/src/main/kotlin/dev/orion/users/application/port/out/UserPersistencePort.kt index f745fb9..ccc2ae5 100644 --- a/src/main/kotlin/dev/orion/users/application/port/out/UserPersistencePort.kt +++ b/src/main/kotlin/dev/orion/users/application/port/out/UserPersistencePort.kt @@ -11,18 +11,30 @@ import dev.orion.users.domain.model.User * Pure Kotlin — no framework types in the contract. */ interface UserPersistencePort { - suspend fun createUser(user: User): User suspend fun findUserByEmail(email: String): User? - suspend fun authenticate(email: String, passwordHash: String): User? - - suspend fun updateEmail(email: String, newEmail: String): User - - suspend fun validateEmail(email: String, code: String): User - - suspend fun changePassword(password: String, newPassword: String, email: String): User + suspend fun authenticate( + email: String, + passwordHash: String, + ): User? + + suspend fun updateEmail( + email: String, + newEmail: String, + ): User + + suspend fun validateEmail( + email: String, + code: String, + ): User + + suspend fun changePassword( + password: String, + newPassword: String, + email: String, + ): User suspend fun recoverPassword(email: String): String diff --git a/src/main/kotlin/dev/orion/users/application/usecases/AuthenticateUC.kt b/src/main/kotlin/dev/orion/users/application/usecases/AuthenticateUC.kt index ee11a6a..94ec255 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/AuthenticateUC.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/AuthenticateUC.kt @@ -11,32 +11,39 @@ import jakarta.inject.Inject import org.apache.commons.codec.digest.DigestUtils @ApplicationScoped -open class AuthenticateUC @Inject constructor() : AuthenticateUCI { +open class AuthenticateUC + @Inject + constructor() : AuthenticateUCI { + private val blank = "Blank arguments" + private val invalid = "Invalid arguments" - private val BLANK = "Blank arguments" - private val INVALID = "Invalid arguments" - - override fun authenticate(email: String, password: String): User { - if (email.isNotEmpty() && password.isNotEmpty() && password.length >= 8) { - val user = User() - user.email = email - user.password = DigestUtils.sha256Hex(password) - return user - } else { - throw IllegalArgumentException(INVALID) + override fun authenticate( + email: String, + password: String, + ): User { + if (email.isNotEmpty() && password.isNotEmpty() && password.length >= 8) { + val user = User() + user.email = email + user.password = DigestUtils.sha256Hex(password) + return user + } else { + throw IllegalArgumentException(INVALID) + } } - } - override fun requireEmailValidationParams(email: String, code: String) { - if (email.isBlank() || code.isBlank()) { - throw IllegalArgumentException(BLANK) + override fun requireEmailValidationParams( + email: String, + code: String, + ) { + if (email.isBlank() || code.isBlank()) { + throw IllegalArgumentException(BLANK) + } } - } - override fun recoverPassword(email: String): String? { - if (email.isBlank()) { - throw IllegalArgumentException(BLANK) + override fun recoverPassword(email: String): String? { + if (email.isBlank()) { + throw IllegalArgumentException(BLANK) + } + return null } - return null } -} diff --git a/src/main/kotlin/dev/orion/users/application/usecases/CreateUserUC.kt b/src/main/kotlin/dev/orion/users/application/usecases/CreateUserUC.kt index 286c8cd..7b49628 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/CreateUserUC.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/CreateUserUC.kt @@ -13,31 +13,40 @@ import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.validator.routines.EmailValidator @ApplicationScoped -open class CreateUserUC @Inject constructor() : CreateUserUCI { - - override fun createUser(name: String, email: String, password: String): User { - if (name.isEmpty() || !EmailValidator.getInstance().isValid(email) || password.isEmpty()) { - throw IllegalArgumentException("Blank arguments or invalid e-mail") +open class CreateUserUC + @Inject + constructor() : CreateUserUCI { + override fun createUser( + name: String, + email: String, + password: String, + ): User { + if (name.isEmpty() || !EmailValidator.getInstance().isValid(email) || password.isEmpty()) { + throw IllegalArgumentException("Blank arguments or invalid e-mail") + } + PasswordValidator.validatePasswordOrThrow(password) + val user = User() + user.name = name + user.email = email + user.password = encryptPassword(password) + user.emailValid = false + return user } - PasswordValidator.validatePasswordOrThrow(password) - val user = User() - user.name = name - user.email = email - user.password = encryptPassword(password) - user.emailValid = false - return user - } - override fun createUser(name: String, email: String, isEmailValid: Boolean): User { - if (name.isBlank() || !EmailValidator.getInstance().isValid(email)) { - throw IllegalArgumentException("Blank arguments or invalid e-mail") + override fun createUser( + name: String, + email: String, + isEmailValid: Boolean, + ): User { + if (name.isBlank() || !EmailValidator.getInstance().isValid(email)) { + throw IllegalArgumentException("Blank arguments or invalid e-mail") + } + val user = User() + user.name = name + user.email = email + user.emailValid = isEmailValid + return user } - val user = User() - user.name = name - user.email = email - user.emailValid = isEmailValid - return user - } - private fun encryptPassword(password: String): String = DigestUtils.sha256Hex(password) -} + private fun encryptPassword(password: String): String = DigestUtils.sha256Hex(password) + } diff --git a/src/main/kotlin/dev/orion/users/application/usecases/DeleteUserImpl.kt b/src/main/kotlin/dev/orion/users/application/usecases/DeleteUserImpl.kt index 62fb72d..6418c3d 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/DeleteUserImpl.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/DeleteUserImpl.kt @@ -9,12 +9,13 @@ import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject @ApplicationScoped -open class DeleteUserImpl @Inject constructor() : DeleteUser { - - override fun deleteUser(email: String): Boolean { - if (email.isBlank()) { - throw IllegalArgumentException("Email can not be blank") +open class DeleteUserImpl + @Inject + constructor() : DeleteUser { + override fun deleteUser(email: String): Boolean { + if (email.isBlank()) { + throw IllegalArgumentException("Email can not be blank") + } + return true } - return true } -} diff --git a/src/main/kotlin/dev/orion/users/application/usecases/SocialAuthUC.kt b/src/main/kotlin/dev/orion/users/application/usecases/SocialAuthUC.kt index afb271b..71d8090 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/SocialAuthUC.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/SocialAuthUC.kt @@ -11,22 +11,27 @@ import jakarta.inject.Inject import org.apache.commons.validator.routines.EmailValidator @ApplicationScoped -open class SocialAuthUC @Inject constructor() : SocialAuthUCI { - - override fun validateSocialAuth(email: String, name: String, provider: String): User { - if (email.isBlank() || name.isBlank() || provider.isBlank()) { - throw IllegalArgumentException("Email, name and provider cannot be blank") - } - if (!EmailValidator.getInstance().isValid(email)) { - throw IllegalArgumentException("Invalid email format") - } - if (provider.lowercase() != "google") { - throw IllegalArgumentException("Unsupported provider: $provider") +open class SocialAuthUC + @Inject + constructor() : SocialAuthUCI { + override fun validateSocialAuth( + email: String, + name: String, + provider: String, + ): User { + if (email.isBlank() || name.isBlank() || provider.isBlank()) { + throw IllegalArgumentException("Email, name and provider cannot be blank") + } + if (!EmailValidator.getInstance().isValid(email)) { + throw IllegalArgumentException("Invalid email format") + } + if (provider.lowercase() != "google") { + throw IllegalArgumentException("Unsupported provider: $provider") + } + val user = User() + user.email = email + user.name = name + user.emailValid = true + return user } - val user = User() - user.email = email - user.name = name - user.emailValid = true - return user } -} diff --git a/src/main/kotlin/dev/orion/users/application/usecases/TwoFactorAuthUC.kt b/src/main/kotlin/dev/orion/users/application/usecases/TwoFactorAuthUC.kt index 0d73726..0c54af1 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/TwoFactorAuthUC.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/TwoFactorAuthUC.kt @@ -13,37 +13,44 @@ import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.validator.routines.EmailValidator @ApplicationScoped -open class TwoFactorAuthUC @Inject constructor() : TwoFactorAuthUCI { +open class TwoFactorAuthUC + @Inject + constructor() : TwoFactorAuthUCI { + private val blank = "Blank arguments" + private val invalid = "Invalid arguments" - private val BLANK = "Blank arguments" - private val INVALID = "Invalid arguments" - - override fun generateQRCode(email: String, password: String): User { - if (email.isBlank() || password.isBlank()) { - throw IllegalArgumentException(BLANK) - } - if (!EmailValidator.getInstance().isValid(email)) { - throw IllegalArgumentException(INVALID) + override fun generateQRCode( + email: String, + password: String, + ): User { + if (email.isBlank() || password.isBlank()) { + throw IllegalArgumentException(BLANK) + } + if (!EmailValidator.getInstance().isValid(email)) { + throw IllegalArgumentException(INVALID) + } + PasswordValidator.validatePasswordOrThrow(password) + val user = User() + user.email = email + user.password = DigestUtils.sha256Hex(password) + return user } - PasswordValidator.validatePasswordOrThrow(password) - val user = User() - user.email = email - user.password = DigestUtils.sha256Hex(password) - return user - } - override fun validateCode(email: String, code: String): User { - if (email.isBlank() || code.isBlank()) { - throw IllegalArgumentException(BLANK) - } - if (!EmailValidator.getInstance().isValid(email)) { - throw IllegalArgumentException(INVALID) - } - if (!code.matches(Regex("\\d{6}"))) { - throw IllegalArgumentException("Invalid TOTP code format") + override fun validateCode( + email: String, + code: String, + ): User { + if (email.isBlank() || code.isBlank()) { + throw IllegalArgumentException(BLANK) + } + if (!EmailValidator.getInstance().isValid(email)) { + throw IllegalArgumentException(INVALID) + } + if (!code.matches(Regex("\\d{6}"))) { + throw IllegalArgumentException("Invalid TOTP code format") + } + val user = User() + user.email = email + return user } - val user = User() - user.email = email - return user } -} diff --git a/src/main/kotlin/dev/orion/users/application/usecases/UpdateUserImpl.kt b/src/main/kotlin/dev/orion/users/application/usecases/UpdateUserImpl.kt index d6728e4..9cd7cab 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/UpdateUserImpl.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/UpdateUserImpl.kt @@ -13,50 +13,51 @@ import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.validator.routines.EmailValidator @ApplicationScoped -open class UpdateUserImpl @Inject constructor() : UpdateUser { +open class UpdateUserImpl + @Inject + constructor() : UpdateUser { + private val blank = "Blank Arguments" - private val BLANK = "Blank Arguments" - - override fun updateUser( - email: String, - name: String?, - newEmail: String?, - password: String?, - newPassword: String? - ): User { - if (email.isBlank()) { - throw IllegalArgumentException(BLANK) - } - if (name.isNullOrBlank() && newEmail.isNullOrBlank() && newPassword.isNullOrBlank()) { - throw IllegalArgumentException("At least one field (name, newEmail or newPassword) must be provided for update") - } - if (!EmailValidator.getInstance().isValid(email)) { - throw IllegalArgumentException("Invalid current email format") - } - val user = User() - user.email = email - if (!name.isNullOrBlank()) { - if (name.trim().isEmpty()) { - throw IllegalArgumentException("Name cannot be empty") + override fun updateUser( + email: String, + name: String?, + newEmail: String?, + password: String?, + newPassword: String?, + ): User { + if (email.isBlank()) { + throw IllegalArgumentException(BLANK) } - user.name = name.trim() - } - if (!newEmail.isNullOrBlank()) { - if (!EmailValidator.getInstance().isValid(newEmail)) { - throw IllegalArgumentException("Invalid new email format") + if (name.isNullOrBlank() && newEmail.isNullOrBlank() && newPassword.isNullOrBlank()) { + throw IllegalArgumentException("At least one field (name, newEmail or newPassword) must be provided for update") } - user.email = newEmail - user.emailValid = false - } - if (!newPassword.isNullOrBlank()) { - if (password.isNullOrBlank()) { - throw IllegalArgumentException("Current password is required when updating password") + if (!EmailValidator.getInstance().isValid(email)) { + throw IllegalArgumentException("Invalid current email format") } - PasswordValidator.validatePasswordOrThrow(newPassword) - user.password = encryptPassword(newPassword) + val user = User() + user.email = email + if (!name.isNullOrBlank()) { + if (name.trim().isEmpty()) { + throw IllegalArgumentException("Name cannot be empty") + } + user.name = name.trim() + } + if (!newEmail.isNullOrBlank()) { + if (!EmailValidator.getInstance().isValid(newEmail)) { + throw IllegalArgumentException("Invalid new email format") + } + user.email = newEmail + user.emailValid = false + } + if (!newPassword.isNullOrBlank()) { + if (password.isNullOrBlank()) { + throw IllegalArgumentException("Current password is required when updating password") + } + PasswordValidator.validatePasswordOrThrow(newPassword) + user.password = encryptPassword(newPassword) + } + return user } - return user - } - private fun encryptPassword(password: String): String = DigestUtils.sha256Hex(password) -} + private fun encryptPassword(password: String): String = DigestUtils.sha256Hex(password) + } diff --git a/src/main/kotlin/dev/orion/users/application/usecases/WebAuthnUC.kt b/src/main/kotlin/dev/orion/users/application/usecases/WebAuthnUC.kt index be92050..ed21dd1 100644 --- a/src/main/kotlin/dev/orion/users/application/usecases/WebAuthnUC.kt +++ b/src/main/kotlin/dev/orion/users/application/usecases/WebAuthnUC.kt @@ -16,99 +16,106 @@ */ package dev.orion.users.application.usecases -import org.apache.commons.validator.routines.EmailValidator - import dev.orion.users.application.port.`in`.WebAuthnUCI import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject +import org.apache.commons.validator.routines.EmailValidator /** * Use case implementation for WebAuthn (input validation; protocol details stay in adapters). */ @ApplicationScoped -open class WebAuthnUC @Inject constructor() : WebAuthnUCI { +open class WebAuthnUC + @Inject + constructor() : WebAuthnUCI { + /** Default blank arguments message. */ + private val blank = "Blank arguments" - /** Default blank arguments message. */ - private val BLANK = "Blank arguments" + /** Default invalid arguments message. */ + private val invalid = "Invalid arguments" - /** Default invalid arguments message. */ - private val INVALID = "Invalid arguments" - - /** - * Starts the WebAuthn registration process. - * - * @param email The email of the user - * @return A JSON string containing PublicKeyCredentialCreationOptions - * @throws IllegalArgumentException if email is invalid - */ - override fun startRegistration(email: String): String { - if (email.isBlank()) { - throw IllegalArgumentException(BLANK) - } - if (!EmailValidator.getInstance().isValid(email)) { - throw IllegalArgumentException(INVALID) + /** + * Starts the WebAuthn registration process. + * + * @param email The email of the user + * @return A JSON string containing PublicKeyCredentialCreationOptions + * @throws IllegalArgumentException if email is invalid + */ + override fun startRegistration(email: String): String { + if (email.isBlank()) { + throw IllegalArgumentException(BLANK) + } + if (!EmailValidator.getInstance().isValid(email)) { + throw IllegalArgumentException(INVALID) + } + // The actual options will be generated in the controller + // This method just validates the input + return "" } - // The actual options will be generated in the controller - // This method just validates the input - return "" - } - /** - * Finishes the WebAuthn registration process. - * - * @param email The email of the user - * @param response The registration response from the client (JSON string) - * @param origin The origin (complete site address) where the device was registered - * @param deviceName Optional name for the device - * @return true if registration was successful - * @throws IllegalArgumentException if arguments are invalid - */ - override fun finishRegistration(email: String, response: String, origin: String, deviceName: String?): Boolean { - if (email.isBlank() || response.isBlank() || origin.isBlank()) { - throw IllegalArgumentException(BLANK) + /** + * Finishes the WebAuthn registration process. + * + * @param email The email of the user + * @param response The registration response from the client (JSON string) + * @param origin The origin (complete site address) where the device was registered + * @param deviceName Optional name for the device + * @return true if registration was successful + * @throws IllegalArgumentException if arguments are invalid + */ + override fun finishRegistration( + email: String, + response: String, + origin: String, + deviceName: String?, + ): Boolean { + if (email.isBlank() || response.isBlank() || origin.isBlank()) { + throw IllegalArgumentException(BLANK) + } + if (!EmailValidator.getInstance().isValid(email)) { + throw IllegalArgumentException(INVALID) + } + // The actual validation will be done in the controller + return true } - if (!EmailValidator.getInstance().isValid(email)) { - throw IllegalArgumentException(INVALID) - } - // The actual validation will be done in the controller - return true - } - /** - * Starts the WebAuthn authentication process. - * - * @param email The email of the user - * @return A JSON string containing PublicKeyCredentialRequestOptions - * @throws IllegalArgumentException if email is invalid - */ - override fun startAuthentication(email: String): String { - if (email.isBlank()) { - throw IllegalArgumentException(BLANK) - } - if (!EmailValidator.getInstance().isValid(email)) { - throw IllegalArgumentException(INVALID) + /** + * Starts the WebAuthn authentication process. + * + * @param email The email of the user + * @return A JSON string containing PublicKeyCredentialRequestOptions + * @throws IllegalArgumentException if email is invalid + */ + override fun startAuthentication(email: String): String { + if (email.isBlank()) { + throw IllegalArgumentException(BLANK) + } + if (!EmailValidator.getInstance().isValid(email)) { + throw IllegalArgumentException(INVALID) + } + // The actual options will be generated in the controller + return "" } - // The actual options will be generated in the controller - return "" - } - /** - * Finishes the WebAuthn authentication process. - * - * @param email The email of the user - * @param response The authentication response from the client (JSON string) - * @return true if authentication was successful - * @throws IllegalArgumentException if arguments are invalid - */ - override fun finishAuthentication(email: String, response: String): Boolean { - if (email.isBlank() || response.isBlank()) { - throw IllegalArgumentException(BLANK) - } - if (!EmailValidator.getInstance().isValid(email)) { - throw IllegalArgumentException(INVALID) + /** + * Finishes the WebAuthn authentication process. + * + * @param email The email of the user + * @param response The authentication response from the client (JSON string) + * @return true if authentication was successful + * @throws IllegalArgumentException if arguments are invalid + */ + override fun finishAuthentication( + email: String, + response: String, + ): Boolean { + if (email.isBlank() || response.isBlank()) { + throw IllegalArgumentException(BLANK) + } + if (!EmailValidator.getInstance().isValid(email)) { + throw IllegalArgumentException(INVALID) + } + // The actual validation will be done in the controller + return true } - // The actual validation will be done in the controller - return true } -} - diff --git a/src/main/kotlin/dev/orion/users/application/utils/PasswordValidator.kt b/src/main/kotlin/dev/orion/users/application/utils/PasswordValidator.kt index c042f7c..74732a7 100644 --- a/src/main/kotlin/dev/orion/users/application/utils/PasswordValidator.kt +++ b/src/main/kotlin/dev/orion/users/application/utils/PasswordValidator.kt @@ -25,7 +25,6 @@ package dev.orion.users.application.utils * - At least one special character */ object PasswordValidator { - /** The minimum size of the password required. */ private const val MIN_PASSWORD_LENGTH = 8 @@ -93,6 +92,5 @@ object PasswordValidator { */ data class PasswordValidationResult( val isValid: Boolean, - val errors: List + val errors: List, ) - diff --git a/src/main/kotlin/dev/orion/users/domain/model/Role.kt b/src/main/kotlin/dev/orion/users/domain/model/Role.kt index b394795..98a0903 100644 --- a/src/main/kotlin/dev/orion/users/domain/model/Role.kt +++ b/src/main/kotlin/dev/orion/users/domain/model/Role.kt @@ -21,5 +21,5 @@ package dev.orion.users.domain.model */ data class Role( /** The name of the role. */ - var name: String? = null + var name: String? = null, ) diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/DashboardSpaRootResource.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/DashboardSpaRootResource.kt index 868161b..98bf9ea 100644 --- a/src/main/kotlin/dev/orion/users/frameworks/rest/DashboardSpaRootResource.kt +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/DashboardSpaRootResource.kt @@ -14,14 +14,14 @@ import jakarta.ws.rs.core.Response @ApplicationScoped @Path("/dashboard") class DashboardSpaRootResource { - @GET @Produces(MediaType.TEXT_HTML) fun index(): Response { - val bytes = javaClass.classLoader - .getResourceAsStream("META-INF/resources/dashboard/index.html") - ?.use { it.readAllBytes() } - ?: return Response.status(Response.Status.NOT_FOUND).build() + val bytes = + javaClass.classLoader + .getResourceAsStream("META-INF/resources/dashboard/index.html") + ?.use { it.readAllBytes() } + ?: return Response.status(Response.Status.NOT_FOUND).build() return Response.ok(bytes, MediaType.TEXT_HTML).build() } } diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/ServiceException.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/ServiceException.kt index 92c53c6..7b411c8 100644 --- a/src/main/kotlin/dev/orion/users/frameworks/rest/ServiceException.kt +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/ServiceException.kt @@ -23,8 +23,10 @@ import jakarta.ws.rs.core.Response.Status /** * Frameworks and Drivers layer of Clean Architecture. */ -class ServiceException(message: String, status: Status) : WebApplicationException(init(message, status)) { - +class ServiceException( + message: String, + status: Status, +) : WebApplicationException(init(message, status)) { companion object { /** * A static method to init the message. @@ -34,7 +36,10 @@ class ServiceException(message: String, status: Status) : WebApplicationExceptio * * @return A Response object */ - private fun init(message: String, status: Status): Response { + private fun init( + message: String, + status: Status, + ): Response { val violations = listOf(mapOf("message" to message)) return Response @@ -44,4 +49,3 @@ class ServiceException(message: String, status: Status) : WebApplicationExceptio } } } - diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.kt index dfbaed8..f087033 100644 --- a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.kt +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.kt @@ -45,9 +45,8 @@ import org.jboss.resteasy.reactive.RestForm @Produces(MediaType.APPLICATION_JSON) @WithSession class AuthenticationWS { - /** Fault tolerance default delay. */ - protected val DELAY: Long = 2000 + protected val delay: Long = 2000 /** Business logic of the system. */ @Inject @@ -73,13 +72,16 @@ class AuthenticationWS { @Deprecated("Use login method instead", ReplaceWith("login(email, password)")) fun authenticate( @RestForm @NotEmpty @Email email: String, - @RestForm @NotEmpty password: String - ): Uni { - return controller.authenticate(email, password) - .onItem().ifNotNull().transform { jwt -> jwt } - .onItem().ifNull() + @RestForm @NotEmpty password: String, + ): Uni = + controller + .authenticate(email, password) + .onItem() + .ifNotNull() + .transform { jwt -> jwt } + .onItem() + .ifNull() .failWith(ServiceException("User not found", Response.Status.UNAUTHORIZED)) - } /** * Authenticates a user. @@ -98,21 +100,22 @@ class AuthenticationWS { @Retry(maxRetries = 1, delay = 2000) fun login( @RestForm @NotEmpty @Email email: String, - @RestForm @NotEmpty password: String - ): Uni { - return controller.login(email, password) + @RestForm @NotEmpty password: String, + ): Uni = + controller + .login(email, password) .onItem().ifNotNull() - .transform { response -> - // Always return LoginResponseDTO complete - Response.ok(response).build() - } + .transform { response -> + // Always return LoginResponseDTO complete + Response.ok(response).build() + } .onItem().ifNull() - .failWith(ServiceException("User not found", Response.Status.UNAUTHORIZED)) - .onFailure().transform { e -> - val message = e.message ?: "Unknown error" - throw ServiceException(message, Response.Status.BAD_REQUEST) - } - } + .failWith(ServiceException("User not found", Response.Status.UNAUTHORIZED)) + .onFailure() + .transform { e -> + val message = e.message ?: "Unknown error" + throw ServiceException(message, Response.Status.BAD_REQUEST) + } /** * Authenticates a user with 2FA code. @@ -130,17 +133,18 @@ class AuthenticationWS { @Retry(maxRetries = 1, delay = 2000) fun loginWith2FA( @RestForm @NotEmpty @Email email: String, - @RestForm @NotEmpty code: String - ): Uni { - return controller.validate2FACode(email, code) - .onItem().transform { response -> + @RestForm @NotEmpty code: String, + ): Uni = + controller + .validate2FACode(email, code) + .onItem() + .transform { response -> Response.ok(response).build() - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Invalid TOTP code" throw ServiceException(message, Response.Status.UNAUTHORIZED) } - } /** * Creates and authenticates a user. @@ -160,15 +164,18 @@ class AuthenticationWS { fun createAuthenticate( @FormParam("name") @NotEmpty name: String, @FormParam("email") @NotEmpty @Email email: String, - @FormParam("password") @NotEmpty password: String - ): Uni { - return controller.createAuthenticate(name, email, password) - .onItem().ifNotNull().transform { response -> Response.ok(response).build() } - .onFailure().transform { e -> + @FormParam("password") @NotEmpty password: String, + ): Uni = + controller + .createAuthenticate(name, email, password) + .onItem() + .ifNotNull() + .transform { response -> Response.ok(response).build() } + .onFailure() + .transform { e -> val message = e.message ?: "Unknown error" throw ServiceException(message, Response.Status.BAD_REQUEST) } - } /** * Validates e-mail, this method is used to confirm the user's e-mail using @@ -187,19 +194,23 @@ class AuthenticationWS { @WithSession fun validateEmail( @QueryParam("email") @NotEmpty email: String, - @QueryParam("code") @NotEmpty code: String - ): Uni { - return controller.validateEmail(email, code) - .onItem().ifNotNull().transform { Response.ok(true).build() } - .onItem().ifNull().continueWith { + @QueryParam("code") @NotEmpty code: String, + ): Uni = + controller + .validateEmail(email, code) + .onItem() + .ifNotNull() + .transform { Response.ok(true).build() } + .onItem() + .ifNull() + .continueWith { val message = "Invalid e-mail or code" throw ServiceException(message, Response.Status.BAD_REQUEST) - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Unknown error" throw ServiceException(message, Response.Status.BAD_REQUEST) } - } /** * Recovers the password of a user. Generates a new password and sends it via email. @@ -215,16 +226,16 @@ class AuthenticationWS { @Produces(MediaType.TEXT_PLAIN) @Retry(maxRetries = 1, delay = 2000) fun recoverPassword( - @RestForm @NotEmpty @Email email: String - ): Uni { - return controller.recoverPassword(email) - .onItem().transform { + @RestForm @NotEmpty @Email email: String, + ): Uni = + controller + .recoverPassword(email) + .onItem() + .transform { Response.noContent().build() - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Unknown error" throw ServiceException(message, Response.Status.BAD_REQUEST) } - } } - diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthWS.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthWS.kt index 0de6c5b..b6ee717 100644 --- a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthWS.kt +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthWS.kt @@ -17,16 +17,17 @@ package dev.orion.users.frameworks.rest.authentication import dev.orion.users.adapters.controllers.UserController -import dev.orion.users.adapters.presenters.LoginResponseDTO import dev.orion.users.frameworks.rest.ServiceException import io.quarkus.hibernate.reactive.panache.common.WithSession import io.smallrye.mutiny.Uni +import io.vertx.core.Vertx +import io.vertx.ext.web.client.WebClient +import io.vertx.ext.web.client.WebClientOptions import jakarta.annotation.security.PermitAll import jakarta.inject.Inject import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotEmpty import jakarta.ws.rs.Consumes -import jakarta.ws.rs.FormParam import jakarta.ws.rs.POST import jakarta.ws.rs.Path import jakarta.ws.rs.Produces @@ -34,9 +35,6 @@ import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.Response import org.eclipse.microprofile.faulttolerance.Retry import org.jboss.resteasy.reactive.RestForm -import io.vertx.core.Vertx -import io.vertx.ext.web.client.WebClient -import io.vertx.ext.web.client.WebClientOptions /** * Social Authentication Web Service. @@ -48,9 +46,8 @@ import io.vertx.ext.web.client.WebClientOptions @Produces(MediaType.APPLICATION_JSON) @WithSession class SocialAuthWS { - /** Fault tolerance default delay. */ - protected val DELAY: Long = 2000 + protected val delay: Long = 2000 /** Business logic of the system. */ @Inject @@ -81,23 +78,23 @@ class SocialAuthWS { @Produces(MediaType.APPLICATION_JSON) @Retry(maxRetries = 1, delay = 2000) fun loginWithGoogle( - @RestForm @NotEmpty idToken: String - ): Uni { - return validateGoogleToken(idToken) - .onItem().transform { (email, name) -> + @RestForm @NotEmpty idToken: String, + ): Uni = + validateGoogleToken(idToken) + .onItem() + .transform { (email, name) -> controller.loginWithSocialProvider(email, name, "google") - } - .onItem().transformToUni { responseUni -> + }.onItem() + .transformToUni { responseUni -> responseUni.onItem().transform { response -> // Always return LoginResponseDTO complete Response.ok(response).build() } - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Google authentication failed" ServiceException(message, Response.Status.UNAUTHORIZED) } - } /** * Validates a TOTP code for 2FA authentication after social login. @@ -115,17 +112,18 @@ class SocialAuthWS { @Retry(maxRetries = 1, delay = 2000) fun loginWithGoogle2FA( @RestForm @NotEmpty @Email email: String, - @RestForm @NotEmpty code: String - ): Uni { - return controller.validateSocialLogin2FA(email, code) - .onItem().transform { response -> + @RestForm @NotEmpty code: String, + ): Uni = + controller + .validateSocialLogin2FA(email, code) + .onItem() + .transform { response -> Response.ok(response).build() - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Invalid TOTP code" ServiceException(message, Response.Status.UNAUTHORIZED) } - } /** * Validates Google ID token or access token and extracts user information. @@ -139,24 +137,26 @@ class SocialAuthWS { return try { // Normalize token: remove leading/trailing whitespace and any extra spaces val normalizedToken = token.trim().replace("\\s+".toRegex(), "") - + // Validate token is not empty if (normalizedToken.isEmpty()) { return Uni.createFrom().failure(IllegalArgumentException("Invalid Google token: Token is empty")) } - + // Try to validate as JWT (id_token) first val jwtResult = tryValidateAsJWT(normalizedToken) if (jwtResult != null) { return jwtResult } - + // If not a valid JWT, assume it's an access_token and fetch user info from Google API fetchUserInfoFromGoogleAPI(normalizedToken) } catch (e: IllegalArgumentException) { Uni.createFrom().failure(IllegalArgumentException("Invalid Google token: ${e.message}")) } catch (e: Exception) { - Uni.createFrom().failure(IllegalArgumentException("Invalid Google token: ${e.javaClass.simpleName} - ${e.message ?: "Unknown error"}")) + Uni.createFrom().failure( + IllegalArgumentException("Invalid Google token: ${e.javaClass.simpleName} - ${e.message ?: "Unknown error"}"), + ) } } @@ -171,7 +171,7 @@ class SocialAuthWS { if (parts.size != 3) { return null // Not a JWT, might be an access_token } - + // Validate parts are not empty if (parts[0].isEmpty() || parts[1].isEmpty() || parts[2].isEmpty()) { return null // Invalid JWT format @@ -180,26 +180,36 @@ class SocialAuthWS { // Decode payload (base64url) val payload: String try { - payload = String(java.util.Base64.getUrlDecoder().decode(parts[1])) + payload = + String( + java.util.Base64 + .getUrlDecoder() + .decode(parts[1]), + ) } catch (e: IllegalArgumentException) { return null // Not a valid base64url, might be an access_token } - + // Parse JSON payload val json: com.fasterxml.jackson.databind.JsonNode try { - json = com.fasterxml.jackson.databind.ObjectMapper().readTree(payload) + json = + com.fasterxml.jackson.databind + .ObjectMapper() + .readTree(payload) } catch (e: Exception) { return null // Not valid JSON, might be an access_token } // Extract email - val email = json.get("email")?.asText() - ?: return null // No email in token, might be an access_token - - val name = json.get("name")?.asText() - ?: json.get("given_name")?.asText()?.let { "$it ${json.get("family_name")?.asText() ?: ""}" } - ?: email + val email = + json.get("email")?.asText() + ?: return null // No email in token, might be an access_token + + val name = + json.get("name")?.asText() + ?: json.get("given_name")?.asText()?.let { "$it ${json.get("family_name")?.asText() ?: ""}" } + ?: email Uni.createFrom().item(Pair(email, name)) } catch (e: Exception) { @@ -211,41 +221,53 @@ class SocialAuthWS { * Fetches user information from Google API using an access_token. */ private fun fetchUserInfoFromGoogleAPI(accessToken: String): Uni> { - val future = webClient.get(443, "www.googleapis.com", "/oauth2/v2/userinfo") - .ssl(true) - .putHeader("Authorization", "Bearer $accessToken") - .send() - - return Uni.createFrom().completionStage(future.toCompletionStage()) - .onItem().transform { response -> + val future = + webClient + .get(443, "www.googleapis.com", "/oauth2/v2/userinfo") + .ssl(true) + .putHeader("Authorization", "Bearer $accessToken") + .send() + + return Uni + .createFrom() + .completionStage(future.toCompletionStage()) + .onItem() + .transform { response -> if (response.statusCode() != 200) { - val errorBody = try { - response.bodyAsString() - } catch (e: Exception) { - "Unable to read error response" - } + val errorBody = + try { + response.bodyAsString() + } catch (e: Exception) { + "Unable to read error response" + } throw IllegalArgumentException("Failed to fetch user info from Google API: HTTP ${response.statusCode()} - $errorBody") } val json: com.fasterxml.jackson.databind.JsonNode try { - json = com.fasterxml.jackson.databind.ObjectMapper().readTree(response.bodyAsString()) + json = + com.fasterxml.jackson.databind + .ObjectMapper() + .readTree(response.bodyAsString()) } catch (e: Exception) { throw IllegalArgumentException("Failed to parse Google API response: ${e.message}") } - val email = json.get("email")?.asText() - ?: throw IllegalArgumentException("Email not found in Google API response") - - val name = json.get("name")?.asText() - ?: json.get("given_name")?.asText()?.let { "$it ${json.get("family_name")?.asText() ?: ""}" } - ?: email + val email = + json.get("email")?.asText() + ?: throw IllegalArgumentException("Email not found in Google API response") + + val name = + json.get("name")?.asText() + ?: json.get("given_name")?.asText()?.let { "$it ${json.get("family_name")?.asText() ?: ""}" } + ?: email Pair(email, name) - } - .onFailure().transform { throwable -> - IllegalArgumentException("Failed to fetch user info from Google API: ${throwable.message ?: throwable.javaClass.simpleName}") + }.onFailure() + .transform { throwable -> + IllegalArgumentException( + "Failed to fetch user info from Google API: ${throwable.message ?: throwable.javaClass.simpleName}", + ) } } } - diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthenticationWS.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthenticationWS.kt index b691d40..41a6773 100644 --- a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthenticationWS.kt +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthenticationWS.kt @@ -23,4 +23,3 @@ import jakarta.ws.rs.Path */ @Path("/api/users") class SocialAuthenticationWS - diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/TwoFactorAuth.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/TwoFactorAuth.kt index d5c2148..e624b42 100644 --- a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/TwoFactorAuth.kt +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/TwoFactorAuth.kt @@ -17,7 +17,6 @@ package dev.orion.users.frameworks.rest.authentication import dev.orion.users.adapters.controllers.UserController -import dev.orion.users.adapters.presenters.LoginResponseDTO import dev.orion.users.frameworks.rest.ServiceException import io.quarkus.hibernate.reactive.panache.common.WithSession import io.smallrye.mutiny.Uni @@ -42,9 +41,8 @@ import org.jboss.resteasy.reactive.RestForm @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @WithSession class TwoFactorAuth { - /** Fault tolerance default delay. */ - protected val DELAY: Long = 2000 + protected val delay: Long = 2000 /** Business logic of the system. */ @Inject @@ -66,19 +64,21 @@ class TwoFactorAuth { @Retry(maxRetries = 1, delay = 2000) fun generateQRCode( @RestForm @NotEmpty @Email email: String, - @RestForm @NotEmpty password: String - ): Uni { - return controller.generate2FAQRCode(email, password) - .onItem().transform { qrCodeBytes -> - Response.ok(qrCodeBytes) + @RestForm @NotEmpty password: String, + ): Uni = + controller + .generate2FAQRCode(email, password) + .onItem() + .transform { qrCodeBytes -> + Response + .ok(qrCodeBytes) .type("image/png") .build() - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Failed to generate QR code" ServiceException(message, Response.Status.BAD_REQUEST) } - } /** * Validates a TOTP code for 2FA authentication. @@ -96,16 +96,16 @@ class TwoFactorAuth { @Retry(maxRetries = 1, delay = 2000) fun validateCode( @RestForm @NotEmpty @Email email: String, - @RestForm @NotEmpty code: String - ): Uni { - return controller.validate2FACode(email, code) - .onItem().transform { response -> + @RestForm @NotEmpty code: String, + ): Uni = + controller + .validate2FACode(email, code) + .onItem() + .transform { response -> Response.ok(response).build() - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Invalid TOTP code" ServiceException(message, Response.Status.UNAUTHORIZED) } - } } - diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/WebAuthnWS.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/WebAuthnWS.kt index d2d52ca..c2c5e47 100644 --- a/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/WebAuthnWS.kt +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/WebAuthnWS.kt @@ -17,7 +17,6 @@ package dev.orion.users.frameworks.rest.authentication import dev.orion.users.adapters.controllers.UserController -import dev.orion.users.adapters.presenters.LoginResponseDTO import dev.orion.users.frameworks.rest.ServiceException import io.quarkus.hibernate.reactive.panache.common.WithSession import io.smallrye.mutiny.Uni @@ -43,9 +42,8 @@ import org.jboss.resteasy.reactive.RestForm @Produces(MediaType.APPLICATION_JSON) @WithSession class WebAuthnWS { - /** Fault tolerance default delay. */ - protected val DELAY: Long = 2000 + protected val delay: Long = 2000 /** Business logic of the system. */ @Inject @@ -66,17 +64,18 @@ class WebAuthnWS { @Retry(maxRetries = 1, delay = 2000) fun startRegistration( @RestForm @NotEmpty @Email email: String, - @RestForm origin: String? - ): Uni { - return controller.startWebAuthnRegistration(email, origin) - .onItem().transform { optionsJson -> + @RestForm origin: String?, + ): Uni = + controller + .startWebAuthnRegistration(email, origin) + .onItem() + .transform { optionsJson -> Response.ok(optionsJson).build() - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Failed to start WebAuthn registration" ServiceException(message, Response.Status.BAD_REQUEST) } - } /** * Finishes the WebAuthn registration process. @@ -98,18 +97,19 @@ class WebAuthnWS { @RestForm @NotEmpty @Email email: String, @RestForm @NotEmpty response: String, @RestForm @NotEmpty origin: String, - @RestForm deviceName: String? - ): Uni { - return controller.finishWebAuthnRegistration(email, response, origin, deviceName) - .onItem().transform { success -> + @RestForm deviceName: String?, + ): Uni = + controller + .finishWebAuthnRegistration(email, response, origin, deviceName) + .onItem() + .transform { success -> val result = mapOf("success" to success, "message" to "WebAuthn credential registered successfully") Response.ok(result).build() - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Failed to finish WebAuthn registration" ServiceException(message, Response.Status.BAD_REQUEST) } - } /** * Starts the WebAuthn authentication process. @@ -125,17 +125,18 @@ class WebAuthnWS { @Produces(MediaType.APPLICATION_JSON) @Retry(maxRetries = 1, delay = 2000) fun startAuthentication( - @RestForm @NotEmpty @Email email: String - ): Uni { - return controller.startWebAuthnAuthentication(email) - .onItem().transform { optionsJson -> + @RestForm @NotEmpty @Email email: String, + ): Uni = + controller + .startWebAuthnAuthentication(email) + .onItem() + .transform { optionsJson -> Response.ok(optionsJson).build() - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Failed to start WebAuthn authentication" ServiceException(message, Response.Status.BAD_REQUEST) } - } /** * Finishes the WebAuthn authentication process. @@ -153,16 +154,16 @@ class WebAuthnWS { @Retry(maxRetries = 1, delay = 2000) fun finishAuthentication( @RestForm @NotEmpty @Email email: String, - @RestForm @NotEmpty response: String - ): Uni { - return controller.finishWebAuthnAuthentication(email, response) - .onItem().transform { loginResponse -> + @RestForm @NotEmpty response: String, + ): Uni = + controller + .finishWebAuthnAuthentication(email, response) + .onItem() + .transform { loginResponse -> Response.ok(loginResponse).build() - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Invalid WebAuthn authentication" ServiceException(message, Response.Status.UNAUTHORIZED) } - } } - diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/users/UserWS.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/users/UserWS.kt index 7427d47..1ac9514 100644 --- a/src/main/kotlin/dev/orion/users/frameworks/rest/users/UserWS.kt +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/users/UserWS.kt @@ -30,7 +30,6 @@ import jakarta.ws.rs.GET import jakarta.ws.rs.POST import jakarta.ws.rs.PUT import jakarta.ws.rs.Path -import jakarta.ws.rs.PathParam import jakarta.ws.rs.Produces import jakarta.ws.rs.QueryParam import jakarta.ws.rs.core.MediaType @@ -47,7 +46,6 @@ import org.jboss.resteasy.reactive.RestForm @Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Produces(MediaType.APPLICATION_JSON) class UserWS { - /** Business logic of the system. */ @Inject lateinit var controller: UserController @@ -57,7 +55,7 @@ class UserWS { lateinit var jwt: JsonWebToken /** Fault tolerance default delay. */ - protected val DELAY: Long = 2000 + protected val delay: Long = 2000 /** * Creates a user inside the service. @@ -75,16 +73,19 @@ class UserWS { fun create( @FormParam("name") @NotEmpty name: String, @FormParam("email") @NotEmpty @Email email: String, - @FormParam("password") @NotEmpty password: String - ): Uni { - return controller.createUser(name, email, password) + @FormParam("password") @NotEmpty password: String, + ): Uni = + controller + .createUser(name, email, password) .log() - .onItem().ifNotNull().transform { user -> Response.ok(user).build() } - .onFailure().transform { e -> + .onItem() + .ifNotNull() + .transform { user -> Response.ok(user).build() } + .onFailure() + .transform { e -> val message = e.message ?: "Unknown error" throw ServiceException(message, Response.Status.BAD_REQUEST) } - } /** * Deletes a user inside the service. @@ -100,18 +101,20 @@ class UserWS { @RolesAllowed("admin") @Retry(maxRetries = 1, delay = 2000) fun delete( - @FormParam("email") @NotEmpty @Email email: String - ): Uni { - return controller.deleteUser(email) + @FormParam("email") @NotEmpty @Email email: String, + ): Uni = + controller + .deleteUser(email) .log() - .onItem().ifNotNull().transform { result -> + .onItem() + .ifNotNull() + .transform { result -> Response.ok(true).build() - } - .onFailure().transform { e -> + }.onFailure() + .transform { e -> val message = e.message ?: "Unknown error" throw ServiceException(message, Response.Status.BAD_REQUEST) } - } /** * Updates user information (name, email and/or password). Requires authentication via JWT token with role "user". @@ -136,34 +139,36 @@ class UserWS { @RestForm name: String?, @RestForm newEmail: String?, @RestForm password: String?, - @RestForm newPassword: String? + @RestForm newPassword: String?, ): Uni { // Extract email from JWT token - val jwtEmail = jwt.getClaim(Claims.email.name) - ?: jwt.getClaim("email") - ?: throw ServiceException( - "Invalid token", - Response.Status.UNAUTHORIZED - ) + val jwtEmail = + jwt.getClaim(Claims.email.name) + ?: jwt.getClaim("email") + ?: throw ServiceException( + "Invalid token", + Response.Status.UNAUTHORIZED, + ) // Extract groups/roles from JWT token - val groups: Set = try { - jwt.getClaim>(Claims.groups.name) - ?: jwt.getClaim>("groups")?.toSet() - ?: emptySet() - } catch (e: Exception) { - emptySet() - } - + val groups: Set = + try { + jwt.getClaim>(Claims.groups.name) + ?: jwt.getClaim>("groups")?.toSet() + ?: emptySet() + } catch (e: Exception) { + emptySet() + } + // Verifica se é admin (admins também têm role "user") val isAdmin = groups.contains("admin") - + // Se não for admin, só pode atualizar seu próprio usuário // Se for admin, pode atualizar qualquer usuário if (!isAdmin && email != jwtEmail) { throw ServiceException( "You can only update your own user", - Response.Status.FORBIDDEN + Response.Status.FORBIDDEN, ) } @@ -173,15 +178,26 @@ class UserWS { val normalizedPassword = if (password.isNullOrBlank()) null else password val normalizedNewPassword = if (newPassword.isNullOrBlank()) null else newPassword - return controller.updateUser(email, normalizedName, normalizedNewEmail, normalizedPassword, normalizedNewPassword, jwtEmail, isAdmin) - .onItem().transform { response -> Response.ok(response).build() } - .onFailure().transform { e -> + return controller + .updateUser( + email, + normalizedName, + normalizedNewEmail, + normalizedPassword, + normalizedNewPassword, + jwtEmail, + isAdmin, + ).onItem() + .transform { response -> Response.ok(response).build() } + .onFailure() + .transform { e -> val message = e.message ?: "Unknown error" - val status = if (message.contains("Unauthorized") || message.contains("token")) { - Response.Status.UNAUTHORIZED - } else { - Response.Status.BAD_REQUEST - } + val status = + if (message.contains("Unauthorized") || message.contains("token")) { + Response.Status.UNAUTHORIZED + } else { + Response.Status.BAD_REQUEST + } throw ServiceException(message, status) } } @@ -205,29 +221,34 @@ class UserWS { fun update2FASettings( @RestForm @NotEmpty @Email email: String, @RestForm require2FAForBasicLogin: Boolean?, - @RestForm require2FAForSocialLogin: Boolean? + @RestForm require2FAForSocialLogin: Boolean?, ): Uni { // Extract email from JWT token - val jwtEmail = jwt.getClaim(Claims.email.name) - ?: jwt.getClaim("email") - ?: throw ServiceException( - "Invalid token", - Response.Status.UNAUTHORIZED - ) + val jwtEmail = + jwt.getClaim(Claims.email.name) + ?: jwt.getClaim("email") + ?: throw ServiceException( + "Invalid token", + Response.Status.UNAUTHORIZED, + ) // Use provided values or default to false val requireBasic = require2FAForBasicLogin ?: false val requireSocial = require2FAForSocialLogin ?: false - return controller.update2FASettings(email, requireBasic, requireSocial, jwtEmail) - .onItem().transform { user -> Response.ok(user).build() } - .onFailure().transform { e -> + return controller + .update2FASettings(email, requireBasic, requireSocial, jwtEmail) + .onItem() + .transform { user -> Response.ok(user).build() } + .onFailure() + .transform { e -> val message = e.message ?: "Unknown error" - val status = if (message.contains("Unauthorized") || message.contains("token")) { - Response.Status.UNAUTHORIZED - } else { - Response.Status.BAD_REQUEST - } + val status = + if (message.contains("Unauthorized") || message.contains("token")) { + Response.Status.UNAUTHORIZED + } else { + Response.Status.BAD_REQUEST + } throw ServiceException(message, status) } } @@ -243,19 +264,22 @@ class UserWS { @RolesAllowed("admin") @Produces(MediaType.APPLICATION_JSON) @Retry(maxRetries = 1, delay = 2000) - fun listUsers(): Uni { - return controller.listAllUsers() - .onItem().transform { users -> Response.ok(users).build() } - .onFailure().transform { e -> + fun listUsers(): Uni = + controller + .listAllUsers() + .onItem() + .transform { users -> Response.ok(users).build() } + .onFailure() + .transform { e -> val message = e.message ?: "Unknown error" - val status = if (message.contains("Unauthorized") || message.contains("token")) { - Response.Status.UNAUTHORIZED - } else { - Response.Status.INTERNAL_SERVER_ERROR - } + val status = + if (message.contains("Unauthorized") || message.contains("token")) { + Response.Status.UNAUTHORIZED + } else { + Response.Status.INTERNAL_SERVER_ERROR + } throw ServiceException(message, status) } - } /** * Gets a user by email. Requires admin role. @@ -271,19 +295,21 @@ class UserWS { @Produces(MediaType.APPLICATION_JSON) @Retry(maxRetries = 1, delay = 2000) fun getUserByEmail( - @QueryParam("email") @NotEmpty @Email email: String - ): Uni { - return controller.getUserByEmail(email) - .onItem().transform { user -> Response.ok(user).build() } - .onFailure().transform { e -> + @QueryParam("email") @NotEmpty @Email email: String, + ): Uni = + controller + .getUserByEmail(email) + .onItem() + .transform { user -> Response.ok(user).build() } + .onFailure() + .transform { e -> val message = e.message ?: "Unknown error" - val status = when { - message.contains("not found") -> Response.Status.NOT_FOUND - message.contains("Unauthorized") || message.contains("token") -> Response.Status.UNAUTHORIZED - else -> Response.Status.BAD_REQUEST - } + val status = + when { + message.contains("not found") -> Response.Status.NOT_FOUND + message.contains("Unauthorized") || message.contains("token") -> Response.Status.UNAUTHORIZED + else -> Response.Status.BAD_REQUEST + } throw ServiceException(message, status) } - } } - diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f8fa9c0..c65d34d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,25 +5,20 @@ quarkus.datasource.devservices.port=5432 quarkus.hibernate-orm.schema-management.strategy=drop-and-create quarkus.datasource.username=orion quarkus.datasource.password=orion - #Edit the datasource for native compilation %prod.quarkus.datasource.reactive.url=postgresql://address:port/database quarkus.hibernate-orm.sql-load-script=import.sql - #JWT Build - -users.issuer = orion-users -mp.jwt.verify.clock.skew = 604800 +users.issuer=orion-users +mp.jwt.verify.clock.skew=604800 # Configuration to sign the token smallrye.jwt.sign.key.location=privateKey.pem # smallrye.jwt.encrypt.key.location=publicKey.pem - # JWT validation -mp.jwt.verify.issuer = orion-users +mp.jwt.verify.issuer=orion-users # Configuration to sign the token mp.jwt.verify.publickey.location=publicKey.pem # mp.jwt.decrypt.key.location=privateKey.pem - # HTTPS %prod.quarkus.ssl.native=true %dev.quarkus.ssl.native=true @@ -34,14 +29,6 @@ mp.jwt.verify.publickey.location=publicKey.pem quarkus.http.ssl-port=8443 quarkus.http.ssl.certificate.key-store-file=keystore.jks quarkus.http.ssl.certificate.key-store-password=password - -#CORS - Desabilitado aqui porque usamos filtros CORS manuais -#%dev.quarkus.http.cors=true -#%dev.quarkus.http.cors.origins=http://localhost:3000 -#%dev.quarkus.http.cors.headers=accept,authorization,content-type,x-requested-with -#%dev.quarkus.http.cors.methods=GET,POST,PUT,DELETE,OPTIONS,HEAD,PATCH -#%dev.quarkus.http.cors.credentials=true - #SMTP quarkus.mailer.auth-methods=DIGEST-MD5 CRAM-SHA256 CRAM-SHA1 CRAM-MD5 PLAIN LOGIN quarkus.mailer.from=devoriontest@gmail.com @@ -53,22 +40,17 @@ quarkus.mailer.username=devoriontest@gmail.com quarkus.mailer.password=${QUARKUS_MAILER_PASSWORD:} %dev.quarkus.mailer.mock=false %test.quarkus.mailer.mock=true - # Email validation users.email.validation.url=http://localhost:8080/users/validateEmail - # Google Openid Provider (definir QUARKUS_OIDC_* / SOCIAL_AUTH_GOOGLE_CLIENT_ID no ambiente) quarkus.oidc.enabled=false quarkus.oidc.provider=GOOGLE quarkus.oidc.client-id=${QUARKUS_OIDC_CLIENT_ID:} quarkus.oidc.credentials.secret=${QUARKUS_OIDC_CREDENTIALS_SECRET:} quarkus.oidc.token.allow-opaque-token-introspection=true - # Social Auth Configuration # Google OAuth2 Client ID (for token validation) social.auth.google.client-id=${SOCIAL_AUTH_GOOGLE_CLIENT_ID:} - quarkus.log.level=INFO - #Swagger %dev.quarkus.swagger-ui.always-include=true diff --git a/src/test/kotlin/dev/orion/users/rest/UsersIT.kt b/src/test/kotlin/dev/orion/users/rest/UsersIT.kt index 9760035..ea65f18 100644 --- a/src/test/kotlin/dev/orion/users/rest/UsersIT.kt +++ b/src/test/kotlin/dev/orion/users/rest/UsersIT.kt @@ -16,46 +16,44 @@ */ package dev.orion.users.rest +import io.quarkus.test.junit.QuarkusTest import io.restassured.RestAssured.given +import io.restassured.response.ValidatableResponse import org.hamcrest.CoreMatchers.`is` import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Order import org.junit.jupiter.api.Test -import io.quarkus.test.junit.QuarkusTest -import io.restassured.response.ValidatableResponse - /** * This class contains test cases for the Users REST API. */ @QuarkusTest class UsersIT { - /** * Represents the HTTP status code for a successful request. */ - private val OK = 200 + private val ok = 200 /** * The HTTP status code for a bad request. */ - private val BAD_REQUEST = 400 + private val badRequest = 400 /** * The HTTP status code for an unauthorized request. */ - private val UNAUTHORIZED = 401 + private val unauthorized = 401 /** * Test case for creating a user. */ - private val NAME = "Orion" - private val EMAIL = "orion@test.com" - private val PASSWORD = "12345678" + private val name = "Orion" + private val email = "orion@test.com" + private val password = "12345678" - private val PARAM_NAME = "name" - private val PARAM_EMAIL = "email" - private val PARAM_PASSWORD = "password" + private val paramName = "name" + private val paramEmail = "email" + private val paramPassword = "password" /** * Test case for creating a user. @@ -63,15 +61,21 @@ class UsersIT { @Test @Order(1) fun createUser() { - val response: ValidatableResponse = given().`when`() - .param(PARAM_NAME, NAME) - .param(PARAM_EMAIL, EMAIL) - .param(PARAM_PASSWORD, PASSWORD) - .post("/users/create") - .then() - .statusCode(OK) - .body(PARAM_NAME, `is`(NAME), - PARAM_EMAIL, `is`(EMAIL)) + val response: ValidatableResponse = + given() + .`when`() + .param(PARAM_NAME, NAME) + .param(PARAM_EMAIL, EMAIL) + .param(PARAM_PASSWORD, PASSWORD) + .post("/users/create") + .then() + .statusCode(OK) + .body( + PARAM_NAME, + `is`(NAME), + PARAM_EMAIL, + `is`(EMAIL), + ) assertEquals(OK, response.extract().statusCode()) } @@ -82,13 +86,15 @@ class UsersIT { @Test @Order(2) fun createUserWithWrongPassword() { - val response: ValidatableResponse = given().`when`() - .param(PARAM_NAME, NAME) - .param(PARAM_EMAIL, EMAIL) - .param(PARAM_PASSWORD, "123") - .post("/users/create") - .then() - .statusCode(BAD_REQUEST) + val response: ValidatableResponse = + given() + .`when`() + .param(PARAM_NAME, NAME) + .param(PARAM_EMAIL, EMAIL) + .param(PARAM_PASSWORD, "123") + .post("/users/create") + .then() + .statusCode(BAD_REQUEST) assertEquals(BAD_REQUEST, response.extract().statusCode()) } @@ -103,21 +109,30 @@ class UsersIT { @Test @Order(3) fun login() { - given().`when`() + given() + .`when`() .param(PARAM_NAME, NAME) .param(PARAM_EMAIL, EMAIL) .param(PARAM_PASSWORD, PASSWORD) .post("/users/create") - val response: ValidatableResponse = given().`when`() - .param(PARAM_EMAIL, EMAIL) - .param(PARAM_PASSWORD, PASSWORD) - .post("/users/login") - .then() - .statusCode(OK) - - assertEquals(NAME, response.extract() - .body().jsonPath().getString("user.name")) + val response: ValidatableResponse = + given() + .`when`() + .param(PARAM_EMAIL, EMAIL) + .param(PARAM_PASSWORD, PASSWORD) + .post("/users/login") + .then() + .statusCode(OK) + + assertEquals( + NAME, + response + .extract() + .body() + .jsonPath() + .getString("user.name"), + ) } /** @@ -128,18 +143,21 @@ class UsersIT { @Test @Order(4) fun loginWithWrongPassword() { - given().`when`() + given() + .`when`() .param(PARAM_NAME, NAME) .param(PARAM_EMAIL, EMAIL) .param(PARAM_PASSWORD, PASSWORD) .post("/users/create") - val response: ValidatableResponse = given().`when`() - .param(PARAM_EMAIL, EMAIL) - .param(PARAM_PASSWORD, "123") - .post("/users/login") - .then() - .statusCode(UNAUTHORIZED) + val response: ValidatableResponse = + given() + .`when`() + .param(PARAM_EMAIL, EMAIL) + .param(PARAM_PASSWORD, "123") + .post("/users/login") + .then() + .statusCode(UNAUTHORIZED) assertEquals(UNAUTHORIZED, response.extract().statusCode()) } @@ -155,17 +173,20 @@ class UsersIT { @Test @Order(5) fun loginWithoutPassword() { - given().`when`() + given() + .`when`() .param(PARAM_NAME, NAME) .param(PARAM_EMAIL, EMAIL) .param(PARAM_PASSWORD, PASSWORD) .post("/users/create") - val response: ValidatableResponse = given().`when`() - .param(PARAM_PASSWORD, PASSWORD) - .post("/users/login") - .then() - .statusCode(BAD_REQUEST) + val response: ValidatableResponse = + given() + .`when`() + .param(PARAM_PASSWORD, PASSWORD) + .post("/users/login") + .then() + .statusCode(BAD_REQUEST) assertEquals(BAD_REQUEST, response.extract().statusCode()) } @@ -181,14 +202,15 @@ class UsersIT { @Test @Order(6) fun loginWithNonexistentUser() { - val response: ValidatableResponse = given().`when`() - .param(EMAIL, "nonexistent@orion-services.dev") - .param(PARAM_PASSWORD, PASSWORD) - .post("/users/login") - .then() - .statusCode(BAD_REQUEST) + val response: ValidatableResponse = + given() + .`when`() + .param(EMAIL, "nonexistent@orion-services.dev") + .param(PARAM_PASSWORD, PASSWORD) + .post("/users/login") + .then() + .statusCode(BAD_REQUEST) assertEquals(BAD_REQUEST, response.extract().statusCode()) } } - diff --git a/src/test/kotlin/dev/orion/users/usecases/AuthenticateUCTest.kt b/src/test/kotlin/dev/orion/users/usecases/AuthenticateUCTest.kt index 83075c4..b6917c0 100644 --- a/src/test/kotlin/dev/orion/users/usecases/AuthenticateUCTest.kt +++ b/src/test/kotlin/dev/orion/users/usecases/AuthenticateUCTest.kt @@ -16,21 +16,19 @@ */ package dev.orion.users.usecases -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Order -import org.junit.jupiter.api.Test - import dev.orion.users.application.port.`in`.AuthenticateUCI import dev.orion.users.application.usecases.AuthenticateUC import dev.orion.users.domain.model.User import io.smallrye.common.constraint.Assert +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test /** * This class contains unit tests for the CreateUserUC class. */ class AuthenticateUCTest { - /** Use cases */ private val uc: AuthenticateUCI = AuthenticateUC() @@ -77,4 +75,3 @@ class AuthenticateUCTest { } } } - diff --git a/src/test/kotlin/dev/orion/users/usecases/CreateUserUCTest.kt b/src/test/kotlin/dev/orion/users/usecases/CreateUserUCTest.kt index 5213d90..3799c63 100644 --- a/src/test/kotlin/dev/orion/users/usecases/CreateUserUCTest.kt +++ b/src/test/kotlin/dev/orion/users/usecases/CreateUserUCTest.kt @@ -16,21 +16,19 @@ */ package dev.orion.users.usecases -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Order -import org.junit.jupiter.api.Test - import dev.orion.users.application.port.`in`.CreateUserUCI import dev.orion.users.application.usecases.CreateUserUC import dev.orion.users.domain.model.User import io.smallrye.common.constraint.Assert +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test /** * This class contains unit tests for the CreateUserUC class. */ class CreateUserUCTest { - /** Use cases */ private val uc: CreateUserUCI = CreateUserUC() @@ -93,4 +91,3 @@ class CreateUserUCTest { } } } -