;
- beforeEach(async(() => {
+ beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
BrowserAnimationsModule,
diff --git a/frontend/src/app/signup/signup.component.ts b/frontend/src/app/signup/signup.component.ts
index aa0b254a6..9c02baf64 100644
--- a/frontend/src/app/signup/signup.component.ts
+++ b/frontend/src/app/signup/signup.component.ts
@@ -7,9 +7,10 @@ import {Subject} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
@Component({
- selector: 'app-signup',
- templateUrl: './signup.component.html',
- styleUrls: ['./signup.component.scss']
+ selector: 'app-signup',
+ templateUrl: './signup.component.html',
+ styleUrls: ['./signup.component.scss'],
+ standalone: false
})
export class SignupComponent implements OnInit, OnDestroy {
title = 'Sign up';
diff --git a/frontend/src/karma.conf.js b/frontend/src/karma.conf.js
index a2e232329..49a62cfaa 100644
--- a/frontend/src/karma.conf.js
+++ b/frontend/src/karma.conf.js
@@ -9,16 +9,20 @@ module.exports = function (config) {
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
- require('karma-coverage-istanbul-reporter'),
+ require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
- coverageIstanbulReporter: {
+ coverageReporter: {
dir: require('path').join(__dirname, '../coverage/angular-spring-starter'),
- reports: ['html', 'lcovonly', 'text-summary'],
- fixWebpackSourcePaths: true
+ subdir: '.',
+ reporters: [
+ { type: 'html' },
+ { type: 'text-summary' },
+ { type: 'lcovonly' }
+ ]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
diff --git a/frontend/src/polyfills.ts b/frontend/src/polyfills.ts
index dc03a7ed3..5997188a0 100644
--- a/frontend/src/polyfills.ts
+++ b/frontend/src/polyfills.ts
@@ -1,63 +1,4 @@
/**
- * This file includes polyfills needed by Angular and is loaded before the app.
- * You can add your own extra polyfills to this file.
- *
- * This file is divided into 2 sections:
- * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
- * 2. Application imports. Files imported after ZoneJS that should be loaded before your main
- * file.
- *
- * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
- * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
- * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
- *
- * Learn more in https://angular.io/guide/browser-support
- */
-
-/***************************************************************************************************
- * BROWSER POLYFILLS
- */
-
-/** IE10 and IE11 requires the following for NgClass support on SVG elements */
-// import 'classlist.js'; // Run `npm install --save classlist.js`.
-
-/**
- * Web Animations `@angular/platform-browser/animations`
- * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
- * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
- */
-// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
-
-/**
- * By default, zone.js will patch all possible macroTask and DomEvents
- * user can disable parts of macroTask/DomEvents patch by setting following flags
- * because those flags need to be set before `zone.js` being loaded, and webpack
- * will put import in the top of bundle, so user need to create a separate file
- * in this directory (for example: zone-flags.ts), and put the following flags
- * into that file, and then add the following code before importing zone.js.
- * import './zone-flags';
- *
- * The flags allowed in zone-flags.ts are listed here.
- *
- * The following flags will work for all browsers.
- *
- * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
- * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
- * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
- *
- * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
- * with the following flag, it will bypass `zone.js` patch for IE/Edge
- *
- * (window as any).__Zone_enable_cross_context_check = true;
- *
- */
-
-/***************************************************************************************************
- * Zone JS is required by default for Angular itself.
- */
-import 'zone.js/dist/zone'; // Included with Angular CLI.
-
-
-/***************************************************************************************************
- * APPLICATION IMPORTS
+ * This file is not needed in Angular 18+ as polyfills are configured in angular.json
+ * Keeping for compatibility during migration
*/
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index dacb3fa79..6a8e50d23 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -1,5 +1,4 @@
/* You can add global styles to this file, and also import other style files */
-@import '~@angular/material/prebuilt-themes/pink-bluegrey.css';
html, body {
height: 100%;
@@ -8,4 +7,40 @@ html, body {
body {
margin: 0;
font-family: Roboto, "Helvetica Neue", sans-serif;
+ background-color: #f5f5f5;
+}
+
+/* Custom theme colors to match the pink header and dark cards */
+:root {
+ --primary-color: #e91e63; /* Pink color for header */
+ --accent-color: #ff4081; /* Lighter pink for accents */
+ --dark-card-bg: #424242; /* Dark gray for cards */
+ --dark-card-text: #ffffff; /* White text on dark cards */
+ --light-bg: #f5f5f5; /* Light gray background */
+}
+
+/* Flexbox utility classes to replace @angular/flex-layout */
+.flex-row {
+ display: flex;
+ flex-direction: row;
+}
+
+.flex-center {
+ justify-content: center;
+ align-items: center;
+}
+
+.flex-end-center {
+ justify-content: flex-end;
+ align-items: center;
+}
+
+.flex-auto {
+ flex: 1 1 auto;
+}
+
+.flex-card {
+ flex: 1;
+ max-width: 600px;
+ margin: 1rem;
}
diff --git a/frontend/src/test.ts b/frontend/src/test.ts
index 599e3957d..fd40d3c30 100644
--- a/frontend/src/test.ts
+++ b/frontend/src/test.ts
@@ -10,8 +10,8 @@ import {getTestBed} from '@angular/core/testing';
import {BrowserDynamicTestingModule, platformBrowserDynamicTesting} from '@angular/platform-browser-dynamic/testing';
// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
-declare var __karma__: any;
-declare var require: any;
+declare let __karma__: any;
+declare let require: any;
// Prevent Karma from running prematurely.
__karma__.loaded = () => {
diff --git a/frontend/src/tsconfig.app.json b/frontend/src/tsconfig.app.json
index 190fd300b..634dd2ea1 100644
--- a/frontend/src/tsconfig.app.json
+++ b/frontend/src/tsconfig.app.json
@@ -2,10 +2,13 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
- "types": []
+ "types": [],
+ "baseUrl": "./"
},
- "exclude": [
- "test.ts",
- "**/*.spec.ts"
+ "files": [
+ "main.ts"
+ ],
+ "include": [
+ "**/*.d.ts"
]
}
diff --git a/frontend/src/tsconfig.spec.json b/frontend/src/tsconfig.spec.json
index de7733630..1a837a8db 100644
--- a/frontend/src/tsconfig.spec.json
+++ b/frontend/src/tsconfig.spec.json
@@ -3,14 +3,9 @@
"compilerOptions": {
"outDir": "../out-tsc/spec",
"types": [
- "jasmine",
- "node"
+ "jasmine"
]
},
- "files": [
- "test.ts",
- "polyfills.ts"
- ],
"include": [
"**/*.spec.ts",
"**/*.d.ts"
diff --git a/frontend/src/tslint.json b/frontend/src/tslint.json
deleted file mode 100644
index aa7c3eeb7..000000000
--- a/frontend/src/tslint.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "extends": "../tslint.json",
- "rules": {
- "directive-selector": [
- true,
- "attribute",
- "app",
- "camelCase"
- ],
- "component-selector": [
- true,
- "element",
- "app",
- "kebab-case"
- ]
- }
-}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index ca345910b..a3e224739 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -1,22 +1,34 @@
{
"compileOnSave": false,
"compilerOptions": {
- "baseUrl": "src",
"outDir": "./dist/out-tsc",
"sourceMap": true,
"declaration": false,
- "module": "es2015",
- "moduleResolution": "node",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
- "target": "es5",
+ "target": "ES2022",
+ "useDefineForClassFields": false,
"typeRoots": [
"node_modules/@types"
],
"lib": [
- "es2018",
+ "ES2022",
"dom"
- ]
+ ],
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": false,
+ "forceConsistentCasingInFileNames": true
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": false
}
}
diff --git a/frontend/tslint.json b/frontend/tslint.json
deleted file mode 100644
index 4f3fc6636..000000000
--- a/frontend/tslint.json
+++ /dev/null
@@ -1,74 +0,0 @@
-{
- "extends": "tslint:recommended",
- "rulesDirectory": [
- "codelyzer"
- ],
- "rules": {
- "array-type": false,
- "arrow-parens": false,
- "deprecation": {
- "severity": "warn"
- },
- "import-blacklist": [
- true,
- "rxjs/Rx"
- ],
- "interface-name": false,
- "max-classes-per-file": false,
- "max-line-length": [
- true,
- 140
- ],
- "member-access": false,
- "member-ordering": [
- true,
- {
- "order": [
- "static-field",
- "instance-field",
- "static-method",
- "instance-method"
- ]
- }
- ],
- "no-consecutive-blank-lines": false,
- "no-console": [
- true,
- "debug",
- "info",
- "time",
- "timeEnd",
- "trace"
- ],
- "no-empty": false,
- "no-inferrable-types": [
- true,
- "ignore-params"
- ],
- "no-non-null-assertion": true,
- "no-redundant-jsdoc": true,
- "no-switch-case-fall-through": true,
- "no-var-requires": false,
- "object-literal-key-quotes": [
- true,
- "as-needed"
- ],
- "object-literal-sort-keys": false,
- "ordered-imports": false,
- "quotemark": [
- true,
- "single"
- ],
- "trailing-comma": false,
- "no-output-on-prefix": true,
- "no-inputs-metadata-property": false,
- "no-outputs-metadata-property": false,
- "no-host-metadata-property": false,
- "no-input-rename": true,
- "no-output-rename": true,
- "use-life-cycle-interface": true,
- "use-pipe-transform-interface": true,
- "component-class-suffix": true,
- "directive-class-suffix": true
- }
-}
diff --git a/server/.mvn/wrapper/maven-wrapper.properties b/server/.mvn/wrapper/maven-wrapper.properties
index c954cec91..11213b0a9 100644
--- a/server/.mvn/wrapper/maven-wrapper.properties
+++ b/server/.mvn/wrapper/maven-wrapper.properties
@@ -1 +1 @@
-distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.3.9/apache-maven-3.3.9-bin.zip
+distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
diff --git a/server/pom.xml b/server/pom.xml
index 42c2e1a16..08eaf2cb4 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -14,14 +14,14 @@
org.springframework.boot
spring-boot-starter-parent
- 2.2.6.RELEASE
+ 3.4.1
UTF-8
UTF-8
- 1.8
+ 21
@@ -37,14 +37,26 @@
org.springframework.boot
spring-boot-starter-data-jpa
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
io.jsonwebtoken
- jjwt
- 0.9.1
+ jjwt-api
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.6
+ runtime
- joda-time
- joda-time
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
com.fasterxml.jackson.core
@@ -72,7 +84,12 @@
io.rest-assured
spring-mock-mvc
- 3.0.5
+ 5.5.0
+ test
+
+
+ org.springframework.security
+ spring-security-test
test
@@ -83,6 +100,65 @@
org.springframework.boot
spring-boot-maven-plugin
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.12
+
+
+
+ prepare-agent
+
+
+
+ report
+ test
+
+ report
+
+
+
+ jacoco-check
+
+ check
+
+
+
+
+ BUNDLE
+
+
+ INSTRUCTION
+ COVEREDRATIO
+ 1.00
+
+
+ BRANCH
+ COVEREDRATIO
+ 1.00
+
+
+ LINE
+ COVEREDRATIO
+ 1.00
+
+
+ CLASS
+ COVEREDRATIO
+ 1.00
+
+
+ METHOD
+ COVEREDRATIO
+ 1.00
+
+
+
+
+
+
+
+
diff --git a/server/src/main/java/com/bfwg/common/Constants.java b/server/src/main/java/com/bfwg/common/Constants.java
new file mode 100644
index 000000000..77dd46c59
--- /dev/null
+++ b/server/src/main/java/com/bfwg/common/Constants.java
@@ -0,0 +1,107 @@
+package com.bfwg.common;
+
+/**
+ * Application-wide constants.
+ *
+ * This class centralizes all constant values used throughout the application,
+ * following the DRY (Don't Repeat Yourself) principle and making the codebase
+ * more maintainable.
+ *
+ * @since 0.2.0
+ */
+public final class Constants {
+
+ private Constants() {
+ throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
+ }
+
+ /**
+ * Security and authentication related constants.
+ */
+ public static final class Security {
+ private Security() {
+ }
+
+ public static final String ROLE_PREFIX = "ROLE_";
+ public static final String ROLE_USER = "ROLE_USER";
+ public static final String ROLE_ADMIN = "ROLE_ADMIN";
+ public static final String BEARER_PREFIX = "Bearer ";
+ public static final int MIN_PASSWORD_LENGTH = 3;
+ public static final int MAX_PASSWORD_LENGTH = 100;
+ public static final String DEFAULT_DEMO_PASSWORD = "123";
+ }
+
+ /**
+ * API endpoint path constants.
+ */
+ public static final class Api {
+ private Api() {
+ }
+
+ public static final String BASE_PATH = "/api";
+ public static final String LOGIN_PATH = "/api/login";
+ public static final String SIGNUP_PATH = "/api/signup";
+ public static final String LOGOUT_PATH = "/api/logout";
+ public static final String REFRESH_PATH = "/api/refresh";
+ public static final String CHANGE_PASSWORD_PATH = "/api/changePassword";
+ public static final String WHOAMI_PATH = "/api/whoami";
+ public static final String USERS_PATH = "/api/user/all";
+ public static final String USER_BY_ID_PATH = "/api/user/{userId}";
+ public static final String RESET_CREDENTIALS_PATH = "/api/user/reset-credentials";
+ }
+
+ /**
+ * HTTP status messages and response constants.
+ */
+ public static final class Messages {
+ private Messages() {
+ }
+
+ public static final String SUCCESS = "success";
+ public static final String ERROR = "error";
+ public static final String RESULT = "result";
+
+ // Error messages
+ public static final String USERNAME_REQUIRED = "Username is required";
+ public static final String PASSWORD_REQUIRED = "Password is required";
+ public static final String USERNAME_EXISTS = "Username already exists";
+ public static final String USER_NOT_FOUND = "User not found";
+ public static final String INVALID_CREDENTIALS = "Invalid credentials";
+ public static final String ACCESS_DENIED = "Access denied";
+ public static final String PASSWORD_MISMATCH = "Passwords must be different";
+ public static final String WEAK_PASSWORD = "Password does not meet strength requirements";
+ }
+
+ /**
+ * Validation constraints.
+ */
+ public static final class Validation {
+ private Validation() {
+ }
+
+ public static final int USERNAME_MIN_LENGTH = 3;
+ public static final int USERNAME_MAX_LENGTH = 100;
+ public static final int NAME_MAX_LENGTH = 100;
+ public static final int PASSWORD_MIN_LENGTH = 3;
+ public static final int PASSWORD_MAX_LENGTH = 100;
+ }
+
+ /**
+ * Database related constants.
+ */
+ public static final class Database {
+ private Database() {
+ }
+
+ public static final String TABLE_USER = "USER";
+ public static final String TABLE_AUTHORITY = "AUTHORITY";
+ public static final String TABLE_USER_AUTHORITY = "user_authority";
+ public static final String COLUMN_ID = "id";
+ public static final String COLUMN_USERNAME = "username";
+ public static final String COLUMN_PASSWORD = "password";
+ public static final String COLUMN_FIRSTNAME = "firstname";
+ public static final String COLUMN_LASTNAME = "lastname";
+ public static final String COLUMN_NAME = "name";
+ }
+}
+
diff --git a/server/src/main/java/com/bfwg/config/WebSecurityConfig.java b/server/src/main/java/com/bfwg/config/WebSecurityConfig.java
index d489278d6..5e2075042 100644
--- a/server/src/main/java/com/bfwg/config/WebSecurityConfig.java
+++ b/server/src/main/java/com/bfwg/config/WebSecurityConfig.java
@@ -1,114 +1,164 @@
package com.bfwg.config;
-import com.bfwg.model.User;
import com.bfwg.security.auth.*;
import com.bfwg.service.impl.CustomUserDetailsService;
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
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.UsernamePasswordAuthenticationToken;
-import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
-import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
-import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
-import org.springframework.security.core.Authentication;
-import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import java.util.Objects;
+
/**
- * Created by fan.jin on 2016-10-19.
+ * Spring Security configuration for JWT-based authentication.
+ *
+ * This configuration class follows Clean Code principles:
+ *
+ * - Single Responsibility: Focuses only on security configuration
+ * - Dependency Inversion: Depends on abstractions via constructor injection
+ * - Clear method names that express intent
+ * - Proper separation of concerns (password logic moved to PasswordService)
+ *
+ *
+ * @author fan.jin
+ * @since 2016-10-19
*/
-
@Configuration
-@EnableGlobalMethodSecurity(prePostEnabled = true)
-public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
-
- protected final Log LOGGER = LogFactory.getLog(getClass());
-
- private final CustomUserDetailsService jwtUserDetailsService;
- private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
- private final LogoutSuccess logoutSuccess;
- private final AuthenticationSuccessHandler authenticationSuccessHandler;
- private final AuthenticationFailureHandler authenticationFailureHandler;
- @Value("${jwt.cookie}")
- private String TOKEN_COOKIE;
-
- @Autowired
- public WebSecurityConfig(CustomUserDetailsService jwtUserDetailsService, RestAuthenticationEntryPoint restAuthenticationEntryPoint, LogoutSuccess logoutSuccess, AuthenticationSuccessHandler authenticationSuccessHandler, AuthenticationFailureHandler authenticationFailureHandler) {
- this.jwtUserDetailsService = jwtUserDetailsService;
- this.restAuthenticationEntryPoint = restAuthenticationEntryPoint;
- this.logoutSuccess = logoutSuccess;
- this.authenticationSuccessHandler = authenticationSuccessHandler;
- this.authenticationFailureHandler = authenticationFailureHandler;
- }
-
- @Bean
- public TokenAuthenticationFilter jwtAuthenticationTokenFilter() throws Exception {
- return new TokenAuthenticationFilter();
- }
+@EnableWebSecurity
+@EnableMethodSecurity(prePostEnabled = true)
+public class WebSecurityConfig {
+
+ private static final Logger logger = LoggerFactory.getLogger(WebSecurityConfig.class);
+
+ private final CustomUserDetailsService jwtUserDetailsService;
+ private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
+ private final LogoutSuccess logoutSuccess;
+ private final AuthenticationSuccessHandler authenticationSuccessHandler;
+ private final AuthenticationFailureHandler authenticationFailureHandler;
+
+ @Value("${jwt.cookie}")
+ private String tokenCookie;
+
+ /**
+ * Constructs the security configuration with required dependencies.
+ *
+ * @param jwtUserDetailsService the user details service
+ * @param restAuthenticationEntryPoint the authentication entry point
+ * @param logoutSuccess the logout success handler
+ * @param authenticationSuccessHandler the authentication success handler
+ * @param authenticationFailureHandler the authentication failure handler
+ */
+ @Autowired
+ public WebSecurityConfig(
+ CustomUserDetailsService jwtUserDetailsService,
+ RestAuthenticationEntryPoint restAuthenticationEntryPoint,
+ LogoutSuccess logoutSuccess,
+ AuthenticationSuccessHandler authenticationSuccessHandler,
+ AuthenticationFailureHandler authenticationFailureHandler) {
+ this.jwtUserDetailsService = Objects.requireNonNull(jwtUserDetailsService, "UserDetailsService must not be null");
+ this.restAuthenticationEntryPoint = Objects.requireNonNull(restAuthenticationEntryPoint, "EntryPoint must not be null");
+ this.logoutSuccess = Objects.requireNonNull(logoutSuccess, "LogoutSuccess must not be null");
+ this.authenticationSuccessHandler = Objects.requireNonNull(authenticationSuccessHandler, "AuthenticationSuccessHandler must not be null");
+ this.authenticationFailureHandler = Objects.requireNonNull(authenticationFailureHandler, "AuthenticationFailureHandler must not be null");
+ }
- @Bean
- @Override
- public AuthenticationManager authenticationManagerBean() throws Exception {
- return super.authenticationManagerBean();
- }
+ /**
+ * Creates the JWT authentication filter bean.
+ *
+ * @return the token authentication filter
+ */
+ @Bean
+ public TokenAuthenticationFilter jwtAuthenticationTokenFilter() {
+ return new TokenAuthenticationFilter();
+ }
- @Bean
- public PasswordEncoder passwordEncoder() {
- return new BCryptPasswordEncoder();
- }
+ /**
+ * Exposes the authentication manager as a bean.
+ *
+ * @param authConfig the authentication configuration
+ * @return the authentication manager
+ * @throws Exception if authentication manager cannot be created
+ */
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
+ return authConfig.getAuthenticationManager();
+ }
- @Autowired
- public void configureGlobal(AuthenticationManagerBuilder authenticationManagerBuilder)
- throws Exception {
- authenticationManagerBuilder.userDetailsService(jwtUserDetailsService)
- .passwordEncoder(passwordEncoder());
+ /**
+ * Configures the DAO authentication provider.
+ *
+ * @return the configured authentication provider
+ */
+ @Bean
+ public DaoAuthenticationProvider authenticationProvider() {
+ DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
+ authProvider.setUserDetailsService(jwtUserDetailsService);
+ authProvider.setPasswordEncoder(passwordEncoder());
+ return authProvider;
+ }
- }
+ /**
+ * Creates the password encoder bean.
+ *
+ * @return the BCrypt password encoder
+ */
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
- @Override
- protected void configure(HttpSecurity http) throws Exception {
- http.csrf().ignoringAntMatchers("/api/login", "/api/signup")
- .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).and()
- .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
- .exceptionHandling().authenticationEntryPoint(restAuthenticationEntryPoint).and()
+ /**
+ * Configures the security filter chain.
+ *
+ * @param http the HTTP security configuration
+ * @return the configured security filter chain
+ * @throws Exception if configuration fails
+ */
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ http
+ .csrf(csrf -> csrf
+ .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
+ .ignoringRequestMatchers("/api/login", "/api/signup")
+ )
+ .sessionManagement(session -> session
+ .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
+ )
+ .exceptionHandling(exception -> exception
+ .authenticationEntryPoint(restAuthenticationEntryPoint)
+ )
+ .authorizeHttpRequests(auth -> auth
+ .anyRequest().authenticated()
+ )
+ .authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationTokenFilter(), BasicAuthenticationFilter.class)
- .authorizeRequests().anyRequest().authenticated().and().formLogin().loginPage("/api/login")
- .successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler)
- .and().logout().logoutRequestMatcher(new AntPathRequestMatcher("/api/logout"))
- .logoutSuccessHandler(logoutSuccess).deleteCookies(TOKEN_COOKIE);
-
- }
-
- public void changePassword(String oldPassword, String newPassword) throws Exception {
-
- Authentication currentUser = SecurityContextHolder.getContext().getAuthentication();
- String username = currentUser.getName();
-
- if (authenticationManagerBean() != null) {
- LOGGER.debug("Re-authenticating user '" + username + "' for password change request.");
-
- authenticationManagerBean().authenticate(new UsernamePasswordAuthenticationToken(username, oldPassword));
- } else {
- LOGGER.debug("No authentication manager set. can't change Password!");
-
- return;
+ .formLogin(form -> form
+ .loginPage("/api/login")
+ .successHandler(authenticationSuccessHandler)
+ .failureHandler(authenticationFailureHandler)
+ )
+ .logout(logout -> logout
+ .logoutRequestMatcher(new AntPathRequestMatcher("/api/logout"))
+ .logoutSuccessHandler(logoutSuccess)
+ .deleteCookies(tokenCookie)
+ );
+
+ logger.info("Security filter chain configured successfully");
+ return http.build();
}
-
- LOGGER.debug("Changing password for user '" + username + "'");
-
- User user = jwtUserDetailsService.loadUserByUsername(username);
-
- user.setPassword(new BCryptPasswordEncoder().encode(newPassword));
- jwtUserDetailsService.save(user);
- }
}
diff --git a/server/src/main/java/com/bfwg/exception/AuthenticationException.java b/server/src/main/java/com/bfwg/exception/AuthenticationException.java
new file mode 100644
index 000000000..d1443600e
--- /dev/null
+++ b/server/src/main/java/com/bfwg/exception/AuthenticationException.java
@@ -0,0 +1,32 @@
+package com.bfwg.exception;
+
+/**
+ * Base exception for authentication-related errors.
+ *
+ * This exception serves as the parent for all authentication-related
+ * exceptions in the application, following the exception hierarchy pattern.
+ *
+ * @since 0.2.0
+ */
+public class AuthenticationException extends RuntimeException {
+
+ /**
+ * Constructs a new authentication exception with the specified detail message.
+ *
+ * @param message the detail message
+ */
+ public AuthenticationException(String message) {
+ super(message);
+ }
+
+ /**
+ * Constructs a new authentication exception with the specified detail message and cause.
+ *
+ * @param message the detail message
+ * @param cause the cause
+ */
+ public AuthenticationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
+
diff --git a/server/src/main/java/com/bfwg/exception/InvalidPasswordException.java b/server/src/main/java/com/bfwg/exception/InvalidPasswordException.java
new file mode 100644
index 000000000..ee4491029
--- /dev/null
+++ b/server/src/main/java/com/bfwg/exception/InvalidPasswordException.java
@@ -0,0 +1,65 @@
+package com.bfwg.exception;
+
+/**
+ * Exception thrown when password validation or verification fails.
+ *
+ * This exception provides specific feedback about password-related errors,
+ * helping users understand what went wrong.
+ *
+ * @since 0.2.0
+ */
+public class InvalidPasswordException extends RuntimeException {
+
+ private final PasswordErrorType errorType;
+
+ /**
+ * Types of password validation errors.
+ */
+ public enum PasswordErrorType {
+ TOO_SHORT("Password is too short"),
+ TOO_LONG("Password is too long"),
+ INCORRECT("Current password is incorrect"),
+ SAME_AS_OLD("New password must be different from old password"),
+ WEAK("Password does not meet strength requirements");
+
+ private final String description;
+
+ PasswordErrorType(String description) {
+ this.description = description;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+ }
+
+ /**
+ * Constructs a new invalid password exception with the specified error type.
+ *
+ * @param errorType the type of password error
+ */
+ public InvalidPasswordException(PasswordErrorType errorType) {
+ super(errorType.getDescription());
+ this.errorType = errorType;
+ }
+
+ /**
+ * Constructs a new invalid password exception with a custom message.
+ *
+ * @param message the detail message
+ */
+ public InvalidPasswordException(String message) {
+ super(message);
+ this.errorType = PasswordErrorType.WEAK;
+ }
+
+ /**
+ * Gets the error type.
+ *
+ * @return the password error type
+ */
+ public PasswordErrorType getErrorType() {
+ return errorType;
+ }
+}
+
diff --git a/server/src/main/java/com/bfwg/exception/InvalidTokenException.java b/server/src/main/java/com/bfwg/exception/InvalidTokenException.java
new file mode 100644
index 000000000..5ca6a88d8
--- /dev/null
+++ b/server/src/main/java/com/bfwg/exception/InvalidTokenException.java
@@ -0,0 +1,76 @@
+package com.bfwg.exception;
+
+/**
+ * Exception thrown when a JWT token is invalid, expired, or malformed.
+ *
+ * This exception provides specific information about token validation failures,
+ * enabling better error handling and user feedback.
+ *
+ * @since 0.2.0
+ */
+public class InvalidTokenException extends AuthenticationException {
+
+ private final TokenErrorType errorType;
+
+ /**
+ * Types of token validation errors.
+ */
+ public enum TokenErrorType {
+ EXPIRED("Token has expired"),
+ MALFORMED("Token is malformed"),
+ INVALID_SIGNATURE("Token signature is invalid"),
+ UNSUPPORTED("Token type is unsupported"),
+ EMPTY_CLAIMS("Token claims are empty");
+
+ private final String description;
+
+ TokenErrorType(String description) {
+ this.description = description;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+ }
+
+ /**
+ * Constructs a new invalid token exception with the specified error type.
+ *
+ * @param errorType the type of token error
+ */
+ public InvalidTokenException(TokenErrorType errorType) {
+ super(errorType.getDescription());
+ this.errorType = errorType;
+ }
+
+ /**
+ * Constructs a new invalid token exception with the specified error type and cause.
+ *
+ * @param errorType the type of token error
+ * @param cause the cause
+ */
+ public InvalidTokenException(TokenErrorType errorType, Throwable cause) {
+ super(errorType.getDescription(), cause);
+ this.errorType = errorType;
+ }
+
+ /**
+ * Constructs a new invalid token exception with a custom message.
+ *
+ * @param message the detail message
+ */
+ public InvalidTokenException(String message) {
+ super(message);
+ this.errorType = TokenErrorType.MALFORMED;
+ }
+
+ /**
+ * Gets the error type.
+ *
+ * @return the token error type
+ */
+ public TokenErrorType getErrorType() {
+ return errorType;
+ }
+}
+
diff --git a/server/src/main/java/com/bfwg/exception/UserNotFoundException.java b/server/src/main/java/com/bfwg/exception/UserNotFoundException.java
new file mode 100644
index 000000000..6787a7193
--- /dev/null
+++ b/server/src/main/java/com/bfwg/exception/UserNotFoundException.java
@@ -0,0 +1,44 @@
+package com.bfwg.exception;
+
+/**
+ * Exception thrown when a requested user cannot be found.
+ *
+ * This exception provides a clear, domain-specific error for user lookup failures,
+ * improving error handling and API responses.
+ *
+ * @since 0.2.0
+ */
+public class UserNotFoundException extends RuntimeException {
+
+ private final Object identifier;
+
+ /**
+ * Constructs a new user not found exception with the specified username.
+ *
+ * @param username the username that was not found
+ */
+ public UserNotFoundException(String username) {
+ super(String.format("User not found with username: %s", username));
+ this.identifier = username;
+ }
+
+ /**
+ * Constructs a new user not found exception with the specified user ID.
+ *
+ * @param userId the user ID that was not found
+ */
+ public UserNotFoundException(Long userId) {
+ super(String.format("User not found with ID: %d", userId));
+ this.identifier = userId;
+ }
+
+ /**
+ * Gets the identifier (username or ID) that was not found.
+ *
+ * @return the identifier
+ */
+ public Object getIdentifier() {
+ return identifier;
+ }
+}
+
diff --git a/server/src/main/java/com/bfwg/model/Authority.java b/server/src/main/java/com/bfwg/model/Authority.java
index 675b3f8eb..66455097a 100644
--- a/server/src/main/java/com/bfwg/model/Authority.java
+++ b/server/src/main/java/com/bfwg/model/Authority.java
@@ -3,46 +3,97 @@
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.springframework.security.core.GrantedAuthority;
-import javax.persistence.*;
+import jakarta.persistence.*;
+import java.util.Objects;
/**
- * Created by fan.jin on 2016-11-03.
+ * Represents a security authority/role in the system.
+ * Implements {@link GrantedAuthority} to integrate with Spring Security.
+ *
+ * This entity follows the Single Responsibility Principle by solely representing
+ * a user's authority/role in the system.
+ *
+ * @author fan.jin
+ * @since 2016-11-03
*/
-
@Entity
@Table(name = "AUTHORITY")
public class Authority implements GrantedAuthority {
@Id
- @Column(name = "id")
+ @Column(name = "id", nullable = false, updatable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
- @Column(name = "name")
+ @Column(name = "name", nullable = false, length = 50)
private UserRoleName name;
+ /**
+ * Default constructor for JPA.
+ */
+ protected Authority() {
+ // Required by JPA
+ }
+
+ /**
+ * Creates a new Authority with the specified role name.
+ *
+ * @param name the role name, must not be null
+ * @throws IllegalArgumentException if name is null
+ */
+ public Authority(UserRoleName name) {
+ this.name = Objects.requireNonNull(name, "Authority name must not be null");
+ }
+
+ /**
+ * Returns the authority string representation for Spring Security.
+ *
+ * @return the authority name as a string
+ */
@Override
public String getAuthority() {
- return name.name();
+ return name != null ? name.name() : null;
}
+ /**
+ * Gets the role name as an enum.
+ *
+ * @return the role name enum
+ */
@JsonIgnore
public UserRoleName getName() {
return name;
}
- public void setName(UserRoleName name) {
- this.name = name;
- }
-
+ /**
+ * Gets the unique identifier.
+ *
+ * @return the id
+ */
@JsonIgnore
public Long getId() {
return id;
}
- public void setId(Long id) {
- this.id = id;
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof Authority)) return false;
+ Authority authority = (Authority) o;
+ return name == authority.name;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name);
}
+ @Override
+ public String toString() {
+ return "Authority{" +
+ "id=" + id +
+ ", name=" + name +
+ '}';
+ }
}
diff --git a/server/src/main/java/com/bfwg/model/PasswordChangeRequest.java b/server/src/main/java/com/bfwg/model/PasswordChangeRequest.java
new file mode 100644
index 000000000..98d69c365
--- /dev/null
+++ b/server/src/main/java/com/bfwg/model/PasswordChangeRequest.java
@@ -0,0 +1,106 @@
+package com.bfwg.model;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+import java.util.Objects;
+
+/**
+ * Data Transfer Object for password change requests.
+ *
+ * This DTO encapsulates the data required for a user to change their password.
+ * Follows Clean Code principles with proper validation and encapsulation.
+ *
+ * @since 0.1.0
+ */
+public class PasswordChangeRequest {
+
+ @NotBlank(message = "Old password is required")
+ private String oldPassword;
+
+ @NotBlank(message = "New password is required")
+ @Size(min = 3, max = 100, message = "New password must be between 3 and 100 characters")
+ private String newPassword;
+
+ /**
+ * Default constructor for JSON deserialization.
+ */
+ public PasswordChangeRequest() {
+ }
+
+ /**
+ * Constructs a PasswordChangeRequest with old and new passwords.
+ *
+ * @param oldPassword the current password
+ * @param newPassword the new password
+ */
+ public PasswordChangeRequest(String oldPassword, String newPassword) {
+ this.oldPassword = oldPassword;
+ this.newPassword = newPassword;
+ }
+
+ /**
+ * Gets the old password.
+ *
+ * @return the old password
+ */
+ public String getOldPassword() {
+ return oldPassword;
+ }
+
+ /**
+ * Sets the old password.
+ *
+ * @param oldPassword the old password
+ */
+ public void setOldPassword(String oldPassword) {
+ this.oldPassword = oldPassword;
+ }
+
+ /**
+ * Gets the new password.
+ *
+ * @return the new password
+ */
+ public String getNewPassword() {
+ return newPassword;
+ }
+
+ /**
+ * Sets the new password.
+ *
+ * @param newPassword the new password
+ */
+ public void setNewPassword(String newPassword) {
+ this.newPassword = newPassword;
+ }
+
+ /**
+ * Validates that the old and new passwords are different.
+ *
+ * @return true if passwords are different, false otherwise
+ */
+ public boolean arePasswordsDifferent() {
+ return oldPassword != null && newPassword != null && !oldPassword.equals(newPassword);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof PasswordChangeRequest)) return false;
+ PasswordChangeRequest that = (PasswordChangeRequest) o;
+ return Objects.equals(oldPassword, that.oldPassword) &&
+ Objects.equals(newPassword, that.newPassword);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(oldPassword, newPassword);
+ }
+
+ @Override
+ public String toString() {
+ return "PasswordChangeRequest{oldPassword='***', newPassword='***'}";
+ }
+}
+
diff --git a/server/src/main/java/com/bfwg/model/User.java b/server/src/main/java/com/bfwg/model/User.java
index 5751abafb..64df3b7bd 100644
--- a/server/src/main/java/com/bfwg/model/User.java
+++ b/server/src/main/java/com/bfwg/model/User.java
@@ -4,115 +4,314 @@
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
-import javax.persistence.*;
+import jakarta.persistence.*;
import java.io.Serializable;
+import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.List;
+import java.util.Objects;
/**
- * Created by fan.jin on 2016-10-15.
+ * Represents a user in the authentication system.
+ * Implements {@link UserDetails} to integrate with Spring Security.
+ *
+ * This entity follows Clean Code principles with:
+ *
+ * - Proper encapsulation and immutability where possible
+ * - Builder pattern for complex object construction
+ * - Defensive copying for collections
+ * - Clear separation of concerns
+ *
+ *
+ * @author fan.jin
+ * @since 2016-10-15
*/
-
@Entity
-@Table(name = "USER")
+@Table(name = "USER", indexes = {
+ @Index(name = "idx_username", columnList = "username", unique = true)
+})
public class User implements UserDetails, Serializable {
+
+ private static final long serialVersionUID = 1L;
+
@Id
- @Column(name = "id")
+ @Column(name = "id", nullable = false, updatable = false)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
- @Column(name = "username")
+ @Column(name = "username", unique = true, nullable = false, length = 100)
private String username;
@JsonIgnore
- @Column(name = "password")
+ @Column(name = "password", nullable = false)
private String password;
- @Column(name = "firstname")
+ @Column(name = "firstname", length = 100)
private String firstname;
- @Column(name = "lastname")
+ @Column(name = "lastname", length = 100)
private String lastname;
+ @ManyToMany(cascade = CascadeType.MERGE, fetch = FetchType.EAGER)
+ @JoinTable(
+ name = "user_authority",
+ joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
+ inverseJoinColumns = @JoinColumn(name = "authority_id", referencedColumnName = "id")
+ )
+ private List authorities = new ArrayList<>();
- @ManyToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER)
- @JoinTable(name = "user_authority",
- joinColumns = @JoinColumn(name = "user_id", referencedColumnName = "id"),
- inverseJoinColumns = @JoinColumn(name = "authority_id", referencedColumnName = "id"))
- private List authorities;
+ /**
+ * Default constructor for JPA.
+ */
+ protected User() {
+ // Required by JPA
+ }
- public Long getId() {
- return id;
+ /**
+ * Private constructor for builder pattern.
+ */
+ private User(Builder builder) {
+ this.username = builder.username;
+ this.password = builder.password;
+ this.firstname = builder.firstname;
+ this.lastname = builder.lastname;
+ this.authorities = new ArrayList<>(builder.authorities);
}
- public void setId(Long id) {
- this.id = id;
+ /**
+ * Creates a new builder for constructing User instances.
+ *
+ * @return a new builder
+ */
+ public static Builder builder() {
+ return new Builder();
}
- public String getUsername() {
- return username;
+ /**
+ * Gets the unique identifier.
+ *
+ * @return the user id
+ */
+ public Long getId() {
+ return id;
}
- public void setUsername(String username) {
- this.username = username;
+ /**
+ * Gets the username used for authentication.
+ *
+ * @return the username
+ */
+ @Override
+ public String getUsername() {
+ return username;
}
+ /**
+ * Gets the encrypted password.
+ *
+ * @return the password hash
+ */
+ @JsonIgnore
+ @Override
public String getPassword() {
return password;
}
+ /**
+ * Updates the user's password.
+ * Should only be called with an already encrypted password.
+ *
+ * @param password the encrypted password
+ */
public void setPassword(String password) {
- this.password = password;
+ this.password = Objects.requireNonNull(password, "Password must not be null");
}
+ /**
+ * Gets the user's first name.
+ *
+ * @return the first name
+ */
public String getFirstname() {
return firstname;
}
- public void setFirstname(String firstname) {
- this.firstname = firstname;
- }
-
+ /**
+ * Gets the user's last name.
+ *
+ * @return the last name
+ */
public String getLastname() {
return lastname;
}
- public void setLastname(String lastname) {
- this.lastname = lastname;
+ /**
+ * Gets the user's full name.
+ *
+ * @return the full name (firstname + lastname)
+ */
+ public String getFullName() {
+ if (firstname == null && lastname == null) {
+ return username;
+ }
+ return String.format("%s %s",
+ firstname != null ? firstname : "",
+ lastname != null ? lastname : "").trim();
}
+ /**
+ * Returns an unmodifiable collection of the user's authorities.
+ *
+ * @return the user's granted authorities
+ */
@Override
public Collection extends GrantedAuthority> getAuthorities() {
- return this.authorities;
+ return Collections.unmodifiableList(authorities);
}
+ /**
+ * Sets the user's authorities. Uses defensive copying.
+ *
+ * @param authorities the new authorities list
+ */
public void setAuthorities(List authorities) {
- this.authorities = authorities;
+ this.authorities = new ArrayList<>(authorities != null ? authorities : Collections.emptyList());
}
- // We can add the below fields in the users table.
- // For now, they are hardcoded.
+ /**
+ * Indicates whether the user's account has expired.
+ * This implementation always returns true.
+ *
+ * @return true if the account is non-expired
+ */
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}
+ /**
+ * Indicates whether the user is locked or unlocked.
+ * This implementation always returns true.
+ *
+ * @return true if the account is non-locked
+ */
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}
+ /**
+ * Indicates whether the user's credentials (password) has expired.
+ * This implementation always returns true.
+ *
+ * @return true if the credentials are non-expired
+ */
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}
+ /**
+ * Indicates whether the user is enabled or disabled.
+ * This implementation always returns true.
+ *
+ * @return true if the user is enabled
+ */
@JsonIgnore
@Override
public boolean isEnabled() {
return true;
}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof User)) return false;
+ User user = (User) o;
+ return Objects.equals(username, user.username);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(username);
+ }
+
+ @Override
+ public String toString() {
+ return "User{" +
+ "id=" + id +
+ ", username='" + username + '\'' +
+ ", firstname='" + firstname + '\'' +
+ ", lastname='" + lastname + '\'' +
+ ", authorities=" + authorities.size() +
+ '}';
+ }
+
+ /**
+ * Builder class for creating User instances.
+ * Follows the Builder pattern for clear and flexible object construction.
+ */
+ public static class Builder {
+ private String username;
+ private String password;
+ private String firstname;
+ private String lastname;
+ private List authorities = new ArrayList<>();
+
+ private Builder() {
+ }
+
+ public Builder username(String username) {
+ this.username = username;
+ return this;
+ }
+
+ public Builder password(String password) {
+ this.password = password;
+ return this;
+ }
+
+ public Builder firstname(String firstname) {
+ this.firstname = firstname;
+ return this;
+ }
+
+ public Builder lastname(String lastname) {
+ this.lastname = lastname;
+ return this;
+ }
+
+ public Builder authorities(List authorities) {
+ this.authorities = new ArrayList<>(authorities != null ? authorities : Collections.emptyList());
+ return this;
+ }
+
+ public Builder addAuthority(Authority authority) {
+ if (authority != null) {
+ this.authorities.add(authority);
+ }
+ return this;
+ }
+
+ /**
+ * Builds a new User instance.
+ *
+ * @return the constructed user
+ * @throws IllegalStateException if required fields are missing
+ */
+ public User build() {
+ Objects.requireNonNull(username, "Username must not be null");
+ Objects.requireNonNull(password, "Password must not be null");
+
+ if (username.trim().isEmpty()) {
+ throw new IllegalArgumentException("Username must not be empty");
+ }
+
+ return new User(this);
+ }
+ }
}
diff --git a/server/src/main/java/com/bfwg/model/UserRequest.java b/server/src/main/java/com/bfwg/model/UserRequest.java
index 04724582c..e68d41230 100644
--- a/server/src/main/java/com/bfwg/model/UserRequest.java
+++ b/server/src/main/java/com/bfwg/model/UserRequest.java
@@ -1,18 +1,61 @@
package com.bfwg.model;
-
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+import java.util.Objects;
+
+/**
+ * Data Transfer Object for user registration/creation requests.
+ *
+ * This DTO follows Clean Code principles:
+ *
+ * - Uses Bean Validation for declarative validation
+ * - Immutable where possible with proper encapsulation
+ * - Clear and descriptive field names
+ *
+ *
+ * @since 0.1.0
+ */
public class UserRequest {
private Long id;
+ @NotBlank(message = "Username is required")
+ @Size(min = 3, max = 100, message = "Username must be between 3 and 100 characters")
private String username;
+ @NotBlank(message = "Password is required")
+ @Size(min = 3, max = 100, message = "Password must be between 3 and 100 characters")
private String password;
+ @Size(max = 100, message = "First name must not exceed 100 characters")
private String firstname;
+ @Size(max = 100, message = "Last name must not exceed 100 characters")
private String lastname;
+ /**
+ * Default constructor.
+ */
+ public UserRequest() {
+ }
+
+ /**
+ * Constructs a UserRequest with all fields.
+ *
+ * @param username the username
+ * @param password the password
+ * @param firstname the first name
+ * @param lastname the last name
+ */
+ public UserRequest(String username, String password, String firstname, String lastname) {
+ this.username = username;
+ this.password = password;
+ this.firstname = firstname;
+ this.lastname = lastname;
+ }
+
public String getUsername() {
return username;
}
@@ -53,4 +96,26 @@ public void setId(Long id) {
this.id = id;
}
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof UserRequest)) return false;
+ UserRequest that = (UserRequest) o;
+ return Objects.equals(username, that.username);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(username);
+ }
+
+ @Override
+ public String toString() {
+ return "UserRequest{" +
+ "id=" + id +
+ ", username='" + username + '\'' +
+ ", firstname='" + firstname + '\'' +
+ ", lastname='" + lastname + '\'' +
+ '}';
+ }
}
diff --git a/server/src/main/java/com/bfwg/model/UserTokenState.java b/server/src/main/java/com/bfwg/model/UserTokenState.java
index dc79b15bd..9e7d838b2 100644
--- a/server/src/main/java/com/bfwg/model/UserTokenState.java
+++ b/server/src/main/java/com/bfwg/model/UserTokenState.java
@@ -1,35 +1,112 @@
package com.bfwg.model;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.Objects;
+
/**
- * Created by fan.jin on 2016-10-17.
+ * Data Transfer Object representing the state of a user's JWT token.
+ *
+ * This DTO is returned to clients upon successful authentication,
+ * containing the access token and its expiration time.
+ *
+ * Uses JSON property annotations to maintain backwards compatibility
+ * with snake_case API contracts while following Java naming conventions.
+ *
+ * @author fan.jin
+ * @since 2016-10-17
*/
public class UserTokenState {
- private String access_token;
- private Long expires_in;
+ @JsonProperty("access_token")
+ private String accessToken;
+
+ @JsonProperty("expires_in")
+ private Long expiresIn;
+
+ /**
+ * Default constructor creating an empty token state.
+ */
public UserTokenState() {
- this.access_token = null;
- this.expires_in = null;
+ this.accessToken = null;
+ this.expiresIn = null;
+ }
+
+ /**
+ * Constructs a UserTokenState with token and expiration.
+ *
+ * @param accessToken the JWT access token
+ * @param expiresIn the expiration time in seconds
+ */
+ public UserTokenState(String accessToken, long expiresIn) {
+ this.accessToken = accessToken;
+ this.expiresIn = expiresIn;
+ }
+
+ /**
+ * Gets the JWT access token.
+ *
+ * @return the access token
+ */
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ /**
+ * Sets the JWT access token.
+ *
+ * @param accessToken the access token
+ */
+ public void setAccessToken(String accessToken) {
+ this.accessToken = accessToken;
+ }
+
+ /**
+ * Gets the token expiration time in seconds.
+ *
+ * @return the expiration time
+ */
+ public Long getExpiresIn() {
+ return expiresIn;
}
- public UserTokenState(String access_token, long expires_in) {
- this.access_token = access_token;
- this.expires_in = expires_in;
+ /**
+ * Sets the token expiration time in seconds.
+ *
+ * @param expiresIn the expiration time
+ */
+ public void setExpiresIn(Long expiresIn) {
+ this.expiresIn = expiresIn;
}
- public String getAccess_token() {
- return access_token;
+ /**
+ * Checks if the token is valid (non-null and non-empty).
+ *
+ * @return true if token is valid, false otherwise
+ */
+ public boolean isValid() {
+ return accessToken != null && !accessToken.trim().isEmpty() && expiresIn != null && expiresIn > 0;
}
- public void setAccess_token(String access_token) {
- this.access_token = access_token;
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof UserTokenState)) return false;
+ UserTokenState that = (UserTokenState) o;
+ return Objects.equals(accessToken, that.accessToken) &&
+ Objects.equals(expiresIn, that.expiresIn);
}
- public Long getExpires_in() {
- return expires_in;
+ @Override
+ public int hashCode() {
+ return Objects.hash(accessToken, expiresIn);
}
- public void setExpires_in(Long expires_in) {
- this.expires_in = expires_in;
+ @Override
+ public String toString() {
+ return "UserTokenState{" +
+ "accessToken='***'" +
+ ", expiresIn=" + expiresIn +
+ '}';
}
}
diff --git a/server/src/main/java/com/bfwg/rest/AuthenticationController.java b/server/src/main/java/com/bfwg/rest/AuthenticationController.java
index 1cf3b288c..53e147775 100644
--- a/server/src/main/java/com/bfwg/rest/AuthenticationController.java
+++ b/server/src/main/java/com/bfwg/rest/AuthenticationController.java
@@ -1,82 +1,159 @@
package com.bfwg.rest;
-import com.bfwg.config.WebSecurityConfig;
+import com.bfwg.model.PasswordChangeRequest;
+import com.bfwg.model.User;
import com.bfwg.model.UserTokenState;
import com.bfwg.security.TokenHelper;
+import com.bfwg.service.PasswordService;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestMethod;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.*;
-import javax.servlet.http.Cookie;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import java.util.HashMap;
+import java.util.Collections;
import java.util.Map;
+import java.util.Objects;
/**
- * Created by fan.jin on 2017-05-10.
+ * REST controller for authentication operations.
+ *
+ * This controller handles token refresh and password change operations.
+ * Follows Clean Code principles with:
+ *
+ * - Single Responsibility: Focuses only on authentication endpoints
+ * - Proper use of DTOs instead of inner classes
+ * - Dependency Inversion via constructor injection
+ * - Clear, descriptive method names
+ *
+ *
+ * @author fan.jin
+ * @since 2017-05-10
*/
-
@RestController
@RequestMapping(value = "/api", produces = MediaType.APPLICATION_JSON_VALUE)
public class AuthenticationController {
+ private static final Logger logger = LoggerFactory.getLogger(AuthenticationController.class);
+ private static final String SUCCESS_MESSAGE = "success";
+
private final TokenHelper tokenHelper;
- private final WebSecurityConfig userDetailsService;
+ private final PasswordService passwordService;
@Value("${jwt.expires_in}")
- private int EXPIRES_IN;
+ private int expiresIn;
@Value("${jwt.cookie}")
- private String TOKEN_COOKIE;
+ private String tokenCookie;
+ /**
+ * Constructs the controller with required dependencies.
+ *
+ * @param tokenHelper the token helper service
+ * @param passwordService the password management service
+ */
@Autowired
- public AuthenticationController(TokenHelper tokenHelper, WebSecurityConfig userDetailsService) {
- this.tokenHelper = tokenHelper;
- this.userDetailsService = userDetailsService;
+ public AuthenticationController(TokenHelper tokenHelper, PasswordService passwordService) {
+ this.tokenHelper = Objects.requireNonNull(tokenHelper, "TokenHelper must not be null");
+ this.passwordService = Objects.requireNonNull(passwordService, "PasswordService must not be null");
}
- @RequestMapping(value = "/refresh", method = RequestMethod.GET)
- public ResponseEntity> refreshAuthenticationToken(HttpServletRequest request, HttpServletResponse response) {
+ /**
+ * Refreshes an authentication token if it's still valid.
+ *
+ * @param request the HTTP request containing the token
+ * @param response the HTTP response to add the refreshed token cookie
+ * @return the refreshed token state or empty state if refresh failed
+ */
+ @GetMapping("/refresh")
+ public ResponseEntity refreshAuthenticationToken(
+ HttpServletRequest request,
+ HttpServletResponse response) {
String authToken = tokenHelper.getToken(request);
+
if (authToken != null && tokenHelper.canTokenBeRefreshed(authToken)) {
- // TODO check user password last update
String refreshedToken = tokenHelper.refreshToken(authToken);
-
- Cookie authCookie = new Cookie(TOKEN_COOKIE, (refreshedToken));
- authCookie.setPath("/");
- authCookie.setHttpOnly(true);
- authCookie.setMaxAge(EXPIRES_IN);
- // Add cookie to response
- response.addCookie(authCookie);
-
- UserTokenState userTokenState = new UserTokenState(refreshedToken, EXPIRES_IN);
+
+ addTokenCookie(response, refreshedToken);
+
+ UserTokenState userTokenState = new UserTokenState(refreshedToken, expiresIn);
+ logger.debug("Token refreshed successfully");
return ResponseEntity.ok(userTokenState);
} else {
- UserTokenState userTokenState = new UserTokenState();
- return ResponseEntity.accepted().body(userTokenState);
+ logger.debug("Token refresh failed - token invalid or expired");
+ return ResponseEntity.status(HttpStatus.ACCEPTED).body(new UserTokenState());
}
}
- @RequestMapping(value = "/changePassword", method = RequestMethod.POST)
+ /**
+ * Changes the current user's password.
+ *
+ * @param request the password change request containing old and new passwords
+ * @return success message or error
+ */
+ @PostMapping("/changePassword")
@PreAuthorize("hasRole('USER')")
- public ResponseEntity> changePassword(@RequestBody PasswordChanger passwordChanger) throws Exception {
- userDetailsService.changePassword(passwordChanger.oldPassword, passwordChanger.newPassword);
- Map result = new HashMap<>();
- result.put("result", "success");
- return ResponseEntity.accepted().body(result);
+ public ResponseEntity