diff --git a/.gitignore b/.gitignore index 7f714f6..5f8f382 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,4 @@ application-local.yml /java_pid20864.hprof /java_pid23168.hprof /project_concatenated.txt +/repomix-output.txt diff --git a/.vscode/settings.json b/.vscode/settings.json index 151742d..42d8092 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,5 +9,5 @@ "debug.javascript.defaultRuntimeExecutable": { "pwa-node": "/Users/devon/.local/share/mise/shims/node" }, - "python.defaultInterpreterPath": "/Users/devon/.local/share/mise/installs/python/3.13.1/bin/python" + "python.defaultInterpreterPath": "/Users/devon/.local/share/mise/installs/python/3.13.2/bin/python" } diff --git a/PROFILE.md b/PROFILE.md new file mode 100644 index 0000000..1ffd376 --- /dev/null +++ b/PROFILE.md @@ -0,0 +1,399 @@ +# User Profile Extension Framework + +This guide explains how to leverage and extend the user profile system in Spring User Framework to create rich, application-specific user data models. + +## Table of Contents +- [User Profile Extension Framework](#user-profile-extension-framework) + - [Table of Contents](#table-of-contents) + - [Overview](#overview) + - [When to Use Profile Extensions](#when-to-use-profile-extensions) + - [Core Components](#core-components) + - [Implementation Guide](#implementation-guide) + - [Step 1: Create Your Custom User Profile](#step-1-create-your-custom-user-profile) + - [Step 2: Create a Profile Repository](#step-2-create-a-profile-repository) + - [Step 3: Implement a Profile Service](#step-3-implement-a-profile-service) + - [Step 4: Create a Session Profile Manager](#step-4-create-a-session-profile-manager) + - [Step 5: Implement an Authentication Listener](#step-5-implement-an-authentication-listener) + - [Usage Examples](#usage-examples) + - [Accessing Profile Data in Controllers](#accessing-profile-data-in-controllers) + - [Using Profiles in Views](#using-profiles-in-views) + - [Profile-Based Authorization](#profile-based-authorization) + - [Advanced Customizations](#advanced-customizations) + - [Custom Profile Initialization](#custom-profile-initialization) + - [Additional Event Handling](#additional-event-handling) + - [Profile Migration Strategies](#profile-migration-strategies) + - [Troubleshooting](#troubleshooting) + +## Overview + +The Spring User Framework provides an extensible user profile system that allows you to: + +1. **Store application-specific user data** beyond the core authentication details +2. **Access profile information throughout the application** via session-scoped components +3. **Automatically load profiles during authentication** with minimal configuration +4. **Keep user-related data organized** in a type-safe, structured manner + +This system is built on Spring's dependency injection, JPA persistence, and session management capabilities, making it seamlessly integrated with your Spring Boot application. + +## When to Use Profile Extensions + +Consider extending the profile system when you need to: + +- Store user preferences, settings, or application-specific data +- Track user activity or state across sessions +- Associate domain-specific entities with users (e.g., subscriptions, permissions) +- Implement features requiring additional user properties beyond authentication + +If your application only needs basic authentication without user-specific data, you may not need to implement these extensions. + +## Core Components + +The profile extension framework consists of these key components: + +1. **`BaseUserProfile`**: The JPA entity base class that links to the core `User` entity +2. **`UserProfileService`**: Interface for retrieving and managing profile objects +3. **`BaseSessionProfile`**: Session-scoped container that holds the current user's profile +4. **`BaseAuthenticationListener`**: Loads the profile on successful authentication + +All components use generics to ensure type safety throughout your application. + +## Implementation Guide + +### Step 1: Create Your Custom User Profile + +Create a JPA entity that extends `BaseUserProfile`: + +```java +@Entity +@Table(name = "app_user_profile") +@Data +@EqualsAndHashCode(callSuper = true) +public class AppUserProfile extends BaseUserProfile { + // Add your application-specific fields + + private String displayName; + + @Enumerated(EnumType.STRING) + private AccountType accountType; + + private boolean notificationsEnabled; + + @OneToMany(mappedBy = "userProfile", cascade = CascadeType.ALL, orphanRemoval = true) + private List preferences = new ArrayList<>(); + + // Domain-specific methods + public void addPreference(UserPreference preference) { + preferences.add(preference); + preference.setUserProfile(this); + } + + public boolean hasPreference(String key) { + return preferences.stream() + .anyMatch(p -> p.getKey().equals(key)); + } +} +``` + +The `BaseUserProfile` class already provides: +- An ID field that maps to the User ID +- A one-to-one relationship with the User entity +- Common fields like lastAccessed and locale + +### Step 2: Create a Profile Repository + +Create a repository interface for your profile entity: + +```java +public interface AppUserProfileRepository extends JpaRepository { + Optional findByUserId(Long userId); +} +``` + +### Step 3: Implement a Profile Service + +Implement the `UserProfileService` interface to manage your profile entity: + +```java +@Service +@Transactional +@RequiredArgsConstructor +public class AppUserProfileService implements UserProfileService { + + private final AppUserProfileRepository profileRepository; + private final UserRepository userRepository; + + @Override + public AppUserProfile getOrCreateProfile(User user) { + if (user == null) { + throw new IllegalArgumentException("User must not be null"); + } + + return profileRepository.findByUserId(user.getId()) + .orElseGet(() -> createAndSaveProfile(user)); + } + + @Override + public AppUserProfile updateProfile(AppUserProfile profile) { + if (profile == null) { + throw new IllegalArgumentException("Profile must not be null"); + } + return profileRepository.save(profile); + } + + private AppUserProfile createAndSaveProfile(User user) { + User managedUser = userRepository.findById(user.getId()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + AppUserProfile profile = new AppUserProfile(); + profile.setUser(managedUser); + + // Set default values for new profiles + profile.setDisplayName(user.getFirstName() + " " + user.getLastName()); + profile.setAccountType(AccountType.BASIC); + profile.setNotificationsEnabled(true); + + return profileRepository.save(profile); + } + + // Additional application-specific methods + public void upgradeAccount(AppUserProfile profile, AccountType newType) { + profile.setAccountType(newType); + profileRepository.save(profile); + } +} +``` + +### Step 4: Create a Session Profile Manager + +Create a session-scoped component to access the current user's profile: + +```java +@Component +@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS) +public class AppSessionProfile extends BaseSessionProfile { + + // Add custom accessor methods for your application + public String getDisplayName() { + return getUserProfile() != null ? getUserProfile().getDisplayName() : null; + } + + public boolean isNotificationsEnabled() { + return getUserProfile() != null && getUserProfile().isNotificationsEnabled(); + } + + public AccountType getAccountType() { + return getUserProfile() != null ? getUserProfile().getAccountType() : null; + } + + public boolean isPremiumUser() { + return getUserProfile() != null && + getUserProfile().getAccountType() == AccountType.PREMIUM; + } +} +``` + +### Step 5: Implement an Authentication Listener + +Create a listener to load profiles during authentication: + +```java +@Component +public class AppAuthenticationListener extends BaseAuthenticationListener { + + public AppAuthenticationListener(AppSessionProfile sessionProfile, + AppUserProfileService profileService) { + super(sessionProfile, profileService); + } + + // Optionally override event handling methods +} +``` + +That's it! With these components in place, your application will automatically: +1. Load the user's profile upon successful authentication +2. Store the profile in the session for easy access +3. Allow you to read and update profile data throughout your application + +## Usage Examples + +### Accessing Profile Data in Controllers + +```java +@Controller +@RequiredArgsConstructor +public class DashboardController { + + private final AppSessionProfile sessionProfile; + + @GetMapping("/dashboard") + public String dashboard(Model model) { + // Access profile data + model.addAttribute("displayName", sessionProfile.getDisplayName()); + model.addAttribute("isPremium", sessionProfile.isPremiumUser()); + + // Access the underlying User object if needed + User user = sessionProfile.getUser(); + + // Use the full profile object + AppUserProfile profile = sessionProfile.getUserProfile(); + + return "dashboard"; + } +} +``` + +### Using Profiles in Views + +In Thymeleaf templates, you can directly access the session profile: + +```html + +
+

