diff --git a/.gitignore b/.gitignore index 667aaef..685b1a9 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,29 @@ build/ ### VS Code ### .vscode/ + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* diff --git a/database.dbml b/database.dbml new file mode 100644 index 0000000..b0e7efb --- /dev/null +++ b/database.dbml @@ -0,0 +1,40 @@ +// Database schema for Secure Software Design API +// Use https://dbml.dbdiagram.io to visualize + +Project secure_software_design { + database_type: 'PostgreSQL' + Note: 'Secure API with JWT Authentication' +} + +Table users { + id bigint [pk, increment] + username varchar(50) [not null, unique, note: 'User login name (3-50 characters)'] + email varchar(255) [not null, unique, note: 'User email address'] + password_hash varchar(255) [not null, note: 'BCrypt hashed password'] + role varchar(50) [not null, note: 'User role (e.g., USER, ADMIN)'] + enabled boolean [not null, default: true, note: 'Account active status'] + + indexes { + username [unique] + email [unique] + } +} + +Table tokens { + id bigint [pk, increment] + token varchar(500) [not null, unique, note: 'JWT token string'] + username varchar(50) [not null, note: 'Username associated with token'] + created_at timestamp [not null, note: 'Token creation timestamp'] + expires_at timestamp [not null, note: 'Token expiration timestamp'] + revoked boolean [not null, default: false, note: 'Token revocation status'] + + indexes { + token [unique] + username + (username, revoked) + } +} + +// Relationships +// Token belongs to user +Ref: tokens.username > users.username diff --git a/pom.xml b/pom.xml index 1e52344..df3d832 100644 --- a/pom.xml +++ b/pom.xml @@ -1,63 +1,60 @@ - - 4.0.0 - - org.springframework.boot - spring-boot-starter-parent - 4.0.1 - - - com.api - application - 0.0.1-SNAPSHOT - application - Demo project for Spring Boot - - - - - - - - - - - - - - - 21 - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-webmvc - - - - org.springframework.boot - spring-boot-starter-data-jpa-test - test - - - org.springframework.boot - spring-boot-starter-webmvc-test - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.1 + + + com.api + application + 0.0.1-SNAPSHOT + application + Secure database access via HTTPS + + 21 + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.postgresql + postgresql + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + diff --git a/src/main/java/com/api/main/Application.java b/src/main/java/com/api/main/Application.java index 28b3288..cc26a5a 100644 --- a/src/main/java/com/api/main/Application.java +++ b/src/main/java/com/api/main/Application.java @@ -3,11 +3,16 @@ package com.api.main; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +/* + * Main entry point for the Secure Software Design API application. + * This class bootstraps the Spring Boot application and initializes + * all necessary configurations, security filters, and components. + * Uses @SpringBootApplication for auto-configuration and component scanning. + */ @SpringBootApplication public class Application { - public static void main(String[] args) { - SpringApplication.run(Application.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } } diff --git a/src/main/java/com/api/main/config/GlobalExceptionHandler.java b/src/main/java/com/api/main/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..3c3b5ed --- /dev/null +++ b/src/main/java/com/api/main/config/GlobalExceptionHandler.java @@ -0,0 +1,36 @@ +package com.api.main.config; + +import java.util.HashMap; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +/* + * Global exception handler for the application. + * Catches and processes validation exceptions across all controllers. + * Returns structured error responses with field-level validation messages. + * Ensures consistent error format for API clients. + * Uses @ControllerAdvice to apply globally to all request mappings. + */ +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationExceptions( + MethodArgumentNotValidException ex) { + Map fieldErrors = new HashMap<>(); + + ex.getBindingResult() + .getFieldErrors() + .forEach(error -> fieldErrors.put(error.getField(), error.getDefaultMessage())); + + Map response = new HashMap<>(); + response.put("status", "error"); + response.put("message", "Validation failed"); + response.put("errors", fieldErrors); + + return ResponseEntity.badRequest().body(response); + } +} diff --git a/src/main/java/com/api/main/config/SecurityConfig.java b/src/main/java/com/api/main/config/SecurityConfig.java new file mode 100644 index 0000000..2305a38 --- /dev/null +++ b/src/main/java/com/api/main/config/SecurityConfig.java @@ -0,0 +1,99 @@ +package com.api.main.config; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +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.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +/* + * Main security configuration for the application. + * Configures Spring Security with stateless authentication. + * Defines authorization rules for endpoints: + * - Public: /auth/login, /health + * - Protected: All other endpoints require authentication + * Uses BCrypt for password hashing with secure work factor. + * Disables CSRF as the API is stateless (token-based). + * Enforces HTTPS when SSL is enabled. + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final UserDetailsService userDetailsService; + + @Value("${server.ssl.enabled:true}") + private boolean sslEnabled; + + public SecurityConfig(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager() { + return new ProviderManager(authenticationProvider()); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests( + auth -> + auth.requestMatchers("/auth/login", "/health") + .permitAll() + .anyRequest() + .authenticated()) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling( + ex -> + ex.authenticationEntryPoint( + (request, response, authException) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response + .getWriter() + .write("{\"status\":\"error\",\"message\":\"Unauthorized\"}"); + }) + .accessDeniedHandler( + (request, response, accessDeniedException) -> { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response + .getWriter() + .write("{\"status\":\"error\",\"message\":\"Access denied\"}"); + })) + .authenticationProvider(authenticationProvider()) + .httpBasic(basic -> basic.realmName("Secure API")); + + if (sslEnabled) { + http.requiresChannel(channel -> channel.anyRequest().requiresSecure()); + } + + return http.build(); + } +} diff --git a/src/main/java/com/api/main/constants/Constants.java b/src/main/java/com/api/main/constants/Constants.java new file mode 100644 index 0000000..10bb81e --- /dev/null +++ b/src/main/java/com/api/main/constants/Constants.java @@ -0,0 +1,52 @@ +package com.api.main.constants; + +/* + * Centralized constants for the application. + * Contains reusable string literals to avoid hardcoding. + * Includes JPQL queries, error messages, and HTTP-related constants. + * Promotes consistency and maintainability across the codebase. + * All constants are static and final for immutability. + */ +public class Constants { + + /* + * JPQL queries use named parameters (:username) instead of string concatenation. + * Hibernate binds these parameters safely, preventing SQL injection attacks. + * User input is never directly interpolated into the query string. + */ + public static final String ADDING_TOKEN_QUERY = + "UPDATE Token t SET t.revoked = true WHERE t.username = :username AND t.revoked = false"; + + public static final String DELETING_TOKEN_QUERY = + "DELETE FROM Token t WHERE t.expiresAt < CURRENT_TIMESTAMP"; + + public static final String USER_NOT_FOUND_MESSAGE = "User not found: "; + + public static final String ROLE = "ROLE_"; + + public static final String AUTHORIZATION = "Authorization"; + + public static final String BEARER_PREFIX = "Bearer "; + + public static final String JWT_TOKEN_INVALID_MESSAGE = "JWT token validation failed: "; + + public static final String UNAUTHORIZED_MESSAGE = "Unauthorized access attempt"; + + public static final String STATUS = "status"; + + public static final String SUCCESS = "success"; + + public static final String MESSAGE = "message"; + + public static final String LOGGED_OUT_SUCCESSFULLY = "Logged out successfully"; + + public static final String ERROR = "error"; + + public static final String LOGOUT_FAILED = "Logout failed"; + + public static final String UP = "UP"; + + public static final String INVALID_CREDENTIALS = "Invalid credentials"; + + public static final String INTERNAL_SERVER_ERROR = "Internal server error"; +} diff --git a/src/main/java/com/api/main/controllers/AuthController.java b/src/main/java/com/api/main/controllers/AuthController.java new file mode 100644 index 0000000..1dce544 --- /dev/null +++ b/src/main/java/com/api/main/controllers/AuthController.java @@ -0,0 +1,42 @@ +package com.api.main.controllers; + +import com.api.main.constants.Constants; +import com.api.main.dto.ErrorResponse; +import com.api.main.dto.LoginRequest; +import com.api.main.dto.LoginResponse; +import com.api.main.services.AuthService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.bind.annotation.*; + +/* + * REST controller for authentication operations. + * Handles user login and token generation. + * Validates credentials against stored user data. + * Returns secure tokens for subsequent API requests. + */ +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + try { + LoginResponse response = authService.authenticate(request); + return ResponseEntity.ok(response); + } catch (BadCredentialsException e) { + return ResponseEntity.status(401) + .body(new ErrorResponse(Constants.ERROR, Constants.INVALID_CREDENTIALS)); + } catch (Exception e) { + return ResponseEntity.status(500) + .body(new ErrorResponse(Constants.ERROR, Constants.INTERNAL_SERVER_ERROR)); + } + } +} diff --git a/src/main/java/com/api/main/controllers/Health.java b/src/main/java/com/api/main/controllers/Health.java new file mode 100644 index 0000000..61ca79a --- /dev/null +++ b/src/main/java/com/api/main/controllers/Health.java @@ -0,0 +1,22 @@ +package com.api.main.controllers; + +import com.api.main.constants.Constants; +import com.api.main.dto.HealthResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/* + * REST controller for application health monitoring. + * Provides a public endpoint for checking API availability. + * Used by load balancers and monitoring systems. + * Does not require authentication for accessibility. + */ +@RestController +public class Health { + + @GetMapping("/health") + public ResponseEntity healthCheck() { + return ResponseEntity.ok(new HealthResponse(Constants.UP)); + } +} diff --git a/src/main/java/com/api/main/controllers/UserController.java b/src/main/java/com/api/main/controllers/UserController.java new file mode 100644 index 0000000..faf3fbe --- /dev/null +++ b/src/main/java/com/api/main/controllers/UserController.java @@ -0,0 +1,92 @@ +package com.api.main.controllers; + +import com.api.main.constants.Constants; +import com.api.main.dto.CreateUserRequest; +import com.api.main.dto.ErrorResponse; +import com.api.main.dto.UserResponse; +import com.api.main.entity.User; +import com.api.main.services.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.Collections; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +/* + * REST controller for user management operations. + * Provides endpoints for user profile access, creation, and logout. + * Admin-only endpoints are protected with @PreAuthorize. + * All endpoints require valid authentication. + * Supports token invalidation for secure logout. + */ +@RestController +@RequestMapping("/users") +public class UserController { + + private final AuthService authService; + + public UserController(AuthService authService) { + this.authService = authService; + } + + @GetMapping("/me") + public ResponseEntity getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return ResponseEntity.status(401) + .body(new ErrorResponse(Constants.ERROR, Constants.UNAUTHORIZED_MESSAGE)); + } + + try { + String username = authentication.getName(); + UserResponse userResponse = authService.getCurrentUser(username); + return ResponseEntity.ok(userResponse); + } catch (RuntimeException e) { + return ResponseEntity.status(401) + .body(new ErrorResponse(Constants.ERROR, Constants.UNAUTHORIZED_MESSAGE)); + } + } + + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/create") + public ResponseEntity createUser(@Valid @RequestBody CreateUserRequest request) { + try { + User user = + authService.registerUser( + request.getUsername(), request.getEmail(), request.getPassword(), request.getRole()); + UserResponse response = + new UserResponse( + user.getId(), + user.getUsername(), + user.getEmail(), + Collections.singletonList(user.getRole())); + return ResponseEntity.status(201).body(response); + } catch (RuntimeException e) { + return ResponseEntity.status(400).body(new ErrorResponse(Constants.ERROR, e.getMessage())); + } + } + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request) { + try { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + authService.logout(token); + } + return ResponseEntity.ok( + Map.of( + Constants.STATUS, + Constants.SUCCESS, + Constants.MESSAGE, + Constants.LOGGED_OUT_SUCCESSFULLY)); + } catch (RuntimeException e) { + return ResponseEntity.status(500) + .body(new ErrorResponse(Constants.ERROR, Constants.LOGOUT_FAILED)); + } + } +} diff --git a/src/main/java/com/api/main/dto/CreateUserRequest.java b/src/main/java/com/api/main/dto/CreateUserRequest.java new file mode 100644 index 0000000..7eabfd9 --- /dev/null +++ b/src/main/java/com/api/main/dto/CreateUserRequest.java @@ -0,0 +1,79 @@ +package com.api.main.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/* + * Data Transfer Object for user registration requests. + * Contains validation constraints for secure user creation: + * - Username: 3-50 characters, must be unique + * - Email: Valid format, must be unique + * - Password: Minimum 8 chars with uppercase, lowercase, number, and special char + * - Role: Required for authorization assignment + * Input validation helps prevent injection attacks and data integrity issues. + */ +public class CreateUserRequest { + + @NotBlank(message = "Username is required") + @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters") + private String username; + + @NotBlank(message = "Email is required") + @Email(message = "Email must be valid") + private String email; + + @NotBlank(message = "Password is required") + @Size(min = 8, message = "Password must be at least 8 characters") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$", + message = + "Password must contain at least one uppercase letter, one lowercase letter, one number," + + " and one special character (@$!%*?&)") + private String password; + + @NotBlank(message = "Role is required") + private String role; + + public CreateUserRequest() {} + + public CreateUserRequest(String username, String email, String password, String role) { + this.username = username; + this.email = email; + this.password = password; + this.role = role; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + 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 getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } +} diff --git a/src/main/java/com/api/main/dto/ErrorResponse.java b/src/main/java/com/api/main/dto/ErrorResponse.java new file mode 100644 index 0000000..abf1205 --- /dev/null +++ b/src/main/java/com/api/main/dto/ErrorResponse.java @@ -0,0 +1,41 @@ +package com.api.main.dto; + +/* + * Data Transfer Object for error responses. + * Provides a standardized format for API error messages. + * Contains status and message fields for consistent error handling. + * Used across all endpoints to return meaningful error information. + */ +public class ErrorResponse { + + private String status; + private String message; + + public ErrorResponse() {} + + public ErrorResponse(String message) { + this.status = "error"; + this.message = message; + } + + public ErrorResponse(String status, String message) { + this.status = status; + this.message = message; + } + + 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; + } +} diff --git a/src/main/java/com/api/main/dto/HealthResponse.java b/src/main/java/com/api/main/dto/HealthResponse.java new file mode 100644 index 0000000..ad3ddb6 --- /dev/null +++ b/src/main/java/com/api/main/dto/HealthResponse.java @@ -0,0 +1,26 @@ +package com.api.main.dto; + +/* + * Data Transfer Object for health check responses. + * Returns the current status of the API service. + * Used by monitoring systems to verify application availability. + * Typically returns "UP" when the service is running correctly. + */ +public class HealthResponse { + + private String status; + + public HealthResponse() {} + + public HealthResponse(String status) { + this.status = status; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/src/main/java/com/api/main/dto/LoginRequest.java b/src/main/java/com/api/main/dto/LoginRequest.java new file mode 100644 index 0000000..2fc1a54 --- /dev/null +++ b/src/main/java/com/api/main/dto/LoginRequest.java @@ -0,0 +1,41 @@ +package com.api.main.dto; + +import jakarta.validation.constraints.NotBlank; + +/* + * Data Transfer Object for authentication requests. + * Contains username and password for user login. + * Both fields are required and validated before processing. + * Passwords are never stored or logged in plain text. + */ +public class LoginRequest { + + @NotBlank(message = "Username is required") + private String username; + + @NotBlank(message = "Password is required") + private String password; + + public LoginRequest() {} + + public LoginRequest(String username, String password) { + this.username = username; + this.password = password; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/src/main/java/com/api/main/dto/LoginResponse.java b/src/main/java/com/api/main/dto/LoginResponse.java new file mode 100644 index 0000000..828afa7 --- /dev/null +++ b/src/main/java/com/api/main/dto/LoginResponse.java @@ -0,0 +1,51 @@ +package com.api.main.dto; + +/* + * Data Transfer Object for authentication response. + * Contains the token returned after successful login. + * Used to transfer authentication results between layers. + * The token should be stored securely and sent in subsequent requests. + */ +public class LoginResponse { + + private String status; + private String message; + private String token; + + public LoginResponse() {} + + public LoginResponse(String status, String message) { + this.status = status; + this.message = message; + } + + public LoginResponse(String status, String message, String token) { + this.status = status; + this.message = message; + this.token = token; + } + + 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 String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } +} diff --git a/src/main/java/com/api/main/dto/UserResponse.java b/src/main/java/com/api/main/dto/UserResponse.java new file mode 100644 index 0000000..d345451 --- /dev/null +++ b/src/main/java/com/api/main/dto/UserResponse.java @@ -0,0 +1,59 @@ +package com.api.main.dto; + +import java.util.ArrayList; +import java.util.List; + +/* + * Data Transfer Object for user information responses. + * Contains safe user data to expose via the API. + * Excludes sensitive fields like password hash for security. + * Used when returning user profile information to clients. + */ +public class UserResponse { + + private Long id; + private String username; + private String email; + private List roles; + + public UserResponse() {} + + public UserResponse(Long id, String username, String email, List roles) { + this.id = id; + this.username = username; + this.email = email; + this.roles = roles != null ? new ArrayList<>(roles) : null; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public List getRoles() { + return roles != null ? new ArrayList<>(roles) : null; + } + + public void setRoles(List roles) { + this.roles = roles != null ? new ArrayList<>(roles) : null; + } +} diff --git a/src/main/java/com/api/main/entity/Token.java b/src/main/java/com/api/main/entity/Token.java new file mode 100644 index 0000000..d0342d5 --- /dev/null +++ b/src/main/java/com/api/main/entity/Token.java @@ -0,0 +1,158 @@ +package com.api.main.entity; + +import jakarta.persistence.*; +import java.time.Instant; + +/* + * JPA Entity representing a JWT token in the database. + * Enables token tracking, validation, and revocation. + * Stores token metadata including creation and expiration timestamps. + * Revoked tokens are invalidated even before expiration. + * Essential for implementing secure logout and token management. + */ +@Entity +@Table(name = "tokens") +public class Token { + + /* Primary key for the token entity */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /* The JWT token string */ + @Column(nullable = false, unique = true, length = 500) + private String token; + + /* Username associated with the token */ + @Column(nullable = false) + private String username; + + /* Timestamp when the token was created */ + @Column(nullable = false) + private Instant createdAt; + + /* Timestamp when the token expires */ + @Column(nullable = false) + private Instant expiresAt; + + /* Flag indicating if the token has been revoked */ + @Column(nullable = false) + private boolean revoked = false; + + /* Default constructor */ + public Token() {} + + /* + * Constructor with parameters + * @param token The JWT token string + * @param username Username associated with the token + * @param createdAt Timestamp when the token was created + * @param expiresAt Timestamp when the token expires + * + */ + public Token(String token, String username, Instant createdAt, Instant expiresAt) { + this.token = token; + this.username = username; + this.createdAt = createdAt; + this.expiresAt = expiresAt; + this.revoked = false; + } + + /* Getters and Setters */ + + /* Get the token ID + * @return id + * + */ + public Long getId() { + return id; + } + + /* Set the token ID + * @param id Token ID + * + */ + public void setId(Long id) { + this.id = id; + } + + /* Get the JWT token string + * @return token + * + */ + public String getToken() { + return token; + } + + /* Set the JWT token string + * @param token The JWT token string + * + */ + public void setToken(String token) { + this.token = token; + } + + /* Get the username associated with the token + * @return username + * + */ + public String getUsername() { + return username; + } + + /* Set the username associated with the token + * @param username Username associated with the token + * + */ + public void setUsername(String username) { + this.username = username; + } + + /* Get the creation timestamp + * @return createdAt + * + */ + public Instant getCreatedAt() { + return createdAt; + } + + /* Set the creation timestamp + * @param createdAt Timestamp when the token was created + * + */ + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + /* Get the expiration timestamp + * @return expiresAt + * + */ + public Instant getExpiresAt() { + return expiresAt; + } + + /* Set the expiration timestamp + * @param expiresAt Timestamp when the token expires + * + */ + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + /* Check if the token is revoked + * @return revoked + * + */ + public boolean isRevoked() { + return revoked; + } + + /* Set the revoked status + * @param revoked Flag indicating if the token has been revoked + * + */ + public void setRevoked(boolean revoked) { + this.revoked = revoked; + } +} diff --git a/src/main/java/com/api/main/entity/User.java b/src/main/java/com/api/main/entity/User.java new file mode 100644 index 0000000..2267811 --- /dev/null +++ b/src/main/java/com/api/main/entity/User.java @@ -0,0 +1,244 @@ +package com.api.main.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/* + * JPA Entity representing a user in the system. + * Stores user credentials and profile information securely. + * Password is stored as a BCrypt hash, never in plain text. + * Contains validation constraints for username, email, and password. + * Used for authentication and authorization throughout the application. + */ +@Entity +@Table(name = "users") +public class User { + + /* + * Primary key for the user entity + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /* + * Username of the user + * Must be unique and between 3 to 50 characters + * @return username + */ + @NotBlank + @Size(min = 3, max = 50) + @Column(unique = true, nullable = false) + private String username; + + /* + * Email address of the user + * Must be unique and a valid email format + * @return email + */ + @NotBlank + @Email + @Column(unique = true, nullable = false) + private String email; + + /* + * BCrypt hashed password of the user + * Stored securely, never in plain text + * @return passwordHash + */ + @NotBlank + @Column(nullable = false) + private String passwordHash; + + /* + * Role of the user (e.g., ROLE_USER, ROLE_ADMIN) + * Used for authorization purposes + * @return role + */ + @NotBlank + @Column(nullable = false) + private String role; + + /* + * Flag indicating if the user account is enabled + * Disabled accounts cannot authenticate + * @return enabled + */ + @Column(nullable = false) + private boolean enabled = true; + + /* Default constructor */ + public User() {} + + /* + * Constructor with parameters + * @param username Username of the user + * @param email Email address of the user + * @param passwordHash BCrypt hashed password + * @param role Role of the user + * + */ + public User(String username, String email, String passwordHash, String role) { + this.username = username; + this.email = email; + this.passwordHash = passwordHash; + this.role = role; + this.enabled = true; + } + + /* + * Constructor with all parameters + * @param id User ID + * @param username Username of the user + * @param email Email address of the user + * @param passwordHash BCrypt hashed password + * @param role Role of the user + * @param enabled Account enabled status + * + */ + public User( + Long id, String username, String email, String passwordHash, String role, boolean enabled) { + this.id = id; + this.username = username; + this.email = email; + this.passwordHash = passwordHash; + this.role = role; + this.enabled = enabled; + } + + /* Getters and Setters */ + + /* + * Get the user ID + * @return id + * + */ + public Long getId() { + return id; + } + + /* + * Set the user ID + * @param id User ID + * + */ + public void setId(Long id) { + this.id = id; + } + + /* + * Get the username + * @return username + * + */ + public String getUsername() { + return username; + } + + /* + * Set the username + * @param username Username of the user + * + */ + public void setUsername(String username) { + this.username = username; + } + + /* + * Get the email address + * @return email + * + */ + public String getEmail() { + return email; + } + + /* + * Set the email address + * @param email Email address of the user + * + */ + public void setEmail(String email) { + this.email = email; + } + + /* + * Get the BCrypt hashed password + * @return passwordHash + * + */ + public String getPasswordHash() { + return passwordHash; + } + + /* + * Set the BCrypt hashed password + * @param passwordHash BCrypt hashed password + * + */ + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + /* + * Get the user role + * @return role + * + */ + public String getRole() { + return role; + } + + /* + * Set the user role + * @param role Role of the user + * + */ + public void setRole(String role) { + this.role = role; + } + + /* + * Check if the user account is enabled + * @return enabled + * + */ + public boolean isEnabled() { + return enabled; + } + + /* + * Set the user account enabled status + * @param enabled Account enabled status + * + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /* + * String representation of the User entity + * @return String representation + * + */ + @Override + public String toString() { + return "User{" + + "id=" + + id + + ", username='" + + username + + '\'' + + ", email='" + + email + + '\'' + + ", role='" + + role + + '\'' + + ", enabled=" + + enabled + + '}'; + } +} diff --git a/src/main/java/com/api/main/repositories/TokenRepository.java b/src/main/java/com/api/main/repositories/TokenRepository.java new file mode 100644 index 0000000..5dd229e --- /dev/null +++ b/src/main/java/com/api/main/repositories/TokenRepository.java @@ -0,0 +1,58 @@ +package com.api.main.repositories; + +import com.api.main.constants.Constants; +import com.api.main.entity.Token; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +/* + * Repository interface for Token entity database operations. + * Manages JWT token persistence and lifecycle. + * Provides methods for token validation and revocation. + * Supports bulk operations for revoking user tokens and cleanup. + * Uses JPQL queries for efficient token management. + * + * SQL Injection Protection: + * All queries use parameterized statements. Derived methods (findByToken) and + * @Query annotations with named parameters (:username) are safely bound by + * Hibernate. No raw SQL or string concatenation is used anywhere. + */ +@Repository +public interface TokenRepository extends JpaRepository { + + /* + * Find a token by its string value. + * @param token The JWT token string + * @return Optional containing the Token if found, else empty + * + */ + Optional findByToken(String token); + + /* + * Find a non-revoked token by its string value. + * @param token The JWT token string + * @return Optional containing the Token if found and not revoked, else empty + * + */ + Optional findByTokenAndRevokedFalse(String token); + + /* + * Revoke all tokens associated with a specific username. + * @param username The username whose tokens are to be revoked + * + */ + @Modifying + @Query(Constants.ADDING_TOKEN_QUERY) + void revokeAllUserTokens(String username); + + /* + * Delete all expired tokens from the database. + * + */ + @Modifying + @Query(Constants.DELETING_TOKEN_QUERY) + void deleteExpiredTokens(); +} diff --git a/src/main/java/com/api/main/repositories/UserRepository.java b/src/main/java/com/api/main/repositories/UserRepository.java new file mode 100644 index 0000000..12de93e --- /dev/null +++ b/src/main/java/com/api/main/repositories/UserRepository.java @@ -0,0 +1,27 @@ +package com.api.main.repositories; + +import com.api.main.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/* + * Repository interface for User entity database operations. + * Extends JpaRepository for standard CRUD operations. + * Provides custom query methods for user lookup and existence checks. + * Used by authentication and user management services. + * + * SQL Injection Protection: + * Spring Data JPA derived query methods (findByUsername, existsByUsername, etc.) + * automatically use parameterized queries. User input is bound as parameters, + * never concatenated into SQL strings, making SQL injection impossible. + */ +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByUsername(String username); + + boolean existsByEmail(String email); +} diff --git a/src/main/java/com/api/main/security/CustomUserDetailsService.java b/src/main/java/com/api/main/security/CustomUserDetailsService.java new file mode 100644 index 0000000..4e351be --- /dev/null +++ b/src/main/java/com/api/main/security/CustomUserDetailsService.java @@ -0,0 +1,59 @@ +package com.api.main.security; + +import com.api.main.constants.Constants; +import com.api.main.entity.User; +import com.api.main.repositories.UserRepository; +import java.util.Collections; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/* + * Custom implementation of Spring Security's UserDetailsService. + * Loads user-specific data from the database during authentication. + * Converts the application's User entity to Spring Security's UserDetails. + * Maps user roles with the ROLE_ prefix for proper authorization checks. + * Throws UsernameNotFoundException if the user does not exist. + */ +@Service +public class CustomUserDetailsService implements UserDetailsService { + + /* Repository for accessing user data from the database */ + private final UserRepository userRepository; + + /* + * Constructor for CustomUserDetailsService + * @param userRepository Repository to access user data + * + */ + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /* + * Load user details by username for authentication. + * @param username The username of the user to load + * @return UserDetails object containing user information and authorities + * @throws UsernameNotFoundException if the user is not found + * + */ + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = + userRepository + .findByUsername(username) + .orElseThrow( + () -> new UsernameNotFoundException(Constants.USER_NOT_FOUND_MESSAGE + username)); + + return new org.springframework.security.core.userdetails.User( + user.getUsername(), + user.getPasswordHash(), + user.isEnabled(), + true, + true, + true, + Collections.singletonList(new SimpleGrantedAuthority(Constants.ROLE + user.getRole()))); + } +} diff --git a/src/main/java/com/api/main/security/SecurityHeadersFilter.java b/src/main/java/com/api/main/security/SecurityHeadersFilter.java new file mode 100644 index 0000000..9aa3268 --- /dev/null +++ b/src/main/java/com/api/main/security/SecurityHeadersFilter.java @@ -0,0 +1,72 @@ +package com.api.main.security; + +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/* + * Filter that adds security headers to all HTTP responses. + * Implements defense-in-depth by configuring multiple security headers: + * - X-Frame-Options: Prevents clickjacking attacks + * - X-Content-Type-Options: Prevents MIME type sniffing + * - X-XSS-Protection: Enables browser XSS filtering + * - Content-Security-Policy: Restricts resource loading sources + * - Strict-Transport-Security: Enforces HTTPS connections + * - Permissions-Policy: Disables unnecessary browser features + * - Cache-Control: Prevents caching of sensitive data + * Runs with highest precedence to ensure headers are applied early. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class SecurityHeadersFilter implements Filter { + + /* Initialization method for the filter */ + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (response instanceof HttpServletResponse httpResponse) { + // Prevent clickjacking + httpResponse.setHeader("X-Frame-Options", "DENY"); + + // Prevent MIME type sniffing + httpResponse.setHeader("X-Content-Type-Options", "nosniff"); + + // Enable XSS filter in browsers + httpResponse.setHeader("X-XSS-Protection", "1; mode=block"); + + // Control referrer information + httpResponse.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + + // Content Security Policy - restrict resource loading + httpResponse.setHeader( + "Content-Security-Policy", + "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "font-src 'self'; " + + "frame-ancestors 'none'; " + + "form-action 'self'"); + + // HTTP Strict Transport Security (HSTS) + httpResponse.setHeader( + "Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload"); + + // Permissions Policy - disable unnecessary features + httpResponse.setHeader( + "Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()"); + + // Prevent caching of sensitive data + httpResponse.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, private"); + httpResponse.setHeader("Pragma", "no-cache"); + } + chain.doFilter(request, response); + } +} diff --git a/src/main/java/com/api/main/services/AuthService.java b/src/main/java/com/api/main/services/AuthService.java new file mode 100644 index 0000000..0c7c6b0 --- /dev/null +++ b/src/main/java/com/api/main/services/AuthService.java @@ -0,0 +1,130 @@ +package com.api.main.services; + +import com.api.main.dto.LoginRequest; +import com.api.main.dto.LoginResponse; +import com.api.main.dto.UserResponse; +import com.api.main.entity.Token; +import com.api.main.entity.User; +import com.api.main.repositories.TokenRepository; +import com.api.main.repositories.UserRepository; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Collections; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/* + * Service layer for authentication and user management operations. + * Handles user login, registration, and logout functionality. + * Generates and validates tokens for authenticated sessions. + * Stores tokens in database for revocation support. + * Uses transactional operations for data consistency. + * Passwords are hashed using BCrypt before storage. + */ +@Service +public class AuthService { + + private final UserRepository userRepository; + private final TokenRepository tokenRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + + @Value("${token.expiration:86400000}") + private Long tokenExpiration; + + public AuthService( + UserRepository userRepository, + TokenRepository tokenRepository, + PasswordEncoder passwordEncoder, + AuthenticationManager authenticationManager) { + this.userRepository = userRepository; + this.tokenRepository = tokenRepository; + this.passwordEncoder = passwordEncoder; + this.authenticationManager = authenticationManager; + } + + @Transactional + public LoginResponse authenticate(LoginRequest request) { + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); + + User user = + userRepository + .findByUsername(request.getUsername()) + .orElseThrow(() -> new BadCredentialsException("Invalid credentials")); + + String tokenValue = generateToken(); + + Instant now = Instant.now(); + Instant expiresAt = now.plus(tokenExpiration, ChronoUnit.MILLIS); + Token token = new Token(tokenValue, user.getUsername(), now, expiresAt); + tokenRepository.save(token); + + return new LoginResponse("success", "Authentication successful", tokenValue); + } catch (AuthenticationException e) { + throw new BadCredentialsException("Invalid username or password"); + } + } + + private String generateToken() { + byte[] randomBytes = new byte[32]; + new java.security.SecureRandom().nextBytes(randomBytes); + return UUID.randomUUID().toString() + "-" + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + } + + public UserResponse getCurrentUser(String username) { + User user = + userRepository + .findByUsername(username) + .orElseThrow(() -> new RuntimeException("User not found")); + + return new UserResponse( + user.getId(), + user.getUsername(), + user.getEmail(), + Collections.singletonList(user.getRole())); + } + + @Transactional + public User registerUser(String username, String email, String password, String role) { + if (userRepository.existsByUsername(username)) { + throw new RuntimeException("Username already exists"); + } + if (userRepository.existsByEmail(email)) { + throw new RuntimeException("Email already exists"); + } + + User user = new User(); + user.setUsername(username); + user.setEmail(email); + user.setPasswordHash(passwordEncoder.encode(password)); + user.setRole(role); + user.setEnabled(true); + + return userRepository.save(user); + } + + @Transactional + public void logout(String token) { + tokenRepository + .findByToken(token) + .ifPresent( + t -> { + t.setRevoked(true); + tokenRepository.save(t); + }); + } + + public boolean isTokenValid(String token) { + return tokenRepository.findByTokenAndRevokedFalse(token).isPresent(); + } +} diff --git a/src/main/resources/keystore.p12 b/src/main/resources/keystore.p12 new file mode 100644 index 0000000..b1d253b Binary files /dev/null and b/src/main/resources/keystore.p12 differ