diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d743a6e..151204a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,28 +1,59 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/universal +// README at: https://github.com/devcontainers/templates/tree/main/src/java { - "name": "Default Linux Universal", + "name": "Java", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/universal:2-linux", + "image": "mcr.microsoft.com/devcontainers/java:21", + "features": { - "ghcr.io/devcontainers/features/docker-in-docker:2": {}, - "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, "ghcr.io/devcontainers/features/java:1": {}, - "ghcr.io/devcontainers-contrib/features/quarkus-sdkman:2": {} - } - - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers-contrib/features/quarkus-sdkman:2": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers-contrib/features/maven-sdkman:2": {} + }, // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "uname -a", + // "postCreateCommand": "java -version", // Configure tool-specific properties. - // "customizations": {}, + "customizations": { + "vscode": { + "extensions": [ + "Equinusocio.vsc-material-theme-icons", + "ybaumes.highlight-trailing-white-spaces", + "amodio.amethyst-theme", + "vscjava.vscode-java-pack", + "redhat.vscode-quarkus", + "redhat.vscode-microprofile-pack", + "eamodio.gitlens", + "rangav.vscode-thunder-client", + "vscjava.vscode-lombok", + "GitHub.copilot", + "GitHub.copilot-chat", + "GitHub.vscode-github-actions", + "GitHub.vscode-pull-request-github", + "cweijan.vscode-mysql-client2", + "cracrayol.java-pmd", + "SonarSource.sonarlint-vscode", + "streetsidesoftware.code-spell-checker-portuguese-brazilian" + ], + "settings": { + "workbench.colorTheme": "Default Light Modern", + "workbench.iconTheme": "eq-material-theme-icons-light", + "editor.rulers": [80,120], + "workbench.colorCustomizations": { + "editorRuler.foreground": "#F3F7FF" + }, + "cSpell.enabled": true, + "cSpell.language": "pt_BR, en, pt" + } + } + } // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" -} +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/actions.yml similarity index 58% rename from .github/workflows/ci.yml rename to .github/workflows/actions.yml index fa63c0e..ae08421 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/actions.yml @@ -1,4 +1,4 @@ -name: Orion User CI +name: Orion User on: push: @@ -14,18 +14,12 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up JDK 19 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: - java-version: '19' + java-version: '21' distribution: 'temurin' cache: maven - - name: Cache SonarCloud packages - uses: actions/cache@v1 - with: - path: ~/.sonar/cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar - name: Cache Maven packages uses: actions/cache@v1 with: @@ -35,6 +29,4 @@ jobs: - name: Build and analyze env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar - -Dsonar.projectKey=orion-services_users + run: mvn -B verify \ No newline at end of file diff --git a/.gitignore b/.gitignore index bdf57ce..2a2a691 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ nb-configuration.xml # Visual Studio Code .vscode .factorypath +.history # OSX .DS_Store diff --git a/.history/.devcontainer/devcontainer_20221127222523.json b/.history/.devcontainer/devcontainer_20221127222523.json deleted file mode 100644 index e7accf7..0000000 --- a/.history/.devcontainer/devcontainer_20221127222523.json +++ /dev/null @@ -1,29 +0,0 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/java -{ - "name": "Java", - "image": "mcr.microsoft.com/devcontainers/java:17", - "features": { - "ghcr.io/devcontainers/features/java:1": { - "version": "none", - "installMaven": "true", - "installGradle": "false" - }, - "ghcr.io/devcontainers/features/docker-from-docker:1": {}, - "ghcr.io/devcontainers/features/docker-in-docker:1": {}, - "ghcr.io/meaningful-ooo/devcontainer-features/homebrew:2": {}, - "ghcr.io/guiyomh/features/vim:0": {} - } - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "java -version", - - // Configure tool-specific properties. - // "customizations": {}, - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} diff --git a/.history/.devcontainer/devcontainer_20221128024613.json b/.history/.devcontainer/devcontainer_20221128024613.json deleted file mode 100644 index c8c91ad..0000000 --- a/.history/.devcontainer/devcontainer_20221128024613.json +++ /dev/null @@ -1,29 +0,0 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/java -{ - "name": "Java", - "image": "mcr.microsoft.com/devcontainers/java:17", - "features": { - "ghcr.io/devcontainers/features/java:1": { - "version": "none", - "installMaven": "true", - "installGradle": "false" - }, - "ghcr.io/devcontainers/features/docker-from-docker:1": {}, - "ghcr.io/devcontainers/features/docker-in-docker:1": {}, - "ghcr.io/meaningful-ooo/devcontainer-features/homebrew:2": {}, - "ghcr.io/guiyomh/features/vim:0": {} - } - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "brew install quarkusio/tap/quarkus", - - // Configure tool-specific properties. - // "customizations": {}, - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} diff --git a/.history/.devcontainer/devcontainer_20221128024617.json b/.history/.devcontainer/devcontainer_20221128024617.json deleted file mode 100644 index 37cc7d2..0000000 --- a/.history/.devcontainer/devcontainer_20221128024617.json +++ /dev/null @@ -1,29 +0,0 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/java -{ - "name": "Java", - "image": "mcr.microsoft.com/devcontainers/java:17", - "features": { - "ghcr.io/devcontainers/features/java:1": { - "version": "none", - "installMaven": "true", - "installGradle": "false" - }, - "ghcr.io/devcontainers/features/docker-from-docker:1": {}, - "ghcr.io/devcontainers/features/docker-in-docker:1": {}, - "ghcr.io/meaningful-ooo/devcontainer-features/homebrew:2": {}, - "ghcr.io/guiyomh/features/vim:0": {} - }, - - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "brew install quarkusio/tap/quarkus", - - // Configure tool-specific properties. - // "customizations": {}, - - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} diff --git a/.mvn/wrapper/.gitignore b/.mvn/wrapper/.gitignore new file mode 100644 index 0000000..e72f5e8 --- /dev/null +++ b/.mvn/wrapper/.gitignore @@ -0,0 +1 @@ +maven-wrapper.jar diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java index 17add53..84d1e60 100644 --- a/.mvn/wrapper/MavenWrapperDownloader.java +++ b/.mvn/wrapper/MavenWrapperDownloader.java @@ -17,110 +17,60 @@ * under the License. */ -import java.net.*; -import java.io.*; -import java.nio.channels.*; -import java.util.Properties; +import java.io.IOException; +import java.io.InputStream; +import java.net.Authenticator; +import java.net.PasswordAuthentication; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; -public class MavenWrapperDownloader +public final class MavenWrapperDownloader { - private static final String WRAPPER_VERSION = "3.1.0"; + private static final String WRAPPER_VERSION = "3.2.0"; - /** - * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. - */ - private static final String DEFAULT_DOWNLOAD_URL = - "https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/" + WRAPPER_VERSION - + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; + private static final boolean VERBOSE = Boolean.parseBoolean( System.getenv( "MVNW_VERBOSE" ) ); - /** - * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to use instead of the - * default one. - */ - private static final String MAVEN_WRAPPER_PROPERTIES_PATH = ".mvn/wrapper/maven-wrapper.properties"; - - /** - * Path where the maven-wrapper.jar will be saved to. - */ - private static final String MAVEN_WRAPPER_JAR_PATH = ".mvn/wrapper/maven-wrapper.jar"; - - /** - * Name of the property which should be used to override the default download url for the wrapper. - */ - private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; - - public static void main( String args[] ) + public static void main( String[] args ) { - System.out.println( "- Downloader started" ); - File baseDirectory = new File( args[0] ); - System.out.println( "- Using base directory: " + baseDirectory.getAbsolutePath() ); + log( "Apache Maven Wrapper Downloader " + WRAPPER_VERSION ); - // If the maven-wrapper.properties exists, read it and check if it contains a custom - // wrapperUrl parameter. - File mavenWrapperPropertyFile = new File( baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH ); - String url = DEFAULT_DOWNLOAD_URL; - if ( mavenWrapperPropertyFile.exists() ) + if ( args.length != 2 ) { - FileInputStream mavenWrapperPropertyFileInputStream = null; - try - { - mavenWrapperPropertyFileInputStream = new FileInputStream( mavenWrapperPropertyFile ); - Properties mavenWrapperProperties = new Properties(); - mavenWrapperProperties.load( mavenWrapperPropertyFileInputStream ); - url = mavenWrapperProperties.getProperty( PROPERTY_NAME_WRAPPER_URL, url ); - } - catch ( IOException e ) - { - System.out.println( "- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'" ); - } - finally - { - try - { - if ( mavenWrapperPropertyFileInputStream != null ) - { - mavenWrapperPropertyFileInputStream.close(); - } - } - catch ( IOException e ) - { - // Ignore ... - } - } + System.err.println( " - ERROR wrapperUrl or wrapperJarPath parameter missing" ); + System.exit( 1 ); } - System.out.println( "- Downloading from: " + url ); - File outputFile = new File( baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH ); - if ( !outputFile.getParentFile().exists() ) - { - if ( !outputFile.getParentFile().mkdirs() ) - { - System.out.println( "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() - + "'" ); - } - } - System.out.println( "- Downloading to: " + outputFile.getAbsolutePath() ); try { - downloadFileFromURL( url, outputFile ); - System.out.println( "Done" ); - System.exit( 0 ); + log( " - Downloader started" ); + final URL wrapperUrl = new URL( args[0] ); + final String jarPath = args[1].replace( "..", "" ); // Sanitize path + final Path wrapperJarPath = Paths.get( jarPath ).toAbsolutePath().normalize(); + downloadFileFromURL( wrapperUrl, wrapperJarPath ); + log( "Done" ); } - catch ( Throwable e ) + catch ( IOException e ) { - System.out.println( "- Error downloading" ); - e.printStackTrace(); + System.err.println( "- Error downloading: " + e.getMessage() ); + if ( VERBOSE ) + { + e.printStackTrace(); + } System.exit( 1 ); } } - private static void downloadFileFromURL( String urlString, File destination ) - throws Exception + private static void downloadFileFromURL( URL wrapperUrl, Path wrapperJarPath ) + throws IOException { + log( " - Downloading to: " + wrapperJarPath ); if ( System.getenv( "MVNW_USERNAME" ) != null && System.getenv( "MVNW_PASSWORD" ) != null ) { - String username = System.getenv( "MVNW_USERNAME" ); - char[] password = System.getenv( "MVNW_PASSWORD" ).toCharArray(); + final String username = System.getenv( "MVNW_USERNAME" ); + final char[] password = System.getenv( "MVNW_PASSWORD" ).toCharArray(); Authenticator.setDefault( new Authenticator() { @Override @@ -130,13 +80,19 @@ protected PasswordAuthentication getPasswordAuthentication() } } ); } - URL website = new URL( urlString ); - ReadableByteChannel rbc; - rbc = Channels.newChannel( website.openStream() ); - FileOutputStream fos = new FileOutputStream( destination ); - fos.getChannel().transferFrom( rbc, 0, Long.MAX_VALUE ); - fos.close(); - rbc.close(); + try ( InputStream inStream = wrapperUrl.openStream() ) + { + Files.copy( inStream, wrapperJarPath, StandardCopyOption.REPLACE_EXISTING ); + } + log( " - Downloader complete" ); + } + + private static void log( String msg ) + { + if ( VERBOSE ) + { + System.out.println( msg ); + } } } diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar index c1dd12f..cb28b0e 100644 Binary files a/.mvn/wrapper/maven-wrapper.jar and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 41d8213..346d645 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -14,5 +14,5 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.4/apache-maven-3.8.4-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/CHANGELOG.md b/CHANGELOG.md index 2468ccc..176de57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Users change Log -## 1.0.0 +## 0.0.5 + +- Port to Kotlin + +## 0.0.4 - Clean Architecture - Updated to Quarkus 3 diff --git a/README.md b/README.md index bc100a2..bdba76e 100755 --- a/README.md +++ b/README.md @@ -31,6 +31,22 @@ You can run your application in dev mode that enables live coding using: > **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at http://localhost:8080/q/dev/. +## Frontend Playground + +The project includes a Vue 3 frontend playground application that provides a user interface for testing all features of the Orion Users service. + +**Access the playground**: After starting the application, navigate to `http://localhost:8080/test` + +The playground includes: +- User registration and login +- Social authentication (Google) +- Two-factor authentication (2FA) +- WebAuthn (biometric/security key authentication) +- Password recovery +- User profile management + +For detailed information about the frontend playground, including development setup and configuration, see the [Frontend Documentation](docs/frontend/Frontend.md). + ## Packaging and running the application The application can be packaged using: @@ -65,6 +81,43 @@ You can then execute your native executable with: `./target/users-0.0.1-runner` If you want to learn more about building native executables, please consult https://quarkus.io/guides/maven-tooling. +## API Endpoints + +The service provides the following main endpoints: + +- `POST /users/create` - Create a new user +- `POST /users/login` - Authenticate a user (returns LoginResponseDTO) +- `PUT /users/update` - Update user information (email and/or password). Requires JWT authentication. Returns LoginResponseDTO with updated token and user. +- `POST /users/delete` - Delete a user (admin only) +- `GET /users/validateEmail` - Validate user email with code +- `POST /users/google/2FAuth/qrCode` - Generate 2FA QR code +- `POST /users/google/2FAuth/validate` - Validate 2FA code + +For complete API documentation, see the [documentation site](https://users.orion-services.dev). + +## Update User Endpoint + +The `/users/update` endpoint allows updating user email and/or password in a single request: + +- **Method**: PUT +- **Authentication**: Required (JWT token in Authorization header) +- **Parameters**: + - `email` (required): Current user email + - `newEmail` (optional): New email address + - `password` (optional): Current password (required if updating password) + - `newPassword` (optional): New password +- **Response**: LoginResponseDTO containing AuthenticationDTO with new JWT token and updated user information +- **Note**: At least one of `newEmail` or `newPassword` must be provided + +Example: +```bash +curl -X PUT 'http://localhost:8080/users/update' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=user@example.com' \ + --data-urlencode 'newEmail=newuser@example.com' +``` + ## Related Guides - RESTEasy Reactive ([guide](https://quarkus.io/guides/resteasy-reactive)): A JAX-RS implementation utilizing build time processing and Vert.x. This extension is not compatible with the quarkus-resteasy extension, or any of the extensions that depend on it. diff --git a/docs/frontend/Frontend.md b/docs/frontend/Frontend.md new file mode 100644 index 0000000..c1fa0c5 --- /dev/null +++ b/docs/frontend/Frontend.md @@ -0,0 +1,688 @@ +--- +layout: default +title: Frontend Documentation +nav_order: 3 +--- + +# Frontend Documentation + +This document provides a comprehensive guide to using and developing the Orion Users frontend application. + +## Overview + +The Orion Users frontend is a Vue 3 application built with Vuetify that provides a user interface for all user management and authentication features of the Orion Users service. The frontend application is located in the `playground` directory and is served by the Quarkus backend at the `/test` URL path. + +## Quick Start + +### Running the Playground + +The playground application is integrated with the Quarkus backend and is accessible at: + +**`http://localhost:8080/test`** + +To run the playground: + +1. **Start the Quarkus backend** (this will serve the compiled frontend): +```bash +./mvnw compile quarkus:dev +``` + +2. **Access the application**: + - Open your browser and navigate to: `http://localhost:8080/test` + - The application will be available at this URL + +### Development Mode + +For development with hot module replacement: + +1. **Start the Quarkus backend**: +```bash +./mvnw compile quarkus:dev +``` + +2. **In a separate terminal, start the Vite dev server**: +```bash +cd src/main/resources/META-INF/resources/playground +npm install # Only needed the first time +npm run dev +``` + +3. **Access the application**: + - Development server: `http://localhost:3000/test` + - The Vite dev server proxies API requests to the Quarkus backend + +**Note**: After making changes, rebuild the application (`npm run build`) for the changes to be available when accessing via the Quarkus backend at `/test`. + +## Features + +- ✅ User registration +- ✅ Simple login with email and password +- ✅ Social authentication (Google) +- ✅ Two-factor authentication (2FA) +- ✅ WebAuthn (biometric/security key authentication) +- ✅ Password recovery +- ✅ User profile management +- ✅ Email validation +- ✅ Debug tools for API testing + +## Prerequisites + +- **Node.js**: Version 18 or higher +- **npm** or **yarn**: Package manager +- **Backend API**: The Orion Users backend service running (default: `http://localhost:8080`) + +## Location + +The frontend playground application is located at: +``` +src/main/resources/META-INF/resources/playground/ +``` + +When built, the compiled files are generated in: +``` +src/main/resources/META-INF/resources/test/ +``` + +The application is served by the Quarkus backend at: **`http://localhost:8080/test`** + +## Installation + +1. Navigate to the playground directory: +```bash +cd src/main/resources/META-INF/resources/playground +``` + +2. Install dependencies: +```bash +npm install +``` + +## Configuration + +### Environment Variables + +Create a `.env` file in the `playground/` directory root: + +```env +# Backend API URL +VITE_API_URL=http://localhost:8080 + +# Google OAuth2 Client ID (optional, for social login) +VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +``` + +### Development Server Configuration + +The development server is configured in `vite.config.js`: + +- **Port**: 3000 (default) +- **Proxy**: Automatically proxies `/users` requests to the backend API +- **Hot Module Replacement**: Enabled for fast development + +## Running the Application + +### Development Mode + +1. Start the Quarkus backend server: +```bash +./mvnw compile quarkus:dev +``` + +2. In a separate terminal, navigate to the playground directory and start the Vite development server: +```bash +cd src/main/resources/META-INF/resources/playground +npm run dev +``` + +The Vite development server will be available at `http://localhost:3000/test` (note the `/test` base path). + +**Note**: In development mode, Vite runs on port 3000 with hot module replacement. The application is configured to use `/test` as the base path, and API requests to `/users` are automatically proxied to the Quarkus backend running on port 8080. + +### Production Build + +1. Navigate to the playground directory: +```bash +cd src/main/resources/META-INF/resources/playground +``` + +2. Build the application: +```bash +npm run build +``` + +The built files will be generated in `src/main/resources/META-INF/resources/test/` directory, which is automatically served by Quarkus. + +3. Start or restart the Quarkus backend: +```bash +./mvnw compile quarkus:dev +``` + +4. Access the application at: +``` +http://localhost:8080/test +``` + +**Important**: The application is configured to be served at the `/test` URL path. Make sure to access it at `http://localhost:8080/test` (not at the root `/`). + +### Preview Production Build + +To preview the production build locally with Vite: + +```bash +npm run preview +``` + +This will serve the built files using Vite's preview server. However, for production-like testing, it's recommended to use the Quarkus backend as described above. + +## Project Structure + +``` +src/main/resources/META-INF/resources/playground/ +├── src/ +│ ├── main.js # Vue app initialization and Vuetify setup +│ ├── App.vue # Root component +│ ├── components/ # Reusable components +│ │ ├── DebugModal.vue # API debug modal +│ │ ├── LogList.vue # Request/response log viewer +│ │ └── PasswordStrengthIndicator.vue # Password strength meter +│ ├── router/ +│ │ └── index.js # Vue Router configuration (base: /test) +│ ├── services/ +│ │ └── api.js # Axios HTTP client and API methods +│ ├── stores/ # Pinia state management +│ │ ├── auth.js # Authentication state +│ │ └── debug.js # Debug logs state +│ ├── utils/ +│ │ └── passwordValidation.js # Password validation rules +│ └── views/ # Page components +│ ├── LoginView.vue # Login and registration page +│ ├── DashboardView.vue # User dashboard +│ ├── TwoFactorView.vue # 2FA setup and validation +│ ├── WebAuthnView.vue # WebAuthn registration and authentication +│ └── RecoverPasswordView.vue # Password recovery +├── index.html # HTML template +├── package.json # Dependencies and scripts +└── vite.config.js # Vite configuration (base: /test/) + +# Build output directory (served by Quarkus at /test) +src/main/resources/META-INF/resources/test/ +├── index.html # Compiled HTML +├── assets/ # Compiled JavaScript and CSS +└── ... +``` + +## Usage Guide + +### User Registration + +1. Navigate to the home page (`/`) +2. Click on the **Register** tab +3. Fill in: + - **Name**: Your full name + - **Email**: A valid email address + - **Password**: Minimum 8 characters with at least one uppercase, one lowercase, one digit, and one special character +4. The password strength indicator will show the password strength in real-time +5. Click **Register** +6. You will be automatically logged in after successful registration +7. Check your email for a validation code + +### Simple Login + +1. Navigate to the home page (`/`) +2. Click on the **Login** tab +3. Enter your email and password +4. Click **Login** +5. If 2FA is enabled, you will be redirected to the 2FA validation page +6. Otherwise, you will be redirected to the dashboard + +### Social Authentication + +#### Google Login + +1. Click the **Login with Google** button +2. A Google sign-in popup will appear +3. Select your Google account +4. Grant permissions if requested +5. You will be automatically logged in + +**Note**: Requires `VITE_GOOGLE_CLIENT_ID` to be configured in `.env` + +### Two-Factor Authentication (2FA) + +#### Setting Up 2FA + +1. Navigate to `/2fa` +2. Enter your email and password +3. Click **Generate QR Code** +4. Scan the QR code with an authenticator app: + - Google Authenticator + - Microsoft Authenticator + - Authy + - Any TOTP-compatible app +5. Enter the 6-digit code from your authenticator app +6. Click **Validate Code** +7. 2FA is now enabled for your account + +#### Logging In with 2FA + +1. Log in with your email and password +2. You will be automatically redirected to the 2FA validation page +3. Enter the 6-digit code from your authenticator app +4. Click **Validate Code** +5. You will be logged in and redirected to the dashboard + +### WebAuthn Authentication + +#### Registering a Device + +1. Navigate to `/webauthn` +2. Click on the **Register Device** tab +3. Enter your email +4. (Optional) Enter a device name (e.g., "My Laptop", "iPhone 13") +5. Click **Register Device** +6. Follow your browser's prompts: + - **Biometric**: Use fingerprint or face recognition + - **Security Key**: Insert and activate your FIDO2 security key + - **Platform Authenticator**: Use Windows Hello, Touch ID, or Face ID +7. The device will be registered and ready for authentication + +#### Authenticating with WebAuthn + +1. Navigate to `/webauthn` +2. Click on the **Authenticate** tab +3. Enter your email +4. Click **Authenticate with WebAuthn** +5. Follow your browser's prompts to authenticate +6. You will be logged in and redirected to the dashboard + +**Note**: WebAuthn requires HTTPS in production. For local development, some browsers allow WebAuthn on `localhost` without HTTPS. + +### Password Recovery + +1. Navigate to `/recover-password` +2. Enter your email address +3. Click **Recover Password** +4. Check your email for the new password +5. Use the new password to log in +6. Consider changing your password after logging in + +### User Dashboard + +After logging in, you will be redirected to the dashboard (`/dashboard`) where you can: + +- View your user profile information +- Update your email address +- Change your password +- Log out + +### Viewing API Request/Response Logs + +The frontend includes a built-in debug tool that automatically logs all API requests and responses. This is very useful for debugging and understanding how the API works. + +#### Opening the Debug Modal + +1. Look for the **bug icon** (🐛) in the top-right corner of the application bar +2. Click the bug icon to open the Debug modal +3. The modal will display all API requests and responses + +#### Using the Debug Logs + +The debug modal provides several features: + +**Tabs:** +- **All**: Shows all API requests (successful and failed) +- **Success**: Shows only successful requests (status 200-299) +- **Errors**: Shows only failed requests (status 400+ or network errors) + +**Log Details:** + +Each log entry shows: +- **Status**: HTTP status code and status text +- **Method**: HTTP method (GET, POST, PUT, DELETE) +- **URL**: Full request URL +- **Timestamp**: When the request was made + +**Expanding Logs:** + +Click on any log entry to expand and view detailed information: + +- **Request Tab**: + - HTTP method + - Full URL (base URL + endpoint) + - Request data (body/payload) + - Request headers (including Authorization token) + +- **Response Tab** (for successful requests): + - HTTP status code and status text + - Response data (JSON response from API) + - Response headers + +- **Error Tab** (for failed requests): + - Error message + - HTTP status code (if available) + - Error response data (if available) + +**Copying Data:** + +- Click the **copy icon** (📋) next to any data field to copy it to your clipboard +- Useful for sharing error messages or request/response data + +**Clearing Logs:** + +- Click the **trash icon** (🗑️) in the modal header to clear all logs +- Logs are automatically limited to the last 50 entries + +#### What Gets Logged + +The debug tool automatically logs: +- All API requests made through the `userApi` service +- Request method, URL, data, and headers +- Response status, data, and headers +- Error information (if request fails) +- Timestamp for each request + +**Example Use Cases:** + +1. **Debugging Login Issues**: + - Check if the request is being sent correctly + - See the exact error message from the API + - Verify the request payload format + +2. **Understanding API Responses**: + - See the exact structure of API responses + - Copy response data for testing + - Understand error formats + +3. **Testing API Endpoints**: + - See what data is being sent + - Verify authentication tokens + - Check request headers + +4. **Development**: + - Monitor all API calls during development + - Debug integration issues + - Understand API behavior + +**Note**: The debug logs are stored in browser memory and will be cleared when you refresh the page. They are not persisted to localStorage or sent to any server. + +#### Updating Email + +1. Go to the dashboard +2. Enter your new email address in the **Update Email** section +3. Click **Update Email** +4. Check your new email for a validation code +5. Click the validation link in the email + +#### Changing Password + +1. Go to the dashboard +2. Enter your current password +3. Enter your new password (must meet strength requirements) +4. Confirm your new password +5. Click **Change Password** + +## API Service + +The frontend uses a centralized API service located in `src/services/api.js`. All API calls are made through this service. + +### Available Methods + +```javascript +import { userApi } from '@/services/api' + +// Registration +userApi.createUser(name, email, password) +userApi.createAndAuthenticate(name, email, password) + +// Authentication +userApi.login(email, password) +userApi.loginWithGoogle(idToken) + +// Two-Factor Authentication +userApi.generate2FAQRCode(email, password) +userApi.validate2FACode(email, code) +userApi.loginWith2FA(email, code) + +// WebAuthn +userApi.startWebAuthnRegistration(email, origin) +userApi.finishWebAuthnRegistration(email, response, origin, deviceName) +userApi.startWebAuthnAuthentication(email) +userApi.finishWebAuthnAuthentication(email, response) + +// Email Validation +userApi.validateEmail(email, code) + +// Password Recovery +userApi.recoverPassword(email) + +// User Management +userApi.updateUser(email, newEmail, password, newPassword) +``` + +### Request/Response Interceptors + +The API service includes interceptors that: +- Automatically add JWT tokens to authenticated requests +- Handle 401 errors by clearing authentication state +- Log all requests and responses for debugging + +## State Management + +The application uses Pinia for state management. + +### Auth Store + +Located in `src/stores/auth.js`: + +```javascript +import { useAuthStore } from '@/stores/auth' + +const authStore = useAuthStore() + +// Check if user is authenticated +authStore.isAuthenticated + +// Get current user +authStore.user + +// Get auth token +authStore.token + +// Set authentication +authStore.setAuth(token, user) + +// Logout +authStore.logout() +``` + +### Debug Store + +Located in `src/stores/debug.js`: + +```javascript +import { useDebugStore } from '@/stores/debug' + +const debugStore = useDebugStore() + +// View logs +debugStore.logs + +// Clear logs +debugStore.clearLogs() +``` + +## Routing + +The application uses Vue Router for navigation. Routes are defined in `src/router/index.js`. + +### Available Routes + +All routes are prefixed with `/test` when served by Quarkus: + +- `/test/` - Login and registration page +- `/test/dashboard` - User dashboard (requires authentication) +- `/test/2fa` - Two-factor authentication setup and validation +- `/test/webauthn` - WebAuthn device registration and authentication +- `/test/recover-password` - Password recovery + +**Note**: The Vue Router is configured with base path `/test`, so internal navigation will automatically include this prefix. + +### Route Guards + +Routes with `meta: { requiresAuth: true }` are protected and will redirect unauthenticated users to the login page. + +## Components + +### PasswordStrengthIndicator + +Displays password strength in real-time based on validation rules. + +**Props:** +- `password` (String): The password to evaluate + +**Usage:** +```vue + +``` + +### DebugModal + +Modal component for viewing API request/response logs. + +**Usage:** +```vue + +``` + +## Utilities + +### Password Validation + +Located in `src/utils/passwordValidation.js`: + +```javascript +import { getPasswordRules } from '@/utils/passwordValidation' + +const rules = getPasswordRules() +// Returns Vuetify validation rules for password fields +``` + +## Browser Support + +- **Chrome**: 90+ +- **Firefox**: 88+ +- **Safari**: 14+ +- **Edge**: 90+ + +## Troubleshooting + +### Application Not Loading at /test + +- Ensure the application has been built: `npm run build` in the playground directory +- Verify the built files exist in `src/main/resources/META-INF/resources/test/` +- Make sure you're accessing the application at `http://localhost:8080/test` (not `/`) +- Check that the Quarkus backend is running and serving static files from `META-INF/resources/` +- Verify the `base: '/test/'` configuration in `vite.config.js` + +### API Connection Issues + +- Verify the backend is running on `http://localhost:8080` +- Check the `VITE_API_URL` in `.env` (if using environment variables) +- In development mode, verify the proxy configuration in `vite.config.js` is correctly forwarding `/users` requests +- Check browser console for CORS errors +- Verify network connectivity + +### Social Login Not Working + +- Ensure environment variables are set in `.env` +- Restart the development server after adding environment variables +- Check browser console for errors +- Verify OAuth credentials are correct + +### WebAuthn Not Working + +- Ensure you're using HTTPS in production (or localhost in development) +- Check browser compatibility +- Verify the device supports WebAuthn +- Check browser console for errors + +### 2FA QR Code Not Displaying + +- Verify your email and password are correct +- Check browser console for errors +- Ensure the backend is running and accessible + +## Development + +### Adding New Features + +1. Create components in `src/components/` +2. Create views in `src/views/` +3. Add API methods in `src/services/api.js` +4. Add routes in `src/router/index.js` +5. Add state management in `src/stores/` if needed + +### Code Style + +- Use Vue 3 Composition API with ` +``` + +**Verificações**: +- O script deve estar no `` do documento +- Os atributos `async defer` garantem que o script não bloqueie o carregamento da página + +#### 3. Reiniciar o Servidor de Desenvolvimento + +Após adicionar ou modificar variáveis de ambiente: + +1. Pare o servidor de desenvolvimento (se estiver rodando) +2. Reinicie o servidor: + ```bash + cd src/main/resources/META-INF/resources/playground + npm run dev + ``` + +#### 4. Testar a Configuração + +1. Acesse a página de login +2. Verifique se o botão "Login with Google" está visível +3. Clique no botão "Login with Google" +4. Deve aparecer um popup do Google para seleção de conta e autenticação +5. Após autenticar, você deve ser redirecionado para o dashboard + +### Configurar o Backend + +Adicione a configuração do Google OAuth2 no arquivo `src/main/resources/application.properties`: + +```properties +# Google OAuth2 Client ID +social.auth.google.client-id=seu-client-id.apps.googleusercontent.com +``` + +**Notas**: +- Substitua `seu-client-id.apps.googleusercontent.com` pelo Client ID obtido no Google Cloud Console +- O Client ID deve estar no formato `xxxxx.apps.googleusercontent.com` +- Não inclua espaços ou caracteres extras +- Reinicie o servidor backend após modificar o arquivo + +**Configuração por Ambiente**: + +Para diferentes ambientes, você pode usar perfis do Quarkus: + +```properties +# Desenvolvimento +%dev.social.auth.google.client-id=dev-client-id.apps.googleusercontent.com + +# Teste +%test.social.auth.google.client-id=test-client-id.apps.googleusercontent.com + +# Produção +%prod.social.auth.google.client-id=prod-client-id.apps.googleusercontent.com +``` + +## Implementação + +### Frontend + +O frontend utiliza o Google Identity Services (GIS) para gerenciar o fluxo OAuth2. A implementação está localizada no arquivo `LoginView.vue`. + +#### Fluxo Básico + +1. **Aguarda o Google Identity Services carregar**: + ```javascript + await waitForGoogleIdentityServices() + ``` + +2. **Inicializa o Google Identity Services**: + ```javascript + const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID + + google.accounts.id.initialize({ + client_id: clientId, + callback: async (response) => { + // response.credential contém o ID token + await userApi.loginWithGoogle(response.credential) + } + }) + ``` + +3. **Tenta exibir One Tap**: + ```javascript + google.accounts.id.prompt((notification) => { + if (notification.isNotDisplayed() || notification.isSkippedMoment()) { + // Fallback para OAuth2 Token Client + google.accounts.oauth2.initTokenClient({ + client_id: clientId, + scope: 'openid profile email', + callback: async (tokenResponse) => { + // tokenResponse.access_token contém o access token + await userApi.loginWithGoogle(tokenResponse.access_token) + } + }).requestAccessToken() + } + }) + ``` + +4. **Envia token para o backend**: + ```javascript + // POST /users/login/google + // Content-Type: application/x-www-form-urlencoded + // Body: idToken={token} + const apiResponse = await userApi.loginWithGoogle(token) + ``` + +#### Serviço de API + +O frontend envia o token através do serviço de API (`api.js`): + +```javascript +loginWithGoogle: (idToken) => { + return api.post('/users/login/google', toFormData({ idToken })) +} +``` + +### Backend + +O backend valida tokens do Google suportando tanto ID tokens (JWT) quanto access tokens. + +#### Processo de Validação + +1. **Normalização**: Remove espaços em branco do token + +2. **Tentativa de Validação como JWT**: + - Verifica se o token tem formato JWT (3 partes separadas por pontos) + - Decodifica o payload (base64url) + - Extrai email e nome do payload + +3. **Fallback para Access Token**: + - Se não for um JWT válido, assume que é um access token + - Faz chamada à API do Google: `GET https://www.googleapis.com/oauth2/v2/userinfo` + - Extrai email e nome da resposta + +4. **Criação/Busca do Usuário**: + - Busca o usuário pelo email + - Se não existir, cria automaticamente com email validado e role padrão + +5. **Geração do JWT do Sistema**: + - Gera um JWT próprio do sistema + - Retorna AuthenticationDTO com usuário e token + +## API Reference + +### Endpoint: Login com Google + +**POST** `/users/login/google` + +**Content-Type**: `application/x-www-form-urlencoded` + +**Parâmetros**: +- `idToken` (String, obrigatório): ID token (JWT) ou access token do Google + +**Resposta de Sucesso** (200 OK): + +```json +{ + "user": { + "hash": "53012a1a-b8ec-40f4-a81e-bc8b97ddab75", + "name": "John Doe", + "email": "john.doe@gmail.com", + "emailValid": true, + "secret2FA": null, + "using2FA": false + }, + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..." +} +``` + +**Resposta de Erro** (401 Unauthorized): + +```json +{ + "message": "Invalid Google token: Token is empty" +} +``` + +**Exemplo com cURL**: + +```bash +curl -X POST \ + 'http://localhost:8080/users/login/google' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'idToken=eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMzQ1NiJ9...' +``` + +## Troubleshooting + +### Problemas Comuns + +| Problema | Solução | +|----------|---------| +| Botão "Login with Google" não aparece | Verificar script no `index.html` e Client ID no `.env` | +| Popup do Google não abre | Desabilitar bloqueador de popup e verificar domínios autorizados no Google Cloud Console | +| Erro "Invalid Google token" | Fazer login novamente para obter novo token | +| Erro 401 | Verificar Client ID e domínios autorizados | +| Erro CORS | Verificar configuração CORS no backend | +| Email não encontrado | Verificar escopos solicitados (`email`) | +| HTTPS necessário | Configurar HTTPS em produção (exceto localhost) | +| Token expirado | Fazer login novamente | + +### Verificações Básicas + +1. **Console do Navegador** (F12): + - Verificar se há erros relacionados ao Google Identity Services + - Verificar se o objeto `google` está disponível: `console.log(typeof google)` + +2. **Variáveis de Ambiente**: + - Verificar se `VITE_GOOGLE_CLIENT_ID` está configurado no `.env` + - Reiniciar servidor após adicionar/modificar variáveis + +3. **Google Cloud Console**: + - Verificar se os domínios estão em **Authorized JavaScript origins** + - Verificar se os domínios estão em **Authorized redirect URIs** + - Desenvolvimento: `http://localhost:3000`, `http://localhost:5173` + - Produção: `https://your-domain.com` + +4. **Backend**: + - Verificar se `social.auth.google.client-id` está configurado em `application.properties` + - Verificar logs do backend para erros de validação + +## Segurança + +### Recomendações para Produção + +A implementação atual fornece validação básica adequada para desenvolvimento. Para produção, recomenda-se implementar: + +1. **Validação de Assinatura do JWT**: + - Baixar as chaves públicas do Google de `https://www.googleapis.com/oauth2/v3/certs` + - Validar a assinatura do token usando a chave pública correspondente + +2. **Validação de Expiração**: + - Verificar o campo `exp` (expiration) do JWT + - Rejeitar tokens expirados + +3. **Validação do Issuer**: + - Verificar que o campo `iss` (issuer) é `https://accounts.google.com` + +4. **Validação do Audience**: + - Verificar que o campo `aud` (audience) corresponde ao Client ID configurado + +5. **Rate Limiting**: + - Implementar rate limiting para prevenir abuso do endpoint + +### Boas Práticas + +- **Sempre use HTTPS em produção** - O Google requer HTTPS (exceto localhost) +- **Use variáveis de ambiente** - Nunca hardcode credenciais no código +- **Monitore tentativas de autenticação** - Logue tentativas falhadas para detectar abusos +- **Mantenha dependências atualizadas** - Bibliotecas de segurança devem estar atualizadas +- **Valide todos os inputs** - Não confie em dados do cliente sem validação +- **Nunca exponha Client Secrets** - Client secrets nunca devem estar no código do frontend + +## Exceptions + +- **HTTP 401 (Unauthorized)**: Se o token for inválido ou expirado +- **HTTP 400 (Bad Request)**: Se a requisição estiver malformada ou faltando parâmetros obrigatórios diff --git a/docs/usecases/TwoFactorAuth/twofactorauth.md b/docs/usecases/TwoFactorAuth/twofactorauth.md index 6d1e714..a4ad5b9 100644 --- a/docs/usecases/TwoFactorAuth/twofactorauth.md +++ b/docs/usecases/TwoFactorAuth/twofactorauth.md @@ -37,10 +37,10 @@ nav_order: 9 -## HTTP(S) endpoints +## HTTPS endpoints -* /api/users/google/2FAuth/qrCode - * HTTP method: POST +* /users/google/2FAuth/qrCode + * Method: POST * Consumes: application/x-www-form-urlencoded * Produces: image/png * Examples: @@ -49,7 +49,7 @@ nav_order: 9 ```shell curl -X POST \ - 'http://localhost:8080/api/users/google/2FAuth/qrCode' \ + 'http://localhost:8080/users/google/2FAuth/qrCode' \ --header 'Accept: */*' \ --header 'User-Agent: Thunder Client (https://www.thunderclient.com)' \ --header 'Content-Type: application/x-www-form-urlencoded' \ @@ -63,8 +63,8 @@ nav_order: 9 ![QRCODE](https://t3.gstatic.com/licensed-image?q=tbn:ANd9GcSh-wrQu254qFaRcoYktJ5QmUhmuUedlbeMaQeaozAVD4lh4ICsGdBNubZ8UlMvWjKC) ``` -* /api/users/google/2FAuth/validate - * HTTP method: POST +* /users/google/2FAuth/validate + * Method: POST * Consumes: application/x-www-form-urlencoded * Produces: image/png * Examples: @@ -73,7 +73,7 @@ nav_order: 9 ```shell curl -X POST \ - 'http://localhost:8080/api/users/google/2FAuth/qrCode' \ + 'http://localhost:8080/users/google/2FAuth/qrCode' \ --header 'Accept: */*' \ --header 'User-Agent: Thunder Client (https://www.thunderclient.com)' \ --header 'Content-Type: application/x-www-form-urlencoded' \ diff --git a/docs/usecases/UpdateUser/updateUser.md b/docs/usecases/UpdateUser/updateUser.md new file mode 100644 index 0000000..f312bf8 --- /dev/null +++ b/docs/usecases/UpdateUser/updateUser.md @@ -0,0 +1,97 @@ +--- +layout: default +title: Update User +parent: Use Cases +nav_order: 7 +--- + +## Normal flow + +* A client sends the current e-mail, optionally the new e-mail and/or the current and new password along with an access token. +* The service validates the access token and the current e-mail to check if the user exists in the service. +* If updating email: If the user exists, updates the user's e-mail, sets the status of e-mail validation to false, sends a message with a code to validate the new e-mail, and generates a new access token. +* If updating password: If the user exists, validates the current password and if it matches, updates the user's password and generates a new access token. +* The service returns a LoginResponseDTO containing an AuthenticationDTO with the new JWT token and updated user information. + +## HTTPS endpoints + +* /users/update + * Method: PUT + * Consumes: application/x-www-form-urlencoded + * Produces: application/json + * Requires: JWT token in Authorization header + * Examples: + + * Example of request to update email only: + + ```shell + curl -X PUT \ + 'http://localhost:8080/users/update' \ + --header 'Accept: */*' \ + --header 'Authorization: Bearer eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00ifQ.UbrbrSkwKUOPm12kdcrbwroXe8cwPRg9tLN0ovxEB89bm9LNPYTj6qATOAHrObG-jDf2CUK1m3C5cTZE5LAx-hFt7YgdjXC4qxx57GvyzNY6Dxg_lp-pM3fEvw3CBQujRD47Lr3KwjPilSrwhAvXv46mw78cPHkw3kuNerdNf00TyIBYFobKezFxqT-jTDAPmzFo4B2jwFDtzOKf61Jq30E8i6H8IIDQ7XohbGdotbqu6RdR5uzoaJqB8ylz1hNXGWaBFD3GrsyCeFX9G6zgs998BoIhceKOksEZYhR7TD9q8SIuZWQTeIcgtuLBNMeQ7PV04bvZAyNCcN45sD7QJw.rwzVRmyTL16f1s9i.JrGJwG-ANTLqKuaEFTk-IEO5sdhthG9Z5-DXcli39X3mJKPn2gYIZyO4yp6VlFVw_gRT7x_YwwnLM0UuTqfpdL7fgSCS20nhC1fJYd0fvZCPLHBtJUDdo7gkswmG6eb4M1QENDkK96biwRGq0sCG5dZPfkDp1PXJQyF63phEPEb9Bo6xVzoJjJl-9C25BQRRSBHrGzp9tzY3Bh2WMv1JGJGG2thhuh2L6ap4QvM1jdyS9ObeNL61al_4UIk8CesZ0a5enheICPaL5YfbMHpwCWd3PutmABr-o05jtw.7dE9ieDGe3qOooGWmR-jJg' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=orion@test.com' \ + --data-urlencode 'newEmail=orion@xyzmail.com' + ``` + + * Example of request to update password only: + + ```shell + curl -X PUT \ + 'http://localhost:8080/users/update' \ + --header 'Accept: */*' \ + --header 'Authorization: Bearer eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00ifQ.UbrbrSkwKUOPm12kdcrbwroXe8cwPRg9tLN0ovxEB89bm9LNPYTj6qATOAHrObG-jDf2CUK1m3C5cTZE5LAx-hFt7YgdjXC4qxx57GvyzNY6Dxg_lp-pM3fEvw3CBQujRD47Lr3KwjPilSrwhAvXv46mw78cPHkw3kuNerdNf00TyIBYFobKezFxqT-jTDAPmzFo4B2jwFDtzOKf61Jq30E8i6H8IIDQ7XohbGdotbqu6RdR5uzoaJqB8ylz1hNXGWaBFD3GrsyCeFX9G6zgs998BoIhceKOksEZYhR7TD9q8SIuZWQTeIcgtuLBNMeQ7PV04bvZAyNCcN45sD7QJw.rwzVRmyTL16f1s9i.JrGJwG-ANTLqKuaEFTk-IEO5sdhthG9Z5-DXcli39X3mJKPn2gYIZyO4yp6VlFVw_gRT7x_YwwnLM0UuTqfpdL7fgSCS20nhC1fJYd0fvZCPLHBtJUDdo7gkswmG6eb4M1QENDkK96biwRGq0sCG5dZPfkDp1PXJQyF63phEPEb9Bo6xVzoJjJl-9C25BQRRSBHrGzp9tzY3Bh2WMv1JGJGG2thhuh2L6ap4QvM1jdyS9ObeNL61al_4UIk8CesZ0a5enheICPaL5YfbMHpwCWd3PutmABr-o05jtw.7dE9ieDGe3qOooGWmR-jJg' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=orion@test.com' \ + --data-urlencode 'password=12345678' \ + --data-urlencode 'newPassword=87654321' + ``` + + * Example of request to update both email and password: + + ```shell + curl -X PUT \ + 'http://localhost:8080/users/update' \ + --header 'Accept: */*' \ + --header 'Authorization: Bearer eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00ifQ.UbrbrSkwKUOPm12kdcrbwroXe8cwPRg9tLN0ovxEB89bm9LNPYTj6qATOAHrObG-jDf2CUK1m3C5cTZE5LAx-hFt7YgdjXC4qxx57GvyzNY6Dxg_lp-pM3fEvw3CBQujRD47Lr3KwjPilSrwhAvXv46mw78cPHkw3kuNerdNf00TyIBYFobKezFxqT-jTDAPmzFo4B2jwFDtzOKf61Jq30E8i6H8IIDQ7XohbGdotbqu6RdR5uzoaJqB8ylz1hNXGWaBFD3GrsyCeFX9G6zgs998BoIhceKOksEZYhR7TD9q8SIuZWQTeIcgtuLBNMeQ7PV04bvZAyNCcN45sD7QJw.rwzVRmyTL16f1s9i.JrGJwG-ANTLqKuaEFTk-IEO5sdhthG9Z5-DXcli39X3mJKPn2gYIZyO4yp6VlFVw_gRT7x_YwwnLM0UuTqfpdL7fgSCS20nhC1fJYd0fvZCPLHBtJUDdo7gkswmG6eb4M1QENDkK96biwRGq0sCG5dZPfkDp1PXJQyF63phEPEb9Bo6xVzoJjJl-9C25BQRRSBHrGzp9tzY3Bh2WMv1JGJGG2thhuh2L6ap4QvM1jdyS9ObeNL61al_4UIk8CesZ0a5enheICPaL5YfbMHpwCWd3PutmABr-o05jtw.7dE9ieDGe3qOooGWmR-jJg' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=orion@test.com' \ + --data-urlencode 'newEmail=orion@xyzmail.com' \ + --data-urlencode 'password=12345678' \ + --data-urlencode 'newPassword=87654321' + ``` + + * Example of response: LoginResponseDTO with AuthenticationDTO in JSON. + + ```json + { + "authentication": { + "token": "eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00ifQ.k3VZmBKoYebrmPQcV5vVrNG1d1s-Ee4Szjh--iUwHClWzOLZfHWBRNHAcp70IS7VZM6JcAtVqXmLHP9quaR3OxSpUAAcgxnG-zIt6ogkd9vxiCttgwNGAqnd4pWUZ9ie4AWi9S-subt5KXDQ41kEuMLMJ2ufHLc4yU7XmKm5rkEWwXTjmmJCfb-soreb1bUpZ-SfoQ3zVX9MWoHYInnjzyZYLUfQIq0JfZZhKx4v689aE27nCek5iol-42LsQzowOTa9kvzxbN9ZofP_mVSuuXNJk7lTTZqX8ZU-BlwA27_W0t0sDj3Ka8H2GYyqAIBbUcWc_MdeHDnUQWeAMF57Aw.LPYiVFh9FxVW2D57.JwHCxJsICElkF85gTBpgX1fOirjFohzWGFeozzfjuyrrC_PJJhzHIR1tsZ6lfQi7jrjHeCT-aRjOW2r-U-baEbkguEzCYyG68ynFjjU65kajeoKSgoI4SVgdByK_bnHGhv-CTUzv4d4gD0Jt0OYw9H9a5QvozA9r_RiRdF-WwEYoyYSlvIxzxx3hlL07tbYO6z_dcEcd_-Y3ylKooRSXsoG_FSd6IzuJqlD10Ixax1uL-bmap2rUEqMjpcnIcMiyL9nF_-PhAjC7FnhCWJUtkj9NGzxPxZqiak-Wc8c2SdXf0vRKaiL72MkIxRo.1IQPzuVpukQwyqBA9S0rZA", + "user": { + "hash": "7eba8ef2-426b-446a-9f05-4ab67e71383d", + "name": "Orion", + "email": "orion@xyzmail.com", + "emailValid": false + } + }, + "requires2FA": false, + "message": null + } + ``` + +## Parameters + +* `email` (required): The current email of the user +* `newEmail` (optional): The new email address. If provided, the email will be updated and a validation email will be sent. +* `password` (optional): The current password. Required if `newPassword` is provided. +* `newPassword` (optional): The new password. Must meet password strength requirements. + +**Note:** At least one of `newEmail` or `newPassword` must be provided. + +## Exceptions + +In the use case layer, exceptions related with arguments will be +IllegalArgumentException. However, in the RESTful Web Service layer will be +transformed to Bad Request (HTTP 400). If the access token is invalid or missing, +the service will return Unauthorized (HTTP 401). + diff --git a/docs/usecases/UseCases.puml b/docs/usecases/UseCases.puml index 1bed5d6..afb3a42 100644 --- a/docs/usecases/UseCases.puml +++ b/docs/usecases/UseCases.puml @@ -10,9 +10,10 @@ rectangle Users{ usecase "Validate E-mail" as UC4 usecase "Delete User" as UC5 usecase "Recover Password" as UC6 - usecase "Update Email" as UC7 - usecase "Update Password" as UC8 - usecase "Two Factor Authenticate" as UC9 + usecase "Update User" as UC7 + usecase "Two Factor Authenticate" as UC8 + usecase "Social Authentication" as UC9 + usecase "WebAuthn Authentication" as UC10 } client --> UC1 @@ -24,5 +25,6 @@ client --> UC6 client --> UC7 client --> UC8 client --> UC9 +client --> UC10 @enduml \ No newline at end of file diff --git a/docs/usecases/ValidateEmail/validateEmail.md b/docs/usecases/ValidateEmail/validateEmail.md index 2cb1a89..7f4c907 100644 --- a/docs/usecases/ValidateEmail/validateEmail.md +++ b/docs/usecases/ValidateEmail/validateEmail.md @@ -13,10 +13,10 @@ nav_order: 4 * The service validates the code to the e-mail. * If the validation code is correct, the service returns just a string true. -## HTTP(S) endpoints +## HTTPS endpoints -* /api/users/validateEmail - * HTTP method: GET +* /users/validateEmail + * Method: GET * Consumes: text/plain * Produces: text/plain * Examples: @@ -25,7 +25,7 @@ nav_order: 4 ```shell curl -X 'GET' \ - 'http://localhost:8080/api/users/validateEmail?code=d32c2a8e-ea4b-4260-b4d7-b3e62d8488e1&email=orion%40test.com' \ + 'http://localhost:8080/users/validateEmail?code=d32c2a8e-ea4b-4260-b4d7-b3e62d8488e1&email=orion%40test.com' \ -H 'accept: application/json' ``` diff --git a/docs/usecases/updateEmail/updateEmail.md b/docs/usecases/updateEmail/updateEmail.md deleted file mode 100644 index 3977786..0000000 --- a/docs/usecases/updateEmail/updateEmail.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -layout: default -title: Update e-mail -parent: Use Cases -nav_order: 7 ---- - -## Normal flow - -* A client sends the current e-mail the new e-mail and the access token. -* The service validates the access token and the current e-mail to check - if the users exists in the service. If the users exists, updates the user's - e-mail, returns the status of e-mail validation to false and sends a message - with a code to the user validates the new e-mail and generates a new access - token to the user. - -## HTTP(S) endpoints - -* /api/users/update/email - * HTTP method: PUT - * Consumes: application/x-www-form-urlencoded - * Produces: text/plain - * Examples: - - * Example of request: - - ```shell - curl -X PUT \ - 'http://localhost:8080/api/users/update/email' \ - --header 'Accept: */*' \ - --header 'User-Agent: Thunder Client (https://www.thunderclient.com)' \ - --header 'Authorization: Bearer eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00ifQ.UbrbrSkwKUOPm12kdcrbwroXe8cwPRg9tLN0ovxEB89bm9LNPYTj6qATOAHrObG-jDf2CUK1m3C5cTZE5LAx-hFt7YgdjXC4qxx57GvyzNY6Dxg_lp-pM3fEvw3CBQujRD47Lr3KwjPilSrwhAvXv46mw78cPHkw3kuNerdNf00TyIBYFobKezFxqT-jTDAPmzFo4B2jwFDtzOKf61Jq30E8i6H8IIDQ7XohbGdotbqu6RdR5uzoaJqB8ylz1hNXGWaBFD3GrsyCeFX9G6zgs998BoIhceKOksEZYhR7TD9q8SIuZWQTeIcgtuLBNMeQ7PV04bvZAyNCcN45sD7QJw.rwzVRmyTL16f1s9i.JrGJwG-ANTLqKuaEFTk-IEO5sdhthG9Z5-DXcli39X3mJKPn2gYIZyO4yp6VlFVw_gRT7x_YwwnLM0UuTqfpdL7fgSCS20nhC1fJYd0fvZCPLHBtJUDdo7gkswmG6eb4M1QENDkK96biwRGq0sCG5dZPfkDp1PXJQyF63phEPEb9Bo6xVzoJjJl-9C25BQRRSBHrGzp9tzY3Bh2WMv1JGJGG2thhuh2L6ap4QvM1jdyS9ObeNL61al_4UIk8CesZ0a5enheICPaL5YfbMHpwCWd3PutmABr-o05jtw.7dE9ieDGe3qOooGWmR-jJg' \ - --header 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'email=orion@test.com' \ - --data-urlencode 'newEmail=orion@xyzmail.com' - ``` - - * Example of response: A new signed JWT. - - ```txt - eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00ifQ.k3VZmBKoYebrmPQcV5vVrNG1d1s-Ee4Szjh--iUwHClWzOLZfHWBRNHAcp70IS7VZM6JcAtVqXmLHP9quaR3OxSpUAAcgxnG-zIt6ogkd9vxiCttgwNGAqnd4pWUZ9ie4AWi9S-subt5KXDQ41kEuMLMJ2ufHLc4yU7XmKm5rkEWwXTjmmJCfb-soreb1bUpZ-SfoQ3zVX9MWoHYInnjzyZYLUfQIq0JfZZhKx4v689aE27nCek5iol-42LsQzowOTa9kvzxbN9ZofP_mVSuuXNJk7lTTZqX8ZU-BlwA27_W0t0sDj3Ka8H2GYyqAIBbUcWc_MdeHDnUQWeAMF57Aw.LPYiVFh9FxVW2D57.JwHCxJsICElkF85gTBpgX1fOirjFohzWGFeozzfjuyrrC_PJJhzHIR1tsZ6lfQi7jrjHeCT-aRjOW2r-U-baEbkguEzCYyG68ynFjjU65kajeoKSgoI4SVgdByK_bnHGhv-CTUzv4d4gD0Jt0OYw9H9a5QvozA9r_RiRdF-WwEYoyYSlvIxzxx3hlL07tbYO6z_dcEcd_-Y3ylKooRSXsoG_FSd6IzuJqlD10Ixax1uL-bmap2rUEqMjpcnIcMiyL9nF_-PhAjC7FnhCWJUtkj9NGzxPxZqiak-Wc8c2SdXf0vRKaiL72MkIxRo.1IQPzuVpukQwyqBA9S0rZA - ``` - -## Exceptions - -In the use case layer, exceptions related with arguments will be -IllegalArgumentException. However, in the RESTful Web Service layer will be -transformed to Bad Request (HTTP 400). diff --git a/docs/usecases/updatePassword/updatePassword.md b/docs/usecases/updatePassword/updatePassword.md deleted file mode 100644 index 0288e0f..0000000 --- a/docs/usecases/updatePassword/updatePassword.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -layout: default -title: Update password -parent: Use Cases -nav_order: 8 ---- - -## Normal flow - -* A client sends user's e-mail, the current and the new password. -* The service check to see if the user's e-mail exists and if the given password - follow the password rules. Thus, update the user's password and return a - User in JSON. - -## HTTP(S) endpoints - -* /api/users/update/password - * HTTP method: PUT - * Consumes: application/x-www-form-urlencoded - * Produces: application/json - * Examples: - - * Example of request: - - ```shell - curl -X PUT \ - 'http://localhost:8080/api/users/update/password' \ - --header 'Accept: */*' \ - --header 'User-Agent: Thunder Client (https://www.thunderclient.com)' \ - --header 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'email=orion@test.com' \ - --data-urlencode 'password=12345678' \ - --data-urlencode 'newPassword=87654321' - ``` - - * Example of response: User in JSON. - - ```json - { - "hash": "7eba8ef2-426b-446a-9f05-4ab67e71383d", - "name": "Orion", - "email": "orion@test.com", - "emailValid": false - } - ``` - -## Exceptions - -In the use case layer, exceptions related with arguments will be -IllegalArgumentException. However, in the RESTful Web Service layer will be -transformed to Bad Request (HTTP 400). diff --git a/docs/usuario/2FA.md b/docs/usuario/2FA.md new file mode 100644 index 0000000..f1f931e --- /dev/null +++ b/docs/usuario/2FA.md @@ -0,0 +1,153 @@ +--- +layout: default +title: Autenticação em Dois Fatores (2FA) +parent: Documentação do Usuário +nav_order: 1 +--- + +# Autenticação em Dois Fatores (2FA) + +## O que é 2FA? + +A Autenticação em Dois Fatores (2FA) adiciona uma camada extra de segurança à sua conta. Além da sua senha, você precisará fornecer um código de verificação gerado por um aplicativo autenticador no seu dispositivo móvel. + +## Como Funciona? + +Quando você ativa o 2FA, o sistema gera um código QR que você escaneia com um aplicativo autenticador (como Google Authenticator, Microsoft Authenticator, ou Authy). A partir desse momento, sempre que você fizer login, além da sua senha, você precisará informar o código de 6 dígitos gerado pelo aplicativo. + +## Como Ativar o 2FA + +### Passo 1: Fazer Login + +Primeiro, faça login na sua conta usando seu email e senha normalmente. + +### Passo 2: Gerar o Código QR + +Envie uma requisição POST para o endpoint `/users/google/2FAuth/qrCode` com suas credenciais: + +```bash +curl -X POST \ + 'http://localhost:8080/users/google/2FAuth/qrCode' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=seu@email.com' \ + --data-urlencode 'password=suasenha' +``` + +A resposta será uma imagem PNG contendo o código QR. + +### Passo 3: Escanear o Código QR + +1. Abra o aplicativo autenticador no seu dispositivo móvel (Google Authenticator, Microsoft Authenticator, etc.) +2. Toque em "Adicionar conta" ou o botão "+" +3. Escolha "Escanear código QR" +4. Escaneie o código QR recebido na resposta da API +5. O aplicativo começará a gerar códigos de 6 dígitos que mudam a cada 30 segundos + +### Passo 4: Validar a Configuração + +Para confirmar que o 2FA está configurado corretamente, valide um código: + +```bash +curl -X POST \ + 'http://localhost:8080/users/google/2FAuth/validate' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=seu@email.com' \ + --data-urlencode 'code=123456' +``` + +Substitua `123456` pelo código atual exibido no seu aplicativo autenticador. + +Se a validação for bem-sucedida, você receberá um token JWT, confirmando que o 2FA está ativo. + +## Como Usar o 2FA no Login + +### Login Normal (sem 2FA) + +Se você não tiver 2FA ativado, o login funciona normalmente: + +```bash +curl -X POST \ + 'http://localhost:8080/users/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=seu@email.com' \ + --data-urlencode 'password=suasenha' +``` + +### Login com 2FA Ativado + +Se você tiver 2FA ativado, o processo é em duas etapas: + +**Etapa 1: Login Inicial** + +```bash +curl -X POST \ + 'http://localhost:8080/users/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=seu@email.com' \ + --data-urlencode 'password=suasenha' +``` + +A resposta indicará que o código 2FA é necessário: + +```json +{ + "requires2FA": true, + "message": "2FA code required" +} +``` + +**Etapa 2: Validar Código 2FA** + +Use o endpoint `/users/login/2fa` para completar a autenticação: + +```bash +curl -X POST \ + 'http://localhost:8080/users/login/2fa' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=seu@email.com' \ + --data-urlencode 'code=123456' +``` + +Substitua `123456` pelo código atual do seu aplicativo autenticador. + +Se o código estiver correto, você receberá o token JWT de autenticação. + +## Como Desativar o 2FA + +Atualmente, a desativação do 2FA requer contato com o suporte ou acesso direto ao banco de dados. Em versões futuras, será possível desativar através da interface do usuário. + +## Solução de Problemas + +### O código não está funcionando + +1. **Verifique a hora do dispositivo**: Os códigos TOTP dependem do tempo sincronizado. Certifique-se de que o relógio do seu dispositivo está correto. + +2. **Use o código mais recente**: Os códigos mudam a cada 30 segundos. Certifique-se de usar o código atual exibido no aplicativo. + +3. **Verifique se o 2FA está ativado**: Confirme que você completou o processo de ativação corretamente. + +### Perdi acesso ao aplicativo autenticador + +Se você perdeu acesso ao aplicativo autenticador e não tem códigos de backup, entre em contato com o suporte para recuperar o acesso à sua conta. + +### O QR Code não escaneia + +1. Certifique-se de que a imagem está nítida +2. Tente aumentar o brilho da tela +3. Verifique se o aplicativo autenticador tem permissão para usar a câmera +4. Tente inserir manualmente a chave secreta (se disponível) + +## Segurança + +- **Mantenha seu dispositivo seguro**: O aplicativo autenticador deve estar protegido com senha ou biometria +- **Não compartilhe códigos**: Nunca compartilhe códigos 2FA com outras pessoas +- **Use códigos de backup**: Alguns aplicativos permitem gerar códigos de backup - guarde-os em local seguro +- **Notifique sobre atividade suspeita**: Se receber códigos 2FA sem ter solicitado login, sua conta pode estar comprometida + +## Aplicativos Recomendados + +- **Google Authenticator**: Disponível para iOS e Android +- **Microsoft Authenticator**: Disponível para iOS e Android +- **Authy**: Disponível para iOS, Android e Desktop +- **1Password**: Inclui autenticador integrado + diff --git a/docs/usuario/WebAuthn.md b/docs/usuario/WebAuthn.md new file mode 100644 index 0000000..e2a5c91 --- /dev/null +++ b/docs/usuario/WebAuthn.md @@ -0,0 +1,279 @@ +--- +layout: default +title: WebAuthn (Autenticação sem Senha) +parent: Documentação do Usuário +nav_order: 2 +--- + +# WebAuthn (Autenticação sem Senha) + +## O que é WebAuthn? + +WebAuthn é um padrão de autenticação que permite fazer login sem usar senhas tradicionais. Em vez disso, você usa dispositivos de segurança físicos (como chaves de segurança USB) ou recursos biométricos do seu dispositivo (como impressão digital ou reconhecimento facial). + +## Dispositivos Suportados + +### Chaves de Segurança FIDO2 + +- **YubiKey**: Chaves de segurança USB e NFC +- **Google Titan**: Chaves de segurança USB e Bluetooth +- **Feitian**: Várias opções de chaves de segurança +- Qualquer dispositivo compatível com FIDO2/WebAuthn + +### Biometria + +- **Windows Hello**: Impressão digital e reconhecimento facial no Windows +- **Touch ID**: Impressão digital em dispositivos Apple +- **Face ID**: Reconhecimento facial em dispositivos Apple +- **Android Biometric**: Impressão digital e reconhecimento facial em Android + +## Como Registrar um Dispositivo WebAuthn + +### Passo 1: Iniciar o Registro + +Envie uma requisição POST para iniciar o processo de registro: + +```bash +curl -X POST \ + 'http://localhost:8080/users/webauthn/register/start' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=seu@email.com' +``` + +A resposta contém as opções de registro (PublicKeyCredentialCreationOptions) que serão usadas pelo navegador para criar a credencial. + +### Passo 2: Criar a Credencial no Navegador + +No seu aplicativo frontend, use a API WebAuthn do navegador para criar a credencial: + +```javascript +// Parse as opções recebidas do servidor +const options = JSON.parse(response.options); + +// Converter challenge de base64url para ArrayBuffer +const challenge = base64urlToArrayBuffer(options.challenge); + +// Criar a credencial +const credential = await navigator.credentials.create({ + publicKey: { + ...options, + challenge: challenge + } +}); +``` + +### Passo 3: Finalizar o Registro + +Envie a resposta da credencial para o servidor: + +```bash +curl -X POST \ + 'http://localhost:8080/users/webauthn/register/finish' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=seu@email.com' \ + --data-urlencode 'response=' \ + --data-urlencode 'deviceName=Meu Dispositivo' +``` + +O `deviceName` é opcional e ajuda você a identificar o dispositivo registrado. + +## Como Autenticar com WebAuthn + +### Passo 1: Iniciar a Autenticação + +Envie uma requisição POST para iniciar o processo de autenticação: + +```bash +curl -X POST \ + 'http://localhost:8080/users/webauthn/authenticate/start' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=seu@email.com' +``` + +A resposta contém as opções de autenticação (PublicKeyCredentialRequestOptions). + +### Passo 2: Autenticar no Navegador + +No seu aplicativo frontend, use a API WebAuthn do navegador: + +```javascript +// Parse as opções recebidas do servidor +const options = JSON.parse(response.options); + +// Converter challenge de base64url para ArrayBuffer +const challenge = base64urlToArrayBuffer(options.challenge); + +// Obter a credencial +const assertion = await navigator.credentials.get({ + publicKey: { + ...options, + challenge: challenge + } +}); +``` + +### Passo 3: Finalizar a Autenticação + +Envie a resposta da autenticação para o servidor: + +```bash +curl -X POST \ + 'http://localhost:8080/users/webauthn/authenticate/finish' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'email=seu@email.com' \ + --data-urlencode 'response=' +``` + +Se a autenticação for bem-sucedida, você receberá um token JWT. + +## Como Remover um Dispositivo + +Atualmente, a remoção de dispositivos WebAuthn requer contato com o suporte ou acesso direto ao banco de dados. Em versões futuras, será possível gerenciar dispositivos através da interface do usuário. + +## Requisitos Técnicos + +### Navegadores Suportados + +- **Chrome**: Versão 67+ +- **Firefox**: Versão 60+ +- **Safari**: Versão 13+ +- **Edge**: Versão 18+ + +### HTTPS Obrigatório + +O WebAuthn requer conexão HTTPS para funcionar. Em desenvolvimento local, você pode usar `https://localhost` com certificado auto-assinado. + +### Domínio Configurado + +O domínio deve estar configurado corretamente no servidor. Para desenvolvimento local, use `localhost`. + +## Solução de Problemas + +### "NotSupportedError" no navegador + +1. **Verifique o navegador**: Certifique-se de estar usando um navegador compatível com WebAuthn +2. **Verifique HTTPS**: WebAuthn só funciona em HTTPS (ou localhost) +3. **Verifique o dispositivo**: Certifique-se de que seu dispositivo suporta WebAuthn + +### A chave de segurança não é reconhecida + +1. **Verifique a conexão**: Certifique-se de que a chave está conectada corretamente +2. **Tente outra porta USB**: Algumas chaves funcionam melhor em portas USB 2.0 +3. **Verifique os drivers**: No Windows, pode ser necessário instalar drivers específicos +4. **Teste em outro navegador**: Alguns navegadores têm melhor suporte que outros + +### Biometria não funciona + +1. **Verifique as configurações**: Certifique-se de que a biometria está configurada no dispositivo +2. **Verifique as permissões**: O navegador precisa ter permissão para acessar a biometria +3. **Tente outro método**: Se Face ID não funcionar, tente Touch ID ou vice-versa + +### "Invalid credential" durante autenticação + +1. **Verifique o email**: Certifique-se de estar usando o mesmo email usado no registro +2. **Verifique o dispositivo**: Use o mesmo dispositivo ou chave de segurança usada no registro +3. **Verifique se o dispositivo está registrado**: Confirme que você completou o processo de registro + +## Segurança + +- **Proteja seu dispositivo**: Mantenha seu dispositivo físico seguro +- **Use chaves de segurança**: Chaves de segurança físicas são mais seguras que biometria +- **Tenha dispositivos de backup**: Registre múltiplos dispositivos para evitar perda de acesso +- **Notifique sobre atividade suspeita**: Se receber solicitações de autenticação WebAuthn sem ter solicitado, sua conta pode estar comprometida + +## Vantagens do WebAuthn + +- **Sem senhas**: Não precisa lembrar ou gerenciar senhas +- **Mais seguro**: Resistant a phishing e ataques de força bruta +- **Mais rápido**: Autenticação rápida com biometria ou chave de segurança +- **Padrão aberto**: Suportado por todos os principais navegadores e plataformas + +## Exemplo Completo (JavaScript) + +```javascript +// Função auxiliar para converter base64url para ArrayBuffer +function base64urlToArrayBuffer(base64url) { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +// Função auxiliar para converter ArrayBuffer para base64url +function arrayBufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + const base64 = btoa(binary); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +// Registrar dispositivo +async function registerWebAuthn(email) { + // 1. Iniciar registro + const startResponse = await fetch('/users/webauthn/register/start', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ email }) + }); + const startData = await startResponse.json(); + const options = JSON.parse(startData.options); + + // 2. Criar credencial + const publicKey = { + ...options, + challenge: base64urlToArrayBuffer(options.challenge) + }; + const credential = await navigator.credentials.create({ publicKey }); + + // 3. Finalizar registro + const finishResponse = await fetch('/users/webauthn/register/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + email, + response: JSON.stringify(credential), + deviceName: 'Meu Dispositivo' + }) + }); + + return await finishResponse.json(); +} + +// Autenticar com WebAuthn +async function authenticateWebAuthn(email) { + // 1. Iniciar autenticação + const startResponse = await fetch('/users/webauthn/authenticate/start', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ email }) + }); + const startData = await startResponse.json(); + const options = JSON.parse(startData.options); + + // 2. Obter credencial + const publicKey = { + ...options, + challenge: base64urlToArrayBuffer(options.challenge) + }; + const assertion = await navigator.credentials.get({ publicKey }); + + // 3. Finalizar autenticação + const finishResponse = await fetch('/users/webauthn/authenticate/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + email, + response: JSON.stringify(assertion) + }) + }); + + return await finishResponse.json(); +} +``` + diff --git a/docs/usuario/index.md b/docs/usuario/index.md new file mode 100644 index 0000000..06f46ff --- /dev/null +++ b/docs/usuario/index.md @@ -0,0 +1,72 @@ +--- +layout: default +title: Documentação do Usuário +nav_order: 1 +--- + +# Documentação do Usuário + +Bem-vindo à documentação do usuário do Orion Users! Esta seção contém guias detalhados sobre como usar as funcionalidades de segurança avançada disponíveis no serviço. + +## Funcionalidades Disponíveis + +### Autenticação em Dois Fatores (2FA) + +A autenticação em dois fatores adiciona uma camada extra de segurança à sua conta usando códigos TOTP gerados por aplicativos autenticadores. + +[**Guia Completo de 2FA →**](2FA.md) + +**Recursos:** +- Geração de código QR para configuração +- Suporte a aplicativos autenticadores populares (Google Authenticator, Microsoft Authenticator, etc.) +- Integração com o fluxo de login existente +- Validação de códigos TOTP + +### WebAuthn (Autenticação sem Senha) + +O WebAuthn permite autenticação sem senhas usando chaves de segurança físicas ou biometria do dispositivo. + +[**Guia Completo de WebAuthn →**](WebAuthn.md) + +**Recursos:** +- Suporte a chaves de segurança FIDO2 +- Autenticação biométrica (impressão digital, reconhecimento facial) +- Registro e gerenciamento de múltiplos dispositivos +- Autenticação rápida e segura + +## Início Rápido + +### Ativar 2FA + +1. Faça login na sua conta +2. Gere um código QR através do endpoint `/users/google/2FAuth/qrCode` +3. Escaneie o código com um aplicativo autenticador +4. Valide a configuração com um código TOTP + +### Registrar Dispositivo WebAuthn + +1. Inicie o registro através do endpoint `/users/webauthn/register/start` +2. Use a API WebAuthn do navegador para criar a credencial +3. Finalize o registro através do endpoint `/users/webauthn/register/finish` + +## Segurança + +Ambas as funcionalidades (2FA e WebAuthn) foram projetadas para aumentar significativamente a segurança da sua conta: + +- **2FA**: Adiciona uma segunda camada de autenticação usando algo que você possui (seu dispositivo móvel) +- **WebAuthn**: Elimina a necessidade de senhas, usando criptografia de chave pública e dispositivos físicos ou biometria + +## Suporte + +Se você encontrar problemas ou tiver dúvidas: + +1. Consulte a seção "Solução de Problemas" em cada guia +2. Verifique os requisitos técnicos +3. Entre em contato com o suporte se necessário + +## Próximos Passos + +- [Configurar 2FA](2FA.md) +- [Configurar WebAuthn](WebAuthn.md) +- [Documentação da API](../usecases/usecases.md) + diff --git a/mvnw b/mvnw index 8a8fb22..8d937f4 100755 --- a/mvnw +++ b/mvnw @@ -8,7 +8,7 @@ # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # -# https://www.apache.org/licenses/LICENSE-2.0 +# 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 @@ -19,7 +19,7 @@ # ---------------------------------------------------------------------------- # ---------------------------------------------------------------------------- -# Maven Start Up Batch script +# Apache Maven Wrapper startup batch script, version 3.2.0 # # Required ENV vars: # ------------------ @@ -27,7 +27,6 @@ # # Optional ENV vars # ----------------- -# M2_HOME - location of maven2's installed home dir # MAVEN_OPTS - parameters passed to the Java VM when running Maven # e.g. to debug Maven itself, use # set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 @@ -54,7 +53,7 @@ fi cygwin=false; darwin=false; mingw=false -case "`uname`" in +case "$(uname)" in CYGWIN*) cygwin=true ;; MINGW*) mingw=true;; Darwin*) darwin=true @@ -62,9 +61,9 @@ case "`uname`" in # See https://developer.apple.com/library/mac/qa/qa1170/_index.html if [ -z "$JAVA_HOME" ]; then if [ -x "/usr/libexec/java_home" ]; then - export JAVA_HOME="`/usr/libexec/java_home`" + JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME else - export JAVA_HOME="/Library/Java/Home" + JAVA_HOME="/Library/Java/Home"; export JAVA_HOME fi fi ;; @@ -72,68 +71,38 @@ esac if [ -z "$JAVA_HOME" ] ; then if [ -r /etc/gentoo-release ] ; then - JAVA_HOME=`java-config --jre-home` + JAVA_HOME=$(java-config --jre-home) fi fi -if [ -z "$M2_HOME" ] ; then - ## resolve links - $0 may be a link to maven's home - PRG="$0" - - # need this for relative symlinks - while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG="`dirname "$PRG"`/$link" - fi - done - - saveddir=`pwd` - - M2_HOME=`dirname "$PRG"`/.. - - # make it fully qualified - M2_HOME=`cd "$M2_HOME" && pwd` - - cd "$saveddir" - # echo Using m2 at $M2_HOME -fi - # For Cygwin, ensure paths are in UNIX format before anything is touched if $cygwin ; then - [ -n "$M2_HOME" ] && - M2_HOME=`cygpath --unix "$M2_HOME"` [ -n "$JAVA_HOME" ] && - JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + JAVA_HOME=$(cygpath --unix "$JAVA_HOME") [ -n "$CLASSPATH" ] && - CLASSPATH=`cygpath --path --unix "$CLASSPATH"` + CLASSPATH=$(cygpath --path --unix "$CLASSPATH") fi # For Mingw, ensure paths are in UNIX format before anything is touched if $mingw ; then - [ -n "$M2_HOME" ] && - M2_HOME="`(cd "$M2_HOME"; pwd)`" - [ -n "$JAVA_HOME" ] && - JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && + JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" fi if [ -z "$JAVA_HOME" ]; then - javaExecutable="`which javac`" - if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + javaExecutable="$(which javac)" + if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then # readlink(1) is not available as standard on Solaris 10. - readLink=`which readlink` - if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + readLink=$(which readlink) + if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then if $darwin ; then - javaHome="`dirname \"$javaExecutable\"`" - javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + javaHome="$(dirname "\"$javaExecutable\"")" + javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" else - javaExecutable="`readlink -f \"$javaExecutable\"`" + javaExecutable="$(readlink -f "\"$javaExecutable\"")" fi - javaHome="`dirname \"$javaExecutable\"`" - javaHome=`expr "$javaHome" : '\(.*\)/bin'` + javaHome="$(dirname "\"$javaExecutable\"")" + javaHome=$(expr "$javaHome" : '\(.*\)/bin') JAVA_HOME="$javaHome" export JAVA_HOME fi @@ -149,7 +118,7 @@ if [ -z "$JAVACMD" ] ; then JAVACMD="$JAVA_HOME/bin/java" fi else - JAVACMD="`\\unset -f command; \\command -v java`" + JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" fi fi @@ -163,12 +132,9 @@ if [ -z "$JAVA_HOME" ] ; then echo "Warning: JAVA_HOME environment variable is not set." fi -CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher - # traverses directory structure from process work directory to filesystem root # first directory with .mvn subdirectory is considered project base directory find_maven_basedir() { - if [ -z "$1" ] then echo "Path not specified to find_maven_basedir" @@ -184,96 +150,99 @@ find_maven_basedir() { fi # workaround for JBEAP-8937 (on Solaris 10/Sparc) if [ -d "${wdir}" ]; then - wdir=`cd "$wdir/.."; pwd` + wdir=$(cd "$wdir/.." || exit 1; pwd) fi # end of workaround done - echo "${basedir}" + printf '%s' "$(cd "$basedir" || exit 1; pwd)" } # concatenates all lines of a file concat_lines() { if [ -f "$1" ]; then - echo "$(tr -s '\n' ' ' < "$1")" + # Remove \r in case we run on Windows within Git Bash + # and check out the repository with auto CRLF management + # enabled. Otherwise, we may read lines that are delimited with + # \r\n and produce $'-Xarg\r' rather than -Xarg due to word + # splitting rules. + tr -s '\r\n' ' ' < "$1" + fi +} + +log() { + if [ "$MVNW_VERBOSE" = true ]; then + printf '%s\n' "$1" fi } -BASE_DIR=`find_maven_basedir "$(pwd)"` +BASE_DIR=$(find_maven_basedir "$(dirname "$0")") if [ -z "$BASE_DIR" ]; then exit 1; fi +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +log "$MAVEN_PROJECTBASEDIR" + ########################################################################################## # Extension to allow automatically downloading the maven-wrapper.jar from Maven-central # This allows using the maven wrapper in projects that prohibit checking in binary data. ########################################################################################## -if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found .mvn/wrapper/maven-wrapper.jar" - fi +wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" +if [ -r "$wrapperJarPath" ]; then + log "Found $wrapperJarPath" else - if [ "$MVNW_VERBOSE" = true ]; then - echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." - fi + log "Couldn't find $wrapperJarPath, downloading it ..." + if [ -n "$MVNW_REPOURL" ]; then - jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" else - jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" fi - while IFS="=" read key value; do - case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + while IFS="=" read -r key value; do + # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) + safeValue=$(echo "$value" | tr -d '\r') + case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; esac - done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" - if [ "$MVNW_VERBOSE" = true ]; then - echo "Downloading from: $jarUrl" - fi - wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" + log "Downloading from: $wrapperUrl" + if $cygwin; then - wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") fi if command -v wget > /dev/null; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found wget ... using wget" - fi + log "Found wget ... using wget" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" else - wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" fi elif command -v curl > /dev/null; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found curl ... using curl" - fi + log "Found curl ... using curl" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - curl -o "$wrapperJarPath" "$jarUrl" -f + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" else - curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" fi - else - if [ "$MVNW_VERBOSE" = true ]; then - echo "Falling back to using Java to download" - fi - javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + log "Falling back to using Java to download" + javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" # For Cygwin, switch paths to Windows format before running javac if $cygwin; then - javaClass=`cygpath --path --windows "$javaClass"` + javaSource=$(cygpath --path --windows "$javaSource") + javaClass=$(cygpath --path --windows "$javaClass") fi - if [ -e "$javaClass" ]; then - if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then - if [ "$MVNW_VERBOSE" = true ]; then - echo " - Compiling MavenWrapperDownloader.java ..." - fi - # Compiling the Java class - ("$JAVA_HOME/bin/javac" "$javaClass") + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + log " - Compiling MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/javac" "$javaSource") fi - if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then - # Running the downloader - if [ "$MVNW_VERBOSE" = true ]; then - echo " - Running MavenWrapperDownloader.java ..." - fi - ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + if [ -e "$javaClass" ]; then + log " - Running MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" fi fi fi @@ -282,35 +251,58 @@ fi # End of extension ########################################################################################## -export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} -if [ "$MVNW_VERBOSE" = true ]; then - echo $MAVEN_PROJECTBASEDIR +# If specified, validate the SHA-256 sum of the Maven wrapper jar file +wrapperSha256Sum="" +while IFS="=" read -r key value; do + case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; + esac +done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" +if [ -n "$wrapperSha256Sum" ]; then + wrapperSha256Result=false + if command -v sha256sum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + elif command -v shasum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." + echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." + exit 1 + fi + if [ $wrapperSha256Result = false ]; then + echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 + echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 + echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 + exit 1 + fi fi + MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" # For Cygwin, switch paths to Windows format before running java if $cygwin; then - [ -n "$M2_HOME" ] && - M2_HOME=`cygpath --path --windows "$M2_HOME"` [ -n "$JAVA_HOME" ] && - JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") [ -n "$CLASSPATH" ] && - CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + CLASSPATH=$(cygpath --path --windows "$CLASSPATH") [ -n "$MAVEN_PROJECTBASEDIR" ] && - MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` + MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") fi # Provide a "standardized" way to retrieve the CLI args that will # work with both Windows and non-Windows executions. -MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" export MAVEN_CMD_LINE_ARGS WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain +# shellcheck disable=SC2086 # safe args exec "$JAVACMD" \ $MAVEN_OPTS \ $MAVEN_DEBUG_OPTS \ -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ - "-Dmaven.home=${M2_HOME}" \ "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd index 1d8ab01..c4586b5 100755 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -7,7 +7,7 @@ @REM "License"); you may not use this file except in compliance @REM with the License. You may obtain a copy of the License at @REM -@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM http://www.apache.org/licenses/LICENSE-2.0 @REM @REM Unless required by applicable law or agreed to in writing, @REM software distributed under the License is distributed on an @@ -18,13 +18,12 @@ @REM ---------------------------------------------------------------------------- @REM ---------------------------------------------------------------------------- -@REM Maven Start Up Batch script +@REM Apache Maven Wrapper startup batch script, version 3.2.0 @REM @REM Required ENV vars: @REM JAVA_HOME - location of a JDK home dir @REM @REM Optional ENV vars -@REM M2_HOME - location of maven2's installed home dir @REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands @REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending @REM MAVEN_OPTS - parameters passed to the Java VM when running Maven @@ -120,10 +119,10 @@ SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain -set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B ) @REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central @@ -134,11 +133,11 @@ if exist %WRAPPER_JAR% ( ) ) else ( if not "%MVNW_REPOURL%" == "" ( - SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" ) if "%MVNW_VERBOSE%" == "true" ( echo Couldn't find %WRAPPER_JAR%, downloading it ... - echo Downloading from: %DOWNLOAD_URL% + echo Downloading from: %WRAPPER_URL% ) powershell -Command "&{"^ @@ -146,7 +145,7 @@ if exist %WRAPPER_JAR% ( "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ "}"^ - "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ "}" if "%MVNW_VERBOSE%" == "true" ( echo Finished downloading %WRAPPER_JAR% @@ -154,6 +153,24 @@ if exist %WRAPPER_JAR% ( ) @REM End of extension +@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file +SET WRAPPER_SHA_256_SUM="" +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B +) +IF NOT %WRAPPER_SHA_256_SUM%=="" ( + powershell -Command "&{"^ + "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ + "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ + " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ + " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ + " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ + " exit 1;"^ + "}"^ + "}" + if ERRORLEVEL 1 goto error +) + @REM Provide a "standardized" way to retrieve the CLI args that will @REM work with both Windows and non-Windows executions. set MAVEN_CMD_LINE_ARGS=%* diff --git a/pom.xml b/pom.xml index 1b716b0..636d176 100755 --- a/pom.xml +++ b/pom.xml @@ -1,22 +1,22 @@ - + 4.0.0 dev.orion users - 1.0.0 + 0.0.5 - 3.10.1 - false - 17 + 3.12.1 + 21 UTF-8 UTF-8 quarkus-bom io.quarkus.platform - 3.5.0 - https://sonarcloud.io - orion-services - 3.0.0-M7 + 3.29.0 + 3.2.5 + 2.1.0 + 21 @@ -30,13 +30,11 @@ - - - org.modelmapper - modelmapper - 3.1.1 - - + + org.modelmapper + modelmapper + 3.1.1 + de.taimos totp @@ -59,22 +57,10 @@ io.quarkus quarkus-smallrye-openapi - - io.quarkus - quarkus-jacoco - io.quarkus quarkus-smallrye-fault-tolerance - - io.quarkus - quarkus-resteasy-reactive-jackson - - - io.quarkus - quarkus-resteasy-reactive - io.quarkus quarkus-hibernate-reactive-panache @@ -100,10 +86,6 @@ io.quarkus quarkus-mailer - - io.quarkus - quarkus-resteasy-reactive-qute - org.passay passay @@ -113,17 +95,41 @@ io.quarkus quarkus-oidc - - org.projectlombok - lombok - 1.18.24 - provided + + io.quarkus + quarkus-kotlin + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 commons-codec commons-codec 1.15 + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-rest-qute + + + com.webauthn4j + webauthn4j-core + 0.21.0.RELEASE + + + io.quarkus + quarkus-jacoco + test + io.quarkus quarkus-junit5 @@ -151,7 +157,7 @@ quarkus-test-security test - + io.quarkus quarkus-junit5-mockito 2.12.0.Final @@ -183,13 +189,77 @@ + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + + compile + + + + ${project.basedir}/src/main/kotlin + + ${kotlin.compiler.jvmTarget} + + -Xjvm-default=all + + + + + test-compile + test-compile + + test-compile + + + + ${project.basedir}/src/test/kotlin + + ${kotlin.compiler.jvmTarget} + + -Xjvm-default=all + + + + + + ${kotlin.compiler.jvmTarget} + + -Xjvm-default=all + -Xno-param-assertions + + + + + org.jetbrains.kotlin + kotlin-maven-allopen + ${kotlin.version} + + + maven-surefire-plugin ${surefire-plugin.version} + + + test + + test + + + org.jboss.logmanager.LogManager ${maven.home} + ${maven.multiModuleProjectDirectory}/target/jacoco-quarkus.exec + true + ${maven.multiModuleProjectDirectory}/target/coverage @@ -198,6 +268,7 @@ ${surefire-plugin.version} + verify integration-test verify @@ -208,6 +279,97 @@ + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + default-prepare-agent + + prepare-agent + + + *QuarkusClassLoader + ${project.build.directory}/jacoco-quarkus.exec + true + + + + test + test + + report + + + ${project.build.directory}/jacoco-quarkus.exec + ${project.build.directory}/jacoco-report + + + + verify + check + + report + + + ${project.build.directory}/jacoco-quarkus.exec + ${project.build.directory}/jacoco-report + + + + + + + PACKAGE + + + LINE + COVEREDRATIO + 0.8 + + + + + + + + maven-checkstyle-plugin + 3.1.1 + + + verify + + check + + + + + true + warning + 10 + static/checkstyle.xml + + + + maven-pmd-plugin + 3.22.0 + + + verify + + check + + + + + true + 10 + + /static/pmd.xml + + + @@ -240,7 +402,6 @@ - org.apache.maven.plugins maven-surefire-plugin 3.0.0 diff --git a/src/main/java/dev/orion/users/adapters/controllers/BasicController.java b/src/main/java/dev/orion/users/adapters/controllers/BasicController.java deleted file mode 100644 index 862d055..0000000 --- a/src/main/java/dev/orion/users/adapters/controllers/BasicController.java +++ /dev/null @@ -1,190 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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.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 java.util.HashSet; -import java.util.Optional; - -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.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; - -/** - * The controller class. - */ -public class BasicController { - - /** The encoding used in the QR code. */ - private static final String UTF_8 = "UTF-8"; - - /** Configure the issuer for JWT generation. */ - @ConfigProperty(name = "users.issuer") - Optional issuer; - - /** Set the validation url. */ - @ConfigProperty(name = "users.email.validation.url", - defaultValue = "http://localhost:8080/users/validateEmail") - String validateURL; - - /** ModelMapper. */ - ModelMapper mapper = new ModelMapper(); - - /** - * Creates a JWT (JSON Web Token) to a user. - * - * @param user : The user object - * - * @return Returns the JWT - */ - public String generateJWT(final UserEntity user) { - return Jwt.issuer(issuer.orElse("orion-users")) - .upn(user.getEmail()) - .groups(new HashSet<>(user.getRoleList())) - .claim(Claims.c_hash, user.getHash()) - .claim(Claims.email, user.getEmail()) - .sign(); - } - - /** - * Verifies if the e-mail from the jwt is the same from request. - * - * @param email : Request e-mail - * @param jwtEmail : JWT e-mail - * @return true if the e-mails are the same - * @throws ServiceException Throw an exception (HTTP 400) if the e-mails are - * different, indicating that possibly the JWT is outdated. - */ - public boolean checkTokenEmail(final String email, - final String jwtEmail) { - if (!email.equals(jwtEmail)) { - throw new ServiceException("JWT outdated", - Response.Status.BAD_REQUEST); - } - return true; - } - - /** - * Send a message to the user validates the e-mail. - * - * @param user : A user object - * @return Return a Uni after to send an e-mail. - */ - public Uni sendValidationEmail(final UserEntity user) { - StringBuilder url = new StringBuilder(); - url.append(validateURL); - url.append("?code=" + user.getEmailValidationCode()); - url.append("&email=" + user.getEmail()); - - return MailTemplate.validateEmail(url.toString()) - .to(user.getEmail()) - .subject("E-mail confirmation") - .send() - .onItem().ifNotNull() - .transform(item -> user); - } - - /** - * Create Time-based one-time password. - * - * @return The Time-based one-time password code in String format - * @throws IllegalArgumentException - */ - public String getTOTPCode(String secretKey) { - try { - Base32 base32 = new Base32(); - byte[] bytes = base32.decode(secretKey); - String hexKey = Hex.encodeHexString(bytes); - return TOTP.getOTP(hexKey); - } catch (Exception e) { - throw new IllegalArgumentException(e); - } - } - - /** - * Create Google Bar Code. - * - * @return The Google Bar Code in String format - * @throws IllegalArgumentException - */ - public String getAuthenticatorBarCode(String secretKey, String account, String issuer) { - try { - return "otpauth://totp/" - + URLEncoder.encode(issuer + ":" + account, UTF_8).replace("+", "%20") - + "?secret=" + URLEncoder.encode(secretKey, UTF_8).replace("+", "%20") - + "&issuer=" + URLEncoder.encode(issuer, UTF_8).replace("+", "%20"); - } catch (UnsupportedEncodingException | NullPointerException e) { - throw new IllegalStateException(e); - } - } - - /** - * Create QrCode. - * - * @return The QrCode with Google Bar Code in a array of byte format - * @throws IllegalArgumentException - */ - public byte[] createQrCode(String barCodeData) { - try { - BitMatrix matrix = new MultiFormatWriter().encode(barCodeData, BarcodeFormat.QR_CODE, 400, 400); - BufferedImage image = MatrixToImageWriter.toBufferedImage(matrix); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ImageIO.write(image, "png", baos); - return baos.toByteArray(); - } catch (WriterException | IOException | NullPointerException e) { - throw new IllegalStateException(e); - } - } - - /** - * Generate Secret Key. - * - * @return The Secret Key in String format - * @throws IllegalArgumentException - */ - public String generateSecretKey() { - SecureRandom random = new SecureRandom(); - byte[] bytes = new byte[20]; - random.nextBytes(bytes); - Base32 base32 = new Base32(); - return base32.encodeToString(bytes); - } - -} diff --git a/src/main/java/dev/orion/users/adapters/controllers/UserController.java b/src/main/java/dev/orion/users/adapters/controllers/UserController.java deleted file mode 100644 index dccb187..0000000 --- a/src/main/java/dev/orion/users/adapters/controllers/UserController.java +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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.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.application.interfaces.AuthenticateUCI; -import dev.orion.users.application.interfaces.CreateUserUCI; -import dev.orion.users.application.usecases.AuthenticateUC; -import dev.orion.users.application.usecases.CreateUserUC; -import dev.orion.users.enterprise.model.User; -import io.quarkus.hibernate.reactive.panache.common.WithSession; -import io.smallrye.mutiny.Uni; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; - -/** - * The controller class. - */ -@ApplicationScoped -@WithSession -public class UserController extends BasicController { - - /** Use cases for users */ - private CreateUserUCI createUC = new CreateUserUC(); - - /** Use cases for authentication.*/ - private AuthenticateUCI authenticationUC = new AuthenticateUC(); - - /** Persistence layer */ - @Inject - UserRepository userRepository; - - /** - * Create a new user. Validates the business rules, persists the user and - * sends an e-mail to the user confirming the registration. - * - * @param name : The user name - * @param email : The user e-mail - * @param password : The user password - * @return : Returns a Uni object - */ - public Uni createUser(String name, String email, String pwd) { - User user = createUC.createUser(name, email, pwd); - UserEntity entity = mapper.map(user, UserEntity.class); - return userRepository.createUser(entity) - .onItem().ifNotNull().transform(u -> u) - .onItem().ifNotNull().call(this::sendValidationEmail); - } - - /** - * Validates the e-mail of a user. - * - * @param email : The e-mail of the user - * @param code : The validation code - * @return : Returns a Uni object - */ - public Uni validateEmail(final String email, - final String code) { - Uni result = null; - if (Boolean.TRUE.equals(authenticationUC.validateEmail(email, code))) { - result = userRepository.validateEmail(email, code); - } - return result; - } - - /** - * Authenticates the user in the service. - * - * @param email : The user e-mail - * @param password : The user password - * @return : Returns a JSON Web Token (JWT) - */ - public Uni authenticate(final String email, final String password) { - // Creates a user in the model to encrypts the password and - // converts it to an entity - UserEntity entity = mapper.map( - authenticationUC.authenticate(email, password), - UserEntity.class); - - // Finds the user in the service through email and password and - // generates a JWT - return userRepository.authenticate(entity) - .onItem().ifNotNull() - .transform(this::generateJWT); - } - - /** - * Creates a user, generates a Json Web Token and returns a - * AuthenticationDTO object. - * - * @param name : The user name - * @param email : The user e-mail - * @param password : The user password - * @return A Uni object - */ - public Uni createAuthenticate(final String name, - final String email, final String password) { - - return this.createUser(name, email, password) - .onItem().ifNotNull().transform(user -> { - AuthenticationDTO dto = new AuthenticationDTO(); - dto.setToken(this.generateJWT(user)); - dto.setUser(user); - return dto; - }); - } - - /** - * Delete a user from the service. - * - * @param email The user's e-mail - * @return A Uni object - */ - public Uni deleteUser(final String email) { - return userRepository.deleteUser(email); - } - -} diff --git a/src/main/java/dev/orion/users/adapters/gateways/entities/UserEntity.java b/src/main/java/dev/orion/users/adapters/gateways/entities/UserEntity.java deleted file mode 100644 index de6a557..0000000 --- a/src/main/java/dev/orion/users/adapters/gateways/entities/UserEntity.java +++ /dev/null @@ -1,143 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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.entities; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.ManyToMany; -import jakarta.persistence.Table; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotNull; - -import com.fasterxml.jackson.annotation.JsonIgnore; - -import io.quarkus.hibernate.reactive.panache.PanacheEntityBase; -import lombok.Getter; -import lombok.Setter; - -/** - * User Entity. - */ -@Entity -@Getter -@Setter -@Table(name = "User") -public class UserEntity extends PanacheEntityBase { - - /** Default size for column. */ - private static final int COLUMN_LENGTH = 256; - - /** Primary key. */ - @Id - @GeneratedValue - @JsonIgnore - private Long id; - - /** The hash used to identify the user. */ - private String hash; - - /** The name of the user. */ - @NotNull(message = "The name can't be null") - private String name; - - /** The e-mail of the user. */ - @NotNull(message = "The e-mail can't be null") - @Email(message = "The e-mail format is necessary") - private String email; - - /** The password of the user. */ - @JsonIgnore - @Column(length = COLUMN_LENGTH) - @NotNull(message = "The password can't be null") - private String password; - - /** Role list. */ - @JsonIgnore - @ManyToMany(fetch = FetchType.EAGER) - private List roles; - - /** Stores if the e-mail was validated. */ - private boolean emailValid; - - /** The hash used to identify the user. */ - @JsonIgnore - private String emailValidationCode; - - /** Stores if is using 2FA */ - private boolean isUsing2FA; - - /** Secret code to be used at 2FA validation */ - private String secret2FA; - - /** - * User constructor. - */ - public UserEntity() { - this.hash = UUID.randomUUID().toString(); - this.roles = new ArrayList<>(); - this.emailValidationCode = UUID.randomUUID().toString(); - } - - /** - * Add a role in a user. - * - * @param role A role object. - */ - public void addRole(final RoleEntity role) { - roles.add(role); - } - - /** - * Transform the a list of object role to a list of String. The role "user" - * is the default role of the server - * - * @return A list of roles in String format - */ - @JsonIgnore - public List getRoleList() { - List strRoles = new ArrayList<>(); - if (this.roles.isEmpty()) { - strRoles.add("user"); - } else { - for (RoleEntity role : roles) { - strRoles.add(role.getName()); - } - } - return strRoles; - } - - /** - * Generates a e-mail validation code to the user. - */ - public void setEmailValidationCode() { - this.emailValidationCode = UUID.randomUUID().toString(); - } - - /** - * Removes all roles of the object. - */ - public void removeRoles() { - this.roles.clear(); - } -} diff --git a/src/main/java/dev/orion/users/adapters/gateways/entities/package-info.java b/src/main/java/dev/orion/users/adapters/gateways/entities/package-info.java deleted file mode 100644 index c6986c5..0000000 --- a/src/main/java/dev/orion/users/adapters/gateways/entities/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Model package. - */ -package dev.orion.users.adapters.gateways.entities; diff --git a/src/main/java/dev/orion/users/adapters/gateways/repository/UserRepositoryImpl.java b/src/main/java/dev/orion/users/adapters/gateways/repository/UserRepositoryImpl.java deleted file mode 100644 index b55aa81..0000000 --- a/src/main/java/dev/orion/users/adapters/gateways/repository/UserRepositoryImpl.java +++ /dev/null @@ -1,330 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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 java.io.IOException; -import java.util.Map; - -import jakarta.enterprise.context.ApplicationScoped; - -import org.apache.commons.codec.digest.DigestUtils; -import org.passay.CharacterData; -import org.passay.CharacterRule; -import org.passay.EnglishCharacterData; -import org.passay.PasswordGenerator; - -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; - -/** - * Implements the repository pattern for the user entity. - */ -@ApplicationScoped -public class UserRepositoryImpl implements UserRepository { - - /** Setting the default role name. */ - private static final String DEFAULT_ROLE_NAME = "user"; - - /** Default password length. */ - private static final int PASSWORD_LENGTH = 8; - - /** Default user not found message. */ - private static final String USER_NOT_FOUND_ERROR = "Error: user not found"; - - /** E-mail column. */ - private static final String EMAIL = "email"; - - /** Password column. */ - private static final String PASSWORD = "password"; - - /** - * Creates a user in the service. - * - * @param u : A user object - * @return Returns a user asynchronously - */ - @Override - public Uni createUser(final UserEntity u) { - return checkEmail(u.getEmail()) - .onItem().ifNotNull().transform(user -> user) - .onItem().ifNull().switchTo(() -> checkName(u.getName()) - .onItem().ifNotNull() - .failWith(new IllegalArgumentException( - "The name already existis")) - .onItem().ifNull().switchTo(() -> checkHash(u.getHash()) - .onItem().ifNotNull() - .failWith(new IllegalArgumentException( - "The hash already existis")) - .onItem().ifNull().switchTo(() -> { - if (u.getPassword().isBlank()) { - u.setPassword(generateSecurePassword()); - } - return persistUser(u); - }))); - } - - /** - * Returns a user searching for e-mail and password. - * - * @param user : A user object - * @return Uni object - */ - @Override - public Uni authenticate(final UserEntity user) { - Map params = Parameters.with(EMAIL, user.getEmail()) - .and(PASSWORD, user.getPassword()).map(); - return find("email = :email and password = :password", params) - .firstResult() - .onItem().ifNotNull().transform(loadedUser -> loadedUser); - } - - /** - * Updates the user's e-mail. - * - * @param email : User's email - * @param newEmail : New User's Email - * @return Uni object - */ - @Override - public Uni updateEmail( - final String email, - final String newEmail) { - return checkEmail(email) - .onItem().ifNull() - .failWith(new IllegalArgumentException(USER_NOT_FOUND_ERROR)) - .onItem().ifNotNull() - .transformToUni(user -> checkEmail(newEmail) - .onItem().ifNotNull() - .failWith(new IllegalArgumentException( - "Email already in use")) - .onItem().ifNull() - .switchTo(() -> { - user.setEmailValidationCode(); - user.setEmailValid(false); - user.setEmail(newEmail); - return Panache.withTransaction( - user::persist); - })); - } - - /** - * 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 - public Uni validateEmail(final String email, final String code) { - Map params = Parameters.with(EMAIL, - email).and("code", code).map(); - return find("email = :email and emailValidationCode = :code", - params) - .firstResult() - .onItem().ifNotNull().transformToUni(user -> { - user.setEmailValid(true); - return Panache.withTransaction(user::persist); - }) - .onItem().ifNull() - .failWith(new 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 - public Uni changePassword( - final String password, - final String newPassword, - final String email) { - return checkEmail(email) - .onItem().ifNull() - .failWith(new IllegalArgumentException(USER_NOT_FOUND_ERROR)) - .onItem().ifNotNull() - .transformToUni(user -> { - if (password.equals(user.getPassword())) { - user.setPassword(newPassword); - } else { - throw new IllegalArgumentException( - "Passwords doesn't match"); - } - return Panache.withTransaction(user::persist); - }); - } - - /** - * 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 - public Uni recoverPassword(final String email) { - String password = generateSecurePassword(); - return checkEmail(email) - .onItem().ifNull() - .failWith(new IllegalArgumentException("E-mail not found")) - .onItem().ifNotNull() - .transformToUni(user -> changePassword(user.getPassword(), - DigestUtils.sha256Hex(password), email) - .onItem().transform(item -> password)); - } - - /** - * Deletes a User from the service. - * - * @param email : User email - * @return Return 1 if user was deleted - */ - @Override - public Uni deleteUser(final String email) { - return checkEmail(email) - .onItem().ifNull().failWith( - new 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 Uni checkEmail(final String email) { - return 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 Uni checkName(final String email) { - return 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 Uni checkHash(final String hash) { - return find("hash", hash).firstResult(); - } - - /** - * Persists a user in the service with a default role (user). - * - * @param user : The user object - * @return Uni object - */ - private Uni persistUser(final UserEntity user) { - return getDefaultRole() - .onItem().ifNull() - .failWith(new IOException("Role not found")) - .onItem().ifNotNull() - .transformToUni(role -> { - user.addRole(role); - return Panache.withTransaction(user::persist); - }); - } - - /** - * Gets the default role "user" from the database. - * - * @return The Uni object of "user" role. - */ - private Uni getDefaultRole() { - return RoleEntity.find("name", DEFAULT_ROLE_NAME).firstResult(); - } - - /** - * Generates a new Secure Password String. - * - * @return A new password - */ - private static String generateSecurePassword() { - // Character rule for lower case characters - CharacterRule lcr = new CharacterRule(EnglishCharacterData.LowerCase); - // Set the number of lower case characters - lcr.setNumberOfCharacters(2); - // Character rule for uppercase characters. - CharacterRule ucr = new CharacterRule(EnglishCharacterData.UpperCase); - // Set the number of upper case characters - ucr.setNumberOfCharacters(2); - - // Character rule for digit characters - CharacterRule dr = new CharacterRule(EnglishCharacterData.Digit); - // Set the number of digit characters. - dr.setNumberOfCharacters(2); - - // Character rule for special characters - CharacterData special = defineSpecialChar("!@#$%^&*()_+"); - CharacterRule sr = new CharacterRule(special); - // Set the number of special characters - sr.setNumberOfCharacters(2); - - PasswordGenerator passGen = new PasswordGenerator(); - 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 static CharacterData defineSpecialChar(final String character) { - return new CharacterData() { - - @Override - public String getErrorCode() { - return "Error"; - } - - @Override - public String getCharacters() { - return character; - } - }; - } - - @Override - public Uni findUserByEmail(String email) { - return find(EMAIL, email).firstResult(); - } - - @Override - public Uni updateUser(UserEntity user) { - return Panache.withTransaction(user::persist); - } -} diff --git a/src/main/java/dev/orion/users/adapters/gateways/repository/package-info.java b/src/main/java/dev/orion/users/adapters/gateways/repository/package-info.java deleted file mode 100644 index 7bb8ca1..0000000 --- a/src/main/java/dev/orion/users/adapters/gateways/repository/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Abstraction of database operations package. - */ -package dev.orion.users.adapters.gateways.repository; diff --git a/src/main/java/dev/orion/users/adapters/presenters/package-info.java b/src/main/java/dev/orion/users/adapters/presenters/package-info.java deleted file mode 100644 index 0cb050f..0000000 --- a/src/main/java/dev/orion/users/adapters/presenters/package-info.java +++ /dev/null @@ -1,5 +0,0 @@ - -/** - * Data transfer objects packages. - */ -package dev.orion.users.adapters.presenters; diff --git a/src/main/java/dev/orion/users/application/interfaces/UpdateUser.java b/src/main/java/dev/orion/users/application/interfaces/UpdateUser.java deleted file mode 100644 index 96ca049..0000000 --- a/src/main/java/dev/orion/users/application/interfaces/UpdateUser.java +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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; - -public interface UpdateUser { - /** - * Updates the e-mail of the user. - * - * @param email : Current user's e-mail - * @param newEmail : New e-mail - * - * @return An User object - */ - User updateEmail(String email, String newEmail); - - /** - * Updates the user's password. - * - * @param email : User's email - * @param password : Current password - * @param newPassword : New Password - * - * @return An User object - */ - User updatePassword(String email, String password, String newPassword); - - /** - * Updates a user. - * - * @param user A user object - * @return An User object - */ - User updateUser(User user); -} diff --git a/src/main/java/dev/orion/users/application/usecases/CreateUserUC.java b/src/main/java/dev/orion/users/application/usecases/CreateUserUC.java deleted file mode 100644 index 1b22a74..0000000 --- a/src/main/java/dev/orion/users/application/usecases/CreateUserUC.java +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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 org.apache.commons.codec.digest.DigestUtils; -import org.apache.commons.validator.routines.EmailValidator; - -import dev.orion.users.application.interfaces.CreateUserUCI; -import dev.orion.users.enterprise.model.User; - -public class CreateUserUC implements CreateUserUCI { - - /** The minimum size of the password required. */ - private static final int SIZE_PASSWORD = 8; - - /** - * 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 - public User createUser(final String name, final String email, - final String password) { - if (name.isBlank() || !EmailValidator.getInstance().isValid(email) - || password.isBlank()) { - throw new IllegalArgumentException( - "Blank arguments or invalid e-mail"); - } else { - if (password.length() < SIZE_PASSWORD) { - throw new IllegalArgumentException( - "Password less than eight characters"); - } else { - // String secretKey = twoFactorAuthHandler.generateSecretKey(); - User user = new User(); - // user.setSecret2FA(secretKey); - user.setName(name); - user.setEmail(email); - user.setPassword(encryptPassword(password)); - user.setEmailValid(false); - 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 - public User createUser(final String name, final String email, - final Boolean isEmailValid) { - if (name.isBlank() || !EmailValidator.getInstance().isValid(email)) { - throw new IllegalArgumentException( - "Blank arguments or invalid e-mail"); - } else { - User user = new User(); - user.setName(name); - user.setEmail(email); - user.setEmailValid(isEmailValid); - return user; - } - } - - /** - * Encrypts the password with SHA-256. - * - * @param password : The password to be encrypted - * @return The encrypted password - */ - private String encryptPassword(final String password) { - return DigestUtils.sha256Hex(password); - } - -} diff --git a/src/main/java/dev/orion/users/application/usecases/UpdateUserImpl.java b/src/main/java/dev/orion/users/application/usecases/UpdateUserImpl.java deleted file mode 100644 index deea74a..0000000 --- a/src/main/java/dev/orion/users/application/usecases/UpdateUserImpl.java +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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.UpdateUser; -import dev.orion.users.enterprise.model.User; - -public class UpdateUserImpl implements UpdateUser { - - /** Default blanck arguments message. */ - private static final String BLANK = "Blank Arguments"; - - /** - * Updates the e-mail of the user. - * - * @param email : Current user's e-mail - * @param newEmail : New e-mail - * @return An User object - */ - @Override - public User updateEmail(final String email, final String newEmail) { - User user = null; - if (email.isBlank() || newEmail.isBlank()) { - throw new IllegalArgumentException(BLANK); - } else { - //user = repository.updateEmail(email, newEmail); - } - return user; - } - - /** - * Changes User password. - * - * @param password : Actual password - * @param newPassword : New Password - * @param email : User's email - * @return Returns a user asynchronously - */ - @Override - public User updatePassword(final String email, final String password, - final String newPassword) { - if (password.isBlank() || newPassword.isBlank() || email.isBlank()) { - throw new IllegalArgumentException(BLANK); - } else { - // return repository.changePassword(DigestUtils.sha256Hex(password), - // DigestUtils.sha256Hex(newPassword), email); - return null; - } - } - - @Override - public User updateUser(User user) { - if (user == null) { - throw new IllegalArgumentException(BLANK); - } - return user; - } - -} diff --git a/src/main/java/dev/orion/users/application/usecases/package-info.java b/src/main/java/dev/orion/users/application/usecases/package-info.java deleted file mode 100644 index 6f1d144..0000000 --- a/src/main/java/dev/orion/users/application/usecases/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Bussines rules package. - */ -package dev.orion.users.application.usecases; diff --git a/src/main/java/dev/orion/users/enterprise/model/User.java b/src/main/java/dev/orion/users/enterprise/model/User.java deleted file mode 100644 index d55ce66..0000000 --- a/src/main/java/dev/orion/users/enterprise/model/User.java +++ /dev/null @@ -1,180 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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.enterprise.model; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import com.fasterxml.jackson.annotation.JsonIgnore; - -/** - * User. - */ -public class User { - - /** The hash used to identify the user. */ - private String hash; - - /** The name of the user. */ - private String name; - - /** The e-mail of the user. */ - private String email; - - /** The password of the user. */ - private String password; - - /** Role list. */ - private List roles; - - /** Stores if the e-mail was validated. */ - private boolean emailValid; - - /** The hash used to identify the user. */ - private String emailValidationCode; - - /** Stores if is using 2FA */ - private boolean isUsing2FA; - - /** Secret code to be used at 2FA validation */ - private String secret2FA; - - /** - * User constructor. - */ - public User() { - this.hash = UUID.randomUUID().toString(); - this.roles = new ArrayList<>(); - this.emailValidationCode = UUID.randomUUID().toString(); - } - - /** - * Add a role in a user. - * - * @param role A role object. - */ - public void addRole(final Role role) { - roles.add(role); - } - - /** - * Transform the a list of object role to a list of String. The role "user" - * is the default role of the server - * - * @return A list of roles in String format - */ - @JsonIgnore - public List getRoleList() { - List strRoles = new ArrayList<>(); - if (this.roles.isEmpty()) { - strRoles.add("user"); - } else { - for (Role role : roles) { - strRoles.add(role.getName()); - } - } - return strRoles; - } - - /** - * Generates a e-mail validation code to the user. - */ - public void setEmailValidationCode() { - this.emailValidationCode = UUID.randomUUID().toString(); - } - - /** - * Removes all roles of the object. - */ - public void removeRoles() { - this.roles.clear(); - } - - public String getHash() { - return hash; - } - - public void setHash(String hash) { - this.hash = hash; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } - - public String getPassword() { - return password; - } - - public void setPassword(String password) { - this.password = password; - } - - public List getRoles() { - return roles; - } - - public void setRoles(List roles) { - this.roles = roles; - } - - public boolean isEmailValid() { - return emailValid; - } - - public void setEmailValid(boolean emailValid) { - this.emailValid = emailValid; - } - - public String getEmailValidationCode() { - return emailValidationCode; - } - - public void setEmailValidationCode(String emailValidationCode) { - this.emailValidationCode = emailValidationCode; - } - - public boolean isUsing2FA() { - return isUsing2FA; - } - - public void setUsing2FA(boolean isUsing2FA) { - this.isUsing2FA = isUsing2FA; - } - - public String getSecret2FA() { - return secret2FA; - } - - public void setSecret2FA(String secret2fa) { - secret2FA = secret2fa; - } - -} diff --git a/src/main/java/dev/orion/users/frameworks/mail/MailTemplate.java b/src/main/java/dev/orion/users/frameworks/mail/MailTemplate.java index 9eb2e7c..6498b80 100644 --- a/src/main/java/dev/orion/users/frameworks/mail/MailTemplate.java +++ b/src/main/java/dev/orion/users/frameworks/mail/MailTemplate.java @@ -1,3 +1,19 @@ +/** + * @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.frameworks.mail; import io.quarkus.mailer.MailTemplate.MailTemplateInstance; @@ -26,3 +42,4 @@ public final class MailTemplate { public static native MailTemplateInstance validateEmail(String url); } + diff --git a/src/main/java/dev/orion/users/frameworks/mail/package-info.java b/src/main/java/dev/orion/users/frameworks/mail/package-info.java deleted file mode 100644 index 4e2d84a..0000000 --- a/src/main/java/dev/orion/users/frameworks/mail/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * E-mail resources. - */ -package dev.orion.users.frameworks.mail; diff --git a/src/main/java/dev/orion/users/frameworks/rest/CorsFilter.java b/src/main/java/dev/orion/users/frameworks/rest/CorsFilter.java new file mode 100644 index 0000000..fe961ef --- /dev/null +++ b/src/main/java/dev/orion/users/frameworks/rest/CorsFilter.java @@ -0,0 +1,62 @@ +/** + * @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.frameworks.rest; + +import jakarta.annotation.Priority; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerResponseContext; +import jakarta.ws.rs.container.ContainerResponseFilter; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.ext.Provider; +import java.io.IOException; + +/** + * CORS Filter to handle Cross-Origin Resource Sharing. + */ +@Provider +@Priority(1) +public class CorsFilter implements ContainerResponseFilter { + + @Override + public void filter(ContainerRequestContext requestContext, + ContainerResponseContext responseContext) throws IOException { + MultivaluedMap headers = responseContext.getHeaders(); + + // Remover headers CORS existentes para evitar duplicação + headers.remove("Access-Control-Allow-Origin"); + headers.remove("Access-Control-Allow-Credentials"); + headers.remove("Access-Control-Allow-Headers"); + headers.remove("Access-Control-Allow-Methods"); + headers.remove("Access-Control-Max-Age"); + + // Adicionar headers CORS corretos + String origin = requestContext.getHeaderString("Origin"); + if (origin != null && (origin.startsWith("http://localhost:") || origin.startsWith("https://localhost:"))) { + headers.putSingle("Access-Control-Allow-Origin", origin); + } else { + headers.putSingle("Access-Control-Allow-Origin", "*"); + } + + headers.putSingle("Access-Control-Allow-Credentials", "true"); + headers.putSingle("Access-Control-Allow-Headers", + "origin, content-type, accept, authorization, x-requested-with"); + headers.putSingle("Access-Control-Allow-Methods", + "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH"); + headers.putSingle("Access-Control-Max-Age", "3600"); + } +} + diff --git a/src/main/java/dev/orion/users/frameworks/rest/CorsRequestFilter.java b/src/main/java/dev/orion/users/frameworks/rest/CorsRequestFilter.java new file mode 100644 index 0000000..6ab0667 --- /dev/null +++ b/src/main/java/dev/orion/users/frameworks/rest/CorsRequestFilter.java @@ -0,0 +1,56 @@ +/** + * @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.frameworks.rest; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.PreMatching; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; + +/** + * CORS Request Filter to handle preflight OPTIONS requests. + */ +@Provider +@PreMatching +public class CorsRequestFilter implements ContainerRequestFilter { + + @Override + public void filter(ContainerRequestContext requestContext) { + // Handle preflight requests + if (requestContext.getRequest().getMethod().equals("OPTIONS")) { + String origin = requestContext.getHeaderString("Origin"); + Response.ResponseBuilder response = Response.ok(); + + if (origin != null && (origin.startsWith("http://localhost:") || origin.startsWith("https://localhost:"))) { + response.header("Access-Control-Allow-Origin", origin); + } else { + response.header("Access-Control-Allow-Origin", "*"); + } + + response.header("Access-Control-Allow-Credentials", "true"); + response.header("Access-Control-Allow-Headers", + "origin, content-type, accept, authorization, x-requested-with"); + response.header("Access-Control-Allow-Methods", + "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH"); + response.header("Access-Control-Max-Age", "3600"); + + requestContext.abortWith(response.build()); + } + } +} + diff --git a/src/main/java/dev/orion/users/frameworks/rest/ServiceException.java b/src/main/java/dev/orion/users/frameworks/rest/ServiceException.java deleted file mode 100644 index ccdb2f7..0000000 --- a/src/main/java/dev/orion/users/frameworks/rest/ServiceException.java +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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.frameworks.rest; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import jakarta.ws.rs.WebApplicationException; -import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.Response.Status; - -/** - * Frameworks and Drivers layer of Clean Architecture - */ -public class ServiceException extends WebApplicationException { - - /** - * Service Exception constructor. - * - * @param message : The message of the exception - * @param status : The HTTP error code - */ - public ServiceException(final String message, final Status status) { - super(init(message, status)); - } - - /** - * A static method to init the message. - * - * @param message : An error message - * @param status : A HTTP error code - * - * @return A Response object - */ - private static Response init(final String message, final Status status) { - List> violations = new ArrayList<>(); - violations.add(Map.of("message",message)); - - return Response - .status(status) - .entity(Map.of("violations", violations)) - .build(); - } - -} \ No newline at end of file diff --git a/src/main/java/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.java b/src/main/java/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.java deleted file mode 100644 index dcbd1e1..0000000 --- a/src/main/java/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.java +++ /dev/null @@ -1,145 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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.frameworks.rest.authentication; - -import org.eclipse.microprofile.faulttolerance.Retry; -import org.jboss.resteasy.reactive.RestForm; - -import dev.orion.users.adapters.controllers.UserController; -import dev.orion.users.frameworks.rest.ServiceException; -import io.quarkus.hibernate.reactive.panache.common.WithSession; -import io.smallrye.mutiny.Uni; -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.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; - -/** - * User API. - */ -@PermitAll -@Path("/users") -@Consumes(MediaType.APPLICATION_FORM_URLENCODED) -@Produces(MediaType.APPLICATION_JSON) -@WithSession -public class AuthenticationWS { - - /** Fault tolerance default delay. */ - protected static final long DELAY = 2000; - - /** Business logic of the system. */ - @Inject - UserController controller; - - /** - * Authenticates the user. - * - * @param email The e-mail of the user - * @param password The password of the user - * @return A JWT (JSON Web Token) - * @throws A Bad Request if the user is not found - */ - @POST - @Path("/authenticate") - @PermitAll - @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - @Produces(MediaType.TEXT_PLAIN) - @Retry(maxRetries = 1, delay = DELAY) - public Uni authenticate( - @RestForm @NotEmpty @Email final String email, - @RestForm @NotEmpty final String password) { - - return controller.authenticate(email, password) - .onItem().ifNotNull().transform(jwt -> jwt) - .onItem().ifNull() - .failWith(new ServiceException("User not found", - Response.Status.UNAUTHORIZED)); - } - - /** - * Creates and authenticates a user. - * - * @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 - * @throws A Bad Request if the service is unable to create the user - */ - @POST - @Path("/createAuthenticate") - @PermitAll - @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - @Produces(MediaType.APPLICATION_JSON) - @Retry(maxRetries = 1, delay = DELAY) - public Uni createAuthenticate( - @FormParam("name") @NotEmpty final String name, - @FormParam("email") @NotEmpty @Email final String email, - @FormParam("password") @NotEmpty final String password) { - - return controller.createAuthenticate(name, email, password) - .log() - .onItem().ifNotNull().transform(dto -> Response.ok(dto).build()) - .onFailure().transform(e -> { - String message = e.getMessage(); - throw new ServiceException(message, - Response.Status.BAD_REQUEST); - }); - } - - /** - * Validates e-mail, this method is used to confirm the user's e-mail using - * a code. - * - * @param email The e-mail of the user - * @param code The code sent to the user - * @return true if was possible to validate the e-mail - * @throws Bad request if the the em-mail or code is invalid - */ - @GET - @PermitAll - @Path("/validateEmail") - @Consumes(MediaType.TEXT_PLAIN) - @Produces(MediaType.TEXT_PLAIN) - @WithSession - public Uni validateEmail( - @QueryParam("email") @NotEmpty final String email, - @QueryParam("code") @NotEmpty final String code) { - - return controller.validateEmail(email, code) - .onItem().ifNotNull().transform(user -> - Response.ok(true).build()) - .onItem().ifNull().continueWith(() -> { - String message = "Invalid e-mail or code"; - throw new ServiceException(message, - Response.Status.BAD_REQUEST); - }) - .onFailure().transform(e -> { - String message = e.getMessage(); - throw new ServiceException(message, - Response.Status.BAD_REQUEST); - }); - } -} diff --git a/src/main/java/dev/orion/users/frameworks/rest/authentication/SocialAuthenticationWS.java b/src/main/java/dev/orion/users/frameworks/rest/authentication/SocialAuthenticationWS.java deleted file mode 100644 index e6b5cfd..0000000 --- a/src/main/java/dev/orion/users/frameworks/rest/authentication/SocialAuthenticationWS.java +++ /dev/null @@ -1,88 +0,0 @@ -// /** -// * @License -// * Copyright 2023 Orion Services @ https://github.com/orion-services -// * -// * 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.frameworks.rest.authentication; - -// import jakarta.ws.rs.Path; - -// /** -// * Social Authenticate. -// */ -// @Path("/api/users") -// public class SocialAuthenticationWS { - -// // /** Fault tolerance default delay. */ -// // protected static final long DELAY = 2000; - -// // @Inject -// // protected AuthenticationHandler authHandler; - -// // /** Business logic. */ -// // protected CreateUser createUserUseCase; - -// // /** -// // * ID Token issued by the OpenID Connect Provider. -// // */ -// // @Inject -// // @IdToken -// // JsonWebToken idToken; - -// /** -// * Authenticate and creates a user using google. -// * -// * @return The Authentication DTO in json format -// * @throws ServiceException Returns a HTTP 409 if the name already exists -// * in the database -// */ -// // @GET -// // @Path("/google") -// // @Authenticated -// // @Consumes(MediaType.TEXT_PLAIN) -// // @Produces(MediaType.APPLICATION_JSON) -// // @WithSession -// // public Uni google() { - -// // // Getting information from id token -// // Object gName = this.idToken.getClaim("given_name"); -// // String fname = this.idToken.getClaim("family_name"); -// // String email = this.idToken.getClaim("email"); - -// // StringBuilder name = new StringBuilder(); -// // name.append(gName); -// // name.append(" "); -// // name.append(fname); - -// // try { -// // return createUserUseCase.createUser(name.toString(), email, true) -// // .onItem().ifNotNull() -// // .transform(user -> { -// // AuthenticationDTO auth = new AuthenticationDTO(); -// // auth.setToken(authHandler.generateJWT(user)); -// // auth.setUser(user); -// // return auth; -// // }) -// // .onFailure() -// // .transform(e -> { -// // throw new ServiceException(e.getMessage(), -// // Response.Status.BAD_REQUEST); -// // }) -// // .log(); -// // } catch (Exception e) { -// // throw new ServiceException(e.getMessage(), -// // Response.Status.BAD_REQUEST); -// // } -// // } -// } diff --git a/src/main/java/dev/orion/users/frameworks/rest/authentication/TwoFactorAuth.java b/src/main/java/dev/orion/users/frameworks/rest/authentication/TwoFactorAuth.java deleted file mode 100644 index 754b155..0000000 --- a/src/main/java/dev/orion/users/frameworks/rest/authentication/TwoFactorAuth.java +++ /dev/null @@ -1,129 +0,0 @@ -// /** -// * @License -// * Copyright 2023 Orion Services @ https://github.com/orion-services -// * -// * 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.frameworks.rest.authentication; - -// import jakarta.ws.rs.Produces; -// 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.core.MediaType; -// import jakarta.ws.rs.core.Response; -// import dev.orion.users.application.interfaces.AuthenticateUser; -// import dev.orion.users.application.interfaces.UpdateUser; -// import dev.orion.users.frameworks.handlers.AuthenticationHandler; -// import dev.orion.users.frameworks.handlers.TwoFactorAuthHandler; -// import dev.orion.users.frameworks.rest.ServiceException; -// import io.quarkus.hibernate.reactive.panache.common.WithSession; -// import io.smallrye.mutiny.Uni; -// import org.eclipse.microprofile.faulttolerance.Retry; - -// /** -// * Two Factor Authenticate. -// */ -// @Path("api/users") -// public class TwoFactorAuth { - -// /** Fault tolerance default delay. */ -// protected static final long DELAY = 2000; - -// @Inject -// private AuthenticationHandler authHandler; - -// /** Auth utilities */ -// @Inject -// protected TwoFactorAuthHandler twoFactorAuthHandler; - -// /** Business logic */ - -// @Inject -// protected AuthenticateUser authenticateUserUseCase; - -// @Inject -// protected UpdateUser updateUserUseCase; - -// /** -// * Authenticate and returns a qrCode to two factor auth. -// * -// * @return The return is in image/png format -// * @throws ServiceException Returns a HTTP 401 if credentials not found -// */ -// // @POST -// // @Path("twoFactorAuth/qrCode") -// // @Consumes(MediaType.APPLICATION_FORM_URLENCODED) -// // @Produces("image/png") -// // @WithSession -// // public Uni generateTwoFactorAuthQrCode( -// // @FormParam("email") @NotEmpty @Email final String email, -// // @FormParam("password") @NotEmpty final String password) { - -// // return authenticateUserUseCase.authenticate(email, password) -// // .onItem().ifNotNull() -// // .transformToUni(user -> { -// // user.setUsing2FA(true); -// // return updateUserUseCase.updateUser(user); -// // }) -// // .onItem().ifNotNull() -// // .transform(user -> { -// // String secret = user.getSecret2FA(); -// // String userEmail = user.getEmail(); -// // String barCodeData = twoFactorAuthHandler.getAutheticatorBarCode( -// // secret, userEmail, "Orion User Service"); -// // return twoFactorAuthHandler.createQrCode(barCodeData); -// // }) -// // .onItem().ifNull() -// // .failWith(new ServiceException("Credentials not found", -// // Response.Status.UNAUTHORIZED)); -// // } - -// /** -// * Validate two factor auth code -// * -// * @return The return is a string with token -// * @throws ServiceException Returns a HTTP 401 if credentials not found -// */ -// // @POST -// // @Path("twoFactorAuth/validate") -// // @Retry(maxRetries = 1, delay = 2000) -// // @Consumes(MediaType.APPLICATION_FORM_URLENCODED) -// // @Produces(MediaType.TEXT_PLAIN) -// // public Uni validateTwoFactorAuthCode( -// // @FormParam("email") @NotEmpty @Email final String email, -// // @FormParam("password") @NotEmpty final String password, -// // @FormParam("code") @NotEmpty final String code) { - -// // return authenticateUserUseCase.authenticate(email, password) -// // .onItem().ifNotNull() -// // .transform(user -> { -// // String secret = user.getSecret2FA(); -// // String userCode = twoFactorAuthHandler.getTOTPCode(secret); -// // if (!user.isUsing2FA()) { -// // return null; -// // } -// // if (!userCode.equals(code)) { -// // return null; -// // } -// // return authHandler.generateJWT(user); -// // }) -// // .onItem().ifNull() -// // .failWith(new ServiceException("Credentials not found or 2FAuth not activated", -// // Response.Status.UNAUTHORIZED)); -// // } -// } diff --git a/src/main/java/dev/orion/users/frameworks/rest/authentication/package-info.java b/src/main/java/dev/orion/users/frameworks/rest/authentication/package-info.java deleted file mode 100644 index 6a6ea54..0000000 --- a/src/main/java/dev/orion/users/frameworks/rest/authentication/package-info.java +++ /dev/null @@ -1,5 +0,0 @@ - -/** - * Authentication WS. - */ -package dev.orion.users.frameworks.rest.authentication; diff --git a/src/main/java/dev/orion/users/frameworks/rest/package-info.java b/src/main/java/dev/orion/users/frameworks/rest/package-info.java deleted file mode 100644 index eafeda0..0000000 --- a/src/main/java/dev/orion/users/frameworks/rest/package-info.java +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Web services package. - */ -package dev.orion.users.frameworks.rest; diff --git a/src/main/java/dev/orion/users/frameworks/rest/users/UserWS.java b/src/main/java/dev/orion/users/frameworks/rest/users/UserWS.java deleted file mode 100644 index 8bd9770..0000000 --- a/src/main/java/dev/orion/users/frameworks/rest/users/UserWS.java +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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.frameworks.rest.users; - -import org.eclipse.microprofile.faulttolerance.Retry; - -import dev.orion.users.adapters.controllers.UserController; -import dev.orion.users.frameworks.rest.ServiceException; -import io.smallrye.mutiny.Uni; -import jakarta.annotation.security.PermitAll; -import jakarta.annotation.security.RolesAllowed; -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; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; - -/** - * Create a user endpoints. - */ -@Path("/users") -@Consumes(MediaType.APPLICATION_FORM_URLENCODED) -@Produces(MediaType.APPLICATION_JSON) -public class UserWS { - - /** Business logic of the system. */ - @Inject - UserController controller; - - /** Fault tolerance default delay. */ - protected static final long DELAY = 2000; - - /** - * Creates a user inside the service. - * - * @param name The name of the user - * @param email The email of the user - * @param password The password of the user - * @return The user object in JSON format - * @throws Bad request if the service was unable to create the user - */ - @POST - @Path("/create") - @PermitAll - @Retry(maxRetries = 1, delay = DELAY) - public Uni create( - @FormParam("name") @NotEmpty final String name, - @FormParam("email") @NotEmpty @Email final String email, - @FormParam("password") @NotEmpty final String password) { - - return controller.createUser(name, email, password) - .log() - .onItem().ifNotNull().transform(user -> Response.ok(user).build()) - .onFailure().transform(e -> { - String message = e.getMessage(); - throw new ServiceException(message, - Response.Status.BAD_REQUEST); - }); - } - - /** - * Deletes a user inside the service. - * - * @param email The email of the user - * @return A boolean - * @throws Bad request if the service was unable to create the user - */ - @POST - @Path("/delete") - @Consumes(MediaType.APPLICATION_FORM_URLENCODED) - @Produces(MediaType.APPLICATION_JSON) - @RolesAllowed("admin") - @Retry(maxRetries = 1, delay = DELAY) - public Uni delete( - @FormParam("email") @NotEmpty @Email final String email) { - - return controller.deleteUser(email) - .log() - .onItem().ifNotNull().transform(result -> - Response.ok(true).build()) - .onFailure().transform(e -> { - String message = e.getMessage(); - throw new ServiceException(message, - Response.Status.BAD_REQUEST); - }); - } - -} diff --git a/src/main/kotlin/dev/orion/users/adapters/controllers/BasicController.kt b/src/main/kotlin/dev/orion/users/adapters/controllers/BasicController.kt new file mode 100644 index 0000000..2035bf3 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/controllers/BasicController.kt @@ -0,0 +1,199 @@ +/** + * @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.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.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 + +/** + * The controller class. + */ +open class BasicController { + + /** The encoding used in the QR code. */ + private val UTF_8 = "UTF-8" + + /** Configure the issuer for JWT generation. */ + @ConfigProperty(name = "users.issuer", defaultValue = "orion-users") + lateinit var issuer: String + + /** Set the validation url. */ + @ConfigProperty(name = "users.email.validation.url", defaultValue = "http://localhost:8080/users/validateEmail") + lateinit var validateURL: String + + /** ModelMapper. */ + protected val mapper: ModelMapper = ModelMapper() + + /** + * Creates a JWT (JSON Web Token) to a user. + * + * @param user : The user object + * @return Returns the JWT + */ + fun generateJWT(user: UserEntity): 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. + * + * @param email : Request e-mail + * @param jwtEmail : JWT e-mail + * @return true if the e-mails are the same + * @throws ServiceException Throw an exception (HTTP 400) if the e-mails are + * different, indicating that possibly the JWT is + * outdated. + */ + fun checkTokenEmail(email: String, jwtEmail: String): Boolean { + if (email != jwtEmail) { + throw ServiceException("JWT outdated", Response.Status.BAD_REQUEST) + } + return true + } + + /** + * Send a message to the user validates the e-mail. + * + * @param user : A user object + * @return Return a Uni after to send an e-mail. + */ + fun sendValidationEmail(user: UserEntity): Uni { + val url = StringBuilder() + url.append(validateURL) + url.append("?code=" + user.emailValidationCode) + url.append("&email=" + user.email) + + return MailTemplate.validateEmail(url.toString()) + .to(user.email ?: "") + .subject("E-mail confirmation") + .send() + .onItem().ifNotNull() + .transform { user } + } + + /** + * Create Time-based one-time password. + * + * @param secretKey : The secret key + * @return The Time-based one-time password code in String format + * @throws IllegalArgumentException + */ + fun getTOTPCode(secretKey: String): String { + try { + val base32 = Base32() + val bytes = base32.decode(secretKey) + val hexKey = Hex.encodeHexString(bytes) + return TOTP.getOTP(hexKey) + } catch (e: Exception) { + throw IllegalArgumentException(e) + } + } + + /** + * Create Google Bar Code. + * + * @param secretKey : The secret key + * @param account : The account name + * @param issuer : The issuer name + * @return The Google Bar Code in String format + * @throws IllegalArgumentException + */ + fun getAuthenticatorBarCode(secretKey: String, account: String, issuer: String): String { + try { + return "otpauth://totp/" + + URLEncoder.encode("$issuer:$account", UTF_8) + .replace("+", "%20") + + "?secret=" + URLEncoder.encode(secretKey, UTF_8) + .replace("+", "%20") + + "&issuer=" + URLEncoder.encode(issuer, UTF_8) + .replace("+", "%20") + } catch (e: UnsupportedEncodingException) { + throw IllegalStateException(e) + } catch (e: NullPointerException) { + throw IllegalStateException(e) + } + } + + /** + * Create QrCode. + * + * @param barCodeData : The Google Bar Code + * @return The QrCode with Google Bar Code in a array of byte format + * @throws IllegalArgumentException + */ + fun createQrCode(barCodeData: String): ByteArray { + try { + val matrix = MultiFormatWriter().encode(barCodeData, BarcodeFormat.QR_CODE, 400, 400) + val image: BufferedImage = MatrixToImageWriter.toBufferedImage(matrix) + val baos = ByteArrayOutputStream() + ImageIO.write(image, "png", baos) + return baos.toByteArray() + } catch (e: WriterException) { + throw IllegalStateException(e) + } catch (e: IOException) { + throw IllegalStateException(e) + } catch (e: NullPointerException) { + throw IllegalStateException(e) + } + } + + /** + * Generate Secret Key. + * + * @return The Secret Key in String format + * @throws IllegalArgumentException + */ + fun generateSecretKey(): String { + val random = SecureRandom() + val bytes = ByteArray(20) + random.nextBytes(bytes) + val base32 = Base32() + 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 new file mode 100644 index 0000000..e5c1429 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/controllers/UserController.kt @@ -0,0 +1,749 @@ +/** + * @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.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.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 jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import org.apache.commons.codec.digest.DigestUtils + +/** + * The controller class. + */ +@WithSession +@ApplicationScoped +class UserController : BasicController() { + + /** Use cases for users. */ + private val createUC: CreateUserUCI = CreateUserUC() + + /** Use cases for authentication. */ + private val authenticationUC: AuthenticateUCI = AuthenticateUC() + + /** Use cases for social authentication. */ + private val socialAuthUC: SocialAuthUCI = SocialAuthUC() + + /** Use cases for two factor authentication. */ + private val twoFactorAuthUC: TwoFactorAuthUCI = TwoFactorAuthUC() + + /** Use cases for WebAuthn. */ + private val webAuthnUC: WebAuthnUCI = WebAuthnUC() + + /** Use cases for updating user. */ + private val updateUserUC: UpdateUser = UpdateUserImpl() + + /** Persistence layer. */ + @Inject + lateinit var userRepository: UserRepository + + /** WebAuthn credential repository. */ + @Inject + lateinit var webAuthnCredentialRepository: WebAuthnCredentialRepository + + /** Object mapper for JSON. */ + private val objectMapper = ObjectMapper() + + /** + * Create a new user. Validates the business rules, persists the user and + * sends an e-mail to the user confirming the registration. + * + * @param name : The user name + * @param email : The user e-mail + * @param password : The user password + * @return : Returns a Uni object + */ + fun createUser(name: String, email: String, password: String): Uni { + val user: User = createUC.createUser(name, email, password) + val entity: UserEntity = mapper.map(user, UserEntity::class.java) + return userRepository.createUser(entity) + .onItem().ifNotNull().transform { u -> u } + .onItem().ifNotNull().call { user -> this.sendValidationEmail(user) } + } + + /** + * Validates the e-mail of a user. + * + * @param email : The e-mail of the user + * @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 + } + + /** + * Authenticates the user in the service. + * + * @param email : The user e-mail + * @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) } + } + + /** + * Authenticates a user with the provided email and password. + * If the user has 2FA enabled, returns a response indicating that 2FA code is required. + * + * @param email the email of the user + * @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 + } + } + + /** + * Creates a user, generates a Json Web Token and returns a + * AuthenticationDTO object. + * + * @param name : The user name + * @param email : The user e-mail + * @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 dto = AuthenticationDTO() + dto.token = this.generateJWT(user) + dto.user = user + dto + } + } + + /** + * 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. + * + * @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()) + 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 + } + } + } + + /** + * Delete a user from the service. + * + * @param email The user's e-mail + * @return A Uni object + */ + fun deleteUser(email: String): Uni { + return userRepository.deleteUser(email) + } + + /** + * Generates a QR code for 2FA setup. + * Validates user credentials, generates a secret key, updates the user, + * and returns a QR code image. + * + * @param email The email of the user + * @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 + 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) + } + } + } + + /** + * Validates a TOTP code for 2FA authentication after social login. + * + * @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 + */ + 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 dto = AuthenticationDTO() + dto.token = generateJWT(userEntity) + dto.user = userEntity + Uni.createFrom().item(dto) + } + } + + /** + * Validates a TOTP code for 2FA authentication. + * + * @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 + */ + 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 dto = AuthenticationDTO() + dto.token = generateJWT(userEntity) + dto.user = userEntity + Uni.createFrom().item(dto) + } + } + + /** + * Starts WebAuthn registration process. + * + * @param email The email of the user + * @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 + 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) + } + } + + /** + * Finishes 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 finishWebAuthnRegistration(email: String, response: String, origin: String, deviceName: String?): Uni { + // Validate using use case + 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}") + } + } + } + + /** + * Starts WebAuthn authentication process. + * + * @param email The email of the user + * @return A JSON string containing PublicKeyCredentialRequestOptions + */ + fun startWebAuthnAuthentication(email: String): Uni { + // Validate email using use case + webAuthnUC.startAuthentication(email) + + 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) + } + } + } + + /** + * Finishes WebAuthn authentication process. + * + * @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 + */ + fun finishWebAuthnAuthentication(email: String, response: String): Uni { + // Validate using use case + 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 dto = AuthenticationDTO() + dto.token = generateJWT(user) + dto.user = user + dto + } + } + } + + /** + * Recovers the password of a user. Generates a new password, updates it in the database, + * and sends it via email. + * + * @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 + 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 } + } + + /** + * 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) + .to(email) + .subject("Recuperação de senha") + .send() + .onItem().transform { null } + } + + /** + * Updates user information (email and/or password). Validates the token, + * updates the fields, generates a new JWT, and sends a validation email if email was changed. + * + * @param email : The current email of the user + * @param newEmail : The new email address (optional) + * @param password : The current password (required if updating password) + * @param newPassword : The new password (optional) + * @param jwtEmail : The email from the JWT token (for validation) + * @return A Uni that emits the authentication response with token and user + */ + fun updateUser( + email: String, + newEmail: String?, + password: String?, + newPassword: String?, + jwtEmail: String + ): Uni { + // Validate using use case + val user: User = updateUserUC.updateUser(email, newEmail, password, newPassword) + + // Validate that JWT email matches the current email + checkTokenEmail(email, jwtEmail) + + // Capture variables for use in lambdas + val emailUpdated = !newEmail.isNullOrBlank() + val passwordUpdate = !newPassword.isNullOrBlank() && !password.isNullOrBlank() + val currentEmail = email + 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") + } + } + + // 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 { + Uni.createFrom().item(userEntity) + } + } + .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 + + 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 + * @param require2FAForSocialLogin Whether 2FA is required for social login + * @param jwtEmail The email from the JWT token (for validation) + * @return A Uni with updated settings + */ + fun update2FASettings( + email: String, + require2FAForBasicLogin: Boolean, + require2FAForSocialLogin: Boolean, + jwtEmail: String + ): Uni { + // Validate that JWT email matches the current email + 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) + } + } + + /** + * 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" + } + } + +} + diff --git a/src/main/java/dev/orion/users/adapters/gateways/entities/RoleEntity.java b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/RoleEntity.kt similarity index 52% rename from src/main/java/dev/orion/users/adapters/gateways/entities/RoleEntity.java rename to src/main/kotlin/dev/orion/users/adapters/gateways/entities/RoleEntity.kt index b6c46c0..dec8804 100644 --- a/src/main/java/dev/orion/users/adapters/gateways/entities/RoleEntity.java +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/RoleEntity.kt @@ -1,6 +1,6 @@ /** * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services + * 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. @@ -14,44 +14,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.orion.users.adapters.gateways.entities; +package dev.orion.users.adapters.gateways.entities -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import jakarta.validation.constraints.NotNull; - -import com.fasterxml.jackson.annotation.JsonIgnore; - -import io.quarkus.hibernate.reactive.panache.PanacheEntityBase; -import lombok.Getter; -import lombok.Setter; +import com.fasterxml.jackson.annotation.JsonIgnore +import io.quarkus.hibernate.reactive.panache.PanacheEntityBase +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull /** * Role Entity. */ @Entity -@Getter -@Setter @Table(name = "Role") -public class RoleEntity extends PanacheEntityBase { +open class RoleEntity : PanacheEntityBase() { /** Primary key. */ @Id @GeneratedValue @JsonIgnore - private Long id; + var id: Long? = null /** The name of the role. */ @NotNull(message = "The name of the role can't be null") - private String name; - - public RoleEntity() { - } - - public RoleEntity(String name) { - this(); - this.name = name; - } + var name: String? = 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 new file mode 100644 index 0000000..b298184 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/UserEntity.kt @@ -0,0 +1,143 @@ +/** + * @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.entities + +import com.fasterxml.jackson.annotation.JsonIgnore +import io.quarkus.hibernate.reactive.panache.PanacheEntityBase +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.Id +import jakarta.persistence.ManyToMany +import jakarta.persistence.Table +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotNull +import java.util.UUID + +/** + * User Entity. + */ +@Entity +@Table(name = "User") +class UserEntity : PanacheEntityBase() { + + /** Default size for column. */ + companion object { + private const val COLUMN_LENGTH = 256 + } + + /** Primary key. */ + @Id + @GeneratedValue + @JsonIgnore + var id: Long? = null + + /** The hash used to identify the user. */ + var hash: String = UUID.randomUUID().toString() + + /** The name of the user. */ + @NotNull(message = "The name can't be null") + var name: String? = null + + /** The e-mail of the user. */ + @NotNull(message = "The e-mail can't be null") + @Email(message = "The e-mail format is necessary") + var email: String? = null + + /** The password of the user. */ + @JsonIgnore + @Column(length = COLUMN_LENGTH) + @NotNull(message = "The password can't be null") + var password: String? = null + + /** Role list. */ + @JsonIgnore + @ManyToMany(fetch = FetchType.EAGER) + var roles: MutableList = mutableListOf() + + /** Stores if the e-mail was validated. */ + var emailValid: Boolean = false + + /** The hash used to identify the user. */ + @JsonIgnore + var emailValidationCode: String = UUID.randomUUID().toString() + + /** Stores if is using 2FA. */ + var isUsing2FA: Boolean = false + + /** Secret code to be used at 2FA validation. */ + var secret2FA: String? = null + + /** Controls if 2FA is required for basic login (email/password). */ + var require2FAForBasicLogin: Boolean = false + + /** Controls if 2FA is required for social login (Google OAuth). */ + var require2FAForSocialLogin: Boolean = false + + /** + * User constructor. + */ + init { + this.hash = UUID.randomUUID().toString() + this.roles = mutableListOf() + this.emailValidationCode = UUID.randomUUID().toString() + } + + /** + * Add a role in a user. + * + * @param role A role object. + */ + fun addRole(role: RoleEntity) { + roles.add(role) + } + + /** + * Transform the a list of object role to a list of String. The role "user" + * is the default role of the server + * + * @return A list of roles in String format + */ + @JsonIgnore + fun getRoleList(): List { + val strRoles = mutableListOf() + if (this.roles.isEmpty()) { + strRoles.add("user") + } else { + for (role in roles) { + role.name?.let { strRoles.add(it) } + } + } + return strRoles + } + + /** + * Generates a e-mail validation code to the user. + */ + fun setEmailValidationCode() { + this.emailValidationCode = UUID.randomUUID().toString() + } + + /** + * Removes all roles of the object. + */ + fun removeRoles() { + 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 new file mode 100644 index 0000000..f236868 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/entities/WebAuthnCredentialEntity.kt @@ -0,0 +1,74 @@ +/** + * @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.entities + +import com.fasterxml.jackson.annotation.JsonIgnore +import io.quarkus.hibernate.reactive.panache.PanacheEntityBase +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull + +/** + * WebAuthn Credential Entity. + * Stores WebAuthn credentials (passkeys) for users. + */ +@Entity +@Table(name = "WebAuthnCredential") +class WebAuthnCredentialEntity : PanacheEntityBase() { + + /** Primary key. */ + @Id + @GeneratedValue + @JsonIgnore + var id: Long? = null + + /** The user's email (foreign key reference). */ + @NotNull(message = "The user email can't be null") + @Column(name = "user_email") + var userEmail: String? = null + + /** The credential ID (base64 encoded). */ + @NotNull(message = "The credential ID can't be null") + @Column(name = "credential_id", length = 512) + var credentialId: String? = null + + /** The public key (JSON string). */ + @NotNull(message = "The public key can't be null") + @Column(name = "public_key", columnDefinition = "TEXT") + var publicKey: String? = null + + /** The signature counter. */ + @NotNull(message = "The counter can't be null") + @Column(name = "counter") + var counter: Long = 0 + + /** The name/description of the device. */ + @Column(name = "device_name", length = 256) + var deviceName: String? = null + + /** The origin (complete site address) where the device was registered. */ + @Column(name = "origin", length = 512) + var origin: String? = null + + /** Notes field to store device name or other information. */ + @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 new file mode 100644 index 0000000..f6bc561 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/RoleRepository.kt @@ -0,0 +1,37 @@ +/** + * @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 io.quarkus.hibernate.reactive.panache.PanacheRepository +import io.smallrye.mutiny.Uni +import jakarta.enterprise.context.ApplicationScoped + +/** + * Role repository interface. + */ +@ApplicationScoped +interface RoleRepository : PanacheRepository { + /** + * Finds a role by name. + * + * @param name The name of the role + * @return A Uni object + */ + 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 new file mode 100644 index 0000000..cb2253a --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/RoleRepositoryImpl.kt @@ -0,0 +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.adapters.gateways.repository + +import dev.orion.users.adapters.gateways.entities.RoleEntity +import io.smallrye.mutiny.Uni +import jakarta.enterprise.context.ApplicationScoped + +/** + * Implementation of the RoleRepository interface. + */ +@ApplicationScoped +class RoleRepositoryImpl : RoleRepository { + override fun findByName(name: String): Uni { + return find("name", name).firstResult() + } +} + diff --git a/src/main/java/dev/orion/users/adapters/gateways/repository/UserRepository.java b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepository.kt similarity index 70% rename from src/main/java/dev/orion/users/adapters/gateways/repository/UserRepository.java rename to src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepository.kt index ce9319d..3f1eb8e 100644 --- a/src/main/java/dev/orion/users/adapters/gateways/repository/UserRepository.java +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepository.kt @@ -1,6 +1,6 @@ /** * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services + * 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. @@ -14,18 +14,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.orion.users.adapters.gateways.repository; +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; +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 -public interface UserRepository extends PanacheRepository { +interface UserRepository : PanacheRepository { /** * Creates a UserEntity in the service. @@ -33,7 +33,7 @@ public interface UserRepository extends PanacheRepository { * @param user : An UserEntity object * @return A Uni object */ - Uni createUser(UserEntity user); + fun createUser(user: UserEntity): Uni /** * Returns a user searching for email. @@ -41,7 +41,7 @@ public interface UserRepository extends PanacheRepository { * @param email : The user e-mail * @return A Uni object */ - Uni findUserByEmail(String email); + fun findUserByEmail(email: String): Uni /** * Returns a user searching for email and password. @@ -49,7 +49,7 @@ public interface UserRepository extends PanacheRepository { * @param user : The user object * @return A Uni object */ - Uni authenticate(UserEntity user); + fun authenticate(user: UserEntity): Uni /** * Updates the e-mail of the user. @@ -59,14 +59,14 @@ public interface UserRepository extends PanacheRepository { * * @return A Uni object */ - Uni updateEmail(String email, String newEmail); + fun updateEmail(email: String, newEmail: String): Uni /** * Updates the user. * @param user : The user object * @return A Uni object */ - Uni updateUser(UserEntity user); + fun updateUser(user: UserEntity): Uni /** * Validates an e-mail of a user. @@ -75,7 +75,7 @@ public interface UserRepository extends PanacheRepository { * @param code : The validation code * @return true if the validation code is correct for the respective e-mail */ - Uni validateEmail(String email, String code); + fun validateEmail(email: String, code: String): Uni /** * Changes User password. @@ -85,7 +85,7 @@ public interface UserRepository extends PanacheRepository { * @param email : User's email * @return A Uni object */ - Uni changePassword(String password, String newPassword, String email); + fun changePassword(password: String, newPassword: String, email: String): Uni /** * Generates a new password of a user. @@ -94,7 +94,7 @@ public interface UserRepository extends PanacheRepository { * @return A new password * @throws IllegalArgumentException if the user informs a wrong e-mail */ - Uni recoverPassword(String email); + fun recoverPassword(email: String): Uni /** * Deletes a User from the service. @@ -102,6 +102,6 @@ public interface UserRepository extends PanacheRepository { * @param email : User e-mail * @return Returns a Long 1 if user was deleted */ - Uni deleteUser(String email); - + fun deleteUser(email: String): 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 new file mode 100644 index 0000000..f252116 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/UserRepositoryImpl.kt @@ -0,0 +1,345 @@ +/** + * @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 -> + 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 } + } +} + 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 new file mode 100644 index 0000000..ffb6b1d --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/WebAuthnCredentialRepository.kt @@ -0,0 +1,60 @@ +/** + * @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.WebAuthnCredentialEntity +import io.quarkus.hibernate.reactive.panache.PanacheRepository +import io.smallrye.mutiny.Uni + +/** + * WebAuthn Credential repository interface. + */ +interface WebAuthnCredentialRepository : PanacheRepository { + + /** + * Finds a credential by credential ID. + * + * @param credentialId The credential ID + * @return A Uni object + */ + fun findByCredentialId(credentialId: String): Uni + + /** + * Finds all credentials for a user. + * + * @param userEmail The user's email + * @return A Uni> object + */ + fun findByUserEmail(userEmail: String): Uni> + + /** + * Saves or updates a credential. + * + * @param credential The credential entity + * @return A Uni object + */ + fun saveCredential(credential: WebAuthnCredentialEntity): Uni + + /** + * Deletes a credential. + * + * @param credentialId The credential ID + * @return A Uni object + */ + fun deleteCredential(credentialId: String): Uni +} + 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 new file mode 100644 index 0000000..2394435 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/gateways/repository/WebAuthnCredentialRepositoryImpl.kt @@ -0,0 +1,81 @@ +/** + * @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.WebAuthnCredentialEntity +import io.quarkus.hibernate.reactive.panache.Panache +import io.smallrye.mutiny.Uni +import jakarta.enterprise.context.ApplicationScoped + +/** + * Implementation of WebAuthnCredentialRepository. + */ +@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) + .find("credentialId", credentialId) + .firstResult() + } + + /** + * Finds all credentials for a user. + * + * @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) + .find("userEmail", userEmail) + .list() + } + + /** + * Saves or updates a credential. + * + * @param credential The credential entity + * @return A Uni object + */ + override fun saveCredential(credential: WebAuthnCredentialEntity): Uni { + return Panache.withTransaction { credential.persist() } + .onItem().transform { credential } + } + + /** + * Deletes a credential. + * + * @param credentialId The credential ID + * @return A Uni object + */ + override fun deleteCredential(credentialId: String): Uni { + return findByCredentialId(credentialId) + .onItem().ifNull() + .failWith(IllegalArgumentException("Credential not found")) + .onItem().ifNotNull() + .transformToUni { credential -> + Panache.withTransaction { credential.delete() } + } + } +} + diff --git a/src/main/java/dev/orion/users/adapters/presenters/AuthenticationDTO.java b/src/main/kotlin/dev/orion/users/adapters/presenters/AuthenticationDTO.kt similarity index 67% rename from src/main/java/dev/orion/users/adapters/presenters/AuthenticationDTO.java rename to src/main/kotlin/dev/orion/users/adapters/presenters/AuthenticationDTO.kt index ce49254..03b6408 100644 --- a/src/main/java/dev/orion/users/adapters/presenters/AuthenticationDTO.java +++ b/src/main/kotlin/dev/orion/users/adapters/presenters/AuthenticationDTO.kt @@ -1,6 +1,6 @@ /** * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services + * 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. @@ -14,24 +14,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.orion.users.adapters.presenters; +package dev.orion.users.adapters.presenters - -import dev.orion.users.adapters.gateways.entities.UserEntity; -import lombok.Getter; -import lombok.Setter; +import dev.orion.users.adapters.gateways.entities.UserEntity /** * Authentication DTO. */ -@Getter -@Setter -public class AuthenticationDTO { - +data class AuthenticationDTO( /** The user object. */ - private UserEntity user; - + var user: UserEntity? = null, /** The authentication token (jwt). */ - private String token; + 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 new file mode 100644 index 0000000..a1ab52c --- /dev/null +++ b/src/main/kotlin/dev/orion/users/adapters/presenters/LoginResponseDTO.kt @@ -0,0 +1,30 @@ +/** + * @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.presenters + +/** + * Login response DTO that can indicate if 2FA is required. + */ +data class LoginResponseDTO( + /** The authentication DTO if login is complete. */ + var authentication: AuthenticationDTO? = null, + /** Indicates if 2FA is required. */ + var requires2FA: Boolean = false, + /** Message for the client. */ + var message: String? = null +) + diff --git a/src/main/java/dev/orion/users/application/interfaces/AuthenticateUCI.java b/src/main/kotlin/dev/orion/users/application/interfaces/AuthenticateUCI.kt similarity index 77% rename from src/main/java/dev/orion/users/application/interfaces/AuthenticateUCI.java rename to src/main/kotlin/dev/orion/users/application/interfaces/AuthenticateUCI.kt index 1083a1f..928d34a 100644 --- a/src/main/java/dev/orion/users/application/interfaces/AuthenticateUCI.java +++ b/src/main/kotlin/dev/orion/users/application/interfaces/AuthenticateUCI.kt @@ -1,6 +1,6 @@ /** * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services + * 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. @@ -14,12 +14,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.orion.users.application.interfaces; +package dev.orion.users.application.interfaces -import dev.orion.users.enterprise.model.User; - -public interface AuthenticateUCI { +import dev.orion.users.enterprise.model.User +interface AuthenticateUCI { /** * Authenticates the user in the service (UC: Authenticate). * @@ -27,7 +26,7 @@ public interface AuthenticateUCI { * @param password : The password of the user * @return An User object */ - User authenticate(String email, String password); + fun authenticate(email: String, password: String): User /** * Validates an e-mail of a user. (UC: Validate e-mail) @@ -36,7 +35,7 @@ public interface AuthenticateUCI { * @param code : The validation code * @return The User object */ - Boolean validateEmail(String email, String code); + fun validateEmail(email: String, code: String): Boolean /** * Generates a new password of a user. @@ -45,5 +44,6 @@ public interface AuthenticateUCI { * @return A new password * @throws IllegalArgumentException if the user informs a blank e-mail */ - String recoverPassword(String email); + fun recoverPassword(email: String): String? } + diff --git a/src/main/java/dev/orion/users/application/interfaces/CreateUserUCI.java b/src/main/kotlin/dev/orion/users/application/interfaces/CreateUserUCI.kt similarity index 76% rename from src/main/java/dev/orion/users/application/interfaces/CreateUserUCI.java rename to src/main/kotlin/dev/orion/users/application/interfaces/CreateUserUCI.kt index 1181e39..cf9caf3 100644 --- a/src/main/java/dev/orion/users/application/interfaces/CreateUserUCI.java +++ b/src/main/kotlin/dev/orion/users/application/interfaces/CreateUserUCI.kt @@ -1,6 +1,6 @@ /** * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services + * 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. @@ -14,12 +14,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.orion.users.application.interfaces; +package dev.orion.users.application.interfaces -import dev.orion.users.enterprise.model.User; - -public interface CreateUserUCI { +import dev.orion.users.enterprise.model.User +interface CreateUserUCI { /** * Creates a user in the service (UC: Create). * @@ -28,8 +27,7 @@ public interface CreateUserUCI { * @param password : The password of the user * @return A User object */ - User createUser(String name, String email, String password); - + fun createUser(name: String, email: String, password: String): User /** * Creates a user in the service (UC: Create). @@ -39,5 +37,6 @@ public interface CreateUserUCI { * @param isEmailValid : Confirm if the e-mail is valid or not * @return A User object */ - User createUser(String name, String email, Boolean isEmailValid); + fun createUser(name: String, email: String, isEmailValid: Boolean): User } + diff --git a/src/main/java/dev/orion/users/application/interfaces/DeleteUser.java b/src/main/kotlin/dev/orion/users/application/interfaces/DeleteUser.kt similarity index 74% rename from src/main/java/dev/orion/users/application/interfaces/DeleteUser.java rename to src/main/kotlin/dev/orion/users/application/interfaces/DeleteUser.kt index abef47c..c163394 100644 --- a/src/main/java/dev/orion/users/application/interfaces/DeleteUser.java +++ b/src/main/kotlin/dev/orion/users/application/interfaces/DeleteUser.kt @@ -1,6 +1,6 @@ /** * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services + * 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. @@ -14,16 +14,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.orion.users.application.interfaces; +package dev.orion.users.application.interfaces -public interface DeleteUser { +interface DeleteUser { /** * Deletes a User from the service. * * @param email : User email * - * @return Return 1 if user was deleted + * @return Return true if user was deleted */ - boolean deleteUser(String email); - + 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 new file mode 100644 index 0000000..3ca50cd --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/interfaces/SocialAuthUCI.kt @@ -0,0 +1,33 @@ +/** + * @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 new file mode 100644 index 0000000..1871cd8 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/interfaces/TwoFactorAuthUCI.kt @@ -0,0 +1,43 @@ +/** + * @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 new file mode 100644 index 0000000..7939eea --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/interfaces/UpdateUser.kt @@ -0,0 +1,35 @@ +/** + * @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 (email and/or password). + * At least one field must be provided for update. + * + * @param email : Current user's email + * @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, 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 new file mode 100644 index 0000000..506e3ab --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/interfaces/WebAuthnUCI.kt @@ -0,0 +1,59 @@ +/** + * @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/java/dev/orion/users/application/usecases/AuthenticateUC.java b/src/main/kotlin/dev/orion/users/application/usecases/AuthenticateUC.kt similarity index 56% rename from src/main/java/dev/orion/users/application/usecases/AuthenticateUC.java rename to src/main/kotlin/dev/orion/users/application/usecases/AuthenticateUC.kt index 9fb6444..47bea02 100644 --- a/src/main/java/dev/orion/users/application/usecases/AuthenticateUC.java +++ b/src/main/kotlin/dev/orion/users/application/usecases/AuthenticateUC.kt @@ -1,6 +1,6 @@ /** * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services + * 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. @@ -14,18 +14,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.orion.users.application.usecases; +package dev.orion.users.application.usecases -import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.codec.digest.DigestUtils -import dev.orion.users.application.interfaces.AuthenticateUCI; -import dev.orion.users.enterprise.model.User; +import dev.orion.users.application.interfaces.AuthenticateUCI +import dev.orion.users.enterprise.model.User - -public class AuthenticateUC implements AuthenticateUCI { +class AuthenticateUC : AuthenticateUCI { /** Default blank arguments message. */ - private static final String BLANK = "Blank Arguments"; + private val BLANK = "Blank arguments" + + /** Default invalid arguments message. */ + private val INVALID = "Invalid arguments" /** * Authenticates the user in the service (UC: Authenticate). @@ -34,15 +36,16 @@ public class AuthenticateUC implements AuthenticateUCI { * @param password : The password of the user * @return An User object */ - @Override - public User authenticate(final String email, final String password) { - if (email != null && password != null) { - User user = new User(); - user.setEmail(email); - user.setPassword(DigestUtils.sha256Hex(password)); - return user; + 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 + user.password = DigestUtils.sha256Hex(password) + return user } else { - throw new IllegalArgumentException("All arguments are required"); + throw IllegalArgumentException(INVALID) } } @@ -53,11 +56,11 @@ public User authenticate(final String email, final String password) { * @param code : The validation code * @return true if the validation code is correct for the respective e-mail */ - public Boolean validateEmail(final String email, final String code) { + override fun validateEmail(email: String, code: String): Boolean { if (email.isBlank() || code.isBlank()) { - throw new IllegalArgumentException(BLANK); + throw IllegalArgumentException(BLANK) } else { - return true; + return true } } @@ -68,14 +71,12 @@ public Boolean validateEmail(final String email, final String code) { * @return A new password * @throws IllegalArgumentException if the user informs a blank e-mail */ - @Override - public String recoverPassword(final String email) { + override fun recoverPassword(email: String): String? { if (email.isBlank()) { - throw new IllegalArgumentException(BLANK); + throw IllegalArgumentException(BLANK) } else { - //return repository.recoverPassword(email); - 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 new file mode 100644 index 0000000..6438bd3 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/usecases/CreateUserUC.kt @@ -0,0 +1,82 @@ +/** + * @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 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 { + + /** + * 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 + user.password = encryptPassword(password) + user.emailValid = false + 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 + } + } + + /** + * 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) + } +} + diff --git a/src/main/java/dev/orion/users/application/usecases/DeleteUserImpl.java b/src/main/kotlin/dev/orion/users/application/usecases/DeleteUserImpl.kt similarity index 63% rename from src/main/java/dev/orion/users/application/usecases/DeleteUserImpl.java rename to src/main/kotlin/dev/orion/users/application/usecases/DeleteUserImpl.kt index 78b5b24..57120ec 100644 --- a/src/main/java/dev/orion/users/application/usecases/DeleteUserImpl.java +++ b/src/main/kotlin/dev/orion/users/application/usecases/DeleteUserImpl.kt @@ -1,6 +1,6 @@ /** * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services + * 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. @@ -14,26 +14,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.orion.users.application.usecases; +package dev.orion.users.application.usecases -import dev.orion.users.application.interfaces.DeleteUser; +import dev.orion.users.application.interfaces.DeleteUser -public class DeleteUserImpl implements DeleteUser { +class DeleteUserImpl : DeleteUser { /** * Deletes a User from the service. * * @param email : User email * - * @return Return 1 if user was deleted + * @return Return true if user was deleted */ - @Override - public boolean deleteUser(final String email) { + override fun deleteUser(email: String): Boolean { if (email.isBlank()) { - throw new IllegalArgumentException("Email can not be blank"); + 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 new file mode 100644 index 0000000..079c0ed --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/usecases/SocialAuthUC.kt @@ -0,0 +1,71 @@ +/** + * @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 org.apache.commons.validator.routines.EmailValidator + +class SocialAuthUC : 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") + } + + // Validate email format + if (!EmailValidator.getInstance().isValid(email)) { + throw IllegalArgumentException("$INVALID: invalid email format") + } + + // Validate name is not blank + if (name.isBlank()) { + throw IllegalArgumentException("$BLANK: name cannot be blank") + } + + // 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 + + 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 new file mode 100644 index 0000000..50a9999 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/usecases/TwoFactorAuthUC.kt @@ -0,0 +1,91 @@ +/** + * @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 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 { + + /** 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) + } + 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) + } + 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 new file mode 100644 index 0000000..feaed32 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/usecases/UpdateUserImpl.kt @@ -0,0 +1,92 @@ +/** + * @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 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 { + + /** Default blank arguments message. */ + private val BLANK = "Blank Arguments" + + /** + * Updates user information (email and/or password). + * At least one field must be provided for update. + * + * @param email : Current user's email + * @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, newEmail: String?, password: String?, newPassword: String?): User { + if (email.isBlank()) { + throw IllegalArgumentException(BLANK) + } + + // Validate that at least one field is being updated + if (newEmail.isNullOrBlank() && newPassword.isNullOrBlank()) { + throw IllegalArgumentException("At least one field (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 email if provided + if (!newEmail.isNullOrBlank()) { + if (!EmailValidator.getInstance().isValid(newEmail)) { + throw IllegalArgumentException("Invalid new email format") + } + 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) + } +} + diff --git a/src/main/kotlin/dev/orion/users/application/usecases/WebAuthnUC.kt b/src/main/kotlin/dev/orion/users/application/usecases/WebAuthnUC.kt new file mode 100644 index 0000000..e4766a3 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/usecases/WebAuthnUC.kt @@ -0,0 +1,114 @@ +/** + * @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 org.apache.commons.validator.routines.EmailValidator + +import dev.orion.users.application.interfaces.WebAuthnUCI + +/** + * 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. + */ +class WebAuthnUC : WebAuthnUCI { + + /** Default blank arguments message. */ + private val BLANK = "Blank 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) + } + // 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) + } + 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) + } + // 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) + } + // 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 new file mode 100644 index 0000000..c042f7c --- /dev/null +++ b/src/main/kotlin/dev/orion/users/application/utils/PasswordValidator.kt @@ -0,0 +1,98 @@ +/** + * @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.utils + +/** + * Utility class for password validation. + * Ensures passwords meet security requirements: + * - Minimum 8 characters + * - At least one uppercase letter + * - At least one lowercase letter + * - At least one special character + */ +object PasswordValidator { + + /** The minimum size of the password required. */ + private const val MIN_PASSWORD_LENGTH = 8 + + /** Regex pattern for uppercase letters. */ + private val UPPERCASE_PATTERN = Regex("[A-Z]") + + /** Regex pattern for lowercase letters. */ + private val LOWERCASE_PATTERN = Regex("[a-z]") + + /** Regex pattern for special characters. */ + private val SPECIAL_CHAR_PATTERN = Regex("[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]") + + /** + * Validates if a password meets all security requirements. + * + * @param password The password to validate + * @return PasswordValidationResult containing validation status and error messages + */ + fun validatePassword(password: String): PasswordValidationResult { + val errors = mutableListOf() + + if (password.isEmpty()) { + errors.add("Senha é obrigatória") + return PasswordValidationResult(false, errors) + } + + if (password.length < MIN_PASSWORD_LENGTH) { + errors.add("Senha deve ter no mínimo 8 caracteres") + } + + if (!UPPERCASE_PATTERN.containsMatchIn(password)) { + errors.add("Senha deve conter pelo menos uma letra maiúscula") + } + + if (!LOWERCASE_PATTERN.containsMatchIn(password)) { + errors.add("Senha deve conter pelo menos uma letra minúscula") + } + + if (!SPECIAL_CHAR_PATTERN.containsMatchIn(password)) { + errors.add("Senha deve conter pelo menos um caractere especial") + } + + return PasswordValidationResult(errors.isEmpty(), errors) + } + + /** + * Validates a password and throws an IllegalArgumentException if invalid. + * + * @param password The password to validate + * @throws IllegalArgumentException if the password does not meet requirements + */ + fun validatePasswordOrThrow(password: String) { + val result = validatePassword(password) + if (!result.isValid) { + throw IllegalArgumentException(result.errors.joinToString("; ")) + } + } +} + +/** + * Data class representing the result of password validation. + * + * @param isValid Whether the password meets all requirements + * @param errors List of error messages describing validation failures + */ +data class PasswordValidationResult( + val isValid: Boolean, + val errors: List +) + diff --git a/src/main/java/dev/orion/users/enterprise/model/Role.java b/src/main/kotlin/dev/orion/users/enterprise/model/Role.kt similarity index 60% rename from src/main/java/dev/orion/users/enterprise/model/Role.java rename to src/main/kotlin/dev/orion/users/enterprise/model/Role.kt index d0418ef..3052e22 100644 --- a/src/main/java/dev/orion/users/enterprise/model/Role.java +++ b/src/main/kotlin/dev/orion/users/enterprise/model/Role.kt @@ -1,6 +1,6 @@ /** * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services + * 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. @@ -14,29 +14,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package dev.orion.users.enterprise.model; +package dev.orion.users.enterprise.model /** - * Role. + * Represents a role in the system. */ -public class Role { - +data class Role( /** The name of the role. */ - private String name; - - public Role() {} - - public Role(String name) { - this(); - this.name = name; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } + var name: String? = null +) -} diff --git a/src/main/kotlin/dev/orion/users/enterprise/model/User.kt b/src/main/kotlin/dev/orion/users/enterprise/model/User.kt new file mode 100644 index 0000000..d815e95 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/enterprise/model/User.kt @@ -0,0 +1,110 @@ +/** + * @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.enterprise.model + +import com.fasterxml.jackson.annotation.JsonIgnore +import java.util.UUID + +/** + * Represents a user in the system. + */ +class User { + /** The hash used to identify the user. */ + var hash: String = UUID.randomUUID().toString() + + /** The name of the user. */ + var name: String? = null + + /** The e-mail of the user. */ + var email: String? = null + + /** The password of the user. */ + var password: String? = null + + /** Role list. */ + var roles: MutableList = mutableListOf() + + /** Stores if the e-mail was validated. */ + var emailValid: Boolean = false + + /** The hash used to identify the user. */ + var emailValidationCode: String = UUID.randomUUID().toString() + + /** Stores if is using 2FA. */ + var using2FA: Boolean = false + + /** Secret code to be used at 2FA validation. */ + var secret2FA: String? = null + + /** Controls if 2FA is required for basic login (email/password). */ + var require2FAForBasicLogin: Boolean = false + + /** 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. + */ + fun addRole(role: Role) { + roles.add(role) + } + + /** + * Get the list of roles assigned to the user. + * + * @return A list of roles in String format. + */ + @JsonIgnore + fun getRoleList(): List { + val strRoles = mutableListOf() + if (this.roles.isEmpty()) { + strRoles.add("user") + } else { + for (role in roles) { + role.name?.let { strRoles.add(it) } + } + } + return strRoles + } + + /** + * Generates a new email validation code for the user. + */ + fun setEmailValidationCode() { + this.emailValidationCode = UUID.randomUUID().toString() + } + + /** + * Removes all roles assigned to the user. + */ + fun removeRoles() { + this.roles.clear() + } +} + diff --git a/src/main/kotlin/dev/orion/users/frameworks/rest/ServiceException.kt b/src/main/kotlin/dev/orion/users/frameworks/rest/ServiceException.kt new file mode 100644 index 0000000..92c53c6 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/ServiceException.kt @@ -0,0 +1,47 @@ +/** + * @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.frameworks.rest + +import jakarta.ws.rs.WebApplicationException +import jakarta.ws.rs.core.Response +import jakarta.ws.rs.core.Response.Status + +/** + * Frameworks and Drivers layer of Clean Architecture. + */ +class ServiceException(message: String, status: Status) : WebApplicationException(init(message, status)) { + + companion object { + /** + * A static method to init the message. + * + * @param message : An error message + * @param status : A HTTP error code + * + * @return A Response object + */ + private fun init(message: String, status: Status): Response { + val violations = listOf(mapOf("message" to message)) + + return Response + .status(status) + .entity(mapOf("violations" to violations)) + .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 new file mode 100644 index 0000000..4b77a0b --- /dev/null +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/AuthenticationWS.kt @@ -0,0 +1,242 @@ +/** + * @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.frameworks.rest.authentication + +import dev.orion.users.adapters.controllers.UserController +import dev.orion.users.frameworks.rest.ServiceException +import io.quarkus.hibernate.reactive.panache.common.WithSession +import io.smallrye.mutiny.Uni +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.GET +import jakarta.ws.rs.POST +import jakarta.ws.rs.Path +import jakarta.ws.rs.Produces +import jakarta.ws.rs.QueryParam +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.eclipse.microprofile.faulttolerance.Retry +import org.jboss.resteasy.reactive.RestForm + +/** + * User API. + */ +@PermitAll +@Path("/users") +@Consumes(MediaType.APPLICATION_FORM_URLENCODED) +@Produces(MediaType.APPLICATION_JSON) +@WithSession +class AuthenticationWS { + + /** Fault tolerance default delay. */ + protected val DELAY: Long = 2000 + + /** Business logic of the system. */ + @Inject + private lateinit var controller: UserController + + /** + * @deprecated This method is deprecated and will be removed in a future + * release. Please, use the login method instead. + * + * Authenticates a user. + * + * @param email The email of the user + * @param password The password of the user + * @return The JWT (JSON Web Token) + * @throws A ServiceException if the user is not found + */ + @POST + @Path("/authenticate") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @Retry(maxRetries = 1, delay = 2000) + @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() + .failWith(ServiceException("User not found", Response.Status.UNAUTHORIZED)) + } + + /** + * Authenticates a user. + * If the user has 2FA enabled, returns a response indicating that 2FA code is required. + * + * @param email The email of the user + * @param password The password of the user + * @return The LoginResponseDTO (may contain JWT or indicate 2FA is required) + * @throws A ServiceException if the user is not found + */ + @POST + @Path("/login") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + fun login( + @RestForm @NotEmpty @Email email: String, + @RestForm @NotEmpty password: String + ): Uni { + 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() + } + } + .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) + } + } + + /** + * Authenticates a user with 2FA code. + * + * @param email The email of the user + * @param code The TOTP code + * @return The AuthenticationDTO with JWT token + * @throws A ServiceException if validation fails + */ + @POST + @Path("/login/2fa") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + fun loginWith2FA( + @RestForm @NotEmpty @Email email: String, + @RestForm @NotEmpty code: String + ): Uni { + return controller.validate2FACode(email, code) + .onItem().transform { dto -> + Response.ok(dto).build() + } + .onFailure().transform { e -> + val message = e.message ?: "Invalid TOTP code" + throw ServiceException(message, Response.Status.UNAUTHORIZED) + } + } + + /** + * Creates and authenticates a user. + * + * @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 + * @throws A Bad Request if the service is unable to create the user + */ + @POST + @Path("/createAuthenticate") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + 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 { dto -> Response.ok(dto).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 + * a code. + * + * @param email The e-mail of the user + * @param code The code sent to the user + * @return true if was possible to validate the e-mail + * @throws Bad request if the the em-mail or code is invalid + */ + @GET + @PermitAll + @Path("/validateEmail") + @Consumes(MediaType.TEXT_PLAIN) + @Produces(MediaType.TEXT_PLAIN) + @WithSession + fun validateEmail( + @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()) + } + } + + /** + * Recovers the password of a user. Generates a new password and sends it via email. + * + * @param email The e-mail of the user + * @return HTTP 204 (No Content) if successful + * @throws Bad request if the email is invalid or user not found + */ + @POST + @PermitAll + @Path("/recoverPassword") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @Retry(maxRetries = 1, delay = 2000) + fun recoverPassword( + @RestForm @NotEmpty @Email email: String + ): Uni { + return controller.recoverPassword(email) + .onItem().transform { + Response.noContent().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 new file mode 100644 index 0000000..00ac293 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthWS.kt @@ -0,0 +1,258 @@ +/** + * @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.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 +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 +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. + * Handles OAuth2 authentication with Google. + */ +@PermitAll +@Path("/users") +@Consumes(MediaType.APPLICATION_FORM_URLENCODED) +@Produces(MediaType.APPLICATION_JSON) +@WithSession +class SocialAuthWS { + + /** Fault tolerance default delay. */ + protected val DELAY: Long = 2000 + + /** Business logic of the system. */ + @Inject + private lateinit var controller: UserController + + /** Vertx instance for reactive HTTP client. */ + @Inject + private lateinit var vertx: Vertx + + /** HTTP client for external API calls. */ + private val webClient: WebClient by lazy { + WebClient.create(vertx, WebClientOptions().setFollowRedirects(true)) + } + + /** + * Authenticates a user with Google OAuth2. + * Receives the ID token from Google and validates it. + * If the user has 2FA enabled and requires it for social login, returns a response indicating that 2FA code is required. + * + * @param idToken The Google ID token (JWT) + * @return LoginResponseDTO (may contain JWT or indicate 2FA is required) + * @throws ServiceException if authentication fails + */ + @POST + @Path("/login/google") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + fun loginWithGoogle( + @RestForm @NotEmpty idToken: String + ): Uni { + return validateGoogleToken(idToken) + .onItem().transform { (email, name) -> + controller.loginWithSocialProvider(email, name, "google") + } + .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() + } + } + } + .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. + * + * @param email The email of the user + * @param code The TOTP code to validate + * @return AuthenticationDTO with JWT token + * @throws ServiceException if validation fails + */ + @POST + @Path("/login/google/2fa") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + fun loginWithGoogle2FA( + @RestForm @NotEmpty @Email email: String, + @RestForm @NotEmpty code: String + ): Uni { + return controller.validateSocialLogin2FA(email, code) + .onItem().transform { dto -> + Response.ok(dto).build() + } + .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. + * If the token is a JWT (id_token), it decodes it directly. + * If the token is not a JWT (access_token), it fetches user info from Google API. + * + * @param token The Google ID token (JWT) or access token + * @return Pair of (email, name) + */ + private fun validateGoogleToken(token: String): Uni> { + 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"}")) + } + } + + /** + * Tries to validate the token as a JWT (id_token). + * Returns Uni> if successful, null otherwise. + */ + private fun tryValidateAsJWT(normalizedToken: String): Uni>? { + return try { + // Validate JWT format: should have exactly 3 parts separated by dots + val parts = normalizedToken.split(".") + 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 + } + + // Decode payload (base64url) + val payload: String + try { + 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) + } 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 + + // 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() ?: "") + ?: email + + Uni.createFrom().item(Pair(email, name)) + } catch (e: Exception) { + null // Any error means it's not a valid JWT + } + } + + /** + * 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 -> + if (response.statusCode() != 200) { + 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()) + } 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()?.plus(" ").plus(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}") + } + } +} + 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 new file mode 100644 index 0000000..b691d40 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/SocialAuthenticationWS.kt @@ -0,0 +1,26 @@ +/** + * @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.frameworks.rest.authentication + +import jakarta.ws.rs.Path + +/** + * Social Authenticate. + */ +@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 new file mode 100644 index 0000000..e65bfe7 --- /dev/null +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/TwoFactorAuth.kt @@ -0,0 +1,111 @@ +/** + * @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.frameworks.rest.authentication + +import dev.orion.users.adapters.controllers.UserController +import dev.orion.users.adapters.presenters.AuthenticationDTO +import dev.orion.users.frameworks.rest.ServiceException +import io.quarkus.hibernate.reactive.panache.common.WithSession +import io.smallrye.mutiny.Uni +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.POST +import jakarta.ws.rs.Path +import jakarta.ws.rs.Produces +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.eclipse.microprofile.faulttolerance.Retry +import org.jboss.resteasy.reactive.RestForm + +/** + * Two Factor Authentication Web Service. + */ +@PermitAll +@Path("/users/google/2FAuth") +@Consumes(MediaType.APPLICATION_FORM_URLENCODED) +@WithSession +class TwoFactorAuth { + + /** Fault tolerance default delay. */ + protected val DELAY: Long = 2000 + + /** Business logic of the system. */ + @Inject + private lateinit var controller: UserController + + /** + * Generates a QR code for 2FA setup. + * + * @param email The email of the user + * @param password The password of the user + * @return The QR code image as PNG + * @throws ServiceException if the user is not found or credentials are invalid + */ + @POST + @Path("/qrCode") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces("image/png") + @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) + .type("image/png") + .build() + } + .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. + * + * @param email The email of the user + * @param code The TOTP code to validate + * @return The AuthenticationDTO with JWT token + * @throws ServiceException if validation fails + */ + @POST + @Path("/validate") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + fun validateCode( + @RestForm @NotEmpty @Email email: String, + @RestForm @NotEmpty code: String + ): Uni { + return controller.validate2FACode(email, code) + .onItem().transform { dto -> + Response.ok(dto).build() + } + .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 new file mode 100644 index 0000000..a1f631b --- /dev/null +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/authentication/WebAuthnWS.kt @@ -0,0 +1,168 @@ +/** + * @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.frameworks.rest.authentication + +import dev.orion.users.adapters.controllers.UserController +import dev.orion.users.adapters.presenters.AuthenticationDTO +import dev.orion.users.frameworks.rest.ServiceException +import io.quarkus.hibernate.reactive.panache.common.WithSession +import io.smallrye.mutiny.Uni +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.POST +import jakarta.ws.rs.Path +import jakarta.ws.rs.Produces +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.eclipse.microprofile.faulttolerance.Retry +import org.jboss.resteasy.reactive.RestForm + +/** + * WebAuthn Web Service. + */ +@PermitAll +@Path("/users/webauthn") +@Consumes(MediaType.APPLICATION_FORM_URLENCODED) +@Produces(MediaType.APPLICATION_JSON) +@WithSession +class WebAuthnWS { + + /** Fault tolerance default delay. */ + protected val DELAY: Long = 2000 + + /** Business logic of the system. */ + @Inject + private lateinit var controller: UserController + + /** + * Starts the WebAuthn registration process. + * + * @param email The email of the user + * @return A JSON string containing PublicKeyCredentialCreationOptions + * @throws ServiceException if the user is not found + */ + @POST + @Path("/register/start") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + fun startRegistration( + @RestForm @NotEmpty @Email email: String, + @RestForm origin: String? + ): Uni { + return controller.startWebAuthnRegistration(email, origin) + .onItem().transform { optionsJson -> + Response.ok(optionsJson).build() + } + .onFailure().transform { e -> + val message = e.message ?: "Failed to start WebAuthn registration" + ServiceException(message, Response.Status.BAD_REQUEST) + } + } + + /** + * 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 ServiceException if registration fails + */ + @POST + @Path("/register/finish") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + fun finishRegistration( + @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 -> + val result = mapOf("success" to success, "message" to "WebAuthn credential registered successfully") + Response.ok(result).build() + } + .onFailure().transform { e -> + val message = e.message ?: "Failed to finish WebAuthn registration" + ServiceException(message, Response.Status.BAD_REQUEST) + } + } + + /** + * Starts the WebAuthn authentication process. + * + * @param email The email of the user + * @return A JSON string containing PublicKeyCredentialRequestOptions + * @throws ServiceException if the user is not found or has no credentials + */ + @POST + @Path("/authenticate/start") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + fun startAuthentication( + @RestForm @NotEmpty @Email email: String + ): Uni { + return controller.startWebAuthnAuthentication(email) + .onItem().transform { optionsJson -> + Response.ok(optionsJson).build() + } + .onFailure().transform { e -> + val message = e.message ?: "Failed to start WebAuthn authentication" + ServiceException(message, Response.Status.BAD_REQUEST) + } + } + + /** + * Finishes the WebAuthn authentication process. + * + * @param email The email of the user + * @param response The authentication response from the client (JSON string) + * @return The AuthenticationDTO with JWT token + * @throws ServiceException if authentication fails + */ + @POST + @Path("/authenticate/finish") + @PermitAll + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + fun finishAuthentication( + @RestForm @NotEmpty @Email email: String, + @RestForm @NotEmpty response: String + ): Uni { + return controller.finishWebAuthnAuthentication(email, response) + .onItem().transform { dto -> + Response.ok(dto).build() + } + .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 new file mode 100644 index 0000000..5d2ecab --- /dev/null +++ b/src/main/kotlin/dev/orion/users/frameworks/rest/users/UserWS.kt @@ -0,0 +1,203 @@ +/** + * @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.frameworks.rest.users + +import dev.orion.users.adapters.controllers.UserController +import dev.orion.users.frameworks.rest.ServiceException +import io.smallrye.mutiny.Uni +import jakarta.annotation.security.PermitAll +import jakarta.annotation.security.RolesAllowed +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.PUT +import jakarta.ws.rs.Path +import jakarta.ws.rs.Produces +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.Response +import org.eclipse.microprofile.faulttolerance.Retry +import org.eclipse.microprofile.jwt.Claims +import org.eclipse.microprofile.jwt.JsonWebToken +import org.jboss.resteasy.reactive.RestForm + +/** + * Create a user endpoints. + */ +@Path("/users") +@Consumes(MediaType.APPLICATION_FORM_URLENCODED) +@Produces(MediaType.APPLICATION_JSON) +class UserWS { + + /** Business logic of the system. */ + @Inject + lateinit var controller: UserController + + /** JWT token for authentication. */ + @Inject + lateinit var jwt: JsonWebToken + + /** Fault tolerance default delay. */ + protected val DELAY: Long = 2000 + + /** + * Creates a user inside the service. + * + * @param name The name of the user + * @param email The email of the user + * @param password The password of the user + * @return The user object in JSON format + * @throws Bad request if the service was unable to create the user + */ + @POST + @Path("/create") + @PermitAll + @Retry(maxRetries = 1, delay = 2000) + 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) + .log() + .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. + * + * @param email The email of the user + * @return A boolean + * @throws Bad request if the service was unable to create the user + */ + @POST + @Path("/delete") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @RolesAllowed("admin") + @Retry(maxRetries = 1, delay = 2000) + fun delete( + @FormParam("email") @NotEmpty @Email email: String + ): Uni { + return controller.deleteUser(email) + .log() + .onItem().ifNotNull().transform { result -> + Response.ok(true).build() + } + .onFailure().transform { e -> + val message = e.message ?: "Unknown error" + throw ServiceException(message, Response.Status.BAD_REQUEST) + } + } + + /** + * Updates user information (email and/or password). Requires authentication via JWT token. + * At least one field (newEmail or newPassword) must be provided. + * + * @param email The current email of the user + * @param newEmail The new email address (optional) + * @param password The current password (required if updating password) + * @param newPassword The new password (optional) + * @return A LoginResponseDTO with AuthenticationDTO containing token and updated user + * @throws Bad request if the service was unable to update the user + */ + @PUT + @Path("/update") + @RolesAllowed("user") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + fun updateUser( + @RestForm @NotEmpty @Email email: String, + @RestForm newEmail: String?, + @RestForm password: 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 + ) + + return controller.updateUser(email, newEmail, password, newPassword, jwtEmail) + .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 + } + throw ServiceException(message, status) + } + } + + /** + * Updates 2FA settings for a user. Requires authentication via JWT token. + * Allows the user to configure if 2FA is required for basic login and/or social login. + * + * @param email The current email of the user + * @param require2FAForBasicLogin Whether 2FA is required for basic login (optional, defaults to false) + * @param require2FAForSocialLogin Whether 2FA is required for social login (optional, defaults to false) + * @return The updated user object + * @throws Bad request if the service was unable to update the settings + */ + @POST + @Path("/2fa/settings") + @RolesAllowed("user") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @Retry(maxRetries = 1, delay = 2000) + fun update2FASettings( + @RestForm @NotEmpty @Email email: String, + @RestForm require2FAForBasicLogin: 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 + ) + + // 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 -> + val message = e.message ?: "Unknown error" + val status = if (message.contains("Unauthorized") || message.contains("token")) { + Response.Status.UNAUTHORIZED + } else { + Response.Status.BAD_REQUEST + } + throw ServiceException(message, status) + } + } +} + diff --git a/src/main/resources/META-INF/resources/.gitignore b/src/main/resources/META-INF/resources/.gitignore new file mode 100644 index 0000000..b59f7e3 --- /dev/null +++ b/src/main/resources/META-INF/resources/.gitignore @@ -0,0 +1 @@ +test/ \ No newline at end of file diff --git a/src/main/resources/META-INF/resources/playground/.gitignore b/src/main/resources/META-INF/resources/playground/.gitignore new file mode 100644 index 0000000..ed36915 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment variables +.env +.env.local +.env.*.local + diff --git a/src/main/resources/META-INF/resources/playground/README.md b/src/main/resources/META-INF/resources/playground/README.md new file mode 100644 index 0000000..38a7188 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/README.md @@ -0,0 +1,113 @@ +# Frontend - Orion Users + +Aplicação Vue 3 com Vuetify para testar todas as funcionalidades do serviço de usuários. + +## Funcionalidades + +- ✅ Cadastro de usuários +- ✅ Login simples +- ✅ Autenticação em dois fatores (2FA) +- ✅ WebAuthn (autenticação biométrica/chave de segurança) + +## Pré-requisitos + +- Node.js 18+ +- npm ou yarn + +## Instalação + +```bash +cd frontend +npm install +``` + +## Configuração + +Crie um arquivo `.env` na raiz do projeto `frontend/` com: + +``` +VITE_API_URL=http://localhost:8080 +``` + +## Executar em desenvolvimento + +```bash +npm run dev +``` + +A aplicação estará disponível em `http://localhost:3000` + +## Build para produção + +```bash +npm run build +``` + +Os arquivos serão gerados na pasta `dist/` + +## Estrutura do Projeto + +``` +frontend/ +├── src/ +│ ├── main.js # Configuração Vue e Vuetify +│ ├── App.vue # Componente principal +│ ├── router/ +│ │ └── index.js # Configuração de rotas +│ ├── services/ +│ │ └── api.js # Cliente HTTP e métodos da API +│ ├── stores/ +│ │ └── auth.js # Store Pinia para autenticação +│ └── views/ +│ ├── LoginView.vue # Página de login/cadastro +│ ├── TwoFactorView.vue # Página de 2FA +│ └── WebAuthnView.vue # Página de WebAuthn +``` + +## Uso + +### Cadastro +1. Acesse a aba "Cadastro" na página inicial +2. Preencha nome, email e senha (mínimo 8 caracteres) +3. Clique em "Cadastrar" + +### Login Simples +1. Acesse a aba "Login" na página inicial +2. Preencha email e senha +3. Clique em "Entrar" +4. Se o usuário tiver 2FA habilitado, será redirecionado para a página de validação + +### Configurar 2FA +1. Acesse a página de 2FA (`/2fa`) +2. Preencha email e senha +3. Clique em "Gerar QR Code" +4. Escaneie o QR code com um aplicativo autenticador (Google Authenticator, Authy, etc.) +5. Clique em "Já escaneei, validar código" +6. Digite o código de 6 dígitos do aplicativo + +### Autenticar com 2FA +1. Após fazer login, se 2FA estiver habilitado, você será redirecionado automaticamente +2. Digite o código de 6 dígitos do seu aplicativo autenticador +3. Clique em "Validar Código" + +### WebAuthn - Registrar Dispositivo +1. Acesse a página de WebAuthn (`/webauthn`) +2. Vá para a aba "Registrar Dispositivo" +3. Preencha seu email +4. (Opcional) Digite um nome para o dispositivo +5. Clique em "Registrar Dispositivo" +6. Siga as instruções do navegador para autenticação biométrica ou chave de segurança + +### WebAuthn - Autenticar +1. Acesse a página de WebAuthn (`/webauthn`) +2. Vá para a aba "Autenticar" +3. Preencha seu email +4. Clique em "Autenticar com WebAuthn" +5. Siga as instruções do navegador para autenticação biométrica ou chave de segurança + +## Notas + +- O backend deve estar rodando em `http://localhost:8080` (ou conforme configurado no `.env`) +- WebAuthn requer um navegador moderno (Chrome, Firefox, Edge) e HTTPS em produção +- Para desenvolvimento local, alguns navegadores podem permitir WebAuthn em localhost sem HTTPS + diff --git a/src/main/resources/META-INF/resources/playground/index.html b/src/main/resources/META-INF/resources/playground/index.html new file mode 100644 index 0000000..16ee415 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/index.html @@ -0,0 +1,16 @@ + + + + + + + Orion Users - Login + + + + +
+ + + + diff --git a/src/main/resources/META-INF/resources/playground/package-lock.json b/src/main/resources/META-INF/resources/playground/package-lock.json new file mode 100644 index 0000000..46f0124 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/package-lock.json @@ -0,0 +1,1573 @@ +{ + "name": "users-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "users-frontend", + "version": "1.0.0", + "dependencies": { + "@mdi/font": "^7.4.47", + "axios": "^1.6.7", + "pinia": "^2.1.7", + "vue": "^3.4.21", + "vue-router": "^4.3.0", + "vuetify": "^3.5.10" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "vite": "^5.1.4", + "vite-plugin-vuetify": "^2.0.2" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@mdi/font": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", + "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==", + "license": "Apache-2.0" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz", + "integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.24", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz", + "integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz", + "integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.24", + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz", + "integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", + "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz", + "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz", + "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.24", + "@vue/runtime-core": "3.5.24", + "@vue/shared": "3.5.24", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz", + "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24" + }, + "peerDependencies": { + "vue": "3.5.24" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz", + "integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==", + "license": "MIT" + }, + "node_modules/@vuetify/loader-shared": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vuetify/loader-shared/-/loader-shared-2.1.1.tgz", + "integrity": "sha512-jSZTzTYaoiv8iwonFCVZQ0YYX/M+Uyl4ng+C4egMJT0Hcmh9gIxJL89qfZICDeo3g0IhqrvipW2FFKKRDMtVcA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "upath": "^2.0.1" + }, + "peerDependencies": { + "vue": "^3.0.0", + "vuetify": "^3.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/upath": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", + "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-vuetify": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-2.1.2.tgz", + "integrity": "sha512-I/wd6QS+DO6lHmuGoi1UTyvvBTQ2KDzQZ9oowJQEJ6OcjWfJnscYXx2ptm6S7fJSASuZT8jGRBL3LV4oS3LpaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vuetify/loader-shared": "^2.1.1", + "debug": "^4.3.3", + "upath": "^2.0.1" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": ">=5", + "vue": "^3.0.0", + "vuetify": "^3.0.0" + } + }, + "node_modules/vue": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz", + "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-sfc": "3.5.24", + "@vue/runtime-dom": "3.5.24", + "@vue/server-renderer": "3.5.24", + "@vue/shared": "3.5.24" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz", + "integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vuetify": { + "version": "3.10.11", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.10.11.tgz", + "integrity": "sha512-hfllXT0/C3O5nZyIRalaDU7ClMIrKrKAbjH0T8xbSUb7FcJrHOqPZfEkSXwrKxajv6EA1rwEOvCZoLDhunnjrQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/johnleider" + }, + "peerDependencies": { + "typescript": ">=4.7", + "vite-plugin-vuetify": ">=2.1.0", + "vue": "^3.5.0", + "webpack-plugin-vuetify": ">=3.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vite-plugin-vuetify": { + "optional": true + }, + "webpack-plugin-vuetify": { + "optional": true + } + } + } + } +} diff --git a/src/main/resources/META-INF/resources/playground/package.json b/src/main/resources/META-INF/resources/playground/package.json new file mode 100644 index 0000000..4f2adec --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/package.json @@ -0,0 +1,24 @@ +{ + "name": "users-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.21", + "vue-router": "^4.3.0", + "pinia": "^2.1.7", + "axios": "^1.6.7", + "vuetify": "^3.5.10", + "@mdi/font": "^7.4.47" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "vite": "^5.1.4", + "vite-plugin-vuetify": "^2.0.2" + } +} + diff --git a/src/main/resources/META-INF/resources/playground/src/App.vue b/src/main/resources/META-INF/resources/playground/src/App.vue new file mode 100644 index 0000000..17b9239 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/App.vue @@ -0,0 +1,62 @@ + + + + diff --git a/src/main/resources/META-INF/resources/playground/src/components/DebugModal.vue b/src/main/resources/META-INF/resources/playground/src/components/DebugModal.vue new file mode 100644 index 0000000..37c4289 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/components/DebugModal.vue @@ -0,0 +1,80 @@ + + + + diff --git a/src/main/resources/META-INF/resources/playground/src/components/LogList.vue b/src/main/resources/META-INF/resources/playground/src/components/LogList.vue new file mode 100644 index 0000000..e9b9153 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/components/LogList.vue @@ -0,0 +1,240 @@ + + + + + + diff --git a/src/main/resources/META-INF/resources/playground/src/components/PasswordStrengthIndicator.vue b/src/main/resources/META-INF/resources/playground/src/components/PasswordStrengthIndicator.vue new file mode 100644 index 0000000..72188f2 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/components/PasswordStrengthIndicator.vue @@ -0,0 +1,97 @@ + + + + + + diff --git a/src/main/resources/META-INF/resources/playground/src/main.js b/src/main/resources/META-INF/resources/playground/src/main.js new file mode 100644 index 0000000..0276249 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/main.js @@ -0,0 +1,26 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import 'vuetify/styles' +import { createVuetify } from 'vuetify' +import * as components from 'vuetify/components' +import * as directives from 'vuetify/directives' +import '@mdi/font/css/materialdesignicons.css' + +const vuetify = createVuetify({ + components, + directives, + theme: { + defaultTheme: 'light' + } +}) + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) +app.use(vuetify) + +app.mount('#app') + diff --git a/src/main/resources/META-INF/resources/playground/src/router/index.js b/src/main/resources/META-INF/resources/playground/src/router/index.js new file mode 100644 index 0000000..c80c72d --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/router/index.js @@ -0,0 +1,50 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' + +const routes = [ + { + path: '/', + name: 'login', + component: () => import('../views/LoginView.vue') + }, + { + path: '/2fa', + name: '2fa', + component: () => import('../views/TwoFactorView.vue') + }, + { + path: '/webauthn', + name: 'webauthn', + component: () => import('../views/WebAuthnView.vue') + }, + { + path: '/dashboard', + name: 'dashboard', + component: () => import('../views/DashboardView.vue'), + meta: { requiresAuth: true } + }, + { + path: '/recover-password', + name: 'recover-password', + component: () => import('../views/RecoverPasswordView.vue') + } +] + +const router = createRouter({ + history: createWebHistory('/test'), + routes +}) + +// Guard de navegação para rotas protegidas +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + next('/') + } else { + next() + } +}) + +export default router + diff --git a/src/main/resources/META-INF/resources/playground/src/services/api.js b/src/main/resources/META-INF/resources/playground/src/services/api.js new file mode 100644 index 0000000..b1bb76e --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/services/api.js @@ -0,0 +1,191 @@ +import axios from 'axios' +import { useDebugStore } from '../stores/debug' + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080' + +const api = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } +}) + +// Interceptor to add JWT token to requests and log requests +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('auth_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + + // Log the request + try { + const debugStore = useDebugStore() + debugStore.addLog(config, null, null) + } catch (e) { + // Store may not be initialized yet, ignore error + console.warn('Debug store not available:', e) + } + + return config + }, + (error) => { + try { + const debugStore = useDebugStore() + debugStore.addLog(error.config, null, error) + } catch (e) { + console.warn('Debug store not available:', e) + } + return Promise.reject(error) + } +) + +// Interceptor to handle response errors and log responses +api.interceptors.response.use( + (response) => { + // Log successful response + try { + const debugStore = useDebugStore() + // Create a copy of the response for logging (without blob if it's a blob) + const logResponse = { + ...response, + data: response.config.responseType === 'blob' + ? '[Blob - ' + response.data.size + ' bytes]' + : response.data + } + debugStore.addLog(response.config, logResponse, null) + } catch (e) { + console.warn('Debug store not available:', e) + } + return response + }, + (error) => { + // Log the error + try { + const debugStore = useDebugStore() + debugStore.addLog(error.config || {}, null, error) + } catch (e) { + console.warn('Debug store not available:', e) + } + + if (error.response?.status === 401) { + localStorage.removeItem('auth_token') + localStorage.removeItem('user') + } + return Promise.reject(error) + } +) + +// Helper function to convert object to FormData +const toFormData = (data) => { + const formData = new URLSearchParams() + Object.keys(data).forEach(key => { + if (data[key] !== null && data[key] !== undefined) { + formData.append(key, data[key]) + } + }) + return formData +} + +export const userApi = { + // Registration + createUser: (name, email, password) => { + return api.post('/users/create', toFormData({ name, email, password })) + }, + + createAndAuthenticate: (name, email, password) => { + return api.post('/users/createAuthenticate', toFormData({ name, email, password })) + }, + + // Login + login: (email, password) => { + return api.post('/users/login', toFormData({ email, password })) + }, + + // 2FA + generate2FAQRCode: (email, password) => { + return api.post('/users/google/2FAuth/qrCode', toFormData({ email, password }), { + responseType: 'blob' + }) + }, + + validate2FACode: (email, code) => { + return api.post('/users/google/2FAuth/validate', toFormData({ email, code })) + }, + + loginWith2FA: (email, code) => { + return api.post('/users/login/2fa', toFormData({ email, code })) + }, + + // WebAuthn + startWebAuthnRegistration: (email, origin) => { + return api.post('/users/webauthn/register/start', toFormData({ + email, + origin: origin || null + })) + }, + + finishWebAuthnRegistration: (email, response, origin, deviceName) => { + return api.post('/users/webauthn/register/finish', toFormData({ + email, + response, + origin, + deviceName: deviceName || null + })) + }, + + startWebAuthnAuthentication: (email) => { + return api.post('/users/webauthn/authenticate/start', toFormData({ email })) + }, + + finishWebAuthnAuthentication: (email, response) => { + return api.post('/users/webauthn/authenticate/finish', toFormData({ email, response })) + }, + + // Email Validation + validateEmail: (email, code) => { + return api.get('/users/validateEmail', { + params: { email, code } + }) + }, + + // Password Recovery + recoverPassword: (email) => { + return api.post('/users/recoverPassword', toFormData({ email })) + }, + + // User Update (email and/or password) + updateUser: (email, newEmail, password, newPassword) => { + const data = { email } + if (newEmail) data.newEmail = newEmail + if (password) data.password = password + if (newPassword) data.newPassword = newPassword + + return api.put('/users/update', toFormData(data), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + }, + + // Social Auth + loginWithGoogle: (idToken) => { + return api.post('/users/login/google', toFormData({ idToken })) + }, + + loginWithGoogle2FA: (email, code) => { + return api.post('/users/login/google/2fa', toFormData({ email, code })) + }, + + // 2FA Settings + update2FASettings: (email, require2FAForBasicLogin, require2FAForSocialLogin) => { + return api.post('/users/2fa/settings', toFormData({ + email, + require2FAForBasicLogin: require2FAForBasicLogin || false, + require2FAForSocialLogin: require2FAForSocialLogin || false + })) + } +} + +export default api + diff --git a/src/main/resources/META-INF/resources/playground/src/stores/auth.js b/src/main/resources/META-INF/resources/playground/src/stores/auth.js new file mode 100644 index 0000000..fbefe40 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/stores/auth.js @@ -0,0 +1,32 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('auth_token') || null) + const user = ref(JSON.parse(localStorage.getItem('user') || 'null')) + + const isAuthenticated = computed(() => !!token.value) + + function setAuth(authToken, userData) { + token.value = authToken + user.value = userData + localStorage.setItem('auth_token', authToken) + localStorage.setItem('user', JSON.stringify(userData)) + } + + function logout() { + token.value = null + user.value = null + localStorage.removeItem('auth_token') + localStorage.removeItem('user') + } + + return { + token, + user, + isAuthenticated, + setAuth, + logout + } +}) + diff --git a/src/main/resources/META-INF/resources/playground/src/stores/debug.js b/src/main/resources/META-INF/resources/playground/src/stores/debug.js new file mode 100644 index 0000000..0b4070a --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/stores/debug.js @@ -0,0 +1,59 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useDebugStore = defineStore('debug', () => { + const logs = ref([]) + const showModal = ref(false) + + function addLog(request, response, error = null) { + const log = { + id: Date.now(), + timestamp: new Date().toISOString(), + request: { + method: request?.method, + url: request?.url, + baseURL: request?.baseURL, + data: request?.data, + headers: request?.headers + }, + response: response ? { + status: response?.status, + statusText: response?.statusText, + data: response?.data, + headers: response?.headers + } : null, + error: error ? { + message: error?.message, + response: error?.response ? { + status: error.response.status, + statusText: error.response.statusText, + data: error.response.data, + headers: error.response.headers + } : null + } : null + } + + logs.value.unshift(log) + // Manter apenas os últimos 50 logs + if (logs.value.length > 50) { + logs.value = logs.value.slice(0, 50) + } + } + + function clearLogs() { + logs.value = [] + } + + function toggleModal() { + showModal.value = !showModal.value + } + + return { + logs, + showModal, + addLog, + clearLogs, + toggleModal + } +}) + diff --git a/src/main/resources/META-INF/resources/playground/src/utils/passwordValidation.js b/src/main/resources/META-INF/resources/playground/src/utils/passwordValidation.js new file mode 100644 index 0000000..5b2aa8e --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/utils/passwordValidation.js @@ -0,0 +1,76 @@ +/** + * Password validation utilities + */ + +/** + * Validates if password has at least 8 characters + */ +export const hasMinLength = (password) => { + return password && password.length >= 8 +} + +/** + * Validates if password has at least one uppercase letter + */ +export const hasUpperCase = (password) => { + return password && /[A-Z]/.test(password) +} + +/** + * Validates if password has at least one lowercase letter + */ +export const hasLowerCase = (password) => { + return password && /[a-z]/.test(password) +} + +/** + * Validates if password has at least one special character + */ +export const hasSpecialChar = (password) => { + return password && /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password) +} + +/** + * Validates all password requirements and returns an object with the status of each one + * @param {string} password - The password to be validated + * @returns {object} Object with status of each requirement + */ +export const validatePassword = (password) => { + if (!password) { + return { + minLength: false, + upperCase: false, + lowerCase: false, + specialChar: false, + isValid: false + } + } + + const minLength = hasMinLength(password) + const upperCase = hasUpperCase(password) + const lowerCase = hasLowerCase(password) + const specialChar = hasSpecialChar(password) + + return { + minLength, + upperCase, + lowerCase, + specialChar, + isValid: minLength && upperCase && lowerCase && specialChar + } +} + +/** + * Returns array of validation rules for Vuetify + * @returns {Array} Array of validation functions + */ +export const getPasswordRules = () => { + return [ + v => !!v || 'Password is required', + v => hasMinLength(v) || 'Password must be at least 8 characters', + v => hasUpperCase(v) || 'Password must contain at least one uppercase letter', + v => hasLowerCase(v) || 'Password must contain at least one lowercase letter', + v => hasSpecialChar(v) || 'Password must contain at least one special character' + ] +} + diff --git a/src/main/resources/META-INF/resources/playground/src/views/DashboardView.vue b/src/main/resources/META-INF/resources/playground/src/views/DashboardView.vue new file mode 100644 index 0000000..a20fd33 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/views/DashboardView.vue @@ -0,0 +1,518 @@ + + + + diff --git a/src/main/resources/META-INF/resources/playground/src/views/LoginView.vue b/src/main/resources/META-INF/resources/playground/src/views/LoginView.vue new file mode 100644 index 0000000..01cbb10 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/views/LoginView.vue @@ -0,0 +1,391 @@ + + + + diff --git a/src/main/resources/META-INF/resources/playground/src/views/RecoverPasswordView.vue b/src/main/resources/META-INF/resources/playground/src/views/RecoverPasswordView.vue new file mode 100644 index 0000000..1bf0bf6 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/views/RecoverPasswordView.vue @@ -0,0 +1,116 @@ + + + + diff --git a/src/main/resources/META-INF/resources/playground/src/views/TwoFactorView.vue b/src/main/resources/META-INF/resources/playground/src/views/TwoFactorView.vue new file mode 100644 index 0000000..5332b5a --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/views/TwoFactorView.vue @@ -0,0 +1,271 @@ + + + + diff --git a/src/main/resources/META-INF/resources/playground/src/views/WebAuthnView.vue b/src/main/resources/META-INF/resources/playground/src/views/WebAuthnView.vue new file mode 100644 index 0000000..7caef86 --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/src/views/WebAuthnView.vue @@ -0,0 +1,347 @@ + + + + diff --git a/src/main/resources/META-INF/resources/playground/vite.config.js b/src/main/resources/META-INF/resources/playground/vite.config.js new file mode 100644 index 0000000..d76267d --- /dev/null +++ b/src/main/resources/META-INF/resources/playground/vite.config.js @@ -0,0 +1,63 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vuetify from 'vite-plugin-vuetify' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + // Configurar base para servir em /test/ tanto em desenvolvimento quanto em produção + base: '/test/', + plugins: [ + vue(), + vuetify({ autoImport: true }) + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + build: { + // Gerar arquivos diretamente em META-INF/resources/test/ + // para serem servidos pelo Quarkus em http://localhost:8080/test/ + outDir: '../test', + rollupOptions: { + output: { + manualChunks(id) { + // Separar bibliotecas grandes em chunks próprios + if (id.includes('node_modules')) { + // Vuetify é grande e pode ser separado + if (id.includes('vuetify')) { + return 'vuetify' + } + // Vue, Vue Router e Pinia são necessários desde o início + // Mas podem ser separados se necessário para reduzir o tamanho do chunk principal + if (id.includes('vue') || id.includes('vue-router') || id.includes('pinia')) { + return 'vue-vendor' + } + // Outras dependências + if (id.includes('axios')) { + return 'vendor' + } + } + } + } + }, + // Desabilitar preload automático de módulos para evitar avisos + // Os chunks serão carregados quando necessário via imports dinâmicos + modulePreload: { + polyfill: false, + resolveDependencies: () => [] + }, + chunkSizeWarningLimit: 1000 + }, + server: { + // Em desenvolvimento, Vite roda em porta diferente do Quarkus + port: 3000, + proxy: { + '/users': { + target: 'http://localhost:8080', + changeOrigin: true + } + } + } +}) + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b65712b..72085c4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -27,7 +27,7 @@ mp.jwt.verify.publickey.location=publicKey.pem # HTTPS %prod.quarkus.ssl.native=true %dev.quarkus.ssl.native=true -%prod.quarkus.http.insecure-requests=disabledreuse-data-file=true +#%prod.quarkus.http.insecure-requests=disabledreuse-data-file=true %prod.quarkus.http.host=0.0.0.0 %dev.quarkus.http.port=8080 %dev.quarkus.http.test-port=8081 @@ -35,9 +35,12 @@ quarkus.http.ssl-port=8443 quarkus.http.ssl.certificate.key-store-file=keystore.jks quarkus.http.ssl.certificate.key-store-password=password -#CORS -%dev.quarkus.http.cors=true -%dev.quarkus.http.cors.origins=/.*/ +#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 @@ -57,10 +60,14 @@ users.email.validation.url=http://localhost:8080/users/validateEmail # Google Openid Provider quarkus.oidc.enabled=false quarkus.oidc.provider=GOOGLE -quarkus.oidc.client-id=307391126869-5c1f7q3vl6hdqv1elvq4humtrc8tvfef.apps.googleusercontent.com +quarkus.oidc.client-id=[Google Client ID] quarkus.oidc.credentials.secret=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 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] + quarkus.log.level=INFO diff --git a/src/test/java/dev/orion/users/rest/UsersTest.java b/src/test/java/dev/orion/users/rest/UsersTest.java deleted file mode 100644 index 02cdcf1..0000000 --- a/src/test/java/dev/orion/users/rest/UsersTest.java +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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.rest; - -import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.is; - -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; - -import io.quarkus.test.junit.QuarkusTest; - -@QuarkusTest -public class UsersTest { - - @Test - @Order(1) - void createUser() { - given().when() - .param("name", "Orion") - .param("email", "orion@test.com") - .param("password", "12345678") - .post("/users/create") - .then() - .statusCode(200) - .body("name", is("Orion"), - "email", is("orion@test.com")); - } - - @Test - @Order(2) - void createUserWithInvalidPassword() { - given().when() - .param("name", "Orion") - .param("email", "orion@test.com") - .param("password", "123") - .post("/users/create") - .then() - .statusCode(400); - } - -} \ No newline at end of file diff --git a/src/test/java/dev/orion/users/usecases/CreateUserUCTest.java b/src/test/java/dev/orion/users/usecases/CreateUserUCTest.java deleted file mode 100644 index 55c548a..0000000 --- a/src/test/java/dev/orion/users/usecases/CreateUserUCTest.java +++ /dev/null @@ -1,59 +0,0 @@ -/** - * @License - * Copyright 2023 Orion Services @ https://github.com/orion-services - * - * 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.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.interfaces.CreateUserUCI; -import dev.orion.users.application.usecases.CreateUserUC; -import dev.orion.users.enterprise.model.User; -import io.smallrye.common.constraint.Assert; - -public class CreateUserUCTest { - - //** Use cases */ - CreateUserUCI uc = new CreateUserUC(); - - @Test - @DisplayName("Create a user with valid arguments") - @Order(1) - void createUserWithValidArguments() { - String name = "Orion"; - String email = "orion@test.com"; - String password = "12345678"; - User user = uc.createUser(name, email, password); - Assert.assertNotNull(user); - } - - @Test - @DisplayName("Create a user with invalid password") - @Order(1) - void createUserWithInValidPassword() { - String name = "Orion"; - String email = "orion@test.com"; - String password = "123"; - Assertions.assertThrows(IllegalArgumentException.class, - () -> { - uc.createUser(name, email, password); - }); - } - - -} diff --git a/src/test/kotlin/dev/orion/users/rest/UsersIT.kt b/src/test/kotlin/dev/orion/users/rest/UsersIT.kt new file mode 100644 index 0000000..9760035 --- /dev/null +++ b/src/test/kotlin/dev/orion/users/rest/UsersIT.kt @@ -0,0 +1,194 @@ +/** + * @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.rest + +import io.restassured.RestAssured.given +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 + + /** + * The HTTP status code for a bad request. + */ + private val BAD_REQUEST = 400 + + /** + * The HTTP status code for an unauthorized request. + */ + 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 PARAM_NAME = "name" + private val PARAM_EMAIL = "email" + private val PARAM_PASSWORD = "password" + + /** + * Test case for creating a user. + */ + @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)) + assertEquals(OK, response.extract().statusCode()) + } + + /** + * Test case to verify the behavior of creating a user with an invalid + * password. + */ + @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) + assertEquals(BAD_REQUEST, response.extract().statusCode()) + } + + /** + * Test case for the login functionality. + * + * This method sends a POST request to the "/users/login" endpoint with the + * specified email and password parameters. It then validates the response + * status code and asserts that the returned user's name matches the + * expected name. + */ + @Test + @Order(3) + fun login() { + 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")) + } + + /** + * Test case to verify the behavior of the loginWithWrongPassword method. + * This method tests the scenario where a user tries to login with an + * incorrect password. + */ + @Test + @Order(4) + fun loginWithWrongPassword() { + 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) + + assertEquals(UNAUTHORIZED, response.extract().statusCode()) + } + + /** + * Test case for logging in without providing a password. + * + * This test sends a POST request to the "/users/login" endpoint without + * providing a password. It expects the server to respond with a 400 Bad + * Request status code. The test asserts that the response status code + * matches the expected value. + */ + @Test + @Order(5) + fun loginWithoutPassword() { + 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) + + assertEquals(BAD_REQUEST, response.extract().statusCode()) + } + + /** + * Test case to verify the behavior when attempting to login with a + * nonexistent user. + * The test sends a POST request to the "/users/login" endpoint with a + * nonexistent user's email and a password. + * The expected behavior is a response with a status code of + * 400 (BAD_REQUEST). + */ + @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) + + 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 new file mode 100644 index 0000000..7250873 --- /dev/null +++ b/src/test/kotlin/dev/orion/users/usecases/AuthenticateUCTest.kt @@ -0,0 +1,80 @@ +/** + * @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.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.interfaces.AuthenticateUCI +import dev.orion.users.application.usecases.AuthenticateUC +import dev.orion.users.enterprise.model.User +import io.smallrye.common.constraint.Assert + +/** + * This class contains unit tests for the CreateUserUC class. + */ +class AuthenticateUCTest { + + /** Use cases */ + private val uc: AuthenticateUCI = AuthenticateUC() + + @Test + @DisplayName("Authenticates a user with valid arguments") + @Order(1) + fun authenticate() { + val email = "orion@services.dev" + val password = "12345678" + val user: User = uc.authenticate(email, password) + Assert.assertNotNull(user) + } + + @Test + @DisplayName("Authenticates a user with valid arguments") + @Order(2) + fun authenticateWithInValidPassword() { + val email = "orion@services.dev" + val password = "123" + Assertions.assertThrows(IllegalArgumentException::class.java) { + uc.authenticate(email, password) + } + } + + @Test + @DisplayName("Authenticates a empty e-mail") + @Order(3) + fun authenticateWithNoEmail() { + val email = "" + val password = "12345678" + Assertions.assertThrows(IllegalArgumentException::class.java) { + uc.authenticate(email, password) + } + } + + @Test + @DisplayName("Authenticates a empty password") + @Order(4) + fun authenticateWithNoPassword() { + val email = "orion@services.dev" + val password = "" + Assertions.assertThrows(IllegalArgumentException::class.java) { + uc.authenticate(email, password) + } + } +} + diff --git a/src/test/kotlin/dev/orion/users/usecases/CreateUserUCTest.kt b/src/test/kotlin/dev/orion/users/usecases/CreateUserUCTest.kt new file mode 100644 index 0000000..c767c75 --- /dev/null +++ b/src/test/kotlin/dev/orion/users/usecases/CreateUserUCTest.kt @@ -0,0 +1,96 @@ +/** + * @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.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.interfaces.CreateUserUCI +import dev.orion.users.application.usecases.CreateUserUC +import dev.orion.users.enterprise.model.User +import io.smallrye.common.constraint.Assert + +/** + * This class contains unit tests for the CreateUserUC class. + */ +class CreateUserUCTest { + + /** Use cases */ + private val uc: CreateUserUCI = CreateUserUC() + + @Test + @DisplayName("Create a user with valid arguments") + @Order(1) + fun createUserWithValidArguments() { + val name = "Orion" + val email = "orion@services.dev" + val password = "12345678" + val user: User = uc.createUser(name, email, password) + Assert.assertNotNull(user) + } + + @Test + @DisplayName("Create a user with invalid password") + @Order(2) + fun createUserWithInValidPassword() { + val name = "Orion" + val email = "orion@services.dev" + val password = "123" + Assertions.assertThrows(IllegalArgumentException::class.java) { + uc.createUser(name, email, password) + } + } + + @Test + @DisplayName("Create a user with no name") + @Order(3) + fun createUserWithNoName() { + val name = "" + val email = "orion@services.dev" + val password = "12345678" + Assertions.assertThrows(IllegalArgumentException::class.java) { + uc.createUser(name, email, password) + } + } + + @Test + @DisplayName("Create a user with no password") + @Order(4) + fun createUserWithNoPassword() { + val name = "Orion" + val email = "orion@services.dev" + val password = "" + Assertions.assertThrows(IllegalArgumentException::class.java) { + uc.createUser(name, email, password) + } + } + + @Test + @DisplayName("Create a user with incorrect e-mail") + @Order(5) + fun createUserWithIncorrectEmail() { + val name = "Orion" + val email = "orionservices.dev" + val password = "12345678" + Assertions.assertThrows(IllegalArgumentException::class.java) { + uc.createUser(name, email, password) + } + } +} + diff --git a/static/checkstyle.xml b/static/checkstyle.xml new file mode 100644 index 0000000..1e3b703 --- /dev/null +++ b/static/checkstyle.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/static/pmd.xml b/static/pmd.xml new file mode 100644 index 0000000..009b2ff --- /dev/null +++ b/static/pmd.xml @@ -0,0 +1,14 @@ + + + + + Orion Users + + + + + + + \ No newline at end of file