init commit
This commit is contained in:
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Use official OpenJDK 17 image
|
||||||
|
FROM openjdk:17-jdk-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Maven
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y maven && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy Maven files first for better caching
|
||||||
|
COPY pom.xml .
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN mvn clean package -DskipTests
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["java", "-jar", "target/device-fingerprint-auth-1.0.0.jar"]
|
||||||
116
docker-compose.yml
Normal file
116
docker-compose.yml
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# MySQL Database
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: auth-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: rootpassword123
|
||||||
|
MYSQL_DATABASE: device_fingerprint_auth
|
||||||
|
MYSQL_USER: auth_user
|
||||||
|
MYSQL_PASSWORD: auth_password123
|
||||||
|
ports:
|
||||||
|
- "23306:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
- ./src/main/resources/db/migration:/docker-entrypoint-initdb.d
|
||||||
|
networks:
|
||||||
|
- auth-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
|
timeout: 20s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# Spring Boot Application
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: auth-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "2080:8080"
|
||||||
|
environment:
|
||||||
|
# Database Configuration
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/device_fingerprint_auth?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
|
||||||
|
SPRING_DATASOURCE_USERNAME: auth_user
|
||||||
|
SPRING_DATASOURCE_PASSWORD: auth_password123
|
||||||
|
|
||||||
|
# JPA Configuration
|
||||||
|
SPRING_JPA_HIBERNATE_DDL_AUTO: create-drop
|
||||||
|
SPRING_JPA_SHOW_SQL: true
|
||||||
|
SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT: org.hibernate.dialect.MySQL8Dialect
|
||||||
|
|
||||||
|
# Flyway Configuration - Disabled for container environments
|
||||||
|
SPRING_FLYWAY_ENABLED: false
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
SPRING_PROFILES_ACTIVE: demo
|
||||||
|
APP_ENVIRONMENT: development
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
APP_JWT_SECRET: your-super-secret-jwt-key-that-should-be-at-least-256-bits-long-for-security
|
||||||
|
APP_JWT_ACCESS_TOKEN_EXPIRATION: 900
|
||||||
|
APP_JWT_REFRESH_TOKEN_EXPIRATION: 2592000
|
||||||
|
|
||||||
|
# Email Configuration (Demo Mode)
|
||||||
|
APP_EMAIL_ENABLED: true
|
||||||
|
APP_EMAIL_FROM_ADDRESS: noreply@company.com
|
||||||
|
APP_EMAIL_FROM_NAME: Auth Service
|
||||||
|
|
||||||
|
# SMS Configuration (Demo Mode)
|
||||||
|
APP_SMS_ENABLED: true
|
||||||
|
APP_SMS_PROVIDER: demo
|
||||||
|
|
||||||
|
# TOTP Configuration
|
||||||
|
APP_TOTP_ISSUER: AuthService
|
||||||
|
APP_TOTP_ENABLED: true
|
||||||
|
|
||||||
|
# GeoLocation Configuration
|
||||||
|
APP_GEOLOCATION_ENABLED: true
|
||||||
|
APP_GEOLOCATION_PROVIDER: demo
|
||||||
|
|
||||||
|
# Security Configuration
|
||||||
|
APP_DEVICE_FINGERPRINT_ENABLED: true
|
||||||
|
APP_TWO_FACTOR_ENABLED: true
|
||||||
|
|
||||||
|
# Demo Configuration
|
||||||
|
APP_DEMO_MODE: true
|
||||||
|
APP_DEMO_SHOW_CODES: true
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- auth-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
# phpMyAdmin for database management (optional)
|
||||||
|
phpmyadmin:
|
||||||
|
image: phpmyadmin/phpmyadmin
|
||||||
|
container_name: auth-phpmyadmin
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "2081:80"
|
||||||
|
environment:
|
||||||
|
PMA_HOST: mysql
|
||||||
|
PMA_PORT: 3306
|
||||||
|
PMA_USER: auth_user
|
||||||
|
PMA_PASSWORD: auth_password123
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
networks:
|
||||||
|
- auth-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
auth-network:
|
||||||
|
driver: bridge
|
||||||
244
pom.xml
Normal file
244
pom.xml
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.2.0</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.company</groupId>
|
||||||
|
<artifactId>device-fingerprint-auth</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>Device Fingerprint Authentication API</name>
|
||||||
|
<description>Enterprise-grade Java Spring Boot API for device fingerprinting and authentication</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<jjwt.version>0.12.3</jjwt.version>
|
||||||
|
<springdoc.version>2.2.0</springdoc.version>
|
||||||
|
<bucket4j.version>7.6.0</bucket4j.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Starters -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT Support -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Database -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-mysql</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Password Hashing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-crypto</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Two-Factor Authentication -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>de.taimos</groupId>
|
||||||
|
<artifactId>totp</artifactId>
|
||||||
|
<version>1.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Encryption/Decryption - Using built-in Java crypto -->
|
||||||
|
<!-- Removed jasypt as we implement our own AES encryption utility -->
|
||||||
|
|
||||||
|
<!-- API Documentation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>${springdoc.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Rate Limiting -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.vladimir-bukhtoyarov</groupId>
|
||||||
|
<artifactId>bucket4j-core</artifactId>
|
||||||
|
<version>${bucket4j.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JSON Processing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||||
|
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Utilities -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-lang3</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.guava</groupId>
|
||||||
|
<artifactId>guava</artifactId>
|
||||||
|
<version>32.1.3-jre</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Testing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.security</groupId>
|
||||||
|
<artifactId>spring-security-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<source>17</source>
|
||||||
|
<target>17</target>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-maven-plugin</artifactId>
|
||||||
|
<version>9.22.3</version>
|
||||||
|
<configuration>
|
||||||
|
<url>jdbc:mysql://localhost:3306/device_auth_db</url>
|
||||||
|
<user>${DB_USERNAME}</user>
|
||||||
|
<password>${DB_PASSWORD}</password>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.1.2</version>
|
||||||
|
<configuration>
|
||||||
|
<includes>
|
||||||
|
<include>**/*Test.java</include>
|
||||||
|
<include>**/*Tests.java</include>
|
||||||
|
</includes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>dev</id>
|
||||||
|
<activation>
|
||||||
|
<activeByDefault>true</activeByDefault>
|
||||||
|
</activation>
|
||||||
|
<properties>
|
||||||
|
<spring.profiles.active>dev</spring.profiles.active>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
|
||||||
|
<profile>
|
||||||
|
<id>demo</id>
|
||||||
|
<properties>
|
||||||
|
<spring.profiles.active>demo</spring.profiles.active>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
|
||||||
|
<profile>
|
||||||
|
<id>prod</id>
|
||||||
|
<properties>
|
||||||
|
<spring.profiles.active>prod</spring.profiles.active>
|
||||||
|
</properties>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Device Fingerprint Authentication Application
|
||||||
|
*
|
||||||
|
* Main Spring Boot application class for the device fingerprinting and authentication system.
|
||||||
|
* This application provides enterprise-grade API endpoints for user authentication,
|
||||||
|
* device fingerprinting, and risk-based authentication with two-factor verification.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main application class for Device Fingerprint Authentication API
|
||||||
|
*
|
||||||
|
* This Spring Boot application provides:
|
||||||
|
* - User authentication and authorization with JWT
|
||||||
|
* - Device fingerprinting for risk-based authentication
|
||||||
|
* - Two-factor authentication for new device registration
|
||||||
|
* - Professional demo capabilities for senior developer presentations
|
||||||
|
* - Comprehensive security monitoring and audit logging
|
||||||
|
*
|
||||||
|
* Key Features:
|
||||||
|
* - RESTful API with OpenAPI documentation
|
||||||
|
* - MySQL database with Flyway migrations
|
||||||
|
* - Rate limiting and security controls
|
||||||
|
* - Environment-specific configurations (dev/demo/prod)
|
||||||
|
* - Professional logging and monitoring
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableTransactionManagement
|
||||||
|
@EnableAsync
|
||||||
|
@EnableScheduling
|
||||||
|
public class DeviceFingerprintAuthApplication {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application entry point
|
||||||
|
*
|
||||||
|
* Starts the Spring Boot application with auto-configuration for:
|
||||||
|
* - Spring Security with JWT authentication
|
||||||
|
* - JPA/Hibernate with MySQL database
|
||||||
|
* - Flyway database migrations
|
||||||
|
* - Swagger/OpenAPI documentation
|
||||||
|
* - Rate limiting and security features
|
||||||
|
*
|
||||||
|
* @param args Command line arguments
|
||||||
|
*/
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(DeviceFingerprintAuthApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/main/java/com/company/auth/config/OpenApiConfig.java
Normal file
143
src/main/java/com/company/auth/config/OpenApiConfig.java
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* OpenAPI Configuration
|
||||||
|
*
|
||||||
|
* Configuration for API documentation using OpenAPI 3.0/Swagger.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.info.Contact;
|
||||||
|
import io.swagger.v3.oas.models.info.License;
|
||||||
|
import io.swagger.v3.oas.models.servers.Server;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.models.Components;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI Configuration
|
||||||
|
*
|
||||||
|
* Configures API documentation with:
|
||||||
|
* - API information and metadata
|
||||||
|
* - Security schemes (JWT Bearer token)
|
||||||
|
* - Server configurations
|
||||||
|
* - Contact and license information
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class OpenApiConfig {
|
||||||
|
|
||||||
|
@Value("${app.version:1.0.0}")
|
||||||
|
private String appVersion;
|
||||||
|
|
||||||
|
@Value("${app.name:Device Fingerprint Authentication API}")
|
||||||
|
private String appName;
|
||||||
|
|
||||||
|
@Value("${app.description:Enterprise-grade authentication service with device fingerprinting and multi-factor authentication}")
|
||||||
|
private String appDescription;
|
||||||
|
|
||||||
|
@Value("${server.port:8080}")
|
||||||
|
private String serverPort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI configuration bean
|
||||||
|
*
|
||||||
|
* @return OpenAPI configuration
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public OpenAPI customOpenAPI() {
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(createApiInfo())
|
||||||
|
.servers(createServers())
|
||||||
|
.components(createComponents())
|
||||||
|
.addSecurityItem(createSecurityRequirement());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates API information
|
||||||
|
*
|
||||||
|
* @return API info
|
||||||
|
*/
|
||||||
|
private Info createApiInfo() {
|
||||||
|
return new Info()
|
||||||
|
.title(appName)
|
||||||
|
.description(appDescription)
|
||||||
|
.version(appVersion)
|
||||||
|
.contact(createContact())
|
||||||
|
.license(createLicense());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates contact information
|
||||||
|
*
|
||||||
|
* @return Contact info
|
||||||
|
*/
|
||||||
|
private Contact createContact() {
|
||||||
|
return new Contact()
|
||||||
|
.name("David Valera Melendez")
|
||||||
|
.email("david@valera-melendez.de")
|
||||||
|
.url("https://valera-melendez.de");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates license information
|
||||||
|
*
|
||||||
|
* @return License info
|
||||||
|
*/
|
||||||
|
private License createLicense() {
|
||||||
|
return new License()
|
||||||
|
.name("MIT License")
|
||||||
|
.url("https://opensource.org/licenses/MIT");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates server configurations
|
||||||
|
*
|
||||||
|
* @return List of servers
|
||||||
|
*/
|
||||||
|
private List<Server> createServers() {
|
||||||
|
Server developmentServer = new Server()
|
||||||
|
.url("http://localhost:" + serverPort)
|
||||||
|
.description("Development Server");
|
||||||
|
|
||||||
|
Server productionServer = new Server()
|
||||||
|
.url("https://api.company.com")
|
||||||
|
.description("Production Server");
|
||||||
|
|
||||||
|
return List.of(developmentServer, productionServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates security components
|
||||||
|
*
|
||||||
|
* @return Components with security schemes
|
||||||
|
*/
|
||||||
|
private Components createComponents() {
|
||||||
|
SecurityScheme bearerAuth = new SecurityScheme()
|
||||||
|
.type(SecurityScheme.Type.HTTP)
|
||||||
|
.scheme("bearer")
|
||||||
|
.bearerFormat("JWT")
|
||||||
|
.description("JWT Bearer token authentication. Format: Bearer {token}");
|
||||||
|
|
||||||
|
return new Components()
|
||||||
|
.addSecuritySchemes("bearerAuth", bearerAuth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates security requirement
|
||||||
|
*
|
||||||
|
* @return Security requirement for bearer auth
|
||||||
|
*/
|
||||||
|
private SecurityRequirement createSecurityRequirement() {
|
||||||
|
return new SecurityRequirement()
|
||||||
|
.addList("bearerAuth");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Password Encoder Configuration
|
||||||
|
*
|
||||||
|
* Separate configuration for password encoder to avoid circular dependencies.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password Encoder Configuration
|
||||||
|
*
|
||||||
|
* Provides password encoder bean separately to avoid circular dependencies
|
||||||
|
* in Spring Security configuration.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class PasswordEncoderConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password encoder bean
|
||||||
|
*
|
||||||
|
* @return BCrypt password encoder with strength 12
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder(12);
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/main/java/com/company/auth/config/SecurityConfig.java
Normal file
221
src/main/java/com/company/auth/config/SecurityConfig.java
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Security Configuration
|
||||||
|
*
|
||||||
|
* Spring Security configuration for JWT-based authentication.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.config;
|
||||||
|
|
||||||
|
import com.company.auth.security.JwtAuthenticationEntryPoint;
|
||||||
|
import com.company.auth.security.JwtAuthenticationFilter;
|
||||||
|
import com.company.auth.service.UserService;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.AuthenticationProvider;
|
||||||
|
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||||
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security Configuration
|
||||||
|
*
|
||||||
|
* Configures Spring Security for:
|
||||||
|
* - JWT-based authentication
|
||||||
|
* - Stateless session management
|
||||||
|
* - CORS configuration
|
||||||
|
* - Security filters and providers
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@EnableMethodSecurity(prePostEnabled = true)
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication provider bean
|
||||||
|
*
|
||||||
|
* @return DAO authentication provider
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public AuthenticationProvider authenticationProvider() {
|
||||||
|
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
|
||||||
|
authProvider.setUserDetailsService(userService);
|
||||||
|
authProvider.setPasswordEncoder(passwordEncoder);
|
||||||
|
return authProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication manager bean
|
||||||
|
*
|
||||||
|
* @param config Authentication configuration
|
||||||
|
* @return Authentication manager
|
||||||
|
* @throws Exception if configuration fails
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||||
|
return config.getAuthenticationManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS configuration source
|
||||||
|
*
|
||||||
|
* @return CORS configuration source
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
|
||||||
|
// Allow specific origins in production, * for development
|
||||||
|
configuration.setAllowedOriginPatterns(Arrays.asList(
|
||||||
|
"http://localhost:3000", // React development
|
||||||
|
"http://localhost:4200", // Angular development
|
||||||
|
"http://localhost:8080", // Local backend
|
||||||
|
"https://*.company.com", // Production domains
|
||||||
|
"https://company.com"
|
||||||
|
));
|
||||||
|
|
||||||
|
configuration.setAllowedMethods(Arrays.asList(
|
||||||
|
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"
|
||||||
|
));
|
||||||
|
|
||||||
|
configuration.setAllowedHeaders(Arrays.asList(
|
||||||
|
"Authorization",
|
||||||
|
"Content-Type",
|
||||||
|
"X-Requested-With",
|
||||||
|
"Accept",
|
||||||
|
"Origin",
|
||||||
|
"Access-Control-Request-Method",
|
||||||
|
"Access-Control-Request-Headers",
|
||||||
|
// Custom headers for device fingerprinting
|
||||||
|
"User-Agent",
|
||||||
|
"Accept-Language",
|
||||||
|
"Timezone",
|
||||||
|
"Screen-Resolution",
|
||||||
|
"Color-Depth",
|
||||||
|
"Hardware-Concurrency",
|
||||||
|
"Device-Memory",
|
||||||
|
"Platform",
|
||||||
|
"Cookie-Enabled",
|
||||||
|
"DNT",
|
||||||
|
"Webdriver",
|
||||||
|
"Device-Fingerprint"
|
||||||
|
));
|
||||||
|
|
||||||
|
configuration.setExposedHeaders(Arrays.asList(
|
||||||
|
"Authorization",
|
||||||
|
"Content-Disposition"
|
||||||
|
));
|
||||||
|
|
||||||
|
configuration.setAllowCredentials(true);
|
||||||
|
configuration.setMaxAge(3600L); // 1 hour preflight cache
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security filter chain configuration
|
||||||
|
*
|
||||||
|
* @param http HTTP security configuration
|
||||||
|
* @return Security filter chain
|
||||||
|
* @throws Exception if configuration fails
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
// Disable CSRF for stateless REST APIs
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
|
||||||
|
// Configure CORS
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
|
||||||
|
// Configure authorization rules
|
||||||
|
.authorizeHttpRequests(authz -> authz
|
||||||
|
// Public endpoints
|
||||||
|
.requestMatchers(
|
||||||
|
"/api/auth/register",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/auth/verify-device",
|
||||||
|
"/api/auth/verify-2fa",
|
||||||
|
"/api/auth/refresh",
|
||||||
|
"/api/auth/resend-device-verification",
|
||||||
|
"/api/auth/resend-2fa"
|
||||||
|
).permitAll()
|
||||||
|
|
||||||
|
// Health check and monitoring endpoints
|
||||||
|
.requestMatchers(
|
||||||
|
"/actuator/health",
|
||||||
|
"/actuator/health/**",
|
||||||
|
"/actuator/info"
|
||||||
|
).permitAll()
|
||||||
|
|
||||||
|
// API documentation endpoints
|
||||||
|
.requestMatchers(
|
||||||
|
"/v3/api-docs/**",
|
||||||
|
"/swagger-ui/**",
|
||||||
|
"/swagger-ui.html",
|
||||||
|
"/swagger-resources/**",
|
||||||
|
"/webjars/**"
|
||||||
|
).permitAll()
|
||||||
|
|
||||||
|
// Static resources
|
||||||
|
.requestMatchers(
|
||||||
|
"/favicon.ico",
|
||||||
|
"/error"
|
||||||
|
).permitAll()
|
||||||
|
|
||||||
|
// All other endpoints require authentication
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure exception handling
|
||||||
|
.exceptionHandling(ex -> ex
|
||||||
|
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure session management (stateless)
|
||||||
|
.sessionManagement(session -> session
|
||||||
|
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configure authentication provider
|
||||||
|
.authenticationProvider(authenticationProvider())
|
||||||
|
|
||||||
|
// Add JWT authentication filter
|
||||||
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
443
src/main/java/com/company/auth/controller/AuthController.java
Normal file
443
src/main/java/com/company/auth/controller/AuthController.java
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Controller
|
||||||
|
*
|
||||||
|
* REST controller for authentication operations including login, registration,
|
||||||
|
* device verification, and two-factor authentication.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.controller;
|
||||||
|
|
||||||
|
import com.company.auth.dto.request.*;
|
||||||
|
import com.company.auth.dto.response.*;
|
||||||
|
import com.company.auth.service.AuthService;
|
||||||
|
import com.company.auth.service.DeviceFingerprintService;
|
||||||
|
import com.company.auth.service.TwoFactorService;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication Controller
|
||||||
|
*
|
||||||
|
* Handles all authentication-related HTTP requests including:
|
||||||
|
* - User registration and login
|
||||||
|
* - Device fingerprinting and verification
|
||||||
|
* - Two-factor authentication workflows
|
||||||
|
* - Token refresh and logout operations
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@Tag(name = "Authentication", description = "Authentication and authorization operations")
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AuthService authService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DeviceFingerprintService deviceFingerprintService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TwoFactorService twoFactorService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User registration endpoint
|
||||||
|
*
|
||||||
|
* @param request Registration request
|
||||||
|
* @param httpRequest HTTP request for IP extraction
|
||||||
|
* @return Authentication response
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
@Operation(summary = "Register new user", description = "Creates a new user account")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "201", description = "User registered successfully"),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid registration data"),
|
||||||
|
@ApiResponse(responseCode = "409", description = "User already exists")
|
||||||
|
})
|
||||||
|
public ResponseEntity<AuthResponse> register(
|
||||||
|
@Valid @RequestBody RegisterRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
logger.info("Registration attempt for email: {}", request.getEmail());
|
||||||
|
|
||||||
|
try {
|
||||||
|
String ipAddress = getClientIpAddress(httpRequest);
|
||||||
|
String userAgent = httpRequest.getHeader("User-Agent");
|
||||||
|
|
||||||
|
AuthResponse response = authService.register(request, ipAddress, userAgent);
|
||||||
|
|
||||||
|
logger.info("User registered successfully: {}", request.getEmail());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Registration failed for email: {}", request.getEmail(), e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User login endpoint
|
||||||
|
*
|
||||||
|
* @param request Login request
|
||||||
|
* @param httpRequest HTTP request for device fingerprinting
|
||||||
|
* @return Authentication response or device verification required
|
||||||
|
*/
|
||||||
|
@PostMapping("/login")
|
||||||
|
@Operation(summary = "User login", description = "Authenticates user and returns JWT tokens")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Login successful"),
|
||||||
|
@ApiResponse(responseCode = "202", description = "Device verification required"),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid credentials"),
|
||||||
|
@ApiResponse(responseCode = "401", description = "Authentication failed"),
|
||||||
|
@ApiResponse(responseCode = "423", description = "Account locked")
|
||||||
|
})
|
||||||
|
public ResponseEntity<AuthResponse> login(
|
||||||
|
@Valid @RequestBody LoginRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
logger.info("Login attempt for email: {}", request.getEmail());
|
||||||
|
|
||||||
|
try {
|
||||||
|
String ipAddress = getClientIpAddress(httpRequest);
|
||||||
|
String userAgent = httpRequest.getHeader("User-Agent");
|
||||||
|
|
||||||
|
// Create device fingerprint
|
||||||
|
DeviceFingerprintRequest fingerprintRequest = createDeviceFingerprintRequest(httpRequest);
|
||||||
|
request.setFingerprint(fingerprintRequest);
|
||||||
|
|
||||||
|
AuthResponse response = authService.login(request, ipAddress, userAgent);
|
||||||
|
|
||||||
|
// Return appropriate status based on response
|
||||||
|
HttpStatus status = response.getRequiresDeviceVerification() ?
|
||||||
|
HttpStatus.ACCEPTED : HttpStatus.OK;
|
||||||
|
|
||||||
|
logger.info("Login processed for email: {}, status: {}", request.getEmail(), status);
|
||||||
|
return ResponseEntity.status(status).body(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Login failed for email: {}", request.getEmail(), e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device verification endpoint
|
||||||
|
*
|
||||||
|
* @param request Device verification request
|
||||||
|
* @param httpRequest HTTP request for context
|
||||||
|
* @return Authentication response
|
||||||
|
*/
|
||||||
|
@PostMapping("/verify-device")
|
||||||
|
@Operation(summary = "Verify device", description = "Verifies a device using verification code")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Device verified successfully"),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid verification code"),
|
||||||
|
@ApiResponse(responseCode = "401", description = "Session expired")
|
||||||
|
})
|
||||||
|
public ResponseEntity<AuthResponse> verifyDevice(
|
||||||
|
@Valid @RequestBody DeviceVerificationRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
logger.info("Device verification attempt for session: {}", request.getSessionToken());
|
||||||
|
|
||||||
|
try {
|
||||||
|
String ipAddress = getClientIpAddress(httpRequest);
|
||||||
|
String userAgent = httpRequest.getHeader("User-Agent");
|
||||||
|
|
||||||
|
AuthResponse response = authService.verifyDevice(request, ipAddress, userAgent);
|
||||||
|
|
||||||
|
logger.info("Device verification completed for session: {}", request.getSessionToken());
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Device verification failed for session: {}", request.getSessionToken(), e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication verification endpoint
|
||||||
|
*
|
||||||
|
* @param request 2FA verification request
|
||||||
|
* @param httpRequest HTTP request for context
|
||||||
|
* @return Authentication response
|
||||||
|
*/
|
||||||
|
@PostMapping("/verify-2fa")
|
||||||
|
@Operation(summary = "Verify 2FA", description = "Verifies two-factor authentication code")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "2FA verified successfully"),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid 2FA code"),
|
||||||
|
@ApiResponse(responseCode = "401", description = "Session expired")
|
||||||
|
})
|
||||||
|
public ResponseEntity<AuthResponse> verifyTwoFactor(
|
||||||
|
@Valid @RequestBody TwoFactorAuthRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
logger.info("2FA verification attempt for session: {}", request.getSessionToken());
|
||||||
|
|
||||||
|
try {
|
||||||
|
String ipAddress = getClientIpAddress(httpRequest);
|
||||||
|
String userAgent = httpRequest.getHeader("User-Agent");
|
||||||
|
|
||||||
|
AuthResponse response = authService.verifyTwoFactor(request, ipAddress, userAgent);
|
||||||
|
|
||||||
|
logger.info("2FA verification completed for session: {}", request.getSessionToken());
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("2FA verification failed for session: {}", request.getSessionToken(), e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token refresh endpoint
|
||||||
|
*
|
||||||
|
* @param refreshToken Refresh token
|
||||||
|
* @param httpRequest HTTP request for context
|
||||||
|
* @return New access token
|
||||||
|
*/
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
@Operation(summary = "Refresh token", description = "Refreshes access token using refresh token")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Token refreshed successfully"),
|
||||||
|
@ApiResponse(responseCode = "401", description = "Invalid refresh token")
|
||||||
|
})
|
||||||
|
public ResponseEntity<AuthResponse> refreshToken(
|
||||||
|
@RequestParam("refresh_token") String refreshToken,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
logger.info("Token refresh attempt");
|
||||||
|
|
||||||
|
try {
|
||||||
|
String ipAddress = getClientIpAddress(httpRequest);
|
||||||
|
|
||||||
|
AuthResponse response = authService.refreshToken(refreshToken, ipAddress);
|
||||||
|
|
||||||
|
logger.info("Token refreshed successfully");
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Token refresh failed", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout endpoint
|
||||||
|
*
|
||||||
|
* @param authorization Authorization header with Bearer token
|
||||||
|
* @param httpRequest HTTP request for context
|
||||||
|
* @return Success response
|
||||||
|
*/
|
||||||
|
@PostMapping("/logout")
|
||||||
|
@Operation(summary = "User logout", description = "Logs out user and invalidates tokens")
|
||||||
|
@SecurityRequirement(name = "bearerAuth")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Logout successful"),
|
||||||
|
@ApiResponse(responseCode = "401", description = "Invalid token")
|
||||||
|
})
|
||||||
|
public ResponseEntity<String> logout(
|
||||||
|
@RequestHeader("Authorization") String authorization,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
logger.info("Logout attempt");
|
||||||
|
|
||||||
|
try {
|
||||||
|
String token = extractTokenFromHeader(authorization);
|
||||||
|
String ipAddress = getClientIpAddress(httpRequest);
|
||||||
|
|
||||||
|
authService.logout(token, null, ipAddress);
|
||||||
|
|
||||||
|
logger.info("Logout successful");
|
||||||
|
return ResponseEntity.ok("Logout successful");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Logout failed", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout from all devices endpoint
|
||||||
|
*
|
||||||
|
* @param authorization Authorization header with Bearer token
|
||||||
|
* @param httpRequest HTTP request for context
|
||||||
|
* @return Success response
|
||||||
|
*/
|
||||||
|
@PostMapping("/logout-all")
|
||||||
|
@Operation(summary = "Logout from all devices", description = "Logs out user from all devices")
|
||||||
|
@SecurityRequirement(name = "bearerAuth")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Logout from all devices successful"),
|
||||||
|
@ApiResponse(responseCode = "401", description = "Invalid token")
|
||||||
|
})
|
||||||
|
public ResponseEntity<String> logoutFromAllDevices(
|
||||||
|
@RequestHeader("Authorization") String authorization,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
logger.info("Logout from all devices attempt");
|
||||||
|
|
||||||
|
try {
|
||||||
|
String token = extractTokenFromHeader(authorization);
|
||||||
|
String ipAddress = getClientIpAddress(httpRequest);
|
||||||
|
|
||||||
|
authService.logoutFromAllDevices(token, ipAddress);
|
||||||
|
|
||||||
|
logger.info("Logout from all devices successful");
|
||||||
|
return ResponseEntity.ok("Logout from all devices successful");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Logout from all devices failed", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend device verification code endpoint
|
||||||
|
*
|
||||||
|
* @param sessionToken Session token for device verification
|
||||||
|
* @param httpRequest HTTP request for context
|
||||||
|
* @return Success response
|
||||||
|
*/
|
||||||
|
@PostMapping("/resend-device-verification")
|
||||||
|
@Operation(summary = "Resend device verification", description = "Resends device verification code")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "Verification code sent"),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid session"),
|
||||||
|
@ApiResponse(responseCode = "429", description = "Too many requests")
|
||||||
|
})
|
||||||
|
public ResponseEntity<String> resendDeviceVerification(
|
||||||
|
@RequestParam("session_token") String sessionToken,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
logger.info("Resend device verification for session: {}", sessionToken);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String ipAddress = getClientIpAddress(httpRequest);
|
||||||
|
|
||||||
|
authService.resendDeviceVerification(sessionToken, ipAddress);
|
||||||
|
|
||||||
|
logger.info("Device verification code resent for session: {}", sessionToken);
|
||||||
|
return ResponseEntity.ok("Verification code sent");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to resend device verification for session: {}", sessionToken, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend 2FA code endpoint
|
||||||
|
*
|
||||||
|
* @param sessionToken Session token for 2FA
|
||||||
|
* @param httpRequest HTTP request for context
|
||||||
|
* @return Success response
|
||||||
|
*/
|
||||||
|
@PostMapping("/resend-2fa")
|
||||||
|
@Operation(summary = "Resend 2FA code", description = "Resends two-factor authentication code")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "2FA code sent"),
|
||||||
|
@ApiResponse(responseCode = "400", description = "Invalid session"),
|
||||||
|
@ApiResponse(responseCode = "429", description = "Too many requests")
|
||||||
|
})
|
||||||
|
public ResponseEntity<String> resendTwoFactorCode(
|
||||||
|
@RequestParam("session_token") String sessionToken,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
|
logger.info("Resend 2FA code for session: {}", sessionToken);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String ipAddress = getClientIpAddress(httpRequest);
|
||||||
|
|
||||||
|
authService.resendTwoFactorCode(sessionToken, ipAddress);
|
||||||
|
|
||||||
|
logger.info("2FA code resent for session: {}", sessionToken);
|
||||||
|
return ResponseEntity.ok("2FA code sent");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to resend 2FA code for session: {}", sessionToken, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private String getClientIpAddress(HttpServletRequest request) {
|
||||||
|
String xForwardedFor = request.getHeader("X-Forwarded-For");
|
||||||
|
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
|
||||||
|
return xForwardedFor.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String xRealIp = request.getHeader("X-Real-IP");
|
||||||
|
if (xRealIp != null && !xRealIp.isEmpty()) {
|
||||||
|
return xRealIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractTokenFromHeader(String authorization) {
|
||||||
|
if (authorization != null && authorization.startsWith("Bearer ")) {
|
||||||
|
return authorization.substring(7);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Invalid authorization header");
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeviceFingerprintRequest createDeviceFingerprintRequest(HttpServletRequest request) {
|
||||||
|
DeviceFingerprintRequest fingerprintRequest = new DeviceFingerprintRequest();
|
||||||
|
|
||||||
|
// Extract device information from headers and request
|
||||||
|
fingerprintRequest.setUserAgent(request.getHeader("User-Agent"));
|
||||||
|
fingerprintRequest.setAcceptLanguage(request.getHeader("Accept-Language"));
|
||||||
|
fingerprintRequest.setTimezone(request.getHeader("Timezone"));
|
||||||
|
fingerprintRequest.setScreenResolution(request.getHeader("Screen-Resolution"));
|
||||||
|
fingerprintRequest.setColorDepth(parseIntegerHeader(request.getHeader("Color-Depth")));
|
||||||
|
fingerprintRequest.setHardwareConcurrency(parseIntegerHeader(request.getHeader("Hardware-Concurrency")));
|
||||||
|
fingerprintRequest.setDeviceMemory(parseIntegerHeader(request.getHeader("Device-Memory")));
|
||||||
|
fingerprintRequest.setPlatform(request.getHeader("Platform"));
|
||||||
|
fingerprintRequest.setCookieEnabled(Boolean.parseBoolean(request.getHeader("Cookie-Enabled")));
|
||||||
|
fingerprintRequest.setDoNotTrack(request.getHeader("DNT"));
|
||||||
|
fingerprintRequest.setWebdriver(Boolean.parseBoolean(request.getHeader("Webdriver")));
|
||||||
|
|
||||||
|
// Additional fingerprinting data would be sent from frontend JavaScript
|
||||||
|
String fingerprintData = request.getHeader("Device-Fingerprint");
|
||||||
|
if (fingerprintData != null) {
|
||||||
|
fingerprintRequest.setAdditionalData(fingerprintData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fingerprintRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse integer header value safely
|
||||||
|
*/
|
||||||
|
private Integer parseIntegerHeader(String headerValue) {
|
||||||
|
if (headerValue == null || headerValue.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(headerValue.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
logger.warn("Failed to parse integer header: {}", headerValue);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Change Password Request
|
||||||
|
*
|
||||||
|
* Request DTO for changing user password.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change Password Request DTO
|
||||||
|
*/
|
||||||
|
@Schema(description = "Password change request")
|
||||||
|
public class ChangePasswordRequest {
|
||||||
|
|
||||||
|
@Schema(description = "Current password", example = "currentPassword123!")
|
||||||
|
@NotBlank(message = "Current password is required")
|
||||||
|
private String currentPassword;
|
||||||
|
|
||||||
|
@Schema(description = "New password", example = "newPassword123!")
|
||||||
|
@NotBlank(message = "New password is required")
|
||||||
|
@Size(min = 8, max = 128, message = "Password must be between 8 and 128 characters")
|
||||||
|
@Pattern(
|
||||||
|
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&].*$",
|
||||||
|
message = "Password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character"
|
||||||
|
)
|
||||||
|
private String newPassword;
|
||||||
|
|
||||||
|
@Schema(description = "Confirm new password", example = "newPassword123!")
|
||||||
|
@NotBlank(message = "Password confirmation is required")
|
||||||
|
private String confirmPassword;
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public ChangePasswordRequest() {}
|
||||||
|
|
||||||
|
public ChangePasswordRequest(String currentPassword, String newPassword, String confirmPassword) {
|
||||||
|
this.currentPassword = currentPassword;
|
||||||
|
this.newPassword = newPassword;
|
||||||
|
this.confirmPassword = confirmPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getCurrentPassword() {
|
||||||
|
return currentPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCurrentPassword(String currentPassword) {
|
||||||
|
this.currentPassword = currentPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNewPassword() {
|
||||||
|
return newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNewPassword(String newPassword) {
|
||||||
|
this.newPassword = newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getConfirmPassword() {
|
||||||
|
return confirmPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConfirmPassword(String confirmPassword) {
|
||||||
|
this.confirmPassword = confirmPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that new password and confirmation match
|
||||||
|
*
|
||||||
|
* @return true if passwords match
|
||||||
|
*/
|
||||||
|
public boolean isPasswordConfirmationValid() {
|
||||||
|
return newPassword != null && newPassword.equals(confirmPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "ChangePasswordRequest{" +
|
||||||
|
"currentPassword='[PROTECTED]'" +
|
||||||
|
", newPassword='[PROTECTED]'" +
|
||||||
|
", confirmPassword='[PROTECTED]'" +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Confirm TOTP Setup Request
|
||||||
|
*
|
||||||
|
* Request DTO for confirming TOTP setup.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm TOTP Setup Request DTO
|
||||||
|
*/
|
||||||
|
@Schema(description = "TOTP setup confirmation request")
|
||||||
|
public class ConfirmTotpSetupRequest {
|
||||||
|
|
||||||
|
@Schema(description = "TOTP code from authenticator app", example = "123456")
|
||||||
|
@NotBlank(message = "TOTP code is required")
|
||||||
|
@Pattern(regexp = "^\\d{6}$", message = "TOTP code must be 6 digits")
|
||||||
|
private String totpCode;
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public ConfirmTotpSetupRequest() {}
|
||||||
|
|
||||||
|
public ConfirmTotpSetupRequest(String totpCode) {
|
||||||
|
this.totpCode = totpCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getTotpCode() {
|
||||||
|
return totpCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotpCode(String totpCode) {
|
||||||
|
this.totpCode = totpCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "ConfirmTotpSetupRequest{" +
|
||||||
|
"totpCode='[PROTECTED]'" +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Delete Account Request
|
||||||
|
*
|
||||||
|
* Request DTO for deleting user account.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Account Request DTO
|
||||||
|
*/
|
||||||
|
@Schema(description = "Account deletion request")
|
||||||
|
public class DeleteAccountRequest {
|
||||||
|
|
||||||
|
@Schema(description = "Current password for verification", example = "currentPassword123!")
|
||||||
|
@NotBlank(message = "Password is required")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Schema(description = "Confirmation phrase", example = "DELETE MY ACCOUNT")
|
||||||
|
@NotBlank(message = "Confirmation phrase is required")
|
||||||
|
@Pattern(regexp = "^DELETE MY ACCOUNT$", message = "Please type 'DELETE MY ACCOUNT' to confirm")
|
||||||
|
private String confirmationPhrase;
|
||||||
|
|
||||||
|
@Schema(description = "TOTP code for two-factor verification (if enabled)", example = "123456")
|
||||||
|
@Pattern(regexp = "^\\d{6}$", message = "TOTP code must be 6 digits")
|
||||||
|
private String totpCode;
|
||||||
|
|
||||||
|
@Schema(description = "Backup code for two-factor verification (if enabled)", example = "ABCD1234")
|
||||||
|
@Pattern(regexp = "^[A-Z0-9]{8}$", message = "Backup code must be 8 alphanumeric characters")
|
||||||
|
private String backupCode;
|
||||||
|
|
||||||
|
@Schema(description = "Reason for account deletion (optional)", example = "No longer need the service")
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public DeleteAccountRequest() {}
|
||||||
|
|
||||||
|
public DeleteAccountRequest(String password, String confirmationPhrase) {
|
||||||
|
this.password = password;
|
||||||
|
this.confirmationPhrase = confirmationPhrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPassword(String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getConfirmationPhrase() {
|
||||||
|
return confirmationPhrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConfirmationPhrase(String confirmationPhrase) {
|
||||||
|
this.confirmationPhrase = confirmationPhrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTotpCode() {
|
||||||
|
return totpCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotpCode(String totpCode) {
|
||||||
|
this.totpCode = totpCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBackupCode() {
|
||||||
|
return backupCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBackupCode(String backupCode) {
|
||||||
|
this.backupCode = backupCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReason(String reason) {
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if two-factor verification is provided
|
||||||
|
*
|
||||||
|
* @return true if TOTP code or backup code is provided
|
||||||
|
*/
|
||||||
|
public boolean hasTwoFactorVerification() {
|
||||||
|
return (totpCode != null && !totpCode.trim().isEmpty()) ||
|
||||||
|
(backupCode != null && !backupCode.trim().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the two-factor verification code
|
||||||
|
*
|
||||||
|
* @return TOTP code or backup code
|
||||||
|
*/
|
||||||
|
public String getTwoFactorCode() {
|
||||||
|
if (totpCode != null && !totpCode.trim().isEmpty()) {
|
||||||
|
return totpCode;
|
||||||
|
}
|
||||||
|
return backupCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the type of two-factor verification
|
||||||
|
*
|
||||||
|
* @return "TOTP" or "BACKUP"
|
||||||
|
*/
|
||||||
|
public String getTwoFactorType() {
|
||||||
|
if (totpCode != null && !totpCode.trim().isEmpty()) {
|
||||||
|
return "TOTP";
|
||||||
|
} else if (backupCode != null && !backupCode.trim().isEmpty()) {
|
||||||
|
return "BACKUP";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "DeleteAccountRequest{" +
|
||||||
|
"password='[PROTECTED]'" +
|
||||||
|
", confirmationPhrase='" + confirmationPhrase + '\'' +
|
||||||
|
", totpCode='[PROTECTED]'" +
|
||||||
|
", backupCode='[PROTECTED]'" +
|
||||||
|
", reason='" + reason + '\'' +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
/**
|
||||||
|
* Device Fingerprint Request DTO
|
||||||
|
*
|
||||||
|
* Data transfer object for browser fingerprint data collection.
|
||||||
|
* Contains browser characteristics used for device identification
|
||||||
|
* and risk-based authentication.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for browser fingerprint data
|
||||||
|
*
|
||||||
|
* Captures browser characteristics that can be collected from
|
||||||
|
* client-side JavaScript and HTTP headers to create a unique
|
||||||
|
* device identifier for security purposes.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Browser fingerprint data for device identification")
|
||||||
|
public class DeviceFingerprintRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Agent string from HTTP headers
|
||||||
|
*
|
||||||
|
* Browser and operating system information that helps
|
||||||
|
* identify the client environment.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Browser User Agent string",
|
||||||
|
example = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
|
||||||
|
@NotBlank(message = "User Agent is required")
|
||||||
|
@Size(min = 10, max = 1000, message = "User Agent must be between 10 and 1000 characters")
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept-Language header value
|
||||||
|
*
|
||||||
|
* Browser language preferences that contribute to
|
||||||
|
* device uniqueness and user locale identification.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Browser language preferences",
|
||||||
|
example = "en-US,en;q=0.9,es;q=0.8")
|
||||||
|
@NotBlank(message = "Accept Language is required")
|
||||||
|
@Size(max = 200, message = "Accept Language must not exceed 200 characters")
|
||||||
|
private String acceptLanguage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen resolution
|
||||||
|
*
|
||||||
|
* Display dimensions that help identify the device type
|
||||||
|
* and contribute to fingerprint uniqueness.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Screen resolution",
|
||||||
|
example = "1920x1080")
|
||||||
|
@NotBlank(message = "Screen resolution is required")
|
||||||
|
@Size(max = 20, message = "Screen resolution must not exceed 20 characters")
|
||||||
|
private String screenResolution;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timezone information
|
||||||
|
*
|
||||||
|
* User's timezone setting that provides geographic
|
||||||
|
* and device configuration context.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User timezone",
|
||||||
|
example = "America/New_York")
|
||||||
|
@NotBlank(message = "Timezone is required")
|
||||||
|
@Size(max = 50, message = "Timezone must not exceed 50 characters")
|
||||||
|
private String timezone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available fonts hash
|
||||||
|
*
|
||||||
|
* Optional hash of available system fonts that provides
|
||||||
|
* additional uniqueness for device identification.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Hash of available fonts")
|
||||||
|
@Size(max = 255, message = "Fonts hash must not exceed 255 characters")
|
||||||
|
private String fontsHash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canvas fingerprint
|
||||||
|
*
|
||||||
|
* Optional HTML5 canvas rendering fingerprint that
|
||||||
|
* provides hardware and driver-specific identification.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Canvas rendering fingerprint")
|
||||||
|
@Size(max = 255, message = "Canvas fingerprint must not exceed 255 characters")
|
||||||
|
private String canvasFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebGL renderer information
|
||||||
|
*
|
||||||
|
* Optional graphics renderer details that provide
|
||||||
|
* hardware-specific device identification.
|
||||||
|
*/
|
||||||
|
@Schema(description = "WebGL renderer information")
|
||||||
|
@Size(max = 255, message = "WebGL renderer must not exceed 255 characters")
|
||||||
|
private String webglRenderer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser plugins hash
|
||||||
|
*
|
||||||
|
* Optional hash of installed browser plugins that
|
||||||
|
* contributes to device uniqueness.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Hash of browser plugins")
|
||||||
|
@Size(max = 255, message = "Plugins hash must not exceed 255 characters")
|
||||||
|
private String pluginsHash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Touch support capability
|
||||||
|
*
|
||||||
|
* Indicates whether the device supports touch input,
|
||||||
|
* helping distinguish between desktop and mobile devices.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether device supports touch input",
|
||||||
|
example = "false")
|
||||||
|
private Boolean touchSupport = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color depth of the display
|
||||||
|
*
|
||||||
|
* Display color depth that contributes to device
|
||||||
|
* hardware identification.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Display color depth",
|
||||||
|
example = "24")
|
||||||
|
private Integer colorDepth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform/OS information
|
||||||
|
*
|
||||||
|
* Operating system platform information for
|
||||||
|
* device type identification.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Platform/OS information",
|
||||||
|
example = "MacIntel")
|
||||||
|
@NotBlank(message = "Platform is required")
|
||||||
|
@Size(max = 50, message = "Platform must not exceed 50 characters")
|
||||||
|
private String platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional device name provided by user
|
||||||
|
*
|
||||||
|
* Human-readable device name for easier device management.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User-provided device name",
|
||||||
|
example = "John's MacBook Pro")
|
||||||
|
@Size(max = 100, message = "Device name must not exceed 100 characters")
|
||||||
|
private String deviceName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hardware concurrency (CPU cores)
|
||||||
|
*
|
||||||
|
* Number of logical CPU cores available to the browser.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Number of logical CPU cores",
|
||||||
|
example = "8")
|
||||||
|
private Integer hardwareConcurrency;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device memory in GB
|
||||||
|
*
|
||||||
|
* Approximate amount of device memory available to the browser.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Device memory in GB",
|
||||||
|
example = "8")
|
||||||
|
private Integer deviceMemory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cookie enabled status
|
||||||
|
*
|
||||||
|
* Whether cookies are enabled in the browser.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether cookies are enabled",
|
||||||
|
example = "true")
|
||||||
|
private Boolean cookieEnabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do Not Track setting
|
||||||
|
*
|
||||||
|
* Browser's Do Not Track setting value.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Do Not Track setting",
|
||||||
|
example = "1")
|
||||||
|
private String doNotTrack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webdriver detection
|
||||||
|
*
|
||||||
|
* Whether a webdriver (automation tool) is detected.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether webdriver is detected",
|
||||||
|
example = "false")
|
||||||
|
private Boolean webdriver = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional fingerprint data
|
||||||
|
*
|
||||||
|
* Any additional fingerprinting data collected from JavaScript.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Additional fingerprint data",
|
||||||
|
example = "{\"battery\": 0.85}")
|
||||||
|
@Size(max = 2000, message = "Additional data must not exceed 2000 characters")
|
||||||
|
private String additionalData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor
|
||||||
|
*/
|
||||||
|
public DeviceFingerprintRequest() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor with required fields
|
||||||
|
*
|
||||||
|
* @param userAgent Browser user agent
|
||||||
|
* @param acceptLanguage Language preferences
|
||||||
|
* @param screenResolution Screen dimensions
|
||||||
|
* @param timezone User timezone
|
||||||
|
* @param platform OS platform
|
||||||
|
*/
|
||||||
|
public DeviceFingerprintRequest(String userAgent, String acceptLanguage, String screenResolution,
|
||||||
|
String timezone, String platform) {
|
||||||
|
this.userAgent = userAgent;
|
||||||
|
this.acceptLanguage = acceptLanguage;
|
||||||
|
this.screenResolution = screenResolution;
|
||||||
|
this.timezone = timezone;
|
||||||
|
this.platform = platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
public String getUserAgent() {
|
||||||
|
return userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserAgent(String userAgent) {
|
||||||
|
this.userAgent = userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAcceptLanguage() {
|
||||||
|
return acceptLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAcceptLanguage(String acceptLanguage) {
|
||||||
|
this.acceptLanguage = acceptLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getScreenResolution() {
|
||||||
|
return screenResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setScreenResolution(String screenResolution) {
|
||||||
|
this.screenResolution = screenResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTimezone() {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimezone(String timezone) {
|
||||||
|
this.timezone = timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFontsHash() {
|
||||||
|
return fontsHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFontsHash(String fontsHash) {
|
||||||
|
this.fontsHash = fontsHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCanvasFingerprint() {
|
||||||
|
return canvasFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCanvasFingerprint(String canvasFingerprint) {
|
||||||
|
this.canvasFingerprint = canvasFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getWebglRenderer() {
|
||||||
|
return webglRenderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWebglRenderer(String webglRenderer) {
|
||||||
|
this.webglRenderer = webglRenderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPluginsHash() {
|
||||||
|
return pluginsHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPluginsHash(String pluginsHash) {
|
||||||
|
this.pluginsHash = pluginsHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getTouchSupport() {
|
||||||
|
return touchSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTouchSupport(Boolean touchSupport) {
|
||||||
|
this.touchSupport = touchSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getColorDepth() {
|
||||||
|
return colorDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorDepth(Integer colorDepth) {
|
||||||
|
this.colorDepth = colorDepth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPlatform() {
|
||||||
|
return platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPlatform(String platform) {
|
||||||
|
this.platform = platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceName() {
|
||||||
|
return deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceName(String deviceName) {
|
||||||
|
this.deviceName = deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getHardwareConcurrency() {
|
||||||
|
return hardwareConcurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHardwareConcurrency(Integer hardwareConcurrency) {
|
||||||
|
this.hardwareConcurrency = hardwareConcurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getDeviceMemory() {
|
||||||
|
return deviceMemory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceMemory(Integer deviceMemory) {
|
||||||
|
this.deviceMemory = deviceMemory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getCookieEnabled() {
|
||||||
|
return cookieEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCookieEnabled(Boolean cookieEnabled) {
|
||||||
|
this.cookieEnabled = cookieEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDoNotTrack() {
|
||||||
|
return doNotTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDoNotTrack(String doNotTrack) {
|
||||||
|
this.doNotTrack = doNotTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getWebdriver() {
|
||||||
|
return webdriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWebdriver(Boolean webdriver) {
|
||||||
|
this.webdriver = webdriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAdditionalData() {
|
||||||
|
return additionalData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAdditionalData(String additionalData) {
|
||||||
|
this.additionalData = additionalData;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "DeviceFingerprintRequest{" +
|
||||||
|
"platform='" + platform + '\'' +
|
||||||
|
", screenResolution='" + screenResolution + '\'' +
|
||||||
|
", timezone='" + timezone + '\'' +
|
||||||
|
", touchSupport=" + touchSupport +
|
||||||
|
", colorDepth=" + colorDepth +
|
||||||
|
", deviceName='" + deviceName + '\'' +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Device Verification Request DTO
|
||||||
|
*
|
||||||
|
* Data transfer object for device verification requests.
|
||||||
|
* Used when users need to verify a new or suspicious device
|
||||||
|
* through email/SMS verification codes.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for device verification
|
||||||
|
*
|
||||||
|
* Contains verification code and device information for
|
||||||
|
* confirming device ownership and establishing trust.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Device verification request data")
|
||||||
|
public class DeviceVerificationRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session token for verification
|
||||||
|
*
|
||||||
|
* Session token that was provided during login for device verification.
|
||||||
|
* Used to validate the verification request.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Session token for verification")
|
||||||
|
@NotBlank(message = "Session token is required")
|
||||||
|
private String sessionToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verification code
|
||||||
|
*
|
||||||
|
* 6-digit numeric code sent via email or SMS
|
||||||
|
* for device verification purposes.
|
||||||
|
*/
|
||||||
|
@Schema(description = "6-digit verification code",
|
||||||
|
example = "123456")
|
||||||
|
@NotBlank(message = "Verification code is required")
|
||||||
|
@Pattern(regexp = "^\\d{6}$", message = "Verification code must be exactly 6 digits")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device identifier token
|
||||||
|
*
|
||||||
|
* Unique token that identifies the device being verified.
|
||||||
|
* Generated during the initial device detection process.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Device identifier token")
|
||||||
|
@NotBlank(message = "Device identifier is required")
|
||||||
|
@Size(max = 255, message = "Device identifier must not exceed 255 characters")
|
||||||
|
private String deviceId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's email address
|
||||||
|
*
|
||||||
|
* Email address of the user requesting device verification.
|
||||||
|
* Used for validation and security purposes.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's email address",
|
||||||
|
example = "john.doe@example.com")
|
||||||
|
@NotBlank(message = "Email is required")
|
||||||
|
@Email(message = "Please provide a valid email address")
|
||||||
|
@Size(max = 255, message = "Email must not exceed 255 characters")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verification method used
|
||||||
|
*
|
||||||
|
* The method through which the verification code was sent.
|
||||||
|
* Either 'email' or 'sms' for tracking purposes.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Verification method used",
|
||||||
|
example = "email",
|
||||||
|
allowableValues = {"email", "sms"})
|
||||||
|
@NotBlank(message = "Verification method is required")
|
||||||
|
@Pattern(regexp = "^(email|sms)$", message = "Verification method must be 'email' or 'sms'")
|
||||||
|
private String verificationMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trust this device flag
|
||||||
|
*
|
||||||
|
* Indicates whether the user wants to mark this device
|
||||||
|
* as trusted to avoid future verification requirements.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether to trust this device for future logins",
|
||||||
|
example = "true")
|
||||||
|
private Boolean trustDevice = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device name (optional)
|
||||||
|
*
|
||||||
|
* Human-readable name for the device to help users
|
||||||
|
* identify it in their device management settings.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User-friendly device name",
|
||||||
|
example = "John's iPhone")
|
||||||
|
@Size(max = 100, message = "Device name must not exceed 100 characters")
|
||||||
|
private String deviceName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request timestamp
|
||||||
|
*
|
||||||
|
* Timestamp when the verification request was initiated.
|
||||||
|
* Used for security and timeout validation.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Verification request timestamp",
|
||||||
|
example = "1699123456789")
|
||||||
|
@NotNull(message = "Request timestamp is required")
|
||||||
|
@Min(value = 0, message = "Timestamp must be positive")
|
||||||
|
private Long timestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP address of the request
|
||||||
|
*
|
||||||
|
* IP address from which the verification request originated.
|
||||||
|
* Used for additional security validation.
|
||||||
|
*/
|
||||||
|
@Schema(description = "IP address of the verification request",
|
||||||
|
example = "192.168.1.100")
|
||||||
|
@NotBlank(message = "IP address is required")
|
||||||
|
@Size(max = 45, message = "IP address must not exceed 45 characters")
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember verification duration
|
||||||
|
*
|
||||||
|
* Number of days to remember this device verification.
|
||||||
|
* Default is 30 days, maximum is 365 days.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Days to remember this device verification",
|
||||||
|
example = "30")
|
||||||
|
@Min(value = 1, message = "Remember duration must be at least 1 day")
|
||||||
|
@Max(value = 365, message = "Remember duration cannot exceed 365 days")
|
||||||
|
private Integer rememberForDays = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor
|
||||||
|
*/
|
||||||
|
public DeviceVerificationRequest() {
|
||||||
|
this.timestamp = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor with required fields
|
||||||
|
*
|
||||||
|
* @param sessionToken Session token for verification
|
||||||
|
* @param code Verification code
|
||||||
|
* @param deviceId Device identifier
|
||||||
|
* @param email User's email
|
||||||
|
* @param verificationMethod Method used for verification
|
||||||
|
* @param ipAddress Request IP address
|
||||||
|
*/
|
||||||
|
public DeviceVerificationRequest(String sessionToken, String code, String deviceId, String email,
|
||||||
|
String verificationMethod, String ipAddress) {
|
||||||
|
this();
|
||||||
|
this.sessionToken = sessionToken;
|
||||||
|
this.code = code;
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
this.email = email;
|
||||||
|
this.verificationMethod = verificationMethod;
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the verification request has expired
|
||||||
|
*
|
||||||
|
* @param maxAgeMinutes Maximum age in minutes
|
||||||
|
* @return true if expired, false otherwise
|
||||||
|
*/
|
||||||
|
public boolean isExpired(int maxAgeMinutes) {
|
||||||
|
if (timestamp == null) return true;
|
||||||
|
long maxAgeMillis = maxAgeMinutes * 60 * 1000L;
|
||||||
|
return System.currentTimeMillis() - timestamp > maxAgeMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears sensitive verification code from memory
|
||||||
|
*/
|
||||||
|
public void clearSensitiveData() {
|
||||||
|
this.code = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
public String getSessionToken() {
|
||||||
|
return sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionToken(String sessionToken) {
|
||||||
|
this.sessionToken = sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCode(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceId() {
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceId(String deviceId) {
|
||||||
|
this.deviceId = deviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEmail() {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEmail(String email) {
|
||||||
|
this.email = email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVerificationMethod() {
|
||||||
|
return verificationMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVerificationMethod(String verificationMethod) {
|
||||||
|
this.verificationMethod = verificationMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getTrustDevice() {
|
||||||
|
return trustDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustDevice(Boolean trustDevice) {
|
||||||
|
this.trustDevice = trustDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceName() {
|
||||||
|
return deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceName(String deviceName) {
|
||||||
|
this.deviceName = deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestamp(Long timestamp) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIpAddress() {
|
||||||
|
return ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIpAddress(String ipAddress) {
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRememberForDays() {
|
||||||
|
return rememberForDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRememberForDays(Integer rememberForDays) {
|
||||||
|
this.rememberForDays = rememberForDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "DeviceVerificationRequest{" +
|
||||||
|
"deviceId='" + deviceId + '\'' +
|
||||||
|
", email='" + email + '\'' +
|
||||||
|
", verificationMethod='" + verificationMethod + '\'' +
|
||||||
|
", trustDevice=" + trustDevice +
|
||||||
|
", deviceName='" + deviceName + '\'' +
|
||||||
|
", timestamp=" + timestamp +
|
||||||
|
", ipAddress='" + ipAddress + '\'' +
|
||||||
|
", rememberForDays=" + rememberForDays +
|
||||||
|
", hasCode=" + (code != null) +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Disable Two Factor Request
|
||||||
|
*
|
||||||
|
* Request DTO for disabling two-factor authentication.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable Two Factor Request DTO
|
||||||
|
*/
|
||||||
|
@Schema(description = "Disable two-factor authentication request")
|
||||||
|
public class DisableTwoFactorRequest {
|
||||||
|
|
||||||
|
@Schema(description = "Current password for verification", example = "currentPassword123!")
|
||||||
|
@NotBlank(message = "Password is required")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Schema(description = "TOTP code or backup code for verification", example = "123456")
|
||||||
|
@NotBlank(message = "Verification code is required")
|
||||||
|
private String verificationCode;
|
||||||
|
|
||||||
|
@Schema(description = "Type of verification code", example = "TOTP", allowableValues = {"TOTP", "BACKUP"})
|
||||||
|
@Pattern(regexp = "^(TOTP|BACKUP)$", message = "Verification type must be TOTP or BACKUP")
|
||||||
|
private String verificationType = "TOTP";
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public DisableTwoFactorRequest() {}
|
||||||
|
|
||||||
|
public DisableTwoFactorRequest(String password, String verificationCode, String verificationType) {
|
||||||
|
this.password = password;
|
||||||
|
this.verificationCode = verificationCode;
|
||||||
|
this.verificationType = verificationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPassword(String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVerificationCode() {
|
||||||
|
return verificationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVerificationCode(String verificationCode) {
|
||||||
|
this.verificationCode = verificationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVerificationType() {
|
||||||
|
return verificationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVerificationType(String verificationType) {
|
||||||
|
this.verificationType = verificationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if this is a TOTP verification
|
||||||
|
*
|
||||||
|
* @return true if TOTP verification
|
||||||
|
*/
|
||||||
|
public boolean isTotpVerification() {
|
||||||
|
return "TOTP".equals(verificationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if this is a backup code verification
|
||||||
|
*
|
||||||
|
* @return true if backup code verification
|
||||||
|
*/
|
||||||
|
public boolean isBackupCodeVerification() {
|
||||||
|
return "BACKUP".equals(verificationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "DisableTwoFactorRequest{" +
|
||||||
|
"password='[PROTECTED]'" +
|
||||||
|
", verificationCode='[PROTECTED]'" +
|
||||||
|
", verificationType='" + verificationType + '\'' +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Generate Backup Codes Request
|
||||||
|
*
|
||||||
|
* Request DTO for generating new backup codes.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Backup Codes Request DTO
|
||||||
|
*/
|
||||||
|
@Schema(description = "Generate new backup codes request")
|
||||||
|
public class GenerateBackupCodesRequest {
|
||||||
|
|
||||||
|
@Schema(description = "Current password for verification", example = "currentPassword123!")
|
||||||
|
@NotBlank(message = "Password is required")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Schema(description = "TOTP code or current backup code for verification", example = "123456")
|
||||||
|
@NotBlank(message = "Verification code is required")
|
||||||
|
private String verificationCode;
|
||||||
|
|
||||||
|
@Schema(description = "Type of verification code", example = "TOTP", allowableValues = {"TOTP", "BACKUP"})
|
||||||
|
@Pattern(regexp = "^(TOTP|BACKUP)$", message = "Verification type must be TOTP or BACKUP")
|
||||||
|
private String verificationType = "TOTP";
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public GenerateBackupCodesRequest() {}
|
||||||
|
|
||||||
|
public GenerateBackupCodesRequest(String password, String verificationCode, String verificationType) {
|
||||||
|
this.password = password;
|
||||||
|
this.verificationCode = verificationCode;
|
||||||
|
this.verificationType = verificationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPassword(String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVerificationCode() {
|
||||||
|
return verificationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVerificationCode(String verificationCode) {
|
||||||
|
this.verificationCode = verificationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVerificationType() {
|
||||||
|
return verificationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVerificationType(String verificationType) {
|
||||||
|
this.verificationType = verificationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if this is a TOTP verification
|
||||||
|
*
|
||||||
|
* @return true if TOTP verification
|
||||||
|
*/
|
||||||
|
public boolean isTotpVerification() {
|
||||||
|
return "TOTP".equals(verificationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if this is a backup code verification
|
||||||
|
*
|
||||||
|
* @return true if backup code verification
|
||||||
|
*/
|
||||||
|
public boolean isBackupCodeVerification() {
|
||||||
|
return "BACKUP".equals(verificationType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "GenerateBackupCodesRequest{" +
|
||||||
|
"password='[PROTECTED]'" +
|
||||||
|
", verificationCode='[PROTECTED]'" +
|
||||||
|
", verificationType='" + verificationType + '\'' +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/main/java/com/company/auth/dto/request/LoginRequest.java
Normal file
173
src/main/java/com/company/auth/dto/request/LoginRequest.java
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Login Request DTO
|
||||||
|
*
|
||||||
|
* Data transfer object for user login requests.
|
||||||
|
* Contains user credentials and supports encrypted payloads
|
||||||
|
* for enhanced security during authentication.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for user authentication
|
||||||
|
*
|
||||||
|
* Supports both plain text and encrypted credential submission.
|
||||||
|
* The encrypted mode provides additional security for sensitive
|
||||||
|
* credential transmission from frontend applications.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User login request with email and password")
|
||||||
|
public class LoginRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's email address
|
||||||
|
*
|
||||||
|
* Primary identifier for user authentication.
|
||||||
|
* Must be a valid email format and not blank.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User email address", example = "john.doe@company.com")
|
||||||
|
@NotBlank(message = "Email is required")
|
||||||
|
@Email(message = "Email must be valid")
|
||||||
|
@Size(max = 255, message = "Email must not exceed 255 characters")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's password (may be encrypted)
|
||||||
|
*
|
||||||
|
* Password for authentication. Can be provided as plain text
|
||||||
|
* or encrypted using frontend encryption for enhanced security.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User password (plain text or encrypted)", example = "SecurePassword123!")
|
||||||
|
@NotBlank(message = "Password is required")
|
||||||
|
@Size(min = 8, max = 1000, message = "Password must be between 8 and 1000 characters")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device fingerprint data for risk assessment
|
||||||
|
*
|
||||||
|
* Optional browser fingerprint information used for
|
||||||
|
* device trust verification and risk-based authentication.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Browser fingerprint data for device verification")
|
||||||
|
private DeviceFingerprintRequest fingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag indicating if credentials are encrypted
|
||||||
|
*
|
||||||
|
* When true, the password field contains encrypted data that
|
||||||
|
* needs to be decrypted before authentication.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether password is encrypted", example = "false")
|
||||||
|
private Boolean encrypted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember device flag
|
||||||
|
*
|
||||||
|
* When true, the device will be registered as trusted after
|
||||||
|
* successful authentication (may require 2FA for new devices).
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether to remember this device", example = "true")
|
||||||
|
private Boolean rememberDevice = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor
|
||||||
|
*/
|
||||||
|
public LoginRequest() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor with basic credentials
|
||||||
|
*
|
||||||
|
* @param email User email
|
||||||
|
* @param password User password
|
||||||
|
*/
|
||||||
|
public LoginRequest(String email, String password) {
|
||||||
|
this.email = email;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor with all fields
|
||||||
|
*
|
||||||
|
* @param email User email
|
||||||
|
* @param password User password
|
||||||
|
* @param fingerprint Device fingerprint
|
||||||
|
* @param encrypted Whether password is encrypted
|
||||||
|
* @param rememberDevice Whether to remember device
|
||||||
|
*/
|
||||||
|
public LoginRequest(String email, String password, DeviceFingerprintRequest fingerprint,
|
||||||
|
Boolean encrypted, Boolean rememberDevice) {
|
||||||
|
this.email = email;
|
||||||
|
this.password = password;
|
||||||
|
this.fingerprint = fingerprint;
|
||||||
|
this.encrypted = encrypted;
|
||||||
|
this.rememberDevice = rememberDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
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 DeviceFingerprintRequest getFingerprint() {
|
||||||
|
return fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFingerprint(DeviceFingerprintRequest fingerprint) {
|
||||||
|
this.fingerprint = fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getEncrypted() {
|
||||||
|
return encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEncrypted(Boolean encrypted) {
|
||||||
|
this.encrypted = encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getRememberDevice() {
|
||||||
|
return rememberDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRememberDevice(Boolean rememberDevice) {
|
||||||
|
this.rememberDevice = rememberDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears sensitive data from memory
|
||||||
|
*/
|
||||||
|
public void clearSensitiveData() {
|
||||||
|
this.password = null;
|
||||||
|
if (this.fingerprint != null) {
|
||||||
|
// Clear any sensitive fingerprint data if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "LoginRequest{" +
|
||||||
|
"email='" + email + '\'' +
|
||||||
|
", encrypted=" + encrypted +
|
||||||
|
", rememberDevice=" + rememberDevice +
|
||||||
|
", hasFingerprint=" + (fingerprint != null) +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/main/java/com/company/auth/dto/request/RegisterRequest.java
Normal file
305
src/main/java/com/company/auth/dto/request/RegisterRequest.java
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* User Registration Request DTO
|
||||||
|
*
|
||||||
|
* Data transfer object for user registration requests.
|
||||||
|
* Contains all necessary information for creating a new user
|
||||||
|
* account with proper validation and security measures.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for user registration
|
||||||
|
*
|
||||||
|
* Handles user registration data with comprehensive validation
|
||||||
|
* and includes device fingerprinting for immediate security
|
||||||
|
* assessment during account creation.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User registration request data")
|
||||||
|
public class RegisterRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's full name
|
||||||
|
*
|
||||||
|
* Complete name of the user for identification
|
||||||
|
* and personalization purposes.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's full name",
|
||||||
|
example = "John Doe")
|
||||||
|
@NotBlank(message = "Name is required")
|
||||||
|
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
|
||||||
|
@Pattern(regexp = "^[a-zA-ZÀ-ÿ\\s-']+$",
|
||||||
|
message = "Name can only contain letters, spaces, hyphens, and apostrophes")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's email address
|
||||||
|
*
|
||||||
|
* Primary identifier and communication channel for the user.
|
||||||
|
* Must be unique across the system.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's email address",
|
||||||
|
example = "john.doe@example.com")
|
||||||
|
@NotBlank(message = "Email is required")
|
||||||
|
@Email(message = "Please provide a valid email address")
|
||||||
|
@Size(max = 255, message = "Email must not exceed 255 characters")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's chosen password
|
||||||
|
*
|
||||||
|
* Primary authentication credential with strength requirements.
|
||||||
|
* Will be hashed before storage.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's password (minimum 8 characters)",
|
||||||
|
example = "SecurePassword123!")
|
||||||
|
@NotBlank(message = "Password is required")
|
||||||
|
@Size(min = 8, max = 128, message = "Password must be between 8 and 128 characters")
|
||||||
|
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$",
|
||||||
|
message = "Password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password confirmation
|
||||||
|
*
|
||||||
|
* Confirmation field to ensure password accuracy.
|
||||||
|
* Must match the password field exactly.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Password confirmation",
|
||||||
|
example = "SecurePassword123!")
|
||||||
|
@NotBlank(message = "Password confirmation is required")
|
||||||
|
private String confirmPassword;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's phone number (optional)
|
||||||
|
*
|
||||||
|
* Phone number for two-factor authentication
|
||||||
|
* and account recovery purposes.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's phone number for 2FA",
|
||||||
|
example = "+1234567890")
|
||||||
|
@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$",
|
||||||
|
message = "Please provide a valid phone number")
|
||||||
|
private String phoneNumber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferred language/locale
|
||||||
|
*
|
||||||
|
* User's language preference for application
|
||||||
|
* localization and communication.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's preferred language",
|
||||||
|
example = "en-US")
|
||||||
|
@Size(max = 10, message = "Language must not exceed 10 characters")
|
||||||
|
@Pattern(regexp = "^[a-z]{2}(-[A-Z]{2})?$",
|
||||||
|
message = "Language must be in format 'en' or 'en-US'")
|
||||||
|
private String language;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terms of service acceptance
|
||||||
|
*
|
||||||
|
* Mandatory acceptance of terms and conditions
|
||||||
|
* for legal compliance.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Terms of service acceptance",
|
||||||
|
example = "true")
|
||||||
|
@NotNull(message = "Terms of service acceptance is required")
|
||||||
|
@AssertTrue(message = "You must accept the terms of service")
|
||||||
|
private Boolean acceptTerms;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketing communications consent
|
||||||
|
*
|
||||||
|
* Optional consent for receiving marketing
|
||||||
|
* and promotional communications.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Consent for marketing communications",
|
||||||
|
example = "false")
|
||||||
|
private Boolean acceptMarketing = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device fingerprint information
|
||||||
|
*
|
||||||
|
* Browser and device characteristics for immediate
|
||||||
|
* device trust establishment during registration.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Device fingerprint for security")
|
||||||
|
@Valid
|
||||||
|
private DeviceFingerprintRequest deviceFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration source/referrer
|
||||||
|
*
|
||||||
|
* Optional tracking of how the user discovered
|
||||||
|
* the application for analytics purposes.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Registration source or referrer",
|
||||||
|
example = "google-ads")
|
||||||
|
@Size(max = 50, message = "Source must not exceed 50 characters")
|
||||||
|
private String source;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's timezone
|
||||||
|
*
|
||||||
|
* User's timezone for proper date/time handling
|
||||||
|
* and scheduled communications.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's timezone",
|
||||||
|
example = "America/New_York")
|
||||||
|
@Size(max = 50, message = "Timezone must not exceed 50 characters")
|
||||||
|
private String timezone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor
|
||||||
|
*/
|
||||||
|
public RegisterRequest() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor with required fields
|
||||||
|
*
|
||||||
|
* @param name User's full name
|
||||||
|
* @param email User's email address
|
||||||
|
* @param password User's password
|
||||||
|
* @param confirmPassword Password confirmation
|
||||||
|
* @param acceptTerms Terms acceptance
|
||||||
|
*/
|
||||||
|
public RegisterRequest(String name, String email, String password,
|
||||||
|
String confirmPassword, Boolean acceptTerms) {
|
||||||
|
this.name = name;
|
||||||
|
this.email = email;
|
||||||
|
this.password = password;
|
||||||
|
this.confirmPassword = confirmPassword;
|
||||||
|
this.acceptTerms = acceptTerms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that password and confirmPassword match
|
||||||
|
*
|
||||||
|
* @return true if passwords match, false otherwise
|
||||||
|
*/
|
||||||
|
public boolean isPasswordMatching() {
|
||||||
|
return password != null && password.equals(confirmPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears sensitive data from memory
|
||||||
|
*
|
||||||
|
* Should be called after successful processing
|
||||||
|
* to minimize password exposure time.
|
||||||
|
*/
|
||||||
|
public void clearSensitiveData() {
|
||||||
|
this.password = null;
|
||||||
|
this.confirmPassword = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
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 String getConfirmPassword() {
|
||||||
|
return confirmPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConfirmPassword(String confirmPassword) {
|
||||||
|
this.confirmPassword = confirmPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPhoneNumber() {
|
||||||
|
return phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhoneNumber(String phoneNumber) {
|
||||||
|
this.phoneNumber = phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLanguage() {
|
||||||
|
return language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLanguage(String language) {
|
||||||
|
this.language = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getAcceptTerms() {
|
||||||
|
return acceptTerms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAcceptTerms(Boolean acceptTerms) {
|
||||||
|
this.acceptTerms = acceptTerms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getAcceptMarketing() {
|
||||||
|
return acceptMarketing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAcceptMarketing(Boolean acceptMarketing) {
|
||||||
|
this.acceptMarketing = acceptMarketing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeviceFingerprintRequest getDeviceFingerprint() {
|
||||||
|
return deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceFingerprint(DeviceFingerprintRequest deviceFingerprint) {
|
||||||
|
this.deviceFingerprint = deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSource() {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSource(String source) {
|
||||||
|
this.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTimezone() {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimezone(String timezone) {
|
||||||
|
this.timezone = timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "RegisterRequest{" +
|
||||||
|
"name='" + name + '\'' +
|
||||||
|
", email='" + email + '\'' +
|
||||||
|
", phoneNumber='" + phoneNumber + '\'' +
|
||||||
|
", language='" + language + '\'' +
|
||||||
|
", acceptTerms=" + acceptTerms +
|
||||||
|
", acceptMarketing=" + acceptMarketing +
|
||||||
|
", source='" + source + '\'' +
|
||||||
|
", timezone='" + timezone + '\'' +
|
||||||
|
", hasDeviceFingerprint=" + (deviceFingerprint != null) +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Two-Factor Authentication Request DTO
|
||||||
|
*
|
||||||
|
* Data transfer object for two-factor authentication operations.
|
||||||
|
* Handles both TOTP (Time-based One-Time Password) and
|
||||||
|
* SMS-based authentication requests.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.request;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for two-factor authentication
|
||||||
|
*
|
||||||
|
* Contains authentication code and method information
|
||||||
|
* for completing the 2FA verification process.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Two-factor authentication request data")
|
||||||
|
public class TwoFactorAuthRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication code
|
||||||
|
*
|
||||||
|
* 6-digit code from authenticator app (TOTP) or
|
||||||
|
* received via SMS for verification.
|
||||||
|
*/
|
||||||
|
@Schema(description = "6-digit 2FA code",
|
||||||
|
example = "123456")
|
||||||
|
@NotBlank(message = "2FA code is required")
|
||||||
|
@Pattern(regexp = "^\\d{6}$", message = "2FA code must be exactly 6 digits")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's email address
|
||||||
|
*
|
||||||
|
* Email address of the user attempting 2FA verification.
|
||||||
|
* Used for user identification and security validation.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's email address",
|
||||||
|
example = "john.doe@example.com")
|
||||||
|
@NotBlank(message = "Email is required")
|
||||||
|
@Email(message = "Please provide a valid email address")
|
||||||
|
@Size(max = 255, message = "Email must not exceed 255 characters")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication method
|
||||||
|
*
|
||||||
|
* The method used for 2FA: 'totp' for authenticator apps
|
||||||
|
* or 'sms' for SMS-based verification.
|
||||||
|
*/
|
||||||
|
@Schema(description = "2FA method used",
|
||||||
|
example = "totp",
|
||||||
|
allowableValues = {"totp", "sms"})
|
||||||
|
@NotBlank(message = "2FA method is required")
|
||||||
|
@Pattern(regexp = "^(totp|sms)$", message = "2FA method must be 'totp' or 'sms'")
|
||||||
|
private String method;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember device for 2FA
|
||||||
|
*
|
||||||
|
* Indicates whether to remember this device to skip
|
||||||
|
* 2FA verification for a specified period.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether to remember this device for 2FA",
|
||||||
|
example = "true")
|
||||||
|
private Boolean rememberDevice = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login session token
|
||||||
|
*
|
||||||
|
* Temporary session token from the initial login attempt.
|
||||||
|
* Required to link the 2FA verification to the login session.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Temporary login session token")
|
||||||
|
@NotBlank(message = "Session token is required")
|
||||||
|
@Size(max = 500, message = "Session token must not exceed 500 characters")
|
||||||
|
private String sessionToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request timestamp
|
||||||
|
*
|
||||||
|
* Timestamp when the 2FA request was made.
|
||||||
|
* Used for timeout and security validation.
|
||||||
|
*/
|
||||||
|
@Schema(description = "2FA request timestamp",
|
||||||
|
example = "1699123456789")
|
||||||
|
@NotNull(message = "Request timestamp is required")
|
||||||
|
@Min(value = 0, message = "Timestamp must be positive")
|
||||||
|
private Long timestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP address of the request
|
||||||
|
*
|
||||||
|
* IP address from which the 2FA verification was attempted.
|
||||||
|
* Used for additional security validation and logging.
|
||||||
|
*/
|
||||||
|
@Schema(description = "IP address of the 2FA request",
|
||||||
|
example = "192.168.1.100")
|
||||||
|
@NotBlank(message = "IP address is required")
|
||||||
|
@Size(max = 45, message = "IP address must not exceed 45 characters")
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User agent string
|
||||||
|
*
|
||||||
|
* Browser/client information for security logging
|
||||||
|
* and session validation purposes.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User agent string")
|
||||||
|
@Size(max = 1000, message = "User agent must not exceed 1000 characters")
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovery code flag
|
||||||
|
*
|
||||||
|
* Indicates if the provided code is a recovery code
|
||||||
|
* instead of a regular 2FA code.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether the code is a recovery code",
|
||||||
|
example = "false")
|
||||||
|
private Boolean isRecoveryCode = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trust duration in days
|
||||||
|
*
|
||||||
|
* Number of days to trust this device for 2FA.
|
||||||
|
* Only applicable when rememberDevice is true.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Days to trust this device for 2FA",
|
||||||
|
example = "30")
|
||||||
|
@Min(value = 1, message = "Trust duration must be at least 1 day")
|
||||||
|
@Max(value = 90, message = "Trust duration cannot exceed 90 days")
|
||||||
|
private Integer trustDurationDays = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor
|
||||||
|
*/
|
||||||
|
public TwoFactorAuthRequest() {
|
||||||
|
this.timestamp = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor with required fields
|
||||||
|
*
|
||||||
|
* @param code 2FA verification code
|
||||||
|
* @param email User's email address
|
||||||
|
* @param method 2FA method used
|
||||||
|
* @param sessionToken Login session token
|
||||||
|
* @param ipAddress Request IP address
|
||||||
|
*/
|
||||||
|
public TwoFactorAuthRequest(String code, String email, String method,
|
||||||
|
String sessionToken, String ipAddress) {
|
||||||
|
this();
|
||||||
|
this.code = code;
|
||||||
|
this.email = email;
|
||||||
|
this.method = method;
|
||||||
|
this.sessionToken = sessionToken;
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the 2FA request has expired
|
||||||
|
*
|
||||||
|
* @param maxAgeMinutes Maximum age in minutes
|
||||||
|
* @return true if expired, false otherwise
|
||||||
|
*/
|
||||||
|
public boolean isExpired(int maxAgeMinutes) {
|
||||||
|
if (timestamp == null) return true;
|
||||||
|
long maxAgeMillis = maxAgeMinutes * 60 * 1000L;
|
||||||
|
return System.currentTimeMillis() - timestamp > maxAgeMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the code format matches the method
|
||||||
|
*
|
||||||
|
* @return true if code format is valid for the method
|
||||||
|
*/
|
||||||
|
public boolean isCodeFormatValidForMethod() {
|
||||||
|
if (code == null || method == null) return false;
|
||||||
|
|
||||||
|
if (isRecoveryCode) {
|
||||||
|
// Recovery codes are typically 8-10 characters
|
||||||
|
return code.matches("^[A-Za-z0-9]{8,10}$");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular 2FA codes are 6 digits
|
||||||
|
return code.matches("^\\d{6}$");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears sensitive data from memory
|
||||||
|
*/
|
||||||
|
public void clearSensitiveData() {
|
||||||
|
this.code = null;
|
||||||
|
this.sessionToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCode(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEmail() {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEmail(String email) {
|
||||||
|
this.email = email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMethod() {
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMethod(String method) {
|
||||||
|
this.method = method;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getRememberDevice() {
|
||||||
|
return rememberDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRememberDevice(Boolean rememberDevice) {
|
||||||
|
this.rememberDevice = rememberDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSessionToken() {
|
||||||
|
return sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionToken(String sessionToken) {
|
||||||
|
this.sessionToken = sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestamp(Long timestamp) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIpAddress() {
|
||||||
|
return ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIpAddress(String ipAddress) {
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserAgent() {
|
||||||
|
return userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserAgent(String userAgent) {
|
||||||
|
this.userAgent = userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsRecoveryCode() {
|
||||||
|
return isRecoveryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsRecoveryCode(Boolean isRecoveryCode) {
|
||||||
|
this.isRecoveryCode = isRecoveryCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTrustDurationDays() {
|
||||||
|
return trustDurationDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustDurationDays(Integer trustDurationDays) {
|
||||||
|
this.trustDurationDays = trustDurationDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "TwoFactorAuthRequest{" +
|
||||||
|
"email='" + email + '\'' +
|
||||||
|
", method='" + method + '\'' +
|
||||||
|
", rememberDevice=" + rememberDevice +
|
||||||
|
", timestamp=" + timestamp +
|
||||||
|
", ipAddress='" + ipAddress + '\'' +
|
||||||
|
", isRecoveryCode=" + isRecoveryCode +
|
||||||
|
", trustDurationDays=" + trustDurationDays +
|
||||||
|
", hasCode=" + (code != null) +
|
||||||
|
", hasSessionToken=" + (sessionToken != null) +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Update Profile Request
|
||||||
|
*
|
||||||
|
* Request DTO for updating user profile information.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Profile Request DTO
|
||||||
|
*/
|
||||||
|
@Schema(description = "User profile update request")
|
||||||
|
public class UpdateProfileRequest {
|
||||||
|
|
||||||
|
@Schema(description = "User's full name", example = "John Doe")
|
||||||
|
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Schema(description = "User's email address", example = "john.doe@example.com")
|
||||||
|
@Email(message = "Please provide a valid email address")
|
||||||
|
@Size(max = 255, message = "Email must not exceed 255 characters")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "User's phone number in international format", example = "+1234567890")
|
||||||
|
@Pattern(regexp = "^\\+[1-9]\\d{1,14}$", message = "Please provide a valid phone number in international format")
|
||||||
|
private String phoneNumber;
|
||||||
|
|
||||||
|
@Schema(description = "User's preferred timezone", example = "America/New_York")
|
||||||
|
@Size(max = 50, message = "Timezone must not exceed 50 characters")
|
||||||
|
private String timezone;
|
||||||
|
|
||||||
|
@Schema(description = "User's preferred language", example = "en")
|
||||||
|
@Pattern(regexp = "^[a-z]{2}(-[A-Z]{2})?$", message = "Please provide a valid language code")
|
||||||
|
private String language;
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public UpdateProfileRequest() {}
|
||||||
|
|
||||||
|
public UpdateProfileRequest(String name, String email, String phoneNumber, String timezone, String language) {
|
||||||
|
this.name = name;
|
||||||
|
this.email = email;
|
||||||
|
this.phoneNumber = phoneNumber;
|
||||||
|
this.timezone = timezone;
|
||||||
|
this.language = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
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 getPhoneNumber() {
|
||||||
|
return phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhoneNumber(String phoneNumber) {
|
||||||
|
this.phoneNumber = phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTimezone() {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimezone(String timezone) {
|
||||||
|
this.timezone = timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLanguage() {
|
||||||
|
return language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLanguage(String language) {
|
||||||
|
this.language = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "UpdateProfileRequest{" +
|
||||||
|
"name='" + name + '\'' +
|
||||||
|
", email='" + email + '\'' +
|
||||||
|
", phoneNumber='" + phoneNumber + '\'' +
|
||||||
|
", timezone='" + timezone + '\'' +
|
||||||
|
", language='" + language + '\'' +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/main/java/com/company/auth/dto/response/ApiResponse.java
Normal file
68
src/main/java/com/company/auth/dto/response/ApiResponse.java
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package com.company.auth.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
@Schema(description = "Generic API response wrapper")
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
|
@Schema(description = "Response status")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(description = "Response message")
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@Schema(description = "Response data")
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
@Schema(description = "Error details")
|
||||||
|
private String error;
|
||||||
|
|
||||||
|
public ApiResponse() {}
|
||||||
|
|
||||||
|
public ApiResponse(String status, String message) {
|
||||||
|
this.status = status;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse(String status, String message, T data) {
|
||||||
|
this.status = status;
|
||||||
|
this.message = message;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static factory methods
|
||||||
|
public static <T> ApiResponse<T> success(T data) {
|
||||||
|
return new ApiResponse<>("success", "Operation completed successfully", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> success(String message, T data) {
|
||||||
|
return new ApiResponse<>("success", message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> error(String message) {
|
||||||
|
ApiResponse<T> response = new ApiResponse<>("error", message);
|
||||||
|
response.setError(message);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> error(String message, String error) {
|
||||||
|
ApiResponse<T> response = new ApiResponse<>("error", message);
|
||||||
|
response.setError(error);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
|
||||||
|
public String getMessage() { return message; }
|
||||||
|
public void setMessage(String message) { this.message = message; }
|
||||||
|
|
||||||
|
public T getData() { return data; }
|
||||||
|
public void setData(T data) { this.data = data; }
|
||||||
|
|
||||||
|
public String getError() { return error; }
|
||||||
|
public void setError(String error) { this.error = error; }
|
||||||
|
}
|
||||||
422
src/main/java/com/company/auth/dto/response/AuthResponse.java
Normal file
422
src/main/java/com/company/auth/dto/response/AuthResponse.java
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Response DTO
|
||||||
|
*
|
||||||
|
* Data transfer object for authentication responses.
|
||||||
|
* Contains authentication tokens, user information,
|
||||||
|
* and security status after successful login.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for authentication operations
|
||||||
|
*
|
||||||
|
* Provides authentication tokens, user details, and security
|
||||||
|
* information after successful login or registration.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Authentication response data")
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class AuthResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT access token
|
||||||
|
*
|
||||||
|
* Short-lived token for API authentication.
|
||||||
|
* Should be included in Authorization header.
|
||||||
|
*/
|
||||||
|
@Schema(description = "JWT access token for API authentication")
|
||||||
|
private String accessToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT refresh token
|
||||||
|
*
|
||||||
|
* Long-lived token for obtaining new access tokens.
|
||||||
|
* Should be stored securely on the client.
|
||||||
|
*/
|
||||||
|
@Schema(description = "JWT refresh token for token renewal")
|
||||||
|
private String refreshToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token type
|
||||||
|
*
|
||||||
|
* Type of the access token, typically "Bearer".
|
||||||
|
*/
|
||||||
|
@Schema(description = "Token type",
|
||||||
|
example = "Bearer")
|
||||||
|
private String tokenType = "Bearer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token expiration time
|
||||||
|
*
|
||||||
|
* Timestamp when the access token expires.
|
||||||
|
* Client should refresh before this time.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Access token expiration time")
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User information
|
||||||
|
*
|
||||||
|
* Basic user profile information for the
|
||||||
|
* authenticated user.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Authenticated user information")
|
||||||
|
private UserResponse user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication required
|
||||||
|
*
|
||||||
|
* Indicates if 2FA verification is required
|
||||||
|
* to complete the authentication process.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether 2FA verification is required",
|
||||||
|
example = "false")
|
||||||
|
private Boolean requiresTwoFactor = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device verification required
|
||||||
|
*
|
||||||
|
* Indicates if device verification is required
|
||||||
|
* for this login attempt.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether device verification is required",
|
||||||
|
example = "false")
|
||||||
|
private Boolean requiresDeviceVerification = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session token for multi-step authentication
|
||||||
|
*
|
||||||
|
* Temporary token for completing multi-step
|
||||||
|
* authentication processes (2FA, device verification).
|
||||||
|
*/
|
||||||
|
@Schema(description = "Temporary session token for multi-step auth")
|
||||||
|
private String sessionToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available 2FA methods
|
||||||
|
*
|
||||||
|
* List of 2FA methods available to the user
|
||||||
|
* when 2FA is required.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Available 2FA methods",
|
||||||
|
example = "[\"totp\", \"sms\"]")
|
||||||
|
private String[] availableTwoFactorMethods;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device trust status
|
||||||
|
*
|
||||||
|
* Indicates the trust level of the current device.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Current device trust status",
|
||||||
|
example = "trusted")
|
||||||
|
private String deviceTrustStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login timestamp
|
||||||
|
*
|
||||||
|
* Timestamp when the authentication occurred.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Login timestamp")
|
||||||
|
private LocalDateTime loginAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP address of the login
|
||||||
|
*
|
||||||
|
* IP address from which the authentication
|
||||||
|
* was performed.
|
||||||
|
*/
|
||||||
|
@Schema(description = "IP address of the login",
|
||||||
|
example = "192.168.1.100")
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New device detected flag
|
||||||
|
*
|
||||||
|
* Indicates if this login was from a new/unknown device.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether this is a new device",
|
||||||
|
example = "false")
|
||||||
|
private Boolean isNewDevice = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security alerts count
|
||||||
|
*
|
||||||
|
* Number of pending security alerts for the user.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Number of pending security alerts",
|
||||||
|
example = "0")
|
||||||
|
private Integer securityAlertsCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last login information
|
||||||
|
*
|
||||||
|
* Information about the user's previous login
|
||||||
|
* for security awareness.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Previous login information")
|
||||||
|
private LastLoginInfo lastLogin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor
|
||||||
|
*/
|
||||||
|
public AuthResponse() {
|
||||||
|
this.loginAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for successful authentication
|
||||||
|
*
|
||||||
|
* @param accessToken JWT access token
|
||||||
|
* @param refreshToken JWT refresh token
|
||||||
|
* @param expiresAt Token expiration time
|
||||||
|
* @param user User information
|
||||||
|
*/
|
||||||
|
public AuthResponse(String accessToken, String refreshToken,
|
||||||
|
LocalDateTime expiresAt, UserResponse user) {
|
||||||
|
this();
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for multi-step authentication
|
||||||
|
*
|
||||||
|
* @param sessionToken Temporary session token
|
||||||
|
* @param requiresTwoFactor Whether 2FA is required
|
||||||
|
* @param requiresDeviceVerification Whether device verification is required
|
||||||
|
*/
|
||||||
|
public AuthResponse(String sessionToken, Boolean requiresTwoFactor,
|
||||||
|
Boolean requiresDeviceVerification) {
|
||||||
|
this();
|
||||||
|
this.sessionToken = sessionToken;
|
||||||
|
this.requiresTwoFactor = requiresTwoFactor;
|
||||||
|
this.requiresDeviceVerification = requiresDeviceVerification;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a successful authentication response
|
||||||
|
*
|
||||||
|
* @param accessToken JWT access token
|
||||||
|
* @param refreshToken JWT refresh token
|
||||||
|
* @param expiresAt Token expiration
|
||||||
|
* @param user User information
|
||||||
|
* @return AuthResponse instance
|
||||||
|
*/
|
||||||
|
public static AuthResponse success(String accessToken, String refreshToken,
|
||||||
|
LocalDateTime expiresAt, UserResponse user) {
|
||||||
|
return new AuthResponse(accessToken, refreshToken, expiresAt, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a response requiring 2FA
|
||||||
|
*
|
||||||
|
* @param sessionToken Temporary session token
|
||||||
|
* @param availableMethods Available 2FA methods
|
||||||
|
* @return AuthResponse instance
|
||||||
|
*/
|
||||||
|
public static AuthResponse requireTwoFactor(String sessionToken, String[] availableMethods) {
|
||||||
|
AuthResponse response = new AuthResponse(sessionToken, true, false);
|
||||||
|
response.setAvailableTwoFactorMethods(availableMethods);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a response requiring device verification
|
||||||
|
*
|
||||||
|
* @param sessionToken Temporary session token
|
||||||
|
* @return AuthResponse instance
|
||||||
|
*/
|
||||||
|
public static AuthResponse requireDeviceVerification(String sessionToken) {
|
||||||
|
return new AuthResponse(sessionToken, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if authentication is complete
|
||||||
|
*
|
||||||
|
* @return true if user is fully authenticated
|
||||||
|
*/
|
||||||
|
public boolean isAuthenticationComplete() {
|
||||||
|
return accessToken != null && !requiresTwoFactor && !requiresDeviceVerification;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if additional verification is needed
|
||||||
|
*
|
||||||
|
* @return true if more steps are required
|
||||||
|
*/
|
||||||
|
public boolean requiresAdditionalVerification() {
|
||||||
|
return requiresTwoFactor || requiresDeviceVerification;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
public String getAccessToken() {
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccessToken(String accessToken) {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRefreshToken() {
|
||||||
|
return refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRefreshToken(String refreshToken) {
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTokenType() {
|
||||||
|
return tokenType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTokenType(String tokenType) {
|
||||||
|
this.tokenType = tokenType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getExpiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserResponse getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(UserResponse user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getRequiresTwoFactor() {
|
||||||
|
return requiresTwoFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequiresTwoFactor(Boolean requiresTwoFactor) {
|
||||||
|
this.requiresTwoFactor = requiresTwoFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getRequiresDeviceVerification() {
|
||||||
|
return requiresDeviceVerification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequiresDeviceVerification(Boolean requiresDeviceVerification) {
|
||||||
|
this.requiresDeviceVerification = requiresDeviceVerification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSessionToken() {
|
||||||
|
return sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSessionToken(String sessionToken) {
|
||||||
|
this.sessionToken = sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String[] getAvailableTwoFactorMethods() {
|
||||||
|
return availableTwoFactorMethods;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvailableTwoFactorMethods(String[] availableTwoFactorMethods) {
|
||||||
|
this.availableTwoFactorMethods = availableTwoFactorMethods;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceTrustStatus() {
|
||||||
|
return deviceTrustStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceTrustStatus(String deviceTrustStatus) {
|
||||||
|
this.deviceTrustStatus = deviceTrustStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLoginAt() {
|
||||||
|
return loginAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLoginAt(LocalDateTime loginAt) {
|
||||||
|
this.loginAt = loginAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIpAddress() {
|
||||||
|
return ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIpAddress(String ipAddress) {
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsNewDevice() {
|
||||||
|
return isNewDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsNewDevice(Boolean isNewDevice) {
|
||||||
|
this.isNewDevice = isNewDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSecurityAlertsCount() {
|
||||||
|
return securityAlertsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSecurityAlertsCount(Integer securityAlertsCount) {
|
||||||
|
this.securityAlertsCount = securityAlertsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LastLoginInfo getLastLogin() {
|
||||||
|
return lastLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastLogin(LastLoginInfo lastLogin) {
|
||||||
|
this.lastLogin = lastLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last login information nested class
|
||||||
|
*/
|
||||||
|
@Schema(description = "Previous login information")
|
||||||
|
public static class LastLoginInfo {
|
||||||
|
@Schema(description = "Previous login timestamp")
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
|
||||||
|
@Schema(description = "Previous login IP address")
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
@Schema(description = "Previous login location")
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
@Schema(description = "Previous login device")
|
||||||
|
private String device;
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
public LastLoginInfo() {}
|
||||||
|
|
||||||
|
public LastLoginInfo(LocalDateTime timestamp, String ipAddress, String location, String device) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
this.location = location;
|
||||||
|
this.device = device;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public LocalDateTime getTimestamp() { return timestamp; }
|
||||||
|
public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
|
||||||
|
|
||||||
|
public String getIpAddress() { return ipAddress; }
|
||||||
|
public void setIpAddress(String ipAddress) { this.ipAddress = ipAddress; }
|
||||||
|
|
||||||
|
public String getLocation() { return location; }
|
||||||
|
public void setLocation(String location) { this.location = location; }
|
||||||
|
|
||||||
|
public String getDevice() { return device; }
|
||||||
|
public void setDevice(String device) { this.device = device; }
|
||||||
|
}
|
||||||
|
}
|
||||||
446
src/main/java/com/company/auth/dto/response/DeviceResponse.java
Normal file
446
src/main/java/com/company/auth/dto/response/DeviceResponse.java
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
/**
|
||||||
|
* Device Response DTO
|
||||||
|
*
|
||||||
|
* Data transfer object for trusted device information responses.
|
||||||
|
* Contains device details for client device management interfaces.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.company.auth.entity.TrustedDevice;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for device information
|
||||||
|
*
|
||||||
|
* Provides device data for management interfaces,
|
||||||
|
* excluding sensitive fingerprint details.
|
||||||
|
*/
|
||||||
|
@Schema(description = "Trusted device information response")
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class DeviceResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device unique identifier
|
||||||
|
*/
|
||||||
|
@Schema(description = "Device unique identifier",
|
||||||
|
example = "123e4567-e89b-12d3-a456-426614174000")
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-friendly device name
|
||||||
|
*/
|
||||||
|
@Schema(description = "User-friendly device name",
|
||||||
|
example = "John's MacBook Pro")
|
||||||
|
private String deviceName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device type classification
|
||||||
|
*/
|
||||||
|
@Schema(description = "Device type",
|
||||||
|
example = "desktop")
|
||||||
|
private String deviceType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operating system information
|
||||||
|
*/
|
||||||
|
@Schema(description = "Operating system",
|
||||||
|
example = "macOS 14.1")
|
||||||
|
private String operatingSystem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser information
|
||||||
|
*/
|
||||||
|
@Schema(description = "Browser information",
|
||||||
|
example = "Chrome 118.0.0.0")
|
||||||
|
private String browser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device location information
|
||||||
|
*/
|
||||||
|
@Schema(description = "Device location",
|
||||||
|
example = "New York, NY, USA")
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last IP address used
|
||||||
|
*/
|
||||||
|
@Schema(description = "Last known IP address",
|
||||||
|
example = "192.168.1.100")
|
||||||
|
private String lastIpAddress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device trust status
|
||||||
|
*/
|
||||||
|
@Schema(description = "Device trust status",
|
||||||
|
example = "TRUSTED")
|
||||||
|
private String trustStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device trust level (0-100)
|
||||||
|
*/
|
||||||
|
@Schema(description = "Device trust level",
|
||||||
|
example = "95")
|
||||||
|
private Integer trustLevel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether device is currently active
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether device is currently active",
|
||||||
|
example = "true")
|
||||||
|
private Boolean isActive;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this is the current device
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether this is the current device",
|
||||||
|
example = "false")
|
||||||
|
private Boolean isCurrent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When device was first trusted
|
||||||
|
*/
|
||||||
|
@Schema(description = "When device was first trusted")
|
||||||
|
private LocalDateTime trustedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last time device was used
|
||||||
|
*/
|
||||||
|
@Schema(description = "Last activity timestamp")
|
||||||
|
private LocalDateTime lastSeenAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When device trust expires
|
||||||
|
*/
|
||||||
|
@Schema(description = "Device trust expiration")
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of successful logins
|
||||||
|
*/
|
||||||
|
@Schema(description = "Number of successful logins",
|
||||||
|
example = "42")
|
||||||
|
private Integer loginCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of failed login attempts
|
||||||
|
*/
|
||||||
|
@Schema(description = "Number of failed attempts",
|
||||||
|
example = "0")
|
||||||
|
private Integer failedAttempts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security risk assessment
|
||||||
|
*/
|
||||||
|
@Schema(description = "Security risk level",
|
||||||
|
example = "LOW")
|
||||||
|
private String riskLevel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device creation timestamp
|
||||||
|
*/
|
||||||
|
@Schema(description = "Device registration timestamp")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last update timestamp
|
||||||
|
*/
|
||||||
|
@Schema(description = "Last update timestamp")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor
|
||||||
|
*/
|
||||||
|
public DeviceResponse() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor with basic device information
|
||||||
|
*
|
||||||
|
* @param id Device ID
|
||||||
|
* @param deviceName Device name
|
||||||
|
* @param deviceType Device type
|
||||||
|
* @param trustStatus Trust status
|
||||||
|
* @param isActive Active status
|
||||||
|
*/
|
||||||
|
public DeviceResponse(String id, String deviceName, String deviceType,
|
||||||
|
String trustStatus, Boolean isActive) {
|
||||||
|
this.id = id;
|
||||||
|
this.deviceName = deviceName;
|
||||||
|
this.deviceType = deviceType;
|
||||||
|
this.trustStatus = trustStatus;
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates DeviceResponse from TrustedDevice entity
|
||||||
|
*
|
||||||
|
* @param device TrustedDevice entity
|
||||||
|
* @return DeviceResponse instance
|
||||||
|
*/
|
||||||
|
public static DeviceResponse fromEntity(TrustedDevice device) {
|
||||||
|
if (device == null) return null;
|
||||||
|
|
||||||
|
DeviceResponse response = new DeviceResponse();
|
||||||
|
response.setId(device.getId().toString());
|
||||||
|
response.setDeviceName(device.getDeviceName());
|
||||||
|
response.setDeviceType(device.getDeviceType());
|
||||||
|
response.setOperatingSystem(device.getOperatingSystem());
|
||||||
|
response.setBrowser(device.getBrowser());
|
||||||
|
response.setLocation(device.getLocation());
|
||||||
|
response.setLastIpAddress(device.getLastIpAddress());
|
||||||
|
response.setTrustStatus(device.getTrustStatus());
|
||||||
|
response.setTrustLevel(device.getTrustLevel());
|
||||||
|
response.setIsActive(device.getIsActive());
|
||||||
|
response.setTrustedAt(device.getTrustedAt());
|
||||||
|
response.setLastSeenAt(device.getLastSeenAt());
|
||||||
|
response.setExpiresAt(device.getExpiresAt());
|
||||||
|
response.setLoginCount(device.getLoginCount());
|
||||||
|
response.setFailedAttempts(device.getFailedAttempts());
|
||||||
|
response.setRiskLevel(device.getRiskLevel().name());
|
||||||
|
response.setCreatedAt(device.getCreatedAt());
|
||||||
|
response.setUpdatedAt(device.getUpdatedAt());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a summary DeviceResponse with minimal information
|
||||||
|
*
|
||||||
|
* @param device TrustedDevice entity
|
||||||
|
* @return Summary DeviceResponse instance
|
||||||
|
*/
|
||||||
|
public static DeviceResponse summary(TrustedDevice device) {
|
||||||
|
if (device == null) return null;
|
||||||
|
|
||||||
|
DeviceResponse response = new DeviceResponse();
|
||||||
|
response.setId(device.getId().toString());
|
||||||
|
response.setDeviceName(device.getDeviceName());
|
||||||
|
response.setDeviceType(device.getDeviceType());
|
||||||
|
response.setBrowser(device.getBrowser());
|
||||||
|
response.setLocation(device.getLocation());
|
||||||
|
response.setTrustStatus(device.getTrustStatus());
|
||||||
|
response.setIsActive(device.getIsActive());
|
||||||
|
response.setLastSeenAt(device.getLastSeenAt());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if device is trusted
|
||||||
|
*
|
||||||
|
* @return true if device is trusted
|
||||||
|
*/
|
||||||
|
public boolean isTrusted() {
|
||||||
|
return "TRUSTED".equals(trustStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if device trust is expired
|
||||||
|
*
|
||||||
|
* @return true if device trust has expired
|
||||||
|
*/
|
||||||
|
public boolean isExpired() {
|
||||||
|
return expiresAt != null && LocalDateTime.now().isAfter(expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if device has high trust level
|
||||||
|
*
|
||||||
|
* @return true if trust level is >= 80
|
||||||
|
*/
|
||||||
|
public boolean hasHighTrust() {
|
||||||
|
return trustLevel != null && trustLevel >= 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if device has security concerns
|
||||||
|
*
|
||||||
|
* @return true if risk level is HIGH
|
||||||
|
*/
|
||||||
|
public boolean hasSecurityConcerns() {
|
||||||
|
return "HIGH".equals(riskLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets days until expiration
|
||||||
|
*
|
||||||
|
* @return days until device trust expires, null if no expiration
|
||||||
|
*/
|
||||||
|
public Long getDaysUntilExpiration() {
|
||||||
|
if (expiresAt == null) return null;
|
||||||
|
return java.time.temporal.ChronoUnit.DAYS.between(LocalDateTime.now(), expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceName() {
|
||||||
|
return deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceName(String deviceName) {
|
||||||
|
this.deviceName = deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceType() {
|
||||||
|
return deviceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceType(String deviceType) {
|
||||||
|
this.deviceType = deviceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOperatingSystem() {
|
||||||
|
return operatingSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOperatingSystem(String operatingSystem) {
|
||||||
|
this.operatingSystem = operatingSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBrowser() {
|
||||||
|
return browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBrowser(String browser) {
|
||||||
|
this.browser = browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocation() {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLocation(String location) {
|
||||||
|
this.location = location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLastIpAddress() {
|
||||||
|
return lastIpAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastIpAddress(String lastIpAddress) {
|
||||||
|
this.lastIpAddress = lastIpAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTrustStatus() {
|
||||||
|
return trustStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustStatus(String trustStatus) {
|
||||||
|
this.trustStatus = trustStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTrustLevel() {
|
||||||
|
return trustLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustLevel(Integer trustLevel) {
|
||||||
|
this.trustLevel = trustLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Boolean isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsCurrent() {
|
||||||
|
return isCurrent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsCurrent(Boolean isCurrent) {
|
||||||
|
this.isCurrent = isCurrent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getTrustedAt() {
|
||||||
|
return trustedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustedAt(LocalDateTime trustedAt) {
|
||||||
|
this.trustedAt = trustedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastSeenAt() {
|
||||||
|
return lastSeenAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastSeenAt(LocalDateTime lastSeenAt) {
|
||||||
|
this.lastSeenAt = lastSeenAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getExpiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLoginCount() {
|
||||||
|
return loginCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLoginCount(Integer loginCount) {
|
||||||
|
this.loginCount = loginCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getFailedAttempts() {
|
||||||
|
return failedAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFailedAttempts(Integer failedAttempts) {
|
||||||
|
this.failedAttempts = failedAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRiskLevel() {
|
||||||
|
return riskLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRiskLevel(String riskLevel) {
|
||||||
|
this.riskLevel = riskLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "DeviceResponse{" +
|
||||||
|
"id='" + id + '\'' +
|
||||||
|
", deviceName='" + deviceName + '\'' +
|
||||||
|
", deviceType='" + deviceType + '\'' +
|
||||||
|
", trustStatus='" + trustStatus + '\'' +
|
||||||
|
", isActive=" + isActive +
|
||||||
|
", trustLevel=" + trustLevel +
|
||||||
|
", riskLevel='" + riskLevel + '\'' +
|
||||||
|
", lastSeenAt=" + lastSeenAt +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.company.auth.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Schema(description = "Security status information response")
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class SecurityStatusResponse {
|
||||||
|
|
||||||
|
@Schema(description = "Overall security score (0-100)")
|
||||||
|
private Integer securityScore;
|
||||||
|
|
||||||
|
@Schema(description = "Whether 2FA is enabled")
|
||||||
|
private Boolean twoFactorEnabled;
|
||||||
|
|
||||||
|
@Schema(description = "Whether email is verified")
|
||||||
|
private Boolean emailVerified;
|
||||||
|
|
||||||
|
@Schema(description = "Whether phone is verified")
|
||||||
|
private Boolean phoneVerified;
|
||||||
|
|
||||||
|
@Schema(description = "Number of trusted devices")
|
||||||
|
private Integer trustedDevicesCount;
|
||||||
|
|
||||||
|
@Schema(description = "Number of active devices")
|
||||||
|
private Integer activeDevicesCount;
|
||||||
|
|
||||||
|
@Schema(description = "Last password change timestamp")
|
||||||
|
private LocalDateTime passwordChangedAt;
|
||||||
|
|
||||||
|
@Schema(description = "Last security update timestamp")
|
||||||
|
private LocalDateTime lastSecurityUpdate;
|
||||||
|
|
||||||
|
@Schema(description = "Account status")
|
||||||
|
private String accountStatus;
|
||||||
|
|
||||||
|
@Schema(description = "Risk level assessment")
|
||||||
|
private String riskLevel;
|
||||||
|
|
||||||
|
public SecurityStatusResponse() {}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public Integer getSecurityScore() { return securityScore; }
|
||||||
|
public void setSecurityScore(Integer securityScore) { this.securityScore = securityScore; }
|
||||||
|
|
||||||
|
public Boolean getTwoFactorEnabled() { return twoFactorEnabled; }
|
||||||
|
public void setTwoFactorEnabled(Boolean twoFactorEnabled) { this.twoFactorEnabled = twoFactorEnabled; }
|
||||||
|
|
||||||
|
public Boolean getEmailVerified() { return emailVerified; }
|
||||||
|
public void setEmailVerified(Boolean emailVerified) { this.emailVerified = emailVerified; }
|
||||||
|
|
||||||
|
public Boolean getPhoneVerified() { return phoneVerified; }
|
||||||
|
public void setPhoneVerified(Boolean phoneVerified) { this.phoneVerified = phoneVerified; }
|
||||||
|
|
||||||
|
public Integer getTrustedDevicesCount() { return trustedDevicesCount; }
|
||||||
|
public void setTrustedDevicesCount(Integer trustedDevicesCount) { this.trustedDevicesCount = trustedDevicesCount; }
|
||||||
|
|
||||||
|
public Integer getActiveDevicesCount() { return activeDevicesCount; }
|
||||||
|
public void setActiveDevicesCount(Integer activeDevicesCount) { this.activeDevicesCount = activeDevicesCount; }
|
||||||
|
|
||||||
|
public LocalDateTime getPasswordChangedAt() { return passwordChangedAt; }
|
||||||
|
public void setPasswordChangedAt(LocalDateTime passwordChangedAt) { this.passwordChangedAt = passwordChangedAt; }
|
||||||
|
|
||||||
|
public LocalDateTime getLastSecurityUpdate() { return lastSecurityUpdate; }
|
||||||
|
public void setLastSecurityUpdate(LocalDateTime lastSecurityUpdate) { this.lastSecurityUpdate = lastSecurityUpdate; }
|
||||||
|
|
||||||
|
public String getAccountStatus() { return accountStatus; }
|
||||||
|
public void setAccountStatus(String accountStatus) { this.accountStatus = accountStatus; }
|
||||||
|
|
||||||
|
public String getRiskLevel() { return riskLevel; }
|
||||||
|
public void setRiskLevel(String riskLevel) { this.riskLevel = riskLevel; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.company.auth.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Schema(description = "User profile information response")
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class UserProfileResponse {
|
||||||
|
|
||||||
|
@Schema(description = "User's unique identifier")
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@Schema(description = "User's full name")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Schema(description = "User's email address")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "User's phone number")
|
||||||
|
private String phoneNumber;
|
||||||
|
|
||||||
|
@Schema(description = "User's preferred language")
|
||||||
|
private String language;
|
||||||
|
|
||||||
|
@Schema(description = "User's timezone")
|
||||||
|
private String timezone;
|
||||||
|
|
||||||
|
@Schema(description = "Whether email is verified")
|
||||||
|
private Boolean emailVerified;
|
||||||
|
|
||||||
|
@Schema(description = "Whether phone is verified")
|
||||||
|
private Boolean phoneVerified;
|
||||||
|
|
||||||
|
@Schema(description = "Whether 2FA is enabled")
|
||||||
|
private Boolean twoFactorEnabled;
|
||||||
|
|
||||||
|
@Schema(description = "Profile picture URL")
|
||||||
|
private String avatarUrl;
|
||||||
|
|
||||||
|
@Schema(description = "Marketing email preference")
|
||||||
|
private Boolean marketingOptIn;
|
||||||
|
|
||||||
|
@Schema(description = "Last profile update timestamp")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public UserProfileResponse() {}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
public String getId() { return id; }
|
||||||
|
public void setId(String id) { this.id = id; }
|
||||||
|
|
||||||
|
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 getPhoneNumber() { return phoneNumber; }
|
||||||
|
public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; }
|
||||||
|
|
||||||
|
public String getLanguage() { return language; }
|
||||||
|
public void setLanguage(String language) { this.language = language; }
|
||||||
|
|
||||||
|
public String getTimezone() { return timezone; }
|
||||||
|
public void setTimezone(String timezone) { this.timezone = timezone; }
|
||||||
|
|
||||||
|
public Boolean getEmailVerified() { return emailVerified; }
|
||||||
|
public void setEmailVerified(Boolean emailVerified) { this.emailVerified = emailVerified; }
|
||||||
|
|
||||||
|
public Boolean getPhoneVerified() { return phoneVerified; }
|
||||||
|
public void setPhoneVerified(Boolean phoneVerified) { this.phoneVerified = phoneVerified; }
|
||||||
|
|
||||||
|
public Boolean getTwoFactorEnabled() { return twoFactorEnabled; }
|
||||||
|
public void setTwoFactorEnabled(Boolean twoFactorEnabled) { this.twoFactorEnabled = twoFactorEnabled; }
|
||||||
|
|
||||||
|
public String getAvatarUrl() { return avatarUrl; }
|
||||||
|
public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; }
|
||||||
|
|
||||||
|
public Boolean getMarketingOptIn() { return marketingOptIn; }
|
||||||
|
public void setMarketingOptIn(Boolean marketingOptIn) { this.marketingOptIn = marketingOptIn; }
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
461
src/main/java/com/company/auth/dto/response/UserResponse.java
Normal file
461
src/main/java/com/company/auth/dto/response/UserResponse.java
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
/**
|
||||||
|
* User Response DTO
|
||||||
|
*
|
||||||
|
* Data transfer object for user information responses.
|
||||||
|
* Contains safe user data for client consumption,
|
||||||
|
* excluding sensitive information like passwords.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.company.auth.entity.User;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for user information
|
||||||
|
*
|
||||||
|
* Provides safe user data for API responses,
|
||||||
|
* excluding sensitive fields like passwords and tokens.
|
||||||
|
*/
|
||||||
|
@Schema(description = "User information response data")
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class UserResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's unique identifier
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's unique identifier",
|
||||||
|
example = "123e4567-e89b-12d3-a456-426614174000")
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's full name
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's full name",
|
||||||
|
example = "John Doe")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's email address
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's email address",
|
||||||
|
example = "john.doe@example.com")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's phone number
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's phone number",
|
||||||
|
example = "+1234567890")
|
||||||
|
private String phoneNumber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's preferred language
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's preferred language",
|
||||||
|
example = "en-US")
|
||||||
|
private String language;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's timezone
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's timezone",
|
||||||
|
example = "America/New_York")
|
||||||
|
private String timezone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether user's email is verified
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether user's email is verified",
|
||||||
|
example = "true")
|
||||||
|
private Boolean emailVerified;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether user's phone is verified
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether user's phone is verified",
|
||||||
|
example = "false")
|
||||||
|
private Boolean phoneVerified;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether two-factor authentication is enabled
|
||||||
|
*/
|
||||||
|
@Schema(description = "Whether 2FA is enabled",
|
||||||
|
example = "true")
|
||||||
|
private Boolean twoFactorEnabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's account status
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's account status",
|
||||||
|
example = "ACTIVE")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's role in the system
|
||||||
|
*/
|
||||||
|
@Schema(description = "User's role",
|
||||||
|
example = "USER")
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account creation timestamp
|
||||||
|
*/
|
||||||
|
@Schema(description = "Account creation timestamp")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last profile update timestamp
|
||||||
|
*/
|
||||||
|
@Schema(description = "Last profile update timestamp")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last login timestamp
|
||||||
|
*/
|
||||||
|
@Schema(description = "Last login timestamp")
|
||||||
|
private LocalDateTime lastLoginAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of trusted devices
|
||||||
|
*/
|
||||||
|
@Schema(description = "Number of trusted devices",
|
||||||
|
example = "3")
|
||||||
|
private Integer trustedDevicesCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security score (0-100)
|
||||||
|
*/
|
||||||
|
@Schema(description = "Account security score",
|
||||||
|
example = "85")
|
||||||
|
private Integer securityScore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether marketing emails are accepted
|
||||||
|
*/
|
||||||
|
@Schema(description = "Marketing email preference",
|
||||||
|
example = "false")
|
||||||
|
private Boolean marketingOptIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile picture URL
|
||||||
|
*/
|
||||||
|
@Schema(description = "Profile picture URL")
|
||||||
|
private String avatarUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's registration source
|
||||||
|
*/
|
||||||
|
@Schema(description = "Registration source",
|
||||||
|
example = "web")
|
||||||
|
private String registrationSource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last password change timestamp
|
||||||
|
*/
|
||||||
|
@Schema(description = "Last password change timestamp")
|
||||||
|
private LocalDateTime passwordChangedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor
|
||||||
|
*/
|
||||||
|
public UserResponse() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor with basic user information
|
||||||
|
*
|
||||||
|
* @param id User ID
|
||||||
|
* @param name User name
|
||||||
|
* @param email User email
|
||||||
|
* @param emailVerified Email verification status
|
||||||
|
* @param twoFactorEnabled 2FA status
|
||||||
|
*/
|
||||||
|
public UserResponse(String id, String name, String email,
|
||||||
|
Boolean emailVerified, Boolean twoFactorEnabled) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.email = email;
|
||||||
|
this.emailVerified = emailVerified;
|
||||||
|
this.twoFactorEnabled = twoFactorEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates UserResponse from User entity
|
||||||
|
*
|
||||||
|
* @param user User entity
|
||||||
|
* @return UserResponse instance
|
||||||
|
*/
|
||||||
|
public static UserResponse fromEntity(User user) {
|
||||||
|
if (user == null) return null;
|
||||||
|
|
||||||
|
UserResponse response = new UserResponse();
|
||||||
|
response.setId(user.getId().toString());
|
||||||
|
response.setName(user.getName());
|
||||||
|
response.setEmail(user.getEmail());
|
||||||
|
response.setPhoneNumber(user.getPhoneNumber());
|
||||||
|
response.setLanguage(user.getLanguage());
|
||||||
|
response.setTimezone(user.getTimezone());
|
||||||
|
response.setEmailVerified(user.getEmailVerified());
|
||||||
|
response.setPhoneVerified(user.getPhoneVerified());
|
||||||
|
response.setTwoFactorEnabled(user.getTwoFactorEnabled());
|
||||||
|
response.setStatus(user.getStatus());
|
||||||
|
response.setRole(user.getRole());
|
||||||
|
response.setCreatedAt(user.getCreatedAt());
|
||||||
|
response.setUpdatedAt(user.getUpdatedAt());
|
||||||
|
response.setLastLoginAt(user.getLastLoginAt());
|
||||||
|
response.setMarketingOptIn(user.getMarketingOptIn());
|
||||||
|
response.setAvatarUrl(user.getAvatarUrl());
|
||||||
|
response.setRegistrationSource(user.getRegistrationSource());
|
||||||
|
response.setPasswordChangedAt(user.getPasswordChangedAt());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a minimal UserResponse with basic information
|
||||||
|
*
|
||||||
|
* @param user User entity
|
||||||
|
* @return Minimal UserResponse instance
|
||||||
|
*/
|
||||||
|
public static UserResponse minimal(User user) {
|
||||||
|
if (user == null) return null;
|
||||||
|
|
||||||
|
UserResponse response = new UserResponse();
|
||||||
|
response.setId(user.getId().toString());
|
||||||
|
response.setName(user.getName());
|
||||||
|
response.setEmail(user.getEmail());
|
||||||
|
response.setEmailVerified(user.getEmailVerified());
|
||||||
|
response.setTwoFactorEnabled(user.getTwoFactorEnabled());
|
||||||
|
response.setStatus(user.getStatus());
|
||||||
|
response.setRole(user.getRole());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a public UserResponse with limited information
|
||||||
|
*
|
||||||
|
* @param user User entity
|
||||||
|
* @return Public UserResponse instance
|
||||||
|
*/
|
||||||
|
public static UserResponse publicInfo(User user) {
|
||||||
|
if (user == null) return null;
|
||||||
|
|
||||||
|
UserResponse response = new UserResponse();
|
||||||
|
response.setId(user.getId().toString());
|
||||||
|
response.setName(user.getName());
|
||||||
|
response.setAvatarUrl(user.getAvatarUrl());
|
||||||
|
response.setCreatedAt(user.getCreatedAt());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the user account is fully verified
|
||||||
|
*
|
||||||
|
* @return true if both email and phone are verified
|
||||||
|
*/
|
||||||
|
public boolean isFullyVerified() {
|
||||||
|
return Boolean.TRUE.equals(emailVerified) && Boolean.TRUE.equals(phoneVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the account is active
|
||||||
|
*
|
||||||
|
* @return true if status is ACTIVE
|
||||||
|
*/
|
||||||
|
public boolean isActive() {
|
||||||
|
return "ACTIVE".equals(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the account has good security
|
||||||
|
*
|
||||||
|
* @return true if security score is >= 70
|
||||||
|
*/
|
||||||
|
public boolean hasGoodSecurity() {
|
||||||
|
return securityScore != null && securityScore >= 70;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getPhoneNumber() {
|
||||||
|
return phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhoneNumber(String phoneNumber) {
|
||||||
|
this.phoneNumber = phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLanguage() {
|
||||||
|
return language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLanguage(String language) {
|
||||||
|
this.language = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTimezone() {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimezone(String timezone) {
|
||||||
|
this.timezone = timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getEmailVerified() {
|
||||||
|
return emailVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEmailVerified(Boolean emailVerified) {
|
||||||
|
this.emailVerified = emailVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getPhoneVerified() {
|
||||||
|
return phoneVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhoneVerified(Boolean phoneVerified) {
|
||||||
|
this.phoneVerified = phoneVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getTwoFactorEnabled() {
|
||||||
|
return twoFactorEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTwoFactorEnabled(Boolean twoFactorEnabled) {
|
||||||
|
this.twoFactorEnabled = twoFactorEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastLoginAt() {
|
||||||
|
return lastLoginAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastLoginAt(LocalDateTime lastLoginAt) {
|
||||||
|
this.lastLoginAt = lastLoginAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTrustedDevicesCount() {
|
||||||
|
return trustedDevicesCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustedDevicesCount(Integer trustedDevicesCount) {
|
||||||
|
this.trustedDevicesCount = trustedDevicesCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSecurityScore() {
|
||||||
|
return securityScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSecurityScore(Integer securityScore) {
|
||||||
|
this.securityScore = securityScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getMarketingOptIn() {
|
||||||
|
return marketingOptIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMarketingOptIn(Boolean marketingOptIn) {
|
||||||
|
this.marketingOptIn = marketingOptIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvatarUrl() {
|
||||||
|
return avatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvatarUrl(String avatarUrl) {
|
||||||
|
this.avatarUrl = avatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRegistrationSource() {
|
||||||
|
return registrationSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRegistrationSource(String registrationSource) {
|
||||||
|
this.registrationSource = registrationSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getPasswordChangedAt() {
|
||||||
|
return passwordChangedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordChangedAt(LocalDateTime passwordChangedAt) {
|
||||||
|
this.passwordChangedAt = passwordChangedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "UserResponse{" +
|
||||||
|
"id='" + id + '\'' +
|
||||||
|
", name='" + name + '\'' +
|
||||||
|
", email='" + email + '\'' +
|
||||||
|
", emailVerified=" + emailVerified +
|
||||||
|
", twoFactorEnabled=" + twoFactorEnabled +
|
||||||
|
", status='" + status + '\'' +
|
||||||
|
", role='" + role + '\'' +
|
||||||
|
", trustedDevicesCount=" + trustedDevicesCount +
|
||||||
|
", securityScore=" + securityScore +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
738
src/main/java/com/company/auth/entity/TrustedDevice.java
Normal file
738
src/main/java/com/company/auth/entity/TrustedDevice.java
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
/**
|
||||||
|
* Trusted Device Entity
|
||||||
|
*
|
||||||
|
* JPA entity representing trusted devices for risk-based authentication.
|
||||||
|
* Stores device fingerprints and metadata to enable device-level security
|
||||||
|
* and reduce the need for repeated two-factor authentication.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.DecimalMax;
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TrustedDevice entity for device-based authentication
|
||||||
|
*
|
||||||
|
* This entity stores device fingerprints and associated metadata to enable
|
||||||
|
* risk-based authentication. When a device is trusted, users can authenticate
|
||||||
|
* without additional verification steps, improving user experience while
|
||||||
|
* maintaining security.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "trusted_devices",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "unique_user_fingerprint",
|
||||||
|
columnNames = {"user_id", "fingerprint_hash"})
|
||||||
|
},
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_trusted_devices_fingerprint", columnList = "fingerprintHash"),
|
||||||
|
@Index(name = "idx_trusted_devices_user_active", columnList = "user_id, isActive"),
|
||||||
|
@Index(name = "idx_trusted_devices_last_used", columnList = "lastUsedAt"),
|
||||||
|
@Index(name = "idx_trusted_devices_risk_score", columnList = "riskScore"),
|
||||||
|
@Index(name = "idx_trusted_devices_created", columnList = "createdAt"),
|
||||||
|
@Index(name = "idx_trusted_devices_cleanup", columnList = "isActive, lastUsedAt, createdAt")
|
||||||
|
})
|
||||||
|
public class TrustedDevice {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary key for the trusted device record
|
||||||
|
*/
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User who owns this trusted device
|
||||||
|
*
|
||||||
|
* Many-to-one relationship with User entity.
|
||||||
|
* Each user can have multiple trusted devices.
|
||||||
|
*/
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
@NotNull(message = "User is required")
|
||||||
|
@JsonIgnore
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreign key reference to user ID
|
||||||
|
*
|
||||||
|
* Stored separately for efficient queries and indexing.
|
||||||
|
*/
|
||||||
|
@Column(name = "user_id", insertable = false, updatable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique device fingerprint hash
|
||||||
|
*
|
||||||
|
* SHA-256 hash of the device fingerprint data.
|
||||||
|
* Used for efficient device matching and lookup.
|
||||||
|
*/
|
||||||
|
@Column(name = "fingerprint_hash", nullable = false, length = 255)
|
||||||
|
@NotBlank(message = "Fingerprint hash is required")
|
||||||
|
private String fingerprintHash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-friendly device name
|
||||||
|
*
|
||||||
|
* Optional name provided by user or generated from device characteristics
|
||||||
|
* (e.g., "John's MacBook Pro", "Chrome on Windows").
|
||||||
|
*/
|
||||||
|
@Column(name = "device_name", length = 100)
|
||||||
|
private String deviceName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete device fingerprint data as JSON
|
||||||
|
*
|
||||||
|
* Stores the full fingerprint information including browser characteristics,
|
||||||
|
* screen resolution, timezone, and other identifying features.
|
||||||
|
*/
|
||||||
|
@Column(name = "device_info", nullable = false, columnDefinition = "JSON")
|
||||||
|
@NotBlank(message = "Device info is required")
|
||||||
|
private String deviceInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerprint confidence score (0.00 to 1.00)
|
||||||
|
*
|
||||||
|
* Indicates the reliability and uniqueness of the device fingerprint.
|
||||||
|
* Higher scores indicate more reliable device identification.
|
||||||
|
*/
|
||||||
|
@Column(name = "confidence_score", nullable = false, precision = 3, scale = 2)
|
||||||
|
@NotNull(message = "Confidence score is required")
|
||||||
|
@DecimalMin(value = "0.00", message = "Confidence score must be at least 0.00")
|
||||||
|
@DecimalMax(value = "1.00", message = "Confidence score must not exceed 1.00")
|
||||||
|
private BigDecimal confidenceScore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current risk assessment score (0.00 to 1.00)
|
||||||
|
*
|
||||||
|
* Dynamic risk score based on usage patterns, location changes,
|
||||||
|
* and behavioral analysis. Updated with each authentication.
|
||||||
|
*/
|
||||||
|
@Column(name = "risk_score", precision = 3, scale = 2)
|
||||||
|
@DecimalMin(value = "0.00", message = "Risk score must be at least 0.00")
|
||||||
|
@DecimalMax(value = "1.00", message = "Risk score must not exceed 1.00")
|
||||||
|
private BigDecimal riskScore = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device active status
|
||||||
|
*
|
||||||
|
* Determines if the device is currently trusted and can be used
|
||||||
|
* for authentication. Inactive devices require re-verification.
|
||||||
|
*/
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private Boolean isActive = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last authentication timestamp
|
||||||
|
*
|
||||||
|
* Records when this device was last used for authentication.
|
||||||
|
* Used for device cleanup and activity monitoring.
|
||||||
|
*/
|
||||||
|
@Column(name = "last_used_at")
|
||||||
|
private LocalDateTime lastUsedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total authentication count
|
||||||
|
*
|
||||||
|
* Tracks how many times this device has been used for authentication.
|
||||||
|
* Used for usage pattern analysis and risk assessment.
|
||||||
|
*/
|
||||||
|
@Column(name = "usage_count", nullable = false)
|
||||||
|
private Integer usageCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last known IP address
|
||||||
|
*
|
||||||
|
* Stores the most recent IP address used with this device.
|
||||||
|
* Supports both IPv4 and IPv6 addresses for location tracking.
|
||||||
|
*/
|
||||||
|
@Column(name = "ip_address", length = 45)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last known user agent string
|
||||||
|
*
|
||||||
|
* Browser and OS information from the most recent authentication.
|
||||||
|
* Used for device change detection and security monitoring.
|
||||||
|
*/
|
||||||
|
@Column(name = "user_agent", columnDefinition = "TEXT")
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geographic location information as JSON
|
||||||
|
*
|
||||||
|
* Optional location data including country, city, and coordinates.
|
||||||
|
* Used for location-based risk assessment and anomaly detection.
|
||||||
|
*/
|
||||||
|
@Column(name = "location_info", columnDefinition = "JSON")
|
||||||
|
private String locationInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security flags and metadata as JSON
|
||||||
|
*
|
||||||
|
* Stores security-related information such as:
|
||||||
|
* - Suspicious activity flags
|
||||||
|
* - Security events
|
||||||
|
* - Risk assessment history
|
||||||
|
* - Behavioral analysis data
|
||||||
|
*/
|
||||||
|
@Column(name = "security_flags", columnDefinition = "JSON")
|
||||||
|
private String securityFlags;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device registration timestamp
|
||||||
|
*
|
||||||
|
* Automatically set when the device is first registered as trusted.
|
||||||
|
* Used for device age calculations and audit purposes.
|
||||||
|
*/
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device type (e.g., "desktop", "mobile", "tablet")
|
||||||
|
*/
|
||||||
|
@Column(name = "device_type", length = 50)
|
||||||
|
private String deviceType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operating system information
|
||||||
|
*/
|
||||||
|
@Column(name = "operating_system", length = 100)
|
||||||
|
private String operatingSystem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser information
|
||||||
|
*/
|
||||||
|
@Column(name = "browser", length = 100)
|
||||||
|
private String browser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device location (city, country)
|
||||||
|
*/
|
||||||
|
@Column(name = "location", length = 200)
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last IP address used
|
||||||
|
*/
|
||||||
|
@Column(name = "last_ip_address", length = 45)
|
||||||
|
private String lastIpAddress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trust status
|
||||||
|
*/
|
||||||
|
@Column(name = "trust_status", length = 20)
|
||||||
|
private String trustStatus = "PENDING";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trust level (0-100)
|
||||||
|
*/
|
||||||
|
@Column(name = "trust_level")
|
||||||
|
private Integer trustLevel = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When device was trusted
|
||||||
|
*/
|
||||||
|
@Column(name = "trusted_at")
|
||||||
|
private LocalDateTime trustedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device verification code for setup
|
||||||
|
*/
|
||||||
|
@Column(name = "verification_code", length = 10)
|
||||||
|
private String verificationCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When verification code expires
|
||||||
|
*/
|
||||||
|
@Column(name = "verification_expires_at")
|
||||||
|
private LocalDateTime verificationExpiresAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last verification timestamp
|
||||||
|
*/
|
||||||
|
@Column(name = "last_verified_at")
|
||||||
|
private LocalDateTime lastVerifiedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last seen timestamp
|
||||||
|
*/
|
||||||
|
@Column(name = "last_seen_at")
|
||||||
|
private LocalDateTime lastSeenAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device expiration date
|
||||||
|
*/
|
||||||
|
@Column(name = "expires_at")
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login count for this device
|
||||||
|
*/
|
||||||
|
@Column(name = "login_count", nullable = false)
|
||||||
|
private Integer loginCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failed attempts counter
|
||||||
|
*/
|
||||||
|
@Column(name = "failed_attempts", nullable = false)
|
||||||
|
private Integer failedAttempts = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Risk level for this device
|
||||||
|
*/
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "risk_level", length = 20)
|
||||||
|
private com.company.auth.util.RiskLevel riskLevel = com.company.auth.util.RiskLevel.LOW;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device removal timestamp
|
||||||
|
*/
|
||||||
|
@Column(name = "removed_at")
|
||||||
|
private LocalDateTime removedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether device is trusted
|
||||||
|
*/
|
||||||
|
@Column(name = "is_trusted", nullable = false)
|
||||||
|
private Boolean isTrusted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last update timestamp
|
||||||
|
*
|
||||||
|
* Automatically updated whenever the device record is modified.
|
||||||
|
* Used for tracking device changes and audit purposes.
|
||||||
|
*/
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JPA
|
||||||
|
*/
|
||||||
|
public TrustedDevice() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for creating trusted device with basic information
|
||||||
|
*
|
||||||
|
* @param user Device owner
|
||||||
|
* @param fingerprintHash Device fingerprint hash
|
||||||
|
* @param deviceInfo Complete device information as JSON
|
||||||
|
* @param confidenceScore Fingerprint confidence score
|
||||||
|
*/
|
||||||
|
public TrustedDevice(User user, String fingerprintHash, String deviceInfo, BigDecimal confidenceScore) {
|
||||||
|
this.user = user;
|
||||||
|
this.fingerprintHash = fingerprintHash;
|
||||||
|
this.deviceInfo = deviceInfo;
|
||||||
|
this.confidenceScore = confidenceScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update device usage statistics
|
||||||
|
*
|
||||||
|
* Called when the device is used for authentication.
|
||||||
|
* Updates usage count, last used timestamp, and IP address.
|
||||||
|
*
|
||||||
|
* @param ipAddress Current IP address
|
||||||
|
* @param userAgent Current user agent
|
||||||
|
*/
|
||||||
|
public void updateUsage(String ipAddress, String userAgent) {
|
||||||
|
this.usageCount++;
|
||||||
|
this.lastUsedAt = LocalDateTime.now();
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
this.userAgent = userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if device is considered stale and needs cleanup
|
||||||
|
*
|
||||||
|
* A device is stale if it hasn't been used recently or has been
|
||||||
|
* inactive for an extended period.
|
||||||
|
*
|
||||||
|
* @param maxIdleDays Maximum days without usage before considering stale
|
||||||
|
* @return true if device should be cleaned up
|
||||||
|
*/
|
||||||
|
public boolean isStale(int maxIdleDays) {
|
||||||
|
if (lastUsedAt == null) {
|
||||||
|
// Device never used, check creation date
|
||||||
|
return createdAt.isBefore(LocalDateTime.now().minusDays(maxIdleDays));
|
||||||
|
}
|
||||||
|
return lastUsedAt.isBefore(LocalDateTime.now().minusDays(maxIdleDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate device age in days
|
||||||
|
*
|
||||||
|
* @return Number of days since device was registered
|
||||||
|
*/
|
||||||
|
public long getDeviceAgeInDays() {
|
||||||
|
return java.time.temporal.ChronoUnit.DAYS.between(createdAt, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if device risk score is above threshold
|
||||||
|
*
|
||||||
|
* @param threshold Risk threshold (0.0 to 1.0)
|
||||||
|
* @return true if device risk exceeds threshold
|
||||||
|
*/
|
||||||
|
public boolean isHighRisk(BigDecimal threshold) {
|
||||||
|
return riskScore != null && riskScore.compareTo(threshold) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(User user) {
|
||||||
|
this.user = user;
|
||||||
|
this.userId = user != null ? user.getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFingerprintHash() {
|
||||||
|
return fingerprintHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFingerprintHash(String fingerprintHash) {
|
||||||
|
this.fingerprintHash = fingerprintHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceName() {
|
||||||
|
return deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceName(String deviceName) {
|
||||||
|
this.deviceName = deviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceInfo() {
|
||||||
|
return deviceInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceInfo(String deviceInfo) {
|
||||||
|
this.deviceInfo = deviceInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getConfidenceScore() {
|
||||||
|
return confidenceScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConfidenceScore(BigDecimal confidenceScore) {
|
||||||
|
this.confidenceScore = confidenceScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getRiskScore() {
|
||||||
|
return riskScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRiskScore(BigDecimal riskScore) {
|
||||||
|
this.riskScore = riskScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Boolean isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastUsedAt() {
|
||||||
|
return lastUsedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastUsedAt(LocalDateTime lastUsedAt) {
|
||||||
|
this.lastUsedAt = lastUsedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getUsageCount() {
|
||||||
|
return usageCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUsageCount(Integer usageCount) {
|
||||||
|
this.usageCount = usageCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIpAddress() {
|
||||||
|
return ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIpAddress(String ipAddress) {
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserAgent() {
|
||||||
|
return userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserAgent(String userAgent) {
|
||||||
|
this.userAgent = userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocationInfo() {
|
||||||
|
return locationInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLocationInfo(String locationInfo) {
|
||||||
|
this.locationInfo = locationInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSecurityFlags() {
|
||||||
|
return securityFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSecurityFlags(String securityFlags) {
|
||||||
|
this.securityFlags = securityFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional getters and setters for new fields
|
||||||
|
|
||||||
|
public String getDeviceType() {
|
||||||
|
return deviceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceType(String deviceType) {
|
||||||
|
this.deviceType = deviceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOperatingSystem() {
|
||||||
|
return operatingSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOperatingSystem(String operatingSystem) {
|
||||||
|
this.operatingSystem = operatingSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBrowser() {
|
||||||
|
return browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBrowser(String browser) {
|
||||||
|
this.browser = browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLocation() {
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLocation(String location) {
|
||||||
|
this.location = location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLastIpAddress() {
|
||||||
|
return lastIpAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastIpAddress(String lastIpAddress) {
|
||||||
|
this.lastIpAddress = lastIpAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTrustStatus() {
|
||||||
|
return trustStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustStatus(String trustStatus) {
|
||||||
|
this.trustStatus = trustStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTrustLevel() {
|
||||||
|
return trustLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustLevel(Integer trustLevel) {
|
||||||
|
this.trustLevel = trustLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getTrustedAt() {
|
||||||
|
return trustedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustedAt(LocalDateTime trustedAt) {
|
||||||
|
this.trustedAt = trustedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastSeenAt() {
|
||||||
|
return lastSeenAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastSeenAt(LocalDateTime lastSeenAt) {
|
||||||
|
this.lastSeenAt = lastSeenAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getExpiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getLoginCount() {
|
||||||
|
return loginCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLoginCount(Integer loginCount) {
|
||||||
|
this.loginCount = loginCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getFailedAttempts() {
|
||||||
|
return failedAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFailedAttempts(Integer failedAttempts) {
|
||||||
|
this.failedAttempts = failedAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public com.company.auth.util.RiskLevel getRiskLevel() {
|
||||||
|
return riskLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRiskLevel(com.company.auth.util.RiskLevel riskLevel) {
|
||||||
|
this.riskLevel = riskLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getRemovedAt() {
|
||||||
|
return removedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRemovedAt(LocalDateTime removedAt) {
|
||||||
|
this.removedAt = removedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsTrusted() {
|
||||||
|
return isTrusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsTrusted(Boolean isTrusted) {
|
||||||
|
this.isTrusted = isTrusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional methods for 2FA and verification functionality
|
||||||
|
public Boolean getTrustedFor2FA() {
|
||||||
|
return isTrusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustedFor2FA(Boolean trustedFor2FA) {
|
||||||
|
this.isTrusted = trustedFor2FA;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getTrustedFor2FAAt() {
|
||||||
|
return trustedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustedFor2FAAt(LocalDateTime trustedFor2FAAt) {
|
||||||
|
this.trustedAt = trustedFor2FAAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getTrustedFor2FAExpiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustedFor2FAExpiresAt(LocalDateTime trustedFor2FAExpiresAt) {
|
||||||
|
this.expiresAt = trustedFor2FAExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVerificationCode() {
|
||||||
|
return verificationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVerificationCode(String verificationCode) {
|
||||||
|
this.verificationCode = verificationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getVerificationCodeExpiresAt() {
|
||||||
|
return verificationExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVerificationCodeExpiresAt(LocalDateTime verificationCodeExpiresAt) {
|
||||||
|
this.verificationExpiresAt = verificationCodeExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getVerificationAttempts() {
|
||||||
|
return failedAttempts != null ? failedAttempts : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVerificationAttempts(Integer verificationAttempts) {
|
||||||
|
this.failedAttempts = verificationAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastVerifiedAt() {
|
||||||
|
return lastVerifiedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastVerifiedAt(LocalDateTime lastVerifiedAt) {
|
||||||
|
this.lastVerifiedAt = lastVerifiedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFingerprintData() {
|
||||||
|
return fingerprintHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFingerprintData(String fingerprintData) {
|
||||||
|
this.fingerprintHash = fingerprintData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getRevokedAt() {
|
||||||
|
return removedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRevokedAt(LocalDateTime revokedAt) {
|
||||||
|
this.removedAt = revokedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "TrustedDevice{" +
|
||||||
|
"id=" + id +
|
||||||
|
", userId=" + userId +
|
||||||
|
", deviceName='" + deviceName + '\'' +
|
||||||
|
", confidenceScore=" + confidenceScore +
|
||||||
|
", riskScore=" + riskScore +
|
||||||
|
", isActive=" + isActive +
|
||||||
|
", usageCount=" + usageCount +
|
||||||
|
", createdAt=" + createdAt +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
500
src/main/java/com/company/auth/entity/TwoFactorVerification.java
Normal file
500
src/main/java/com/company/auth/entity/TwoFactorVerification.java
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
/**
|
||||||
|
* Two Factor Verification Entity
|
||||||
|
*
|
||||||
|
* JPA entity for managing temporary verification codes during device registration.
|
||||||
|
* Handles the complete two-factor authentication workflow including code generation,
|
||||||
|
* validation, and verification lifecycle management.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TwoFactorVerification entity for device registration workflows
|
||||||
|
*
|
||||||
|
* This entity manages temporary verification sessions when users attempt
|
||||||
|
* to register new devices. It stores verification codes, tracks attempts,
|
||||||
|
* and maintains security context for the 2FA process.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "two_factor_verifications",
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_two_factor_user_id", columnList = "user_id"),
|
||||||
|
@Index(name = "idx_two_factor_code_hash", columnList = "codeHash"),
|
||||||
|
@Index(name = "idx_two_factor_expires_at", columnList = "expiresAt"),
|
||||||
|
@Index(name = "idx_two_factor_user_unused", columnList = "user_id, isUsed"),
|
||||||
|
@Index(name = "idx_two_factor_method", columnList = "method"),
|
||||||
|
@Index(name = "idx_two_factor_created", columnList = "createdAt"),
|
||||||
|
@Index(name = "idx_two_factor_cleanup", columnList = "expiresAt, isUsed, createdAt"),
|
||||||
|
@Index(name = "idx_two_factor_active", columnList = "user_id, isUsed, isBlocked, expiresAt")
|
||||||
|
})
|
||||||
|
public class TwoFactorVerification {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration for two-factor verification methods
|
||||||
|
*/
|
||||||
|
public enum Method {
|
||||||
|
SMS("sms"),
|
||||||
|
EMAIL("email"),
|
||||||
|
TOTP("totp");
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
|
||||||
|
Method(String value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary key for the verification record
|
||||||
|
*/
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User requesting device verification
|
||||||
|
*
|
||||||
|
* Many-to-one relationship with User entity.
|
||||||
|
* Links the verification session to the user account.
|
||||||
|
*/
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
@NotNull(message = "User is required")
|
||||||
|
@JsonIgnore
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreign key reference to user ID
|
||||||
|
*
|
||||||
|
* Stored separately for efficient queries and indexing.
|
||||||
|
*/
|
||||||
|
@Column(name = "user_id", insertable = false, updatable = false)
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashed verification code
|
||||||
|
*
|
||||||
|
* SHA-256 hash of the verification code for secure storage.
|
||||||
|
* Plain codes are never stored in the database.
|
||||||
|
*/
|
||||||
|
@Column(name = "code_hash", nullable = false, length = 255)
|
||||||
|
@NotBlank(message = "Code hash is required")
|
||||||
|
private String codeHash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verification delivery method
|
||||||
|
*
|
||||||
|
* Indicates how the verification code was delivered to the user
|
||||||
|
* (SMS, email, or TOTP authenticator app).
|
||||||
|
*/
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "method", nullable = false)
|
||||||
|
@NotNull(message = "Verification method is required")
|
||||||
|
private Method method;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device fingerprint data as JSON
|
||||||
|
*
|
||||||
|
* Stores the complete fingerprint information for the device
|
||||||
|
* being registered. Used to create trusted device after successful verification.
|
||||||
|
*/
|
||||||
|
@Column(name = "device_fingerprint", nullable = false, columnDefinition = "JSON")
|
||||||
|
@NotBlank(message = "Device fingerprint is required")
|
||||||
|
private String deviceFingerprint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client IP address
|
||||||
|
*
|
||||||
|
* IP address from which the verification was initiated.
|
||||||
|
* Used for security monitoring and geographic analysis.
|
||||||
|
*/
|
||||||
|
@Column(name = "ip_address", length = 45)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client user agent string
|
||||||
|
*
|
||||||
|
* Browser and OS information from the verification request.
|
||||||
|
* Used for device identification and security analysis.
|
||||||
|
*/
|
||||||
|
@Column(name = "user_agent", columnDefinition = "TEXT")
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code expiration timestamp
|
||||||
|
*
|
||||||
|
* When the verification code expires and becomes invalid.
|
||||||
|
* Typically set to 5-15 minutes from creation time.
|
||||||
|
*/
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
@NotNull(message = "Expiration time is required")
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of verification attempts
|
||||||
|
*
|
||||||
|
* Tracks how many times the user has attempted to verify the code.
|
||||||
|
* Used to prevent brute force attacks.
|
||||||
|
*/
|
||||||
|
@Column(name = "attempts", nullable = false)
|
||||||
|
private Integer attempts = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum allowed attempts
|
||||||
|
*
|
||||||
|
* Configurable limit for verification attempts before blocking.
|
||||||
|
* Typically set to 3-5 attempts.
|
||||||
|
*/
|
||||||
|
@Column(name = "max_attempts", nullable = false)
|
||||||
|
private Integer maxAttempts = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether code has been successfully used
|
||||||
|
*
|
||||||
|
* Set to true when verification is completed successfully.
|
||||||
|
* Prevents code reuse after successful verification.
|
||||||
|
*/
|
||||||
|
@Column(name = "is_used", nullable = false)
|
||||||
|
private Boolean isUsed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether session is blocked due to too many attempts
|
||||||
|
*
|
||||||
|
* Set to true when maximum attempts are exceeded.
|
||||||
|
* Blocks further verification attempts for this session.
|
||||||
|
*/
|
||||||
|
@Column(name = "is_blocked", nullable = false)
|
||||||
|
private Boolean isBlocked = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When verification was completed successfully
|
||||||
|
*
|
||||||
|
* Timestamp indicating when the code was verified.
|
||||||
|
* Used for audit trail and verification lifecycle tracking.
|
||||||
|
*/
|
||||||
|
@Column(name = "verified_at")
|
||||||
|
private LocalDateTime verifiedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether verification record is active
|
||||||
|
*
|
||||||
|
* Active records can be used for verification.
|
||||||
|
* Inactive records are for historical purposes only.
|
||||||
|
*/
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private Boolean isActive = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When verification was disabled
|
||||||
|
*
|
||||||
|
* Timestamp when the verification was deactivated.
|
||||||
|
* Used for audit trail and cleanup operations.
|
||||||
|
*/
|
||||||
|
@Column(name = "disabled_at")
|
||||||
|
private LocalDateTime disabledAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain verification code (temporary, not stored)
|
||||||
|
*
|
||||||
|
* Used temporarily during verification process.
|
||||||
|
* This field is never persisted to database.
|
||||||
|
*/
|
||||||
|
@Transient
|
||||||
|
private String verificationCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional metadata as JSON
|
||||||
|
*
|
||||||
|
* Stores supplementary information such as:
|
||||||
|
* - Demo mode flags
|
||||||
|
* - Environment information
|
||||||
|
* - Security context
|
||||||
|
* - Behavioral analysis data
|
||||||
|
*/
|
||||||
|
@Column(name = "metadata", columnDefinition = "JSON")
|
||||||
|
private String metadata;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verification session creation timestamp
|
||||||
|
*
|
||||||
|
* Automatically set when the verification session is created.
|
||||||
|
* Used for session age calculations and cleanup.
|
||||||
|
*/
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JPA
|
||||||
|
*/
|
||||||
|
public TwoFactorVerification() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for creating verification session
|
||||||
|
*
|
||||||
|
* @param user User requesting verification
|
||||||
|
* @param codeHash Hashed verification code
|
||||||
|
* @param method Verification delivery method
|
||||||
|
* @param deviceFingerprint Device fingerprint data
|
||||||
|
* @param expiresAt Code expiration time
|
||||||
|
*/
|
||||||
|
public TwoFactorVerification(User user, String codeHash, Method method,
|
||||||
|
String deviceFingerprint, LocalDateTime expiresAt) {
|
||||||
|
this.user = user;
|
||||||
|
this.codeHash = codeHash;
|
||||||
|
this.method = method;
|
||||||
|
this.deviceFingerprint = deviceFingerprint;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if verification code has expired
|
||||||
|
*
|
||||||
|
* @return true if current time is past expiration time
|
||||||
|
*/
|
||||||
|
public boolean isExpired() {
|
||||||
|
return LocalDateTime.now().isAfter(expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if verification session is still valid
|
||||||
|
*
|
||||||
|
* A session is valid if it's not expired, not used, not blocked,
|
||||||
|
* and hasn't exceeded maximum attempts.
|
||||||
|
*
|
||||||
|
* @return true if session can accept verification attempts
|
||||||
|
*/
|
||||||
|
public boolean isValid() {
|
||||||
|
return !isExpired() && !isUsed && !isBlocked && attempts < maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment verification attempt counter
|
||||||
|
*
|
||||||
|
* Called each time user submits a verification code.
|
||||||
|
* Automatically blocks session if max attempts exceeded.
|
||||||
|
*/
|
||||||
|
public void incrementAttempts() {
|
||||||
|
this.attempts++;
|
||||||
|
if (this.attempts >= this.maxAttempts) {
|
||||||
|
this.isBlocked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark verification as successfully completed
|
||||||
|
*
|
||||||
|
* Sets isUsed flag to prevent code reuse and indicates
|
||||||
|
* that device registration can proceed.
|
||||||
|
*/
|
||||||
|
public void markAsUsed() {
|
||||||
|
this.isUsed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining verification attempts
|
||||||
|
*
|
||||||
|
* @return Number of attempts remaining before session is blocked
|
||||||
|
*/
|
||||||
|
public int getRemainingAttempts() {
|
||||||
|
return Math.max(0, maxAttempts - attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if session should be cleaned up
|
||||||
|
*
|
||||||
|
* Sessions should be cleaned up if they are expired and either
|
||||||
|
* used or have been inactive for an extended period.
|
||||||
|
*
|
||||||
|
* @param cleanupHours Hours after expiration to keep sessions
|
||||||
|
* @return true if session can be safely deleted
|
||||||
|
*/
|
||||||
|
public boolean shouldCleanup(int cleanupHours) {
|
||||||
|
if (!isExpired()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime cleanupThreshold = expiresAt.plusHours(cleanupHours);
|
||||||
|
return LocalDateTime.now().isAfter(cleanupThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUser(User user) {
|
||||||
|
this.user = user;
|
||||||
|
this.userId = user != null ? user.getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCodeHash() {
|
||||||
|
return codeHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCodeHash(String codeHash) {
|
||||||
|
this.codeHash = codeHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Method getMethod() {
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMethod(Method method) {
|
||||||
|
this.method = method;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceFingerprint() {
|
||||||
|
return deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeviceFingerprint(String deviceFingerprint) {
|
||||||
|
this.deviceFingerprint = deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getIpAddress() {
|
||||||
|
return ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIpAddress(String ipAddress) {
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUserAgent() {
|
||||||
|
return userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserAgent(String userAgent) {
|
||||||
|
this.userAgent = userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getExpiresAt() {
|
||||||
|
return expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getAttempts() {
|
||||||
|
return attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAttempts(Integer attempts) {
|
||||||
|
this.attempts = attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getMaxAttempts() {
|
||||||
|
return maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxAttempts(Integer maxAttempts) {
|
||||||
|
this.maxAttempts = maxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsUsed() {
|
||||||
|
return isUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsUsed(Boolean isUsed) {
|
||||||
|
this.isUsed = isUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsBlocked() {
|
||||||
|
return isBlocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsBlocked(Boolean isBlocked) {
|
||||||
|
this.isBlocked = isBlocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMetadata() {
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMetadata(String metadata) {
|
||||||
|
this.metadata = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getVerifiedAt() {
|
||||||
|
return verifiedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVerifiedAt(LocalDateTime verifiedAt) {
|
||||||
|
this.verifiedAt = verifiedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Boolean isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getDisabledAt() {
|
||||||
|
return disabledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDisabledAt(LocalDateTime disabledAt) {
|
||||||
|
this.disabledAt = disabledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVerificationCode() {
|
||||||
|
return verificationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVerificationCode(String verificationCode) {
|
||||||
|
this.verificationCode = verificationCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "TwoFactorVerification{" +
|
||||||
|
"id=" + id +
|
||||||
|
", userId=" + userId +
|
||||||
|
", method=" + method +
|
||||||
|
", attempts=" + attempts +
|
||||||
|
", maxAttempts=" + maxAttempts +
|
||||||
|
", isUsed=" + isUsed +
|
||||||
|
", isBlocked=" + isBlocked +
|
||||||
|
", expiresAt=" + expiresAt +
|
||||||
|
", createdAt=" + createdAt +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
631
src/main/java/com/company/auth/entity/User.java
Normal file
631
src/main/java/com/company/auth/entity/User.java
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
/**
|
||||||
|
* User Entity
|
||||||
|
*
|
||||||
|
* JPA entity representing user accounts in the authentication system.
|
||||||
|
* Maps to the users table and provides the foundation for authentication,
|
||||||
|
* authorization, and device trust management.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User entity for authentication and authorization
|
||||||
|
*
|
||||||
|
* This entity stores user account information including credentials,
|
||||||
|
* roles, permissions, and account status. It supports the complete
|
||||||
|
* authentication workflow and integrates with device trust management.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users", indexes = {
|
||||||
|
@Index(name = "idx_users_email", columnList = "email"),
|
||||||
|
@Index(name = "idx_users_active", columnList = "isActive"),
|
||||||
|
@Index(name = "idx_users_last_login", columnList = "lastLoginAt"),
|
||||||
|
@Index(name = "idx_users_created", columnList = "createdAt")
|
||||||
|
})
|
||||||
|
public class User implements UserDetails {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary key for the user record
|
||||||
|
*/
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's first name
|
||||||
|
*/
|
||||||
|
@Column(name = "first_name", nullable = false, length = 50)
|
||||||
|
@NotBlank(message = "First name is required")
|
||||||
|
@Size(min = 1, max = 50, message = "First name must be between 1 and 50 characters")
|
||||||
|
private String firstName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's last name
|
||||||
|
*/
|
||||||
|
@Column(name = "last_name", nullable = false, length = 50)
|
||||||
|
@NotBlank(message = "Last name is required")
|
||||||
|
@Size(min = 1, max = 50, message = "Last name must be between 1 and 50 characters")
|
||||||
|
private String lastName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's email address (unique identifier)
|
||||||
|
*/
|
||||||
|
@Column(name = "email", nullable = false, unique = true, length = 255)
|
||||||
|
@NotBlank(message = "Email is required")
|
||||||
|
@Email(message = "Email must be valid")
|
||||||
|
@Size(max = 255, message = "Email must not exceed 255 characters")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted password hash
|
||||||
|
*
|
||||||
|
* Password is hashed using BCrypt before storage.
|
||||||
|
* This field is excluded from JSON serialization for security.
|
||||||
|
*/
|
||||||
|
@Column(name = "password", nullable = false, length = 255)
|
||||||
|
@NotBlank(message = "Password is required")
|
||||||
|
@JsonIgnore
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User roles as JSON array
|
||||||
|
*
|
||||||
|
* Stores user roles (e.g., ["USER", "ADMIN"]) for authorization.
|
||||||
|
* Uses JSON column type for flexible role management.
|
||||||
|
*/
|
||||||
|
@Column(name = "roles", columnDefinition = "JSON")
|
||||||
|
private String roles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User permissions as JSON array
|
||||||
|
*
|
||||||
|
* Stores specific permissions (e.g., ["READ_PROFILE", "UPDATE_PROFILE"])
|
||||||
|
* for fine-grained access control.
|
||||||
|
*/
|
||||||
|
@Column(name = "permissions", columnDefinition = "JSON")
|
||||||
|
private String permissions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account active status
|
||||||
|
*
|
||||||
|
* Determines if the user account is active and can authenticate.
|
||||||
|
* Inactive accounts are denied access.
|
||||||
|
*/
|
||||||
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private Boolean isActive = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email verification status
|
||||||
|
*
|
||||||
|
* Tracks whether the user has verified their email address.
|
||||||
|
* Can be used to enforce email verification requirements.
|
||||||
|
*/
|
||||||
|
@Column(name = "email_verified", nullable = false)
|
||||||
|
private Boolean emailVerified = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's phone number
|
||||||
|
*/
|
||||||
|
@Column(name = "phone_number", length = 20)
|
||||||
|
private String phoneNumber;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone verification status
|
||||||
|
*/
|
||||||
|
@Column(name = "phone_verified", nullable = false)
|
||||||
|
private Boolean phoneVerified = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication enabled status
|
||||||
|
*/
|
||||||
|
@Column(name = "two_factor_enabled", nullable = false)
|
||||||
|
private Boolean twoFactorEnabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-factor authentication method
|
||||||
|
*/
|
||||||
|
@Column(name = "two_factor_method", length = 20)
|
||||||
|
private String twoFactorMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's preferred language
|
||||||
|
*/
|
||||||
|
@Column(name = "language", length = 10)
|
||||||
|
private String language = "en";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User's timezone
|
||||||
|
*/
|
||||||
|
@Column(name = "timezone", length = 50)
|
||||||
|
private String timezone = "UTC";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketing opt-in status
|
||||||
|
*/
|
||||||
|
@Column(name = "marketing_opt_in")
|
||||||
|
private Boolean marketingOptIn = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avatar/profile picture URL
|
||||||
|
*/
|
||||||
|
@Column(name = "avatar_url", length = 500)
|
||||||
|
private String avatarUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failed login attempts counter
|
||||||
|
*/
|
||||||
|
@Column(name = "failed_login_attempts", nullable = false)
|
||||||
|
private Integer failedLoginAttempts = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account locked until timestamp
|
||||||
|
*/
|
||||||
|
@Column(name = "locked_until")
|
||||||
|
private LocalDateTime lockedUntil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password last changed timestamp
|
||||||
|
*/
|
||||||
|
@Column(name = "password_changed_at")
|
||||||
|
private LocalDateTime passwordChangedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last successful login timestamp
|
||||||
|
*
|
||||||
|
* Records when the user last successfully authenticated.
|
||||||
|
* Used for security monitoring and account activity tracking.
|
||||||
|
*/
|
||||||
|
@Column(name = "last_login_at")
|
||||||
|
private LocalDateTime lastLoginAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account creation timestamp
|
||||||
|
*
|
||||||
|
* Automatically set when the user account is created.
|
||||||
|
* Used for account age calculations and audit purposes.
|
||||||
|
*/
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last update timestamp
|
||||||
|
*
|
||||||
|
* Automatically updated whenever the user record is modified.
|
||||||
|
* Used for tracking account changes and audit purposes.
|
||||||
|
*/
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relationship to trusted devices
|
||||||
|
*
|
||||||
|
* One-to-many relationship with trusted devices for this user.
|
||||||
|
* Supports device trust management and risk-based authentication.
|
||||||
|
*/
|
||||||
|
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||||
|
@JsonIgnore
|
||||||
|
private List<TrustedDevice> trustedDevices;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JPA
|
||||||
|
*/
|
||||||
|
public User() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for creating user with basic information
|
||||||
|
*
|
||||||
|
* @param firstName User's first name
|
||||||
|
* @param lastName User's last name
|
||||||
|
* @param email User's email address
|
||||||
|
* @param password Encrypted password
|
||||||
|
*/
|
||||||
|
public User(String firstName, String lastName, String email, String password) {
|
||||||
|
this.firstName = firstName;
|
||||||
|
this.lastName = lastName;
|
||||||
|
this.email = email;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full name by combining first and last name
|
||||||
|
*
|
||||||
|
* @return Full name as "firstName lastName"
|
||||||
|
*/
|
||||||
|
public String getFullName() {
|
||||||
|
return firstName + " " + lastName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user account is active and verified
|
||||||
|
*
|
||||||
|
* @return true if account is active and email is verified
|
||||||
|
*/
|
||||||
|
public boolean isAccountFullyActive() {
|
||||||
|
return isActive != null && isActive && emailVerified != null && emailVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last login timestamp to current time
|
||||||
|
*
|
||||||
|
* Called when user successfully authenticates.
|
||||||
|
* Used for security monitoring and activity tracking.
|
||||||
|
*/
|
||||||
|
public void updateLastLogin() {
|
||||||
|
this.lastLoginAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFirstName() {
|
||||||
|
return firstName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFirstName(String firstName) {
|
||||||
|
this.firstName = firstName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLastName() {
|
||||||
|
return lastName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastName(String lastName) {
|
||||||
|
this.lastName = lastName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEmail() {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEmail(String email) {
|
||||||
|
this.email = email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPassword() {
|
||||||
|
return password; // This is already the password hash
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPassword(String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRoles() {
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRoles(String roles) {
|
||||||
|
this.roles = roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPermissions() {
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPermissions(String permissions) {
|
||||||
|
this.permissions = permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Boolean isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getEmailVerified() {
|
||||||
|
return emailVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEmailVerified(Boolean emailVerified) {
|
||||||
|
this.emailVerified = emailVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastLoginAt() {
|
||||||
|
return lastLoginAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastLoginAt(LocalDateTime lastLoginAt) {
|
||||||
|
this.lastLoginAt = lastLoginAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) {
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getUpdatedAt() {
|
||||||
|
return updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TrustedDevice> getTrustedDevices() {
|
||||||
|
return trustedDevices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrustedDevices(List<TrustedDevice> trustedDevices) {
|
||||||
|
this.trustedDevices = trustedDevices;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional getters and setters for new fields
|
||||||
|
|
||||||
|
public String getPhoneNumber() {
|
||||||
|
return phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhoneNumber(String phoneNumber) {
|
||||||
|
this.phoneNumber = phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getPhoneVerified() {
|
||||||
|
return phoneVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhoneVerified(Boolean phoneVerified) {
|
||||||
|
this.phoneVerified = phoneVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getTwoFactorEnabled() {
|
||||||
|
return twoFactorEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTwoFactorEnabled(Boolean twoFactorEnabled) {
|
||||||
|
this.twoFactorEnabled = twoFactorEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTwoFactorMethod() {
|
||||||
|
return twoFactorMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTwoFactorMethod(String twoFactorMethod) {
|
||||||
|
this.twoFactorMethod = twoFactorMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLanguage() {
|
||||||
|
return language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLanguage(String language) {
|
||||||
|
this.language = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTimezone() {
|
||||||
|
return timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimezone(String timezone) {
|
||||||
|
this.timezone = timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getMarketingOptIn() {
|
||||||
|
return marketingOptIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMarketingOptIn(Boolean marketingOptIn) {
|
||||||
|
this.marketingOptIn = marketingOptIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAvatarUrl() {
|
||||||
|
return avatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAvatarUrl(String avatarUrl) {
|
||||||
|
this.avatarUrl = avatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getFailedLoginAttempts() {
|
||||||
|
return failedLoginAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFailedLoginAttempts(Integer failedLoginAttempts) {
|
||||||
|
this.failedLoginAttempts = failedLoginAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLockedUntil() {
|
||||||
|
return lockedUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLockedUntil(LocalDateTime lockedUntil) {
|
||||||
|
this.lockedUntil = lockedUntil;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getPasswordChangedAt() {
|
||||||
|
return passwordChangedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordChangedAt(LocalDateTime passwordChangedAt) {
|
||||||
|
this.passwordChangedAt = passwordChangedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPasswordHash() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordHash(String passwordHash) {
|
||||||
|
this.password = passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return getFullName();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
// Parse full name into first and last name
|
||||||
|
if (name != null && !name.trim().isEmpty()) {
|
||||||
|
String[] parts = name.trim().split("\\s+", 2);
|
||||||
|
this.firstName = parts[0];
|
||||||
|
this.lastName = parts.length > 1 ? parts[1] : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional methods for compatibility
|
||||||
|
|
||||||
|
public String getRole() {
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(String role) {
|
||||||
|
// Convert single role to JSON array format
|
||||||
|
this.roles = "[\"" + role + "\"]";
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return isActive != null && isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.isActive = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAccountNonLocked() {
|
||||||
|
return lockedUntil == null || lockedUntil.isBefore(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccountNonLocked(boolean accountNonLocked) {
|
||||||
|
if (!accountNonLocked) {
|
||||||
|
this.lockedUntil = LocalDateTime.now().plusHours(24);
|
||||||
|
} else {
|
||||||
|
this.lockedUntil = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
if (!isActive) return "INACTIVE";
|
||||||
|
if (!isAccountNonLocked()) return "LOCKED";
|
||||||
|
if (!emailVerified) return "PENDING_VERIFICATION";
|
||||||
|
return "ACTIVE";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case "INACTIVE":
|
||||||
|
this.isActive = false;
|
||||||
|
break;
|
||||||
|
case "LOCKED":
|
||||||
|
this.lockedUntil = LocalDateTime.now().plusHours(24);
|
||||||
|
break;
|
||||||
|
case "PENDING_VERIFICATION":
|
||||||
|
this.emailVerified = false;
|
||||||
|
break;
|
||||||
|
case "ACTIVE":
|
||||||
|
this.isActive = true;
|
||||||
|
this.emailVerified = true;
|
||||||
|
this.lockedUntil = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRegistrationSource() {
|
||||||
|
return "web"; // Default value
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRegistrationSource(String registrationSource) {
|
||||||
|
// This can be stored in a separate field if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRegistrationIp() {
|
||||||
|
return null; // This would need a separate field
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRegistrationIp(String registrationIp) {
|
||||||
|
// This can be stored in a separate field if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getLastActivity() {
|
||||||
|
return lastLoginAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastActivity(LocalDateTime lastActivity) {
|
||||||
|
this.lastLoginAt = lastActivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLastLoginIp() {
|
||||||
|
return null; // This would need a separate field
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastLoginIp(String lastLoginIp) {
|
||||||
|
// This can be stored in a separate field if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDetails interface implementation
|
||||||
|
@Override
|
||||||
|
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||||
|
// Parse roles from JSON string field
|
||||||
|
List<GrantedAuthority> authorities = new ArrayList<>();
|
||||||
|
if (roles != null && !roles.isEmpty()) {
|
||||||
|
// Assuming roles is stored as JSON array like ["USER", "ADMIN"]
|
||||||
|
if (roles.contains("ADMIN")) {
|
||||||
|
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
|
||||||
|
}
|
||||||
|
if (roles.contains("USER")) {
|
||||||
|
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (authorities.isEmpty()) {
|
||||||
|
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
|
||||||
|
}
|
||||||
|
return authorities;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUsername() {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAccountNonExpired() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isCredentialsNonExpired() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "User{" +
|
||||||
|
"id=" + id +
|
||||||
|
", firstName='" + firstName + '\'' +
|
||||||
|
", lastName='" + lastName + '\'' +
|
||||||
|
", email='" + email + '\'' +
|
||||||
|
", isActive=" + isActive +
|
||||||
|
", emailVerified=" + emailVerified +
|
||||||
|
", createdAt=" + createdAt +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class AccountDeletionException extends RuntimeException {
|
||||||
|
public AccountDeletionException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AccountDeletionException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Account Disabled Exception
|
||||||
|
*
|
||||||
|
* Thrown when trying to access a disabled account.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when an account is disabled
|
||||||
|
*/
|
||||||
|
public class AccountDisabledException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new AccountDisabledException with the specified detail message.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
*/
|
||||||
|
public AccountDisabledException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new AccountDisabledException with the specified detail message and cause.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
* @param cause the cause
|
||||||
|
*/
|
||||||
|
public AccountDisabledException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Account Locked Exception
|
||||||
|
*
|
||||||
|
* Thrown when trying to access a locked account.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when an account is locked
|
||||||
|
*/
|
||||||
|
public class AccountLockedException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new AccountLockedException with the specified detail message.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
*/
|
||||||
|
public AccountLockedException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new AccountLockedException with the specified detail message and cause.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
* @param cause the cause
|
||||||
|
*/
|
||||||
|
public AccountLockedException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Account Suspended Exception
|
||||||
|
*
|
||||||
|
* Thrown when trying to access a suspended account.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when an account is suspended
|
||||||
|
*/
|
||||||
|
public class AccountSuspendedException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new AccountSuspendedException with the specified detail message.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
*/
|
||||||
|
public AccountSuspendedException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new AccountSuspendedException with the specified detail message and cause.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
* @param cause the cause
|
||||||
|
*/
|
||||||
|
public AccountSuspendedException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class AuthenticationException extends RuntimeException {
|
||||||
|
public AuthenticationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Service Exception
|
||||||
|
*
|
||||||
|
* Custom exception for authentication service operations.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown by authentication service operations
|
||||||
|
*/
|
||||||
|
public class AuthenticationServiceException extends RuntimeException {
|
||||||
|
|
||||||
|
private final HttpStatus status;
|
||||||
|
private final String detail;
|
||||||
|
|
||||||
|
public AuthenticationServiceException(String message) {
|
||||||
|
super(message);
|
||||||
|
this.status = HttpStatus.BAD_REQUEST;
|
||||||
|
this.detail = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationServiceException(String message, HttpStatus status) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.detail = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationServiceException(String message, String detail) {
|
||||||
|
super(message);
|
||||||
|
this.status = HttpStatus.BAD_REQUEST;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationServiceException(String message, String detail, HttpStatus status) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationServiceException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
this.detail = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationServiceException(String message, String detail, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDetail() {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when device fingerprinting operations fail
|
||||||
|
*/
|
||||||
|
public class DeviceFingerprintException extends RuntimeException {
|
||||||
|
|
||||||
|
public DeviceFingerprintException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeviceFingerprintException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class DeviceManagementException extends RuntimeException {
|
||||||
|
public DeviceManagementException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeviceManagementException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Device Not Found Exception
|
||||||
|
*
|
||||||
|
* Exception thrown when a requested device is not found.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when a trusted device is not found
|
||||||
|
*/
|
||||||
|
public class DeviceNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
public DeviceNotFoundException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeviceNotFoundException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Device Verification Exception
|
||||||
|
*
|
||||||
|
* Thrown when device verification fails.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when device verification fails
|
||||||
|
*/
|
||||||
|
public class DeviceVerificationException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new DeviceVerificationException with the specified detail message.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
*/
|
||||||
|
public DeviceVerificationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new DeviceVerificationException with the specified detail message and cause.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
* @param cause the cause
|
||||||
|
*/
|
||||||
|
public DeviceVerificationException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Email Service Exception
|
||||||
|
*
|
||||||
|
* Custom exception for email service operations.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown by EmailService operations
|
||||||
|
*/
|
||||||
|
public class EmailServiceException extends RuntimeException {
|
||||||
|
|
||||||
|
public EmailServiceException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmailServiceException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* GeoLocation Service Exception
|
||||||
|
*
|
||||||
|
* Custom exception for geolocation service operations.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown by GeoLocationService operations
|
||||||
|
*/
|
||||||
|
public class GeoLocationException extends RuntimeException {
|
||||||
|
|
||||||
|
public GeoLocationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GeoLocationException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
/**
|
||||||
|
* Global Exception Handler
|
||||||
|
*
|
||||||
|
* Centralized exception handling for the authentication service.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
|
import org.springframework.security.authentication.DisabledException;
|
||||||
|
import org.springframework.security.authentication.LockedException;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.context.request.WebRequest;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import jakarta.validation.ConstraintViolation;
|
||||||
|
import jakarta.validation.ConstraintViolationException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Exception Handler
|
||||||
|
*
|
||||||
|
* Provides centralized exception handling with standardized error responses.
|
||||||
|
*/
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles validation errors for request body validation
|
||||||
|
*
|
||||||
|
* @param ex Method argument not valid exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleValidationExceptions(
|
||||||
|
MethodArgumentNotValidException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.debug("Validation error: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, String> fieldErrors = new HashMap<>();
|
||||||
|
ex.getBindingResult().getAllErrors().forEach(error -> {
|
||||||
|
String fieldName = ((FieldError) error).getField();
|
||||||
|
String errorMessage = error.getDefaultMessage();
|
||||||
|
fieldErrors.put(fieldName, errorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Validation failed",
|
||||||
|
"Invalid input data",
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
errorResponse.put("fieldErrors", fieldErrors);
|
||||||
|
|
||||||
|
return ResponseEntity.badRequest().body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles constraint violation exceptions
|
||||||
|
*
|
||||||
|
* @param ex Constraint violation exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(ConstraintViolationException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleConstraintViolationExceptions(
|
||||||
|
ConstraintViolationException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.debug("Constraint violation: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, String> fieldErrors = ex.getConstraintViolations().stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
violation -> violation.getPropertyPath().toString(),
|
||||||
|
ConstraintViolation::getMessage
|
||||||
|
));
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Validation failed",
|
||||||
|
"Constraint violations detected",
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
errorResponse.put("fieldErrors", fieldErrors);
|
||||||
|
|
||||||
|
return ResponseEntity.badRequest().body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles authentication exceptions
|
||||||
|
*
|
||||||
|
* @param ex Authentication exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(AuthenticationException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleAuthenticationExceptions(
|
||||||
|
AuthenticationException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.warn("Authentication error: {}", ex.getMessage());
|
||||||
|
|
||||||
|
String message = "Authentication failed";
|
||||||
|
HttpStatus status = HttpStatus.UNAUTHORIZED;
|
||||||
|
|
||||||
|
if (ex instanceof BadCredentialsException) {
|
||||||
|
message = "Invalid credentials";
|
||||||
|
} else if (ex instanceof DisabledException) {
|
||||||
|
message = "Account is disabled";
|
||||||
|
} else if (ex instanceof LockedException) {
|
||||||
|
message = "Account is locked";
|
||||||
|
status = HttpStatus.LOCKED;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
"Please check your credentials and try again",
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(status).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles bad credentials exceptions
|
||||||
|
*
|
||||||
|
* @param ex Bad credentials exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(BadCredentialsException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleBadCredentialsException(
|
||||||
|
BadCredentialsException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.warn("Bad credentials: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.UNAUTHORIZED,
|
||||||
|
"Invalid credentials",
|
||||||
|
"The email or password you entered is incorrect",
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles entity not found exceptions
|
||||||
|
*
|
||||||
|
* @param ex Entity not found exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(EntityNotFoundException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleEntityNotFoundException(
|
||||||
|
EntityNotFoundException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.debug("Entity not found: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"Resource not found",
|
||||||
|
ex.getMessage(),
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles illegal argument exceptions
|
||||||
|
*
|
||||||
|
* @param ex Illegal argument exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleIllegalArgumentException(
|
||||||
|
IllegalArgumentException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.debug("Illegal argument: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Invalid request",
|
||||||
|
ex.getMessage(),
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.badRequest().body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles illegal state exceptions
|
||||||
|
*
|
||||||
|
* @param ex Illegal state exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(IllegalStateException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleIllegalStateException(
|
||||||
|
IllegalStateException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.warn("Illegal state: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
"Invalid operation",
|
||||||
|
ex.getMessage(),
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles custom authentication service exceptions
|
||||||
|
*
|
||||||
|
* @param ex Authentication service exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(AuthenticationServiceException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleAuthenticationServiceException(
|
||||||
|
AuthenticationServiceException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.warn("Authentication service error: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
ex.getStatus(),
|
||||||
|
ex.getMessage(),
|
||||||
|
ex.getDetail(),
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(ex.getStatus()).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles device fingerprint exceptions
|
||||||
|
*
|
||||||
|
* @param ex Device fingerprint exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(DeviceFingerprintException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleDeviceFingerprintException(
|
||||||
|
DeviceFingerprintException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.warn("Device fingerprint error: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Device verification failed",
|
||||||
|
ex.getMessage(),
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.badRequest().body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles two-factor authentication exceptions
|
||||||
|
*
|
||||||
|
* @param ex Two-factor authentication exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(TwoFactorAuthException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleTwoFactorAuthException(
|
||||||
|
TwoFactorAuthException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.warn("Two-factor authentication error: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Two-factor authentication failed",
|
||||||
|
ex.getMessage(),
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.badRequest().body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles email service exceptions
|
||||||
|
*
|
||||||
|
* @param ex Email service exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(EmailServiceException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleEmailServiceException(
|
||||||
|
EmailServiceException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.error("Email service error: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"Email service unavailable",
|
||||||
|
"Unable to send email notification",
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles SMS service exceptions
|
||||||
|
*
|
||||||
|
* @param ex SMS service exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(SmsServiceException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleSmsServiceException(
|
||||||
|
SmsServiceException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.error("SMS service error: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"SMS service unavailable",
|
||||||
|
"Unable to send SMS notification",
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles TOTP service exceptions
|
||||||
|
*
|
||||||
|
* @param ex TOTP exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(TotpException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleTotpException(
|
||||||
|
TotpException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.warn("TOTP error: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"TOTP operation failed",
|
||||||
|
ex.getMessage(),
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.badRequest().body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles geolocation service exceptions
|
||||||
|
*
|
||||||
|
* @param ex Geolocation exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(GeoLocationException.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleGeoLocationException(
|
||||||
|
GeoLocationException ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.error("Geolocation service error: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"Location service unavailable",
|
||||||
|
"Unable to determine location",
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles all other exceptions
|
||||||
|
*
|
||||||
|
* @param ex General exception
|
||||||
|
* @param request Web request
|
||||||
|
* @return Error response
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<Map<String, Object>> handleGeneralException(
|
||||||
|
Exception ex, WebRequest request) {
|
||||||
|
|
||||||
|
logger.error("Unexpected error occurred", ex);
|
||||||
|
|
||||||
|
Map<String, Object> errorResponse = createErrorResponse(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal server error",
|
||||||
|
"An unexpected error occurred",
|
||||||
|
request.getDescription(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates standardized error response
|
||||||
|
*
|
||||||
|
* @param status HTTP status
|
||||||
|
* @param message Error message
|
||||||
|
* @param detail Error detail
|
||||||
|
* @param path Request path
|
||||||
|
* @return Error response map
|
||||||
|
*/
|
||||||
|
private Map<String, Object> createErrorResponse(HttpStatus status, String message, String detail, String path) {
|
||||||
|
Map<String, Object> errorResponse = new HashMap<>();
|
||||||
|
errorResponse.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||||
|
errorResponse.put("status", status.value());
|
||||||
|
errorResponse.put("error", status.getReasonPhrase());
|
||||||
|
errorResponse.put("message", message);
|
||||||
|
|
||||||
|
if (detail != null && !detail.equals(message)) {
|
||||||
|
errorResponse.put("detail", detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up path (remove "uri=" prefix if present)
|
||||||
|
String cleanPath = path.startsWith("uri=") ? path.substring(4) : path;
|
||||||
|
errorResponse.put("path", cleanPath);
|
||||||
|
|
||||||
|
return errorResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class InvalidCredentialsException extends RuntimeException {
|
||||||
|
public InvalidCredentialsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvalidCredentialsException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Invalid Email Exception
|
||||||
|
*
|
||||||
|
* Thrown when an invalid email address is provided or encountered.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when an invalid email address is encountered
|
||||||
|
*/
|
||||||
|
public class InvalidEmailException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new InvalidEmailException with the specified detail message.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
*/
|
||||||
|
public InvalidEmailException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new InvalidEmailException with the specified detail message and cause.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
* @param cause the cause
|
||||||
|
*/
|
||||||
|
public InvalidEmailException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Invalid Registration Data Exception
|
||||||
|
*
|
||||||
|
* Thrown when invalid registration data is provided.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when invalid registration data is encountered
|
||||||
|
*/
|
||||||
|
public class InvalidRegistrationDataException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new InvalidRegistrationDataException with the specified detail message.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
*/
|
||||||
|
public InvalidRegistrationDataException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new InvalidRegistrationDataException with the specified detail message and cause.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
* @param cause the cause
|
||||||
|
*/
|
||||||
|
public InvalidRegistrationDataException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class InvalidTokenException extends RuntimeException {
|
||||||
|
public InvalidTokenException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvalidTokenException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Invalid Two Factor Code Exception
|
||||||
|
*
|
||||||
|
* Thrown when an invalid two-factor authentication code is provided.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when an invalid two-factor authentication code is encountered
|
||||||
|
*/
|
||||||
|
public class InvalidTwoFactorCodeException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new InvalidTwoFactorCodeException with the specified detail message.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
*/
|
||||||
|
public InvalidTwoFactorCodeException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new InvalidTwoFactorCodeException with the specified detail message and cause.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
* @param cause the cause
|
||||||
|
*/
|
||||||
|
public InvalidTwoFactorCodeException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class InvalidUpdateDataException extends RuntimeException {
|
||||||
|
public InvalidUpdateDataException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InvalidUpdateDataException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Invalid Verification Code Exception
|
||||||
|
*
|
||||||
|
* Thrown when an invalid verification code is provided during device verification.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when an invalid verification code is encountered
|
||||||
|
*/
|
||||||
|
public class InvalidVerificationCodeException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new InvalidVerificationCodeException with the specified detail message.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
*/
|
||||||
|
public InvalidVerificationCodeException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new InvalidVerificationCodeException with the specified detail message and cause.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
* @param cause the cause
|
||||||
|
*/
|
||||||
|
public InvalidVerificationCodeException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class PasswordChangeException extends RuntimeException {
|
||||||
|
public PasswordChangeException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PasswordChangeException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class ProfileUpdateException extends RuntimeException {
|
||||||
|
public ProfileUpdateException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProfileUpdateException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class RegistrationException extends RuntimeException {
|
||||||
|
public RegistrationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RegistrationException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* SMS Service Exception
|
||||||
|
*
|
||||||
|
* Custom exception for SMS service operations.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown by SmsService operations
|
||||||
|
*/
|
||||||
|
public class SmsServiceException extends RuntimeException {
|
||||||
|
|
||||||
|
public SmsServiceException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SmsServiceException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class TokenExpiredException extends RuntimeException {
|
||||||
|
public TokenExpiredException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TokenExpiredException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class TokenGenerationException extends RuntimeException {
|
||||||
|
public TokenGenerationException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TokenGenerationException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Token Refresh Exception
|
||||||
|
*
|
||||||
|
* Thrown when token refresh operation fails.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when token refresh fails
|
||||||
|
*/
|
||||||
|
public class TokenRefreshException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new TokenRefreshException with the specified detail message.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
*/
|
||||||
|
public TokenRefreshException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new TokenRefreshException with the specified detail message and cause.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
* @param cause the cause
|
||||||
|
*/
|
||||||
|
public TokenRefreshException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/main/java/com/company/auth/exception/TotpException.java
Normal file
23
src/main/java/com/company/auth/exception/TotpException.java
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* TOTP Service Exception
|
||||||
|
*
|
||||||
|
* Custom exception for TOTP service operations.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown by TotpService operations
|
||||||
|
*/
|
||||||
|
public class TotpException extends RuntimeException {
|
||||||
|
|
||||||
|
public TotpException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TotpException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when two-factor authentication operations fail
|
||||||
|
*/
|
||||||
|
public class TwoFactorAuthException extends RuntimeException {
|
||||||
|
|
||||||
|
public TwoFactorAuthException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TwoFactorAuthException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Two Factor Exception
|
||||||
|
*
|
||||||
|
* Generic exception for two-factor authentication related errors.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown for two-factor authentication related errors
|
||||||
|
*/
|
||||||
|
public class TwoFactorException extends RuntimeException {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new TwoFactorException with the specified detail message.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
*/
|
||||||
|
public TwoFactorException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new TwoFactorException with the specified detail message and cause.
|
||||||
|
*
|
||||||
|
* @param message the detail message
|
||||||
|
* @param cause the cause
|
||||||
|
*/
|
||||||
|
public TwoFactorException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class UserAlreadyExistsException extends RuntimeException {
|
||||||
|
public UserAlreadyExistsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserAlreadyExistsException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when user is not found
|
||||||
|
*/
|
||||||
|
public class UserNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
public UserNotFoundException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UserNotFoundException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.company.auth.exception;
|
||||||
|
|
||||||
|
public class WeakPasswordException extends RuntimeException {
|
||||||
|
public WeakPasswordException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* Trusted Device Repository
|
||||||
|
*
|
||||||
|
* JPA repository interface for TrustedDevice entity operations.
|
||||||
|
* Provides database access methods for device fingerprint management,
|
||||||
|
* risk assessment, and device trust verification.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.repository;
|
||||||
|
|
||||||
|
import com.company.auth.entity.TrustedDevice;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository interface for TrustedDevice entity
|
||||||
|
*
|
||||||
|
* Extends JpaRepository to provide standard CRUD operations plus
|
||||||
|
* specialized query methods for device fingerprint matching,
|
||||||
|
* risk assessment, and device lifecycle management.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface TrustedDeviceRepository extends JpaRepository<TrustedDevice, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find device by user and fingerprint hash
|
||||||
|
*
|
||||||
|
* Primary method for device verification during authentication.
|
||||||
|
* Used to check if a device is already trusted for a user.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param fingerprintHash Device fingerprint hash
|
||||||
|
* @return Optional containing device if found, empty otherwise
|
||||||
|
*/
|
||||||
|
Optional<TrustedDevice> findByUserIdAndFingerprintHash(Long userId, String fingerprintHash);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all active devices for a user
|
||||||
|
*
|
||||||
|
* Returns all trusted devices that are currently active for a user.
|
||||||
|
* Used for device management and security overview.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @return List of active trusted devices
|
||||||
|
*/
|
||||||
|
List<TrustedDevice> findByUserIdAndIsActiveTrue(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all devices for a user (active and inactive)
|
||||||
|
*
|
||||||
|
* Returns all devices associated with a user regardless of status.
|
||||||
|
* Used for comprehensive device history and management.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @return List of all user devices
|
||||||
|
*/
|
||||||
|
List<TrustedDevice> findByUserId(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find device by fingerprint hash across all users
|
||||||
|
*
|
||||||
|
* Used for global fingerprint analysis and duplicate detection.
|
||||||
|
* Helps identify potential security issues or shared devices.
|
||||||
|
*
|
||||||
|
* @param fingerprintHash Device fingerprint hash
|
||||||
|
* @return List of devices with matching fingerprint
|
||||||
|
*/
|
||||||
|
List<TrustedDevice> findByFingerprintHash(String fingerprintHash);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find devices with high risk scores
|
||||||
|
*
|
||||||
|
* Returns devices with risk scores above a specified threshold.
|
||||||
|
* Used for security monitoring and risk assessment.
|
||||||
|
*
|
||||||
|
* @param riskThreshold Risk score threshold
|
||||||
|
* @return List of high-risk devices
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.riskScore >= :riskThreshold AND d.isActive = true")
|
||||||
|
List<TrustedDevice> findHighRiskDevices(@Param("riskThreshold") BigDecimal riskThreshold);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find devices with low confidence scores
|
||||||
|
*
|
||||||
|
* Returns devices with fingerprint confidence below threshold.
|
||||||
|
* Used for identifying unreliable device fingerprints.
|
||||||
|
*
|
||||||
|
* @param confidenceThreshold Confidence score threshold
|
||||||
|
* @return List of low-confidence devices
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.confidenceScore < :confidenceThreshold AND d.isActive = true")
|
||||||
|
List<TrustedDevice> findLowConfidenceDevices(@Param("confidenceThreshold") BigDecimal confidenceThreshold);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find stale devices not used recently
|
||||||
|
*
|
||||||
|
* Returns devices that haven't been used for authentication
|
||||||
|
* within a specified time period. Used for device cleanup.
|
||||||
|
*
|
||||||
|
* @param cutoffDate Date threshold for recent usage
|
||||||
|
* @return List of stale devices
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE " +
|
||||||
|
"(d.lastUsedAt IS NULL AND d.createdAt < :cutoffDate) OR " +
|
||||||
|
"(d.lastUsedAt IS NOT NULL AND d.lastUsedAt < :cutoffDate)")
|
||||||
|
List<TrustedDevice> findStaleDevices(@Param("cutoffDate") LocalDateTime cutoffDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find devices by IP address
|
||||||
|
*
|
||||||
|
* Returns devices that have been used from a specific IP address.
|
||||||
|
* Used for location-based security analysis.
|
||||||
|
*
|
||||||
|
* @param ipAddress IP address to search for
|
||||||
|
* @return List of devices used from the IP address
|
||||||
|
*/
|
||||||
|
List<TrustedDevice> findByIpAddress(String ipAddress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count active devices for a user
|
||||||
|
*
|
||||||
|
* Returns the number of active trusted devices for a user.
|
||||||
|
* Used for enforcing device limits and user statistics.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @return Number of active devices
|
||||||
|
*/
|
||||||
|
long countByUserIdAndIsActiveTrue(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find most recently used devices for a user
|
||||||
|
*
|
||||||
|
* Returns user's devices ordered by most recent usage.
|
||||||
|
* Limited to specified number of results.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param limit Maximum number of devices to return
|
||||||
|
* @return List of recently used devices
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.userId = :userId AND d.isActive = true " +
|
||||||
|
"ORDER BY d.lastUsedAt DESC NULLS LAST")
|
||||||
|
List<TrustedDevice> findRecentlyUsedDevices(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find devices created within date range
|
||||||
|
*
|
||||||
|
* Returns devices registered within a specific time period.
|
||||||
|
* Used for analytics and device registration trends.
|
||||||
|
*
|
||||||
|
* @param startDate Start of date range
|
||||||
|
* @param endDate End of date range
|
||||||
|
* @return List of devices created in date range
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.createdAt BETWEEN :startDate AND :endDate ORDER BY d.createdAt")
|
||||||
|
List<TrustedDevice> findByCreatedAtBetween(@Param("startDate") LocalDateTime startDate,
|
||||||
|
@Param("endDate") LocalDateTime endDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find devices with similar fingerprints
|
||||||
|
*
|
||||||
|
* Advanced query to find devices with fingerprint hashes that
|
||||||
|
* are similar but not identical. Used for fuzzy device matching.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param fingerprintPattern Pattern for fuzzy matching
|
||||||
|
* @return List of devices with similar fingerprints
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.userId = :userId AND d.isActive = true " +
|
||||||
|
"AND d.fingerprintHash LIKE :fingerprintPattern")
|
||||||
|
List<TrustedDevice> findSimilarFingerprints(@Param("userId") Long userId,
|
||||||
|
@Param("fingerprintPattern") String fingerprintPattern);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update device usage statistics
|
||||||
|
*
|
||||||
|
* Efficiently updates device usage information without loading
|
||||||
|
* the full entity. Used during authentication process.
|
||||||
|
*
|
||||||
|
* @param deviceId Device ID to update
|
||||||
|
* @param ipAddress Current IP address
|
||||||
|
* @param userAgent Current user agent
|
||||||
|
* @param usageTime Current timestamp
|
||||||
|
* @return Number of affected rows (should be 1 for success)
|
||||||
|
*/
|
||||||
|
@Query("UPDATE TrustedDevice d SET " +
|
||||||
|
"d.usageCount = d.usageCount + 1, " +
|
||||||
|
"d.lastUsedAt = :usageTime, " +
|
||||||
|
"d.ipAddress = :ipAddress, " +
|
||||||
|
"d.userAgent = :userAgent " +
|
||||||
|
"WHERE d.id = :deviceId")
|
||||||
|
int updateDeviceUsage(@Param("deviceId") Long deviceId,
|
||||||
|
@Param("ipAddress") String ipAddress,
|
||||||
|
@Param("userAgent") String userAgent,
|
||||||
|
@Param("usageTime") LocalDateTime usageTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update device risk score
|
||||||
|
*
|
||||||
|
* Updates the risk assessment score for a device.
|
||||||
|
* Used by risk calculation algorithms.
|
||||||
|
*
|
||||||
|
* @param deviceId Device ID to update
|
||||||
|
* @param riskScore New risk score
|
||||||
|
* @return Number of affected rows (should be 1 for success)
|
||||||
|
*/
|
||||||
|
@Query("UPDATE TrustedDevice d SET d.riskScore = :riskScore WHERE d.id = :deviceId")
|
||||||
|
int updateRiskScore(@Param("deviceId") Long deviceId, @Param("riskScore") BigDecimal riskScore);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate device
|
||||||
|
*
|
||||||
|
* Sets device status to inactive, removing it from trusted device list.
|
||||||
|
* Used for device revocation and security incidents.
|
||||||
|
*
|
||||||
|
* @param deviceId Device ID to deactivate
|
||||||
|
* @return Number of affected rows (should be 1 for success)
|
||||||
|
*/
|
||||||
|
@Query("UPDATE TrustedDevice d SET d.isActive = false WHERE d.id = :deviceId")
|
||||||
|
int deactivateDevice(@Param("deviceId") Long deviceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate all devices for a user
|
||||||
|
*
|
||||||
|
* Sets all user devices to inactive status. Used for account
|
||||||
|
* security incidents or user-requested device cleanup.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @return Number of affected rows
|
||||||
|
*/
|
||||||
|
@Query("UPDATE TrustedDevice d SET d.isActive = false WHERE d.userId = :userId")
|
||||||
|
int deactivateAllUserDevices(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete stale devices
|
||||||
|
*
|
||||||
|
* Permanently removes devices that haven't been used for
|
||||||
|
* an extended period. Used for database cleanup.
|
||||||
|
*
|
||||||
|
* @param cutoffDate Date threshold for deletion
|
||||||
|
* @return Number of deleted devices
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM TrustedDevice d WHERE " +
|
||||||
|
"(d.lastUsedAt IS NULL AND d.createdAt < :cutoffDate) OR " +
|
||||||
|
"(d.lastUsedAt IS NOT NULL AND d.lastUsedAt < :cutoffDate)")
|
||||||
|
int deleteStaleDevices(@Param("cutoffDate") LocalDateTime cutoffDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find devices by user agent pattern
|
||||||
|
*
|
||||||
|
* Returns devices matching a specific user agent pattern.
|
||||||
|
* Used for browser/OS specific analysis and security monitoring.
|
||||||
|
*
|
||||||
|
* @param userAgentPattern User agent pattern to match
|
||||||
|
* @return List of devices with matching user agent
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.userAgent LIKE :userAgentPattern AND d.isActive = true")
|
||||||
|
List<TrustedDevice> findByUserAgentPattern(@Param("userAgentPattern") String userAgentPattern);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get device statistics for user
|
||||||
|
*
|
||||||
|
* Returns aggregated statistics about user's devices.
|
||||||
|
* Used for security dashboards and user analytics.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @return Array containing [total_devices, active_devices, avg_risk_score, avg_confidence]
|
||||||
|
*/
|
||||||
|
@Query("SELECT " +
|
||||||
|
"COUNT(d), " +
|
||||||
|
"SUM(CASE WHEN d.isActive = true THEN 1 ELSE 0 END), " +
|
||||||
|
"AVG(d.riskScore), " +
|
||||||
|
"AVG(d.confidenceScore) " +
|
||||||
|
"FROM TrustedDevice d WHERE d.userId = :userId")
|
||||||
|
Object[] getDeviceStatistics(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
// Additional methods needed by services
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count active devices for user (Long version)
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(d) FROM TrustedDevice d WHERE d.userId = :userId AND d.isActive = :isActive")
|
||||||
|
long countByUserIdAndIsActive(@Param("userId") Long userId, @Param("isActive") boolean isActive);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count trusted devices for user
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(d) FROM TrustedDevice d WHERE d.userId = :userId AND d.isTrusted = :isTrusted")
|
||||||
|
long countByUserIdAndIsTrusted(@Param("userId") Long userId, @Param("isTrusted") boolean isTrusted);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find devices by user ordered by last seen
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.userId = :userId ORDER BY d.lastUsedAt DESC")
|
||||||
|
List<TrustedDevice> findByUserIdOrderByLastSeenAtDesc(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find active devices by user ordered by last seen
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.userId = :userId AND d.isActive = :isActive ORDER BY d.lastUsedAt DESC")
|
||||||
|
List<TrustedDevice> findByUserIdAndIsActiveOrderByLastSeenAtDesc(@Param("userId") Long userId, @Param("isActive") boolean isActive);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find device by ID and user
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.id = :id AND d.userId = :userId")
|
||||||
|
Optional<TrustedDevice> findByIdAndUserId(@Param("id") Long id, @Param("userId") Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find active devices for user
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.userId = :userId AND d.isActive = :isActive")
|
||||||
|
List<TrustedDevice> findByUserIdAndIsActive(@Param("userId") Long userId, @Param("isActive") boolean isActive);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find active devices by user ordered by last seen (alternative method name)
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.userId = :userId AND d.isActive = true ORDER BY d.lastUsedAt DESC")
|
||||||
|
List<TrustedDevice> findByUserIdAndIsActiveTrueOrderByLastSeenAtDesc(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find device by user ID and last IP address
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.userId = :userId AND d.lastIpAddress = :ipAddress")
|
||||||
|
Optional<TrustedDevice> findByUserIdAndLastIpAddress(@Param("userId") Long userId, @Param("ipAddress") String ipAddress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find last login device for user
|
||||||
|
*/
|
||||||
|
@Query("SELECT d FROM TrustedDevice d WHERE d.userId = :userId ORDER BY d.lastUsedAt DESC LIMIT 1")
|
||||||
|
Optional<TrustedDevice> findLastLoginDevice(@Param("userId") Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* Two Factor Verification Repository
|
||||||
|
*
|
||||||
|
* JPA repository interface for TwoFactorVerification entity operations.
|
||||||
|
* Provides database access methods for managing two-factor authentication
|
||||||
|
* verification sessions and code validation workflows.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.repository;
|
||||||
|
|
||||||
|
import com.company.auth.entity.TwoFactorVerification;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository interface for TwoFactorVerification entity
|
||||||
|
*
|
||||||
|
* Extends JpaRepository to provide standard CRUD operations plus
|
||||||
|
* specialized query methods for managing 2FA verification sessions,
|
||||||
|
* code validation, and session cleanup.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface TwoFactorVerificationRepository extends JpaRepository<TwoFactorVerification, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find active verification session by ID and user
|
||||||
|
*
|
||||||
|
* Primary method for retrieving verification sessions during
|
||||||
|
* the 2FA process. Only returns sessions that are not used or blocked.
|
||||||
|
*
|
||||||
|
* @param id Verification session ID
|
||||||
|
* @param userId User ID
|
||||||
|
* @return Optional containing verification if found and active
|
||||||
|
*/
|
||||||
|
@Query("SELECT v FROM TwoFactorVerification v WHERE v.id = :id AND v.userId = :userId " +
|
||||||
|
"AND v.isUsed = false AND v.isBlocked = false")
|
||||||
|
Optional<TwoFactorVerification> findActiveVerification(@Param("id") Long id, @Param("userId") Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find verification by code hash
|
||||||
|
*
|
||||||
|
* Used for code validation when the verification ID is not available.
|
||||||
|
* Returns only non-expired, non-used sessions.
|
||||||
|
*
|
||||||
|
* @param codeHash Hashed verification code
|
||||||
|
* @return Optional containing verification if found and valid
|
||||||
|
*/
|
||||||
|
@Query("SELECT v FROM TwoFactorVerification v WHERE v.codeHash = :codeHash " +
|
||||||
|
"AND v.isUsed = false AND v.isBlocked = false AND v.expiresAt > :now")
|
||||||
|
Optional<TwoFactorVerification> findByCodeHashAndNotExpired(@Param("codeHash") String codeHash,
|
||||||
|
@Param("now") LocalDateTime now);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all active verifications for a user
|
||||||
|
*
|
||||||
|
* Returns all non-expired, non-used verification sessions for a user.
|
||||||
|
* Used for managing concurrent verification attempts.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @return List of active verification sessions
|
||||||
|
*/
|
||||||
|
@Query("SELECT v FROM TwoFactorVerification v WHERE v.userId = :userId " +
|
||||||
|
"AND v.isUsed = false AND v.isBlocked = false AND v.expiresAt > :now " +
|
||||||
|
"ORDER BY v.createdAt DESC")
|
||||||
|
List<TwoFactorVerification> findActiveVerificationsByUser(@Param("userId") Long userId,
|
||||||
|
@Param("now") LocalDateTime now);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find verification by user and active status
|
||||||
|
*
|
||||||
|
* Returns verification sessions by user ID and active status.
|
||||||
|
* Used for checking if user has active 2FA sessions.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param isActive Active status filter
|
||||||
|
* @return Optional containing verification if found
|
||||||
|
*/
|
||||||
|
Optional<TwoFactorVerification> findByUserIdAndIsActive(Long userId, Boolean isActive);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find verification sessions by method and user
|
||||||
|
*
|
||||||
|
* Returns verification sessions filtered by delivery method.
|
||||||
|
* Used for method-specific analytics and management.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param method Verification method
|
||||||
|
* @return List of verification sessions for the method
|
||||||
|
*/
|
||||||
|
List<TwoFactorVerification> findByUserIdAndMethod(Long userId, TwoFactorVerification.Method method);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find expired verification sessions
|
||||||
|
*
|
||||||
|
* Returns all verification sessions that have passed their expiration time.
|
||||||
|
* Used for cleanup processes and security monitoring.
|
||||||
|
*
|
||||||
|
* @param now Current timestamp
|
||||||
|
* @return List of expired verification sessions
|
||||||
|
*/
|
||||||
|
@Query("SELECT v FROM TwoFactorVerification v WHERE v.expiresAt <= :now")
|
||||||
|
List<TwoFactorVerification> findExpiredVerifications(@Param("now") LocalDateTime now);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find blocked verification sessions
|
||||||
|
*
|
||||||
|
* Returns verification sessions that have been blocked due to
|
||||||
|
* too many failed attempts. Used for security analysis.
|
||||||
|
*
|
||||||
|
* @return List of blocked verification sessions
|
||||||
|
*/
|
||||||
|
List<TwoFactorVerification> findByIsBlockedTrue();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find used verification sessions
|
||||||
|
*
|
||||||
|
* Returns verification sessions that have been successfully completed.
|
||||||
|
* Used for audit trails and success rate analysis.
|
||||||
|
*
|
||||||
|
* @return List of used verification sessions
|
||||||
|
*/
|
||||||
|
List<TwoFactorVerification> findByIsUsedTrue();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find verifications created within date range
|
||||||
|
*
|
||||||
|
* Returns verification sessions created within a specific time period.
|
||||||
|
* Used for analytics and monitoring 2FA usage patterns.
|
||||||
|
*
|
||||||
|
* @param startDate Start of date range
|
||||||
|
* @param endDate End of date range
|
||||||
|
* @return List of verifications created in date range
|
||||||
|
*/
|
||||||
|
@Query("SELECT v FROM TwoFactorVerification v WHERE v.createdAt BETWEEN :startDate AND :endDate " +
|
||||||
|
"ORDER BY v.createdAt")
|
||||||
|
List<TwoFactorVerification> findByCreatedAtBetween(@Param("startDate") LocalDateTime startDate,
|
||||||
|
@Param("endDate") LocalDateTime endDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find verifications by IP address
|
||||||
|
*
|
||||||
|
* Returns verification sessions initiated from a specific IP address.
|
||||||
|
* Used for location-based security analysis and fraud detection.
|
||||||
|
*
|
||||||
|
* @param ipAddress IP address to search for
|
||||||
|
* @return List of verifications from the IP address
|
||||||
|
*/
|
||||||
|
List<TwoFactorVerification> findByIpAddress(String ipAddress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count active verifications for user
|
||||||
|
*
|
||||||
|
* Returns the number of active verification sessions for a user.
|
||||||
|
* Used for preventing excessive concurrent verification attempts.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param now Current timestamp
|
||||||
|
* @return Number of active verification sessions
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(v) FROM TwoFactorVerification v WHERE v.userId = :userId " +
|
||||||
|
"AND v.isUsed = false AND v.isBlocked = false AND v.expiresAt > :now")
|
||||||
|
long countActiveVerificationsByUser(@Param("userId") Long userId, @Param("now") LocalDateTime now);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count verification attempts by IP address within time window
|
||||||
|
*
|
||||||
|
* Returns the number of verification attempts from an IP address
|
||||||
|
* within a specified time window. Used for rate limiting.
|
||||||
|
*
|
||||||
|
* @param ipAddress IP address to check
|
||||||
|
* @param since Time window start
|
||||||
|
* @return Number of verification attempts
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(v) FROM TwoFactorVerification v WHERE v.ipAddress = :ipAddress " +
|
||||||
|
"AND v.createdAt >= :since")
|
||||||
|
long countVerificationAttemptsByIpSince(@Param("ipAddress") String ipAddress,
|
||||||
|
@Param("since") LocalDateTime since);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count verification attempts by user within time window
|
||||||
|
*
|
||||||
|
* Returns the number of verification attempts by a user
|
||||||
|
* within a specified time window. Used for user-based rate limiting.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param since Time window start
|
||||||
|
* @return Number of verification attempts
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(v) FROM TwoFactorVerification v WHERE v.userId = :userId " +
|
||||||
|
"AND v.createdAt >= :since")
|
||||||
|
long countVerificationAttemptsByUserSince(@Param("userId") Long userId,
|
||||||
|
@Param("since") LocalDateTime since);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find verification sessions ready for cleanup
|
||||||
|
*
|
||||||
|
* Returns verification sessions that are expired and can be safely deleted.
|
||||||
|
* Includes additional time buffer for audit purposes.
|
||||||
|
*
|
||||||
|
* @param cleanupThreshold Timestamp before which sessions can be deleted
|
||||||
|
* @return List of verification sessions ready for cleanup
|
||||||
|
*/
|
||||||
|
@Query("SELECT v FROM TwoFactorVerification v WHERE " +
|
||||||
|
"v.expiresAt < :cleanupThreshold AND " +
|
||||||
|
"(v.isUsed = true OR v.isBlocked = true)")
|
||||||
|
List<TwoFactorVerification> findVerificationsForCleanup(@Param("cleanupThreshold") LocalDateTime cleanupThreshold);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update verification session attempts
|
||||||
|
*
|
||||||
|
* Increments the attempt counter and optionally blocks the session
|
||||||
|
* if maximum attempts are exceeded.
|
||||||
|
*
|
||||||
|
* @param verificationId Verification session ID
|
||||||
|
* @return Number of affected rows (should be 1 for success)
|
||||||
|
*/
|
||||||
|
@Query("UPDATE TwoFactorVerification v SET " +
|
||||||
|
"v.attempts = v.attempts + 1, " +
|
||||||
|
"v.isBlocked = CASE WHEN v.attempts + 1 >= v.maxAttempts THEN true ELSE v.isBlocked END " +
|
||||||
|
"WHERE v.id = :verificationId")
|
||||||
|
int incrementAttempts(@Param("verificationId") Long verificationId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark verification as used
|
||||||
|
*
|
||||||
|
* Sets the verification session as successfully completed.
|
||||||
|
* Prevents code reuse and indicates device registration can proceed.
|
||||||
|
*
|
||||||
|
* @param verificationId Verification session ID
|
||||||
|
* @return Number of affected rows (should be 1 for success)
|
||||||
|
*/
|
||||||
|
@Query("UPDATE TwoFactorVerification v SET v.isUsed = true WHERE v.id = :verificationId")
|
||||||
|
int markAsUsed(@Param("verificationId") Long verificationId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block verification session
|
||||||
|
*
|
||||||
|
* Manually blocks a verification session for security reasons.
|
||||||
|
* Used when suspicious activity is detected.
|
||||||
|
*
|
||||||
|
* @param verificationId Verification session ID
|
||||||
|
* @return Number of affected rows (should be 1 for success)
|
||||||
|
*/
|
||||||
|
@Query("UPDATE TwoFactorVerification v SET v.isBlocked = true WHERE v.id = :verificationId")
|
||||||
|
int blockVerification(@Param("verificationId") Long verificationId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete expired verification sessions
|
||||||
|
*
|
||||||
|
* Permanently removes expired verification sessions from database.
|
||||||
|
* Used for regular cleanup to maintain database performance.
|
||||||
|
*
|
||||||
|
* @param cleanupThreshold Timestamp before which sessions are deleted
|
||||||
|
* @return Number of deleted verification sessions
|
||||||
|
*/
|
||||||
|
@Query("DELETE FROM TwoFactorVerification v WHERE v.expiresAt < :cleanupThreshold")
|
||||||
|
int deleteExpiredVerifications(@Param("cleanupThreshold") LocalDateTime cleanupThreshold);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all verification sessions for a user
|
||||||
|
*
|
||||||
|
* Removes all verification sessions associated with a user.
|
||||||
|
* Used when user account is deleted or for security cleanup.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @return Number of deleted verification sessions
|
||||||
|
*/
|
||||||
|
int deleteByUserId(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get verification statistics by method
|
||||||
|
*
|
||||||
|
* Returns aggregated statistics for different verification methods.
|
||||||
|
* Used for analytics and method effectiveness analysis.
|
||||||
|
*
|
||||||
|
* @param startDate Start of analysis period
|
||||||
|
* @param endDate End of analysis period
|
||||||
|
* @return List of arrays containing [method, total_count, success_count, block_count]
|
||||||
|
*/
|
||||||
|
@Query("SELECT " +
|
||||||
|
"v.method, " +
|
||||||
|
"COUNT(v), " +
|
||||||
|
"SUM(CASE WHEN v.isUsed = true THEN 1 ELSE 0 END), " +
|
||||||
|
"SUM(CASE WHEN v.isBlocked = true THEN 1 ELSE 0 END) " +
|
||||||
|
"FROM TwoFactorVerification v " +
|
||||||
|
"WHERE v.createdAt BETWEEN :startDate AND :endDate " +
|
||||||
|
"GROUP BY v.method")
|
||||||
|
List<Object[]> getVerificationStatisticsByMethod(@Param("startDate") LocalDateTime startDate,
|
||||||
|
@Param("endDate") LocalDateTime endDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find recent verification failures for user
|
||||||
|
*
|
||||||
|
* Returns recent failed verification attempts for security monitoring.
|
||||||
|
* Used for detecting potential account compromise attempts.
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param since Time window start
|
||||||
|
* @return List of recent failed verification attempts
|
||||||
|
*/
|
||||||
|
@Query("SELECT v FROM TwoFactorVerification v WHERE v.userId = :userId " +
|
||||||
|
"AND v.createdAt >= :since AND v.isBlocked = true " +
|
||||||
|
"ORDER BY v.createdAt DESC")
|
||||||
|
List<TwoFactorVerification> findRecentFailuresByUser(@Param("userId") Long userId,
|
||||||
|
@Param("since") LocalDateTime since);
|
||||||
|
}
|
||||||
245
src/main/java/com/company/auth/repository/UserRepository.java
Normal file
245
src/main/java/com/company/auth/repository/UserRepository.java
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* User Repository
|
||||||
|
*
|
||||||
|
* JPA repository interface for User entity operations.
|
||||||
|
* Provides database access methods for user authentication,
|
||||||
|
* registration, and profile management.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.repository;
|
||||||
|
|
||||||
|
import com.company.auth.entity.User;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository interface for User entity
|
||||||
|
*
|
||||||
|
* Extends JpaRepository to provide standard CRUD operations plus
|
||||||
|
* custom query methods for authentication and user management.
|
||||||
|
* All methods are automatically implemented by Spring Data JPA.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find user by email address
|
||||||
|
*
|
||||||
|
* Primary method for user authentication and login.
|
||||||
|
* Email addresses are unique in the system.
|
||||||
|
*
|
||||||
|
* @param email User's email address
|
||||||
|
* @return Optional containing user if found, empty otherwise
|
||||||
|
*/
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find user by email (case-insensitive)
|
||||||
|
*
|
||||||
|
* Alternative lookup method that handles case variations
|
||||||
|
* in email addresses for better user experience.
|
||||||
|
*
|
||||||
|
* @param email User's email address (any case)
|
||||||
|
* @return Optional containing user if found, empty otherwise
|
||||||
|
*/
|
||||||
|
Optional<User> findByEmailIgnoreCase(String email);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user exists by email
|
||||||
|
*
|
||||||
|
* Efficient method to verify email uniqueness during registration
|
||||||
|
* without loading the full user entity.
|
||||||
|
*
|
||||||
|
* @param email Email address to check
|
||||||
|
* @return true if user with email exists, false otherwise
|
||||||
|
*/
|
||||||
|
boolean existsByEmail(String email);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user exists by email (case-insensitive)
|
||||||
|
*
|
||||||
|
* Case-insensitive version for email uniqueness validation.
|
||||||
|
*
|
||||||
|
* @param email Email address to check (any case)
|
||||||
|
* @return true if user with email exists, false otherwise
|
||||||
|
*/
|
||||||
|
boolean existsByEmailIgnoreCase(String email);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all active users
|
||||||
|
*
|
||||||
|
* Returns only users with active account status.
|
||||||
|
* Used for administrative functions and user management.
|
||||||
|
*
|
||||||
|
* @return List of active users
|
||||||
|
*/
|
||||||
|
List<User> findByIsActiveTrue();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all inactive users
|
||||||
|
*
|
||||||
|
* Returns users with inactive account status.
|
||||||
|
* Used for account cleanup and administrative review.
|
||||||
|
*
|
||||||
|
* @return List of inactive users
|
||||||
|
*/
|
||||||
|
List<User> findByIsActiveFalse();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find users by email verification status
|
||||||
|
*
|
||||||
|
* Returns users based on their email verification status.
|
||||||
|
* Used for email verification campaigns and account management.
|
||||||
|
*
|
||||||
|
* @param emailVerified Email verification status
|
||||||
|
* @return List of users with specified verification status
|
||||||
|
*/
|
||||||
|
List<User> findByEmailVerified(Boolean emailVerified);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find users who haven't logged in recently
|
||||||
|
*
|
||||||
|
* Identifies inactive users for account cleanup or re-engagement.
|
||||||
|
* Users with null lastLoginAt are considered as never logged in.
|
||||||
|
*
|
||||||
|
* @param cutoffDate Date threshold for recent login
|
||||||
|
* @return List of users who haven't logged in since cutoff date
|
||||||
|
*/
|
||||||
|
@Query("SELECT u FROM User u WHERE u.lastLoginAt IS NULL OR u.lastLoginAt < :cutoffDate")
|
||||||
|
List<User> findUsersNotLoggedInSince(@Param("cutoffDate") LocalDateTime cutoffDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find recently registered users
|
||||||
|
*
|
||||||
|
* Returns users who registered after a specific date.
|
||||||
|
* Used for new user onboarding and analytics.
|
||||||
|
*
|
||||||
|
* @param sinceDate Date threshold for recent registration
|
||||||
|
* @return List of recently registered users
|
||||||
|
*/
|
||||||
|
@Query("SELECT u FROM User u WHERE u.createdAt >= :sinceDate ORDER BY u.createdAt DESC")
|
||||||
|
List<User> findRecentlyRegisteredUsers(@Param("sinceDate") LocalDateTime sinceDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find users by partial name match
|
||||||
|
*
|
||||||
|
* Searches users by first name or last name containing the search term.
|
||||||
|
* Used for user search functionality in administrative interfaces.
|
||||||
|
*
|
||||||
|
* @param searchTerm Partial name to search for
|
||||||
|
* @return List of users matching search criteria
|
||||||
|
*/
|
||||||
|
@Query("SELECT u FROM User u WHERE " +
|
||||||
|
"LOWER(u.firstName) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR " +
|
||||||
|
"LOWER(u.lastName) LIKE LOWER(CONCAT('%', :searchTerm, '%'))")
|
||||||
|
List<User> findByNameContaining(@Param("searchTerm") String searchTerm);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find users with specific role
|
||||||
|
*
|
||||||
|
* Searches users whose roles JSON contains the specified role.
|
||||||
|
* Used for role-based user management and permissions.
|
||||||
|
*
|
||||||
|
* @param role Role to search for
|
||||||
|
* @return List of users with the specified role
|
||||||
|
*/
|
||||||
|
@Query(value = "SELECT * FROM users u WHERE JSON_CONTAINS(u.roles, JSON_QUOTE(?1))", nativeQuery = true)
|
||||||
|
List<User> findByRole(@Param("role") String role);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count active users
|
||||||
|
*
|
||||||
|
* Returns the total number of active user accounts.
|
||||||
|
* Used for dashboard metrics and system monitoring.
|
||||||
|
*
|
||||||
|
* @return Number of active users
|
||||||
|
*/
|
||||||
|
long countByIsActiveTrue();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count verified users
|
||||||
|
*
|
||||||
|
* Returns the total number of email-verified users.
|
||||||
|
* Used for user engagement metrics and email campaign effectiveness.
|
||||||
|
*
|
||||||
|
* @return Number of verified users
|
||||||
|
*/
|
||||||
|
long countByEmailVerifiedTrue();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find users created between dates
|
||||||
|
*
|
||||||
|
* Returns users who registered within a specific date range.
|
||||||
|
* Used for analytics and reporting on user growth.
|
||||||
|
*
|
||||||
|
* @param startDate Start of date range
|
||||||
|
* @param endDate End of date range
|
||||||
|
* @return List of users created in date range
|
||||||
|
*/
|
||||||
|
@Query("SELECT u FROM User u WHERE u.createdAt BETWEEN :startDate AND :endDate ORDER BY u.createdAt")
|
||||||
|
List<User> findByCreatedAtBetween(@Param("startDate") LocalDateTime startDate,
|
||||||
|
@Param("endDate") LocalDateTime endDate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user last login timestamp
|
||||||
|
*
|
||||||
|
* Efficiently updates only the last login timestamp without
|
||||||
|
* loading the full entity. Used during authentication process.
|
||||||
|
*
|
||||||
|
* @param userId User ID to update
|
||||||
|
* @param loginTime New login timestamp
|
||||||
|
* @return Number of affected rows (should be 1 for success)
|
||||||
|
*/
|
||||||
|
@Query("UPDATE User u SET u.lastLoginAt = :loginTime WHERE u.id = :userId")
|
||||||
|
int updateLastLoginAt(@Param("userId") Long userId, @Param("loginTime") LocalDateTime loginTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user email verification status
|
||||||
|
*
|
||||||
|
* Efficiently updates email verification status without loading
|
||||||
|
* the full entity. Used during email verification process.
|
||||||
|
*
|
||||||
|
* @param userId User ID to update
|
||||||
|
* @param verified New verification status
|
||||||
|
* @return Number of affected rows (should be 1 for success)
|
||||||
|
*/
|
||||||
|
@Query("UPDATE User u SET u.emailVerified = :verified WHERE u.id = :userId")
|
||||||
|
int updateEmailVerificationStatus(@Param("userId") Long userId, @Param("verified") Boolean verified);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate user account
|
||||||
|
*
|
||||||
|
* Sets user account to inactive status. Used for account
|
||||||
|
* suspension or user-requested account deactivation.
|
||||||
|
*
|
||||||
|
* @param userId User ID to deactivate
|
||||||
|
* @return Number of affected rows (should be 1 for success)
|
||||||
|
*/
|
||||||
|
@Query("UPDATE User u SET u.isActive = false WHERE u.id = :userId")
|
||||||
|
int deactivateUser(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search users with pagination
|
||||||
|
*
|
||||||
|
* Searches users by name or email with pagination support.
|
||||||
|
* Used for administrative user management interfaces.
|
||||||
|
*
|
||||||
|
* @param searchTerm Search term for name or email
|
||||||
|
* @param pageable Pagination parameters
|
||||||
|
* @return Page of users matching search criteria
|
||||||
|
*/
|
||||||
|
@Query("SELECT u FROM User u WHERE " +
|
||||||
|
"LOWER(u.firstName) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR " +
|
||||||
|
"LOWER(u.lastName) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR " +
|
||||||
|
"LOWER(u.email) LIKE LOWER(CONCAT('%', :searchTerm, '%'))")
|
||||||
|
org.springframework.data.domain.Page<User> searchUsers(@Param("searchTerm") String searchTerm,
|
||||||
|
org.springframework.data.domain.Pageable pageable);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* JWT Authentication Entry Point
|
||||||
|
*
|
||||||
|
* Handles authentication errors for JWT-based security.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Authentication Entry Point
|
||||||
|
*
|
||||||
|
* Handles authentication failures and returns standardized error responses.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class);
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles authentication entry point
|
||||||
|
*
|
||||||
|
* @param request HTTP request
|
||||||
|
* @param response HTTP response
|
||||||
|
* @param authException Authentication exception
|
||||||
|
* @throws IOException if response writing fails
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void commence(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
AuthenticationException authException) throws IOException {
|
||||||
|
|
||||||
|
logger.warn("Authentication failed for request: {} {}, Error: {}",
|
||||||
|
request.getMethod(), request.getRequestURI(), authException.getMessage());
|
||||||
|
|
||||||
|
// Prepare error response
|
||||||
|
Map<String, Object> errorResponse = new HashMap<>();
|
||||||
|
errorResponse.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
|
||||||
|
errorResponse.put("status", HttpStatus.UNAUTHORIZED.value());
|
||||||
|
errorResponse.put("error", "Unauthorized");
|
||||||
|
errorResponse.put("message", "Authentication required");
|
||||||
|
errorResponse.put("path", request.getRequestURI());
|
||||||
|
|
||||||
|
// Add specific error details based on exception type
|
||||||
|
String errorDetail = getErrorDetail(authException);
|
||||||
|
if (errorDetail != null) {
|
||||||
|
errorResponse.put("detail", errorDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set response properties
|
||||||
|
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
|
||||||
|
// Add security headers
|
||||||
|
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
|
response.setHeader("Pragma", "no-cache");
|
||||||
|
response.setHeader("Expires", "0");
|
||||||
|
|
||||||
|
// Write error response
|
||||||
|
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets specific error details based on exception type
|
||||||
|
*
|
||||||
|
* @param authException Authentication exception
|
||||||
|
* @return Error detail message
|
||||||
|
*/
|
||||||
|
private String getErrorDetail(AuthenticationException authException) {
|
||||||
|
String message = authException.getMessage();
|
||||||
|
|
||||||
|
if (message == null) {
|
||||||
|
return "Authentication failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map common authentication error messages
|
||||||
|
if (message.contains("JWT expired")) {
|
||||||
|
return "Token has expired";
|
||||||
|
} else if (message.contains("JWT signature")) {
|
||||||
|
return "Invalid token signature";
|
||||||
|
} else if (message.contains("JWT malformed")) {
|
||||||
|
return "Malformed token";
|
||||||
|
} else if (message.contains("JWT token compact")) {
|
||||||
|
return "Invalid token format";
|
||||||
|
} else if (message.contains("Full authentication is required")) {
|
||||||
|
return "Authentication token is required";
|
||||||
|
} else if (message.contains("Access Denied")) {
|
||||||
|
return "Insufficient permissions";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return generic message for security
|
||||||
|
return "Authentication failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* JWT Authentication Filter
|
||||||
|
*
|
||||||
|
* Servlet filter for JWT token authentication.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.security;
|
||||||
|
|
||||||
|
import com.company.auth.service.JwtTokenService;
|
||||||
|
import com.company.auth.service.UserService;
|
||||||
|
import com.company.auth.entity.User;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Authentication Filter
|
||||||
|
*
|
||||||
|
* Processes JWT tokens from request headers and sets up Spring Security context.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
|
||||||
|
|
||||||
|
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||||
|
private static final String BEARER_PREFIX = "Bearer ";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtTokenService jwtTokenService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters incoming requests for JWT authentication
|
||||||
|
*
|
||||||
|
* @param request HTTP request
|
||||||
|
* @param response HTTP response
|
||||||
|
* @param filterChain Filter chain
|
||||||
|
* @throws ServletException if filter processing fails
|
||||||
|
* @throws IOException if I/O error occurs
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(@NonNull HttpServletRequest request,
|
||||||
|
@NonNull HttpServletResponse response,
|
||||||
|
@NonNull FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract JWT token from request
|
||||||
|
String jwt = extractJwtFromRequest(request);
|
||||||
|
|
||||||
|
if (jwt != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
|
// Validate and process JWT token
|
||||||
|
processJwtToken(jwt, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Cannot set user authentication in security context", e);
|
||||||
|
// Don't throw exception here - let the request proceed to be handled by AuthenticationEntryPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts JWT token from Authorization header
|
||||||
|
*
|
||||||
|
* @param request HTTP request
|
||||||
|
* @return JWT token or null if not found
|
||||||
|
*/
|
||||||
|
private String extractJwtFromRequest(HttpServletRequest request) {
|
||||||
|
String authorizationHeader = request.getHeader(AUTHORIZATION_HEADER);
|
||||||
|
|
||||||
|
if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) {
|
||||||
|
return authorizationHeader.substring(BEARER_PREFIX.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes JWT token and sets up authentication context
|
||||||
|
*
|
||||||
|
* @param jwt JWT token
|
||||||
|
* @param request HTTP request
|
||||||
|
*/
|
||||||
|
private void processJwtToken(String jwt, HttpServletRequest request) {
|
||||||
|
try {
|
||||||
|
// Validate token and get user ID
|
||||||
|
Long userId = jwtTokenService.validateAccessToken(jwt);
|
||||||
|
if (userId == null) {
|
||||||
|
logger.debug("Invalid JWT token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user information from token
|
||||||
|
String email = jwtTokenService.getEmailFromToken(jwt);
|
||||||
|
|
||||||
|
if (email != null && userId != null) {
|
||||||
|
// Load user details
|
||||||
|
UserDetails userDetails = userService.loadUserByUsername(email);
|
||||||
|
|
||||||
|
// Verify user still exists and is enabled
|
||||||
|
if (userDetails instanceof User) {
|
||||||
|
User user = (User) userDetails;
|
||||||
|
|
||||||
|
// Additional security checks
|
||||||
|
if (!user.getId().equals(userId)) {
|
||||||
|
logger.warn("Token user ID mismatch for email: {}", email);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isEnabled()) {
|
||||||
|
logger.warn("Disabled user attempted access: {}", email);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.isAccountNonLocked()) {
|
||||||
|
logger.warn("Locked user attempted access: {}", email);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create authentication token
|
||||||
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
|
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
||||||
|
|
||||||
|
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
|
||||||
|
// Set authentication in security context
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
|
||||||
|
logger.debug("Successfully authenticated user: {}", email);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error processing JWT token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the filter should be applied to the request
|
||||||
|
*
|
||||||
|
* @param request HTTP request
|
||||||
|
* @return false to apply filter to all requests
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
// Apply filter to all requests - let the security configuration handle authorization
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
712
src/main/java/com/company/auth/service/AuthService.java
Normal file
712
src/main/java/com/company/auth/service/AuthService.java
Normal file
@@ -0,0 +1,712 @@
|
|||||||
|
/**
|
||||||
|
* Authentication Service
|
||||||
|
*
|
||||||
|
* Core service for user authentication, registration, and session management.
|
||||||
|
* Handles login flows, device verification, two-factor authentication,
|
||||||
|
* and security risk assessment.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.service;
|
||||||
|
|
||||||
|
import com.company.auth.dto.request.*;
|
||||||
|
import com.company.auth.dto.response.*;
|
||||||
|
import com.company.auth.entity.*;
|
||||||
|
import com.company.auth.repository.*;
|
||||||
|
import com.company.auth.exception.*;
|
||||||
|
import com.company.auth.util.*;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication Service Implementation
|
||||||
|
*
|
||||||
|
* Provides comprehensive authentication services including:
|
||||||
|
* - User registration and login
|
||||||
|
* - Device fingerprinting and trust management
|
||||||
|
* - Two-factor authentication
|
||||||
|
* - Security risk assessment
|
||||||
|
* - Session and token management
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(AuthService.class);
|
||||||
|
|
||||||
|
// Maximum failed login attempts before account lockout
|
||||||
|
private static final int MAX_FAILED_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
// Session timeout for incomplete authentication (minutes)
|
||||||
|
private static final int SESSION_TIMEOUT_MINUTES = 15;
|
||||||
|
|
||||||
|
// Device verification timeout (minutes)
|
||||||
|
private static final int DEVICE_VERIFICATION_TIMEOUT = 10;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TrustedDeviceRepository trustedDeviceRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TwoFactorVerificationRepository twoFactorRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DeviceFingerprintService deviceFingerprintService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TwoFactorService twoFactorService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtTokenService jwtTokenService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EmailService emailService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SmsService smsService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SecurityService securityService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new user account
|
||||||
|
*
|
||||||
|
* @param request Registration request data
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
* @param userAgent Client user agent
|
||||||
|
* @return Authentication response with tokens or verification requirements
|
||||||
|
* @throws UserAlreadyExistsException if user with email already exists
|
||||||
|
* @throws InvalidRegistrationDataException if registration data is invalid
|
||||||
|
*/
|
||||||
|
public AuthResponse register(RegisterRequest request, String ipAddress, String userAgent) {
|
||||||
|
logger.info("Attempting user registration for email: {}", request.getEmail());
|
||||||
|
|
||||||
|
// Validate request data
|
||||||
|
validateRegistrationRequest(request);
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
|
||||||
|
throw new UserAlreadyExistsException("User with email already exists: " + request.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create new user entity
|
||||||
|
User user = createUserFromRequest(request, ipAddress);
|
||||||
|
|
||||||
|
// Save user to database
|
||||||
|
user = userRepository.save(user);
|
||||||
|
logger.info("User registered successfully with ID: {}", user.getId());
|
||||||
|
|
||||||
|
// Process device fingerprint if provided
|
||||||
|
TrustedDevice device = null;
|
||||||
|
if (request.getDeviceFingerprint() != null) {
|
||||||
|
device = deviceFingerprintService.processDeviceFingerprint(
|
||||||
|
user, request.getDeviceFingerprint(), ipAddress, userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if verification is required
|
||||||
|
boolean requiresEmailVerification = !user.getEmailVerified();
|
||||||
|
boolean requiresDeviceVerification = device != null && !device.getIsTrusted();
|
||||||
|
|
||||||
|
if (requiresEmailVerification) {
|
||||||
|
// Send email verification
|
||||||
|
emailService.sendEmailVerification(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresDeviceVerification) {
|
||||||
|
// Send device verification
|
||||||
|
String sessionToken = jwtTokenService.generateSessionToken(user.getId(), device.getId());
|
||||||
|
emailService.sendDeviceVerificationCode(user, device);
|
||||||
|
|
||||||
|
AuthResponse response = AuthResponse.requireDeviceVerification(sessionToken);
|
||||||
|
response.setIpAddress(ipAddress);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate authentication tokens
|
||||||
|
String accessToken = jwtTokenService.generateAccessToken(user);
|
||||||
|
String refreshToken = jwtTokenService.generateRefreshToken(user);
|
||||||
|
LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(jwtTokenService.getAccessTokenExpirationMinutes());
|
||||||
|
|
||||||
|
// Update user login information
|
||||||
|
updateUserLoginInfo(user, ipAddress, userAgent);
|
||||||
|
|
||||||
|
// Create success response
|
||||||
|
AuthResponse response = AuthResponse.success(accessToken, refreshToken, expiresAt, UserResponse.fromEntity(user));
|
||||||
|
response.setIpAddress(ipAddress);
|
||||||
|
response.setIsNewDevice(device != null);
|
||||||
|
|
||||||
|
if (device != null) {
|
||||||
|
response.setDeviceTrustStatus(device.getTrustStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Registration completed successfully for user: {}", user.getId());
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Registration failed for email: {}", request.getEmail(), e);
|
||||||
|
throw new RegistrationException("Registration failed: " + e.getMessage(), e);
|
||||||
|
} finally {
|
||||||
|
// Clear sensitive data
|
||||||
|
request.clearSensitiveData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates a user login attempt
|
||||||
|
*
|
||||||
|
* @param request Login request data
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
* @param userAgent Client user agent
|
||||||
|
* @return Authentication response with tokens or verification requirements
|
||||||
|
* @throws InvalidCredentialsException if credentials are invalid
|
||||||
|
* @throws AccountLockedException if account is locked
|
||||||
|
* @throws AccountDisabledException if account is disabled
|
||||||
|
*/
|
||||||
|
public AuthResponse login(LoginRequest request, String ipAddress, String userAgent) {
|
||||||
|
logger.info("Attempting login for email: {}", request.getEmail());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find user by email
|
||||||
|
User user = userRepository.findByEmail(request.getEmail())
|
||||||
|
.orElseThrow(() -> new InvalidCredentialsException("Invalid email or password"));
|
||||||
|
|
||||||
|
// Check account status
|
||||||
|
validateAccountStatus(user);
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||||
|
handleFailedLoginAttempt(user, ipAddress);
|
||||||
|
throw new InvalidCredentialsException("Invalid email or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset failed attempts on successful password verification
|
||||||
|
if (user.getFailedLoginAttempts() > 0) {
|
||||||
|
user.setFailedLoginAttempts(0);
|
||||||
|
user.setLockedUntil(null);
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process device fingerprint
|
||||||
|
DeviceAuthResult deviceResult = processDeviceAuthentication(
|
||||||
|
user, request.getFingerprint(), ipAddress, userAgent);
|
||||||
|
|
||||||
|
// Check if 2FA is required
|
||||||
|
boolean requires2FA = user.getTwoFactorEnabled() && !deviceResult.isTrustedFor2FA();
|
||||||
|
|
||||||
|
// Check if device verification is required
|
||||||
|
boolean requiresDeviceVerification = !deviceResult.isTrusted();
|
||||||
|
|
||||||
|
// Handle multi-step authentication
|
||||||
|
if (requires2FA || requiresDeviceVerification) {
|
||||||
|
String sessionToken = jwtTokenService.generateSessionToken(user.getId(), deviceResult.getDeviceId());
|
||||||
|
|
||||||
|
if (requiresDeviceVerification) {
|
||||||
|
// Send device verification code
|
||||||
|
emailService.sendDeviceVerificationCode(user, deviceResult.getDevice());
|
||||||
|
|
||||||
|
AuthResponse response = AuthResponse.requireDeviceVerification(sessionToken);
|
||||||
|
response.setIpAddress(ipAddress);
|
||||||
|
response.setIsNewDevice(deviceResult.isNewDevice());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requires2FA) {
|
||||||
|
// Determine available 2FA methods
|
||||||
|
List<TwoFactorMethod> availableMethods = twoFactorService.getAvailableMethods(user);
|
||||||
|
|
||||||
|
// Send 2FA code if SMS is preferred
|
||||||
|
if (TwoFactorMethod.SMS.name().equals(user.getTwoFactorMethod())) {
|
||||||
|
twoFactorService.sendSmsCode(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthResponse response = AuthResponse.requireTwoFactor(sessionToken,
|
||||||
|
availableMethods.stream().map(Enum::name).toArray(String[]::new));
|
||||||
|
response.setIpAddress(ipAddress);
|
||||||
|
response.setIsNewDevice(deviceResult.isNewDevice());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete authentication
|
||||||
|
return completeAuthentication(user, deviceResult, ipAddress, userAgent);
|
||||||
|
|
||||||
|
} catch (AuthenticationException e) {
|
||||||
|
logger.warn("Authentication failed for email: {} - {}", request.getEmail(), e.getMessage());
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Unexpected error during login for email: {}", request.getEmail(), e);
|
||||||
|
throw new AuthenticationException("Login failed due to system error", e);
|
||||||
|
} finally {
|
||||||
|
// Clear sensitive data
|
||||||
|
request.clearSensitiveData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a device using verification code
|
||||||
|
*
|
||||||
|
* @param request Device verification request
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
* @param userAgent Client user agent
|
||||||
|
* @return Authentication response
|
||||||
|
* @throws InvalidVerificationCodeException if code is invalid
|
||||||
|
* @throws VerificationExpiredException if verification has expired
|
||||||
|
*/
|
||||||
|
public AuthResponse verifyDevice(DeviceVerificationRequest request, String ipAddress, String userAgent) {
|
||||||
|
logger.info("Attempting device verification for email: {}", request.getEmail());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate session token
|
||||||
|
Long userId = jwtTokenService.validateSessionToken(request.getSessionToken());
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
// Find the device verification record
|
||||||
|
Optional<TrustedDevice> deviceOpt = trustedDeviceRepository.findByIdAndUserId(
|
||||||
|
Long.parseLong(request.getDeviceId()), user.getId());
|
||||||
|
|
||||||
|
if (deviceOpt.isEmpty()) {
|
||||||
|
throw new DeviceNotFoundException("Device not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
TrustedDevice device = deviceOpt.get();
|
||||||
|
|
||||||
|
// Verify the code
|
||||||
|
boolean codeValid = deviceFingerprintService.verifyDeviceCode(device, request.getCode());
|
||||||
|
|
||||||
|
if (!codeValid) {
|
||||||
|
device.setFailedAttempts(device.getFailedAttempts() + 1);
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
throw new InvalidVerificationCodeException("Invalid verification code");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark device as trusted if requested
|
||||||
|
if (Boolean.TRUE.equals(request.getTrustDevice())) {
|
||||||
|
deviceFingerprintService.trustDevice(device, request.getRememberForDays());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update device information
|
||||||
|
if (request.getDeviceName() != null) {
|
||||||
|
device.setDeviceName(request.getDeviceName());
|
||||||
|
}
|
||||||
|
device.setLastSeenAt(LocalDateTime.now());
|
||||||
|
device.setLastIpAddress(ipAddress);
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
|
||||||
|
// Check if 2FA is still required
|
||||||
|
if (user.getTwoFactorEnabled() && !deviceFingerprintService.isTrustedFor2FA(device)) {
|
||||||
|
String sessionToken = jwtTokenService.generateSessionToken(user.getId(), device.getId());
|
||||||
|
List<TwoFactorMethod> availableMethods = twoFactorService.getAvailableMethods(user);
|
||||||
|
|
||||||
|
AuthResponse response = AuthResponse.requireTwoFactor(sessionToken,
|
||||||
|
availableMethods.stream().map(Enum::name).toArray(String[]::new));
|
||||||
|
response.setIpAddress(ipAddress);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete authentication
|
||||||
|
DeviceAuthResult deviceResult = new DeviceAuthResult(device, false, true, true);
|
||||||
|
return completeAuthentication(user, deviceResult, ipAddress, userAgent);
|
||||||
|
|
||||||
|
} catch (AuthenticationException e) {
|
||||||
|
logger.warn("Device verification failed: {}", e.getMessage());
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Unexpected error during device verification", e);
|
||||||
|
throw new DeviceVerificationException("Device verification failed", e);
|
||||||
|
} finally {
|
||||||
|
request.clearSensitiveData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies two-factor authentication code
|
||||||
|
*
|
||||||
|
* @param request 2FA verification request
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
* @param userAgent Client user agent
|
||||||
|
* @return Authentication response with tokens
|
||||||
|
* @throws InvalidTwoFactorCodeException if 2FA code is invalid
|
||||||
|
* @throws TwoFactorExpiredException if 2FA has expired
|
||||||
|
*/
|
||||||
|
public AuthResponse verifyTwoFactor(TwoFactorAuthRequest request, String ipAddress, String userAgent) {
|
||||||
|
logger.info("Attempting 2FA verification for email: {}", request.getEmail());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate session token
|
||||||
|
Long userId = jwtTokenService.validateSessionToken(request.getSessionToken());
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
// Verify 2FA code
|
||||||
|
boolean codeValid;
|
||||||
|
if (Boolean.TRUE.equals(request.getIsRecoveryCode())) {
|
||||||
|
codeValid = twoFactorService.verifyRecoveryCode(user, request.getCode());
|
||||||
|
} else {
|
||||||
|
codeValid = twoFactorService.verifyCode(user, request.getCode(), request.getMethod());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!codeValid) {
|
||||||
|
// Log failed 2FA attempt
|
||||||
|
twoFactorService.logFailedAttempt(user, request.getMethod(), ipAddress);
|
||||||
|
throw new InvalidTwoFactorCodeException("Invalid 2FA code");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create device
|
||||||
|
TrustedDevice device = findOrCreateDevice(user, ipAddress, userAgent);
|
||||||
|
|
||||||
|
// Mark device as 2FA trusted if requested
|
||||||
|
if (Boolean.TRUE.equals(request.getRememberDevice()) && device != null) {
|
||||||
|
deviceFingerprintService.trustDeviceFor2FA(device, request.getTrustDurationDays());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete authentication
|
||||||
|
DeviceAuthResult deviceResult = new DeviceAuthResult(device, false, true, true);
|
||||||
|
return completeAuthentication(user, deviceResult, ipAddress, userAgent);
|
||||||
|
|
||||||
|
} catch (AuthenticationException e) {
|
||||||
|
logger.warn("2FA verification failed: {}", e.getMessage());
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Unexpected error during 2FA verification", e);
|
||||||
|
throw new TwoFactorException("2FA verification failed", e);
|
||||||
|
} finally {
|
||||||
|
request.clearSensitiveData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes authentication tokens
|
||||||
|
*
|
||||||
|
* @param refreshToken Refresh token
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
* @return New authentication tokens
|
||||||
|
* @throws InvalidTokenException if refresh token is invalid
|
||||||
|
* @throws TokenExpiredException if refresh token is expired
|
||||||
|
*/
|
||||||
|
public AuthResponse refreshToken(String refreshToken, String ipAddress) {
|
||||||
|
logger.debug("Attempting token refresh");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate refresh token
|
||||||
|
Long userId = jwtTokenService.validateRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
// Check account status
|
||||||
|
validateAccountStatus(user);
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
String newAccessToken = jwtTokenService.generateAccessToken(user);
|
||||||
|
String newRefreshToken = jwtTokenService.generateRefreshToken(user);
|
||||||
|
LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(jwtTokenService.getAccessTokenExpirationMinutes());
|
||||||
|
|
||||||
|
// Invalidate old refresh token
|
||||||
|
jwtTokenService.invalidateRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
AuthResponse response = new AuthResponse(newAccessToken, newRefreshToken, expiresAt, UserResponse.minimal(user));
|
||||||
|
response.setIpAddress(ipAddress);
|
||||||
|
|
||||||
|
logger.debug("Token refresh successful for user: {}", user.getId());
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (AuthenticationException e) {
|
||||||
|
logger.warn("Token refresh failed: {}", e.getMessage());
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Unexpected error during token refresh", e);
|
||||||
|
throw new TokenRefreshException("Token refresh failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs out a user and invalidates tokens
|
||||||
|
*
|
||||||
|
* @param accessToken Access token to invalidate
|
||||||
|
* @param refreshToken Refresh token to invalidate (optional)
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
*/
|
||||||
|
public void logout(String accessToken, String refreshToken, String ipAddress) {
|
||||||
|
logger.info("Processing logout request from IP: {}", ipAddress);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate and get user from access token
|
||||||
|
Long userId = jwtTokenService.validateAccessToken(accessToken);
|
||||||
|
|
||||||
|
// Invalidate tokens
|
||||||
|
jwtTokenService.invalidateAccessToken(accessToken);
|
||||||
|
if (refreshToken != null) {
|
||||||
|
jwtTokenService.invalidateRefreshToken(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user's last activity
|
||||||
|
userRepository.findById(userId)
|
||||||
|
.ifPresent(user -> {
|
||||||
|
user.setLastActivity(LocalDateTime.now());
|
||||||
|
userRepository.save(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Logout successful for user: {}", userId);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Error during logout: {}", e.getMessage());
|
||||||
|
// Don't throw exception for logout failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log out from all devices
|
||||||
|
*/
|
||||||
|
public void logoutFromAllDevices(String accessToken, String ipAddress) {
|
||||||
|
try {
|
||||||
|
// Validate token and get user
|
||||||
|
Long userId = jwtTokenService.validateAccessToken(accessToken);
|
||||||
|
|
||||||
|
// Invalidate all tokens for this user
|
||||||
|
jwtTokenService.invalidateAllTokensForUser(userId);
|
||||||
|
|
||||||
|
// Update user's last activity
|
||||||
|
userRepository.findById(userId)
|
||||||
|
.ifPresent(user -> {
|
||||||
|
user.setLastActivity(LocalDateTime.now());
|
||||||
|
userRepository.save(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("User {} logged out from all devices from IP: {}", userId, ipAddress);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Logout from all devices failed: {}", e.getMessage());
|
||||||
|
throw new AuthenticationException("Logout from all devices failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend device verification code
|
||||||
|
*/
|
||||||
|
public void resendDeviceVerification(String sessionToken, String ipAddress) {
|
||||||
|
try {
|
||||||
|
// Validate session token
|
||||||
|
Long userId = jwtTokenService.validateSessionToken(sessionToken);
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
// Find pending device verification
|
||||||
|
// Implementation would depend on how device verification is tracked
|
||||||
|
logger.info("Device verification code resent for user: {} from IP: {}", userId, ipAddress);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Resend device verification failed: {}", e.getMessage());
|
||||||
|
throw new AuthenticationException("Resend device verification failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend two-factor authentication code
|
||||||
|
*/
|
||||||
|
public void resendTwoFactorCode(String sessionToken, String ipAddress) {
|
||||||
|
try {
|
||||||
|
// Validate session token
|
||||||
|
Long userId = jwtTokenService.validateSessionToken(sessionToken);
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
// Resend 2FA code via SMS if enabled
|
||||||
|
if (user.getTwoFactorEnabled()) {
|
||||||
|
twoFactorService.sendSmsCode(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("2FA code resent for user: {} from IP: {}", userId, ipAddress);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Resend 2FA code failed: {}", e.getMessage());
|
||||||
|
throw new AuthenticationException("Resend 2FA code failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private void validateRegistrationRequest(RegisterRequest request) {
|
||||||
|
if (!request.isPasswordMatching()) {
|
||||||
|
throw new InvalidRegistrationDataException("Passwords do not match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional business validation can be added here
|
||||||
|
securityService.validatePasswordStrength(request.getPassword());
|
||||||
|
securityService.validateEmailDomain(request.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
private User createUserFromRequest(RegisterRequest request, String ipAddress) {
|
||||||
|
User user = new User();
|
||||||
|
user.setName(request.getName());
|
||||||
|
user.setEmail(request.getEmail().toLowerCase());
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||||
|
user.setPhoneNumber(request.getPhoneNumber());
|
||||||
|
user.setLanguage(request.getLanguage());
|
||||||
|
user.setTimezone(request.getTimezone());
|
||||||
|
user.setMarketingOptIn(Boolean.TRUE.equals(request.getAcceptMarketing()));
|
||||||
|
user.setRegistrationSource(request.getSource());
|
||||||
|
user.setRegistrationIp(ipAddress);
|
||||||
|
user.setStatus(UserStatus.ACTIVE.name());
|
||||||
|
user.setRole(UserRole.USER.name());
|
||||||
|
user.setEmailVerified(false);
|
||||||
|
user.setPhoneVerified(false);
|
||||||
|
user.setTwoFactorEnabled(false);
|
||||||
|
user.setFailedLoginAttempts(0);
|
||||||
|
user.setCreatedAt(LocalDateTime.now());
|
||||||
|
user.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateAccountStatus(User user) {
|
||||||
|
if (UserStatus.DISABLED.name().equals(user.getStatus())) {
|
||||||
|
throw new AccountDisabledException("Account is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UserStatus.SUSPENDED.name().equals(user.getStatus())) {
|
||||||
|
throw new AccountSuspendedException("Account is suspended");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.getLockedUntil() != null && LocalDateTime.now().isBefore(user.getLockedUntil())) {
|
||||||
|
throw new AccountLockedException("Account is temporarily locked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleFailedLoginAttempt(User user, String ipAddress) {
|
||||||
|
int attempts = user.getFailedLoginAttempts() + 1;
|
||||||
|
user.setFailedLoginAttempts(attempts);
|
||||||
|
|
||||||
|
if (attempts >= MAX_FAILED_ATTEMPTS) {
|
||||||
|
user.setLockedUntil(LocalDateTime.now().plusMinutes(30)); // Lock for 30 minutes
|
||||||
|
logger.warn("Account locked due to failed attempts: {} from IP: {}", user.getEmail(), ipAddress);
|
||||||
|
|
||||||
|
// Send security alert
|
||||||
|
emailService.sendSecurityAlert(user, "Account Locked",
|
||||||
|
"Your account has been temporarily locked due to multiple failed login attempts from IP: " + ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeviceAuthResult processDeviceAuthentication(User user, DeviceFingerprintRequest fingerprint,
|
||||||
|
String ipAddress, String userAgent) {
|
||||||
|
if (fingerprint == null) {
|
||||||
|
// No fingerprint provided, create minimal device record
|
||||||
|
TrustedDevice device = deviceFingerprintService.createMinimalDevice(user, ipAddress, userAgent);
|
||||||
|
return new DeviceAuthResult(device, true, false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceFingerprintService.DeviceAuthResult result = deviceFingerprintService.authenticateDevice(user, fingerprint, ipAddress, userAgent);
|
||||||
|
return new DeviceAuthResult(result.getDevice(), result.isNewDevice(), result.isTrusted(), result.isTrustedFor2FA());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TrustedDevice findOrCreateDevice(User user, String ipAddress, String userAgent) {
|
||||||
|
// Try to find existing device by IP and basic characteristics
|
||||||
|
Optional<TrustedDevice> existingDevice = trustedDeviceRepository
|
||||||
|
.findByUserIdAndLastIpAddress(user.getId(), ipAddress);
|
||||||
|
|
||||||
|
if (existingDevice.isPresent()) {
|
||||||
|
return existingDevice.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new minimal device record
|
||||||
|
return deviceFingerprintService.createMinimalDevice(user, ipAddress, userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthResponse completeAuthentication(User user, DeviceAuthResult deviceResult,
|
||||||
|
String ipAddress, String userAgent) {
|
||||||
|
// Generate authentication tokens
|
||||||
|
String accessToken = jwtTokenService.generateAccessToken(user);
|
||||||
|
String refreshToken = jwtTokenService.generateRefreshToken(user);
|
||||||
|
LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(jwtTokenService.getAccessTokenExpirationMinutes());
|
||||||
|
|
||||||
|
// Update user login information
|
||||||
|
updateUserLoginInfo(user, ipAddress, userAgent);
|
||||||
|
|
||||||
|
// Update device last seen
|
||||||
|
if (deviceResult.getDevice() != null) {
|
||||||
|
TrustedDevice device = deviceResult.getDevice();
|
||||||
|
device.setLastSeenAt(LocalDateTime.now());
|
||||||
|
device.setLastIpAddress(ipAddress);
|
||||||
|
device.setLoginCount(device.getLoginCount() + 1);
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create response
|
||||||
|
AuthResponse response = AuthResponse.success(accessToken, refreshToken, expiresAt, UserResponse.fromEntity(user));
|
||||||
|
response.setIpAddress(ipAddress);
|
||||||
|
response.setIsNewDevice(deviceResult.isNewDevice());
|
||||||
|
|
||||||
|
if (deviceResult.getDevice() != null) {
|
||||||
|
response.setDeviceTrustStatus(deviceResult.getDevice().getTrustStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add security information
|
||||||
|
response.setSecurityAlertsCount(securityService.getPendingAlertsCount(user));
|
||||||
|
|
||||||
|
// Add last login information
|
||||||
|
Optional<TrustedDevice> lastDevice = trustedDeviceRepository.findLastLoginDevice(user.getId());
|
||||||
|
if (lastDevice.isPresent() && !lastDevice.get().getId().equals(deviceResult.getDeviceId())) {
|
||||||
|
TrustedDevice lastLoginDevice = lastDevice.get();
|
||||||
|
AuthResponse.LastLoginInfo lastLogin = new AuthResponse.LastLoginInfo(
|
||||||
|
lastLoginDevice.getLastSeenAt(),
|
||||||
|
lastLoginDevice.getLastIpAddress(),
|
||||||
|
lastLoginDevice.getLocation(),
|
||||||
|
lastLoginDevice.getDeviceName()
|
||||||
|
);
|
||||||
|
response.setLastLogin(lastLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Authentication completed successfully for user: {}", user.getId());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateUserLoginInfo(User user, String ipAddress, String userAgent) {
|
||||||
|
user.setLastLoginAt(LocalDateTime.now());
|
||||||
|
user.setLastActivity(LocalDateTime.now());
|
||||||
|
user.setLastLoginIp(ipAddress);
|
||||||
|
userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device authentication result container
|
||||||
|
*/
|
||||||
|
private static class DeviceAuthResult {
|
||||||
|
private final TrustedDevice device;
|
||||||
|
private final boolean newDevice;
|
||||||
|
private final boolean trusted;
|
||||||
|
private final boolean trustedFor2FA;
|
||||||
|
|
||||||
|
public DeviceAuthResult(TrustedDevice device, boolean newDevice, boolean trusted, boolean trustedFor2FA) {
|
||||||
|
this.device = device;
|
||||||
|
this.newDevice = newDevice;
|
||||||
|
this.trusted = trusted;
|
||||||
|
this.trustedFor2FA = trustedFor2FA;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrustedDevice getDevice() { return device; }
|
||||||
|
public boolean isNewDevice() { return newDevice; }
|
||||||
|
public boolean isTrusted() { return trusted; }
|
||||||
|
public boolean isTrustedFor2FA() { return trustedFor2FA; }
|
||||||
|
public Long getDeviceId() { return device != null ? device.getId() : null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
/**
|
||||||
|
* Device Fingerprint Service
|
||||||
|
*
|
||||||
|
* Service for processing browser fingerprints, device identification,
|
||||||
|
* and trust management. Handles device registration, verification,
|
||||||
|
* and risk assessment for authentication security.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.service;
|
||||||
|
|
||||||
|
import com.company.auth.dto.request.DeviceFingerprintRequest;
|
||||||
|
import com.company.auth.entity.*;
|
||||||
|
import com.company.auth.repository.TrustedDeviceRepository;
|
||||||
|
import com.company.auth.exception.*;
|
||||||
|
import com.company.auth.util.*;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device Fingerprint Service Implementation
|
||||||
|
*
|
||||||
|
* Provides device fingerprinting capabilities including:
|
||||||
|
* - Browser fingerprint processing and analysis
|
||||||
|
* - Device identification and matching
|
||||||
|
* - Trust level calculation and management
|
||||||
|
* - Risk assessment for unknown devices
|
||||||
|
* - Device verification code generation
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class DeviceFingerprintService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(DeviceFingerprintService.class);
|
||||||
|
|
||||||
|
// Fingerprint similarity threshold for device matching (0.0 - 1.0)
|
||||||
|
private static final double FINGERPRINT_SIMILARITY_THRESHOLD = 0.85;
|
||||||
|
|
||||||
|
// Default device trust duration in days
|
||||||
|
private static final int DEFAULT_TRUST_DURATION_DAYS = 30;
|
||||||
|
|
||||||
|
// 2FA trust duration in days
|
||||||
|
private static final int DEFAULT_2FA_TRUST_DURATION_DAYS = 30;
|
||||||
|
|
||||||
|
// Verification code length
|
||||||
|
private static final int VERIFICATION_CODE_LENGTH = 6;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TrustedDeviceRepository trustedDeviceRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GeoLocationService geoLocationService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SecurityService securityService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RandomCodeGenerator codeGenerator;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes device fingerprint and determines trust status
|
||||||
|
*
|
||||||
|
* @param user User entity
|
||||||
|
* @param fingerprintRequest Device fingerprint data
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
* @param userAgent Client user agent
|
||||||
|
* @return TrustedDevice entity
|
||||||
|
*/
|
||||||
|
public TrustedDevice processDeviceFingerprint(User user, DeviceFingerprintRequest fingerprintRequest,
|
||||||
|
String ipAddress, String userAgent) {
|
||||||
|
logger.debug("Processing device fingerprint for user: {}", user.getId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate fingerprint hash
|
||||||
|
String fingerprintHash = generateFingerprintHash(fingerprintRequest);
|
||||||
|
|
||||||
|
// Check for existing device with same fingerprint
|
||||||
|
Optional<TrustedDevice> existingDevice = trustedDeviceRepository
|
||||||
|
.findByUserIdAndFingerprintHash(user.getId(), fingerprintHash);
|
||||||
|
|
||||||
|
if (existingDevice.isPresent()) {
|
||||||
|
// Update existing device
|
||||||
|
TrustedDevice device = existingDevice.get();
|
||||||
|
updateExistingDevice(device, fingerprintRequest, ipAddress, userAgent);
|
||||||
|
return trustedDeviceRepository.save(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for similar devices
|
||||||
|
List<TrustedDevice> userDevices = trustedDeviceRepository.findByUserIdAndIsActive(user.getId(), true);
|
||||||
|
TrustedDevice similarDevice = findSimilarDevice(userDevices, fingerprintRequest);
|
||||||
|
|
||||||
|
if (similarDevice != null) {
|
||||||
|
// Update similar device with new fingerprint
|
||||||
|
updateSimilarDevice(similarDevice, fingerprintRequest, fingerprintHash, ipAddress, userAgent);
|
||||||
|
return trustedDeviceRepository.save(similarDevice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new device
|
||||||
|
TrustedDevice newDevice = createNewDevice(user, fingerprintRequest, fingerprintHash, ipAddress, userAgent);
|
||||||
|
return trustedDeviceRepository.save(newDevice);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error processing device fingerprint for user: {}", user.getId(), e);
|
||||||
|
throw new DeviceFingerprintException("Failed to process device fingerprint", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates device and returns authentication result
|
||||||
|
*
|
||||||
|
* @param user User entity
|
||||||
|
* @param fingerprintRequest Device fingerprint data
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
* @param userAgent Client user agent
|
||||||
|
* @return DeviceAuthResult with trust information
|
||||||
|
*/
|
||||||
|
public DeviceAuthResult authenticateDevice(User user, DeviceFingerprintRequest fingerprintRequest,
|
||||||
|
String ipAddress, String userAgent) {
|
||||||
|
logger.debug("Authenticating device for user: {}", user.getId());
|
||||||
|
|
||||||
|
TrustedDevice device = processDeviceFingerprint(user, fingerprintRequest, ipAddress, userAgent);
|
||||||
|
|
||||||
|
boolean isNewDevice = device.getCreatedAt().isAfter(LocalDateTime.now().minusMinutes(1));
|
||||||
|
boolean isTrusted = isDeviceTrusted(device);
|
||||||
|
boolean isTrustedFor2FA = isTrustedFor2FA(device);
|
||||||
|
|
||||||
|
// Update risk assessment
|
||||||
|
updateDeviceRisk(device, user, ipAddress);
|
||||||
|
|
||||||
|
return new DeviceAuthResult(device, isNewDevice, isTrusted, isTrustedFor2FA);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a minimal device record without fingerprint
|
||||||
|
*
|
||||||
|
* @param user User entity
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
* @param userAgent Client user agent
|
||||||
|
* @return TrustedDevice entity
|
||||||
|
*/
|
||||||
|
public TrustedDevice createMinimalDevice(User user, String ipAddress, String userAgent) {
|
||||||
|
logger.debug("Creating minimal device record for user: {}", user.getId());
|
||||||
|
|
||||||
|
TrustedDevice device = new TrustedDevice();
|
||||||
|
device.setUser(user);
|
||||||
|
device.setDeviceName("Unknown Device");
|
||||||
|
device.setDeviceType(parseDeviceType(userAgent));
|
||||||
|
device.setBrowser(parseBrowser(userAgent));
|
||||||
|
device.setOperatingSystem(parseOperatingSystem(userAgent));
|
||||||
|
device.setLastIpAddress(ipAddress);
|
||||||
|
device.setLocation(convertMapToJson(geoLocationService.getLocationFromIp(ipAddress)));
|
||||||
|
device.setTrustStatus(TrustStatus.UNKNOWN.name());
|
||||||
|
device.setTrustLevel(50); // Neutral trust level
|
||||||
|
device.setRiskLevel(RiskLevel.MEDIUM);
|
||||||
|
device.setIsActive(true);
|
||||||
|
device.setIsTrusted(false);
|
||||||
|
device.setTrustedFor2FA(false);
|
||||||
|
device.setLoginCount(0);
|
||||||
|
device.setFailedAttempts(0);
|
||||||
|
device.setCreatedAt(LocalDateTime.now());
|
||||||
|
device.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
return trustedDeviceRepository.save(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates and sends device verification code
|
||||||
|
*
|
||||||
|
* @param device TrustedDevice entity
|
||||||
|
* @return Generated verification code
|
||||||
|
*/
|
||||||
|
public String generateDeviceVerificationCode(TrustedDevice device) {
|
||||||
|
logger.debug("Generating verification code for device: {}", device.getId());
|
||||||
|
|
||||||
|
String code = codeGenerator.generateNumericCode(VERIFICATION_CODE_LENGTH);
|
||||||
|
String hashedCode = securityService.hashVerificationCode(code);
|
||||||
|
|
||||||
|
device.setVerificationCode(hashedCode);
|
||||||
|
device.setVerificationCodeExpiresAt(LocalDateTime.now().plusMinutes(10));
|
||||||
|
device.setVerificationAttempts(0);
|
||||||
|
device.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies device verification code
|
||||||
|
*
|
||||||
|
* @param device TrustedDevice entity
|
||||||
|
* @param code Verification code to verify
|
||||||
|
* @return true if code is valid
|
||||||
|
*/
|
||||||
|
public boolean verifyDeviceCode(TrustedDevice device, String code) {
|
||||||
|
logger.debug("Verifying device code for device: {}", device.getId());
|
||||||
|
|
||||||
|
if (device.getVerificationCode() == null ||
|
||||||
|
device.getVerificationCodeExpiresAt() == null ||
|
||||||
|
LocalDateTime.now().isAfter(device.getVerificationCodeExpiresAt())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
device.setVerificationAttempts(device.getVerificationAttempts() + 1);
|
||||||
|
|
||||||
|
boolean isValid = securityService.verifyCode(code, device.getVerificationCode());
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
// Clear verification code after successful verification
|
||||||
|
device.setVerificationCode(null);
|
||||||
|
device.setVerificationCodeExpiresAt(null);
|
||||||
|
device.setVerificationAttempts(0);
|
||||||
|
device.setLastVerifiedAt(LocalDateTime.now());
|
||||||
|
} else if (device.getVerificationAttempts() >= 3) {
|
||||||
|
// Clear code after too many failed attempts
|
||||||
|
device.setVerificationCode(null);
|
||||||
|
device.setVerificationCodeExpiresAt(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
device.setUpdatedAt(LocalDateTime.now());
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks device as trusted
|
||||||
|
*
|
||||||
|
* @param device TrustedDevice entity
|
||||||
|
* @param trustDurationDays Number of days to trust the device
|
||||||
|
*/
|
||||||
|
public void trustDevice(TrustedDevice device, Integer trustDurationDays) {
|
||||||
|
logger.info("Marking device as trusted: {}", device.getId());
|
||||||
|
|
||||||
|
int duration = trustDurationDays != null ? trustDurationDays : DEFAULT_TRUST_DURATION_DAYS;
|
||||||
|
|
||||||
|
device.setIsTrusted(true);
|
||||||
|
device.setTrustStatus(TrustStatus.TRUSTED.name());
|
||||||
|
device.setTrustLevel(Math.min(device.getTrustLevel() + 20, 100));
|
||||||
|
device.setTrustedAt(LocalDateTime.now());
|
||||||
|
device.setExpiresAt(LocalDateTime.now().plusDays(duration));
|
||||||
|
device.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks device as trusted for 2FA bypass
|
||||||
|
*
|
||||||
|
* @param device TrustedDevice entity
|
||||||
|
* @param trustDurationDays Number of days to trust for 2FA
|
||||||
|
*/
|
||||||
|
public void trustDeviceFor2FA(TrustedDevice device, Integer trustDurationDays) {
|
||||||
|
logger.info("Marking device as 2FA trusted: {}", device.getId());
|
||||||
|
|
||||||
|
int duration = trustDurationDays != null ? trustDurationDays : DEFAULT_2FA_TRUST_DURATION_DAYS;
|
||||||
|
|
||||||
|
device.setTrustedFor2FA(true);
|
||||||
|
device.setTrustedFor2FAAt(LocalDateTime.now());
|
||||||
|
device.setTrustedFor2FAExpiresAt(LocalDateTime.now().plusDays(duration));
|
||||||
|
device.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes device trust
|
||||||
|
*
|
||||||
|
* @param device TrustedDevice entity
|
||||||
|
*/
|
||||||
|
public void revokeDeviceTrust(TrustedDevice device) {
|
||||||
|
logger.info("Revoking device trust: {}", device.getId());
|
||||||
|
|
||||||
|
device.setIsTrusted(false);
|
||||||
|
device.setTrustedFor2FA(false);
|
||||||
|
device.setTrustStatus(TrustStatus.REVOKED.name());
|
||||||
|
device.setTrustLevel(Math.max(device.getTrustLevel() - 30, 0));
|
||||||
|
device.setRevokedAt(LocalDateTime.now());
|
||||||
|
device.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if device is currently trusted
|
||||||
|
*
|
||||||
|
* @param device TrustedDevice entity
|
||||||
|
* @return true if device is trusted and not expired
|
||||||
|
*/
|
||||||
|
public boolean isDeviceTrusted(TrustedDevice device) {
|
||||||
|
if (!device.getIsTrusted() || !TrustStatus.TRUSTED.name().equals(device.getTrustStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.getExpiresAt() != null && LocalDateTime.now().isAfter(device.getExpiresAt())) {
|
||||||
|
// Trust has expired, update status
|
||||||
|
device.setIsTrusted(false);
|
||||||
|
device.setTrustStatus(TrustStatus.EXPIRED.name());
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if device is trusted for 2FA bypass
|
||||||
|
*
|
||||||
|
* @param device TrustedDevice entity
|
||||||
|
* @return true if device is trusted for 2FA and not expired
|
||||||
|
*/
|
||||||
|
public boolean isTrustedFor2FA(TrustedDevice device) {
|
||||||
|
if (!device.getTrustedFor2FA()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.getTrustedFor2FAExpiresAt() != null &&
|
||||||
|
LocalDateTime.now().isAfter(device.getTrustedFor2FAExpiresAt())) {
|
||||||
|
// 2FA trust has expired
|
||||||
|
device.setTrustedFor2FA(false);
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private String generateFingerprintHash(DeviceFingerprintRequest request) {
|
||||||
|
try {
|
||||||
|
StringBuilder fingerprintData = new StringBuilder();
|
||||||
|
|
||||||
|
// Core fingerprint components
|
||||||
|
fingerprintData.append(request.getUserAgent() != null ? request.getUserAgent() : "");
|
||||||
|
fingerprintData.append("|");
|
||||||
|
fingerprintData.append(request.getScreenResolution() != null ? request.getScreenResolution() : "");
|
||||||
|
fingerprintData.append("|");
|
||||||
|
fingerprintData.append(request.getTimezone() != null ? request.getTimezone() : "");
|
||||||
|
fingerprintData.append("|");
|
||||||
|
fingerprintData.append(request.getPlatform() != null ? request.getPlatform() : "");
|
||||||
|
fingerprintData.append("|");
|
||||||
|
fingerprintData.append(request.getAcceptLanguage() != null ? request.getAcceptLanguage() : "");
|
||||||
|
|
||||||
|
// Optional enhanced fingerprint components
|
||||||
|
if (request.getCanvasFingerprint() != null) {
|
||||||
|
fingerprintData.append("|canvas:").append(request.getCanvasFingerprint());
|
||||||
|
}
|
||||||
|
if (request.getWebglRenderer() != null) {
|
||||||
|
fingerprintData.append("|webgl:").append(request.getWebglRenderer());
|
||||||
|
}
|
||||||
|
if (request.getFontsHash() != null) {
|
||||||
|
fingerprintData.append("|fonts:").append(request.getFontsHash());
|
||||||
|
}
|
||||||
|
if (request.getPluginsHash() != null) {
|
||||||
|
fingerprintData.append("|plugins:").append(request.getPluginsHash());
|
||||||
|
}
|
||||||
|
if (request.getColorDepth() != null) {
|
||||||
|
fingerprintData.append("|color:").append(request.getColorDepth());
|
||||||
|
}
|
||||||
|
if (request.getTouchSupport() != null) {
|
||||||
|
fingerprintData.append("|touch:").append(request.getTouchSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate SHA-256 hash
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(fingerprintData.toString().getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
StringBuilder hexString = new StringBuilder();
|
||||||
|
for (byte b : hash) {
|
||||||
|
String hex = Integer.toHexString(0xff & b);
|
||||||
|
if (hex.length() == 1) {
|
||||||
|
hexString.append('0');
|
||||||
|
}
|
||||||
|
hexString.append(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hexString.toString();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error generating fingerprint hash", e);
|
||||||
|
throw new DeviceFingerprintException("Failed to generate fingerprint hash", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TrustedDevice findSimilarDevice(List<TrustedDevice> userDevices, DeviceFingerprintRequest request) {
|
||||||
|
for (TrustedDevice device : userDevices) {
|
||||||
|
if (device.getFingerprintData() == null) continue;
|
||||||
|
|
||||||
|
double similarity = calculateFingerprintSimilarity(device.getFingerprintData(), request);
|
||||||
|
if (similarity >= FINGERPRINT_SIMILARITY_THRESHOLD) {
|
||||||
|
logger.debug("Found similar device with similarity: {}", similarity);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private double calculateFingerprintSimilarity(String storedFingerprint, DeviceFingerprintRequest request) {
|
||||||
|
try {
|
||||||
|
// Parse stored fingerprint data (simplified implementation)
|
||||||
|
// In a real implementation, you'd store and compare individual components
|
||||||
|
|
||||||
|
int matches = 0;
|
||||||
|
int total = 0;
|
||||||
|
|
||||||
|
// Compare basic components that are available
|
||||||
|
String currentUserAgent = request.getUserAgent();
|
||||||
|
if (currentUserAgent != null && storedFingerprint.contains(currentUserAgent.substring(0, Math.min(50, currentUserAgent.length())))) {
|
||||||
|
matches++;
|
||||||
|
}
|
||||||
|
total++;
|
||||||
|
|
||||||
|
String currentResolution = request.getScreenResolution();
|
||||||
|
if (currentResolution != null && storedFingerprint.contains(currentResolution)) {
|
||||||
|
matches++;
|
||||||
|
}
|
||||||
|
total++;
|
||||||
|
|
||||||
|
String currentTimezone = request.getTimezone();
|
||||||
|
if (currentTimezone != null && storedFingerprint.contains(currentTimezone)) {
|
||||||
|
matches++;
|
||||||
|
}
|
||||||
|
total++;
|
||||||
|
|
||||||
|
String currentPlatform = request.getPlatform();
|
||||||
|
if (currentPlatform != null && storedFingerprint.contains(currentPlatform)) {
|
||||||
|
matches++;
|
||||||
|
}
|
||||||
|
total++;
|
||||||
|
|
||||||
|
return total > 0 ? (double) matches / total : 0.0;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Error calculating fingerprint similarity", e);
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateExistingDevice(TrustedDevice device, DeviceFingerprintRequest request,
|
||||||
|
String ipAddress, String userAgent) {
|
||||||
|
device.setLastSeenAt(LocalDateTime.now());
|
||||||
|
device.setLastIpAddress(ipAddress);
|
||||||
|
device.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
// Update location if IP changed
|
||||||
|
if (!ipAddress.equals(device.getLastIpAddress())) {
|
||||||
|
device.setLocation(convertMapToJson(geoLocationService.getLocationFromIp(ipAddress)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update device name if provided
|
||||||
|
if (request.getDeviceName() != null && !request.getDeviceName().equals(device.getDeviceName())) {
|
||||||
|
device.setDeviceName(request.getDeviceName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment trust level for returning device
|
||||||
|
if (device.getTrustLevel() < 100) {
|
||||||
|
device.setTrustLevel(Math.min(device.getTrustLevel() + 5, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateSimilarDevice(TrustedDevice device, DeviceFingerprintRequest request,
|
||||||
|
String fingerprintHash, String ipAddress, String userAgent) {
|
||||||
|
// Update device with new fingerprint
|
||||||
|
device.setFingerprintHash(fingerprintHash);
|
||||||
|
device.setFingerprintData(serializeFingerprintData(request));
|
||||||
|
device.setLastSeenAt(LocalDateTime.now());
|
||||||
|
device.setLastIpAddress(ipAddress);
|
||||||
|
device.setLocation(convertMapToJson(geoLocationService.getLocationFromIp(ipAddress)));
|
||||||
|
device.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
// Update parsed information
|
||||||
|
device.setBrowser(parseBrowser(userAgent));
|
||||||
|
device.setOperatingSystem(parseOperatingSystem(userAgent));
|
||||||
|
|
||||||
|
// Slightly increase trust level for evolved device
|
||||||
|
if (device.getTrustLevel() < 100) {
|
||||||
|
device.setTrustLevel(Math.min(device.getTrustLevel() + 3, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private TrustedDevice createNewDevice(User user, DeviceFingerprintRequest request,
|
||||||
|
String fingerprintHash, String ipAddress, String userAgent) {
|
||||||
|
TrustedDevice device = new TrustedDevice();
|
||||||
|
|
||||||
|
device.setUser(user);
|
||||||
|
device.setFingerprintHash(fingerprintHash);
|
||||||
|
device.setFingerprintData(serializeFingerprintData(request));
|
||||||
|
device.setDeviceName(request.getDeviceName() != null ? request.getDeviceName() : "New Device");
|
||||||
|
device.setDeviceType(parseDeviceType(userAgent));
|
||||||
|
device.setBrowser(parseBrowser(userAgent));
|
||||||
|
device.setOperatingSystem(parseOperatingSystem(userAgent));
|
||||||
|
device.setLastIpAddress(ipAddress);
|
||||||
|
device.setLocation(convertMapToJson(geoLocationService.getLocationFromIp(ipAddress)));
|
||||||
|
|
||||||
|
// Set required validation fields
|
||||||
|
device.setDeviceInfo(serializeFingerprintData(request));
|
||||||
|
device.setConfidenceScore(calculateFingerprintConfidence(request));
|
||||||
|
|
||||||
|
// Set initial trust status
|
||||||
|
device.setTrustStatus(TrustStatus.UNKNOWN.name());
|
||||||
|
device.setTrustLevel(calculateInitialTrustLevel(request, user));
|
||||||
|
device.setRiskLevel(assessInitialRiskLevel(request, ipAddress, user));
|
||||||
|
|
||||||
|
device.setIsActive(true);
|
||||||
|
device.setIsTrusted(false);
|
||||||
|
device.setTrustedFor2FA(false);
|
||||||
|
device.setLoginCount(0);
|
||||||
|
device.setFailedAttempts(0);
|
||||||
|
device.setCreatedAt(LocalDateTime.now());
|
||||||
|
device.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String serializeFingerprintData(DeviceFingerprintRequest request) {
|
||||||
|
// Create a JSON-like string of fingerprint data for storage
|
||||||
|
StringBuilder data = new StringBuilder("{");
|
||||||
|
|
||||||
|
data.append("\"userAgent\":\"").append(escapeJson(request.getUserAgent())).append("\",");
|
||||||
|
data.append("\"screenResolution\":\"").append(escapeJson(request.getScreenResolution())).append("\",");
|
||||||
|
data.append("\"timezone\":\"").append(escapeJson(request.getTimezone())).append("\",");
|
||||||
|
data.append("\"platform\":\"").append(escapeJson(request.getPlatform())).append("\",");
|
||||||
|
data.append("\"acceptLanguage\":\"").append(escapeJson(request.getAcceptLanguage())).append("\"");
|
||||||
|
|
||||||
|
if (request.getCanvasFingerprint() != null) {
|
||||||
|
data.append(",\"canvasFingerprint\":\"").append(escapeJson(request.getCanvasFingerprint())).append("\"");
|
||||||
|
}
|
||||||
|
if (request.getWebglRenderer() != null) {
|
||||||
|
data.append(",\"webglRenderer\":\"").append(escapeJson(request.getWebglRenderer())).append("\"");
|
||||||
|
}
|
||||||
|
if (request.getFontsHash() != null) {
|
||||||
|
data.append(",\"fontsHash\":\"").append(escapeJson(request.getFontsHash())).append("\"");
|
||||||
|
}
|
||||||
|
if (request.getPluginsHash() != null) {
|
||||||
|
data.append(",\"pluginsHash\":\"").append(escapeJson(request.getPluginsHash())).append("\"");
|
||||||
|
}
|
||||||
|
if (request.getColorDepth() != null) {
|
||||||
|
data.append(",\"colorDepth\":").append(request.getColorDepth());
|
||||||
|
}
|
||||||
|
if (request.getTouchSupport() != null) {
|
||||||
|
data.append(",\"touchSupport\":").append(request.getTouchSupport());
|
||||||
|
}
|
||||||
|
|
||||||
|
data.append("}");
|
||||||
|
return data.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeJson(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return value.replace("\"", "\\\"").replace("\\", "\\\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseDeviceType(String userAgent) {
|
||||||
|
if (userAgent == null) return "unknown";
|
||||||
|
|
||||||
|
String ua = userAgent.toLowerCase();
|
||||||
|
if (ua.contains("mobile") || ua.contains("android") || ua.contains("iphone")) {
|
||||||
|
return "mobile";
|
||||||
|
} else if (ua.contains("tablet") || ua.contains("ipad")) {
|
||||||
|
return "tablet";
|
||||||
|
} else {
|
||||||
|
return "desktop";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseBrowser(String userAgent) {
|
||||||
|
if (userAgent == null) return "Unknown";
|
||||||
|
|
||||||
|
if (userAgent.contains("Chrome")) return "Chrome";
|
||||||
|
if (userAgent.contains("Firefox")) return "Firefox";
|
||||||
|
if (userAgent.contains("Safari") && !userAgent.contains("Chrome")) return "Safari";
|
||||||
|
if (userAgent.contains("Edge")) return "Edge";
|
||||||
|
if (userAgent.contains("Opera")) return "Opera";
|
||||||
|
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseOperatingSystem(String userAgent) {
|
||||||
|
if (userAgent == null) return "Unknown";
|
||||||
|
|
||||||
|
if (userAgent.contains("Windows")) return "Windows";
|
||||||
|
if (userAgent.contains("Mac OS")) return "macOS";
|
||||||
|
if (userAgent.contains("Linux")) return "Linux";
|
||||||
|
if (userAgent.contains("Android")) return "Android";
|
||||||
|
if (userAgent.contains("iOS")) return "iOS";
|
||||||
|
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private int calculateInitialTrustLevel(DeviceFingerprintRequest request, User user) {
|
||||||
|
int trustLevel = 30; // Base trust level for new devices
|
||||||
|
|
||||||
|
// Increase trust if comprehensive fingerprint provided
|
||||||
|
if (request.getCanvasFingerprint() != null) trustLevel += 10;
|
||||||
|
if (request.getWebglRenderer() != null) trustLevel += 10;
|
||||||
|
if (request.getFontsHash() != null) trustLevel += 5;
|
||||||
|
if (request.getPluginsHash() != null) trustLevel += 5;
|
||||||
|
|
||||||
|
// Increase trust for established users
|
||||||
|
if (user.getCreatedAt().isBefore(LocalDateTime.now().minusDays(30))) {
|
||||||
|
trustLevel += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(trustLevel, 70); // Cap initial trust at 70
|
||||||
|
}
|
||||||
|
|
||||||
|
private RiskLevel assessInitialRiskLevel(DeviceFingerprintRequest request, String ipAddress, User user) {
|
||||||
|
// Simple risk assessment - in production, this would be more sophisticated
|
||||||
|
|
||||||
|
// Check if IP is from a known risky location
|
||||||
|
if (securityService.isHighRiskIp(ipAddress)) {
|
||||||
|
return RiskLevel.HIGH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a very different device type for the user
|
||||||
|
List<TrustedDevice> userDevices = trustedDeviceRepository.findByUserIdAndIsActive(user.getId(), true);
|
||||||
|
if (!userDevices.isEmpty()) {
|
||||||
|
String newDeviceType = parseDeviceType(request.getUserAgent());
|
||||||
|
boolean hasMatchingDeviceType = userDevices.stream()
|
||||||
|
.anyMatch(d -> newDeviceType.equals(d.getDeviceType()));
|
||||||
|
|
||||||
|
if (!hasMatchingDeviceType) {
|
||||||
|
return RiskLevel.MEDIUM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RiskLevel.LOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateDeviceRisk(TrustedDevice device, User user, String ipAddress) {
|
||||||
|
// Update risk level based on current context
|
||||||
|
RiskLevel currentRisk = device.getRiskLevel();
|
||||||
|
|
||||||
|
// Check for suspicious activity
|
||||||
|
if (securityService.hasRecentSuspiciousActivity(user, ipAddress)) {
|
||||||
|
device.setRiskLevel(RiskLevel.HIGH);
|
||||||
|
} else if (device.getFailedAttempts() > 2) {
|
||||||
|
device.setRiskLevel(RiskLevel.MEDIUM);
|
||||||
|
} else if (device.getLoginCount() > 10 && device.getFailedAttempts() == 0) {
|
||||||
|
device.setRiskLevel(RiskLevel.LOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentRisk.equals(device.getRiskLevel())) {
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device authentication result container
|
||||||
|
*/
|
||||||
|
public static class DeviceAuthResult {
|
||||||
|
private final TrustedDevice device;
|
||||||
|
private final boolean newDevice;
|
||||||
|
private final boolean trusted;
|
||||||
|
private final boolean trustedFor2FA;
|
||||||
|
|
||||||
|
public DeviceAuthResult(TrustedDevice device, boolean newDevice, boolean trusted, boolean trustedFor2FA) {
|
||||||
|
this.device = device;
|
||||||
|
this.newDevice = newDevice;
|
||||||
|
this.trusted = trusted;
|
||||||
|
this.trustedFor2FA = trustedFor2FA;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrustedDevice getDevice() { return device; }
|
||||||
|
public boolean isNewDevice() { return newDevice; }
|
||||||
|
public boolean isTrusted() { return trusted; }
|
||||||
|
public boolean isTrustedFor2FA() { return trustedFor2FA; }
|
||||||
|
public Long getDeviceId() { return device != null ? device.getId() : null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of trusted devices for a user
|
||||||
|
*/
|
||||||
|
public List<com.company.auth.dto.response.DeviceResponse> getUserTrustedDevices(Long userId) {
|
||||||
|
logger.debug("Getting trusted devices for user: {}", userId);
|
||||||
|
|
||||||
|
List<TrustedDevice> devices = trustedDeviceRepository.findByUserIdAndIsActiveTrueOrderByLastSeenAtDesc(userId);
|
||||||
|
return devices.stream().map(com.company.auth.dto.response.DeviceResponse::fromEntity).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke trust for a specific device
|
||||||
|
*/
|
||||||
|
public void revokeDeviceTrust(Long userId, Long deviceId, String ipAddress) {
|
||||||
|
logger.info("Revoking device trust for user: {}, device: {}", userId, deviceId);
|
||||||
|
|
||||||
|
TrustedDevice device = trustedDeviceRepository.findByIdAndUserId(deviceId, userId)
|
||||||
|
.orElseThrow(() -> new DeviceNotFoundException("Device not found: " + deviceId));
|
||||||
|
|
||||||
|
device.setIsTrusted(false);
|
||||||
|
device.setTrustStatus(TrustStatus.REVOKED.name());
|
||||||
|
device.setLastSeenAt(LocalDateTime.now());
|
||||||
|
device.setLastIpAddress(ipAddress);
|
||||||
|
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
logger.info("Device trust revoked for device: {}", deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a device completely
|
||||||
|
*/
|
||||||
|
public void removeDevice(Long userId, Long deviceId, String ipAddress) {
|
||||||
|
logger.info("Removing device for user: {}, device: {}", userId, deviceId);
|
||||||
|
|
||||||
|
TrustedDevice device = trustedDeviceRepository.findByIdAndUserId(deviceId, userId)
|
||||||
|
.orElseThrow(() -> new DeviceNotFoundException("Device not found: " + deviceId));
|
||||||
|
|
||||||
|
device.setIsActive(false);
|
||||||
|
device.setTrustStatus(TrustStatus.REVOKED.name());
|
||||||
|
device.setLastSeenAt(LocalDateTime.now());
|
||||||
|
device.setLastIpAddress(ipAddress);
|
||||||
|
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
logger.info("Device removed: {}", deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Map to JSON string
|
||||||
|
*/
|
||||||
|
private String convertMapToJson(Map<String, Object> map) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(map);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to convert map to JSON: {}", e.getMessage());
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates fingerprint confidence score based on available data
|
||||||
|
*
|
||||||
|
* @param request Device fingerprint request
|
||||||
|
* @return Confidence score between 0.00 and 1.00
|
||||||
|
*/
|
||||||
|
private BigDecimal calculateFingerprintConfidence(DeviceFingerprintRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
return BigDecimal.valueOf(0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
double confidence = 0.0;
|
||||||
|
int availableFactors = 0;
|
||||||
|
int totalFactors = 8; // Number of fingerprint factors we check
|
||||||
|
|
||||||
|
// Check availability of fingerprint components
|
||||||
|
if (request.getUserAgent() != null && !request.getUserAgent().trim().isEmpty()) {
|
||||||
|
confidence += 0.15; // User agent is fairly reliable
|
||||||
|
availableFactors++;
|
||||||
|
}
|
||||||
|
if (request.getScreenResolution() != null && !request.getScreenResolution().trim().isEmpty()) {
|
||||||
|
confidence += 0.10; // Screen resolution is moderately reliable
|
||||||
|
availableFactors++;
|
||||||
|
}
|
||||||
|
if (request.getTimezone() != null && !request.getTimezone().trim().isEmpty()) {
|
||||||
|
confidence += 0.05; // Timezone is less reliable but useful
|
||||||
|
availableFactors++;
|
||||||
|
}
|
||||||
|
if (request.getPlatform() != null && !request.getPlatform().trim().isEmpty()) {
|
||||||
|
confidence += 0.10; // Platform is moderately reliable
|
||||||
|
availableFactors++;
|
||||||
|
}
|
||||||
|
if (request.getAcceptLanguage() != null && !request.getAcceptLanguage().trim().isEmpty()) {
|
||||||
|
confidence += 0.05; // Language is less reliable
|
||||||
|
availableFactors++;
|
||||||
|
}
|
||||||
|
if (request.getCanvasFingerprint() != null && !request.getCanvasFingerprint().trim().isEmpty()) {
|
||||||
|
confidence += 0.20; // Canvas fingerprint is highly reliable
|
||||||
|
availableFactors++;
|
||||||
|
}
|
||||||
|
if (request.getWebglRenderer() != null && !request.getWebglRenderer().trim().isEmpty()) {
|
||||||
|
confidence += 0.15; // WebGL renderer is fairly reliable
|
||||||
|
availableFactors++;
|
||||||
|
}
|
||||||
|
if (request.getFontsHash() != null && !request.getFontsHash().trim().isEmpty()) {
|
||||||
|
confidence += 0.20; // Font fingerprint is highly reliable
|
||||||
|
availableFactors++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust confidence based on completeness
|
||||||
|
double completenessRatio = (double) availableFactors / totalFactors;
|
||||||
|
confidence *= completenessRatio;
|
||||||
|
|
||||||
|
// Ensure confidence is within valid range
|
||||||
|
confidence = Math.max(0.10, Math.min(1.00, confidence)); // Minimum 0.10, maximum 1.00
|
||||||
|
|
||||||
|
return BigDecimal.valueOf(confidence).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
}
|
||||||
836
src/main/java/com/company/auth/service/EmailService.java
Normal file
836
src/main/java/com/company/auth/service/EmailService.java
Normal file
@@ -0,0 +1,836 @@
|
|||||||
|
/**
|
||||||
|
* Email Service
|
||||||
|
*
|
||||||
|
* Service for sending transactional emails including verification codes,
|
||||||
|
* security notifications, and authentication-related communications.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.service;
|
||||||
|
|
||||||
|
import com.company.auth.entity.User;
|
||||||
|
import com.company.auth.entity.TrustedDevice;
|
||||||
|
import com.company.auth.exception.EmailServiceException;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email Service Implementation
|
||||||
|
*
|
||||||
|
* Provides email services including:
|
||||||
|
* - Email verification
|
||||||
|
* - Device verification notifications
|
||||||
|
* - Security alerts and notifications
|
||||||
|
* - Password reset emails
|
||||||
|
* - Account management notifications
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class EmailService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(EmailService.class);
|
||||||
|
|
||||||
|
@Value("${app.email.enabled:true}")
|
||||||
|
private boolean emailEnabled;
|
||||||
|
|
||||||
|
@Value("${app.email.from-address:noreply@company.com}")
|
||||||
|
private String fromAddress;
|
||||||
|
|
||||||
|
@Value("${app.email.from-name:Auth Service}")
|
||||||
|
private String fromName;
|
||||||
|
|
||||||
|
@Value("${app.email.base-url:http://localhost:8080}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
@Value("${app.environment:development}")
|
||||||
|
private String environment;
|
||||||
|
|
||||||
|
// In production, inject a real email service like SendGrid, AWS SES, etc.
|
||||||
|
// private EmailProvider emailProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends email verification message
|
||||||
|
*
|
||||||
|
* @param user User to send verification to
|
||||||
|
* @return true if email sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendEmailVerification(User user) {
|
||||||
|
logger.info("Sending email verification to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!emailEnabled) {
|
||||||
|
logger.debug("Email service disabled, skipping email verification");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String subject = "Please verify your email address";
|
||||||
|
String verificationUrl = baseUrl + "/verify-email?token=" + generateEmailVerificationToken(user);
|
||||||
|
|
||||||
|
String htmlContent = buildEmailVerificationTemplate(user, verificationUrl);
|
||||||
|
String textContent = buildEmailVerificationTextTemplate(user, verificationUrl);
|
||||||
|
|
||||||
|
return sendEmail(user.getEmail(), subject, htmlContent, textContent);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send email verification to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends device verification code
|
||||||
|
*
|
||||||
|
* @param user User requesting device verification
|
||||||
|
* @param device Device being verified
|
||||||
|
* @return true if email sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendDeviceVerificationCode(User user, TrustedDevice device) {
|
||||||
|
logger.info("Sending device verification code to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!emailEnabled) {
|
||||||
|
logger.debug("Email service disabled, skipping device verification email");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String verificationCode = generateDeviceVerificationCode(device);
|
||||||
|
String subject = "New device verification required";
|
||||||
|
|
||||||
|
String htmlContent = buildDeviceVerificationTemplate(user, device, verificationCode);
|
||||||
|
String textContent = buildDeviceVerificationTextTemplate(user, device, verificationCode);
|
||||||
|
|
||||||
|
return sendEmail(user.getEmail(), subject, htmlContent, textContent);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send device verification code to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends security alert notification
|
||||||
|
*
|
||||||
|
* @param user User to notify
|
||||||
|
* @param alertType Type of security alert
|
||||||
|
* @param details Alert details
|
||||||
|
* @return true if email sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendSecurityAlert(User user, String alertType, String details) {
|
||||||
|
logger.info("Sending security alert to user: {} - {}", user.getId(), alertType);
|
||||||
|
|
||||||
|
if (!emailEnabled) {
|
||||||
|
logger.debug("Email service disabled, skipping security alert");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String subject = "Security Alert: " + alertType;
|
||||||
|
|
||||||
|
String htmlContent = buildSecurityAlertTemplate(user, alertType, details);
|
||||||
|
String textContent = buildSecurityAlertTextTemplate(user, alertType, details);
|
||||||
|
|
||||||
|
return sendEmail(user.getEmail(), subject, htmlContent, textContent);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send security alert to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends password change notification
|
||||||
|
*
|
||||||
|
* @param user User whose password was changed
|
||||||
|
* @param ipAddress IP address from which password was changed
|
||||||
|
* @return true if email sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendPasswordChangeNotification(User user, String ipAddress) {
|
||||||
|
logger.info("Sending password change notification to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!emailEnabled) {
|
||||||
|
logger.debug("Email service disabled, skipping password change notification");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String subject = "Password changed successfully";
|
||||||
|
|
||||||
|
String htmlContent = buildPasswordChangeTemplate(user, ipAddress);
|
||||||
|
String textContent = buildPasswordChangeTextTemplate(user, ipAddress);
|
||||||
|
|
||||||
|
return sendEmail(user.getEmail(), subject, htmlContent, textContent);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send password change notification to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends device trust revoked notification
|
||||||
|
*
|
||||||
|
* @param user User whose device trust was revoked
|
||||||
|
* @param device Device that was revoked
|
||||||
|
* @param ipAddress IP address from which revocation occurred
|
||||||
|
* @return true if email sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendDeviceRevokedNotification(User user, TrustedDevice device, String ipAddress) {
|
||||||
|
logger.info("Sending device revoked notification to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!emailEnabled) {
|
||||||
|
logger.debug("Email service disabled, skipping device revoked notification");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String subject = "Device trust revoked";
|
||||||
|
|
||||||
|
String htmlContent = buildDeviceRevokedTemplate(user, device, ipAddress);
|
||||||
|
String textContent = buildDeviceRevokedTextTemplate(user, device, ipAddress);
|
||||||
|
|
||||||
|
return sendEmail(user.getEmail(), subject, htmlContent, textContent);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send device revoked notification to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends device removed notification
|
||||||
|
*
|
||||||
|
* @param user User whose device was removed
|
||||||
|
* @param device Device that was removed
|
||||||
|
* @param ipAddress IP address from which removal occurred
|
||||||
|
* @return true if email sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendDeviceRemovedNotification(User user, TrustedDevice device, String ipAddress) {
|
||||||
|
logger.info("Sending device removed notification to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!emailEnabled) {
|
||||||
|
logger.debug("Email service disabled, skipping device removed notification");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String subject = "Device removed from your account";
|
||||||
|
|
||||||
|
String htmlContent = buildDeviceRemovedTemplate(user, device, ipAddress);
|
||||||
|
String textContent = buildDeviceRemovedTextTemplate(user, device, ipAddress);
|
||||||
|
|
||||||
|
return sendEmail(user.getEmail(), subject, htmlContent, textContent);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send device removed notification to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends account deletion confirmation
|
||||||
|
*
|
||||||
|
* @param user User whose account was deleted
|
||||||
|
* @return true if email sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendAccountDeletionConfirmation(User user) {
|
||||||
|
logger.info("Sending account deletion confirmation to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!emailEnabled) {
|
||||||
|
logger.debug("Email service disabled, skipping account deletion confirmation");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String subject = "Account deletion confirmation";
|
||||||
|
|
||||||
|
String htmlContent = buildAccountDeletionTemplate(user);
|
||||||
|
String textContent = buildAccountDeletionTextTemplate(user);
|
||||||
|
|
||||||
|
return sendEmail(user.getEmail(), subject, htmlContent, textContent);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send account deletion confirmation to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends password reset email
|
||||||
|
*
|
||||||
|
* @param user User requesting password reset
|
||||||
|
* @param resetToken Password reset token
|
||||||
|
* @return true if email sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendPasswordResetEmail(User user, String resetToken) {
|
||||||
|
logger.info("Sending password reset email to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!emailEnabled) {
|
||||||
|
logger.debug("Email service disabled, skipping password reset email");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String subject = "Reset your password";
|
||||||
|
String resetUrl = baseUrl + "/reset-password?token=" + resetToken;
|
||||||
|
|
||||||
|
String htmlContent = buildPasswordResetTemplate(user, resetUrl);
|
||||||
|
String textContent = buildPasswordResetTextTemplate(user, resetUrl);
|
||||||
|
|
||||||
|
return sendEmail(user.getEmail(), subject, htmlContent, textContent);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send password reset email to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private boolean sendEmail(String toEmail, String subject, String htmlContent, String textContent) {
|
||||||
|
if ("development".equals(environment) || "demo".equals(environment)) {
|
||||||
|
// Email service in development mode - logging instead of sending
|
||||||
|
logger.info("EMAIL [{}]: To: {}, Subject: {}", environment.toUpperCase(), toEmail, subject);
|
||||||
|
logger.debug("Email content:\n{}", textContent);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Production email service integration would be implemented here
|
||||||
|
// Configure with actual email service provider (SendGrid, AWS SES, etc.)
|
||||||
|
/*
|
||||||
|
MimeMessage message = mailSender.createMimeMessage();
|
||||||
|
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||||
|
|
||||||
|
helper.setFrom(fromAddress, fromName);
|
||||||
|
helper.setTo(toEmail);
|
||||||
|
helper.setSubject(subject);
|
||||||
|
helper.setText(textContent, htmlContent);
|
||||||
|
|
||||||
|
mailSender.send(message);
|
||||||
|
*/
|
||||||
|
|
||||||
|
// For now, simulate successful sending
|
||||||
|
CompletableFuture.runAsync(() -> {
|
||||||
|
try {
|
||||||
|
Thread.sleep(100); // Simulate network delay
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Email sent successfully to: {}", toEmail);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send email to: {}", toEmail, e);
|
||||||
|
throw new EmailServiceException("Failed to send email", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateEmailVerificationToken(User user) {
|
||||||
|
// In production, use JWT or similar secure token
|
||||||
|
return "email_verification_" + user.getId() + "_" + System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateDeviceVerificationCode(TrustedDevice device) {
|
||||||
|
// Return a fixed code for development/demo environments
|
||||||
|
if ("development".equals(environment) || "demo".equals(environment)) {
|
||||||
|
return "123456";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production implementation would integrate with DeviceFingerprintService
|
||||||
|
return "123456";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email template builders
|
||||||
|
|
||||||
|
private String buildEmailVerificationTemplate(User user, String verificationUrl) {
|
||||||
|
return String.format("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Email Verification</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #2c3e50;">Email Verification Required</h2>
|
||||||
|
|
||||||
|
<p>Hello %s,</p>
|
||||||
|
|
||||||
|
<p>Thank you for registering with our service. To complete your registration, please verify your email address by clicking the button below:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="%s" style="background-color: #3498db; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">Verify Email Address</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
|
||||||
|
<p style="word-break: break-all; color: #7f8c8d;">%s</p>
|
||||||
|
|
||||||
|
<p>This verification link will expire in 24 hours.</p>
|
||||||
|
|
||||||
|
<p>If you didn't create this account, please ignore this email.</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||||
|
<p style="font-size: 12px; color: #7f8c8d;">This is an automated message from %s. Please do not reply to this email.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", user.getName(), verificationUrl, verificationUrl, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildEmailVerificationTextTemplate(User user, String verificationUrl) {
|
||||||
|
return String.format("""
|
||||||
|
Email Verification Required
|
||||||
|
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
Thank you for registering with our service. To complete your registration, please verify your email address by visiting this link:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
This verification link will expire in 24 hours.
|
||||||
|
|
||||||
|
If you didn't create this account, please ignore this email.
|
||||||
|
|
||||||
|
This is an automated message from %s. Please do not reply to this email.
|
||||||
|
""", user.getName(), verificationUrl, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDeviceVerificationTemplate(User user, TrustedDevice device, String verificationCode) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Device Verification</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #e74c3c;">New Device Detected</h2>
|
||||||
|
|
||||||
|
<p>Hello %s,</p>
|
||||||
|
|
||||||
|
<p>We detected a login attempt from a new device. For your security, please verify this device using the code below:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<div style="background-color: #f8f9fa; border: 2px solid #3498db; border-radius: 8px; padding: 20px; display: inline-block;">
|
||||||
|
<h3 style="margin: 0; color: #2c3e50; font-size: 24px; letter-spacing: 2px;">%s</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<h4 style="margin-top: 0;">Device Information:</h4>
|
||||||
|
<p><strong>Device:</strong> %s</p>
|
||||||
|
<p><strong>Location:</strong> %s</p>
|
||||||
|
<p><strong>Time:</strong> %s</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>This code will expire in 10 minutes.</p>
|
||||||
|
|
||||||
|
<p>If this wasn't you, please secure your account immediately by changing your password.</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||||
|
<p style="font-size: 12px; color: #7f8c8d;">This is an automated security message from %s.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", user.getName(), verificationCode, device.getDeviceName(),
|
||||||
|
device.getLocation(), timestamp, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDeviceVerificationTextTemplate(User user, TrustedDevice device, String verificationCode) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
New Device Detected
|
||||||
|
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
We detected a login attempt from a new device. For your security, please verify this device using the code below:
|
||||||
|
|
||||||
|
Verification Code: %s
|
||||||
|
|
||||||
|
Device Information:
|
||||||
|
- Device: %s
|
||||||
|
- Location: %s
|
||||||
|
- Time: %s
|
||||||
|
|
||||||
|
This code will expire in 10 minutes.
|
||||||
|
|
||||||
|
If this wasn't you, please secure your account immediately by changing your password.
|
||||||
|
|
||||||
|
This is an automated security message from %s.
|
||||||
|
""", user.getName(), verificationCode, device.getDeviceName(),
|
||||||
|
device.getLocation(), timestamp, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSecurityAlertTemplate(User user, String alertType, String details) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Security Alert</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #e74c3c;">Security Alert: %s</h2>
|
||||||
|
|
||||||
|
<p>Hello %s,</p>
|
||||||
|
|
||||||
|
<p>We detected the following security event on your account:</p>
|
||||||
|
|
||||||
|
<div style="background-color: #fff5f5; border-left: 4px solid #e74c3c; padding: 15px; margin: 20px 0;">
|
||||||
|
<p><strong>Alert:</strong> %s</p>
|
||||||
|
<p><strong>Details:</strong> %s</p>
|
||||||
|
<p><strong>Time:</strong> %s</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If this was you, no action is required. If you don't recognize this activity, please:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Change your password immediately</li>
|
||||||
|
<li>Review your trusted devices</li>
|
||||||
|
<li>Enable two-factor authentication if not already enabled</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||||
|
<p style="font-size: 12px; color: #7f8c8d;">This is an automated security message from %s.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", alertType, user.getName(), alertType, details, timestamp, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSecurityAlertTextTemplate(User user, String alertType, String details) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
Security Alert: %s
|
||||||
|
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
We detected the following security event on your account:
|
||||||
|
|
||||||
|
Alert: %s
|
||||||
|
Details: %s
|
||||||
|
Time: %s
|
||||||
|
|
||||||
|
If this was you, no action is required. If you don't recognize this activity, please:
|
||||||
|
- Change your password immediately
|
||||||
|
- Review your trusted devices
|
||||||
|
- Enable two-factor authentication if not already enabled
|
||||||
|
|
||||||
|
This is an automated security message from %s.
|
||||||
|
""", alertType, user.getName(), alertType, details, timestamp, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPasswordChangeTemplate(User user, String ipAddress) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Password Changed</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #27ae60;">Password Changed Successfully</h2>
|
||||||
|
|
||||||
|
<p>Hello %s,</p>
|
||||||
|
|
||||||
|
<p>Your password has been changed successfully.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Change Details:</strong></p>
|
||||||
|
<p><strong>Time:</strong> %s</p>
|
||||||
|
<p><strong>IP Address:</strong> %s</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If you didn't make this change, please contact support immediately and consider:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Securing your email account</li>
|
||||||
|
<li>Checking for unauthorized account access</li>
|
||||||
|
<li>Enabling two-factor authentication</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||||
|
<p style="font-size: 12px; color: #7f8c8d;">This is an automated security message from %s.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", user.getName(), timestamp, ipAddress, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPasswordChangeTextTemplate(User user, String ipAddress) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
Password Changed Successfully
|
||||||
|
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
Your password has been changed successfully.
|
||||||
|
|
||||||
|
Change Details:
|
||||||
|
- Time: %s
|
||||||
|
- IP Address: %s
|
||||||
|
|
||||||
|
If you didn't make this change, please contact support immediately and consider:
|
||||||
|
- Securing your email account
|
||||||
|
- Checking for unauthorized account access
|
||||||
|
- Enabling two-factor authentication
|
||||||
|
|
||||||
|
This is an automated security message from %s.
|
||||||
|
""", user.getName(), timestamp, ipAddress, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDeviceRevokedTemplate(User user, TrustedDevice device, String ipAddress) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Device Trust Revoked</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #f39c12;">Device Trust Revoked</h2>
|
||||||
|
|
||||||
|
<p>Hello %s,</p>
|
||||||
|
|
||||||
|
<p>The trust for one of your devices has been revoked.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Device Details:</strong></p>
|
||||||
|
<p><strong>Device:</strong> %s</p>
|
||||||
|
<p><strong>Last Location:</strong> %s</p>
|
||||||
|
<p><strong>Revoked From:</strong> %s</p>
|
||||||
|
<p><strong>Time:</strong> %s</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>This device will now require verification for future logins.</p>
|
||||||
|
|
||||||
|
<p>If you didn't make this change, please secure your account immediately.</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||||
|
<p style="font-size: 12px; color: #7f8c8d;">This is an automated security message from %s.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", user.getName(), device.getDeviceName(), device.getLocation(), ipAddress, timestamp, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDeviceRevokedTextTemplate(User user, TrustedDevice device, String ipAddress) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
Device Trust Revoked
|
||||||
|
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
The trust for one of your devices has been revoked.
|
||||||
|
|
||||||
|
Device Details:
|
||||||
|
- Device: %s
|
||||||
|
- Last Location: %s
|
||||||
|
- Revoked From: %s
|
||||||
|
- Time: %s
|
||||||
|
|
||||||
|
This device will now require verification for future logins.
|
||||||
|
|
||||||
|
If you didn't make this change, please secure your account immediately.
|
||||||
|
|
||||||
|
This is an automated security message from %s.
|
||||||
|
""", user.getName(), device.getDeviceName(), device.getLocation(), ipAddress, timestamp, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDeviceRemovedTemplate(User user, TrustedDevice device, String ipAddress) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Device Removed</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #e74c3c;">Device Removed</h2>
|
||||||
|
|
||||||
|
<p>Hello %s,</p>
|
||||||
|
|
||||||
|
<p>A device has been removed from your account.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Device Details:</strong></p>
|
||||||
|
<p><strong>Device:</strong> %s</p>
|
||||||
|
<p><strong>Last Location:</strong> %s</p>
|
||||||
|
<p><strong>Removed From:</strong> %s</p>
|
||||||
|
<p><strong>Time:</strong> %s</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>This device will no longer be recognized and will require full verification for future logins.</p>
|
||||||
|
|
||||||
|
<p>If you didn't make this change, please secure your account immediately.</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||||
|
<p style="font-size: 12px; color: #7f8c8d;">This is an automated security message from %s.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", user.getName(), device.getDeviceName(), device.getLocation(), ipAddress, timestamp, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDeviceRemovedTextTemplate(User user, TrustedDevice device, String ipAddress) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
Device Removed
|
||||||
|
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
A device has been removed from your account.
|
||||||
|
|
||||||
|
Device Details:
|
||||||
|
- Device: %s
|
||||||
|
- Last Location: %s
|
||||||
|
- Removed From: %s
|
||||||
|
- Time: %s
|
||||||
|
|
||||||
|
This device will no longer be recognized and will require full verification for future logins.
|
||||||
|
|
||||||
|
If you didn't make this change, please secure your account immediately.
|
||||||
|
|
||||||
|
This is an automated security message from %s.
|
||||||
|
""", user.getName(), device.getDeviceName(), device.getLocation(), ipAddress, timestamp, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildAccountDeletionTemplate(User user) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Account Deleted</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #95a5a6;">Account Deletion Confirmation</h2>
|
||||||
|
|
||||||
|
<p>Hello %s,</p>
|
||||||
|
|
||||||
|
<p>Your account has been successfully deleted at your request.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<p><strong>Deletion Details:</strong></p>
|
||||||
|
<p><strong>Time:</strong> %s</p>
|
||||||
|
<p><strong>Email:</strong> %s</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>All your personal data has been removed from our systems. This action cannot be undone.</p>
|
||||||
|
|
||||||
|
<p>Thank you for using our service.</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||||
|
<p style="font-size: 12px; color: #7f8c8d;">This is the final automated message from %s.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", user.getName(), timestamp, user.getEmail(), fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildAccountDeletionTextTemplate(User user) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
|
||||||
|
return String.format("""
|
||||||
|
Account Deletion Confirmation
|
||||||
|
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
Your account has been successfully deleted at your request.
|
||||||
|
|
||||||
|
Deletion Details:
|
||||||
|
- Time: %s
|
||||||
|
- Email: %s
|
||||||
|
|
||||||
|
All your personal data has been removed from our systems. This action cannot be undone.
|
||||||
|
|
||||||
|
Thank you for using our service.
|
||||||
|
|
||||||
|
This is the final automated message from %s.
|
||||||
|
""", user.getName(), timestamp, user.getEmail(), fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPasswordResetTemplate(User user, String resetUrl) {
|
||||||
|
return String.format("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Password Reset</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<h2 style="color: #3498db;">Reset Your Password</h2>
|
||||||
|
|
||||||
|
<p>Hello %s,</p>
|
||||||
|
|
||||||
|
<p>We received a request to reset your password. Click the button below to create a new password:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="%s" style="background-color: #3498db; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">Reset Password</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
|
||||||
|
<p style="word-break: break-all; color: #7f8c8d;">%s</p>
|
||||||
|
|
||||||
|
<p>This password reset link will expire in 1 hour.</p>
|
||||||
|
|
||||||
|
<p>If you didn't request this password reset, please ignore this email. Your password will remain unchanged.</p>
|
||||||
|
|
||||||
|
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||||
|
<p style="font-size: 12px; color: #7f8c8d;">This is an automated message from %s. Please do not reply to this email.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", user.getName(), resetUrl, resetUrl, fromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPasswordResetTextTemplate(User user, String resetUrl) {
|
||||||
|
return String.format("""
|
||||||
|
Reset Your Password
|
||||||
|
|
||||||
|
Hello %s,
|
||||||
|
|
||||||
|
We received a request to reset your password. Visit this link to create a new password:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
This password reset link will expire in 1 hour.
|
||||||
|
|
||||||
|
If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
|
||||||
|
|
||||||
|
This is an automated message from %s. Please do not reply to this email.
|
||||||
|
""", user.getName(), resetUrl, fromName);
|
||||||
|
}
|
||||||
|
}
|
||||||
621
src/main/java/com/company/auth/service/GeoLocationService.java
Normal file
621
src/main/java/com/company/auth/service/GeoLocationService.java
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
/**
|
||||||
|
* GeoLocation Service
|
||||||
|
*
|
||||||
|
* Service for geographic location resolution and analysis.
|
||||||
|
* Provides IP-based location detection and geographic risk assessment.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.service;
|
||||||
|
|
||||||
|
import com.company.auth.exception.GeoLocationException;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeoLocation Service Implementation
|
||||||
|
*
|
||||||
|
* Provides geographic location services including:
|
||||||
|
* - IP-based location resolution
|
||||||
|
* - Geographic risk assessment
|
||||||
|
* - Location-based security features
|
||||||
|
* - Travel pattern analysis
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class GeoLocationService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(GeoLocationService.class);
|
||||||
|
|
||||||
|
private static final Pattern IP_PATTERN = Pattern.compile(
|
||||||
|
"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
|
||||||
|
);
|
||||||
|
|
||||||
|
@Value("${app.geolocation.enabled:true}")
|
||||||
|
private boolean geoLocationEnabled;
|
||||||
|
|
||||||
|
@Value("${app.geolocation.provider:demo}")
|
||||||
|
private String geoLocationProvider;
|
||||||
|
|
||||||
|
@Value("${app.geolocation.cache-duration:3600}")
|
||||||
|
private long cacheDurationSeconds;
|
||||||
|
|
||||||
|
@Value("${app.environment:development}")
|
||||||
|
private String environment;
|
||||||
|
|
||||||
|
// Simple in-memory cache for demo purposes
|
||||||
|
// In production, use Redis or similar distributed cache
|
||||||
|
private final Map<String, LocationInfo> locationCache = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Long> cacheTimestamps = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location information container
|
||||||
|
*/
|
||||||
|
public static class LocationInfo {
|
||||||
|
private final String ipAddress;
|
||||||
|
private final String country;
|
||||||
|
private final String countryCode;
|
||||||
|
private final String region;
|
||||||
|
private final String city;
|
||||||
|
private final String timezone;
|
||||||
|
private final double latitude;
|
||||||
|
private final double longitude;
|
||||||
|
private final String isp;
|
||||||
|
private final boolean isVpn;
|
||||||
|
private final boolean isProxy;
|
||||||
|
private final boolean isTor;
|
||||||
|
private final String riskLevel;
|
||||||
|
|
||||||
|
public LocationInfo(String ipAddress, String country, String countryCode,
|
||||||
|
String region, String city, String timezone,
|
||||||
|
double latitude, double longitude, String isp,
|
||||||
|
boolean isVpn, boolean isProxy, boolean isTor, String riskLevel) {
|
||||||
|
this.ipAddress = ipAddress;
|
||||||
|
this.country = country;
|
||||||
|
this.countryCode = countryCode;
|
||||||
|
this.region = region;
|
||||||
|
this.city = city;
|
||||||
|
this.timezone = timezone;
|
||||||
|
this.latitude = latitude;
|
||||||
|
this.longitude = longitude;
|
||||||
|
this.isp = isp;
|
||||||
|
this.isVpn = isVpn;
|
||||||
|
this.isProxy = isProxy;
|
||||||
|
this.isTor = isTor;
|
||||||
|
this.riskLevel = riskLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
public String getIpAddress() { return ipAddress; }
|
||||||
|
public String getCountry() { return country; }
|
||||||
|
public String getCountryCode() { return countryCode; }
|
||||||
|
public String getRegion() { return region; }
|
||||||
|
public String getCity() { return city; }
|
||||||
|
public String getTimezone() { return timezone; }
|
||||||
|
public double getLatitude() { return latitude; }
|
||||||
|
public double getLongitude() { return longitude; }
|
||||||
|
public String getIsp() { return isp; }
|
||||||
|
public boolean isVpn() { return isVpn; }
|
||||||
|
public boolean isProxy() { return isProxy; }
|
||||||
|
public boolean isTor() { return isTor; }
|
||||||
|
public String getRiskLevel() { return riskLevel; }
|
||||||
|
|
||||||
|
public String getLocationString() {
|
||||||
|
if (city != null && !city.isEmpty()) {
|
||||||
|
return city + ", " + region + ", " + country;
|
||||||
|
} else if (region != null && !region.isEmpty()) {
|
||||||
|
return region + ", " + country;
|
||||||
|
}
|
||||||
|
return country;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isHighRisk() {
|
||||||
|
return "HIGH".equals(riskLevel) || isVpn || isProxy || isTor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.format("LocationInfo{ip='%s', location='%s', country='%s', risk='%s', vpn=%s}",
|
||||||
|
ipAddress, getLocationString(), countryCode, riskLevel, isVpn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves geographic location from IP address
|
||||||
|
*
|
||||||
|
* @param ipAddress IP address to resolve
|
||||||
|
* @return Location information
|
||||||
|
*/
|
||||||
|
public LocationInfo resolveLocation(String ipAddress) {
|
||||||
|
logger.debug("Resolving location for IP: {}", ipAddress);
|
||||||
|
|
||||||
|
if (!geoLocationEnabled) {
|
||||||
|
logger.debug("GeoLocation service disabled, returning default location");
|
||||||
|
return createDefaultLocation(ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidIpAddress(ipAddress)) {
|
||||||
|
logger.warn("Invalid IP address format: {}", ipAddress);
|
||||||
|
return createDefaultLocation(ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check cache first
|
||||||
|
LocationInfo cachedLocation = getCachedLocation(ipAddress);
|
||||||
|
if (cachedLocation != null) {
|
||||||
|
logger.debug("Returning cached location for IP: {}", ipAddress);
|
||||||
|
return cachedLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve location
|
||||||
|
LocationInfo location = resolveLocationFromProvider(ipAddress);
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
cacheLocation(ipAddress, location);
|
||||||
|
|
||||||
|
logger.debug("Resolved location for IP {}: {}", ipAddress, location.getLocationString());
|
||||||
|
return location;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to resolve location for IP: {}", ipAddress, e);
|
||||||
|
return createDefaultLocation(ipAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates risk score for a location
|
||||||
|
*
|
||||||
|
* @param location Location to assess
|
||||||
|
* @param userCountryCode User's typical country code
|
||||||
|
* @return Risk score (0-100, higher is riskier)
|
||||||
|
*/
|
||||||
|
public int calculateLocationRisk(LocationInfo location, String userCountryCode) {
|
||||||
|
logger.debug("Calculating location risk for: {}", location.getLocationString());
|
||||||
|
|
||||||
|
int riskScore = 0;
|
||||||
|
|
||||||
|
// High risk indicators
|
||||||
|
if (location.isVpn()) {
|
||||||
|
riskScore += 40;
|
||||||
|
logger.debug("VPN detected, adding 40 risk points");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.isProxy()) {
|
||||||
|
riskScore += 35;
|
||||||
|
logger.debug("Proxy detected, adding 35 risk points");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.isTor()) {
|
||||||
|
riskScore += 50;
|
||||||
|
logger.debug("Tor detected, adding 50 risk points");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Country risk assessment
|
||||||
|
if (userCountryCode != null && !userCountryCode.equals(location.getCountryCode())) {
|
||||||
|
riskScore += 20;
|
||||||
|
logger.debug("Different country detected, adding 20 risk points");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known high-risk countries (example list)
|
||||||
|
if (isHighRiskCountry(location.getCountryCode())) {
|
||||||
|
riskScore += 25;
|
||||||
|
logger.debug("High-risk country detected, adding 25 risk points");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISP risk assessment
|
||||||
|
if (isHighRiskIsp(location.getIsp())) {
|
||||||
|
riskScore += 15;
|
||||||
|
logger.debug("High-risk ISP detected, adding 15 risk points");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at 100
|
||||||
|
riskScore = Math.min(riskScore, 100);
|
||||||
|
|
||||||
|
logger.debug("Final risk score for {}: {}", location.getLocationString(), riskScore);
|
||||||
|
return riskScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates distance between two locations
|
||||||
|
*
|
||||||
|
* @param location1 First location
|
||||||
|
* @param location2 Second location
|
||||||
|
* @return Distance in kilometers
|
||||||
|
*/
|
||||||
|
public double calculateDistance(LocationInfo location1, LocationInfo location2) {
|
||||||
|
return calculateDistance(
|
||||||
|
location1.getLatitude(), location1.getLongitude(),
|
||||||
|
location2.getLatitude(), location2.getLongitude()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates distance between two coordinates using Haversine formula
|
||||||
|
*
|
||||||
|
* @param lat1 Latitude of first point
|
||||||
|
* @param lon1 Longitude of first point
|
||||||
|
* @param lat2 Latitude of second point
|
||||||
|
* @param lon2 Longitude of second point
|
||||||
|
* @return Distance in kilometers
|
||||||
|
*/
|
||||||
|
public double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||||
|
final int EARTH_RADIUS = 6371; // Earth radius in kilometers
|
||||||
|
|
||||||
|
double latDistance = Math.toRadians(lat2 - lat1);
|
||||||
|
double lonDistance = Math.toRadians(lon2 - lon1);
|
||||||
|
|
||||||
|
double a = Math.sin(latDistance / 2) * Math.sin(latDistance / 2)
|
||||||
|
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||||||
|
* Math.sin(lonDistance / 2) * Math.sin(lonDistance / 2);
|
||||||
|
|
||||||
|
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
|
return EARTH_RADIUS * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if location represents impossible travel
|
||||||
|
*
|
||||||
|
* @param previousLocation Previous user location
|
||||||
|
* @param currentLocation Current location
|
||||||
|
* @param timeDifferenceHours Time difference in hours
|
||||||
|
* @return true if travel is impossible
|
||||||
|
*/
|
||||||
|
public boolean isImpossibleTravel(LocationInfo previousLocation, LocationInfo currentLocation, double timeDifferenceHours) {
|
||||||
|
if (previousLocation == null || currentLocation == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
double distance = calculateDistance(previousLocation, currentLocation);
|
||||||
|
|
||||||
|
// Maximum reasonable travel speed (including commercial flights)
|
||||||
|
double maxSpeedKmh = 1000; // 1000 km/h
|
||||||
|
|
||||||
|
double maxPossibleDistance = maxSpeedKmh * timeDifferenceHours;
|
||||||
|
|
||||||
|
boolean impossible = distance > maxPossibleDistance;
|
||||||
|
|
||||||
|
if (impossible) {
|
||||||
|
logger.warn("Impossible travel detected: {} km in {} hours (max: {} km)",
|
||||||
|
distance, timeDifferenceHours, maxPossibleDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return impossible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes location string for comparison
|
||||||
|
*
|
||||||
|
* @param location Location to normalize
|
||||||
|
* @return Normalized location string
|
||||||
|
*/
|
||||||
|
public String normalizeLocation(LocationInfo location) {
|
||||||
|
if (location == null) {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder normalized = new StringBuilder();
|
||||||
|
|
||||||
|
if (location.getCity() != null && !location.getCity().isEmpty()) {
|
||||||
|
normalized.append(location.getCity());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.getRegion() != null && !location.getRegion().isEmpty()) {
|
||||||
|
if (normalized.length() > 0) {
|
||||||
|
normalized.append(", ");
|
||||||
|
}
|
||||||
|
normalized.append(location.getRegion());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.getCountry() != null && !location.getCountry().isEmpty()) {
|
||||||
|
if (normalized.length() > 0) {
|
||||||
|
normalized.append(", ");
|
||||||
|
}
|
||||||
|
normalized.append(location.getCountry());
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.length() > 0 ? normalized.toString() : "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private LocationInfo resolveLocationFromProvider(String ipAddress) {
|
||||||
|
if ("development".equals(environment) || "demo".equals(environment)) {
|
||||||
|
return createDemoLocation(ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (geoLocationProvider.toLowerCase()) {
|
||||||
|
case "maxmind":
|
||||||
|
return resolveWithMaxMind(ipAddress);
|
||||||
|
case "ipapi":
|
||||||
|
return resolveWithIpApi(ipAddress);
|
||||||
|
case "demo":
|
||||||
|
return createDemoLocation(ipAddress);
|
||||||
|
default:
|
||||||
|
logger.warn("Unknown geolocation provider: {}", geoLocationProvider);
|
||||||
|
return createDefaultLocation(ipAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocationInfo resolveWithMaxMind(String ipAddress) {
|
||||||
|
// Production integration with MaxMind GeoIP2 would be implemented here
|
||||||
|
// Configure with actual MaxMind database and API key
|
||||||
|
/*
|
||||||
|
try (DatabaseReader reader = new DatabaseReader.Builder(database).build()) {
|
||||||
|
InetAddress inetAddress = InetAddress.getByName(ipAddress);
|
||||||
|
CityResponse response = reader.city(inetAddress);
|
||||||
|
|
||||||
|
return new LocationInfo(
|
||||||
|
ipAddress,
|
||||||
|
response.getCountry().getName(),
|
||||||
|
response.getCountry().getIsoCode(),
|
||||||
|
response.getMostSpecificSubdivision().getName(),
|
||||||
|
response.getCity().getName(),
|
||||||
|
response.getLocation().getTimeZone(),
|
||||||
|
response.getLocation().getLatitude(),
|
||||||
|
response.getLocation().getLongitude(),
|
||||||
|
"", // ISP would need separate database
|
||||||
|
false, // VPN detection would need separate service
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"LOW"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
return createDemoLocation(ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocationInfo resolveWithIpApi(String ipAddress) {
|
||||||
|
// Integration with IP-API service would be implemented here for production use
|
||||||
|
// Currently returns demo location data for development and testing
|
||||||
|
|
||||||
|
return createDemoLocation(ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocationInfo createDemoLocation(String ipAddress) {
|
||||||
|
// Create demo locations based on IP patterns for development
|
||||||
|
Map<String, Object> demoData = getDemoLocationData(ipAddress);
|
||||||
|
|
||||||
|
return new LocationInfo(
|
||||||
|
ipAddress,
|
||||||
|
(String) demoData.get("country"),
|
||||||
|
(String) demoData.get("countryCode"),
|
||||||
|
(String) demoData.get("region"),
|
||||||
|
(String) demoData.get("city"),
|
||||||
|
(String) demoData.get("timezone"),
|
||||||
|
(Double) demoData.get("latitude"),
|
||||||
|
(Double) demoData.get("longitude"),
|
||||||
|
(String) demoData.get("isp"),
|
||||||
|
(Boolean) demoData.get("isVpn"),
|
||||||
|
(Boolean) demoData.get("isProxy"),
|
||||||
|
(Boolean) demoData.get("isTor"),
|
||||||
|
(String) demoData.get("riskLevel")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocationInfo createDefaultLocation(String ipAddress) {
|
||||||
|
return new LocationInfo(
|
||||||
|
ipAddress,
|
||||||
|
"Unknown",
|
||||||
|
"XX",
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"UTC",
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
"Unknown ISP",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"MEDIUM"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> getDemoLocationData(String ipAddress) {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
|
||||||
|
// Create different demo locations based on IP patterns
|
||||||
|
if (ipAddress.startsWith("192.168.") || ipAddress.startsWith("10.") || ipAddress.startsWith("172.")) {
|
||||||
|
// Private/Local IP
|
||||||
|
data.put("country", "Local Network");
|
||||||
|
data.put("countryCode", "LO");
|
||||||
|
data.put("region", "Local");
|
||||||
|
data.put("city", "Local");
|
||||||
|
data.put("timezone", "UTC");
|
||||||
|
data.put("latitude", 0.0);
|
||||||
|
data.put("longitude", 0.0);
|
||||||
|
data.put("isp", "Local ISP");
|
||||||
|
data.put("isVpn", false);
|
||||||
|
data.put("isProxy", false);
|
||||||
|
data.put("isTor", false);
|
||||||
|
data.put("riskLevel", "LOW");
|
||||||
|
} else if (ipAddress.contains("127.")) {
|
||||||
|
// Localhost
|
||||||
|
data.put("country", "Localhost");
|
||||||
|
data.put("countryCode", "LH");
|
||||||
|
data.put("region", "Local");
|
||||||
|
data.put("city", "Localhost");
|
||||||
|
data.put("timezone", "UTC");
|
||||||
|
data.put("latitude", 0.0);
|
||||||
|
data.put("longitude", 0.0);
|
||||||
|
data.put("isp", "Localhost");
|
||||||
|
data.put("isVpn", false);
|
||||||
|
data.put("isProxy", false);
|
||||||
|
data.put("isTor", false);
|
||||||
|
data.put("riskLevel", "LOW");
|
||||||
|
} else {
|
||||||
|
// Create varied demo data based on IP hash
|
||||||
|
int hash = ipAddress.hashCode();
|
||||||
|
String[] countries = {"United States", "Germany", "United Kingdom", "France", "Japan", "Canada"};
|
||||||
|
String[] countryCodes = {"US", "DE", "GB", "FR", "JP", "CA"};
|
||||||
|
String[] cities = {"New York", "Berlin", "London", "Paris", "Tokyo", "Toronto"};
|
||||||
|
String[] regions = {"New York", "Berlin", "England", "Île-de-France", "Tokyo", "Ontario"};
|
||||||
|
String[] timezones = {"America/New_York", "Europe/Berlin", "Europe/London", "Europe/Paris", "Asia/Tokyo", "America/Toronto"};
|
||||||
|
double[] latitudes = {40.7128, 52.5200, 51.5074, 48.8566, 35.6762, 43.6532};
|
||||||
|
double[] longitudes = {-74.0060, 13.4050, -0.1278, 2.3522, 139.6503, -79.3832};
|
||||||
|
|
||||||
|
int index = Math.abs(hash) % countries.length;
|
||||||
|
|
||||||
|
data.put("country", countries[index]);
|
||||||
|
data.put("countryCode", countryCodes[index]);
|
||||||
|
data.put("region", regions[index]);
|
||||||
|
data.put("city", cities[index]);
|
||||||
|
data.put("timezone", timezones[index]);
|
||||||
|
data.put("latitude", latitudes[index]);
|
||||||
|
data.put("longitude", longitudes[index]);
|
||||||
|
data.put("isp", "Demo ISP " + (index + 1));
|
||||||
|
data.put("isVpn", hash % 10 == 0); // 10% chance of VPN
|
||||||
|
data.put("isProxy", hash % 15 == 0); // ~6.7% chance of proxy
|
||||||
|
data.put("isTor", hash % 20 == 0); // 5% chance of Tor
|
||||||
|
|
||||||
|
boolean hasRisk = (Boolean) data.get("isVpn") || (Boolean) data.get("isProxy") || (Boolean) data.get("isTor");
|
||||||
|
data.put("riskLevel", hasRisk ? "HIGH" : (hash % 3 == 0 ? "MEDIUM" : "LOW"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isValidIpAddress(String ipAddress) {
|
||||||
|
if (ipAddress == null || ipAddress.trim().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
InetAddress.getByName(ipAddress);
|
||||||
|
return true;
|
||||||
|
} catch (UnknownHostException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocationInfo getCachedLocation(String ipAddress) {
|
||||||
|
Long timestamp = cacheTimestamps.get(ipAddress);
|
||||||
|
if (timestamp == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long age = (System.currentTimeMillis() - timestamp) / 1000;
|
||||||
|
if (age > cacheDurationSeconds) {
|
||||||
|
// Cache expired
|
||||||
|
locationCache.remove(ipAddress);
|
||||||
|
cacheTimestamps.remove(ipAddress);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return locationCache.get(ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cacheLocation(String ipAddress, LocationInfo location) {
|
||||||
|
locationCache.put(ipAddress, location);
|
||||||
|
cacheTimestamps.put(ipAddress, System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isHighRiskCountry(String countryCode) {
|
||||||
|
// Example high-risk countries (this would be configurable in production)
|
||||||
|
String[] highRiskCountries = {"XX", "ZZ"}; // Placeholder codes
|
||||||
|
|
||||||
|
for (String riskCountry : highRiskCountries) {
|
||||||
|
if (riskCountry.equals(countryCode)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isHighRiskIsp(String isp) {
|
||||||
|
if (isp == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String ispLower = isp.toLowerCase();
|
||||||
|
|
||||||
|
// Common VPN/proxy ISP indicators
|
||||||
|
return ispLower.contains("vpn") ||
|
||||||
|
ispLower.contains("proxy") ||
|
||||||
|
ispLower.contains("tor") ||
|
||||||
|
ispLower.contains("hosting") ||
|
||||||
|
ispLower.contains("cloud");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets service status information
|
||||||
|
*
|
||||||
|
* @return Service status description
|
||||||
|
*/
|
||||||
|
public String getServiceStatus() {
|
||||||
|
if (!geoLocationEnabled) {
|
||||||
|
return "GeoLocation service disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.format("GeoLocation service active (provider: %s, cache: %d entries)",
|
||||||
|
geoLocationProvider, locationCache.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the location cache
|
||||||
|
*/
|
||||||
|
public void clearCache() {
|
||||||
|
locationCache.clear();
|
||||||
|
cacheTimestamps.clear();
|
||||||
|
logger.info("GeoLocation cache cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets cache statistics
|
||||||
|
*
|
||||||
|
* @return Cache statistics
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getCacheStats() {
|
||||||
|
Map<String, Object> stats = new HashMap<>();
|
||||||
|
stats.put("entries", locationCache.size());
|
||||||
|
stats.put("cacheDurationSeconds", cacheDurationSeconds);
|
||||||
|
stats.put("provider", geoLocationProvider);
|
||||||
|
stats.put("enabled", geoLocationEnabled);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets location information from IP address
|
||||||
|
*
|
||||||
|
* @param ipAddress IP address to resolve
|
||||||
|
* @return Map containing location information
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getLocationFromIp(String ipAddress) {
|
||||||
|
try {
|
||||||
|
LocationInfo location = resolveLocation(ipAddress);
|
||||||
|
Map<String, Object> locationMap = new HashMap<>();
|
||||||
|
locationMap.put("country", location.getCountry());
|
||||||
|
locationMap.put("city", location.getCity());
|
||||||
|
locationMap.put("region", location.getRegion());
|
||||||
|
locationMap.put("latitude", location.getLatitude());
|
||||||
|
locationMap.put("longitude", location.getLongitude());
|
||||||
|
locationMap.put("timezone", location.getTimezone());
|
||||||
|
locationMap.put("isp", location.getIsp());
|
||||||
|
return locationMap;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to resolve location for IP: {}", ipAddress, e);
|
||||||
|
Map<String, Object> fallback = new HashMap<>();
|
||||||
|
fallback.put("country", "Unknown");
|
||||||
|
fallback.put("city", "Unknown");
|
||||||
|
fallback.put("region", "Unknown");
|
||||||
|
fallback.put("latitude", 0.0);
|
||||||
|
fallback.put("longitude", 0.0);
|
||||||
|
fallback.put("timezone", "UTC");
|
||||||
|
fallback.put("isp", "Unknown");
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
560
src/main/java/com/company/auth/service/JwtTokenService.java
Normal file
560
src/main/java/com/company/auth/service/JwtTokenService.java
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
/**
|
||||||
|
* JWT Token Service
|
||||||
|
*
|
||||||
|
* Service for JWT token generation, validation, and management.
|
||||||
|
* Handles access tokens, refresh tokens, and session tokens
|
||||||
|
* for the authentication system.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.service;
|
||||||
|
|
||||||
|
import com.company.auth.entity.User;
|
||||||
|
import com.company.auth.exception.*;
|
||||||
|
import com.company.auth.util.SecurityUtils;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.*;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import io.jsonwebtoken.security.SecurityException;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.security.Key;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Token Service Implementation
|
||||||
|
*
|
||||||
|
* Provides JWT token services including:
|
||||||
|
* - Access token generation and validation
|
||||||
|
* - Refresh token management
|
||||||
|
* - Session token handling for multi-step auth
|
||||||
|
* - Token revocation and blacklisting
|
||||||
|
* - Security claims management
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class JwtTokenService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(JwtTokenService.class);
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(JwtTokenService.class);
|
||||||
|
|
||||||
|
// Token configuration
|
||||||
|
@Value("${app.jwt.secret}")
|
||||||
|
private String jwtSecret;
|
||||||
|
|
||||||
|
@Value("${app.jwt.access-token-expiration:900}") // 15 minutes default
|
||||||
|
private int accessTokenExpirationSeconds;
|
||||||
|
|
||||||
|
@Value("${app.jwt.refresh-token-expiration:2592000}") // 30 days default
|
||||||
|
private int refreshTokenExpirationSeconds;
|
||||||
|
|
||||||
|
@Value("${app.jwt.session-token-expiration:900}") // 15 minutes default
|
||||||
|
private int sessionTokenExpirationSeconds;
|
||||||
|
|
||||||
|
@Value("${app.jwt.issuer:auth-service}")
|
||||||
|
private String tokenIssuer;
|
||||||
|
|
||||||
|
// Token blacklist for revoked tokens
|
||||||
|
private final Set<String> revokedTokens = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
// Signing key
|
||||||
|
private Key signingKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize signing key after properties are set
|
||||||
|
*/
|
||||||
|
public void init() {
|
||||||
|
if (jwtSecret == null || jwtSecret.length() < 32) {
|
||||||
|
throw new IllegalStateException("JWT secret must be at least 32 characters long");
|
||||||
|
}
|
||||||
|
this.signingKey = Keys.hmacShaKeyFor(jwtSecret.getBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract email from token
|
||||||
|
*/
|
||||||
|
public String getEmailFromToken(String token) throws InvalidTokenException {
|
||||||
|
try {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
return claims.get("email", String.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new InvalidTokenException("Cannot extract email from token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an access token for authenticated user
|
||||||
|
*
|
||||||
|
* @param user Authenticated user
|
||||||
|
* @return JWT access token
|
||||||
|
*/
|
||||||
|
public String generateAccessToken(User user) {
|
||||||
|
logger.info("Generating access token for user: {}", user.getId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
Date now = new Date();
|
||||||
|
Date expiryDate = new Date(now.getTime() + (accessTokenExpirationSeconds * 1000L));
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.setSubject(user.getId().toString())
|
||||||
|
.setIssuer(tokenIssuer)
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpiration(expiryDate)
|
||||||
|
.claim("email", user.getEmail())
|
||||||
|
.claim("name", user.getName())
|
||||||
|
.claim("role", user.getRole())
|
||||||
|
.claim("emailVerified", user.getEmailVerified())
|
||||||
|
.claim("twoFactorEnabled", user.getTwoFactorEnabled())
|
||||||
|
.claim("type", "access")
|
||||||
|
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
|
||||||
|
.compact();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error generating access token for user: {}", user.getId(), e);
|
||||||
|
throw new TokenGenerationException("Failed to generate access token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a refresh token for token renewal
|
||||||
|
*
|
||||||
|
* @param user Authenticated user
|
||||||
|
* @return JWT refresh token
|
||||||
|
*/
|
||||||
|
public String generateRefreshToken(User user) {
|
||||||
|
logger.info("Generating refresh token for user: {}", user.getId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
Date now = new Date();
|
||||||
|
Date expiryDate = new Date(now.getTime() + (refreshTokenExpirationSeconds * 1000L));
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.setSubject(user.getId().toString())
|
||||||
|
.setIssuer(tokenIssuer)
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpiration(expiryDate)
|
||||||
|
.claim("type", "refresh")
|
||||||
|
.claim("tokenId", UUID.randomUUID().toString()) // Unique token ID for revocation
|
||||||
|
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
|
||||||
|
.compact();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error generating refresh token for user: {}", user.getId(), e);
|
||||||
|
throw new TokenGenerationException("Failed to generate refresh token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a session token for multi-step authentication
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param deviceId Device ID (optional)
|
||||||
|
* @return JWT session token
|
||||||
|
*/
|
||||||
|
public String generateSessionToken(Long userId, Long deviceId) {
|
||||||
|
// Generating session token for user
|
||||||
|
String jti = UUID.randomUUID().toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
Date now = new Date();
|
||||||
|
Date expiryDate = new Date(now.getTime() + (sessionTokenExpirationSeconds * 1000L));
|
||||||
|
|
||||||
|
JwtBuilder builder = Jwts.builder()
|
||||||
|
.setSubject(userId.toString())
|
||||||
|
.setIssuer(tokenIssuer)
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpiration(expiryDate)
|
||||||
|
.claim("type", "session")
|
||||||
|
.claim("sessionId", UUID.randomUUID().toString());
|
||||||
|
|
||||||
|
if (deviceId != null) {
|
||||||
|
builder.claim("deviceId", deviceId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.signWith(getSigningKey(), SignatureAlgorithm.HS512).compact();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error generating session token for user: {}", userId, e);
|
||||||
|
throw new TokenGenerationException("Failed to generate session token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an access token
|
||||||
|
*
|
||||||
|
* @param token JWT access token
|
||||||
|
* @return User ID from token
|
||||||
|
* @throws InvalidTokenException if token is invalid
|
||||||
|
* @throws TokenExpiredException if token is expired
|
||||||
|
*/
|
||||||
|
public Long validateAccessToken(String token) {
|
||||||
|
// Validating access token
|
||||||
|
try {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
|
||||||
|
// Check token type
|
||||||
|
String tokenType = claims.get("type", String.class);
|
||||||
|
if (!"access".equals(tokenType)) {
|
||||||
|
throw new InvalidTokenException("Invalid token type: " + tokenType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is revoked
|
||||||
|
if (isTokenRevoked(token)) {
|
||||||
|
throw new InvalidTokenException("Token has been revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Long.parseLong(claims.getSubject());
|
||||||
|
|
||||||
|
} catch (ExpiredJwtException e) {
|
||||||
|
logger.debug("Access token expired");
|
||||||
|
throw new TokenExpiredException("Access token has expired", e);
|
||||||
|
} catch (JwtException e) {
|
||||||
|
logger.warn("Invalid access token: {}", e.getMessage());
|
||||||
|
throw new InvalidTokenException("Invalid access token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a refresh token
|
||||||
|
*
|
||||||
|
* @param token JWT refresh token
|
||||||
|
* @return User ID from token
|
||||||
|
* @throws InvalidTokenException if token is invalid
|
||||||
|
* @throws TokenExpiredException if token is expired
|
||||||
|
*/
|
||||||
|
public Long validateRefreshToken(String token) {
|
||||||
|
logger.debug("Validating refresh token");
|
||||||
|
|
||||||
|
try {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
|
||||||
|
// Check token type
|
||||||
|
String tokenType = claims.get("type", String.class);
|
||||||
|
if (!"refresh".equals(tokenType)) {
|
||||||
|
throw new InvalidTokenException("Invalid token type: " + tokenType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is revoked
|
||||||
|
if (isTokenRevoked(token)) {
|
||||||
|
throw new InvalidTokenException("Token has been revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Long.parseLong(claims.getSubject());
|
||||||
|
|
||||||
|
} catch (ExpiredJwtException e) {
|
||||||
|
logger.debug("Refresh token expired");
|
||||||
|
throw new TokenExpiredException("Refresh token has expired", e);
|
||||||
|
} catch (JwtException e) {
|
||||||
|
logger.warn("Invalid refresh token: {}", e.getMessage());
|
||||||
|
throw new InvalidTokenException("Invalid refresh token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a session token
|
||||||
|
*
|
||||||
|
* @param token JWT session token
|
||||||
|
* @return User ID from token
|
||||||
|
* @throws InvalidTokenException if token is invalid
|
||||||
|
* @throws TokenExpiredException if token is expired
|
||||||
|
*/
|
||||||
|
public Long validateSessionToken(String token) {
|
||||||
|
logger.debug("Validating session token");
|
||||||
|
|
||||||
|
try {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
|
||||||
|
// Check token type
|
||||||
|
String tokenType = claims.get("type", String.class);
|
||||||
|
if (!"session".equals(tokenType)) {
|
||||||
|
throw new InvalidTokenException("Invalid token type: " + tokenType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is revoked
|
||||||
|
if (isTokenRevoked(token)) {
|
||||||
|
throw new InvalidTokenException("Token has been revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Long.parseLong(claims.getSubject());
|
||||||
|
|
||||||
|
} catch (ExpiredJwtException e) {
|
||||||
|
logger.debug("Session token expired");
|
||||||
|
throw new TokenExpiredException("Session token has expired", e);
|
||||||
|
} catch (JwtException e) {
|
||||||
|
logger.warn("Invalid session token: {}", e.getMessage());
|
||||||
|
throw new InvalidTokenException("Invalid session token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts claims from a valid token
|
||||||
|
*
|
||||||
|
* @param token JWT token
|
||||||
|
* @return Claims object
|
||||||
|
*/
|
||||||
|
public Claims getClaimsFromToken(String token) {
|
||||||
|
try {
|
||||||
|
return parseToken(token);
|
||||||
|
} catch (JwtException e) {
|
||||||
|
logger.warn("Error extracting claims from token: {}", e.getMessage());
|
||||||
|
throw new InvalidTokenException("Cannot extract claims from token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets user ID from token without full validation
|
||||||
|
*
|
||||||
|
* @param token JWT token
|
||||||
|
* @return User ID
|
||||||
|
*/
|
||||||
|
public Long getUserIdFromToken(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = getClaimsFromToken(token);
|
||||||
|
return Long.parseLong(claims.getSubject());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Error extracting user ID from token: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if token is expired
|
||||||
|
*
|
||||||
|
* @param token JWT token
|
||||||
|
* @return true if token is expired
|
||||||
|
*/
|
||||||
|
public boolean isTokenExpired(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = getClaimsFromToken(token);
|
||||||
|
return claims.getExpiration().before(new Date());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return true; // Consider invalid tokens as expired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets token expiration date
|
||||||
|
*
|
||||||
|
* @param token JWT token
|
||||||
|
* @return Expiration date
|
||||||
|
*/
|
||||||
|
public Date getExpirationDateFromToken(String token) {
|
||||||
|
try {
|
||||||
|
Claims claims = getClaimsFromToken(token);
|
||||||
|
return claims.getExpiration();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates an access token
|
||||||
|
*
|
||||||
|
* @param token Access token to invalidate
|
||||||
|
*/
|
||||||
|
public void invalidateAccessToken(String token) {
|
||||||
|
logger.debug("Invalidating access token");
|
||||||
|
revokedTokens.add(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates a refresh token
|
||||||
|
*
|
||||||
|
* @param token Refresh token to invalidate
|
||||||
|
*/
|
||||||
|
public void invalidateRefreshToken(String token) {
|
||||||
|
logger.debug("Invalidating refresh token");
|
||||||
|
revokedTokens.add(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates a session token
|
||||||
|
*
|
||||||
|
* @param token Session token to invalidate
|
||||||
|
*/
|
||||||
|
public void invalidateSessionToken(String token) {
|
||||||
|
logger.debug("Invalidating session token");
|
||||||
|
revokedTokens.add(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if token is revoked
|
||||||
|
*
|
||||||
|
* @param token JWT token
|
||||||
|
* @return true if token is revoked
|
||||||
|
*/
|
||||||
|
public boolean isTokenRevoked(String token) {
|
||||||
|
return revokedTokens.contains(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up expired revoked tokens
|
||||||
|
*
|
||||||
|
* This method should be called periodically to prevent memory leaks
|
||||||
|
*/
|
||||||
|
public void cleanupExpiredTokens() {
|
||||||
|
logger.debug("Cleaning up expired revoked tokens");
|
||||||
|
|
||||||
|
revokedTokens.removeIf(token -> {
|
||||||
|
try {
|
||||||
|
return isTokenExpired(token);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Remove tokens that can't be parsed
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug("Revoked tokens cleanup completed. Remaining: {}", revokedTokens.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets access token expiration time in minutes
|
||||||
|
*
|
||||||
|
* @return Expiration time in minutes
|
||||||
|
*/
|
||||||
|
public int getAccessTokenExpirationMinutes() {
|
||||||
|
return accessTokenExpirationSeconds / 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets refresh token expiration time in minutes
|
||||||
|
*
|
||||||
|
* @return Expiration time in minutes
|
||||||
|
*/
|
||||||
|
public int getRefreshTokenExpirationMinutes() {
|
||||||
|
return refreshTokenExpirationSeconds / 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a token for password reset
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param email User email
|
||||||
|
* @return Password reset token
|
||||||
|
*/
|
||||||
|
public String generatePasswordResetToken(UUID userId, String email) {
|
||||||
|
logger.debug("Generating password reset token for user: {}", userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Date now = new Date();
|
||||||
|
Date expiryDate = new Date(now.getTime() + (3600 * 1000L)); // 1 hour
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.setSubject(userId.toString())
|
||||||
|
.setIssuer(tokenIssuer)
|
||||||
|
.setIssuedAt(now)
|
||||||
|
.setExpiration(expiryDate)
|
||||||
|
.claim("email", email)
|
||||||
|
.claim("type", "password_reset")
|
||||||
|
.claim("resetId", UUID.randomUUID().toString())
|
||||||
|
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
|
||||||
|
.compact();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error generating password reset token for user: {}", userId, e);
|
||||||
|
throw new TokenGenerationException("Failed to generate password reset token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a password reset token
|
||||||
|
*
|
||||||
|
* @param token Password reset token
|
||||||
|
* @return User ID from token
|
||||||
|
*/
|
||||||
|
public Long validatePasswordResetToken(String token) {
|
||||||
|
logger.debug("Validating password reset token");
|
||||||
|
|
||||||
|
try {
|
||||||
|
Claims claims = parseToken(token);
|
||||||
|
|
||||||
|
// Check token type
|
||||||
|
String tokenType = claims.get("type", String.class);
|
||||||
|
if (!"password_reset".equals(tokenType)) {
|
||||||
|
throw new InvalidTokenException("Invalid token type: " + tokenType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is revoked
|
||||||
|
if (isTokenRevoked(token)) {
|
||||||
|
throw new InvalidTokenException("Token has been revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Long.parseLong(claims.getSubject());
|
||||||
|
|
||||||
|
} catch (ExpiredJwtException e) {
|
||||||
|
logger.debug("Password reset token expired");
|
||||||
|
throw new TokenExpiredException("Password reset token has expired", e);
|
||||||
|
} catch (JwtException e) {
|
||||||
|
logger.warn("Invalid password reset token: {}", e.getMessage());
|
||||||
|
throw new InvalidTokenException("Invalid password reset token", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private Claims parseToken(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.setSigningKey(getSigningKey())
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Key getSigningKey() {
|
||||||
|
if (signingKey == null) {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
return signingKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate all tokens for a specific user
|
||||||
|
*/
|
||||||
|
public void invalidateAllTokensForUser(Long userId) {
|
||||||
|
// In a production system, this would invalidate all tokens for the user
|
||||||
|
// Implement token blacklist functionality using in-memory storage
|
||||||
|
// Store user ID in revoked tokens to blacklist all user tokens
|
||||||
|
String userKey = "user_" + userId;
|
||||||
|
revokedTokens.add(userKey);
|
||||||
|
|
||||||
|
// This could be enhanced using a token blacklist in Redis or database for persistence
|
||||||
|
// For now, we use in-memory storage which works for current deployment
|
||||||
|
log.info("All tokens invalidated for user: {}", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token information container class
|
||||||
|
*/
|
||||||
|
public static class TokenInfo {
|
||||||
|
private final String token;
|
||||||
|
private final Date expiresAt;
|
||||||
|
private final String type;
|
||||||
|
private final Map<String, Object> claims;
|
||||||
|
|
||||||
|
public TokenInfo(String token, Date expiresAt, String type, Map<String, Object> claims) {
|
||||||
|
this.token = token;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
this.type = type;
|
||||||
|
this.claims = claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getToken() { return token; }
|
||||||
|
public Date getExpiresAt() { return expiresAt; }
|
||||||
|
public String getType() { return type; }
|
||||||
|
public Map<String, Object> getClaims() { return claims; }
|
||||||
|
|
||||||
|
public LocalDateTime getExpiresAtLocalDateTime() {
|
||||||
|
return expiresAt.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
505
src/main/java/com/company/auth/service/SecurityService.java
Normal file
505
src/main/java/com/company/auth/service/SecurityService.java
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
/**
|
||||||
|
* Security Service
|
||||||
|
*
|
||||||
|
* Centralized security service for password validation, encryption,
|
||||||
|
* security event logging, and various security-related utilities.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.service;
|
||||||
|
|
||||||
|
import com.company.auth.entity.User;
|
||||||
|
import com.company.auth.exception.*;
|
||||||
|
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.KeyGenerator;
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import javax.crypto.spec.IvParameterSpec;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security Service Implementation
|
||||||
|
*
|
||||||
|
* Provides security services including:
|
||||||
|
* - Password strength validation
|
||||||
|
* - Data encryption and decryption
|
||||||
|
* - Security event logging
|
||||||
|
* - Input validation and sanitization
|
||||||
|
* - Risk assessment utilities
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SecurityService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SecurityService.class);
|
||||||
|
|
||||||
|
// Password validation patterns
|
||||||
|
private static final Pattern LOWERCASE_PATTERN = Pattern.compile(".*[a-z].*");
|
||||||
|
private static final Pattern UPPERCASE_PATTERN = Pattern.compile(".*[A-Z].*");
|
||||||
|
private static final Pattern DIGIT_PATTERN = Pattern.compile(".*\\d.*");
|
||||||
|
private static final Pattern SPECIAL_CHAR_PATTERN = Pattern.compile(".*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?].*");
|
||||||
|
|
||||||
|
// Common weak passwords
|
||||||
|
private static final Set<String> WEAK_PASSWORDS = Set.of(
|
||||||
|
"password", "123456", "123456789", "qwerty", "abc123", "password123",
|
||||||
|
"admin", "letmein", "welcome", "monkey", "dragon", "master", "shadow"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Email domain validation
|
||||||
|
private static final Pattern EMAIL_DOMAIN_PATTERN = Pattern.compile("^[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$");
|
||||||
|
|
||||||
|
// Phone number pattern (international format)
|
||||||
|
private static final Pattern PHONE_PATTERN = Pattern.compile("^\\+?[1-9]\\d{1,14}$");
|
||||||
|
|
||||||
|
// Language code pattern
|
||||||
|
private static final Pattern LANGUAGE_PATTERN = Pattern.compile("^[a-z]{2}(-[A-Z]{2})?$");
|
||||||
|
|
||||||
|
// Known high-risk IP ranges (simplified for demo)
|
||||||
|
private static final Set<String> HIGH_RISK_IP_PREFIXES = Set.of(
|
||||||
|
"10.0.0.", "192.168.1.", "127.0.0." // Add real threat intelligence IPs in production
|
||||||
|
);
|
||||||
|
|
||||||
|
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(12);
|
||||||
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates password strength
|
||||||
|
*
|
||||||
|
* @param password Password to validate
|
||||||
|
* @throws WeakPasswordException if password doesn't meet requirements
|
||||||
|
*/
|
||||||
|
public void validatePasswordStrength(String password) {
|
||||||
|
if (password == null || password.trim().isEmpty()) {
|
||||||
|
throw new WeakPasswordException("Password cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length check
|
||||||
|
if (password.length() < 8) {
|
||||||
|
throw new WeakPasswordException("Password must be at least 8 characters long");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length() > 128) {
|
||||||
|
throw new WeakPasswordException("Password cannot exceed 128 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character requirements
|
||||||
|
if (!LOWERCASE_PATTERN.matcher(password).matches()) {
|
||||||
|
throw new WeakPasswordException("Password must contain at least one lowercase letter");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!UPPERCASE_PATTERN.matcher(password).matches()) {
|
||||||
|
throw new WeakPasswordException("Password must contain at least one uppercase letter");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DIGIT_PATTERN.matcher(password).matches()) {
|
||||||
|
throw new WeakPasswordException("Password must contain at least one digit");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SPECIAL_CHAR_PATTERN.matcher(password).matches()) {
|
||||||
|
throw new WeakPasswordException("Password must contain at least one special character");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common password check
|
||||||
|
if (WEAK_PASSWORDS.contains(password.toLowerCase())) {
|
||||||
|
throw new WeakPasswordException("Password is too common. Please choose a different password.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequential character check
|
||||||
|
if (hasSequentialCharacters(password)) {
|
||||||
|
throw new WeakPasswordException("Password should not contain sequential characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repeated character check
|
||||||
|
if (hasRepeatedCharacters(password)) {
|
||||||
|
throw new WeakPasswordException("Password should not have too many repeated characters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if password was recently used by user
|
||||||
|
*
|
||||||
|
* @param user User entity
|
||||||
|
* @param newPassword New password to check
|
||||||
|
* @return true if password was recently used
|
||||||
|
*/
|
||||||
|
public boolean isPasswordRecentlyUsed(User user, String newPassword) {
|
||||||
|
// In a production system, you would check against a password history table
|
||||||
|
// For now, just check against current password
|
||||||
|
if (user.getPasswordHash() != null) {
|
||||||
|
return passwordEncoder.matches(newPassword, user.getPasswordHash());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts sensitive data using AES encryption
|
||||||
|
*
|
||||||
|
* @param plaintext Data to encrypt
|
||||||
|
* @return Encrypted data as Base64 string
|
||||||
|
*/
|
||||||
|
public String encrypt(String plaintext) {
|
||||||
|
try {
|
||||||
|
// Generate AES key
|
||||||
|
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
|
||||||
|
keyGenerator.init(256);
|
||||||
|
SecretKey secretKey = keyGenerator.generateKey();
|
||||||
|
|
||||||
|
// Generate IV
|
||||||
|
byte[] iv = new byte[16];
|
||||||
|
secureRandom.nextBytes(iv);
|
||||||
|
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivParameterSpec);
|
||||||
|
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
// Combine key + IV + encrypted data
|
||||||
|
byte[] combined = new byte[32 + 16 + encrypted.length];
|
||||||
|
System.arraycopy(secretKey.getEncoded(), 0, combined, 0, 32);
|
||||||
|
System.arraycopy(iv, 0, combined, 32, 16);
|
||||||
|
System.arraycopy(encrypted, 0, combined, 48, encrypted.length);
|
||||||
|
|
||||||
|
return Base64.getEncoder().encodeToString(combined);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error encrypting data", e);
|
||||||
|
throw new SecurityException("Encryption failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts data encrypted with encrypt method
|
||||||
|
*
|
||||||
|
* @param encryptedData Base64 encoded encrypted data
|
||||||
|
* @return Decrypted plaintext
|
||||||
|
*/
|
||||||
|
public String decrypt(String encryptedData) {
|
||||||
|
try {
|
||||||
|
byte[] combined = Base64.getDecoder().decode(encryptedData);
|
||||||
|
|
||||||
|
// Extract components
|
||||||
|
byte[] keyBytes = new byte[32];
|
||||||
|
byte[] iv = new byte[16];
|
||||||
|
byte[] encrypted = new byte[combined.length - 48];
|
||||||
|
|
||||||
|
System.arraycopy(combined, 0, keyBytes, 0, 32);
|
||||||
|
System.arraycopy(combined, 32, iv, 0, 16);
|
||||||
|
System.arraycopy(combined, 48, encrypted, 0, encrypted.length);
|
||||||
|
|
||||||
|
// Recreate key and IV
|
||||||
|
SecretKey secretKey = new SecretKeySpec(keyBytes, "AES");
|
||||||
|
IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameterSpec);
|
||||||
|
byte[] decrypted = cipher.doFinal(encrypted);
|
||||||
|
|
||||||
|
return new String(decrypted, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error decrypting data", e);
|
||||||
|
throw new SecurityException("Decryption failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashes verification codes for secure storage
|
||||||
|
*
|
||||||
|
* @param code Verification code to hash
|
||||||
|
* @return Hashed code
|
||||||
|
*/
|
||||||
|
public String hashVerificationCode(String code) {
|
||||||
|
return passwordEncoder.encode(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a code against its hash
|
||||||
|
*
|
||||||
|
* @param code Plain code
|
||||||
|
* @param hashedCode Hashed code
|
||||||
|
* @return true if code matches
|
||||||
|
*/
|
||||||
|
public boolean verifyCode(String code, String hashedCode) {
|
||||||
|
try {
|
||||||
|
return passwordEncoder.matches(code, hashedCode);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Error verifying code", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates email domain
|
||||||
|
*
|
||||||
|
* @param email Email address to validate
|
||||||
|
* @throws InvalidEmailException if email domain is invalid
|
||||||
|
*/
|
||||||
|
public void validateEmailDomain(String email) {
|
||||||
|
if (email == null || !email.contains("@")) {
|
||||||
|
throw new InvalidEmailException("Invalid email format");
|
||||||
|
}
|
||||||
|
|
||||||
|
String domain = email.substring(email.lastIndexOf("@") + 1);
|
||||||
|
|
||||||
|
if (!EMAIL_DOMAIN_PATTERN.matcher(domain).matches()) {
|
||||||
|
throw new InvalidEmailException("Invalid email domain: " + domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for disposable email domains (simplified list)
|
||||||
|
Set<String> disposableDomains = Set.of(
|
||||||
|
"10minutemail.com", "tempmail.org", "guerrillamail.com",
|
||||||
|
"mailinator.com", "throwaway.email"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (disposableDomains.contains(domain.toLowerCase())) {
|
||||||
|
throw new InvalidEmailException("Disposable email addresses are not allowed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates phone number format
|
||||||
|
*
|
||||||
|
* @param phoneNumber Phone number to validate
|
||||||
|
* @return true if valid
|
||||||
|
*/
|
||||||
|
public boolean isValidPhoneNumber(String phoneNumber) {
|
||||||
|
return phoneNumber != null && PHONE_PATTERN.matcher(phoneNumber).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates language code format
|
||||||
|
*
|
||||||
|
* @param languageCode Language code to validate
|
||||||
|
* @return true if valid
|
||||||
|
*/
|
||||||
|
public boolean isValidLanguageCode(String languageCode) {
|
||||||
|
return languageCode != null && LANGUAGE_PATTERN.matcher(languageCode).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates timezone string
|
||||||
|
*
|
||||||
|
* @param timezone Timezone to validate
|
||||||
|
* @return true if valid
|
||||||
|
*/
|
||||||
|
public boolean isValidTimezone(String timezone) {
|
||||||
|
if (timezone == null || timezone.trim().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ZoneId.of(timezone);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if IP address is from a high-risk location
|
||||||
|
*
|
||||||
|
* @param ipAddress IP address to check
|
||||||
|
* @return true if high risk
|
||||||
|
*/
|
||||||
|
public boolean isHighRiskIp(String ipAddress) {
|
||||||
|
if (ipAddress == null) return false;
|
||||||
|
|
||||||
|
// Simple prefix matching (in production, use proper threat intelligence)
|
||||||
|
return HIGH_RISK_IP_PREFIXES.stream()
|
||||||
|
.anyMatch(prefix -> ipAddress.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for recent suspicious activity
|
||||||
|
*
|
||||||
|
* @param user User to check
|
||||||
|
* @param ipAddress Current IP address
|
||||||
|
* @return true if suspicious activity detected
|
||||||
|
*/
|
||||||
|
public boolean hasRecentSuspiciousActivity(User user, String ipAddress) {
|
||||||
|
// Check for recent failed login attempts
|
||||||
|
if (user.getFailedLoginAttempts() > 3) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for account lockout in last 24 hours
|
||||||
|
if (user.getLockedUntil() != null &&
|
||||||
|
user.getLockedUntil().isAfter(LocalDateTime.now().minusDays(1))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for high-risk IP
|
||||||
|
if (isHighRiskIp(ipAddress)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, you would check:
|
||||||
|
// - Multiple failed attempts from different IPs
|
||||||
|
// - Unusual login times
|
||||||
|
// - Geographic anomalies
|
||||||
|
// - Device fingerprint changes
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets pending security alerts count for user
|
||||||
|
*
|
||||||
|
* @param user User to check
|
||||||
|
* @return Number of pending alerts
|
||||||
|
*/
|
||||||
|
public int getPendingAlertsCount(User user) {
|
||||||
|
int alertCount = 0;
|
||||||
|
|
||||||
|
// Check for unverified email
|
||||||
|
if (!Boolean.TRUE.equals(user.getEmailVerified())) {
|
||||||
|
alertCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for disabled 2FA
|
||||||
|
if (!Boolean.TRUE.equals(user.getTwoFactorEnabled())) {
|
||||||
|
alertCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for old password
|
||||||
|
if (user.getPasswordChangedAt() == null ||
|
||||||
|
user.getPasswordChangedAt().isBefore(LocalDateTime.now().minusDays(90))) {
|
||||||
|
alertCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for recent failed attempts
|
||||||
|
if (user.getFailedLoginAttempts() > 0) {
|
||||||
|
alertCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return alertCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs security events for monitoring
|
||||||
|
*
|
||||||
|
* @param user User involved in the event
|
||||||
|
* @param eventType Type of security event
|
||||||
|
* @param details Event details
|
||||||
|
*/
|
||||||
|
public void logSecurityEvent(User user, String eventType, String details) {
|
||||||
|
// In production, this would write to a security monitoring system
|
||||||
|
logger.info("SECURITY_EVENT: type={}, user={}, details={}",
|
||||||
|
eventType, user.getId(), details);
|
||||||
|
|
||||||
|
// You could implement:
|
||||||
|
// - Database logging to security_events table
|
||||||
|
// - Integration with SIEM systems
|
||||||
|
// - Real-time alerting for critical events
|
||||||
|
// - Audit trail maintenance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes user input to prevent XSS
|
||||||
|
*
|
||||||
|
* @param input User input to sanitize
|
||||||
|
* @return Sanitized input
|
||||||
|
*/
|
||||||
|
public String sanitizeInput(String input) {
|
||||||
|
if (input == null) return null;
|
||||||
|
|
||||||
|
return input.trim()
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("/", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a secure random string
|
||||||
|
*
|
||||||
|
* @param length Length of the string
|
||||||
|
* @return Random string
|
||||||
|
*/
|
||||||
|
public String generateSecureRandomString(int length) {
|
||||||
|
String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
result.append(characters.charAt(secureRandom.nextInt(characters.length())));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a secure numeric code
|
||||||
|
*
|
||||||
|
* @param length Length of the code
|
||||||
|
* @return Numeric code
|
||||||
|
*/
|
||||||
|
public String generateSecureNumericCode(int length) {
|
||||||
|
StringBuilder code = new StringBuilder();
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
code.append(secureRandom.nextInt(10));
|
||||||
|
}
|
||||||
|
return code.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private boolean hasSequentialCharacters(String password) {
|
||||||
|
int sequentialCount = 0;
|
||||||
|
for (int i = 0; i < password.length() - 1; i++) {
|
||||||
|
if (Math.abs(password.charAt(i) - password.charAt(i + 1)) == 1) {
|
||||||
|
sequentialCount++;
|
||||||
|
if (sequentialCount >= 3) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sequentialCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasRepeatedCharacters(String password) {
|
||||||
|
Map<Character, Integer> charCount = new HashMap<>();
|
||||||
|
for (char c : password.toCharArray()) {
|
||||||
|
charCount.put(c, charCount.getOrDefault(c, 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any character appears more than 25% of the time
|
||||||
|
int maxAllowed = Math.max(2, password.length() / 4);
|
||||||
|
return charCount.values().stream().anyMatch(count -> count > maxAllowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security validation result class
|
||||||
|
*/
|
||||||
|
public static class SecurityValidationResult {
|
||||||
|
private final boolean valid;
|
||||||
|
private final String message;
|
||||||
|
private final int severity; // 1=info, 2=warning, 3=error
|
||||||
|
|
||||||
|
public SecurityValidationResult(boolean valid, String message, int severity) {
|
||||||
|
this.valid = valid;
|
||||||
|
this.message = message;
|
||||||
|
this.severity = severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValid() { return valid; }
|
||||||
|
public String getMessage() { return message; }
|
||||||
|
public int getSeverity() { return severity; }
|
||||||
|
}
|
||||||
|
}
|
||||||
439
src/main/java/com/company/auth/service/SmsService.java
Normal file
439
src/main/java/com/company/auth/service/SmsService.java
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
/**
|
||||||
|
* SMS Service
|
||||||
|
*
|
||||||
|
* Service for sending SMS notifications including verification codes
|
||||||
|
* and security alerts.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.service;
|
||||||
|
|
||||||
|
import com.company.auth.entity.User;
|
||||||
|
import com.company.auth.entity.TrustedDevice;
|
||||||
|
import com.company.auth.exception.SmsServiceException;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMS Service Implementation
|
||||||
|
*
|
||||||
|
* Provides SMS services including:
|
||||||
|
* - Two-factor authentication codes
|
||||||
|
* - Device verification notifications
|
||||||
|
* - Security alerts via SMS
|
||||||
|
* - Emergency notifications
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SmsService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(SmsService.class);
|
||||||
|
|
||||||
|
private static final Pattern PHONE_PATTERN = Pattern.compile("^\\+[1-9]\\d{1,14}$");
|
||||||
|
|
||||||
|
@Value("${app.sms.enabled:true}")
|
||||||
|
private boolean smsEnabled;
|
||||||
|
|
||||||
|
@Value("${app.sms.provider:demo}")
|
||||||
|
private String smsProvider;
|
||||||
|
|
||||||
|
@Value("${app.sms.from-number:+1234567890}")
|
||||||
|
private String fromNumber;
|
||||||
|
|
||||||
|
@Value("${app.sms.service-name:AuthService}")
|
||||||
|
private String serviceName;
|
||||||
|
|
||||||
|
@Value("${app.environment:development}")
|
||||||
|
private String environment;
|
||||||
|
|
||||||
|
// In production, inject a real SMS service like Twilio, AWS SNS, etc.
|
||||||
|
// private SmsProvider smsProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends SMS with two-factor authentication code
|
||||||
|
*
|
||||||
|
* @param user User requesting 2FA
|
||||||
|
* @param phoneNumber Phone number to send to
|
||||||
|
* @param code Verification code
|
||||||
|
* @return true if SMS sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendTwoFactorCode(User user, String phoneNumber, String code) {
|
||||||
|
logger.info("Sending 2FA code via SMS to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!smsEnabled) {
|
||||||
|
logger.debug("SMS service disabled, skipping 2FA SMS");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPhoneNumber(phoneNumber)) {
|
||||||
|
logger.warn("Invalid phone number format for user: {}", user.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String message = buildTwoFactorMessage(code);
|
||||||
|
return sendSms(phoneNumber, message);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send 2FA SMS to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends device verification SMS
|
||||||
|
*
|
||||||
|
* @param user User requesting device verification
|
||||||
|
* @param phoneNumber Phone number to send to
|
||||||
|
* @param device Device being verified
|
||||||
|
* @param code Verification code
|
||||||
|
* @return true if SMS sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendDeviceVerificationSms(User user, String phoneNumber, TrustedDevice device, String code) {
|
||||||
|
logger.info("Sending device verification SMS to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!smsEnabled) {
|
||||||
|
logger.debug("SMS service disabled, skipping device verification SMS");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPhoneNumber(phoneNumber)) {
|
||||||
|
logger.warn("Invalid phone number format for user: {}", user.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String message = buildDeviceVerificationMessage(device, code);
|
||||||
|
return sendSms(phoneNumber, message);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send device verification SMS to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends security alert SMS
|
||||||
|
*
|
||||||
|
* @param user User to notify
|
||||||
|
* @param phoneNumber Phone number to send to
|
||||||
|
* @param alertType Type of security alert
|
||||||
|
* @param details Alert details
|
||||||
|
* @return true if SMS sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendSecurityAlertSms(User user, String phoneNumber, String alertType, String details) {
|
||||||
|
logger.info("Sending security alert SMS to user: {} - {}", user.getId(), alertType);
|
||||||
|
|
||||||
|
if (!smsEnabled) {
|
||||||
|
logger.debug("SMS service disabled, skipping security alert SMS");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPhoneNumber(phoneNumber)) {
|
||||||
|
logger.warn("Invalid phone number format for user: {}", user.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String message = buildSecurityAlertMessage(alertType, details);
|
||||||
|
return sendSms(phoneNumber, message);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send security alert SMS to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends emergency security notification
|
||||||
|
*
|
||||||
|
* @param user User to notify
|
||||||
|
* @param phoneNumber Phone number to send to
|
||||||
|
* @param emergencyType Type of emergency
|
||||||
|
* @return true if SMS sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendEmergencyNotification(User user, String phoneNumber, String emergencyType) {
|
||||||
|
logger.info("Sending emergency notification SMS to user: {} - {}", user.getId(), emergencyType);
|
||||||
|
|
||||||
|
if (!smsEnabled) {
|
||||||
|
logger.debug("SMS service disabled, skipping emergency notification SMS");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPhoneNumber(phoneNumber)) {
|
||||||
|
logger.warn("Invalid phone number format for user: {}", user.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String message = buildEmergencyNotificationMessage(emergencyType);
|
||||||
|
return sendSms(phoneNumber, message);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send emergency notification SMS to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends password change notification
|
||||||
|
*
|
||||||
|
* @param user User whose password was changed
|
||||||
|
* @param phoneNumber Phone number to send to
|
||||||
|
* @param ipAddress IP address from which password was changed
|
||||||
|
* @return true if SMS sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendPasswordChangeNotificationSms(User user, String phoneNumber, String ipAddress) {
|
||||||
|
logger.info("Sending password change notification SMS to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!smsEnabled) {
|
||||||
|
logger.debug("SMS service disabled, skipping password change notification SMS");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPhoneNumber(phoneNumber)) {
|
||||||
|
logger.warn("Invalid phone number format for user: {}", user.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String message = buildPasswordChangeNotificationMessage(ipAddress);
|
||||||
|
return sendSms(phoneNumber, message);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send password change notification SMS to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends login alert SMS
|
||||||
|
*
|
||||||
|
* @param user User who logged in
|
||||||
|
* @param phoneNumber Phone number to send to
|
||||||
|
* @param device Device used for login
|
||||||
|
* @param location Login location
|
||||||
|
* @return true if SMS sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendLoginAlertSms(User user, String phoneNumber, TrustedDevice device, String location) {
|
||||||
|
logger.info("Sending login alert SMS to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!smsEnabled) {
|
||||||
|
logger.debug("SMS service disabled, skipping login alert SMS");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPhoneNumber(phoneNumber)) {
|
||||||
|
logger.warn("Invalid phone number format for user: {}", user.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String message = buildLoginAlertMessage(device, location);
|
||||||
|
return sendSms(phoneNumber, message);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send login alert SMS to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends account locked notification
|
||||||
|
*
|
||||||
|
* @param user User whose account was locked
|
||||||
|
* @param phoneNumber Phone number to send to
|
||||||
|
* @param reason Reason for account lock
|
||||||
|
* @return true if SMS sent successfully
|
||||||
|
*/
|
||||||
|
public boolean sendAccountLockedNotificationSms(User user, String phoneNumber, String reason) {
|
||||||
|
logger.info("Sending account locked notification SMS to user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!smsEnabled) {
|
||||||
|
logger.debug("SMS service disabled, skipping account locked notification SMS");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPhoneNumber(phoneNumber)) {
|
||||||
|
logger.warn("Invalid phone number format for user: {}", user.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String message = buildAccountLockedNotificationMessage(reason);
|
||||||
|
return sendSms(phoneNumber, message);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send account locked notification SMS to user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private boolean sendSms(String phoneNumber, String message) {
|
||||||
|
if ("development".equals(environment) || "demo".equals(environment)) {
|
||||||
|
// SMS service in development mode - logging instead of sending
|
||||||
|
logger.info("SMS [{}]: To: {}, Message: {}", environment.toUpperCase(), phoneNumber, message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Production SMS service integration would be implemented here
|
||||||
|
// Configure with actual SMS service provider (Twilio, AWS SNS, etc.)
|
||||||
|
/*
|
||||||
|
Message message = Message.creator(
|
||||||
|
new PhoneNumber(phoneNumber),
|
||||||
|
new PhoneNumber(fromNumber),
|
||||||
|
messageText)
|
||||||
|
.create();
|
||||||
|
*/
|
||||||
|
|
||||||
|
// For now, simulate successful sending
|
||||||
|
CompletableFuture.runAsync(() -> {
|
||||||
|
try {
|
||||||
|
Thread.sleep(100); // Simulate network delay
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("SMS sent successfully to: {}", phoneNumber);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to send SMS to: {}", phoneNumber, e);
|
||||||
|
throw new SmsServiceException("Failed to send SMS", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isValidPhoneNumber(String phoneNumber) {
|
||||||
|
if (phoneNumber == null || phoneNumber.trim().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove spaces and dashes for validation
|
||||||
|
String cleanedNumber = phoneNumber.replaceAll("[\\s-]", "");
|
||||||
|
return PHONE_PATTERN.matcher(cleanedNumber).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMS message builders
|
||||||
|
|
||||||
|
private String buildTwoFactorMessage(String code) {
|
||||||
|
return String.format(
|
||||||
|
"%s: Your verification code is %s. Valid for 10 minutes. Don't share this code.",
|
||||||
|
serviceName, code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildDeviceVerificationMessage(TrustedDevice device, String code) {
|
||||||
|
return String.format(
|
||||||
|
"%s: New device detected (%s). Verification code: %s. Valid for 10 minutes.",
|
||||||
|
serviceName, device.getDeviceName(), code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSecurityAlertMessage(String alertType, String details) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM/dd HH:mm"));
|
||||||
|
return String.format(
|
||||||
|
"%s SECURITY ALERT: %s at %s. Details: %s. Contact support if this wasn't you.",
|
||||||
|
serviceName, alertType, timestamp, truncateDetails(details, 100)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildEmergencyNotificationMessage(String emergencyType) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM/dd HH:mm"));
|
||||||
|
return String.format(
|
||||||
|
"%s EMERGENCY: %s detected at %s. Secure your account immediately. Contact support.",
|
||||||
|
serviceName, emergencyType, timestamp
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildPasswordChangeNotificationMessage(String ipAddress) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM/dd HH:mm"));
|
||||||
|
return String.format(
|
||||||
|
"%s: Password changed at %s from %s. Contact support if this wasn't you.",
|
||||||
|
serviceName, timestamp, ipAddress
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildLoginAlertMessage(TrustedDevice device, String location) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM/dd HH:mm"));
|
||||||
|
return String.format(
|
||||||
|
"%s: Login at %s from %s in %s. Contact support if this wasn't you.",
|
||||||
|
serviceName, timestamp, device.getDeviceName(), location
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildAccountLockedNotificationMessage(String reason) {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM/dd HH:mm"));
|
||||||
|
return String.format(
|
||||||
|
"%s: Account locked at %s. Reason: %s. Contact support to unlock.",
|
||||||
|
serviceName, timestamp, truncateDetails(reason, 80)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncateDetails(String details, int maxLength) {
|
||||||
|
if (details == null) {
|
||||||
|
return "N/A";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.length() <= maxLength) {
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
return details.substring(0, maxLength - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if SMS service is properly configured
|
||||||
|
*
|
||||||
|
* @return true if SMS service is ready
|
||||||
|
*/
|
||||||
|
public boolean isServiceAvailable() {
|
||||||
|
if (!smsEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add additional checks for SMS provider configuration
|
||||||
|
switch (smsProvider.toLowerCase()) {
|
||||||
|
case "twilio":
|
||||||
|
// Check Twilio configuration
|
||||||
|
return true; // Placeholder
|
||||||
|
case "aws":
|
||||||
|
// Check AWS SNS configuration
|
||||||
|
return true; // Placeholder
|
||||||
|
case "demo":
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
logger.warn("Unknown SMS provider: {}", smsProvider);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets SMS service status information
|
||||||
|
*
|
||||||
|
* @return Service status description
|
||||||
|
*/
|
||||||
|
public String getServiceStatus() {
|
||||||
|
if (!smsEnabled) {
|
||||||
|
return "SMS service disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("development".equals(environment) || "demo".equals(environment)) {
|
||||||
|
return "SMS service running in " + environment + " mode";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SMS service active with provider: " + smsProvider;
|
||||||
|
}
|
||||||
|
}
|
||||||
485
src/main/java/com/company/auth/service/TotpService.java
Normal file
485
src/main/java/com/company/auth/service/TotpService.java
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
/**
|
||||||
|
* TOTP Service
|
||||||
|
*
|
||||||
|
* Service for Time-based One-Time Password (TOTP) generation and validation.
|
||||||
|
* Implements RFC 6238 TOTP algorithm for two-factor authentication.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.service;
|
||||||
|
|
||||||
|
import com.company.auth.entity.User;
|
||||||
|
import com.company.auth.exception.TotpException;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import java.security.InvalidKeyException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOTP Service Implementation
|
||||||
|
*
|
||||||
|
* Provides TOTP services including:
|
||||||
|
* - Secret key generation
|
||||||
|
* - TOTP code generation
|
||||||
|
* - TOTP code validation
|
||||||
|
* - QR code URL generation for authenticator apps
|
||||||
|
* - Backup code generation and validation
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class TotpService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TotpService.class);
|
||||||
|
|
||||||
|
private static final String HMAC_SHA1 = "HmacSHA1";
|
||||||
|
private static final int SECRET_KEY_LENGTH = 20; // 160 bits
|
||||||
|
private static final int CODE_LENGTH = 6;
|
||||||
|
private static final int TIME_STEP = 30; // 30 seconds
|
||||||
|
private static final int WINDOW_SIZE = 1; // Allow 1 window before/after current
|
||||||
|
|
||||||
|
@Value("${app.totp.issuer:AuthService}")
|
||||||
|
private String issuer;
|
||||||
|
|
||||||
|
@Value("${app.totp.enabled:true}")
|
||||||
|
private boolean totpEnabled;
|
||||||
|
|
||||||
|
@Value("${app.totp.backup-codes-count:10}")
|
||||||
|
private int backupCodesCount;
|
||||||
|
|
||||||
|
@Value("${app.environment:development}")
|
||||||
|
private String environment;
|
||||||
|
|
||||||
|
// Cache for used TOTP codes to prevent replay attacks
|
||||||
|
private final Map<String, Long> usedCodes = new ConcurrentHashMap<>();
|
||||||
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOTP Setup Information
|
||||||
|
*/
|
||||||
|
public static class TotpSetup {
|
||||||
|
private final String secretKey;
|
||||||
|
private final String qrCodeUrl;
|
||||||
|
private final String manualEntryKey;
|
||||||
|
private final String[] backupCodes;
|
||||||
|
|
||||||
|
public TotpSetup(String secretKey, String qrCodeUrl, String manualEntryKey, String[] backupCodes) {
|
||||||
|
this.secretKey = secretKey;
|
||||||
|
this.qrCodeUrl = qrCodeUrl;
|
||||||
|
this.manualEntryKey = manualEntryKey;
|
||||||
|
this.backupCodes = backupCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSecretKey() { return secretKey; }
|
||||||
|
public String getQrCodeUrl() { return qrCodeUrl; }
|
||||||
|
public String getManualEntryKey() { return manualEntryKey; }
|
||||||
|
public String[] getBackupCodes() { return backupCodes; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new TOTP setup for a user
|
||||||
|
*
|
||||||
|
* @param user User to generate TOTP setup for
|
||||||
|
* @return TOTP setup information
|
||||||
|
*/
|
||||||
|
public TotpSetup generateTotpSetup(User user) {
|
||||||
|
logger.info("Generating TOTP setup for user: {}", user.getId());
|
||||||
|
|
||||||
|
if (!totpEnabled) {
|
||||||
|
throw new TotpException("TOTP service is disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate secret key
|
||||||
|
String secretKey = generateSecretKey();
|
||||||
|
|
||||||
|
// Generate QR code URL
|
||||||
|
String qrCodeUrl = generateQrCodeUrl(user.getEmail(), secretKey);
|
||||||
|
|
||||||
|
// Format secret key for manual entry
|
||||||
|
String manualEntryKey = formatSecretKeyForManualEntry(secretKey);
|
||||||
|
|
||||||
|
// Generate backup codes
|
||||||
|
String[] backupCodes = generateBackupCodes();
|
||||||
|
|
||||||
|
logger.debug("Generated TOTP setup for user: {}", user.getId());
|
||||||
|
return new TotpSetup(secretKey, qrCodeUrl, manualEntryKey, backupCodes);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to generate TOTP setup for user: {}", user.getId(), e);
|
||||||
|
throw new TotpException("Failed to generate TOTP setup", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates TOTP code for the current time
|
||||||
|
*
|
||||||
|
* @param secretKey Base32-encoded secret key
|
||||||
|
* @return 6-digit TOTP code
|
||||||
|
*/
|
||||||
|
public String generateTotpCode(String secretKey) {
|
||||||
|
long currentTime = System.currentTimeMillis() / 1000L;
|
||||||
|
long timeWindow = currentTime / TIME_STEP;
|
||||||
|
|
||||||
|
return generateTotpCode(secretKey, timeWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates TOTP code for a specific time window
|
||||||
|
*
|
||||||
|
* @param secretKey Base32-encoded secret key
|
||||||
|
* @param timeWindow Time window
|
||||||
|
* @return 6-digit TOTP code
|
||||||
|
*/
|
||||||
|
public String generateTotpCode(String secretKey, long timeWindow) {
|
||||||
|
try {
|
||||||
|
byte[] key = base32Decode(secretKey);
|
||||||
|
byte[] timeBytes = longToBytes(timeWindow);
|
||||||
|
|
||||||
|
Mac mac = Mac.getInstance(HMAC_SHA1);
|
||||||
|
SecretKeySpec keySpec = new SecretKeySpec(key, HMAC_SHA1);
|
||||||
|
mac.init(keySpec);
|
||||||
|
|
||||||
|
byte[] hash = mac.doFinal(timeBytes);
|
||||||
|
|
||||||
|
// Dynamic truncation
|
||||||
|
int offset = hash[hash.length - 1] & 0x0F;
|
||||||
|
int code = ((hash[offset] & 0x7F) << 24) |
|
||||||
|
((hash[offset + 1] & 0xFF) << 16) |
|
||||||
|
((hash[offset + 2] & 0xFF) << 8) |
|
||||||
|
(hash[offset + 3] & 0xFF);
|
||||||
|
|
||||||
|
code = code % (int) Math.pow(10, CODE_LENGTH);
|
||||||
|
|
||||||
|
return String.format("%0" + CODE_LENGTH + "d", code);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to generate TOTP code", e);
|
||||||
|
throw new TotpException("Failed to generate TOTP code", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates TOTP code
|
||||||
|
*
|
||||||
|
* @param secretKey Base32-encoded secret key
|
||||||
|
* @param code TOTP code to validate
|
||||||
|
* @param userId User ID (for replay protection)
|
||||||
|
* @return true if code is valid
|
||||||
|
*/
|
||||||
|
public boolean validateTotpCode(String secretKey, String code, Long userId) {
|
||||||
|
logger.debug("Validating TOTP code for user: {}", userId);
|
||||||
|
|
||||||
|
if (!totpEnabled) {
|
||||||
|
logger.warn("TOTP validation attempted but service is disabled");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secretKey == null || code == null || code.length() != CODE_LENGTH) {
|
||||||
|
logger.debug("Invalid TOTP parameters");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for replay attack
|
||||||
|
String codeKey = userId + ":" + code;
|
||||||
|
if (isCodeUsed(codeKey)) {
|
||||||
|
logger.warn("TOTP code replay attack detected for user: {}", userId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
long currentTime = System.currentTimeMillis() / 1000L;
|
||||||
|
long currentWindow = currentTime / TIME_STEP;
|
||||||
|
|
||||||
|
// Check current window and adjacent windows
|
||||||
|
for (int i = -WINDOW_SIZE; i <= WINDOW_SIZE; i++) {
|
||||||
|
long timeWindow = currentWindow + i;
|
||||||
|
String expectedCode = generateTotpCode(secretKey, timeWindow);
|
||||||
|
|
||||||
|
if (code.equals(expectedCode)) {
|
||||||
|
// Mark code as used
|
||||||
|
markCodeAsUsed(codeKey);
|
||||||
|
logger.debug("TOTP code validated successfully for user: {}", userId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("TOTP code validation failed for user: {}", userId);
|
||||||
|
return false;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Error validating TOTP code for user: {}", userId, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates backup code
|
||||||
|
*
|
||||||
|
* @param backupCodes Array of user's backup codes
|
||||||
|
* @param code Code to validate
|
||||||
|
* @return true if code is valid backup code
|
||||||
|
*/
|
||||||
|
public boolean validateBackupCode(String[] backupCodes, String code) {
|
||||||
|
logger.debug("Validating backup code");
|
||||||
|
|
||||||
|
if (!totpEnabled || backupCodes == null || code == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String backupCode : backupCodes) {
|
||||||
|
if (code.equals(backupCode)) {
|
||||||
|
logger.debug("Backup code validated successfully");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Backup code validation failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates new backup codes
|
||||||
|
*
|
||||||
|
* @return Array of backup codes
|
||||||
|
*/
|
||||||
|
public String[] generateBackupCodes() {
|
||||||
|
logger.debug("Generating {} backup codes", backupCodesCount);
|
||||||
|
|
||||||
|
String[] codes = new String[backupCodesCount];
|
||||||
|
|
||||||
|
for (int i = 0; i < backupCodesCount; i++) {
|
||||||
|
codes[i] = generateBackupCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a used backup code from the array
|
||||||
|
*
|
||||||
|
* @param backupCodes Array of backup codes
|
||||||
|
* @param usedCode Code that was used
|
||||||
|
* @return Updated array with used code removed
|
||||||
|
*/
|
||||||
|
public String[] removeUsedBackupCode(String[] backupCodes, String usedCode) {
|
||||||
|
if (backupCodes == null || usedCode == null) {
|
||||||
|
return backupCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] updatedCodes = new String[backupCodes.length];
|
||||||
|
int index = 0;
|
||||||
|
|
||||||
|
for (String code : backupCodes) {
|
||||||
|
if (!usedCode.equals(code)) {
|
||||||
|
updatedCodes[index++] = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize array to remove null entries
|
||||||
|
String[] result = new String[index];
|
||||||
|
System.arraycopy(updatedCodes, 0, result, 0, index);
|
||||||
|
|
||||||
|
logger.debug("Removed used backup code, {} codes remaining", result.length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current TOTP code for demo purposes
|
||||||
|
*
|
||||||
|
* @param secretKey Secret key
|
||||||
|
* @return Current TOTP code
|
||||||
|
*/
|
||||||
|
public String getCurrentCode(String secretKey) {
|
||||||
|
if ("development".equals(environment) || "demo".equals(environment)) {
|
||||||
|
return generateTotpCode(secretKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TotpException("Current code generation only available in development mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private String generateSecretKey() {
|
||||||
|
byte[] key = new byte[SECRET_KEY_LENGTH];
|
||||||
|
secureRandom.nextBytes(key);
|
||||||
|
return base32Encode(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateQrCodeUrl(String userEmail, String secretKey) {
|
||||||
|
String encodedIssuer = urlEncode(issuer);
|
||||||
|
String encodedEmail = urlEncode(userEmail);
|
||||||
|
String encodedSecret = urlEncode(secretKey);
|
||||||
|
|
||||||
|
return String.format(
|
||||||
|
"otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d",
|
||||||
|
encodedIssuer, encodedEmail, encodedSecret, encodedIssuer, CODE_LENGTH, TIME_STEP
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatSecretKeyForManualEntry(String secretKey) {
|
||||||
|
// Format secret key in groups of 4 for easier manual entry
|
||||||
|
StringBuilder formatted = new StringBuilder();
|
||||||
|
|
||||||
|
for (int i = 0; i < secretKey.length(); i++) {
|
||||||
|
if (i > 0 && i % 4 == 0) {
|
||||||
|
formatted.append(" ");
|
||||||
|
}
|
||||||
|
formatted.append(secretKey.charAt(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatted.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateBackupCode() {
|
||||||
|
// Generate 8-character alphanumeric backup code
|
||||||
|
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
|
StringBuilder code = new StringBuilder();
|
||||||
|
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
code.append(chars.charAt(secureRandom.nextInt(chars.length())));
|
||||||
|
}
|
||||||
|
|
||||||
|
return code.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isCodeUsed(String codeKey) {
|
||||||
|
Long timestamp = usedCodes.get(codeKey);
|
||||||
|
if (timestamp == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired entries (older than 2 time windows)
|
||||||
|
long expiry = System.currentTimeMillis() - (2 * TIME_STEP * 1000L);
|
||||||
|
if (timestamp < expiry) {
|
||||||
|
usedCodes.remove(codeKey);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markCodeAsUsed(String codeKey) {
|
||||||
|
usedCodes.put(codeKey, System.currentTimeMillis());
|
||||||
|
|
||||||
|
// Clean up old entries periodically
|
||||||
|
if (usedCodes.size() > 1000) {
|
||||||
|
cleanupUsedCodes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupUsedCodes() {
|
||||||
|
long expiry = System.currentTimeMillis() - (2 * TIME_STEP * 1000L);
|
||||||
|
usedCodes.entrySet().removeIf(entry -> entry.getValue() < expiry);
|
||||||
|
logger.debug("Cleaned up used TOTP codes, {} entries remaining", usedCodes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] longToBytes(long value) {
|
||||||
|
byte[] result = new byte[8];
|
||||||
|
for (int i = 7; i >= 0; i--) {
|
||||||
|
result[i] = (byte) (value & 0xFF);
|
||||||
|
value >>= 8;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String base32Encode(byte[] data) {
|
||||||
|
String base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
|
||||||
|
int buffer = 0;
|
||||||
|
int bufferBits = 0;
|
||||||
|
|
||||||
|
for (byte b : data) {
|
||||||
|
buffer = (buffer << 8) | (b & 0xFF);
|
||||||
|
bufferBits += 8;
|
||||||
|
|
||||||
|
while (bufferBits >= 5) {
|
||||||
|
result.append(base32Chars.charAt((buffer >> (bufferBits - 5)) & 0x1F));
|
||||||
|
bufferBits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bufferBits > 0) {
|
||||||
|
result.append(base32Chars.charAt((buffer << (5 - bufferBits)) & 0x1F));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] base32Decode(String encoded) {
|
||||||
|
String base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
encoded = encoded.toUpperCase().replaceAll("[^A-Z2-7]", "");
|
||||||
|
|
||||||
|
if (encoded.isEmpty()) {
|
||||||
|
return new byte[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
int outputLength = encoded.length() * 5 / 8;
|
||||||
|
byte[] output = new byte[outputLength];
|
||||||
|
|
||||||
|
int buffer = 0;
|
||||||
|
int bufferBits = 0;
|
||||||
|
int outputIndex = 0;
|
||||||
|
|
||||||
|
for (char c : encoded.toCharArray()) {
|
||||||
|
int value = base32Chars.indexOf(c);
|
||||||
|
if (value < 0) {
|
||||||
|
throw new IllegalArgumentException("Invalid base32 character: " + c);
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = (buffer << 5) | value;
|
||||||
|
bufferBits += 5;
|
||||||
|
|
||||||
|
if (bufferBits >= 8) {
|
||||||
|
output[outputIndex++] = (byte) ((buffer >> (bufferBits - 8)) & 0xFF);
|
||||||
|
bufferBits -= 8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String urlEncode(String value) {
|
||||||
|
try {
|
||||||
|
return java.net.URLEncoder.encode(value, "UTF-8");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets TOTP service status
|
||||||
|
*
|
||||||
|
* @return Service status information
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getServiceStatus() {
|
||||||
|
Map<String, Object> status = new java.util.HashMap<>();
|
||||||
|
status.put("enabled", totpEnabled);
|
||||||
|
status.put("issuer", issuer);
|
||||||
|
status.put("codeLength", CODE_LENGTH);
|
||||||
|
status.put("timeStep", TIME_STEP);
|
||||||
|
status.put("windowSize", WINDOW_SIZE);
|
||||||
|
status.put("backupCodesCount", backupCodesCount);
|
||||||
|
status.put("usedCodesCount", usedCodes.size());
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the used codes cache
|
||||||
|
*/
|
||||||
|
public void clearUsedCodesCache() {
|
||||||
|
usedCodes.clear();
|
||||||
|
logger.info("TOTP used codes cache cleared");
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/main/java/com/company/auth/service/TwoFactorService.java
Normal file
158
src/main/java/com/company/auth/service/TwoFactorService.java
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Two Factor Authentication Service
|
||||||
|
*
|
||||||
|
* Simplified service for managing two-factor authentication operations.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.service;
|
||||||
|
|
||||||
|
import com.company.auth.entity.User;
|
||||||
|
import com.company.auth.entity.TwoFactorVerification;
|
||||||
|
import com.company.auth.repository.TwoFactorVerificationRepository;
|
||||||
|
import com.company.auth.util.TwoFactorMethod;
|
||||||
|
import com.company.auth.exception.TwoFactorAuthException;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplified Two Factor Authentication Service Implementation
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class TwoFactorService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TwoFactorService.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TwoFactorVerificationRepository twoFactorRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if two-factor authentication is enabled for a user
|
||||||
|
*/
|
||||||
|
public boolean isTwoFactorEnabled(User user) {
|
||||||
|
Optional<TwoFactorVerification> verification = twoFactorRepository.findByUserIdAndIsActive(user.getId(), true);
|
||||||
|
return verification.isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify two-factor authentication code (simplified)
|
||||||
|
*/
|
||||||
|
public boolean verifyTwoFactorCode(User user, String code) {
|
||||||
|
logger.debug("Verifying 2FA code for user: {}", user.getId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
Optional<TwoFactorVerification> verificationOpt = twoFactorRepository.findByUserIdAndIsActive(user.getId(), true);
|
||||||
|
if (verificationOpt.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
TwoFactorVerification verification = verificationOpt.get();
|
||||||
|
|
||||||
|
// Simple code verification (you can enhance this)
|
||||||
|
boolean isValid = code.equals(verification.getVerificationCode()) &&
|
||||||
|
verification.getExpiresAt() != null &&
|
||||||
|
LocalDateTime.now().isBefore(verification.getExpiresAt());
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
verification.setVerifiedAt(LocalDateTime.now());
|
||||||
|
twoFactorRepository.save(verification);
|
||||||
|
logger.info("2FA verification successful for user: {}", user.getId());
|
||||||
|
} else {
|
||||||
|
logger.warn("2FA verification failed for user: {}", user.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to verify 2FA code for user: {}", user.getId(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random verification code
|
||||||
|
*/
|
||||||
|
private String generateVerificationCode() {
|
||||||
|
return String.valueOf((int) (Math.random() * 900000) + 100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available 2FA methods for a user
|
||||||
|
*/
|
||||||
|
public List<TwoFactorMethod> getAvailableMethods(User user) {
|
||||||
|
List<TwoFactorMethod> methods = new ArrayList<>();
|
||||||
|
// Always include SMS for simplicity
|
||||||
|
methods.add(TwoFactorMethod.SMS);
|
||||||
|
return methods;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send SMS code to user
|
||||||
|
*/
|
||||||
|
public void sendSmsCode(User user) {
|
||||||
|
logger.info("Sending SMS code to user: {}", user.getId());
|
||||||
|
|
||||||
|
String code = generateVerificationCode();
|
||||||
|
LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(5);
|
||||||
|
|
||||||
|
// Create or update verification record
|
||||||
|
Optional<TwoFactorVerification> existingOpt = twoFactorRepository.findByUserIdAndIsActive(user.getId(), true);
|
||||||
|
TwoFactorVerification verification;
|
||||||
|
|
||||||
|
if (existingOpt.isPresent()) {
|
||||||
|
verification = existingOpt.get();
|
||||||
|
} else {
|
||||||
|
verification = new TwoFactorVerification();
|
||||||
|
verification.setUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
verification.setVerificationCode(code);
|
||||||
|
verification.setExpiresAt(expiresAt);
|
||||||
|
verification.setIsActive(true);
|
||||||
|
verification.setCreatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
twoFactorRepository.save(verification);
|
||||||
|
|
||||||
|
// In production, integrate with SMS service
|
||||||
|
logger.info("SMS code generated for user: {} (code: {})", user.getId(), code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify code with method
|
||||||
|
*/
|
||||||
|
public boolean verifyCode(User user, String code, String method) {
|
||||||
|
logger.debug("Verifying code for user: {} with method: {}", user.getId(), method);
|
||||||
|
return verifyTwoFactorCode(user, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify recovery code
|
||||||
|
*/
|
||||||
|
public boolean verifyRecoveryCode(User user, String recoveryCode) {
|
||||||
|
logger.debug("Verifying recovery code for user: {}", user.getId());
|
||||||
|
// Simplified recovery code verification
|
||||||
|
// In production, you'd store and verify actual recovery codes
|
||||||
|
return recoveryCode != null && recoveryCode.length() >= 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log failed attempt
|
||||||
|
*/
|
||||||
|
public void logFailedAttempt(User user, String method, String ipAddress) {
|
||||||
|
logger.warn("Failed 2FA attempt for user: {} using method: {} from IP: {}",
|
||||||
|
user.getId(), method, ipAddress);
|
||||||
|
// In production, implement rate limiting and security logging
|
||||||
|
}
|
||||||
|
}
|
||||||
702
src/main/java/com/company/auth/service/UserService.java
Normal file
702
src/main/java/com/company/auth/service/UserService.java
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
/**
|
||||||
|
* User Service
|
||||||
|
*
|
||||||
|
* Service for user management operations including profile management,
|
||||||
|
* password changes, account settings, and user administration.
|
||||||
|
*
|
||||||
|
* @author David Valera Melendez <david@valera-melendez.de>
|
||||||
|
* @since February 2025
|
||||||
|
*/
|
||||||
|
package com.company.auth.service;
|
||||||
|
|
||||||
|
import com.company.auth.dto.request.UpdateProfileRequest;
|
||||||
|
import com.company.auth.dto.request.ChangePasswordRequest;
|
||||||
|
import com.company.auth.dto.request.DeleteAccountRequest;
|
||||||
|
import com.company.auth.dto.response.UserResponse;
|
||||||
|
import com.company.auth.dto.response.UserProfileResponse;
|
||||||
|
import com.company.auth.dto.response.DeviceResponse;
|
||||||
|
import com.company.auth.dto.response.SecurityStatusResponse;
|
||||||
|
import com.company.auth.dto.response.ApiResponse;
|
||||||
|
import com.company.auth.entity.User;
|
||||||
|
import com.company.auth.entity.TrustedDevice;
|
||||||
|
import com.company.auth.entity.TwoFactorVerification;
|
||||||
|
import com.company.auth.repository.UserRepository;
|
||||||
|
import com.company.auth.repository.TrustedDeviceRepository;
|
||||||
|
import com.company.auth.repository.TwoFactorVerificationRepository;
|
||||||
|
import com.company.auth.exception.UserNotFoundException;
|
||||||
|
import com.company.auth.exception.*;
|
||||||
|
import com.company.auth.util.TwoFactorMethod;
|
||||||
|
import com.company.auth.util.UserStatus;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Service Implementation
|
||||||
|
*
|
||||||
|
* Provides user management services including:
|
||||||
|
* - User profile management
|
||||||
|
* - Password and security settings
|
||||||
|
* - Account preferences and settings
|
||||||
|
* - User administration functions
|
||||||
|
* - Device management
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class UserService implements UserDetailsService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(UserService.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TrustedDeviceRepository trustedDeviceRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TwoFactorVerificationRepository twoFactorRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SecurityService securityService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EmailService emailService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
|
User user = userRepository.findByEmail(username)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DeviceFingerprintService deviceFingerprintService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TwoFactorService twoFactorService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets user profile information
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @return UserResponse with profile data
|
||||||
|
* @throws UserNotFoundException if user not found
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public UserResponse getUserProfile(Long userId) {
|
||||||
|
logger.debug("Getting profile for user: {}", userId);
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
UserResponse response = UserResponse.fromEntity(user);
|
||||||
|
|
||||||
|
// Add device count
|
||||||
|
int deviceCount = (int) trustedDeviceRepository.countByUserIdAndIsActive(userId, true);
|
||||||
|
response.setTrustedDevicesCount(deviceCount);
|
||||||
|
|
||||||
|
// Calculate security score
|
||||||
|
int securityScore = calculateSecurityScore(user);
|
||||||
|
response.setSecurityScore(securityScore);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates user profile information
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param request Profile update request
|
||||||
|
* @return Updated UserResponse
|
||||||
|
* @throws UserNotFoundException if user not found
|
||||||
|
* @throws InvalidUpdateDataException if update data is invalid
|
||||||
|
*/
|
||||||
|
public UserResponse updateProfile(Long userId, ProfileUpdateRequest request) {
|
||||||
|
logger.info("Updating profile for user: {}", userId);
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate update request
|
||||||
|
validateProfileUpdate(request, user);
|
||||||
|
|
||||||
|
// Update basic profile information
|
||||||
|
if (request.getName() != null) {
|
||||||
|
user.setName(request.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getPhoneNumber() != null) {
|
||||||
|
// If phone number changed, mark as unverified
|
||||||
|
if (!request.getPhoneNumber().equals(user.getPhoneNumber())) {
|
||||||
|
user.setPhoneNumber(request.getPhoneNumber());
|
||||||
|
user.setPhoneVerified(false);
|
||||||
|
|
||||||
|
// Send verification SMS if 2FA is enabled
|
||||||
|
if (user.getTwoFactorEnabled() && "SMS".equals(user.getTwoFactorMethod())) {
|
||||||
|
// User will need to re-verify phone for 2FA
|
||||||
|
logger.info("Phone number changed for user with SMS 2FA: {}", userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getLanguage() != null) {
|
||||||
|
user.setLanguage(request.getLanguage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getTimezone() != null) {
|
||||||
|
user.setTimezone(request.getTimezone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getMarketingOptIn() != null) {
|
||||||
|
user.setMarketingOptIn(request.getMarketingOptIn());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getAvatarUrl() != null) {
|
||||||
|
user.setAvatarUrl(request.getAvatarUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setUpdatedAt(LocalDateTime.now());
|
||||||
|
user = userRepository.save(user);
|
||||||
|
|
||||||
|
logger.info("Profile updated successfully for user: {}", userId);
|
||||||
|
return UserResponse.fromEntity(user);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to update profile for user: {}", userId, e);
|
||||||
|
throw new ProfileUpdateException("Failed to update profile", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes user password
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param request Password change request
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
* @throws UserNotFoundException if user not found
|
||||||
|
* @throws InvalidCredentialsException if current password is wrong
|
||||||
|
* @throws WeakPasswordException if new password is weak
|
||||||
|
*/
|
||||||
|
public void changePassword(Long userId, PasswordChangeRequest request, String ipAddress) {
|
||||||
|
logger.info("Changing password for user: {}", userId);
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify current password
|
||||||
|
if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPasswordHash())) {
|
||||||
|
throw new InvalidCredentialsException("Current password is incorrect");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate new password
|
||||||
|
securityService.validatePasswordStrength(request.getNewPassword());
|
||||||
|
|
||||||
|
// Check password history (prevent reuse of recent passwords)
|
||||||
|
if (securityService.isPasswordRecentlyUsed(user, request.getNewPassword())) {
|
||||||
|
throw new WeakPasswordException("Password was recently used. Please choose a different password.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(request.getNewPassword()));
|
||||||
|
user.setPasswordChangedAt(LocalDateTime.now());
|
||||||
|
user.setUpdatedAt(LocalDateTime.now());
|
||||||
|
|
||||||
|
// Reset failed login attempts
|
||||||
|
user.setFailedLoginAttempts(0);
|
||||||
|
user.setLockedUntil(null);
|
||||||
|
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
// Send security notification
|
||||||
|
emailService.sendPasswordChangeNotification(user, ipAddress);
|
||||||
|
|
||||||
|
// Log security event
|
||||||
|
securityService.logSecurityEvent(user, "PASSWORD_CHANGED",
|
||||||
|
"Password changed from IP: " + ipAddress);
|
||||||
|
|
||||||
|
logger.info("Password changed successfully for user: {}", userId);
|
||||||
|
|
||||||
|
} catch (SecurityException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to change password for user: {}", userId, e);
|
||||||
|
throw new PasswordChangeException("Failed to change password", e);
|
||||||
|
} finally {
|
||||||
|
// Clear sensitive data
|
||||||
|
request.clearSensitiveData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets user's trusted devices
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param includeInactive Whether to include inactive devices
|
||||||
|
* @return List of DeviceResponse
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<DeviceResponse> getUserDevices(Long userId, boolean includeInactive) {
|
||||||
|
logger.debug("Getting devices for user: {}", userId);
|
||||||
|
|
||||||
|
List<TrustedDevice> devices;
|
||||||
|
if (includeInactive) {
|
||||||
|
devices = trustedDeviceRepository.findByUserIdOrderByLastSeenAtDesc(userId);
|
||||||
|
} else {
|
||||||
|
devices = trustedDeviceRepository.findByUserIdAndIsActiveOrderByLastSeenAtDesc(userId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices.stream()
|
||||||
|
.map(DeviceResponse::fromEntity)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes trust for a specific device
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param deviceId Device ID
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
* @throws UserNotFoundException if user not found
|
||||||
|
* @throws DeviceNotFoundException if device not found
|
||||||
|
* @throws UnauthorizedDeviceAccessException if device doesn't belong to user
|
||||||
|
*/
|
||||||
|
public void revokeDeviceTrust(Long userId, Long deviceId, String ipAddress) {
|
||||||
|
logger.info("Revoking device trust for user: {} device: {}", userId, deviceId);
|
||||||
|
|
||||||
|
// Verify user exists
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
// Find device and verify ownership
|
||||||
|
TrustedDevice device = trustedDeviceRepository.findByIdAndUserId(deviceId, userId)
|
||||||
|
.orElseThrow(() -> new DeviceNotFoundException("Device not found or not owned by user"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Revoke device trust
|
||||||
|
deviceFingerprintService.revokeDeviceTrust(userId, deviceId, ipAddress);
|
||||||
|
|
||||||
|
// Send security notification
|
||||||
|
emailService.sendDeviceRevokedNotification(user, device, ipAddress);
|
||||||
|
|
||||||
|
// Log security event
|
||||||
|
securityService.logSecurityEvent(user, "DEVICE_TRUST_REVOKED",
|
||||||
|
"Device trust revoked for: " + device.getDeviceName() + " from IP: " + ipAddress);
|
||||||
|
|
||||||
|
logger.info("Device trust revoked successfully for user: {} device: {}", userId, deviceId);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to revoke device trust for user: {} device: {}", userId, deviceId, e);
|
||||||
|
throw new DeviceManagementException("Failed to revoke device trust", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a device completely
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param deviceId Device ID
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
*/
|
||||||
|
public void removeDevice(Long userId, Long deviceId, String ipAddress) {
|
||||||
|
logger.info("Removing device for user: {} device: {}", userId, deviceId);
|
||||||
|
|
||||||
|
// Verify user exists
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
// Find device and verify ownership
|
||||||
|
TrustedDevice device = trustedDeviceRepository.findByIdAndUserId(deviceId, userId)
|
||||||
|
.orElseThrow(() -> new DeviceNotFoundException("Device not found or not owned by user"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mark device as inactive instead of deleting (for audit trail)
|
||||||
|
device.setIsActive(false);
|
||||||
|
device.setRemovedAt(LocalDateTime.now());
|
||||||
|
device.setUpdatedAt(LocalDateTime.now());
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
|
||||||
|
// Send security notification
|
||||||
|
emailService.sendDeviceRemovedNotification(user, device, ipAddress);
|
||||||
|
|
||||||
|
// Log security event
|
||||||
|
securityService.logSecurityEvent(user, "DEVICE_REMOVED",
|
||||||
|
"Device removed: " + device.getDeviceName() + " from IP: " + ipAddress);
|
||||||
|
|
||||||
|
logger.info("Device removed successfully for user: {} device: {}", userId, deviceId);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to remove device for user: {} device: {}", userId, deviceId, e);
|
||||||
|
throw new DeviceManagementException("Failed to remove device", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets user's account security status
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @return SecurityStatusResponse with security information
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public SecurityStatusResponse getSecurityStatus(Long userId) {
|
||||||
|
logger.debug("Getting security status for user: {}", userId);
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
SecurityStatusResponse status = new SecurityStatusResponse();
|
||||||
|
status.setUserId(userId.toString());
|
||||||
|
status.setEmailVerified(user.getEmailVerified());
|
||||||
|
status.setPhoneVerified(user.getPhoneVerified());
|
||||||
|
status.setTwoFactorEnabled(user.getTwoFactorEnabled());
|
||||||
|
status.setTwoFactorMethod(user.getTwoFactorMethod());
|
||||||
|
status.setPasswordChangedAt(user.getPasswordChangedAt());
|
||||||
|
status.setLastLoginAt(user.getLastLoginAt());
|
||||||
|
status.setFailedLoginAttempts(user.getFailedLoginAttempts());
|
||||||
|
status.setAccountLocked(user.getLockedUntil() != null && LocalDateTime.now().isBefore(user.getLockedUntil()));
|
||||||
|
|
||||||
|
// Calculate security score
|
||||||
|
int securityScore = calculateSecurityScore(user);
|
||||||
|
status.setSecurityScore(securityScore);
|
||||||
|
|
||||||
|
// Get trusted devices count
|
||||||
|
int trustedDevicesCount = (int) trustedDeviceRepository.countByUserIdAndIsTrusted(userId, true);
|
||||||
|
status.setTrustedDevicesCount(trustedDevicesCount);
|
||||||
|
|
||||||
|
// Get security alerts count
|
||||||
|
int alertsCount = securityService.getPendingAlertsCount(user);
|
||||||
|
status.setSecurityAlertsCount(alertsCount);
|
||||||
|
|
||||||
|
// Generate security recommendations
|
||||||
|
List<String> recommendations = generateSecurityRecommendations(user);
|
||||||
|
status.setSecurityRecommendations(recommendations);
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes user account
|
||||||
|
*
|
||||||
|
* @param userId User ID
|
||||||
|
* @param request Account deletion request with confirmation
|
||||||
|
* @param ipAddress Client IP address
|
||||||
|
* @throws UserNotFoundException if user not found
|
||||||
|
* @throws InvalidCredentialsException if password confirmation fails
|
||||||
|
*/
|
||||||
|
public void deleteAccount(Long userId, AccountDeletionRequest request, String ipAddress) {
|
||||||
|
logger.warn("Account deletion requested for user: {}", userId);
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new UserNotFoundException("User not found"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify password for account deletion
|
||||||
|
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||||
|
throw new InvalidCredentialsException("Password confirmation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check confirmation
|
||||||
|
if (!Boolean.TRUE.equals(request.getConfirmDeletion())) {
|
||||||
|
throw new AccountDeletionException("Account deletion not confirmed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform account deletion
|
||||||
|
deleteUserData(user);
|
||||||
|
|
||||||
|
// Send account deletion confirmation
|
||||||
|
emailService.sendAccountDeletionConfirmation(user);
|
||||||
|
|
||||||
|
// Log security event
|
||||||
|
securityService.logSecurityEvent(user, "ACCOUNT_DELETED",
|
||||||
|
"Account deleted from IP: " + ipAddress);
|
||||||
|
|
||||||
|
logger.warn("Account deleted for user: {}", userId);
|
||||||
|
|
||||||
|
} catch (SecurityException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("Failed to delete account for user: {}", userId, e);
|
||||||
|
throw new AccountDeletionException("Failed to delete account", e);
|
||||||
|
} finally {
|
||||||
|
request.clearSensitiveData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches users (admin function)
|
||||||
|
*
|
||||||
|
* @param query Search query
|
||||||
|
* @param pageable Pagination parameters
|
||||||
|
* @return Page of UserResponse
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<UserResponse> searchUsers(String query, Pageable pageable) {
|
||||||
|
logger.debug("Searching users with query: {}", query);
|
||||||
|
|
||||||
|
Page<User> users = userRepository.searchUsers(query, pageable);
|
||||||
|
return users.map(UserResponse::fromEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private void validateProfileUpdate(ProfileUpdateRequest request, User user) {
|
||||||
|
if (request.getPhoneNumber() != null && !securityService.isValidPhoneNumber(request.getPhoneNumber())) {
|
||||||
|
throw new InvalidUpdateDataException("Invalid phone number format");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getName() != null && (request.getName().trim().length() < 2 || request.getName().length() > 100)) {
|
||||||
|
throw new InvalidUpdateDataException("Name must be between 2 and 100 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getLanguage() != null && !securityService.isValidLanguageCode(request.getLanguage())) {
|
||||||
|
throw new InvalidUpdateDataException("Invalid language code");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getTimezone() != null && !securityService.isValidTimezone(request.getTimezone())) {
|
||||||
|
throw new InvalidUpdateDataException("Invalid timezone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int calculateSecurityScore(User user) {
|
||||||
|
int score = 0;
|
||||||
|
|
||||||
|
// Email verification (20 points)
|
||||||
|
if (Boolean.TRUE.equals(user.getEmailVerified())) {
|
||||||
|
score += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone verification (15 points)
|
||||||
|
if (Boolean.TRUE.equals(user.getPhoneVerified())) {
|
||||||
|
score += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-factor authentication (30 points)
|
||||||
|
if (Boolean.TRUE.equals(user.getTwoFactorEnabled())) {
|
||||||
|
score += 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent password change (10 points)
|
||||||
|
if (user.getPasswordChangedAt() != null &&
|
||||||
|
user.getPasswordChangedAt().isAfter(LocalDateTime.now().minusDays(90))) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No recent failed attempts (10 points)
|
||||||
|
if (user.getFailedLoginAttempts() == 0) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account age bonus (15 points if > 30 days)
|
||||||
|
if (user.getCreatedAt().isBefore(LocalDateTime.now().minusDays(30))) {
|
||||||
|
score += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(score, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> generateSecurityRecommendations(User user) {
|
||||||
|
List<String> recommendations = new ArrayList<>();
|
||||||
|
|
||||||
|
if (!Boolean.TRUE.equals(user.getEmailVerified())) {
|
||||||
|
recommendations.add("Verify your email address to improve account security");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Boolean.TRUE.equals(user.getPhoneVerified())) {
|
||||||
|
recommendations.add("Add and verify a phone number for account recovery");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Boolean.TRUE.equals(user.getTwoFactorEnabled())) {
|
||||||
|
recommendations.add("Enable two-factor authentication for enhanced security");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.getPasswordChangedAt() == null ||
|
||||||
|
user.getPasswordChangedAt().isBefore(LocalDateTime.now().minusDays(180))) {
|
||||||
|
recommendations.add("Consider changing your password regularly");
|
||||||
|
}
|
||||||
|
|
||||||
|
int trustedDevicesCount = (int) trustedDeviceRepository.countByUserIdAndIsTrusted(user.getId(), true);
|
||||||
|
if (trustedDevicesCount > 5) {
|
||||||
|
recommendations.add("Review your trusted devices and remove any you no longer use");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.getFailedLoginAttempts() > 0) {
|
||||||
|
recommendations.add("Review recent login attempts and secure your account if needed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return recommendations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteUserData(User user) {
|
||||||
|
// Mark user as deleted instead of hard delete (for audit purposes)
|
||||||
|
user.setStatus(UserStatus.DELETED.name());
|
||||||
|
user.setEmail("deleted_" + user.getId() + "@deleted.local");
|
||||||
|
user.setName("Deleted User");
|
||||||
|
user.setPhoneNumber(null);
|
||||||
|
user.setPasswordHash(null);
|
||||||
|
user.setUpdatedAt(LocalDateTime.now());
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
// Deactivate all trusted devices
|
||||||
|
List<TrustedDevice> devices = trustedDeviceRepository.findByUserIdAndIsActive(user.getId(), true);
|
||||||
|
for (TrustedDevice device : devices) {
|
||||||
|
device.setIsActive(false);
|
||||||
|
device.setRemovedAt(LocalDateTime.now());
|
||||||
|
trustedDeviceRepository.save(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate 2FA verifications
|
||||||
|
Optional<TwoFactorVerification> twoFactorVerificationOpt = twoFactorRepository.findByUserIdAndIsActive(user.getId(), true);
|
||||||
|
if (twoFactorVerificationOpt.isPresent()) {
|
||||||
|
TwoFactorVerification verification = twoFactorVerificationOpt.get();
|
||||||
|
verification.setIsActive(false);
|
||||||
|
verification.setDisabledAt(LocalDateTime.now());
|
||||||
|
twoFactorRepository.save(verification);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request DTOs for profile management
|
||||||
|
|
||||||
|
public static class ProfileUpdateRequest {
|
||||||
|
private String name;
|
||||||
|
private String phoneNumber;
|
||||||
|
private String language;
|
||||||
|
private String timezone;
|
||||||
|
private Boolean marketingOptIn;
|
||||||
|
private String avatarUrl;
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getPhoneNumber() { return phoneNumber; }
|
||||||
|
public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; }
|
||||||
|
|
||||||
|
public String getLanguage() { return language; }
|
||||||
|
public void setLanguage(String language) { this.language = language; }
|
||||||
|
|
||||||
|
public String getTimezone() { return timezone; }
|
||||||
|
public void setTimezone(String timezone) { this.timezone = timezone; }
|
||||||
|
|
||||||
|
public Boolean getMarketingOptIn() { return marketingOptIn; }
|
||||||
|
public void setMarketingOptIn(Boolean marketingOptIn) { this.marketingOptIn = marketingOptIn; }
|
||||||
|
|
||||||
|
public String getAvatarUrl() { return avatarUrl; }
|
||||||
|
public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PasswordChangeRequest {
|
||||||
|
private String currentPassword;
|
||||||
|
private String newPassword;
|
||||||
|
private String confirmPassword;
|
||||||
|
|
||||||
|
public void clearSensitiveData() {
|
||||||
|
this.currentPassword = null;
|
||||||
|
this.newPassword = null;
|
||||||
|
this.confirmPassword = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
public String getCurrentPassword() { return currentPassword; }
|
||||||
|
public void setCurrentPassword(String currentPassword) { this.currentPassword = currentPassword; }
|
||||||
|
|
||||||
|
public String getNewPassword() { return newPassword; }
|
||||||
|
public void setNewPassword(String newPassword) { this.newPassword = newPassword; }
|
||||||
|
|
||||||
|
public String getConfirmPassword() { return confirmPassword; }
|
||||||
|
public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AccountDeletionRequest {
|
||||||
|
private String password;
|
||||||
|
private Boolean confirmDeletion;
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
public void clearSensitiveData() {
|
||||||
|
this.password = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
public String getPassword() { return password; }
|
||||||
|
public void setPassword(String password) { this.password = password; }
|
||||||
|
|
||||||
|
public Boolean getConfirmDeletion() { return confirmDeletion; }
|
||||||
|
public void setConfirmDeletion(Boolean confirmDeletion) { this.confirmDeletion = confirmDeletion; }
|
||||||
|
|
||||||
|
public String getReason() { return reason; }
|
||||||
|
public void setReason(String reason) { this.reason = reason; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SecurityStatusResponse {
|
||||||
|
private String userId;
|
||||||
|
private Boolean emailVerified;
|
||||||
|
private Boolean phoneVerified;
|
||||||
|
private Boolean twoFactorEnabled;
|
||||||
|
private String twoFactorMethod;
|
||||||
|
private LocalDateTime passwordChangedAt;
|
||||||
|
private LocalDateTime lastLoginAt;
|
||||||
|
private Integer failedLoginAttempts;
|
||||||
|
private Boolean accountLocked;
|
||||||
|
private Integer securityScore;
|
||||||
|
private Integer trustedDevicesCount;
|
||||||
|
private Integer securityAlertsCount;
|
||||||
|
private List<String> securityRecommendations;
|
||||||
|
|
||||||
|
// Getters and setters
|
||||||
|
public String getUserId() { return userId; }
|
||||||
|
public void setUserId(String userId) { this.userId = userId; }
|
||||||
|
|
||||||
|
public Boolean getEmailVerified() { return emailVerified; }
|
||||||
|
public void setEmailVerified(Boolean emailVerified) { this.emailVerified = emailVerified; }
|
||||||
|
|
||||||
|
public Boolean getPhoneVerified() { return phoneVerified; }
|
||||||
|
public void setPhoneVerified(Boolean phoneVerified) { this.phoneVerified = phoneVerified; }
|
||||||
|
|
||||||
|
public Boolean getTwoFactorEnabled() { return twoFactorEnabled; }
|
||||||
|
public void setTwoFactorEnabled(Boolean twoFactorEnabled) { this.twoFactorEnabled = twoFactorEnabled; }
|
||||||
|
|
||||||
|
public String getTwoFactorMethod() { return twoFactorMethod; }
|
||||||
|
public void setTwoFactorMethod(String twoFactorMethod) { this.twoFactorMethod = twoFactorMethod; }
|
||||||
|
|
||||||
|
public LocalDateTime getPasswordChangedAt() { return passwordChangedAt; }
|
||||||
|
public void setPasswordChangedAt(LocalDateTime passwordChangedAt) { this.passwordChangedAt = passwordChangedAt; }
|
||||||
|
|
||||||
|
public LocalDateTime getLastLoginAt() { return lastLoginAt; }
|
||||||
|
public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; }
|
||||||
|
|
||||||
|
public Integer getFailedLoginAttempts() { return failedLoginAttempts; }
|
||||||
|
public void setFailedLoginAttempts(Integer failedLoginAttempts) { this.failedLoginAttempts = failedLoginAttempts; }
|
||||||
|
|
||||||
|
public Boolean getAccountLocked() { return accountLocked; }
|
||||||
|
public void setAccountLocked(Boolean accountLocked) { this.accountLocked = accountLocked; }
|
||||||
|
|
||||||
|
public Integer getSecurityScore() { return securityScore; }
|
||||||
|
public void setSecurityScore(Integer securityScore) { this.securityScore = securityScore; }
|
||||||
|
|
||||||
|
public Integer getTrustedDevicesCount() { return trustedDevicesCount; }
|
||||||
|
public void setTrustedDevicesCount(Integer trustedDevicesCount) { this.trustedDevicesCount = trustedDevicesCount; }
|
||||||
|
|
||||||
|
public Integer getSecurityAlertsCount() { return securityAlertsCount; }
|
||||||
|
public void setSecurityAlertsCount(Integer securityAlertsCount) { this.securityAlertsCount = securityAlertsCount; }
|
||||||
|
|
||||||
|
public List<String> getSecurityRecommendations() { return securityRecommendations; }
|
||||||
|
public void setSecurityRecommendations(List<String> securityRecommendations) { this.securityRecommendations = securityRecommendations; }
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/main/java/com/company/auth/util/RandomCodeGenerator.java
Normal file
47
src/main/java/com/company/auth/util/RandomCodeGenerator.java
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package com.company.auth.util;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class for generating random codes and tokens
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class RandomCodeGenerator {
|
||||||
|
|
||||||
|
private static final String NUMERIC_CHARS = "0123456789";
|
||||||
|
private static final String ALPHANUMERIC_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||||
|
private static final SecureRandom random = new SecureRandom();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a numeric verification code
|
||||||
|
*/
|
||||||
|
public String generateNumericCode(int length) {
|
||||||
|
return generateCode(NUMERIC_CHARS, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an alphanumeric code
|
||||||
|
*/
|
||||||
|
public String generateAlphanumericCode(int length) {
|
||||||
|
return generateCode(ALPHANUMERIC_CHARS, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random code from the given character set
|
||||||
|
*/
|
||||||
|
private String generateCode(String chars, int length) {
|
||||||
|
StringBuilder code = new StringBuilder(length);
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
code.append(chars.charAt(random.nextInt(chars.length())));
|
||||||
|
}
|
||||||
|
return code.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a backup recovery code (format: XXXX-XXXX)
|
||||||
|
*/
|
||||||
|
public String generateBackupCode() {
|
||||||
|
return generateAlphanumericCode(4) + "-" + generateAlphanumericCode(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/main/java/com/company/auth/util/RiskLevel.java
Normal file
59
src/main/java/com/company/auth/util/RiskLevel.java
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package com.company.auth.util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration for risk levels in device fingerprinting
|
||||||
|
*/
|
||||||
|
public enum RiskLevel {
|
||||||
|
LOW(0.0, 0.3, "Low risk - trusted device"),
|
||||||
|
MEDIUM(0.3, 0.7, "Medium risk - verification recommended"),
|
||||||
|
HIGH(0.7, 1.0, "High risk - strong verification required");
|
||||||
|
|
||||||
|
private final double minScore;
|
||||||
|
private final double maxScore;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
RiskLevel(double minScore, double maxScore, String description) {
|
||||||
|
this.minScore = minScore;
|
||||||
|
this.maxScore = maxScore;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getMinScore() {
|
||||||
|
return minScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getMaxScore() {
|
||||||
|
return maxScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get risk level based on risk score
|
||||||
|
*/
|
||||||
|
public static RiskLevel fromScore(double score) {
|
||||||
|
if (score <= 0.3) {
|
||||||
|
return LOW;
|
||||||
|
} else if (score <= 0.7) {
|
||||||
|
return MEDIUM;
|
||||||
|
} else {
|
||||||
|
return HIGH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this risk level requires two-factor authentication
|
||||||
|
*/
|
||||||
|
public boolean requiresTwoFactor() {
|
||||||
|
return this == MEDIUM || this == HIGH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this risk level blocks the action
|
||||||
|
*/
|
||||||
|
public boolean isBlocking() {
|
||||||
|
return this == HIGH;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/main/java/com/company/auth/util/SecurityUtils.java
Normal file
27
src/main/java/com/company/auth/util/SecurityUtils.java
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package com.company.auth.util;
|
||||||
|
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
|
||||||
|
public class SecurityUtils {
|
||||||
|
|
||||||
|
public static String getCurrentUserEmail() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication == null || !authentication.isAuthenticated()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object principal = authentication.getPrincipal();
|
||||||
|
if (principal instanceof UserDetails) {
|
||||||
|
return ((UserDetails) principal).getUsername();
|
||||||
|
}
|
||||||
|
|
||||||
|
return principal.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isAuthenticated() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
return authentication != null && authentication.isAuthenticated();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main/java/com/company/auth/util/TrustStatus.java
Normal file
10
src/main/java/com/company/auth/util/TrustStatus.java
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package com.company.auth.util;
|
||||||
|
|
||||||
|
public enum TrustStatus {
|
||||||
|
PENDING,
|
||||||
|
TRUSTED,
|
||||||
|
REVOKED,
|
||||||
|
EXPIRED,
|
||||||
|
SUSPICIOUS,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
35
src/main/java/com/company/auth/util/TwoFactorMethod.java
Normal file
35
src/main/java/com/company/auth/util/TwoFactorMethod.java
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package com.company.auth.util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration for two-factor authentication methods
|
||||||
|
*/
|
||||||
|
public enum TwoFactorMethod {
|
||||||
|
TOTP("Time-based One-Time Password"),
|
||||||
|
SMS("SMS Text Message"),
|
||||||
|
EMAIL("Email Verification"),
|
||||||
|
BACKUP_CODES("Backup Recovery Codes");
|
||||||
|
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
TwoFactorMethod(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this method requires a device
|
||||||
|
*/
|
||||||
|
public boolean requiresDevice() {
|
||||||
|
return this == TOTP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this method sends a code
|
||||||
|
*/
|
||||||
|
public boolean sendsCode() {
|
||||||
|
return this == SMS || this == EMAIL;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/main/java/com/company/auth/util/UserRole.java
Normal file
8
src/main/java/com/company/auth/util/UserRole.java
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package com.company.auth.util;
|
||||||
|
|
||||||
|
public enum UserRole {
|
||||||
|
USER,
|
||||||
|
ADMIN,
|
||||||
|
MODERATOR,
|
||||||
|
GUEST
|
||||||
|
}
|
||||||
11
src/main/java/com/company/auth/util/UserStatus.java
Normal file
11
src/main/java/com/company/auth/util/UserStatus.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.company.auth.util;
|
||||||
|
|
||||||
|
public enum UserStatus {
|
||||||
|
PENDING,
|
||||||
|
ACTIVE,
|
||||||
|
SUSPENDED,
|
||||||
|
DEACTIVATED,
|
||||||
|
DELETED,
|
||||||
|
LOCKED,
|
||||||
|
DISABLED
|
||||||
|
}
|
||||||
67
src/main/resources/application-demo.yml
Normal file
67
src/main/resources/application-demo.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Demo Environment Configuration
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://localhost:3306/device_fingerprint_auth?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||||
|
username: ${DB_USERNAME:auth_user}
|
||||||
|
password: ${DB_PASSWORD:auth_password123}
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
show-sql: false
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
# Container environment - always clean start
|
||||||
|
clean-disabled: false
|
||||||
|
baseline-on-migrate: true
|
||||||
|
validate-on-migrate: true
|
||||||
|
|
||||||
|
# Demo-specific settings for senior developer presentations
|
||||||
|
device-fingerprint:
|
||||||
|
demo-mode:
|
||||||
|
enabled: true
|
||||||
|
include-verification-code: true
|
||||||
|
include-user-details: true
|
||||||
|
include-environment-info: true
|
||||||
|
|
||||||
|
security:
|
||||||
|
enable-rate-limiting: true
|
||||||
|
enable-audit-logging: true
|
||||||
|
# More lenient settings for demo
|
||||||
|
max-devices-per-user: 20
|
||||||
|
device-expiry-days: 180
|
||||||
|
|
||||||
|
# Moderate rate limiting for demo
|
||||||
|
rate-limiting:
|
||||||
|
login-attempts:
|
||||||
|
capacity: 20
|
||||||
|
refill-period: 300 # 5 minutes
|
||||||
|
registration-attempts:
|
||||||
|
capacity: 10
|
||||||
|
refill-period: 600 # 10 minutes
|
||||||
|
two-factor-attempts:
|
||||||
|
capacity: 30
|
||||||
|
refill-period: 300 # 5 minutes
|
||||||
|
|
||||||
|
# Demo logging - clean and informative
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.company.auth: INFO
|
||||||
|
org.springframework.security: WARN
|
||||||
|
org.springframework.web: WARN
|
||||||
|
org.hibernate.SQL: WARN
|
||||||
|
|
||||||
|
# CORS - Specific origins for demo
|
||||||
|
cors:
|
||||||
|
allowed-origins: "http://localhost:3000,http://localhost:4200,https://demo.company.com"
|
||||||
|
allowed-methods: "GET,POST,PUT,DELETE,OPTIONS"
|
||||||
|
allowed-headers: "*"
|
||||||
|
allow-credentials: true
|
||||||
|
|
||||||
|
# Management endpoints - selective exposure for demo
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: "health,info,metrics"
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: when_authorized
|
||||||
63
src/main/resources/application-dev.yml
Normal file
63
src/main/resources/application-dev.yml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Development Environment Configuration
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://localhost:3306/device_auth_dev_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||||
|
username: ${DB_USERNAME:root}
|
||||||
|
password: ${DB_PASSWORD:password}
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
show-sql: true
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true
|
||||||
|
use_sql_comments: true
|
||||||
|
|
||||||
|
# Development-specific settings
|
||||||
|
device-fingerprint:
|
||||||
|
demo-mode:
|
||||||
|
enabled: true
|
||||||
|
include-verification-code: true
|
||||||
|
include-user-details: true
|
||||||
|
include-environment-info: true
|
||||||
|
|
||||||
|
security:
|
||||||
|
enable-rate-limiting: false
|
||||||
|
enable-audit-logging: true
|
||||||
|
|
||||||
|
# Relaxed rate limiting for development
|
||||||
|
rate-limiting:
|
||||||
|
login-attempts:
|
||||||
|
capacity: 100
|
||||||
|
refill-period: 60
|
||||||
|
registration-attempts:
|
||||||
|
capacity: 50
|
||||||
|
refill-period: 60
|
||||||
|
two-factor-attempts:
|
||||||
|
capacity: 100
|
||||||
|
refill-period: 60
|
||||||
|
|
||||||
|
# Development logging
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.company.auth: DEBUG
|
||||||
|
org.springframework.security: DEBUG
|
||||||
|
org.springframework.web: DEBUG
|
||||||
|
org.hibernate.SQL: DEBUG
|
||||||
|
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
||||||
|
|
||||||
|
# CORS - Allow all origins in development
|
||||||
|
cors:
|
||||||
|
allowed-origins: "*"
|
||||||
|
allowed-methods: "GET,POST,PUT,DELETE,OPTIONS,PATCH"
|
||||||
|
allowed-headers: "*"
|
||||||
|
allow-credentials: true
|
||||||
|
|
||||||
|
# Management endpoints - expose all in development
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: "*"
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: always
|
||||||
95
src/main/resources/application-prod.yml
Normal file
95
src/main/resources/application-prod.yml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Production Environment Configuration
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:device_auth_prod_db}?useSSL=true&requireSSL=true&serverTimezone=UTC
|
||||||
|
username: ${DB_USERNAME}
|
||||||
|
password: ${DB_PASSWORD}
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: 50
|
||||||
|
minimum-idle: 10
|
||||||
|
idle-timeout: 600000
|
||||||
|
max-lifetime: 1800000
|
||||||
|
connection-timeout: 30000
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
show-sql: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: false
|
||||||
|
use_sql_comments: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: ${SERVER_PORT:8080}
|
||||||
|
ssl:
|
||||||
|
enabled: ${SSL_ENABLED:false}
|
||||||
|
key-store: ${SSL_KEYSTORE_PATH:}
|
||||||
|
key-store-password: ${SSL_KEYSTORE_PASSWORD:}
|
||||||
|
key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12}
|
||||||
|
|
||||||
|
# Production security settings
|
||||||
|
device-fingerprint:
|
||||||
|
demo-mode:
|
||||||
|
enabled: false
|
||||||
|
include-verification-code: false
|
||||||
|
include-user-details: false
|
||||||
|
include-environment-info: false
|
||||||
|
|
||||||
|
security:
|
||||||
|
enable-rate-limiting: true
|
||||||
|
enable-audit-logging: true
|
||||||
|
max-devices-per-user: 10
|
||||||
|
device-expiry-days: 90
|
||||||
|
fingerprint-similarity-threshold: 0.85
|
||||||
|
risk-score-threshold: 0.3
|
||||||
|
|
||||||
|
# Strict rate limiting for production
|
||||||
|
rate-limiting:
|
||||||
|
login-attempts:
|
||||||
|
capacity: 5
|
||||||
|
refill-period: 900 # 15 minutes
|
||||||
|
registration-attempts:
|
||||||
|
capacity: 3
|
||||||
|
refill-period: 3600 # 1 hour
|
||||||
|
two-factor-attempts:
|
||||||
|
capacity: 10
|
||||||
|
refill-period: 300 # 5 minutes
|
||||||
|
|
||||||
|
# Production logging - security focused
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.company.auth: INFO
|
||||||
|
org.springframework.security: WARN
|
||||||
|
org.springframework.web: WARN
|
||||||
|
org.hibernate.SQL: WARN
|
||||||
|
ROOT: INFO
|
||||||
|
file:
|
||||||
|
name: /var/log/device-fingerprint-auth/application.log
|
||||||
|
max-size: 100MB
|
||||||
|
max-history: 30
|
||||||
|
|
||||||
|
# CORS - Restricted origins for production
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:https://app.company.com,https://admin.company.com}
|
||||||
|
allowed-methods: "GET,POST,PUT,DELETE,OPTIONS"
|
||||||
|
allowed-headers: "Authorization,Content-Type,X-Requested-With"
|
||||||
|
allow-credentials: true
|
||||||
|
max-age: 3600
|
||||||
|
|
||||||
|
# Management endpoints - minimal exposure for production
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: "health"
|
||||||
|
base-path: /management
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: never
|
||||||
|
security:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Production-specific JWT settings
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET} # Must be provided via environment
|
||||||
|
expiration: 3600000 # 1 hour in production
|
||||||
|
refresh-expiration: 86400000 # 24 hours in production
|
||||||
74
src/main/resources/application-production.properties
Normal file
74
src/main/resources/application-production.properties
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Production Configuration Override
|
||||||
|
# Override development settings for production deployment
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# DATASOURCE CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
spring.datasource.url=${DATABASE_URL:jdbc:mysql://mysql:3306/device_fingerprint_auth?createDatabaseIfNotExist=true&useSSL=true&allowPublicKeyRetrieval=true&serverTimezone=UTC}
|
||||||
|
spring.datasource.username=${DATABASE_USERNAME:auth_user}
|
||||||
|
spring.datasource.password=${DATABASE_PASSWORD:secure_password}
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# JPA / HIBERNATE CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
spring.jpa.hibernate.ddl-auto=create-drop
|
||||||
|
spring.jpa.show-sql=false
|
||||||
|
spring.jpa.properties.hibernate.format_sql=false
|
||||||
|
spring.jpa.properties.hibernate.use_sql_comments=false
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# FLYWAY CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
spring.flyway.enabled=false
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# SECURITY CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.security.jwt.secret=${JWT_SECRET:ThisIsAVeryLongAndSecureJWTSecretKeyThatShouldBeChangedInProduction123456789}
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# EMAIL CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.email.enabled=${EMAIL_ENABLED:false}
|
||||||
|
app.email.from-address=${EMAIL_FROM:noreply@company.com}
|
||||||
|
app.email.base-url=${BASE_URL:http://localhost:8080}
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# SMS CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.sms.enabled=${SMS_ENABLED:false}
|
||||||
|
app.sms.provider=${SMS_PROVIDER:demo}
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# APPLICATION CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.environment=production
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# LOGGING CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
logging.level.com.company.auth=INFO
|
||||||
|
logging.level.org.springframework.security=WARN
|
||||||
|
logging.level.org.springframework.web=WARN
|
||||||
|
logging.level.org.hibernate.SQL=WARN
|
||||||
|
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=WARN
|
||||||
|
logging.level.org.springframework=WARN
|
||||||
|
logging.level.org.hibernate=WARN
|
||||||
|
|
||||||
|
# Security focused logging
|
||||||
|
logging.level.com.company.auth.security=INFO
|
||||||
|
logging.level.com.company.auth.service.AuthService=INFO
|
||||||
|
logging.level.com.company.auth.service.DeviceFingerprintService=INFO
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# ACTUATOR CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
management.endpoints.web.exposure.include=health,metrics
|
||||||
|
management.endpoint.health.show-details=never
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# DEMO MODE CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.demo.enabled=false
|
||||||
|
app.demo.show-verification-codes=false
|
||||||
|
app.demo.bypass-email-verification=false
|
||||||
165
src/main/resources/application.properties
Normal file
165
src/main/resources/application.properties
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Application Configuration
|
||||||
|
# Development profile configuration for the Device Fingerprint Authentication API
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# SERVER CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
server.port=8080
|
||||||
|
server.servlet.context-path=/
|
||||||
|
server.compression.enabled=true
|
||||||
|
server.compression.mime-types=text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
|
||||||
|
server.compression.min-response-size=1024
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# DATASOURCE CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
spring.datasource.url=jdbc:mysql://localhost:3306/device_fingerprint_auth?createDatabaseIfNotExist=true&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||||
|
spring.datasource.username=auth_user
|
||||||
|
spring.datasource.password=secure_password
|
||||||
|
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
||||||
|
|
||||||
|
# Connection Pool Configuration
|
||||||
|
spring.datasource.hikari.maximum-pool-size=20
|
||||||
|
spring.datasource.hikari.minimum-idle=5
|
||||||
|
spring.datasource.hikari.connection-timeout=20000
|
||||||
|
spring.datasource.hikari.idle-timeout=300000
|
||||||
|
spring.datasource.hikari.max-lifetime=1200000
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# JPA / HIBERNATE CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
spring.jpa.hibernate.ddl-auto=validate
|
||||||
|
spring.jpa.show-sql=false
|
||||||
|
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
|
||||||
|
spring.jpa.properties.hibernate.format_sql=true
|
||||||
|
spring.jpa.properties.hibernate.use_sql_comments=true
|
||||||
|
spring.jpa.properties.hibernate.jdbc.batch_size=20
|
||||||
|
spring.jpa.properties.hibernate.order_inserts=true
|
||||||
|
spring.jpa.properties.hibernate.order_updates=true
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# FLYWAY CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
spring.flyway.enabled=true
|
||||||
|
spring.flyway.baseline-on-migrate=true
|
||||||
|
spring.flyway.locations=classpath:db/migration
|
||||||
|
spring.flyway.sql-migration-suffixes=.sql
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# SECURITY CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.security.jwt.secret=ThisIsAVeryLongAndSecureJWTSecretKeyThatShouldBeChangedInProduction123456789
|
||||||
|
app.security.jwt.access-token-expiration=900000
|
||||||
|
app.security.jwt.refresh-token-expiration=2592000000
|
||||||
|
app.security.jwt.session-token-expiration=1800000
|
||||||
|
app.security.jwt.password-reset-token-expiration=3600000
|
||||||
|
|
||||||
|
# Password Configuration
|
||||||
|
app.security.password.min-length=8
|
||||||
|
app.security.password.max-length=128
|
||||||
|
app.security.password.require-uppercase=true
|
||||||
|
app.security.password.require-lowercase=true
|
||||||
|
app.security.password.require-digits=true
|
||||||
|
app.security.password.require-special-chars=true
|
||||||
|
app.security.password.max-failed-attempts=5
|
||||||
|
app.security.password.lockout-duration=1800000
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# DEVICE FINGERPRINTING
|
||||||
|
# ===============================
|
||||||
|
app.device.verification-code-length=6
|
||||||
|
app.device.verification-code-expiry=600000
|
||||||
|
app.device.trust-duration=2592000000
|
||||||
|
app.device.max-trusted-devices=10
|
||||||
|
app.device.risk-threshold=70
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# TWO-FACTOR AUTHENTICATION
|
||||||
|
# ===============================
|
||||||
|
app.totp.enabled=true
|
||||||
|
app.totp.issuer=DeviceAuthService
|
||||||
|
app.totp.backup-codes-count=10
|
||||||
|
app.totp.window-size=1
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# EMAIL CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.email.enabled=true
|
||||||
|
app.email.from-address=noreply@company.com
|
||||||
|
app.email.from-name=Device Auth Service
|
||||||
|
app.email.base-url=http://localhost:8080
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# SMS CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.sms.enabled=true
|
||||||
|
app.sms.provider=demo
|
||||||
|
app.sms.from-number=+1234567890
|
||||||
|
app.sms.service-name=AuthService
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# GEOLOCATION CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.geolocation.enabled=true
|
||||||
|
app.geolocation.provider=demo
|
||||||
|
app.geolocation.cache-duration=3600
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# APPLICATION CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.name=Device Fingerprint Authentication API
|
||||||
|
app.version=1.0.0
|
||||||
|
app.description=Enterprise-grade authentication service with device fingerprinting
|
||||||
|
app.environment=development
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# LOGGING CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
logging.level.com.company.auth=DEBUG
|
||||||
|
logging.level.org.springframework.security=DEBUG
|
||||||
|
logging.level.org.springframework.web=INFO
|
||||||
|
logging.level.org.hibernate.SQL=DEBUG
|
||||||
|
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
|
||||||
|
|
||||||
|
# Logging Pattern
|
||||||
|
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %msg%n
|
||||||
|
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# ACTUATOR CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
management.endpoints.web.exposure.include=health,info,metrics,env
|
||||||
|
management.endpoint.health.show-details=when-authorized
|
||||||
|
management.info.env.enabled=true
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# SPRINGDOC OPENAPI CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
springdoc.api-docs.path=/v3/api-docs
|
||||||
|
springdoc.swagger-ui.path=/swagger-ui.html
|
||||||
|
springdoc.swagger-ui.operationsSorter=method
|
||||||
|
springdoc.swagger-ui.tagsSorter=alpha
|
||||||
|
springdoc.swagger-ui.tryItOutEnabled=true
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# RATE LIMITING CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.rate-limit.login.capacity=5
|
||||||
|
app.rate-limit.login.refill-rate=1
|
||||||
|
app.rate-limit.login.refill-period=60
|
||||||
|
|
||||||
|
app.rate-limit.registration.capacity=3
|
||||||
|
app.rate-limit.registration.refill-rate=1
|
||||||
|
app.rate-limit.registration.refill-period=300
|
||||||
|
|
||||||
|
app.rate-limit.verification.capacity=10
|
||||||
|
app.rate-limit.verification.refill-rate=2
|
||||||
|
app.rate-limit.verification.refill-period=60
|
||||||
|
|
||||||
|
# ===============================
|
||||||
|
# DEMO MODE CONFIGURATION
|
||||||
|
# ===============================
|
||||||
|
app.demo.enabled=true
|
||||||
|
app.demo.show-verification-codes=true
|
||||||
|
app.demo.bypass-email-verification=false
|
||||||
|
app.demo.default-verification-code=123456
|
||||||
157
src/main/resources/application.yml
Normal file
157
src/main/resources/application.yml
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: device-fingerprint-auth
|
||||||
|
profiles:
|
||||||
|
active: ${APP_ENV:dev}
|
||||||
|
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://localhost:3306/device_auth_db?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
|
||||||
|
username: ${DB_USERNAME:root}
|
||||||
|
password: ${DB_PASSWORD:password}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: 20
|
||||||
|
minimum-idle: 5
|
||||||
|
idle-timeout: 300000
|
||||||
|
max-lifetime: 1200000
|
||||||
|
connection-timeout: 20000
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate
|
||||||
|
show-sql: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
dialect: org.hibernate.dialect.MySQL8Dialect
|
||||||
|
format_sql: true
|
||||||
|
use_sql_comments: true
|
||||||
|
jdbc:
|
||||||
|
batch_size: 20
|
||||||
|
order_inserts: true
|
||||||
|
order_updates: true
|
||||||
|
open-in-view: false
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
locations: classpath:db/migration
|
||||||
|
baseline-on-migrate: true
|
||||||
|
validate-on-migrate: true
|
||||||
|
clean-disabled: true
|
||||||
|
|
||||||
|
jackson:
|
||||||
|
serialization:
|
||||||
|
write-dates-as-timestamps: false
|
||||||
|
default-property-inclusion: non_null
|
||||||
|
time-zone: UTC
|
||||||
|
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 10MB
|
||||||
|
max-request-size: 10MB
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: ${SERVER_PORT:8080}
|
||||||
|
servlet:
|
||||||
|
context-path: /api
|
||||||
|
compression:
|
||||||
|
enabled: true
|
||||||
|
mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
|
||||||
|
http2:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET:your-very-secure-secret-key-here-make-it-at-least-256-bits}
|
||||||
|
expiration: ${JWT_EXPIRATION:86400000} # 24 hours in milliseconds
|
||||||
|
refresh-expiration: ${JWT_REFRESH_EXPIRATION:604800000} # 7 days in milliseconds
|
||||||
|
issuer: ${JWT_ISSUER:device-fingerprint-auth}
|
||||||
|
|
||||||
|
# Device Fingerprinting Configuration
|
||||||
|
device-fingerprint:
|
||||||
|
demo-mode:
|
||||||
|
enabled: ${DEMO_MODE_ENABLED:false}
|
||||||
|
include-verification-code: ${DEMO_INCLUDE_CODES:false}
|
||||||
|
include-user-details: ${DEMO_INCLUDE_USER_DETAILS:false}
|
||||||
|
include-environment-info: ${DEMO_INCLUDE_ENV_INFO:true}
|
||||||
|
|
||||||
|
two-factor:
|
||||||
|
code-expiry-minutes: ${2FA_CODE_EXPIRY:5}
|
||||||
|
max-attempts: ${2FA_MAX_ATTEMPTS:3}
|
||||||
|
code-length: ${2FA_CODE_LENGTH:6}
|
||||||
|
resend-cooldown-seconds: ${2FA_RESEND_COOLDOWN:30}
|
||||||
|
|
||||||
|
security:
|
||||||
|
enable-rate-limiting: ${ENABLE_RATE_LIMITING:true}
|
||||||
|
max-devices-per-user: ${MAX_DEVICES_PER_USER:10}
|
||||||
|
device-expiry-days: ${DEVICE_EXPIRY_DAYS:90}
|
||||||
|
enable-audit-logging: ${ENABLE_AUDIT_LOGGING:true}
|
||||||
|
fingerprint-similarity-threshold: ${FINGERPRINT_SIMILARITY_THRESHOLD:0.85}
|
||||||
|
risk-score-threshold: ${RISK_SCORE_THRESHOLD:0.3}
|
||||||
|
|
||||||
|
# Encryption Configuration
|
||||||
|
encryption:
|
||||||
|
secret-key: ${ENCRYPTION_SECRET_KEY:your-encryption-secret-key}
|
||||||
|
algorithm: ${ENCRYPTION_ALGORITHM:AES/CBC/PKCS5Padding}
|
||||||
|
|
||||||
|
# Rate Limiting Configuration
|
||||||
|
rate-limiting:
|
||||||
|
login-attempts:
|
||||||
|
capacity: ${LOGIN_RATE_LIMIT_CAPACITY:5}
|
||||||
|
refill-period: ${LOGIN_RATE_LIMIT_REFILL:900} # 15 minutes
|
||||||
|
registration-attempts:
|
||||||
|
capacity: ${REGISTRATION_RATE_LIMIT_CAPACITY:3}
|
||||||
|
refill-period: ${REGISTRATION_RATE_LIMIT_REFILL:3600} # 1 hour
|
||||||
|
two-factor-attempts:
|
||||||
|
capacity: ${2FA_RATE_LIMIT_CAPACITY:10}
|
||||||
|
refill-period: ${2FA_RATE_LIMIT_REFILL:300} # 5 minutes
|
||||||
|
|
||||||
|
# Management and Monitoring
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,metrics,prometheus
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: when_authorized
|
||||||
|
health:
|
||||||
|
db:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# API Documentation
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
path: /api-docs
|
||||||
|
swagger-ui:
|
||||||
|
path: /swagger-ui.html
|
||||||
|
operations-sorter: alpha
|
||||||
|
tags-sorter: alpha
|
||||||
|
info:
|
||||||
|
title: Device Fingerprint Authentication API
|
||||||
|
description: Enterprise-grade API for device fingerprinting and risk-based authentication
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
name: David Valera Melendez
|
||||||
|
email: david@valera-melendez.de
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.company.auth: ${LOG_LEVEL:INFO}
|
||||||
|
org.springframework.security: WARN
|
||||||
|
org.springframework.web: WARN
|
||||||
|
org.hibernate.SQL: WARN
|
||||||
|
org.hibernate.type.descriptor.sql.BasicBinder: WARN
|
||||||
|
pattern:
|
||||||
|
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||||
|
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||||
|
file:
|
||||||
|
name: logs/device-fingerprint-auth.log
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
cors:
|
||||||
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:4200}
|
||||||
|
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS}
|
||||||
|
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
|
||||||
|
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
|
||||||
|
max-age: ${CORS_MAX_AGE:3600}
|
||||||
49
src/main/resources/db/migration/V1__Create_users_table.sql
Normal file
49
src/main/resources/db/migration/V1__Create_users_table.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
-- Create users table
|
||||||
|
-- Author: David Valera Melendez <david@valera-melendez.de>
|
||||||
|
-- Created: 2025-08-08
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
first_name VARCHAR(50) NOT NULL COMMENT 'User first name',
|
||||||
|
last_name VARCHAR(50) NOT NULL COMMENT 'User last name',
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL COMMENT 'User email address (unique)',
|
||||||
|
password VARCHAR(255) NOT NULL COMMENT 'Encrypted password hash',
|
||||||
|
roles JSON COMMENT 'User roles as JSON array',
|
||||||
|
permissions JSON COMMENT 'User permissions as JSON array',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE COMMENT 'Account active status',
|
||||||
|
email_verified BOOLEAN DEFAULT FALSE COMMENT 'Email verification status',
|
||||||
|
last_login_at TIMESTAMP NULL COMMENT 'Last successful login timestamp',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Account creation timestamp',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Last update timestamp'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='User accounts and authentication data';
|
||||||
|
|
||||||
|
-- Create indexes for performance
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
CREATE INDEX idx_users_active ON users(is_active);
|
||||||
|
CREATE INDEX idx_users_last_login ON users(last_login_at);
|
||||||
|
CREATE INDEX idx_users_created ON users(created_at);
|
||||||
|
|
||||||
|
-- Insert demo user for testing (password: 'Demo123!')
|
||||||
|
INSERT INTO users (first_name, last_name, email, password, roles, permissions, is_active, email_verified)
|
||||||
|
VALUES (
|
||||||
|
'John',
|
||||||
|
'Doe',
|
||||||
|
'john.doe@company.com',
|
||||||
|
'$2a$10$rHzQJlUzVr/aXbKpY9BUZeK8LGGzJVVGOmE8oY3OV4p3mX7QW1nXC',
|
||||||
|
'["USER"]',
|
||||||
|
'["READ_PROFILE", "UPDATE_PROFILE"]',
|
||||||
|
TRUE,
|
||||||
|
TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO users (first_name, last_name, email, password, roles, permissions, is_active, email_verified)
|
||||||
|
VALUES (
|
||||||
|
'Jane',
|
||||||
|
'Smith',
|
||||||
|
'jane.smith@company.com',
|
||||||
|
'$2a$10$rHzQJlUzVr/aXbKpY9BUZeK8LGGzJVVGOmE8oY3OV4p3mX7QW1nXC',
|
||||||
|
'["USER", "ADMIN"]',
|
||||||
|
'["READ_PROFILE", "UPDATE_PROFILE", "ADMIN_ACCESS"]',
|
||||||
|
TRUE,
|
||||||
|
TRUE
|
||||||
|
);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- Create trusted_devices table
|
||||||
|
-- Author: David Valera Melendez <david@valera-melendez.de>
|
||||||
|
-- Created: 2025-08-08
|
||||||
|
|
||||||
|
CREATE TABLE trusted_devices (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL COMMENT 'Reference to users table',
|
||||||
|
fingerprint_hash VARCHAR(255) NOT NULL COMMENT 'Unique device fingerprint hash',
|
||||||
|
device_name VARCHAR(100) COMMENT 'User-friendly device name',
|
||||||
|
device_info JSON NOT NULL COMMENT 'Complete device fingerprint data',
|
||||||
|
confidence_score DECIMAL(3,2) NOT NULL COMMENT 'Fingerprint confidence score (0.00-1.00)',
|
||||||
|
risk_score DECIMAL(3,2) DEFAULT 0.0 COMMENT 'Current risk assessment score (0.00-1.00)',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE COMMENT 'Device active status',
|
||||||
|
last_used_at TIMESTAMP NULL COMMENT 'Last authentication timestamp',
|
||||||
|
usage_count INT DEFAULT 0 COMMENT 'Total authentication count',
|
||||||
|
ip_address VARCHAR(45) COMMENT 'Last known IP address (supports IPv6)',
|
||||||
|
user_agent TEXT COMMENT 'Last known user agent string',
|
||||||
|
location_info JSON COMMENT 'Geographic location data if available',
|
||||||
|
security_flags JSON COMMENT 'Security-related flags and metadata',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Device registration timestamp',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Last update timestamp'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Trusted devices for risk-based authentication';
|
||||||
|
|
||||||
|
-- Foreign key constraint
|
||||||
|
ALTER TABLE trusted_devices
|
||||||
|
ADD CONSTRAINT fk_trusted_devices_user_id
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Unique constraint for user-device combination
|
||||||
|
ALTER TABLE trusted_devices
|
||||||
|
ADD CONSTRAINT unique_user_fingerprint
|
||||||
|
UNIQUE KEY (user_id, fingerprint_hash);
|
||||||
|
|
||||||
|
-- Create indexes for performance
|
||||||
|
CREATE INDEX idx_trusted_devices_fingerprint ON trusted_devices(fingerprint_hash);
|
||||||
|
CREATE INDEX idx_trusted_devices_user_active ON trusted_devices(user_id, is_active);
|
||||||
|
CREATE INDEX idx_trusted_devices_last_used ON trusted_devices(last_used_at);
|
||||||
|
CREATE INDEX idx_trusted_devices_risk_score ON trusted_devices(risk_score);
|
||||||
|
CREATE INDEX idx_trusted_devices_created ON trusted_devices(created_at);
|
||||||
|
|
||||||
|
-- Create index for device cleanup jobs
|
||||||
|
CREATE INDEX idx_trusted_devices_cleanup ON trusted_devices(is_active, last_used_at, created_at);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Create two_factor_verifications table
|
||||||
|
-- Author: David Valera Melendez <david@valera-melendez.de>
|
||||||
|
-- Created: 2025-08-08
|
||||||
|
|
||||||
|
CREATE TABLE two_factor_verifications (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL COMMENT 'Reference to users table',
|
||||||
|
code_hash VARCHAR(255) NOT NULL COMMENT 'Hashed verification code',
|
||||||
|
method ENUM('sms', 'email', 'totp') NOT NULL COMMENT 'Verification delivery method',
|
||||||
|
device_fingerprint JSON NOT NULL COMMENT 'Device fingerprint data for registration',
|
||||||
|
ip_address VARCHAR(45) COMMENT 'Client IP address (supports IPv6)',
|
||||||
|
user_agent TEXT COMMENT 'Client user agent string',
|
||||||
|
expires_at TIMESTAMP NOT NULL COMMENT 'Code expiration timestamp',
|
||||||
|
attempts INT DEFAULT 0 COMMENT 'Number of verification attempts',
|
||||||
|
max_attempts INT DEFAULT 3 COMMENT 'Maximum allowed attempts',
|
||||||
|
is_used BOOLEAN DEFAULT FALSE COMMENT 'Whether code has been successfully used',
|
||||||
|
is_blocked BOOLEAN DEFAULT FALSE COMMENT 'Whether session is blocked due to too many attempts',
|
||||||
|
metadata JSON COMMENT 'Additional metadata for demo mode and security tracking',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Verification session creation timestamp'
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Two-factor authentication verification sessions';
|
||||||
|
|
||||||
|
-- Foreign key constraint
|
||||||
|
ALTER TABLE two_factor_verifications
|
||||||
|
ADD CONSTRAINT fk_two_factor_verifications_user_id
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- Create indexes for performance
|
||||||
|
CREATE INDEX idx_two_factor_user_id ON two_factor_verifications(user_id);
|
||||||
|
CREATE INDEX idx_two_factor_code_hash ON two_factor_verifications(code_hash);
|
||||||
|
CREATE INDEX idx_two_factor_expires_at ON two_factor_verifications(expires_at);
|
||||||
|
CREATE INDEX idx_two_factor_user_unused ON two_factor_verifications(user_id, is_used);
|
||||||
|
CREATE INDEX idx_two_factor_method ON two_factor_verifications(method);
|
||||||
|
CREATE INDEX idx_two_factor_created ON two_factor_verifications(created_at);
|
||||||
|
|
||||||
|
-- Create index for cleanup jobs (expired and used verifications)
|
||||||
|
CREATE INDEX idx_two_factor_cleanup ON two_factor_verifications(expires_at, is_used, created_at);
|
||||||
|
|
||||||
|
-- Create composite index for active verification lookup
|
||||||
|
CREATE INDEX idx_two_factor_active ON two_factor_verifications(user_id, is_used, is_blocked, expires_at);
|
||||||
Reference in New Issue
Block a user