Welcome, Premium Member User!

+ +
+ + +
+ +
+``` + +### Profile-Based Authorization + +You can use profile data for fine-grained authorization: + +```java +@PreAuthorize("@appSessionProfile.isPremiumUser()") +@GetMapping("/premium-content") +public String premiumContent() { + return "premium/content"; +} +``` + +## Advanced Customizations + +### Custom Profile Initialization + +Override the `getOrCreateProfile` method to implement custom initialization logic: + +```java +@Override +public AppUserProfile getOrCreateProfile(User user) { + return profileRepository.findByUserId(user.getId()) + .orElseGet(() -> { + AppUserProfile profile = new AppUserProfile(); + profile.setUser(user); + + // Apply business logic for new profiles + if (user.getEmail().endsWith("@company.com")) { + profile.setAccountType(AccountType.INTERNAL); + } + + // Set up default preferences + UserPreference theme = new UserPreference(); + theme.setKey("theme"); + theme.setValue("light"); + profile.addPreference(theme); + + return profileRepository.save(profile); + }); +} +``` + +### Additional Event Handling + +You can handle more authentication-related events by adding methods to your listener: + +```java +@Component +public class ExtendedAuthListener extends BaseAuthenticationListener { + + private final LoginAttemptService loginAttemptService; + + public ExtendedAuthListener( + AppSessionProfile sessionProfile, + AppUserProfileService profileService, + LoginAttemptService loginAttemptService) { + super(sessionProfile, profileService); + this.loginAttemptService = loginAttemptService; + } + + @EventListener + public void onLogoutSuccess(LogoutSuccessEvent event) { + // Handle logout, e.g., update last logout timestamp + if (event.getAuthentication().getPrincipal() instanceof DSUserDetails) { + User user = ((DSUserDetails) event.getAuthentication().getPrincipal()).getUser(); + AppUserProfile profile = profileService.getOrCreateProfile(user); + profile.setLastLogout(new Date()); + profileService.updateProfile(profile); + } + } +} +``` + +### Profile Migration Strategies + +If you need to migrate or update existing profiles: + +```java +@Service +@RequiredArgsConstructor +public class ProfileMigrationService { + + private final AppUserProfileRepository profileRepository; + + @Transactional + @Scheduled(fixedRate = 86400000) // Daily + public void migrateProfilesToNewSchema() { + List profiles = profileRepository.findAll(); + for (AppUserProfile profile : profiles) { + // Perform migration logic + if (profile.getAccountType() == null) { + profile.setAccountType(AccountType.BASIC); + } + + // Initialize new fields with default values + if (profile.getPreferences().isEmpty()) { + UserPreference defaultPref = new UserPreference(); + defaultPref.setKey("notifications"); + defaultPref.setValue("true"); + profile.addPreference(defaultPref); + } + } + profileRepository.saveAll(profiles); + } +} +``` + +## Troubleshooting + +**Profile Not Loading After Authentication** +- Ensure your `AuthenticationListener` is properly registered as a Spring bean +- Verify that Spring Security is configured to use the framework's authentication provider +- Check that your transaction boundaries are correctly defined + +**Session Profile Returns Null** +- Make sure the session scoping is correctly configured +- Ensure authentication events are being fired +- Check for circular dependencies in your profile service + +**Missing Profile Data** +- Verify that profile initialization logic correctly sets default values +- Check that database schema updates include new fields +- Review transaction isolation levels if concurrent updates are possible + +For more complex issues, enable debug logging: + +```yaml +logging: + level: + com.digitalsanctuary.spring.user.profile: DEBUG + com.example.myapp.profile: DEBUG +``` + +--- + +This framework provides a flexible foundation for managing user-specific data in your application. By extending these base components, you can create a rich user experience while maintaining clean separation of concerns and leveraging Spring's powerful features. + +For a complete working example, refer to the [Spring User Framework Demo Application](https://github.com/devondragon/SpringUserFrameworkDemoApp). diff --git a/PUBLISH.md b/PUBLISH.md index 9545ebc..a8e01fd 100644 --- a/PUBLISH.md +++ b/PUBLISH.md @@ -31,4 +31,9 @@ gradle publishMavenCentral ``` +## Create a new Release and Publish to Maven Central + +```shell +gradle release +``` diff --git a/README.md b/README.md index 6125762..e5e3f2e 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,241 @@ -## Table of Contents -- [SpringUserFramework](#springuserframework) - - [Summary](#summary) - - [Features](#features) - - [How To Get Started](#how-to-get-started) - - [Refer to the Demo Project](#refer-to-the-demo-project) - - [Configuring Your Local Environment](#configuring-your-local-environment) - - [Database](#database) - - [Mail Sending (SMTP)](#mail-sending-smtp) - - [SSO OAuth2 with Google and Facebook](#sso-oauth2-with-google-and-facebook) - - [Overriding Spring Security Messages](#overriding-spring-security-messages) - - [Notes](#notes) - +# Spring User Framework -# SpringUserFramework +[![Maven Central](https://img.shields.io/maven-central/v/com.digitalsanctuary/ds-spring-user-framework.svg)](https://central.sonatype.com/artifact/com.digitalsanctuary/ds-spring-user-framework) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Java Version](https://img.shields.io/badge/Java-17%2B-brightgreen)](https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html) +A comprehensive Spring Boot User Management Framework that simplifies the implementation of robust user authentication and management features. Built on top of Spring Security, this library provides ready-to-use solutions for user registration, login, account management, and more. +Check out the [Spring User Framework Demo Application](https://github.com/devondragon/SpringUserFrameworkDemoApp) for a complete example of how to use this library. -SpringUserFramework is a Java Spring Boot User Management Framework designed to simplify the implementation of user management features in your SpringBoot web application. It is built on top of [Spring Security](https://spring.io/projects/spring-security) and provides out-of-the-box support for registration, login, logout, and forgot password flows. It also supports SSO with Google and Facebook. -The framework includes basic example pages that are unstyled, allowing for seamless integration into your application. -## Summary - -This framework aims to achieve the following goals: -- Provide an easy-to-use starting point for any Spring-based web application that requires user management features. -- Offer a local database-backed user store, with the flexibility to integrate Single Sign-On (SSO) using Spring Security. -- Design the framework around REST APIs. -- Utilize Spring Security for enhanced security features, such as two-factor authentication (2FA) and SSO integrations. -- Enable easy configuration through the use of `application.yml` whenever possible. -- Support internationalization by utilizing the messages feature for all user-facing text and messaging. -- Provide an audit event framework to facilitate the generation of security audit trails. -- Use the email address as the default username. +## Table of Contents +- [Spring User Framework](#spring-user-framework) + - [Table of Contents](#table-of-contents) + - [Features](#features) + - [Installation](#installation) + - [Maven](#maven) + - [Gradle](#gradle) + - [Quick Start](#quick-start) + - [Configuration](#configuration) + - [Security Features](#security-features) + - [Role-Based Access Control](#role-based-access-control) + - [Account Lockout](#account-lockout) + - [Audit Logging](#audit-logging) + - [User Management](#user-management) + - [Registration](#registration) + - [Profile Management](#profile-management) + - [Email Verification](#email-verification) + - [Authentication](#authentication) + - [Local Authentication](#local-authentication) + - [OAuth2/SSO](#oauth2sso) + - [Extensibility](#extensibility) + - [Custom User Profiles](#custom-user-profiles) + - [Examples](#examples) + - [Reference Documentation](#reference-documentation) + - [License](#license) ## Features -The framework provides support for the following features: -- Registration, with optional email verification. -- Login and logout functionality. -- Forgot password flow. -- Database-backed user store using Spring JPA. -- SSO support for Google -- SSO support for Facebook -- Configuration options to control anonymous access, whitelist URIs, and protect specific URIs requiring a logged-in user session. -- CSRF protection enabled by default, with example jQuery AJAX calls passing the CSRF token from the Thymeleaf page context. -- Audit event framework for recording and logging security events, customizable to store audit events in a database or publish them via a REST API. -- Role and Privilege setup service to define roles, associated privileges, and role inheritance hierarchy using `application.yml`. -- Configurable Account Lockout after too many failed login attempts +- **User Registration and Authentication** + - Local username/password authentication + - OAuth2/SSO with Google, Facebook, and more + - Email verification workflow + - Password reset functionality + - Account management (update profile, change password) +- **Advanced Security** + - Role and privilege-based authorization + - Configurable password policies + - Account lockout after failed login attempts + - Audit logging for security events + - CSRF protection out of the box +- **Extensible Architecture** + - Easily extend user profiles with custom data + - Override default behaviors where needed + - Integration with Spring ecosystem + - Customizable UI templates -## How To Get Started +- **Developer-Friendly** + - Minimal boilerplate code to get started + - Configuration-driven features + - Comprehensive documentation + - Demo application for reference -This Framework is now available as a library on Maven Central. You can add it to your Gradle project by adding the following dependency to your `build.gradle` file: +## Installation -```groovy -implementation 'com.digitalsanctuary:ds-spring-user-framework:3.0.1' -``` - -Or to your Maven project by adding it to your `pom.xml` file: +### Maven ```xml com.digitalsanctuary ds-spring-user-framework - 3.0.1 + 3.1.0 ``` -Please check for the latest version on [Maven Central](https://central.sonatype.com/artifact/com.digitalsanctuary/ds-spring-user-framework) (this README may not always be up to date). -When upgrading to a new version, please check the [CHANGELOG](CHANGELOG.md) for any breaking changes or new features. +### Gradle + +```groovy +implementation 'com.digitalsanctuary:ds-spring-user-framework:3.1.0' +``` + +## Quick Start + +1. **Add the dependency** as shown above + +2. **Set essential configuration** in your `application.yml`: + +```yaml +spring: + datasource: + url: jdbc:mariadb://localhost:3306/yourdb + username: dbuser + password: dbpassword + driver-class-name: org.mariadb.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + mail: + host: smtp.example.com + port: 587 + username: your-username + password: your-password + properties: + mail: + smtp: + auth: true + starttls: + enable: true + +user: + mail: + fromAddress: noreply@yourdomain.com + security: + defaultAction: deny + bcryptStrength: 12 + failedLoginAttempts: 5 + accountLockoutDuration: 15 +``` + +3. **Create a UserProfile extension** for your application-specific user data: +```java +@Entity +@Table(name = "app_user_profile") +public class AppUserProfile extends BaseUserProfile { + // Add your application-specific fields + private String preferredLanguage; + private boolean receiveNewsletter; -### Refer to the Demo Project -I have created a demo project that uses this framework. You can find it here: [SpringUserFrameworkDemo](https://github.com/devondragon/SpringUserFrameworkDemoApp). This demo project is a full SpringBoot application that uses this framework as a library. You can use it as a reference for how to use this framework in your own project. It demonstrates all of the configuration values and how to override them in your own `application.yml` file. It also has functioning examples for all front end pages, javascript, etc... + // Getters and setters +} +``` -In addition to being a fully functional reference, you can also use the demo project as a starting point for your own project. Just clone the repo and start building your own application on top of it. +4. **Run your application** and navigate to `/user/login.html` to see the login page. +## Configuration -### Configuring Your Local Environment +The framework uses a configuration-first approach to customize behavior. See the [Configuration Guide](CONFIG.md) for detailed documentation of all configuration options. -You can read more about the required configuration values in the [Configuration Guide](CONFIG.md). +Key configuration categories: -Missing or incorrect configuration values will make this framework not work correctly. +- **Security**: Access control, password policies, CSRF protection +- **Mail**: Email server settings for verification and notification emails +- **User Registration**: Self-registration options, verification requirements +- **Authentication**: Local and OAuth2 provider configuration +- **UI**: Paths to customized templates and views -### Database -This framework uses a database as a user store. By building on top of Spring JPA it is easy to use whichever database you like. +## Security Features -If you set your JPA Hibernate ddl-auto property to "create" it will create the tables for you. If you set it to "update" it will update the tables for you. If you set it to "none" you will need to create the tables yourself. +### Role-Based Access Control -If you are not using automatic schema updates or Flyway, you can set up your database manually using the provided `schema.sql` file: +Define roles and privileges with hierarchical inheritance: -```bash -mysql -u username -p database_name < db-scripts/mariadb-schema.sql +```yaml +user: + roles: + roles-and-privileges: + "[ROLE_ADMIN]": + - ADMIN_PRIVILEGE + - USER_MANAGEMENT_PRIVILEGE + "[ROLE_USER]": + - LOGIN_PRIVILEGE + - SELF_SERVICE_PRIVILEGE + role-hierarchy: + - ROLE_ADMIN > ROLE_USER ``` -Flyway support will be coming soon. This will allow you to automatically update your database schema as you deploy new versions of your application. +### Account Lockout +Prevent brute force attacks with configurable lockout policies: -### Mail Sending (SMTP) -The framework sends emails for verification links, forgot password flow, etc... so you need to configure the outbound SMTP server and authentication information. This is done in the `application.yml` file. You can see the example configuration in the Demo Project's `application.yml` file. Please also refer to the [Spring Boot Mail Properties](https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html#mail-properties) for more information on the available properties. +```yaml +user: + security: + failedLoginAttempts: 5 + accountLockoutDuration: 30 # minutes +``` + +### Audit Logging + +Track security-relevant events with built-in audit logging: + +```yaml +user: + audit: + logEvents: true + logFilePath: /path/to/audit/log + flushOnWrite: false + flushRate: 10000 +``` + +## User Management + +### Registration + +Default registration flow includes: +- Form submission validation +- Email uniqueness check +- Email verification (optional) +- Welcome email +- Configurable initial roles + +### Profile Management + +Users can: +- Update their profile information +- Change their password +- Delete their account (configurable to either disable or fully delete) +## Email Verification -### SSO OAuth2 with Google and Facebook -The framework supports SSO OAuth2 with Google and Facebook. To enable this you need to configure the client id and secret for each provider. This is done in the application.yml (or application.properties) file using the [Spring Security OAuth2 properties](https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html). You can see the example configuration in the Demo Project's `application.yml` file. +The framework includes a complete email verification system: +- Token generation and verification +- Customizable email templates +- Token expiration and renewal +- Automatic account activation -Here is a quick example for your reference: +## Authentication + +### Local Authentication + +Username/password authentication with: +- Secure password hashing (bcrypt) +- Account lockout protection +- Remember-me functionality + +### OAuth2/SSO + +Support for social login providers: +- Google +- Facebook +- Apple +- Custom providers + +Configuration example: ```yaml spring: @@ -113,25 +244,51 @@ spring: client: registration: google: - client-id: YOUR_GOOGLE_CLIENT_ID - client-secret: YOUR_GOOGLE_CLIENT_SECRET - redirect-uri: "{baseUrl}/login/oauth2/code/google" - facebook: - client-id: YOUR_FACEBOOK_CLIENT_ID - client-secret: YOUR_FACEBOOK_CLIENT_SECRET - redirect-uri: "{baseUrl}/login/oauth2/code/facebook" + client-id: your-client-id + client-secret: your-client-secret + scope: profile,email ``` -For public OAuth you will need a public hostname and HTTPS enabled. You can use ngrok or Cloudflare tunnels to create a public hostname and tunnel to your local machine during development. You can then use the ngrok hostname in your Google and Facebook developer console configuration. +## Extensibility + +The framework is designed to be extended without modifying the core code. + +### Custom User Profiles + +Extend the `BaseUserProfile` to add your application-specific user data: + +```java +@Service +public class CustomUserProfileService implements UserProfileService { + @Override + public CustomUserProfile getOrCreateProfile(User user) { + // Implementation + } + + @Override + public CustomUserProfile updateProfile(CustomUserProfile profile) { + // Implementation + } +} +``` +Read more in the [Profile Guide](PROFILE.md). +## Examples +For complete working examples, check out the [Spring User Framework Demo Application](https://github.com/devondragon/SpringUserFrameworkDemoApp). +## Reference Documentation -## Overriding Spring Security Messages +- [API Documentation](https://digitalSanctuary.github.io/SpringUserFramework/) +- [Configuration Guide](CONFIG.md) +- [Security Guide](SECURITY.md) +- [Customization Guide](CUSTOMIZATION.md) +- [Demo Application](https://github.com/devondragon/SpringUserFrameworkDemoApp) -You may want to override the default Spring Security user facing messages. You can do this in your messages.properties file, by adding any of the message keys from Spring Security (found here: [Spring Security Messages](https://github.com/spring-projects/spring-security/blob/main/core/src/main/resources/org/springframework/security/messages.properties)) and providing your own values. +## License +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. +--- -## Notes -Please note that there is no warranty or guarantee of functionality, quality, performance, or security made by the author. The code is available freely, but you assume all responsibility and liability for its usage in your application. +Created by [Devon Hillard](https://github.com/devondragon/) at [Digital Sanctuary](https://www.digitalsanctuary.com/) diff --git a/gradle.properties b/gradle.properties index c647be3..ef465a3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=3.0.2-SNAPSHOT +version=3.1.0-SNAPSHOT diff --git a/src/main/java/com/digitalsanctuary/spring/user/UserAutoConfigurationRegistrar.java b/src/main/java/com/digitalsanctuary/spring/user/UserAutoConfigurationRegistrar.java new file mode 100644 index 0000000..9a9ec2b --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/UserAutoConfigurationRegistrar.java @@ -0,0 +1,42 @@ +package com.digitalsanctuary.spring.user; + +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.boot.autoconfigure.AutoConfigurationPackages; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.type.AnnotationMetadata; + +/** + * {@code UserAutoConfigurationRegistrar} dynamically registers the base package of this library with Spring Boot to ensure that its entities, + * repositories, and other Spring-managed components are properly detected and included in the application context. + * + *

+ * This class is designed to simplify the integration of the library into Spring Boot applications by automatically registering the library's base + * package (com.digitalsanctuary.spring.user) for component scanning. It ensures that: + *

    + *
  • The library's repositories and entities are discovered and configured correctly.
  • + *
  • The consuming application retains its ability to automatically detect its own repositories and entities.
  • + *
+ * + *

+ * This approach avoids the need for the consuming application to manually specify the library's base package or manage complex configuration, + * reducing setup effort and minimizing potential errors. + * + *

+ * Note: This solution leverages {@link AutoConfigurationPackages#register} to dynamically register the library's package during the + * auto-configuration phase, ensuring compatibility with Spring Boot's component scanning and auto-configuration mechanisms. + */ +public class UserAutoConfigurationRegistrar implements ImportBeanDefinitionRegistrar { + + /** + * Registers the library's base package (com.digitalsanctuary.spring.user) with the Spring application context to enable automatic + * detection of entities, repositories, and other components provided by the library. + * + * @param importingClassMetadata metadata of the class that imports this registrar + * @param registry the bean definition registry used to register the base package + */ + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { + // Register the top-level package for the library + AutoConfigurationPackages.register(registry, "com.digitalsanctuary.spring.user"); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java b/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java index 50c1c95..1b38e14 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java +++ b/src/main/java/com/digitalsanctuary/spring/user/UserConfiguration.java @@ -1,11 +1,11 @@ package com.digitalsanctuary.spring.user; -import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.context.annotation.Import; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; @@ -18,19 +18,19 @@ @Configuration @EnableAsync @EnableScheduling +@EnableMethodSecurity @ComponentScan(basePackages = "com.digitalsanctuary.spring.user") -@EnableJpaRepositories(basePackages = "com.digitalsanctuary.spring.user.persistence.repository") -@EntityScan(basePackages = "com.digitalsanctuary.spring.user.persistence.model") +@Import(UserAutoConfigurationRegistrar.class) public class UserConfiguration { + /** - * Method executed after the bean initialization. - *

- * This method logs a message indicating that the DigitalSanctuary Spring Boot User Framework LIbrary has been loaded. - *

+ * Logs a message when the UserConfiguration class is loaded to indicate that the DigitalSanctuary Spring Boot User Framework Library has been + * loaded. */ @PostConstruct public void onStartup() { - log.info("DigitalSanctuary SpringBoot User Framework Library loaded"); + log.info("DigitalSanctuary SpringBoot User Framework Library loaded."); } + } diff --git a/src/main/java/com/digitalsanctuary/spring/user/profile/BaseUserProfile.java b/src/main/java/com/digitalsanctuary/spring/user/profile/BaseUserProfile.java new file mode 100644 index 0000000..f0eb8a5 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/profile/BaseUserProfile.java @@ -0,0 +1,68 @@ +package com.digitalsanctuary.spring.user.profile; + +import java.time.LocalDateTime; +import com.digitalsanctuary.spring.user.persistence.model.User; +import jakarta.persistence.Column; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import lombok.Data; + +/** + * Base class for user profile entities that extend the core {@link User} functionality. This class provides the foundation for creating + * application-specific user profiles with shared common attributes. + * + *

+ * This class uses {@code @MappedSuperclass} to allow inheritance of JPA mappings, enabling extending classes to add additional fields while + * maintaining a consistent database structure. The profile shares its primary key with the associated {@link User} entity through the {@code @MapsId} + * annotation. + *

+ * + * Example implementation: {@code @Entity + * + * @Table(name = "customer_profile") public class CustomerProfile extends BaseUserProfile { private String customerType; private String + * shippingPreference; private List orders; } } + * + * Database Structure: - id/user_id (PK/FK to user_account table) - last_accessed (timestamp) - preferred_locale (varchar) + * + * @see User + * @see MappedSuperclass + */ +@Data +@MappedSuperclass +public abstract class BaseUserProfile { + + /** + * The primary key for the profile, shared with the associated User entity. This is automatically populated through the {@code @MapsId} annotation + * when the profile is persisted. + */ + @Id + private Long id; + + /** + * The associated User entity. This establishes a one-to-one relationship with shared primary key through the {@code @MapsId} annotation. The + * foreign key column will be named "user_id". + */ + @OneToOne + @MapsId + @JoinColumn(name = "user_id") + private User user; + + /** + * Timestamp of the last time this profile was accessed. This can be used for tracking user activity, session management, or implementing timeout + * functionality. + */ + @Column(name = "last_accessed") + private LocalDateTime lastAccessed; + + /** + * The user's preferred locale for internationalization purposes. This should contain a valid locale code (e.g., "en_US", "fr_FR"). Applications + * can use this to provide localized content and formatting. + */ + @Column(name = "preferred_locale") + private String locale; + + // Note: Getters and setters are provided by Lombok @Data annotation +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/profile/UserProfileService.java b/src/main/java/com/digitalsanctuary/spring/user/profile/UserProfileService.java new file mode 100644 index 0000000..e38137f --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/profile/UserProfileService.java @@ -0,0 +1,67 @@ +package com.digitalsanctuary.spring.user.profile; + +import com.digitalsanctuary.spring.user.persistence.model.User; + +/** + * Service interface for managing user profiles. This interface defines the core operations for retrieving, creating, and updating user profiles that + * extend the base profile functionality. + * + *

+ * Implementations of this interface handle the persistence and business logic for user profiles, providing a standardized way to manage extended user + * data beyond the core {@link User} entity. + *

+ * + * Example implementation: {@code @Service public class CustomUserProfileService implements UserProfileService { private final + * CustomUserProfileRepository profileRepository; + * + * @Override public CustomUserProfile getOrCreateProfile(User user) { return profileRepository.findByUserId(user.getId()) .orElseGet(() -> { + * CustomUserProfile profile = new CustomUserProfile(); profile.setUser(user); return profileRepository.save(profile); }); } + * + * @Override public CustomUserProfile updateProfile(CustomUserProfile profile) { return profileRepository.save(profile); } } } + * + * @param the type of user profile to manage, must extend BaseUserProfile + * @see BaseUserProfile + * @see User + */ +public interface UserProfileService { + + /** + * Retrieves an existing profile for the given user or creates a new one if none exists. This method ensures that every user has an associated + * profile. + * + *

+ * Implementations should: + *

+ *
    + *
  • Check if a profile exists for the user
  • + *
  • Create a new profile if none exists
  • + *
  • Initialize any required default values for new profiles
  • + *
  • Persist the profile if newly created
  • + *
+ * + * @param user the user to get or create a profile for + * @return the existing or newly created profile + * @throws IllegalArgumentException if user is null + * @throws RuntimeException if profile creation or retrieval fails + */ + T getOrCreateProfile(User user); + + /** + * Updates an existing user profile with new information. + * + *

+ * Implementations should: + *

+ *
    + *
  • Validate the profile data before updating
  • + *
  • Persist the changes to the data store
  • + *
  • Return the updated profile instance
  • + *
+ * + * @param profile the profile to update + * @return the updated profile + * @throws IllegalArgumentException if profile is null or invalid + * @throws RuntimeException if profile update fails + */ + T updateProfile(T profile); +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/profile/session/BaseAuthenticationListener.java b/src/main/java/com/digitalsanctuary/spring/user/profile/session/BaseAuthenticationListener.java new file mode 100644 index 0000000..c3f6f31 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/profile/session/BaseAuthenticationListener.java @@ -0,0 +1,83 @@ +package com.digitalsanctuary.spring.user.profile.session; + +import org.springframework.context.ApplicationListener; +import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent; +import org.springframework.stereotype.Component; +import com.digitalsanctuary.spring.user.profile.BaseUserProfile; +import com.digitalsanctuary.spring.user.profile.UserProfileService; +import com.digitalsanctuary.spring.user.service.DSUserDetails; + +/** + * Base authentication listener that handles successful user authentication events by loading or creating the appropriate user profile and storing it + * in the session. This class provides the core functionality for maintaining user profile state across the application session. + * + *

+ * This listener automatically responds to successful interactive authentication events (like form login) by retrieving or creating a user profile via + * the {@link UserProfileService} and storing it in the session-scoped {@link BaseSessionProfile}. + *

+ * + *

+ * Example implementation: + *

+ * + *
+* {@code
+ * @Component
+ * public class CustomAuthenticationListener extends BaseAuthenticationListener {
+ *     public CustomAuthenticationListener(CustomSessionProfile sessionProfile, CustomUserProfileService profileService) {
+ *         super(sessionProfile, profileService);
+ *     }
+ * }
+ * }
+ * + * @param the type of user profile, must extend BaseUserProfile + * + * @see BaseSessionProfile + * @see UserProfileService + * @see InteractiveAuthenticationSuccessEvent + * @see DSUserDetails + */ +@Component +public abstract class BaseAuthenticationListener implements ApplicationListener { + + /** The session profile manager for storing user profile data. */ + private final BaseSessionProfile sessionProfile; + + /** The service for retrieving or creating user profiles. */ + private final UserProfileService profileService; + + /** + * Constructs a new BaseAuthenticationListener with the specified session profile and profile service. + * + * @param sessionProfile the session-scoped profile manager + * @param profileService the service for managing user profiles + * @throws IllegalArgumentException if either parameter is null + */ + protected BaseAuthenticationListener(BaseSessionProfile sessionProfile, UserProfileService profileService) { + if (sessionProfile == null || profileService == null) { + throw new IllegalArgumentException("Session profile and profile service must not be null"); + } + this.sessionProfile = sessionProfile; + this.profileService = profileService; + } + + /** + * Handles successful authentication events by loading or creating the user's profile and storing it in the session. + * + *

+ * This method is automatically called by Spring's event system when a user successfully authenticates. It checks if the authentication principal + * is a {@link DSUserDetails} instance, and if so, retrieves or creates the associated profile and stores it in the session. + *

+ * + * @param event the authentication success event + * @throws IllegalStateException if the authentication details are invalid or missing + */ + @Override + public void onApplicationEvent(InteractiveAuthenticationSuccessEvent event) { + if (event.getAuthentication().getPrincipal() instanceof DSUserDetails) { + DSUserDetails userDetails = (DSUserDetails) event.getAuthentication().getPrincipal(); + T profile = profileService.getOrCreateProfile(userDetails.getUser()); + sessionProfile.setUserProfile(profile); + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/profile/session/BaseSessionProfile.java b/src/main/java/com/digitalsanctuary/spring/user/profile/session/BaseSessionProfile.java new file mode 100644 index 0000000..9f45a98 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/profile/session/BaseSessionProfile.java @@ -0,0 +1,86 @@ +package com.digitalsanctuary.spring.user.profile.session; + +import java.io.Serializable; +import java.time.LocalDateTime; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.stereotype.Component; +import org.springframework.web.context.WebApplicationContext; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.profile.BaseUserProfile; +import lombok.Data; + +/** + * Base class for session-scoped user profile management. This class provides the foundation for maintaining user profile data within the session + * context of a web application. It is designed to be extended by applications to add custom profile management functionality. + * + *

+ * This class is session-scoped and uses proxy mode TARGET_CLASS to ensure proper session management in a web environment. It maintains a reference to + * the user's profile and tracks when it was last updated. + *

+ * + *

+ * Example usage: + *

+ * + *
+ * {@code
+ * @Component
+ * public class CustomSessionProfile extends BaseSessionProfile {
+ *     // Add custom methods for your application
+ *     public boolean hasSpecificPermission() {
+ *         return getUserProfile().getPermissions().contains("SPECIFIC_PERMISSION");
+ *     }
+ * }
+ * }
+ * + * @param the type of user profile, must extend BaseUserProfile + * + * @see BaseUserProfile + * @see WebApplicationContext + * @see Serializable + */ +@Data +@Component +@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS) +public abstract class BaseSessionProfile implements Serializable { + + /** Serialization version ID. */ + private static final long serialVersionUID = 1L; + + /** The current user's profile. */ + private T userProfile; + + /** Timestamp of when the profile was last updated. */ + private LocalDateTime lastUpdated; + + /** + * Retrieves the current user's profile. + * + * @return the user profile of type T, or null if no profile is set + */ + public T getUserProfile() { + return userProfile; + } + + /** + * Sets the user's profile and updates the lastUpdated timestamp. This method is typically called during authentication or when the profile data + * is modified. + * + * @param userProfile the user profile to set + */ + public void setUserProfile(T userProfile) { + this.userProfile = userProfile; + this.lastUpdated = LocalDateTime.now(); + } + + /** + * Convenience method to get the core User entity associated with the profile. + * + * @return the User entity if a profile is set, null otherwise + * @see User + */ + public User getUser() { + return userProfile != null ? userProfile.getUser() : null; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/AuthorityService.java b/src/main/java/com/digitalsanctuary/spring/user/service/AuthorityService.java new file mode 100644 index 0000000..1e157ca --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/service/AuthorityService.java @@ -0,0 +1,53 @@ +package com.digitalsanctuary.spring.user.service; + +import java.util.Collection; +import java.util.stream.Collectors; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.digitalsanctuary.spring.user.persistence.model.Privilege; +import com.digitalsanctuary.spring.user.persistence.model.Role; +import com.digitalsanctuary.spring.user.persistence.model.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * The AuthorityService class provides helper methods for generating Spring Security's GrantedAuthority objects from a collection of roles and + * privileges. + */ +@Slf4j +@RequiredArgsConstructor +@Service +@Transactional +public class AuthorityService { + + /** + * Generates the list of authorities for the given user from their roles and privileges. + * + * @param user The user whose authorities to generate. + * @return The list of authorities for the user. + */ + public Collection getAuthoritiesFromUser(User user) { + return getAuthoritiesFromRoles(user.getRoles()); + } + + /** + * + * Returns a collection of Spring Security's GrantedAuthority objects that corresponds to the privileges associated with the given collection of + * roles. + * + * @param roles a collection of roles whose privileges should be converted into Spring Security's GrantedAuthority objects + * @return a collection of Spring Security's GrantedAuthority objects that corresponds to the privileges associated with the given collection of + * roles + */ + public Collection getAuthoritiesFromRoles(Collection roles) { + // flatMap streams the roles, and maps each Role to its privileges (a Collection of Privilege objects). + // The stream of Collection objects is then flattened into a single stream of Privilege objects. + // Finally, each Privilege is mapped to its name as a String, wrapped in a SimpleGrantedAuthority object, + // and collected into a Set of GrantedAuthority objects. + return roles.stream().flatMap(role -> role.getPrivileges().stream()).map(Privilege::getName).map(SimpleGrantedAuthority::new) + .collect(Collectors.toSet()); + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java index f034fca..8590191 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSOAuth2UserService.java @@ -1,5 +1,7 @@ package com.digitalsanctuary.spring.user.service; +import java.util.Arrays; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; @@ -7,7 +9,10 @@ import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.digitalsanctuary.spring.user.audit.AuditEvent; import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,12 +39,26 @@ */ @Slf4j @Service +@Transactional @RequiredArgsConstructor public class DSOAuth2UserService implements OAuth2UserService { /** The user repository. */ private final UserRepository userRepository; + /** The role repository. */ + private final RoleRepository roleRepository; + + private final LoginHelperService loginHelperService; + + + + /** The Event Publisher. */ + private final ApplicationEventPublisher eventPublisher; + + /** The user role name. */ + private static final String USER_ROLE_NAME = "ROLE_USER"; + DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService(); /** @@ -68,7 +87,7 @@ public User handleOAuthLoginSuccess(String registrationId, OAuth2User oAuth2User "Sorry! An error occurred while processing your login request."); } log.debug("handleOAuthLoginSuccess: looking up user with email: {}", user.getEmail()); - User existingUser = userRepository.findByEmail(user.getEmail()); + User existingUser = userRepository.findByEmail(user.getEmail().toLowerCase()); log.debug("handleOAuthLoginSuccess: existingUser: {}", existingUser); if (existingUser != null && registrationId != null) { log.debug("handleOAuthLoginSuccess: existingUser.getProvider(): {}", existingUser.getProvider()); @@ -97,12 +116,17 @@ public User handleOAuthLoginSuccess(String registrationId, OAuth2User oAuth2User * @param user The User object representing the authenticated user. * @return A User object representing the newly registered user. */ + @Transactional private User registerNewOAuthUser(String registrationId, User user) { User.Provider provider = User.Provider.valueOf(registrationId.toUpperCase()); user.setProvider(provider); - // user.setRoles(Collections.singletonList(roleRepository.findByName(RoleName.ROLE_USER))); + user.setRoles(Arrays.asList(roleRepository.findByName(USER_ROLE_NAME))); // We will trust OAuth2 providers to provide us with a verified email address. user.setEnabled(true); + AuditEvent registrationAuditEvent = AuditEvent.builder().source(this).user(user).action("OAuth2 Registration Success").actionStatus("Success") + .message("Registration Confirmed. User logged in.").build(); + + eventPublisher.publishEvent(registrationAuditEvent); return userRepository.save(user); } @@ -184,8 +208,8 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic String registrationId = userRequest.getClientRegistration().getRegistrationId(); log.debug("registrationId: " + registrationId); User dbUser = handleOAuthLoginSuccess(registrationId, user); - DSUserDetails dsUserDetails = new DSUserDetails(dbUser); - return dsUserDetails; + DSUserDetails userDetails = loginHelperService.userLoginHelper(dbUser); + return userDetails; } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/DSUserDetailsService.java b/src/main/java/com/digitalsanctuary/spring/user/service/DSUserDetailsService.java index a6f6e03..2c2b905 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/DSUserDetailsService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/DSUserDetailsService.java @@ -1,16 +1,9 @@ package com.digitalsanctuary.spring.user.service; -import java.util.Collection; -import java.util.Date; -import java.util.stream.Collectors; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.digitalsanctuary.spring.user.persistence.model.Privilege; -import com.digitalsanctuary.spring.user.persistence.model.Role; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -29,8 +22,7 @@ public class DSUserDetailsService implements UserDetailsService { /** The user repository. */ private final UserRepository userRepository; - /** The login attempt service. */ - private final LoginAttemptService loginAttemptService; + private final LoginHelperService loginHelperService; /** The request. */ // private final HttpServletRequest request; @@ -44,44 +36,12 @@ public class DSUserDetailsService implements UserDetailsService { */ @Override public DSUserDetails loadUserByUsername(final String email) throws UsernameNotFoundException { - log.debug("DSUserDetailsService.loadUserByUsername:" + "called with username: {}", email); - - try { - User user = userRepository.findByEmail(email); - if (user == null) { - throw new UsernameNotFoundException("No user found with email/username: " + email); - } - // Updating lastActivity date for this login - user.setLastActivityDate(new Date()); - - // Check if the user account is locked, but should be unlocked now, and unlock it - user = loginAttemptService.checkIfUserShouldBeUnlocked(user); - - Collection authorities = getAuthorities(user.getRoles()); - DSUserDetails userDetails = new DSUserDetails(user, authorities); - return userDetails; - } catch (final Exception e) { - log.error("DSUserDetailsService.loadUserByUsername:" + "Exception!", e); - throw new RuntimeException(e); + log.debug("DSUserDetailsService.loadUserByUsername: called with username: {}", email); + User dbUser = userRepository.findByEmail(email); + if (dbUser == null) { + throw new UsernameNotFoundException("No user found with email/username: " + email); } - } - - /** - * - * Returns a collection of Spring Security's GrantedAuthority objects that corresponds to the privileges associated with the given collection of - * roles. - * - * @param roles a collection of roles whose privileges should be converted into Spring Security's GrantedAuthority objects - * @return a collection of Spring Security's GrantedAuthority objects that corresponds to the privileges associated with the given collection of - * roles - */ - private Collection getAuthorities(Collection roles) { - // flatMap streams the roles, and maps each Role to its privileges (a Collection of Privilege objects). - // The stream of Collection objects is then flattened into a single stream of Privilege objects. - // Finally, each Privilege is mapped to its name as a String, wrapped in a SimpleGrantedAuthority object, - // and collected into a Set of GrantedAuthority objects. - return roles.stream().flatMap(role -> role.getPrivileges().stream()).map(Privilege::getName).map(SimpleGrantedAuthority::new) - .collect(Collectors.toSet()); + return loginHelperService.userLoginHelper(dbUser); } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java b/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java new file mode 100644 index 0000000..411eb60 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/service/LoginHelperService.java @@ -0,0 +1,45 @@ +package com.digitalsanctuary.spring.user.service; + +import java.util.Collection; +import java.util.Date; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.digitalsanctuary.spring.user.persistence.model.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * The LoginHelperService class provides helper methods for authenticating users after login. This class is used by the DSUserDetailsService and + * DSOAuth2UserService classes to authenticate users after they have been successfully authenticated. + */ +@Slf4j +@RequiredArgsConstructor +@Service +@Transactional +public class LoginHelperService { + + /** The login attempt service. */ + private final LoginAttemptService loginAttemptService; + + private final AuthorityService authorityService; + + /** + * Helper method to authenticate a user after login. This method is called from the DSUserDetailsService and DSOAuth2UserService classes after a + * user has been successfully authenticated. + * + * @param dbUser The user to authenticate. + * @return The user details object. + */ + public DSUserDetails userLoginHelper(User dbUser) { + // Updating lastActivity date for this login + dbUser.setLastActivityDate(new Date()); + + // Check if the user account is locked, but should be unlocked now, and unlock it + dbUser = loginAttemptService.checkIfUserShouldBeUnlocked(dbUser); + + Collection authorities = authorityService.getAuthoritiesFromUser(dbUser); + DSUserDetails userDetails = new DSUserDetails(dbUser, authorities); + return userDetails; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index cc2c6de..210b909 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -10,7 +10,6 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -22,8 +21,6 @@ import com.digitalsanctuary.spring.user.dto.UserDto; import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException; import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; -import com.digitalsanctuary.spring.user.persistence.model.Privilege; -import com.digitalsanctuary.spring.user.persistence.model.Role; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; import com.digitalsanctuary.spring.user.persistence.repository.PasswordResetTokenRepository; @@ -101,8 +98,7 @@ *

*
    *
  • {@link #emailExists(String)}: Checks if an email exists in the user repository.
  • - *
  • {@link #getAuthorities(User)}: Generates the list of authorities for a user.
  • - *
  • {@link #authenticateUser(DSUserDetails, List)}: Authenticates a user by setting the authentication object in the security context.
  • + *
  • {@link #authenticateUser(DSUserDetails, Collection)}: Authenticates a user by setting the authentication object in the security context.
  • *
  • {@link #storeSecurityContextInSession()}: Stores the current security context in the session.
  • *
* @@ -193,6 +189,8 @@ public String getValue() { /** The user verification service. */ public final UserVerificationService userVerificationService; + private final AuthorityService authorityService; + /** The user details service. */ private final DSUserDetailsService dsUserDetailsService; @@ -323,7 +321,8 @@ public void changeUserPassword(final User user, final String password) { * @return true, if successful */ public boolean checkIfValidOldPassword(final User user, final String oldPassword) { - System.out.println(user.getPassword() + " " + oldPassword); + // Removed System.out.println, using log.debug for minimal output (avoid logging passwords in production) + log.debug("Verifying old password for user: {}", user.getEmail()); return passwordEncoder.matches(oldPassword, user.getPassword()); } @@ -334,7 +333,7 @@ public boolean checkIfValidOldPassword(final User user, final String oldPassword * @return true, if the email address is already in the user repository */ private boolean emailExists(final String email) { - return userRepository.findByEmail(email) != null; + return userRepository.findByEmail(email.toLowerCase()) != null; } /** @@ -395,7 +394,7 @@ public void authWithoutPassword(User user) { } // Generate authorities from user roles and privileges - List authorities = getAuthorities(user); + Collection authorities = authorityService.getAuthoritiesFromUser(user); // Authenticate user authenticateUser(userDetails, authorities); @@ -406,26 +405,13 @@ public void authWithoutPassword(User user) { log.debug("UserService.authWithoutPassword: authenticated user: {}", user.getEmail()); } - /** - * Generates the list of authorities for the given user from their roles and privileges. - * - * @param user The user whose authorities to generate. - * @return The list of authorities for the user. - */ - - private List getAuthorities(User user) { - List privileges = - user.getRoles().stream().map(Role::getPrivileges).flatMap(Collection::stream).distinct().collect(Collectors.toList()); - return privileges.stream().map(p -> new SimpleGrantedAuthority(p.getName())).collect(Collectors.toList()); - } - /** * Authenticates the user by creating an authentication object and setting it in the security context. * * @param userDetails The user details. * @param authorities The list of authorities for the user. */ - private void authenticateUser(DSUserDetails userDetails, List authorities) { + private void authenticateUser(DSUserDetails userDetails, Collection authorities) { Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } @@ -448,4 +434,6 @@ private void storeSecurityContextInSession() { session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext()); } + + } diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties index 38acbf5..31ce551 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -1,3 +1,12 @@ + +# Enable Spring Data JPA repository scanning +spring.data.jpa.repositories.enabled=true +spring.data.jpa.repositories.packages=com.digitalsanctuary.spring.user.persistence.repository + +# Enable JPA entity scanning +spring.jpa.entity.packages=com.digitalsanctuary.spring.user.persistence.model + + # Spring Configuration Overrides spring.messages.basename=messages/messages,messages/dsspringusermessages diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java index b8b0b74..b3a1858 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -44,6 +44,10 @@ public class UserServiceTest { public UserVerificationService userVerificationService; @Mock private DSUserDetailsService dsUserDetailsService; + + @Mock + private AuthorityService authorityService; + private UserService userService; private User testUser; private UserDto testUserDto; @@ -65,11 +69,8 @@ void setUp() { testUserDto.setPassword("testPassword"); testUserDto.setRole(1); - userService = new UserService( - userRepository, tokenRepository, passwordTokenRepository, - passwordEncoder, roleRepository, sessionRegistry, - userEmailService, userVerificationService, dsUserDetailsService - ); + userService = new UserService(userRepository, tokenRepository, passwordTokenRepository, passwordEncoder, roleRepository, sessionRegistry, + userEmailService, userVerificationService, authorityService, dsUserDetailsService); } @Test