From 47a26993148274a325b4e300a8f6c044ea5f4a42 Mon Sep 17 00:00:00 2001 From: Phong Chu Date: Sun, 30 Nov 2025 11:51:35 +0700 Subject: [PATCH] feat: Add authentication module with JWT support and custom security handlers - Introduced a new `authentication` module for handling user authentication and authorization. - Implemented JWT-based authentication with configurable issuer, secret, and expiration time. - Added custom security handlers for JSON responses on authentication success, failure, and access denial. - Updated the console module to include the new authentication module as a dependency. - Enhanced API response structure with standardized error handling and metadata. - Configured Spring Security to use custom filters for JSON login requests. - Added necessary configuration files and logging settings for development and testing environments. --- .github/workflows/workastra_server.yaml | 9 +- .vscode/launch.json | 3 +- build.gradle.kts | 27 ++++++ config/checkstyle/checkstyle.xml | 6 ++ gradle/libs.versions.toml | 3 + modules/authentication/build.gradle.kts | 13 +++ .../AuthenticationModuleConfig.java | 41 +++++++++ .../controller/AuthController.java | 21 +++++ .../controller/package-info.java | 4 + .../config/PasswordEncoderConfig.java | 58 ++++++++++++ .../infrastructure/config/SecurityConfig.java | 88 +++++++++++++++++++ .../infrastructure/config/package-info.java | 10 +++ .../filter/JsonLoginAuthenticationFilter.java | 61 +++++++++++++ .../infrastructure/filter/package-info.java | 9 ++ .../handler/JsonAccessDeniedHandler.java | 46 ++++++++++ .../handler/JsonAuthFailureHandler.java | 37 ++++++++ .../handler/JsonAuthSuccessHandler.java | 37 ++++++++ .../handler/JsonAuthenticationEntryPoint.java | 43 +++++++++ .../infrastructure/handler/package-info.java | 12 +++ .../authentication/package-info.java | 4 + modules/common/build.gradle.kts | 4 +- .../com/workastra/common/api/ApiResponse.java | 55 ++++++++++++ .../com/workastra/common/api/ErrorItem.java | 3 + .../java/com/workastra/common/api/Meta.java | 71 +++++++++++++++ .../com/workastra/common/api/Pagination.java | 3 + .../workastra/common/api/package-info.java | 4 + modules/console/build.gradle.kts | 8 ++ .../console/WorkastraConsoleApplication.java | 2 +- .../com/workastra/console/package-info.java | 4 + ...itional-spring-configuration-metadata.json | 19 ++++ .../src/main/resources/application-dev.yaml | 6 ++ .../src/main/resources/application-test.yaml | 1 + .../src/main/resources/application.yaml | 14 +-- .../com/workastra/worker/package-info.java | 4 + settings.gradle.kts | 9 +- 35 files changed, 724 insertions(+), 15 deletions(-) create mode 100644 modules/authentication/build.gradle.kts create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/AuthenticationModuleConfig.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/controller/AuthController.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/controller/package-info.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/PasswordEncoderConfig.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/SecurityConfig.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/package-info.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/infrastructure/filter/JsonLoginAuthenticationFilter.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/infrastructure/filter/package-info.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAccessDeniedHandler.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthFailureHandler.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthSuccessHandler.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthenticationEntryPoint.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/package-info.java create mode 100644 modules/authentication/src/main/java/com/workastra/authentication/package-info.java create mode 100644 modules/common/src/main/java/com/workastra/common/api/ApiResponse.java create mode 100644 modules/common/src/main/java/com/workastra/common/api/ErrorItem.java create mode 100644 modules/common/src/main/java/com/workastra/common/api/Meta.java create mode 100644 modules/common/src/main/java/com/workastra/common/api/Pagination.java create mode 100644 modules/common/src/main/java/com/workastra/common/api/package-info.java create mode 100644 modules/console/src/main/java/com/workastra/console/package-info.java create mode 100644 modules/console/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 modules/console/src/main/resources/application-dev.yaml create mode 100644 modules/console/src/main/resources/application-test.yaml create mode 100644 modules/worker/src/main/java/com/workastra/worker/package-info.java diff --git a/.github/workflows/workastra_server.yaml b/.github/workflows/workastra_server.yaml index e18f075..dbff998 100644 --- a/.github/workflows/workastra_server.yaml +++ b/.github/workflows/workastra_server.yaml @@ -46,9 +46,16 @@ jobs: cache: gradle - name: Run checks - run: ./gradlew --no-daemon clean check + run: ./gradlew --no-daemon clean check -x test + + - name: Run JVM tests + env: + SPRING_PROFILES_ACTIVE: test + run: ./gradlew --no-daemon clean test - name: Run native tests + env: + SPRING_PROFILES_ACTIVE: test run: ./gradlew --no-daemon clean nativeTest build: diff --git a/.vscode/launch.json b/.vscode/launch.json index a67070e..7179ec4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,8 @@ "cwd": "${workspaceFolder}", "mainClass": "com.workastra.console.WorkastraConsoleApplication", "projectName": "console", - "env": {} + "vmArgs": "-XX:+UseCompactObjectHeaders", + "env": { "SPRING_PROFILES_ACTIVE": "dev" } } ] } diff --git a/build.gradle.kts b/build.gradle.kts index 50b4d35..ed198da 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,9 @@ +import net.ltgt.gradle.errorprone.errorprone + +val libsCatalog = extensions + .getByType(VersionCatalogsExtension::class.java) + .named("libs") + plugins { java checkstyle @@ -5,6 +11,7 @@ plugins { alias(libs.plugins.spring.dependency.management) apply false alias(libs.plugins.graalvm.native) apply false alias(libs.plugins.spotless) + alias(libs.plugins.net.ltgt.errorprone) apply true } group = "com.workastra" @@ -24,6 +31,7 @@ subprojects { apply(plugin = "java") apply(plugin = "checkstyle") apply(plugin = "com.diffplug.spotless") + apply(plugin = "net.ltgt.errorprone") checkstyle { toolVersion = "12.1.2" @@ -46,4 +54,23 @@ subprojects { )).configFile(file("${rootDir}/java.prettierrc.yaml")) } } + + dependencies { + add("errorprone", libsCatalog.findLibrary("error-prone-core").get()) + add("errorprone", libsCatalog.findLibrary("nullaway").get()) + } + + tasks.withType().configureEach { + options.errorprone { + disableWarningsInGeneratedCode.set(true) + excludedPaths.set(".*/build/generated/.*") + + // Enable nullness checks only in null-marked code + option("NullAway:OnlyNullMarked", "true") + // Bump checks from warnings (default) to errors + error("NullAway") + // https://github.com/uber/NullAway/wiki/JSpecify-Support + option("NullAway:JSpecifyMode", "true") + } + } } diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index 9c93121..2c35c1e 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -139,5 +139,11 @@ + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0dd3f9a..a94f3fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,9 +3,12 @@ spring-boot = "4.0.0" [libraries] spring-boot-bom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" } +error-prone-core = { module = "com.google.errorprone:error_prone_core", version = "2.44.0" } +nullaway = { module = "com.uber.nullaway:nullaway", version = "0.12.12" } [plugins] spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.7" } graalvm-native = { id = "org.graalvm.buildtools.native", version = "0.11.3" } spotless = { id = "com.diffplug.spotless", version = "8.1.0" } +net-ltgt-errorprone = { id = "net.ltgt.errorprone", version = "4.3.0" } diff --git a/modules/authentication/build.gradle.kts b/modules/authentication/build.gradle.kts new file mode 100644 index 0000000..8d0e47f --- /dev/null +++ b/modules/authentication/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + `java-library` + alias(libs.plugins.spring.dependency.management) +} + +dependencies { + implementation(platform(libs.spring.boot.bom)) + implementation(project(":modules:common")) + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-security-oauth2-client") + testImplementation("org.springframework.boot:spring-boot-starter-security-oauth2-client-test") + testImplementation("org.springframework.boot:spring-boot-starter-security-test") +} diff --git a/modules/authentication/src/main/java/com/workastra/authentication/AuthenticationModuleConfig.java b/modules/authentication/src/main/java/com/workastra/authentication/AuthenticationModuleConfig.java new file mode 100644 index 0000000..0b7c27d --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/AuthenticationModuleConfig.java @@ -0,0 +1,41 @@ +package com.workastra.authentication; + +import com.workastra.authentication.infrastructure.handler.JsonAccessDeniedHandler; +import com.workastra.authentication.infrastructure.handler.JsonAuthenticationEntryPoint; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import tools.jackson.databind.ObjectMapper; + +/** + * AuthenticationModuleConfig is the module-level bootstrap configuration for the authentication module. + * + * This configuration is responsible for: + * - Registering exception handlers as Spring components + * - Providing infrastructure beans needed across the module + * + * Actual security configuration is delegated to the infrastructure layer: + * - SecurityConfig: HTTP security filter chain and authorization rules + * - PasswordEncoderConfig: Password encoding and authentication beans + * - Various handlers: JSON-based exception and authentication event handling + */ +@Configuration +public class AuthenticationModuleConfig { + + /** + * Registers the JsonAuthenticationEntryPoint as a Spring component. + * This handler processes 401 Unauthorized responses. + */ + @Bean + JsonAuthenticationEntryPoint jsonAuthenticationEntryPoint(ObjectMapper mapper) { + return new JsonAuthenticationEntryPoint(mapper); + } + + /** + * Registers the JsonAccessDeniedHandler as a Spring component. + * This handler processes 403 Forbidden responses. + */ + @Bean + JsonAccessDeniedHandler jsonAccessDeniedHandler(ObjectMapper mapper) { + return new JsonAccessDeniedHandler(mapper); + } +} diff --git a/modules/authentication/src/main/java/com/workastra/authentication/controller/AuthController.java b/modules/authentication/src/main/java/com/workastra/authentication/controller/AuthController.java new file mode 100644 index 0000000..e21558d --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/controller/AuthController.java @@ -0,0 +1,21 @@ +package com.workastra.authentication.controller; + +import com.workastra.common.api.ApiResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AuthController { + + @GetMapping(path = "/api/v{version}/csrf", version = "1") + public ApiResponse getCsrf(CsrfToken csrfToken) { + return ApiResponse.ok(csrfToken); + } + + @GetMapping(path = "/api/v{version}/auth/me", version = "1") + public ApiResponse me(Authentication authentication) { + return ApiResponse.ok(authentication); + } +} diff --git a/modules/authentication/src/main/java/com/workastra/authentication/controller/package-info.java b/modules/authentication/src/main/java/com/workastra/authentication/controller/package-info.java new file mode 100644 index 0000000..fbafabf --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/controller/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.workastra.authentication.controller; + +import org.jspecify.annotations.NullMarked; diff --git a/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/PasswordEncoderConfig.java b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..12896c5 --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/PasswordEncoderConfig.java @@ -0,0 +1,58 @@ +package com.workastra.authentication.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.core.userdetails.User; +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.provisioning.InMemoryUserDetailsManager; + +/** + * PasswordEncoderConfig defines password encoding and authentication-related beans. + * + * This configuration class sets up: + * - BCryptPasswordEncoder for secure password hashing + * - AuthenticationManager for delegating authentication + * - UserDetailsService with in-memory user store (for demo purposes) + */ +@Configuration +public class PasswordEncoderConfig { + + /** + * Provides the BCryptPasswordEncoder bean for password encryption. + * BCrypt automatically handles salt generation and secure hashing. + */ + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * Provides the AuthenticationManager bean from the AuthenticationConfiguration. + * This manager delegates to the configured authentication providers. + */ + @Bean + AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + /** + * Provides an in-memory UserDetailsService with a default admin user. + * In production, this should be replaced with a persistent user repository + * connected to a database. + * + * Default user: + * - username: "admin" + * - password: "admin" (bcrypt encoded) + * - roles: "USER" + */ + @Bean + UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { + return new InMemoryUserDetailsManager( + User.withUsername("admin").password(passwordEncoder.encode("admin")).roles("USER").build() + ); + } +} diff --git a/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/SecurityConfig.java b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/SecurityConfig.java new file mode 100644 index 0000000..5251b31 --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/SecurityConfig.java @@ -0,0 +1,88 @@ +package com.workastra.authentication.infrastructure.config; + +import com.workastra.authentication.infrastructure.filter.JsonLoginAuthenticationFilter; +import com.workastra.authentication.infrastructure.handler.JsonAccessDeniedHandler; +import com.workastra.authentication.infrastructure.handler.JsonAuthFailureHandler; +import com.workastra.authentication.infrastructure.handler.JsonAuthSuccessHandler; +import com.workastra.authentication.infrastructure.handler.JsonAuthenticationEntryPoint; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import tools.jackson.databind.ObjectMapper; + +/** + * SecurityConfig configures Spring Security for the authentication module. + * It sets up the HTTP security chain, CSRF protection, authorization rules, + * and integrates custom JSON-based authentication handlers. + */ +@Configuration +public class SecurityConfig { + + private final JsonAccessDeniedHandler accessDeniedHandler; + private final JsonAuthenticationEntryPoint authenticationEntryPoint; + + public SecurityConfig( + JsonAuthenticationEntryPoint authenticationEntryPoint, + JsonAccessDeniedHandler accessDeniedHandler + ) { + this.authenticationEntryPoint = authenticationEntryPoint; + this.accessDeniedHandler = accessDeniedHandler; + } + + /** + * Configures the security filter chain for HTTP requests. + * + * - Enables CSRF protection for Single Page Applications (SPA) + * - Permits public endpoints: /actuator/health, /api/v1/csrf, /api/v1/auth/login + * - Requires authentication for all other /api/** endpoints + * - Denies all other requests + * - Uses custom JSON authentication filter instead of form login + * - Registers custom exception handlers for unauthorized and forbidden access + */ + @Bean + SecurityFilterChain securityFilterChain( + HttpSecurity http, + AuthenticationManager authenticationManager, + ObjectMapper objectMapper + ) throws Exception { + var jsonLoginAuthenticationFilter = new JsonLoginAuthenticationFilter(authenticationManager, objectMapper); + jsonLoginAuthenticationFilter.setFilterProcessesUrl("/api/v1/auth/login"); + jsonLoginAuthenticationFilter.setAuthenticationSuccessHandler(new JsonAuthSuccessHandler(objectMapper)); + jsonLoginAuthenticationFilter.setAuthenticationFailureHandler(new JsonAuthFailureHandler(objectMapper)); + + var sessionRepo = new HttpSessionSecurityContextRepository(); + jsonLoginAuthenticationFilter.setSecurityContextRepository(sessionRepo); + + http + .csrf((csrf) -> csrf.spa()) + .authorizeHttpRequests((auth) -> + auth + .requestMatchers(HttpMethod.GET, "/actuator/health") + .permitAll() + .requestMatchers("/api/v1/csrf") + .permitAll() + .requestMatchers("/api/v1/auth/login") + .permitAll() + .requestMatchers("/api/**") + .authenticated() + .anyRequest() + .denyAll() + ) + .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .formLogin((c) -> c.disable()) + .httpBasic((c) -> c.disable()) + .addFilterAt(jsonLoginAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling((ex) -> { + ex.authenticationEntryPoint(this.authenticationEntryPoint); + ex.accessDeniedHandler(this.accessDeniedHandler); + }); + + return http.build(); + } +} diff --git a/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/package-info.java b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/package-info.java new file mode 100644 index 0000000..e20febc --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/config/package-info.java @@ -0,0 +1,10 @@ +/** + * Infrastructure layer configuration for authentication and security. + * + * This package contains Spring configuration classes that set up security beans, + * filters, and the HTTP security filter chain. + * + * - SecurityConfig: Configures the HTTP security filter chain + * - PasswordEncoderConfig: Defines password encoding and authentication manager beans + */ +package com.workastra.authentication.infrastructure.config; diff --git a/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/filter/JsonLoginAuthenticationFilter.java b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/filter/JsonLoginAuthenticationFilter.java new file mode 100644 index 0000000..55a8043 --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/filter/JsonLoginAuthenticationFilter.java @@ -0,0 +1,61 @@ +package com.workastra.authentication.infrastructure.filter; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import tools.jackson.databind.ObjectMapper; + +/** + * JsonLoginAuthenticationFilter is a custom authentication filter that processes + * login requests containing username and password in JSON format. It extends + * the UsernamePasswordAuthenticationFilter to handle authentication via a JSON + * payload instead of traditional form data. + * + * This filter expects a POST request with a JSON body that includes the following + * fields: + * - username: The username of the user attempting to authenticate. + * - password: The password of the user attempting to authenticate. + * + * If the request method is not POST, an AuthenticationServiceException is thrown. + * Upon successful authentication, it returns an Authentication object; otherwise, + * it returns null in case of an IOException during the reading of the request input. + */ +public class JsonLoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + record LoginRequest(String username, String password) {} + + private final ObjectMapper objectMapper; + + public JsonLoginAuthenticationFilter(AuthenticationManager authenticationManager, ObjectMapper objectMapper) { + super(authenticationManager); + this.objectMapper = objectMapper; + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException { + if (!request.getMethod().equals(HttpMethod.POST.name())) { + throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); + } + + try { + var loginRequest = this.objectMapper.readValue(request.getInputStream(), LoginRequest.class); + var authentication = UsernamePasswordAuthenticationToken.unauthenticated( + loginRequest.username(), + loginRequest.password() + ); + setDetails(request, authentication); + return getAuthenticationManager().authenticate(authentication); + } catch (IOException e) { + throw new BadCredentialsException("Invalid login request", e); + } + } +} diff --git a/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/filter/package-info.java b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/filter/package-info.java new file mode 100644 index 0000000..c6cbf65 --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/filter/package-info.java @@ -0,0 +1,9 @@ +/** + * Infrastructure layer filters for custom authentication processing. + * + * This package contains Spring Security filters that process authentication + * requests before they reach the standard authentication flow. + * + * - JsonLoginAuthenticationFilter: Processes JSON login requests + */ +package com.workastra.authentication.infrastructure.filter; diff --git a/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAccessDeniedHandler.java b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAccessDeniedHandler.java new file mode 100644 index 0000000..cf11985 --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAccessDeniedHandler.java @@ -0,0 +1,46 @@ +package com.workastra.authentication.infrastructure.handler; + +import com.workastra.common.api.ApiResponse; +import com.workastra.common.api.Meta; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import tools.jackson.databind.ObjectMapper; + +/** + * JsonAccessDeniedHandler handles access denied (forbidden) access by returning + * a JSON response with a 403 status code. + */ +public class JsonAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper mapper; + + public JsonAccessDeniedHandler(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response + .getWriter() + .write( + this.mapper.writeValueAsString( + ApiResponse.error( + "FORBIDDEN", + "You do not have permission to access this resource.", + Meta.fromCurrentRequest() + ) + ) + ); + } +} diff --git a/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthFailureHandler.java b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthFailureHandler.java new file mode 100644 index 0000000..3ca02ea --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthFailureHandler.java @@ -0,0 +1,37 @@ +package com.workastra.authentication.infrastructure.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import tools.jackson.databind.ObjectMapper; + +/** + * JsonAuthFailureHandler handles authentication failures by returning + * a JSON response with error details. + */ +public class JsonAuthFailureHandler implements AuthenticationFailureHandler { + + private final ObjectMapper objectMapper; + + public JsonAuthFailureHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException ex + ) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + var body = Map.of("success", false, "error", ex.getMessage()); + + response.getWriter().write(this.objectMapper.writeValueAsString(body)); + } +} diff --git a/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthSuccessHandler.java b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthSuccessHandler.java new file mode 100644 index 0000000..091281e --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthSuccessHandler.java @@ -0,0 +1,37 @@ +package com.workastra.authentication.infrastructure.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import tools.jackson.databind.ObjectMapper; + +/** + * JsonAuthSuccessHandler handles successful authentication by returning + * a JSON response containing the authenticated user's username. + */ +public class JsonAuthSuccessHandler implements AuthenticationSuccessHandler { + + private final ObjectMapper objectMapper; + + public JsonAuthSuccessHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + var body = Map.of("success", true, "username", authentication.getName()); + + response.getWriter().write(this.objectMapper.writeValueAsString(body)); + } +} diff --git a/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthenticationEntryPoint.java b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthenticationEntryPoint.java new file mode 100644 index 0000000..80ee77d --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/JsonAuthenticationEntryPoint.java @@ -0,0 +1,43 @@ +package com.workastra.authentication.infrastructure.handler; + +import com.workastra.common.api.ApiResponse; +import com.workastra.common.api.Meta; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import tools.jackson.databind.ObjectMapper; + +/** + * JsonAuthenticationEntryPoint handles unauthorized access by returning + * a JSON response with a 401 status code. + */ +public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper mapper; + + public JsonAuthenticationEntryPoint(ObjectMapper mapper) { + this.mapper = mapper; + } + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + ApiResponse body = ApiResponse.error( + "UNAUTHORIZED", + "Authentication is required to access this resource.", + Meta.fromCurrentRequest() + ); + + response.getWriter().write(mapper.writeValueAsString(body)); + } +} diff --git a/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/package-info.java b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/package-info.java new file mode 100644 index 0000000..50db100 --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/infrastructure/handler/package-info.java @@ -0,0 +1,12 @@ +/** + * Infrastructure layer handlers for security exceptions and authentication events. + * + * This package contains Spring Security handlers that process authentication + * and authorization outcomes, returning standardized JSON responses. + * + * - JsonAuthenticationEntryPoint: Handles 401 Unauthorized responses + * - JsonAccessDeniedHandler: Handles 403 Forbidden responses + * - JsonAuthSuccessHandler: Handles successful authentication + * - JsonAuthFailureHandler: Handles authentication failures + */ +package com.workastra.authentication.infrastructure.handler; diff --git a/modules/authentication/src/main/java/com/workastra/authentication/package-info.java b/modules/authentication/src/main/java/com/workastra/authentication/package-info.java new file mode 100644 index 0000000..b93d2e3 --- /dev/null +++ b/modules/authentication/src/main/java/com/workastra/authentication/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.workastra.authentication; + +import org.jspecify.annotations.NullMarked; diff --git a/modules/common/build.gradle.kts b/modules/common/build.gradle.kts index a332c7e..1eae621 100644 --- a/modules/common/build.gradle.kts +++ b/modules/common/build.gradle.kts @@ -5,5 +5,5 @@ plugins { dependencies { implementation(platform(libs.spring.boot.bom)) - implementation("org.springframework.boot:spring-boot-starter") -} \ No newline at end of file + api("org.springframework.boot:spring-boot-starter-webmvc") +} diff --git a/modules/common/src/main/java/com/workastra/common/api/ApiResponse.java b/modules/common/src/main/java/com/workastra/common/api/ApiResponse.java new file mode 100644 index 0000000..bfc8f63 --- /dev/null +++ b/modules/common/src/main/java/com/workastra/common/api/ApiResponse.java @@ -0,0 +1,55 @@ +package com.workastra.common.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Standardized wrapper for all API responses. + *

+ * This record provides a consistent schema for representing both successful + * and failed operations, including payload, error details, and technical + * metadata. It is designed to be version-stable so client applications can + * reliably perform branching logic based on the response fields. + * + * @param success indicates whether the request completed successfully; + * {@code true} means success, {@code false} means an error occurred + * @param code machine-readable status or error code, stable across versions; + * examples: {@code "OK"}, {@code "VALIDATION_ERROR"}, + * {@code "AUTH_FAILED"} + * @param message human-readable message for UI or logs; typically {@code null} + * for successful responses and populated for user-facing errors + * @param data business payload of the response; non-null only when + * {@code success == true} + * @param errors detailed validation or field-level errors; present when + * {@code code == "VALIDATION_ERROR"}, otherwise {@code null} + * @param meta technical metadata for the response such as request ID, + * timestamp, schema version, and pagination info + * + * @param the type of the business payload returned in {@code data} + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiResponse( + boolean success, + String code, + @Nullable String message, + @Nullable T data, + @Nullable List errors, + Meta meta +) { + public static ApiResponse ok() { + return new ApiResponse<>(true, "OK", null, null, null, Meta.fromCurrentRequest()); + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, "OK", null, data, null, Meta.fromCurrentRequest()); + } + + public static ApiResponse error(String code, String message, @Nullable List errors, Meta meta) { + return new ApiResponse<>(false, code, message, null, errors, meta); + } + + public static ApiResponse error(String code, String message, Meta meta) { + return error(code, message, null, meta); + } +} diff --git a/modules/common/src/main/java/com/workastra/common/api/ErrorItem.java b/modules/common/src/main/java/com/workastra/common/api/ErrorItem.java new file mode 100644 index 0000000..d9ba0f9 --- /dev/null +++ b/modules/common/src/main/java/com/workastra/common/api/ErrorItem.java @@ -0,0 +1,3 @@ +package com.workastra.common.api; + +public record ErrorItem(String field, String code, String message) {} diff --git a/modules/common/src/main/java/com/workastra/common/api/Meta.java b/modules/common/src/main/java/com/workastra/common/api/Meta.java new file mode 100644 index 0000000..ed94ab9 --- /dev/null +++ b/modules/common/src/main/java/com/workastra/common/api/Meta.java @@ -0,0 +1,71 @@ +package com.workastra.common.api; + +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Instant; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record Meta( + String requestId, + String timestamp, // ISO-8601 + @Nullable Pagination pagination +) { + public static Meta fromCurrentRequest() { + HttpServletRequest req = currentRequest(); + + String requestId = resolveRequestId(req); + String timestamp = Instant.now().toString(); // always ISO-8601 UTC + // Pagination pagination = resolvePagination(req); + + return new Meta(requestId, timestamp, null); + } + + private static HttpServletRequest currentRequest() { + ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + if (attr == null) { + throw new IllegalStateException( + "No current HTTP request — Meta.fromCurrentRequest() called outside web context" + ); + } + + return attr.getRequest(); + } + + private static String resolveRequestId(HttpServletRequest req) { + // 1. Prefer header: X-Request-Id + String id = req.getHeader("X-Request-Id"); + if (id != null && !id.isBlank()) { + return id; + } + + // 2. Try Slf4j MDC (used by many log systems) + id = org.slf4j.MDC.get("requestId"); + if (id != null && !id.isBlank()) { + return id; + } + + // 3. Generate a new one + return UUID.randomUUID().toString(); + } + + // private static Pagination resolvePagination(HttpServletRequest req) { + // String page = req.getParameter("page"); + // String size = req.getParameter("size"); + + // if (page == null || size == null) return null; + + // try { + // return new Pagination( + // Integer.parseInt(page), + // Integer.parseInt(size) + // ); + // } catch (NumberFormatException ex) { + // return null; + // } + // } +} diff --git a/modules/common/src/main/java/com/workastra/common/api/Pagination.java b/modules/common/src/main/java/com/workastra/common/api/Pagination.java new file mode 100644 index 0000000..bd3d7c1 --- /dev/null +++ b/modules/common/src/main/java/com/workastra/common/api/Pagination.java @@ -0,0 +1,3 @@ +package com.workastra.common.api; + +public record Pagination(int page, int pageSize, long totalItems, int totalPages) {} diff --git a/modules/common/src/main/java/com/workastra/common/api/package-info.java b/modules/common/src/main/java/com/workastra/common/api/package-info.java new file mode 100644 index 0000000..a503085 --- /dev/null +++ b/modules/common/src/main/java/com/workastra/common/api/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.workastra.common.api; + +import org.jspecify.annotations.NullMarked; diff --git a/modules/console/build.gradle.kts b/modules/console/build.gradle.kts index 32a786c..a9fe941 100644 --- a/modules/console/build.gradle.kts +++ b/modules/console/build.gradle.kts @@ -5,10 +5,18 @@ plugins { alias(libs.plugins.graalvm.native) } +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + dependencies { implementation(project(":modules:common")) + implementation(project(":modules:authentication")) implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-webmvc") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") testImplementation("org.springframework.boot:spring-boot-starter-actuator-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/modules/console/src/main/java/com/workastra/console/WorkastraConsoleApplication.java b/modules/console/src/main/java/com/workastra/console/WorkastraConsoleApplication.java index 1f3bf62..5baf424 100644 --- a/modules/console/src/main/java/com/workastra/console/WorkastraConsoleApplication.java +++ b/modules/console/src/main/java/com/workastra/console/WorkastraConsoleApplication.java @@ -3,7 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -@SpringBootApplication(scanBasePackages = { "com.workastra.console", "com.workastra.common" }) +@SpringBootApplication(scanBasePackages = "com.workastra") public class WorkastraConsoleApplication { public static void main(String[] args) { diff --git a/modules/console/src/main/java/com/workastra/console/package-info.java b/modules/console/src/main/java/com/workastra/console/package-info.java new file mode 100644 index 0000000..4b36cf1 --- /dev/null +++ b/modules/console/src/main/java/com/workastra/console/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.workastra.console; + +import org.jspecify.annotations.NullMarked; diff --git a/modules/console/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/modules/console/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..f54c762 --- /dev/null +++ b/modules/console/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,19 @@ +{ + "properties": [ + { + "name": "workastra.jwt.issuer", + "type": "java.lang.String", + "description": "The issuer claim value for JWT tokens" + }, + { + "name": "workastra.jwt.secret", + "type": "java.lang.String", + "description": "The secret key used to sign and verify JWT tokens" + }, + { + "name": "workastra.jwt.ttl", + "type": "java.lang.Integer", + "description": "The JWT token expiration time (in seconds or duration format)" + } + ] +} diff --git a/modules/console/src/main/resources/application-dev.yaml b/modules/console/src/main/resources/application-dev.yaml new file mode 100644 index 0000000..23082a2 --- /dev/null +++ b/modules/console/src/main/resources/application-dev.yaml @@ -0,0 +1,6 @@ +--- +logging: + level: + org: + springframework: + security: DEBUG diff --git a/modules/console/src/main/resources/application-test.yaml b/modules/console/src/main/resources/application-test.yaml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/modules/console/src/main/resources/application-test.yaml @@ -0,0 +1 @@ +--- diff --git a/modules/console/src/main/resources/application.yaml b/modules/console/src/main/resources/application.yaml index b21391c..b7ef2bf 100644 --- a/modules/console/src/main/resources/application.yaml +++ b/modules/console/src/main/resources/application.yaml @@ -1,10 +1,4 @@ --- -logging: - structured: - format: - console: logstash - file: logstash - spring: application: name: Workastra Console @@ -12,3 +6,11 @@ spring: banner-mode: "off" jmx: enabled: false + mvc: + apiversion: + use: + path-segment: 1 + web: + error: + whitelabel: + enabled: false diff --git a/modules/worker/src/main/java/com/workastra/worker/package-info.java b/modules/worker/src/main/java/com/workastra/worker/package-info.java new file mode 100644 index 0000000..bd783fa --- /dev/null +++ b/modules/worker/src/main/java/com/workastra/worker/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.workastra.worker; + +import org.jspecify.annotations.NullMarked; diff --git a/settings.gradle.kts b/settings.gradle.kts index 523d9f4..23a89b2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,8 @@ rootProject.name = "workastra-server" include( - "modules:common", - "modules:console", - "modules:worker", -) \ No newline at end of file + "modules:common", + "modules:authentication", + "modules:console", + "modules:worker", +)