diff --git a/CHANGELOG.md b/CHANGELOG.md index 176de57..78bb284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,20 @@ # Users change Log +## 0.0.6 + +- LoginResponseDTO as a standard response object from all endpoints +- + ## 0.0.5 - Port to Kotlin +- webauthn (experimental) ## 0.0.4 - - Clean Architecture - - Updated to Quarkus 3 - - Updated to Java 20 Temurin +- Clean Architecture +- Updated to Quarkus 3 +- Updated to Java 20 Temurin ## 0.0.3 diff --git a/docs/usecases/Autenticate/Authenticate.md b/docs/usecases/Autenticate/Authenticate.md deleted file mode 100644 index ba6ad36..0000000 --- a/docs/usecases/Autenticate/Authenticate.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -layout: default -title: Authenticate -parent: Use Cases -nav_order: 1 ---- - -## Authenticate - -This use case is responsible for authenticate a user in the system. - -### Normal flow - -* A client sends a e-mail and password. -* The service validates the input data and verifies if the users exists in the - system. If the users exists, the service returns a JSON with the user data - and a signed JWT. - -## HTTPS endpoints - -* /users/login - * Method: POST - * Consumes: application/x-www-form-urlencoded - * Produces: application/json - -* Request: - -```shell -curl -X POST \ - 'http://localhost:8080/users/login' \ - --header 'Accept: */*' \ - --header 'User-Agent: Thunder Client (https://www.thunderclient.com)' \ - --header 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'email=orion@services.dev' \ - --data-urlencode 'password=12345678' -``` - -* Response: - -```json -{ -"user": { - "hash": "53012a1a-b8ec-40f4-a81e-bc8b97ddab75", - "name": "Orion", - "email": "orion@services.dev", - "emailValid": false, - "secret2FA": null, - "using2FA": false -}, -"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJvcmlvbi11c2VycyIsInVwbiI6Im9yaW9uQHNlcnZpY2VzLmRldiIsImdyb3VwcyI6WyJ1c2VyIl0sImNfaGFzaCI6IjUzMDEyYTFhLWI4ZWMtNDBmNC1hODFlLWJjOGI5N2RkYWI3NSIsImVtYWlsIjoib3Jpb25Ac2VydmljZXMuZGV2IiwiaWF0IjoxNzE1Mzk0NzA0LCJleHAiOjE3MTUzOTUwMDQsImp0aSI6ImMzYjZkZmFkLTAyMDAtNDc3YS05MDJmLTU0ZDg5YjdiMTUzYyJ9.I93SpcxIm31wfMQeiFLuUuuWuwlG-C0aGascSEDseRueILn9Tf5shEyNDMLQr6QRNhQbNjRjnCwe_quenVfjBEF_BLgtDDq7maoqpzDdrnDoKxtxex0dIXmRg2ABZoktB-jBo8yJcflandp1FUe7hG1VduE2E8D6WqvUQiNrhhCiiEZ4d5Moc1H11S3YGg3X1U-QnWUGx70FYQG4Qo-1Ini7T6miC0xCxSJRxumXKKtBRLYMDizp5qPIVoVIatJUu4WgoVZWliStmE7wBu6X_La7z4rAddgIlGRiqLZPkaSruzO2PP3i_T1Ezupcw9ol6LP_nlPaOQHeAjJ7aSQMyA" -} -``` - -## Social Authentication - -The system also supports authentication via social providers (Google). - -### Google Login - -* Endpoint: `/users/login/google` -* Method: POST -* Consumes: application/x-www-form-urlencoded -* Produces: application/json - -* Request: - -```shell -curl -X POST \ - 'http://localhost:8080/users/login/google' \ - --header 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'idToken=GOOGLE_ID_TOKEN' -``` - -* Response: Same as normal login - AuthenticationDTO with user and token. - -### Social Authentication Flow - -1. User clicks "Login with Google" in the frontend -2. Frontend initiates OAuth2 flow with the provider -3. Provider returns an ID token (JWT) -4. Frontend sends the ID token to the backend endpoint -5. Backend validates the token and extracts user information (email, name) -6. Backend searches for user by email -7. If user doesn't exist, backend creates it automatically -8. Backend generates a JWT token for the system -9. Backend returns AuthenticationDTO with user and token - -## Exceptions - -RESTful Web Service layer will return a HTTP 401 (Unauthorized) if the user -does not exist or the password is incorrect. If the request is invalid, for -example, without the required parameters, the service will return a HTTP 400 -(Bad Request). \ No newline at end of file diff --git a/docs/usecases/Autenticate/login.md b/docs/usecases/Autenticate/login.md new file mode 100644 index 0000000..2bc9fda --- /dev/null +++ b/docs/usecases/Autenticate/login.md @@ -0,0 +1,137 @@ +--- +layout: default +title: Login +parent: Use Cases +nav_order: 1 +--- + +## Login + +This use case is responsible for authenticating a user in the system. The endpoint returns a `LoginResponseDTO` that may contain authentication credentials or indicate that two-factor authentication (2FA) is required. + +### Normal flow + +* A client sends an e-mail and password. +* The service validates the input data and verifies if the user exists in the system. +* If the user exists and has 2FA enabled with `require2FAForBasicLogin` set to `true`, the service returns a `LoginResponseDTO` indicating that 2FA is required. +* If the user exists and 2FA is not required, the service returns a `LoginResponseDTO` containing the user data and a signed JWT token. + +### LoginResponseDTO + +The `/users/login` endpoint returns a `LoginResponseDTO` object that can represent different authentication states: + +**Structure:** + +```json +{ + "authentication": { + "user": { + "hash": "string", + "name": "string", + "email": "string", + "emailValid": boolean, + "secret2FA": "string | null", + "using2FA": boolean + }, + "token": "string (JWT)" + }, + "requires2FA": boolean, + "message": "string | null" +} +``` + +**Fields:** + +- `authentication` (AuthenticationDTO | null): Contains the user data and JWT token when login is successful. This field is `null` when 2FA is required. +- `requires2FA` (boolean): Indicates whether two-factor authentication is required to complete the login process. +- `message` (string | null): Optional message providing additional information, typically "2FA code required" when 2FA is needed. + +**AuthenticationDTO Structure:** + +When `authentication` is present, it contains: + +- `user` (UserEntity): The authenticated user object containing: + - `hash`: Unique identifier for the user + - `name`: User's display name + - `email`: User's email address + - `emailValid`: Whether the email has been validated + - `secret2FA`: The 2FA secret (null if not configured) + - `using2FA`: Whether 2FA is enabled for this user +- `token` (string): A signed JWT token that can be used for authenticated requests + +## HTTPS endpoints + +* /users/login + * Method: POST + * Consumes: application/x-www-form-urlencoded + * Produces: application/json + +### Request Example + +```shell +curl -X POST \ + 'http://localhost:8080/users/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=orion@services.dev' \ + --data-urlencode 'password=12345678' +``` + +### Response Examples + +#### Example 1: Successful Login (without 2FA requirement) + +When the user exists and 2FA is not required, the response contains the authentication data: + +```json +{ + "authentication": { + "user": { + "hash": "53012a1a-b8ec-40f4-a81e-bc8b97ddab75", + "name": "Orion", + "email": "orion@services.dev", + "emailValid": false, + "secret2FA": null, + "using2FA": false + }, + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJvcmlvbi11c2VycyIsInVwbiI6Im9yaW9uQHNlcnZpY2VzLmRldiIsImdyb3VwcyI6WyJ1c2VyIl0sImNfaGFzaCI6IjUzMDEyYTFhLWI4ZWMtNDBmNC1hODFlLWJjOGI5N2RkYWI3NSIsImVtYWlsIjoib3Jpb25Ac2VydmljZXMuZGV2IiwiaWF0IjoxNzE1Mzk0NzA0LCJleHAiOjE3MTUzOTUwMDQsImp0aSI6ImMzYjZkZmFkLTAyMDAtNDc3YS05MDJmLTU0ZDg5YjdiMTUzYyJ9.I93SpcxIm31wfMQeiFLuUuuWuwlG-C0aGascSEDseRueILn9Tf5shEyNDMLQr6QRNhQbNjRjnCwe_quenVfjBEF_BLgtDDq7maoqpzDdrnDoKxtxex0dIXmRg2ABZoktB-jBo8yJcflandp1FUe7hG1VduE2E8D6WqvUQiNrhhCiiEZ4d5Moc1H11S3YGg3X1U-QnWUGx70FYQG4Qo-1Ini7T6miC0xCxSJRxumXKKtBRLYMDizp5qPIVoVIatJUu4WgoVZWliStmE7wBu6X_La7z4rAddgIlGRiqLZPkaSruzO2PP3i_T1Ezupcw9ol6LP_nlPaOQHeAjJ7aSQMyA" + }, + "requires2FA": false, + "message": null +} +``` + +#### Example 2: Login Requiring 2FA + +When the user has 2FA enabled and `require2FAForBasicLogin` is set to `true`, the response indicates that a 2FA code is required: + +```json +{ + "authentication": null, + "requires2FA": true, + "message": "2FA code required" +} +``` + +In this case, the client should: +1. Extract the 2FA code from the user (typically from an authenticator app) +2. Make a subsequent request to `/users/login/2fa` with the email and 2FA code +3. The 2FA endpoint will return a complete `LoginResponseDTO` with authentication data upon successful validation + +### Handling the Response + +**When `requires2FA` is `false`:** +- Use the `token` from `authentication.token` for subsequent authenticated requests +- Store the token securely (e.g., in localStorage or httpOnly cookie) +- Include the token in the `Authorization` header as `Bearer {token}` + +**When `requires2FA` is `true`:** +- Prompt the user for their 2FA code +- Make a POST request to `/users/login/2fa` with: + - `email`: The user's email + - `code`: The 2FA code from the authenticator app +- The 2FA endpoint will return a `LoginResponseDTO` with `authentication` populated upon success + +## Exceptions + +RESTful Web Service layer will return a HTTP 401 (Unauthorized) if the user does not exist or the password is incorrect. If the request is invalid, for example, without the required parameters, the service will return a HTTP 400 (Bad Request). + diff --git a/docs/usecases/UseCases.puml b/docs/usecases/UseCases.puml index afb3a42..8da7557 100644 --- a/docs/usecases/UseCases.puml +++ b/docs/usecases/UseCases.puml @@ -4,7 +4,7 @@ left to right direction actor "Client" as client rectangle Users{ - usecase "Authenticate" as UC1 + usecase "Login" as UC1 usecase "Create and Authenticate" as UC2 usecase "Create User" as UC3 usecase "Validate E-mail" as UC4 diff --git a/pom.xml b/pom.xml index 636d176..12eea31 100755 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 dev.orion users - 0.0.5 + 0.0.6 3.12.1 21 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 e5c1429..8ab0a4b 100644 --- a/src/main/kotlin/dev/orion/users/adapters/controllers/UserController.kt +++ b/src/main/kotlin/dev/orion/users/adapters/controllers/UserController.kt @@ -175,20 +175,25 @@ class UserController : BasicController() { /** * Creates a user, generates a Json Web Token and returns a - * AuthenticationDTO object. + * LoginResponseDTO object. * * @param name : The user name * @param email : The user e-mail * @param password : The user password - * @return A Uni object + * @return A Uni object */ - fun createAuthenticate(name: String, email: String, password: String): Uni { + fun createAuthenticate(name: String, email: String, password: String): Uni { return this.createUser(name, email, password) .onItem().ifNotNull().transform { user -> - val dto = AuthenticationDTO() - dto.token = this.generateJWT(user) - dto.user = user - dto + val authDto = AuthenticationDTO() + authDto.token = this.generateJWT(user) + authDto.user = user + + val response = LoginResponseDTO() + response.authentication = authDto + response.requires2FA = false + + response } } @@ -298,9 +303,9 @@ class UserController : BasicController() { * * @param email The email of the user * @param code The TOTP code to validate - * @return A Uni that emits an AuthenticationDTO with JWT if validation succeeds + * @return A Uni that emits a LoginResponseDTO with JWT if validation succeeds */ - fun validateSocialLogin2FA(email: String, code: String): Uni { + fun validateSocialLogin2FA(email: String, code: String): Uni { // Validate code format using use case val user: User = twoFactorAuthUC.validateCode(email, code) @@ -332,10 +337,15 @@ class UserController : BasicController() { } // Generate JWT and return DTO - val dto = AuthenticationDTO() - dto.token = generateJWT(userEntity) - dto.user = userEntity - Uni.createFrom().item(dto) + val authDto = AuthenticationDTO() + authDto.token = generateJWT(userEntity) + authDto.user = userEntity + + val response = LoginResponseDTO() + response.authentication = authDto + response.requires2FA = false + + Uni.createFrom().item(response) } } @@ -344,9 +354,9 @@ class UserController : BasicController() { * * @param email The email of the user * @param code The TOTP code to validate - * @return A Uni that emits an AuthenticationDTO with JWT if validation succeeds + * @return A Uni that emits a LoginResponseDTO with JWT if validation succeeds */ - fun validate2FACode(email: String, code: String): Uni { + fun validate2FACode(email: String, code: String): Uni { // Validate code format using use case val user: User = twoFactorAuthUC.validateCode(email, code) @@ -373,10 +383,15 @@ class UserController : BasicController() { } // Generate JWT and return DTO - val dto = AuthenticationDTO() - dto.token = generateJWT(userEntity) - dto.user = userEntity - Uni.createFrom().item(dto) + val authDto = AuthenticationDTO() + authDto.token = generateJWT(userEntity) + authDto.user = userEntity + + val response = LoginResponseDTO() + response.authentication = authDto + response.requires2FA = false + + Uni.createFrom().item(response) } } @@ -540,9 +555,9 @@ class UserController : BasicController() { * * @param email The email of the user * @param response The authentication response from the client (JSON string) - * @return An AuthenticationDTO with JWT if authentication succeeds + * @return A LoginResponseDTO with JWT if authentication succeeds */ - fun finishWebAuthnAuthentication(email: String, response: String): Uni { + fun finishWebAuthnAuthentication(email: String, response: String): Uni { // Validate using use case webAuthnUC.finishAuthentication(email, response) @@ -566,10 +581,15 @@ class UserController : BasicController() { webAuthnCredentialRepository.saveCredential(credential) // Generate JWT and return DTO - val dto = AuthenticationDTO() - dto.token = generateJWT(user) - dto.user = user - dto + val authDto = AuthenticationDTO() + authDto.token = generateJWT(user) + authDto.user = user + + val loginResponse = LoginResponseDTO() + loginResponse.authentication = authDto + loginResponse.requires2FA = false + + loginResponse } } } 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 4b77a0b..257f94c 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 @@ -103,13 +103,8 @@ class AuthenticationWS { return controller.login(email, password) .onItem().ifNotNull() .transform { response -> - if (response.requires2FA) { - // Return 200 OK but indicate 2FA is required - Response.ok(response).status(Response.Status.OK).build() - } else { - // Normal login response - Response.ok(response.authentication).build() - } + // Always return LoginResponseDTO complete + Response.ok(response).build() } .onItem().ifNull() .failWith(ServiceException("User not found", Response.Status.UNAUTHORIZED)) @@ -124,7 +119,7 @@ class AuthenticationWS { * * @param email The email of the user * @param code The TOTP code - * @return The AuthenticationDTO with JWT token + * @return The LoginResponseDTO with JWT token * @throws A ServiceException if validation fails */ @POST @@ -138,8 +133,8 @@ class AuthenticationWS { @RestForm @NotEmpty code: String ): Uni { return controller.validate2FACode(email, code) - .onItem().transform { dto -> - Response.ok(dto).build() + .onItem().transform { response -> + Response.ok(response).build() } .onFailure().transform { e -> val message = e.message ?: "Invalid TOTP code" @@ -153,7 +148,7 @@ class AuthenticationWS { * @param name The name of the user * @param email The email of the user * @param password The password of the user - * @return The Authentication DTO + * @return The LoginResponseDTO * @throws A Bad Request if the service is unable to create the user */ @POST @@ -168,7 +163,7 @@ class AuthenticationWS { @FormParam("password") @NotEmpty password: String ): Uni { return controller.createAuthenticate(name, email, password) - .onItem().ifNotNull().transform { dto -> Response.ok(dto).build() } + .onItem().ifNotNull().transform { response -> Response.ok(response).build() } .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 00ac293..52b8356 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,7 +17,6 @@ package dev.orion.users.frameworks.rest.authentication import dev.orion.users.adapters.controllers.UserController -import dev.orion.users.adapters.presenters.AuthenticationDTO import dev.orion.users.adapters.presenters.LoginResponseDTO import dev.orion.users.frameworks.rest.ServiceException import io.quarkus.hibernate.reactive.panache.common.WithSession @@ -90,13 +89,8 @@ class SocialAuthWS { } .onItem().transformToUni { responseUni -> responseUni.onItem().transform { response -> - if (response.requires2FA) { - // Return 200 OK but indicate 2FA is required - Response.ok(response).status(Response.Status.OK).build() - } else { - // Normal login response - Response.ok(response.authentication).build() - } + // Always return LoginResponseDTO complete + Response.ok(response).build() } } .onFailure().transform { e -> @@ -110,7 +104,7 @@ class SocialAuthWS { * * @param email The email of the user * @param code The TOTP code to validate - * @return AuthenticationDTO with JWT token + * @return LoginResponseDTO with JWT token * @throws ServiceException if validation fails */ @POST @@ -124,8 +118,8 @@ class SocialAuthWS { @RestForm @NotEmpty code: String ): Uni { return controller.validateSocialLogin2FA(email, code) - .onItem().transform { dto -> - Response.ok(dto).build() + .onItem().transform { response -> + Response.ok(response).build() } .onFailure().transform { e -> val message = e.message ?: "Invalid TOTP code" 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 e65bfe7..d5c2148 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,7 @@ package dev.orion.users.frameworks.rest.authentication import dev.orion.users.adapters.controllers.UserController -import dev.orion.users.adapters.presenters.AuthenticationDTO +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 @@ -85,7 +85,7 @@ class TwoFactorAuth { * * @param email The email of the user * @param code The TOTP code to validate - * @return The AuthenticationDTO with JWT token + * @return The LoginResponseDTO with JWT token * @throws ServiceException if validation fails */ @POST @@ -99,8 +99,8 @@ class TwoFactorAuth { @RestForm @NotEmpty code: String ): Uni { return controller.validate2FACode(email, code) - .onItem().transform { dto -> - Response.ok(dto).build() + .onItem().transform { response -> + Response.ok(response).build() } .onFailure().transform { e -> val message = e.message ?: "Invalid TOTP code" 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 a1f631b..d2d52ca 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,7 @@ package dev.orion.users.frameworks.rest.authentication import dev.orion.users.adapters.controllers.UserController -import dev.orion.users.adapters.presenters.AuthenticationDTO +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 @@ -142,7 +142,7 @@ class WebAuthnWS { * * @param email The email of the user * @param response The authentication response from the client (JSON string) - * @return The AuthenticationDTO with JWT token + * @return The LoginResponseDTO with JWT token * @throws ServiceException if authentication fails */ @POST @@ -156,8 +156,8 @@ class WebAuthnWS { @RestForm @NotEmpty response: String ): Uni { return controller.finishWebAuthnAuthentication(email, response) - .onItem().transform { dto -> - Response.ok(dto).build() + .onItem().transform { loginResponse -> + Response.ok(loginResponse).build() } .onFailure().transform { e -> val message = e.message ?: "Invalid WebAuthn authentication"