diff --git a/assembly/src/assembly/empty-skeleton.xml b/assembly/src/assembly/empty-skeleton.xml index 677d8ec..6f7738f 100644 --- a/assembly/src/assembly/empty-skeleton.xml +++ b/assembly/src/assembly/empty-skeleton.xml @@ -33,7 +33,9 @@ src/main/bundles/** src/main/frontend/generated/** src/main/frontend/components/** + src/main/frontend/security/** src/main/frontend/views/** + src/main/frontend/index.tsx src/main/java/** src/test/java/** diff --git a/assembly/src/assembly/flow-skeleton.xml b/assembly/src/assembly/flow-skeleton.xml index 0e9a7e6..2a5fb16 100644 --- a/assembly/src/assembly/flow-skeleton.xml +++ b/assembly/src/assembly/flow-skeleton.xml @@ -9,6 +9,9 @@ src/main/java ${basedir}/target/generated-sources/flow + + com/example/application/security/hilla/** + @@ -30,7 +33,9 @@ src/main/bundles/** src/main/frontend/generated/** src/main/frontend/components/** + src/main/frontend/security/** src/main/frontend/views/** + src/main/frontend/index.tsx src/main/java/** diff --git a/walking-skeleton/pom.xml b/walking-skeleton/pom.xml index b933659..f745b43 100644 --- a/walking-skeleton/pom.xml +++ b/walking-skeleton/pom.xml @@ -64,6 +64,19 @@ com.vaadin vaadin-spring-boot-starter + + + com.vaadin + control-center-starter + 1.3.0.beta2 + + + org.flywaydb + flyway-core + + + @@ -86,6 +99,10 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-security + org.springframework.boot spring-boot-devtools @@ -98,6 +115,11 @@ spring-boot-starter-test test + + org.springframework.security + spring-security-test + test + org.testcontainers junit-jupiter diff --git a/walking-skeleton/src/main/frontend/index.tsx b/walking-skeleton/src/main/frontend/index.tsx new file mode 100644 index 0000000..84cc540 --- /dev/null +++ b/walking-skeleton/src/main/frontend/index.tsx @@ -0,0 +1,18 @@ +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { RouterProvider } from 'react-router'; +import { router } from 'Frontend/generated/routes.js'; +import { AuthProvider } from 'Frontend/security/auth'; + +function App() { + return ( + + + + ); +} + +const outlet = document.getElementById('outlet')!; +let root = (outlet as any)._root ?? createRoot(outlet); +(outlet as any)._root = root; +root.render(createElement(App)); diff --git a/walking-skeleton/src/main/frontend/security/auth.ts b/walking-skeleton/src/main/frontend/security/auth.ts new file mode 100644 index 0000000..23ac8a6 --- /dev/null +++ b/walking-skeleton/src/main/frontend/security/auth.ts @@ -0,0 +1,6 @@ +import { configureAuth } from '@vaadin/hilla-react-auth'; +import { CurrentUserService } from 'Frontend/generated/endpoints'; + +const auth = configureAuth(CurrentUserService.getUserInfo); +export const useAuth = auth.useAuth; +export const AuthProvider = auth.AuthProvider; diff --git a/walking-skeleton/src/main/frontend/views/@layout.tsx b/walking-skeleton/src/main/frontend/views/@layout.tsx index 86f8a0f..00b3e13 100644 --- a/walking-skeleton/src/main/frontend/views/@layout.tsx +++ b/walking-skeleton/src/main/frontend/views/@layout.tsx @@ -12,8 +12,10 @@ import { SideNav, SideNavItem, } from '@vaadin/react-components'; -import { Suspense } from 'react'; +import { Suspense, useMemo } from 'react'; import { createMenuItems } from '@vaadin/hilla-file-router/runtime.js'; +import { useAuth } from 'Frontend/security/auth'; +import { ViewConfig } from '@vaadin/hilla-file-router/types.js'; function Header() { // TODO Replace with real application logo and name @@ -41,22 +43,33 @@ function MainMenu() { ); } -type UserMenuItem = MenuBarItem<{ action?: () => void }>; +type UserMenuItem = MenuBarItem<{ action?: () => void | Promise }>; function UserMenu() { - // TODO Replace with real user information and actions + const { logout, state } = useAuth(); + + const fullName = state.user?.fullName; + const pictureUrl = state.user?.pictureUrl; + const profileUrl = state.user?.profileUrl; + + const children: Array = useMemo(() => { + const items: Array = []; + if (profileUrl) { + items.push({ text: 'View Profile', action: () => window.open(profileUrl, 'blank')?.focus() }); + } + // TODO Add additional items to the user menu if needed + items.push({ text: 'Logout', action: logout }); + return items; + }, [profileUrl, logout]); + const items: Array = [ { component: ( <> - John Smith + {fullName} ), - children: [ - { text: 'View Profile', disabled: true, action: () => console.log('View Profile') }, - { text: 'Manage Settings', disabled: true, action: () => console.log('Manage Settings') }, - { text: 'Logout', disabled: true, action: () => console.log('Logout') }, - ], + children: children, }, ]; const onItemSelected = (event: MenuBarItemSelectedEvent) => { @@ -67,6 +80,10 @@ function UserMenu() { ); } +export const config: ViewConfig = { + loginRequired: true, +}; + export default function MainLayout() { return ( diff --git a/walking-skeleton/src/main/frontend/views/task-list.tsx b/walking-skeleton/src/main/frontend/views/task-list.tsx index 4440c09..e75c818 100644 --- a/walking-skeleton/src/main/frontend/views/task-list.tsx +++ b/walking-skeleton/src/main/frontend/views/task-list.tsx @@ -15,6 +15,7 @@ export const config: ViewConfig = { order: 1, title: 'Task List', }, + loginRequired: true, }; const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { diff --git a/walking-skeleton/src/main/java/com/example/application/Application.java b/walking-skeleton/src/main/java/com/example/application/Application.java index 925aa89..cc6f06c 100644 --- a/walking-skeleton/src/main/java/com/example/application/Application.java +++ b/walking-skeleton/src/main/java/com/example/application/Application.java @@ -2,12 +2,47 @@ import com.vaadin.flow.component.page.AppShellConfigurator; import com.vaadin.flow.theme.Theme; -import org.springframework.boot.SpringApplication; +import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import java.time.Clock; +import java.util.Arrays; +/** + * Main Spring Boot application class for the Vaadin application. + * + *

Automatic Development Profile Activation

This application automatically activates the "dev" Spring profile + * when: + *
    + *
  • Vaadin is running in development mode (vaadin-dev-server dependency is present)
  • + *
  • No Spring profiles have been explicitly configured
  • + *
+ * + *

+ * If you need to use explicit profile management, remove the custom initializer from the {@link #main(String[])} method + * and use standard Spring profile activation: + *

+ * + * + *
+ * {@code
+ * // Replace the current main method with:
+ * public static void main(String[] args) {
+ *     SpringApplication.run(Application.class, args);
+ * }
+ *
+ * // Then use explicit profile activation:
+ * java -Dspring.profiles.active=prod -jar your-app.jar
+ * // or set SPRING_PROFILES_ACTIVE environment variable
+ * }
+ * 
+ * + * + * @see #enableDevelopmentModeIfNeeded(ConfigurableApplicationContext) + */ @SpringBootApplication @Theme("default") public class Application implements AppShellConfigurator { @@ -18,7 +53,65 @@ public Clock clock() { } public static void main(String[] args) { - SpringApplication.run(Application.class, args); + new SpringApplicationBuilder(Application.class).initializers(Application::enableDevelopmentModeIfNeeded) + .run(args); } -} + /** + * Conditionally activates the "dev" Spring profile based on Vaadin's runtime mode. + * + *

+ * This method is called during Spring context initialization and will activate the "dev" profile if and only if: + *

+ *
    + *
  • No Spring profiles are already active (excluding the default "default" profile)
  • + *
  • Vaadin is detected to be running in development mode
  • + *
+ * + *

+ * Note: If you prefer explicit profile management, remove this initializer from the main method + * and use standard Spring profile activation methods. + *

+ * + * @param context + * the Spring application context being initialized + * @see #isVaadinInDevelopmentMode() + */ + private static void enableDevelopmentModeIfNeeded(ConfigurableApplicationContext context) { + var environment = context.getEnvironment(); + + // Check if any profiles are already explicitly set + var activeProfiles = environment.getActiveProfiles(); + var defaultProfiles = environment.getDefaultProfiles(); + + var hasExplicitProfiles = activeProfiles.length > 0 + || (defaultProfiles.length > 0 && !Arrays.equals(defaultProfiles, new String[] { "default" })); + + if (!hasExplicitProfiles && isVaadinInDevelopmentMode()) { + LoggerFactory.getLogger(Application.class).warn("Automatically enabling the DEVELOPMENT profile"); + environment.setActiveProfiles("dev"); + } + } + + /** + * Determines if Vaadin is running in development mode by checking for the presence of development-specific classes + * in the classpath. + * + *

+ * This method uses classpath inspection rather than system properties because it's more reliable - the + * vaadin-dev-server dependency should only be present during development builds, not in production deployments. + *

+ * + * @return {@code true} if Vaadin development mode is detected, {@code false} otherwise + */ + private static boolean isVaadinInDevelopmentMode() { + try { + // This class is in the 'vaadin-dev-server' dependency, which should be in the classpath when running + // in development mode, but not when running in production mode. + Class.forName("com.vaadin.base.devserver.ServerInfo"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } +} \ No newline at end of file diff --git a/walking-skeleton/src/main/java/com/example/application/base/ui/view/MainLayout.java b/walking-skeleton/src/main/java/com/example/application/base/ui/view/MainLayout.java index 5e11208..f5fe81c 100644 --- a/walking-skeleton/src/main/java/com/example/application/base/ui/view/MainLayout.java +++ b/walking-skeleton/src/main/java/com/example/application/base/ui/view/MainLayout.java @@ -1,6 +1,8 @@ package com.example.application.base.ui.view; +import com.example.application.security.CurrentUser; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; import com.vaadin.flow.component.applayout.AppLayout; import com.vaadin.flow.component.avatar.Avatar; import com.vaadin.flow.component.avatar.AvatarVariant; @@ -16,6 +18,7 @@ import com.vaadin.flow.router.Layout; import com.vaadin.flow.server.menu.MenuConfiguration; import com.vaadin.flow.server.menu.MenuEntry; +import com.vaadin.flow.spring.security.AuthenticationContext; import jakarta.annotation.security.PermitAll; import static com.vaadin.flow.theme.lumo.LumoUtility.*; @@ -24,7 +27,12 @@ @PermitAll // When security is enabled, allow all authenticated users public final class MainLayout extends AppLayout { - MainLayout() { + private final CurrentUser currentUser; + private final AuthenticationContext authenticationContext; + + MainLayout(CurrentUser currentUser, AuthenticationContext authenticationContext) { + this.currentUser = currentUser; + this.authenticationContext = authenticationContext; setPrimarySection(Section.DRAWER); addToDrawer(createHeader(), new Scroller(createSideNav()), createUserMenu()); } @@ -58,8 +66,9 @@ private SideNavItem createSideNavItem(MenuEntry menuEntry) { } private Component createUserMenu() { - // TODO Replace with real user information and actions - var avatar = new Avatar("John Smith"); + var user = currentUser.require(); + + var avatar = new Avatar(user.getFullName(), user.getPictureUrl()); avatar.addThemeVariants(AvatarVariant.LUMO_XSMALL); avatar.addClassNames(Margin.Right.SMALL); avatar.setColorIndex(5); @@ -69,10 +78,13 @@ private Component createUserMenu() { userMenu.addClassNames(Margin.MEDIUM); var userMenuItem = userMenu.addItem(avatar); - userMenuItem.add("John Smith"); - userMenuItem.getSubMenu().addItem("View Profile").setEnabled(false); - userMenuItem.getSubMenu().addItem("Manage Settings").setEnabled(false); - userMenuItem.getSubMenu().addItem("Logout").setEnabled(false); + userMenuItem.add(user.getFullName()); + if (user.getProfileUrl() != null) { + userMenuItem.getSubMenu().addItem("View Profile", + event -> UI.getCurrent().getPage().open(user.getProfileUrl())); + } + // TODO Add additional items to the user menu if needed + userMenuItem.getSubMenu().addItem("Logout", event -> authenticationContext.logout()); return userMenu; } diff --git a/walking-skeleton/src/main/java/com/example/application/security/AppRoles.java b/walking-skeleton/src/main/java/com/example/application/security/AppRoles.java new file mode 100644 index 0000000..b73b13d --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/AppRoles.java @@ -0,0 +1,65 @@ +package com.example.application.security; + +/** + * Constants for application role names used throughout the security system. + *

+ * This class centralizes the definition of all role names used in the application, ensuring consistency across security + * configurations, annotations, and tests. Using these constants instead of string literals helps prevent typos and + * makes role management more maintainable. + *

+ *

+ * These role names are used in various contexts: + *

    + *
  • Security method annotations: {@code @PreAuthorize("hasRole('" + AppRoles.ADMIN + "')")}
  • + *
  • Security configuration and access control rules
  • + *
+ *

+ *

+ * Customization: Modify or add role constants in this class to match your application's security + * requirements. Consider the principle of least privilege when defining roles and their associated permissions. + *

+ *

+ * Example usage: + *

+ * {@code
+ * // In security annotations
+ * @PreAuthorize("hasRole('" + AppRoles.ADMIN + "')")
+ * public void adminOnlyMethod() { ... }
+ *
+ * @Route
+ * @RolesAllowed(AppRoles.ADMIN)
+ * public class AdminView extends Main { ... }
+ * }
+ * 
+ * + *

+ * + * @see org.springframework.security.access.prepost.PreAuthorize + */ +public final class AppRoles { + + /** + * Private constructor to prevent instantiation of this utility class. + */ + private AppRoles() { + } + + /** + * Role for administrative users with elevated privileges. + *

+ * Users with this role typically have access to administrative functions such as user management, system + * configuration, and sensitive operations. Use this role sparingly and only for users who require administrative + * access. + *

+ */ + public static final String ADMIN = "ADMIN"; + + /** + * Role for standard application users. + *

+ * This is the default role for regular users of the application. Users with this role have access to standard + * application features but not administrative functions. + *

+ */ + public static final String USER = "USER"; +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/AppUserInfo.java b/walking-skeleton/src/main/java/com/example/application/security/AppUserInfo.java new file mode 100644 index 0000000..1dca6ce --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/AppUserInfo.java @@ -0,0 +1,137 @@ +package com.example.application.security; + +import com.example.application.security.domain.UserId; +import org.jspecify.annotations.Nullable; + +import java.time.ZoneId; +import java.util.Locale; + +/** + * Interface for accessing information about an application user. + *

+ * This interface provides standard methods to access user identity and profile information throughout the application. + * It can be used in various contexts, both for retrieving the current authenticated user and for accessing information + * about any user in the system (e.g., when displaying audit information about who last modified a record). + *

+ *

+ * Note: This interface intentionally does not extend {@link org.springframework.security.core.userdetails.UserDetails + * UserDetails} or {@link org.springframework.security.core.AuthenticatedPrincipal AuthenticatedPrincipal} to maintain + * separation between authentication concerns and general user information access. For the same reason, it does not + * contain information about the user's roles or authorities. + *

+ */ +public interface AppUserInfo { + + /** + * Returns the user's unique identifier within the application. + *

+ * For OIDC authenticated users, this typically corresponds to the "subject" claim. This identifier remains + * consistent across sessions and is used as the primary key for user-related data. + *

+ * + * @return the unique user identifier (never {@code null}) + */ + UserId getUserId(); + + /** + * Returns the user's preferred username for display and identification purposes. + *

+ * This username is intended for human-readable display in user interfaces and may be different from the unique + * {@link #getUserId()}. Unlike the user ID, the preferred username is typically chosen by the user and may be more + * meaningful to them and other users of the application. + *

+ *

+ * Important: The preferred username can change over time as users update their profiles. It should + * not be used as a permanent identifier for users in database relationships, audit logs, or any + * other persistent storage. Use {@link #getUserId()} for permanent user identification. The preferred username + * should only be used for identification when entered by a human user (e.g., in search forms or user lookup + * interfaces). + *

+ *

+ * For OIDC authenticated users, this typically corresponds to the "preferred_username" claim. + *

+ * + * @return the user's preferred username (never {@code null}) + * @see #getUserId() For permanent, immutable user identification + */ + String getPreferredUsername(); + + /** + * Returns the user's full display name. + *

+ * This typically combines the user's first and last name in a format appropriate for display in the user interface. + * If the user has no full name, the preferred username is used instead. + *

+ * + * @return the user's full name (never {@code null}) + */ + default String getFullName() { + return getPreferredUsername(); + } + + /** + * Returns a URL to the user's profile page in the application or external system. + *

+ * Implementations may return {@code null} if no profile page is available or if the current context doesn't have + * permission to access this information. + *

+ * + * @return URL to the user's profile, or {@code null} if not available + */ + default @Nullable String getProfileUrl() { + return null; + } + + /** + * Returns a URL to the user's profile picture or avatar. + *

+ * Implementations may return {@code null} if no picture is available or if the current context doesn't have + * permission to access this information. + *

+ * + * @return URL to the user's picture, or {@code null} if not available + */ + default @Nullable String getPictureUrl() { + return null; + } + + /** + * Returns the user's email address. + *

+ * This email address is considered the primary contact method for the user and may be used for notifications and + * communications. + *

+ * + * @return the user's email address, or {@code null} if not available + */ + default @Nullable String getEmail() { + return null; + } + + /** + * Returns the user's preferred time zone. + *

+ * This time zone is used for displaying dates and times in the user interface. If the user has not explicitly set a + * time zone preference, the system default time zone is returned as a fallback. + *

+ * + * @return the user's time zone (never {@code null}) + */ + default ZoneId getZoneId() { + return ZoneId.systemDefault(); + } + + /** + * Returns the user's preferred locale for internationalization. + *

+ * This locale is used for language selection and formatting of numbers, dates, and currencies in the user + * interface. If the user has not explicitly set a locale preference, the system default locale is returned as a + * fallback. + *

+ * + * @return the user's locale (never {@code null}) + */ + default Locale getLocale() { + return Locale.getDefault(); + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/AppUserPrincipal.java b/walking-skeleton/src/main/java/com/example/application/security/AppUserPrincipal.java new file mode 100644 index 0000000..9de671e --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/AppUserPrincipal.java @@ -0,0 +1,52 @@ +package com.example.application.security; + +import org.springframework.security.core.GrantedAuthority; + +import java.security.Principal; +import java.util.Collection; + +/** + * Interface for principals that provide access to application user information. + *

+ * This interface extends {@link Principal} to ensure it can be used as a security principal while providing consistent + * access to the application's user information model. + *

+ *

+ * All authenticated principals in the application should implement this interface, allowing security expressions to + * access user information with a consistent pattern: {@code authentication.principal.appUser.userId} + *

+ */ +public interface AppUserPrincipal extends Principal { + + /** + * Returns the application's user information. + *

+ * This method provides consistent access to the application's user model regardless of the authentication + * mechanism. + *

+ * + * @return the application user information (never {@code null}) + */ + AppUserInfo getAppUser(); + + /** + * Returns the name of the principal, which is the user ID as a string. + *

+ * This default implementation uses the user ID from {@link #getAppUser()} as the principal name, ensuring a + * consistent, unique identifier across all authentication mechanisms. + *

+ * + * @return the principal name (never {@code null}) + */ + @Override + default String getName() { + return getAppUser().getUserId().toString(); + } + + /** + * Returns the authorities of the principal. + * + * @return an unmodifiable collection of granted authorities (never {@code null}) + */ + Collection getAuthorities(); +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/CommonSecurityConfig.java b/walking-skeleton/src/main/java/com/example/application/security/CommonSecurityConfig.java new file mode 100644 index 0000000..ca4a450 --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/CommonSecurityConfig.java @@ -0,0 +1,173 @@ +package com.example.application.security; + +import com.example.application.security.domain.UserId; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.auditing.DateTimeProvider; +import org.springframework.data.domain.AuditorAware; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolderStrategy; + +import java.time.Clock; +import java.util.Optional; + +/** + * Common security configuration that enables method-level security and JPA auditing. + *

+ * This configuration provides foundational security and auditing capabilities that are shared across all authentication + * mechanisms in the application. It enables: + *

    + *
  • Spring Security's method-level security annotations
  • + *
  • JPA auditing with automatic tracking of who and when entities are modified
  • + *
  • A {@link CurrentUser} for accessing information about the current user, using the application's security + * model
  • + *
+ *

+ *

+ * Method security allows the use of the following annotations throughout the application: + *

    + *
  • {@link org.springframework.security.access.prepost.PreAuthorize @PreAuthorize} - Controls method access based on + * expressions evaluated before method execution
  • + *
  • {@link org.springframework.security.access.prepost.PostAuthorize @PostAuthorize} - Controls method access based + * on expressions evaluated after method execution
  • + *
  • {@link org.springframework.security.access.prepost.PreFilter @PreFilter} - Filters method arguments before method + * execution
  • + *
  • {@link org.springframework.security.access.prepost.PostFilter @PostFilter} - Filters method return values after + * method execution
  • + *
+ *

+ *

+ * JPA auditing automatically populates audit fields in entities annotated with + * {@link org.springframework.data.annotation.CreatedBy @CreatedBy}, + * {@link org.springframework.data.annotation.LastModifiedBy @LastModifiedBy}, + * {@link org.springframework.data.annotation.CreatedDate @CreatedDate}, and + * {@link org.springframework.data.annotation.LastModifiedDate @LastModifiedDate}. Important: Entities + * must also be annotated with + * {@link org.springframework.data.jpa.domain.support.AuditingEntityListener @EntityListeners(AuditingEntityListener.class)} + * (or have a superclass with this annotation) for the auditing annotations to work. + *

+ *

+ * All authenticated principals must implement {@link AppUserPrincipal}, allowing security expressions to access user + * information with the pattern: {@code authentication.principal.appUser.userId} + *

+ *

+ * Example usage in entities and methods: + *

+ * {@code
+ * // JPA Entity with auditing
+ * @Entity
+ * public class Document {
+ *     @CreatedBy
+ *     private UserId createdBy;
+ *
+ *     @LastModifiedBy
+ *     private UserId lastModifiedBy;
+ *
+ *     @CreatedDate
+ *     private Instant createdDate;
+ *
+ *     @LastModifiedDate
+ *     private Instant lastModifiedDate;
+ * }
+ *
+ * // Method security
+ * @PreAuthorize("authentication.principal.appUser.userId == #document.ownerId")
+ * public void updateDocument(Document document) { ... }
+ *
+ * @PostAuthorize("returnObject.createdBy == authentication.principal.appUser.userId")
+ * public Document getDocument(long id) { ... }
+ * }
+ * 
+ * + *

+ * + * @see AppUserPrincipal The principal interface that all authenticated users implement + * @see CurrentUser Utility for accessing the current user information + * @see org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity + * @see org.springframework.data.jpa.repository.config.EnableJpaAuditing + * @see org.springframework.data.jpa.domain.support.AuditingEntityListener + */ +@EnableMethodSecurity +@EnableJpaAuditing +@Configuration +class CommonSecurityConfig { + + /** + * Provides the current user for JPA auditing purposes. + *

+ * This bean is used by Spring Data JPA to automatically populate + * {@link org.springframework.data.annotation.CreatedBy @CreatedBy} and + * {@link org.springframework.data.annotation.LastModifiedBy @LastModifiedBy} fields in audited entities with the + * current user's ID. + *

+ *

+ * If no user is currently authenticated, the auditor will be empty, and the audit fields will remain null. + *

+ *

+ * Note: Entities must be annotated with {@code @EntityListeners(AuditingEntityListener.class)} for + * this auditor to be used. + *

+ * + * @return an {@link AuditorAware} that provides the current user's ID + */ + @Bean + public AuditorAware auditorAware(CurrentUser currentUser) { + return () -> currentUser.get().map(AppUserInfo::getUserId); + } + + /** + * Provides the current date and time for JPA auditing purposes. + *

+ * This bean is used by Spring Data JPA to automatically populate + * {@link org.springframework.data.annotation.CreatedDate @CreatedDate} and + * {@link org.springframework.data.annotation.LastModifiedDate @LastModifiedDate} fields in audited entities with + * the current timestamp. + *

+ *

+ * The date and time are provided by the injected {@link Clock}, allowing for consistent time handling and easier + * testing with fixed clocks. + *

+ *

+ * Note: Entities must be annotated with {@code @EntityListeners(AuditingEntityListener.class)} for + * this date time provider to be used. + *

+ * + * @param clock + * the clock to use for generating timestamps + * @return a {@link DateTimeProvider} that provides the current instant + */ + @Bean + public DateTimeProvider dateTimeProvider(Clock clock) { + return () -> Optional.of(clock.instant()); + } + + /** + * Provides access to the currently authenticated user's information. + *

+ * This method creates a {@link CurrentUser} service that can be used throughout the application to safely access + * information about the currently authenticated user. The service uses the provided + * {@link SecurityContextHolderStrategy} to retrieve the security context and extract user details from the + * authentication principal. + *

+ *

+ * The {@link CurrentUser} service provides both optional access via {@link CurrentUser#get()} for cases where + * authentication may not be present, and required access via {@link CurrentUser#require()} for endpoints that + * mandate authentication. It expects all authenticated principals to implement {@link AppUserPrincipal}. + *

+ *

+ * Note: This bean is also used by the JPA auditing configuration to automatically populate audit + * fields in entities with the current user's ID. + *

+ * + * @param securityContextHolderStrategy + * the strategy for accessing the security context + * @return a {@link CurrentUser} service for accessing current user information + * @see AppUserPrincipal The principal interface that all authenticated users must implement + * @see CurrentUser The service class for accessing current user information + */ + @Bean + public CurrentUser currentUser(SecurityContextHolderStrategy securityContextHolderStrategy) { + return new CurrentUser(securityContextHolderStrategy); + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/CurrentUser.java b/walking-skeleton/src/main/java/com/example/application/security/CurrentUser.java new file mode 100644 index 0000000..c84da1f --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/CurrentUser.java @@ -0,0 +1,161 @@ +package com.example.application.security; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolderStrategy; + +import java.util.Optional; + +import static java.util.Objects.requireNonNull; + +/** + * Service for retrieving the currently authenticated user from the Spring Security context. + *

+ * This service provides methods to safely access user information stored in the authentication principal, supporting + * principals that implement {@link AppUserPrincipal}. It serves as a bridge between Spring Security's authentication + * model and the application's user information model. + *

+ *

+ * Usage examples (assumes {@code currentUser} has been injected): + * + * + *

+ * {@code
+ * // Get the current user if available
+ * Optional currentUser = currentUser.get();
+ *
+ * // Get the current user, throwing an exception if not authenticated
+ * AppUserInfo user = currentUser.require();
+ *
+ * // Access user properties
+ * String fullName = currentUser.require().fullName();
+ * }
+ * 
+ * + *

+ * + * @see AppUserInfo The application's user information model + * @see AppUserPrincipal The principal interface that provides access to user information + */ +public class CurrentUser { + + private static final Logger log = LoggerFactory.getLogger(CurrentUser.class); + + private final SecurityContextHolderStrategy securityContextHolderStrategy; + + /** + * Creates a new {@code CurrentUser} service for the given {@link SecurityContextHolderStrategy}. + *

+ * This constructor uses the new Spring Security recommendation of accessing the + * {@link SecurityContextHolderStrategy} as a bean rather than using the static methods of + * {@link org.springframework.security.core.context.SecurityContextHolder}. + *

+ * + * @param securityContextHolderStrategy + * the strategy used to fetch the security context (never {@code null}). + */ + CurrentUser(SecurityContextHolderStrategy securityContextHolderStrategy) { + this.securityContextHolderStrategy = requireNonNull(securityContextHolderStrategy); + } + + /** + * Returns the currently authenticated user from the security context. + *

+ * This method safely extracts user information from the current security context without throwing exceptions for + * unauthenticated requests or incompatible principal types. + *

+ *

+ * The method expects the authentication principal to implement {@link AppUserPrincipal}. If the principal doesn't + * implement this interface, a warning is logged and an empty Optional is returned. + *

+ * + * @return an {@code Optional} containing the current user if authenticated and accessible, or an empty + * {@code Optional} if there is no authenticated user or the principal doesn't implement + * {@link AppUserPrincipal} + * @see #require() For cases where authentication is required + */ + public Optional get() { + return getPrincipal().map(AppUserPrincipal::getAppUser); + } + + /** + * Returns the currently authenticated principal from the security context. + *

+ * This method safely extracts the principal from the current security context without throwing exceptions for + * unauthenticated requests or incompatible principal types. + *

+ *

+ * The method expects the authentication principal to implement {@link AppUserPrincipal}. If the principal doesn't + * implement this interface, a warning is logged and an empty Optional is returned. + *

+ * + * @return an {@code Optional} containing the current principal if authenticated and accessible, or an empty + * {@code Optional} if there is no authenticated user or the principal doesn't implement + * {@link AppUserPrincipal} + * @see #requirePrincipal() For cases where authentication is required + */ + public Optional getPrincipal() { + return Optional.ofNullable( + getPrincipalFromAuthentication(securityContextHolderStrategy.getContext().getAuthentication())); + } + + /** + * Extracts the principal from the provided authentication object. + * + * @param authentication + * the authentication object from which to extract the principal, may be {@code null} + * @return the principal if available, or {@code null} if it cannot be extracted + */ + private @Nullable AppUserPrincipal getPrincipalFromAuthentication(@Nullable Authentication authentication) { + if (authentication == null || authentication.getPrincipal() == null + || authentication instanceof AnonymousAuthenticationToken) { + return null; + } + + var principal = authentication.getPrincipal(); + + if (principal instanceof AppUserPrincipal appUserPrincipal) { + return appUserPrincipal; + } + + log.warn("Unexpected principal type: {}", principal.getClass().getName()); + + return null; + } + + /** + * Returns the currently authenticated user from the security context. + *

+ * Unlike {@link #get()}, this method throws an exception if no user is authenticated, making it suitable for + * endpoints that require authentication. + *

+ * + * @return the currently authenticated user (never {@code null}) + * @throws AuthenticationCredentialsNotFoundException + * if there is no authenticated user, or the authenticated principal doesn't implement + * {@link AppUserPrincipal} + */ + public AppUserInfo require() { + return get().orElseThrow(() -> new AuthenticationCredentialsNotFoundException("No current user")); + } + + /** + * Returns the currently authenticated principal from the security context. + *

+ * Unlike {@link #getPrincipal()}, this method throws an exception if no user is authenticated, making it suitable + * for endpoints that require authentication. + *

+ * + * @return the currently authenticated principal (never {@code null}) + * @throws AuthenticationCredentialsNotFoundException + * if there is no authenticated user, or the authenticated principal doesn't implement + * {@link AppUserPrincipal} + */ + public AppUserPrincipal requirePrincipal() { + return getPrincipal().orElseThrow(() -> new AuthenticationCredentialsNotFoundException("No current user")); + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/cc/CCSecurityConfig.java b/walking-skeleton/src/main/java/com/example/application/security/cc/CCSecurityConfig.java new file mode 100644 index 0000000..4e208fd --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/cc/CCSecurityConfig.java @@ -0,0 +1,120 @@ +package com.example.application.security.cc; + +import com.vaadin.controlcenter.starter.idm.IdentityManagementConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform; +import org.springframework.boot.cloud.CloudPlatform; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.util.StringUtils; + +/** + * Security configuration for production environments where the application is deployed and managed by Vaadin Control + * Center (CC). + * + *

+ * This configuration is specifically designed for Kubernetes deployments and integrates with Vaadin Control Center's + * Identity Management system using OIDC (OpenID Connect) and Keycloak as the identity provider. + *

+ * + *

+ * The configuration is conditionally activated when: + *

+ *
    + *
  • The application is NOT running in 'dev' profile
  • + *
  • The application is deployed on Kubernetes platform
  • + *
+ * + *

+ * This class extends {@link IdentityManagementConfiguration} to leverage Control Center's built-in identity management + * capabilities while customizing OIDC user mapping to use the application's own security model. + *

+ * + * @see IdentityManagementConfiguration + */ +@EnableWebSecurity +@Configuration +@Profile("!dev") +@ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES) +class CCSecurityConfig extends IdentityManagementConfiguration { + + /** + * Creates and configures an OIDC user service with custom user mapping for the application. + * + *

+ * This bean configures how Spring Security should load and map OIDC user information after successful + * authentication. The service uses a custom mapper that transforms the standard OIDC user data from Keycloak into + * the application's own security model via {@link OidcUserAdapter}. + *

+ * + *

+ * Spring Security will use this service during the OIDC authentication flow to create application-specific user + * objects from the authenticated OIDC user information. + *

+ * + * @return configured {@link OidcUserService} instance with custom user mapping + * @see OidcUserService + * @see #mapOidcUser(OidcUserRequest, OidcUserInfo) + */ + @Bean + OidcUserService oidcUserService() { + var userService = new OidcUserService(); + userService.setOidcUserMapper(CCSecurityConfig::mapOidcUser); + return userService; + } + + /** + * Maps OIDC user request and user info to an application-specific {@link OidcUserAdapter} instance. + * + *

+ * This method transforms the raw OIDC user information received from Keycloak into the application's own security + * model by first creating a standard {@link DefaultOidcUser} and then wrapping it in an {@link OidcUserAdapter} + * that adapts the OIDC user to the application's security model. + *

+ *

+ * This adapter pattern allows the application to: + *

    + *
  • Use consistent security expressions like {@code authentication.principal.appUser.userId}
  • + *
  • Avoid method name conflicts between OIDC interfaces and the application's user model
  • + *
  • Provide a uniform interface for all authentication mechanisms
  • + *
+ *

+ *

+ * The mapping process: + *

+ *
    + *
  1. Extracts and maps user authorities from the OIDC user request and info
  2. + *
  3. Retrieves the username attribute name from the provider configuration
  4. + *
  5. Creates a {@link DefaultOidcUser} with the mapped authorities, ID token, user info, and optionally the + * username attribute name
  6. + *
  7. Wraps the {@link DefaultOidcUser} in an application-specific {@link OidcUserAdapter} that bridges OIDC + * authentication with the application's security model
  8. + *
+ * + * @param userRequest + * the OIDC user request containing client registration and tokens + * @param userInfo + * the OIDC user information containing user claims and attributes + * @return an {@link OidcUserAdapter} instance that adapts the OIDC user to the application's security model + * @throws IllegalArgumentException + * if userRequest or userInfo is null + * @see DefaultOidcUser + * @see OidcUserAdapter + * @see #mapAuthorities(OidcUserRequest, OidcUserInfo) + */ + private static OidcUser mapOidcUser(OidcUserRequest userRequest, OidcUserInfo userInfo) { + var authorities = mapAuthorities(userRequest, userInfo); + var providerDetails = userRequest.getClientRegistration().getProviderDetails(); + var userNameAttributeName = providerDetails.getUserInfoEndpoint().getUserNameAttributeName(); + var oidcUser = StringUtils.hasText(userNameAttributeName) + ? new DefaultOidcUser(authorities, userRequest.getIdToken(), userInfo, userNameAttributeName) + : new DefaultOidcUser(authorities, userRequest.getIdToken(), userInfo); + return new OidcUserAdapter(oidcUser); + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/cc/OidcUserAdapter.java b/walking-skeleton/src/main/java/com/example/application/security/cc/OidcUserAdapter.java new file mode 100644 index 0000000..22f022a --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/cc/OidcUserAdapter.java @@ -0,0 +1,196 @@ +package com.example.application.security.cc; + +import com.example.application.security.AppUserInfo; +import com.example.application.security.AppUserPrincipal; +import com.example.application.security.domain.UserId; +import org.jspecify.annotations.Nullable; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import java.time.DateTimeException; +import java.time.ZoneId; +import java.util.Collection; +import java.util.Locale; +import java.util.Map; + +import static java.util.Objects.requireNonNull; + +/** + * Adapter implementation that bridges Spring Security's OIDC user representation with the application's + * {@link AppUserInfo} interface. + *

+ * This class wraps an {@link OidcUser} and implements {@link AppUserPrincipal} to provide consistent access to user + * information throughout the application without method conflicts. + *

+ */ +final class OidcUserAdapter implements OidcUser, AppUserPrincipal { + + private final OidcUser delegate; + private final AppUserInfo appUserInfo; + + /** + * Creates a new adapter for the specified OIDC user. + * + * @param oidcUser + * the OIDC user to adapt (never {@code null}) + */ + public OidcUserAdapter(OidcUser oidcUser) { + this.delegate = requireNonNull(oidcUser); + this.appUserInfo = createAppUserInfo(oidcUser); + } + + private static AppUserInfo createAppUserInfo(OidcUser oidcUser) { + return new AppUserInfo() { + private final UserId userId = UserId.of(oidcUser.getSubject()); + private final String preferredUsername = requireNonNull(oidcUser.getPreferredUsername()); + private final String fullName = requireNonNull(oidcUser.getFullName()); + private final ZoneId zoneId = parseZoneInfo(oidcUser.getZoneInfo()); + private final Locale locale = parseLocale(oidcUser.getLocale()); + + @Override + public UserId getUserId() { + return userId; + } + + @Override + public String getPreferredUsername() { + return preferredUsername; + } + + @Override + public String getFullName() { + return fullName; + } + + @Override + public @Nullable String getProfileUrl() { + return oidcUser.getProfile(); + } + + @Override + public @Nullable String getPictureUrl() { + return oidcUser.getPicture(); + } + + @Override + public @Nullable String getEmail() { + return oidcUser.getEmail(); + } + + @Override + public ZoneId getZoneId() { + return zoneId; + } + + @Override + public Locale getLocale() { + return locale; + } + }; + } + + /** + * Parses a zone info string into a {@link ZoneId}, with fallback to system default. + *

+ * This utility method safely converts OIDC "zoneinfo" claim values or similar timezone identifiers into Java + * {@link ZoneId} objects. It handles invalid or null input gracefully by falling back to the system default + * timezone. + *

+ *

+ * The method accepts standard timezone identifiers such as: + *

    + *
  • "America/New_York"
  • + *
  • "Europe/London"
  • + *
  • "UTC"
  • + *
  • "+02:00" (offset-based IDs)
  • + *
+ *

+ * + * @param zoneInfo + * the timezone identifier string, may be {@code null} + * @return a {@link ZoneId} parsed from the input, or the system default if the input is null or invalid + * @see ZoneId#of(String) The underlying parsing method + * @see ZoneId#systemDefault() The fallback used for invalid input + */ + static ZoneId parseZoneInfo(@Nullable String zoneInfo) { + if (zoneInfo == null) { + return ZoneId.systemDefault(); + } + try { + return ZoneId.of(zoneInfo); + } catch (DateTimeException e) { + return ZoneId.systemDefault(); + } + } + + /** + * Parses a locale string into a {@link Locale}, with fallback to system default. + *

+ * This utility method safely converts OIDC "locale" claim values or similar locale identifiers into Java + * {@link Locale} objects. It handles null input by falling back to the system default locale. + *

+ *

+ * The method accepts standard locale tags such as: + *

    + *
  • "en-US" (English, United States)
  • + *
  • "fr-FR" (French, France)
  • + *
  • "en" (English, no specific region)
  • + *
  • "zh-Hans-CN" (Chinese, Simplified script, China)
  • + *
+ *

+ *

+ * Unlike {@link #parseZoneInfo(String)}, this method does not catch parsing exceptions, as + * {@link Locale#forLanguageTag(String)} handles malformed tags gracefully by returning a best-effort locale or the + * root locale. + *

+ * + * @param locale + * the locale identifier string (BCP 47 language tag), may be {@code null} + * @return a {@link Locale} parsed from the input, or the system default if the input is null + * @see Locale#forLanguageTag(String) The underlying parsing method + * @see Locale#getDefault() The fallback used for null input + */ + static Locale parseLocale(@Nullable String locale) { + if (locale == null) { + return Locale.getDefault(); + } + return Locale.forLanguageTag(locale); + } + + @Override + public AppUserInfo getAppUser() { + return appUserInfo; + } + + @Override + public Map getClaims() { + return delegate.getClaims(); + } + + @Override + public OidcUserInfo getUserInfo() { + return delegate.getUserInfo(); + } + + @Override + public OidcIdToken getIdToken() { + return delegate.getIdToken(); + } + + @Override + public Map getAttributes() { + return delegate.getAttributes(); + } + + @Override + public Collection getAuthorities() { + return delegate.getAuthorities(); + } + + @Override + public String getName() { + return delegate.getName(); + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/cc/package-info.java b/walking-skeleton/src/main/java/com/example/application/security/cc/package-info.java new file mode 100644 index 0000000..3e94fe0 --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/cc/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.example.application.security.cc; + +import org.jspecify.annotations.NullMarked; \ No newline at end of file diff --git a/walking-skeleton/src/main/java/com/example/application/security/dev/DevSecurityConfig.java b/walking-skeleton/src/main/java/com/example/application/security/dev/DevSecurityConfig.java new file mode 100644 index 0000000..c4d356c --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/dev/DevSecurityConfig.java @@ -0,0 +1,139 @@ +package com.example.application.security.dev; + +import com.vaadin.flow.server.VaadinServiceInitListener; +import com.vaadin.flow.spring.security.VaadinAwareSecurityContextHolderStrategyConfiguration; +import com.vaadin.flow.spring.security.VaadinSecurityConfigurer; +import jakarta.servlet.Filter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import java.io.PrintWriter; +import java.io.StringWriter; + +/** + * Security configuration for the development environment. + *

+ * This configuration simplifies authentication during development by: + *

    + *
  • Using a simple login view for authentication
  • + *
  • Providing predefined test users with fixed credentials
  • + *
  • Using an in-memory user details service with no external dependencies
  • + *
+ *

+ *

+ * This configuration is automatically activated when the application is started with the "dev" profile. It should + * not be used in production environments, as it uses hardcoded credentials and simplified security + * settings. + *

+ *

+ * The predefined users are declared in the {@link SampleUsers} class. + *

+ *

+ * This configuration integrates with Vaadin's security framework through {@link VaadinSecurityConfigurer} to provide a + * seamless login experience in the Vaadin UI. + *

+ * + * @see DevUserDetailsService The in-memory user details service implementation + * @see DevUser Builder for creating development test users + * @see Profile The profile annotation that activates this configuration + * @see SampleUsers User credentials for the predefined users + */ +@EnableWebSecurity +@Configuration +@Profile("dev") +@Import({ VaadinAwareSecurityContextHolderStrategyConfiguration.class }) +class DevSecurityConfig { + + private static final Logger log = LoggerFactory.getLogger(DevSecurityConfig.class); + + DevSecurityConfig() { + log.warn("Using DEVELOPMENT security configuration. This should not be used in production environments!"); + } + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.formLogin(Customizer.withDefaults()) + .with(VaadinSecurityConfigurer.vaadin(), Customizer.withDefaults()) + .addFilterBefore(vaadinLoginPageFilter(), UsernamePasswordAuthenticationFilter.class).build(); + } + + /** + * Creates a servlet filter that injects the Vaadin refresh comment into Spring Security's default login page + * response. + * + *

+ * This filter is necessary because when a Vaadin application performs a logout operation, it expects either a JSON + * response or an HTML response containing the special comment {@code }. Without this + * comment, Vaadin's client-side code doesn't recognize that the user has been logged out and fails to refresh the + * page to show the login form. + *

+ * + *

+ * The filter intercepts requests to {@code /login}, captures Spring Security's generated HTML login page, and + * injects the required Vaadin comment immediately after the opening {@code } tag. This allows the application + * to use Spring Security's auto-generated login page during development while maintaining compatibility with + * Vaadin's logout handling. + *

+ * + *

+ * Note: This is intended for development use only. Production deployments should use OIDC + * authentication which handles logout properly without requiring this workaround. + *

+ * + * @return a Filter that modifies login page responses to include the Vaadin refresh comment + * @see HttpServletResponseWrapper + */ + private Filter vaadinLoginPageFilter() { + return (request, response, chain) -> { + var httpRequest = (HttpServletRequest) request; + if (httpRequest.getRequestURI().equals("/login")) { + var stringWriter = new StringWriter(); + var wrapper = new HttpServletResponseWrapper((HttpServletResponse) response) { + private final PrintWriter printWriter = new PrintWriter(stringWriter); + + @Override + public PrintWriter getWriter() { + return printWriter; + } + }; + chain.doFilter(request, wrapper); + var html = stringWriter.toString(); + if (html.contains("")) { + html = html.replace("", ""); + } + response.setContentLength(html.length()); + response.getWriter().write(html); + } else { + chain.doFilter(request, response); + } + }; + } + + @Bean + UserDetailsService userDetailsService() { + return new DevUserDetailsService(SampleUsers.ALL_USERS); + } + + @Bean + VaadinServiceInitListener productionModeGuard() { + return (serviceInitEvent) -> { + if (serviceInitEvent.getSource().getDeploymentConfiguration().isProductionMode()) { + throw new IllegalStateException( + "Development profile is active but Vaadin is running in production mode. This indicates a configuration error - development profile should not be used in production."); + } + }; + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/dev/DevUser.java b/walking-skeleton/src/main/java/com/example/application/security/dev/DevUser.java new file mode 100644 index 0000000..19370ba --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/dev/DevUser.java @@ -0,0 +1,335 @@ +package com.example.application.security.dev; + +import com.example.application.security.AppUserInfo; +import com.example.application.security.AppUserPrincipal; +import com.example.application.security.domain.UserId; +import org.jspecify.annotations.Nullable; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.ZoneId; +import java.util.*; + +import static java.util.Objects.requireNonNull; + +/** + * Implementation of {@link AppUserPrincipal} and {@link UserDetails} for development environments. + *

+ * This class provides a convenient way to create test users with predefined credentials and user information for + * development and testing purposes. It implements both Spring Security's {@link UserDetails} interface and the + * application's {@link AppUserPrincipal} interface, making it compatible with both authentication and authorization + * systems. + *

+ *

+ * DevUser instances should only be used in development and test environments, not in production. They are typically + * created through the {@link #builder()} method and customized using the fluent {@link DevUserBuilder} API. + *

+ *

+ * Example usage: + *

+ * {@code
+ * DevUser adminUser = DevUser.builder()
+ *     .preferredUsername("admin")
+ *     .fullName("Admin User")
+ *     .password("securePassword")
+ *     .email("admin@example.com")
+ *     .roles("ADMIN")
+ *     .build();
+ * }
+ * 
+ * + *

+ * + * @see DevUserBuilder The builder for creating DevUser instances + * @see AppUserPrincipal The principal interface providing application user information + * @see UserDetails Spring Security's interface for user authentication information + */ +final class DevUser implements AppUserPrincipal, UserDetails { + + private final AppUserInfo appUser; + private final Set authorities; + private final String password; + + DevUser(AppUserInfo appUser, Collection authorities, String password) { + this.appUser = requireNonNull(appUser); + this.authorities = Set.copyOf(authorities); + this.password = requireNonNull(password); + } + + @Override + public AppUserInfo getAppUser() { + return appUser; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return appUser.getPreferredUsername(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof DevUser user) { + return this.appUser.getUserId().equals(user.appUser.getUserId()); + } + return false; + } + + @Override + public int hashCode() { + return this.appUser.getUserId().hashCode(); + } + + /** + * Creates a new builder for constructing a development user. + *

+ * The builder provides a fluent API for setting user properties. At minimum, a preferred username and password must + * be provided before calling {@link DevUserBuilder#build()}. + *

+ * + * @return a new builder for creating a development user + */ + public static DevUserBuilder builder() { + return new DevUserBuilder(); + } + + /** + * Builder for creating {@link DevUser} instances with customized properties. + *

+ * This builder uses a fluent API pattern to set various user properties before constructing the final user object. + *

+ *

+ * Required properties that must be set before calling {@link #build()}: + *

    + *
  • Preferred username (set via {@link #preferredUsername(String)})
  • + *
  • Password (set via {@link #password(String)})
  • + *
+ *

+ *

+ * Optional properties with default values: + *

    + *
  • User ID (random UUID)
  • + *
  • Full name (preferred username)
  • + *
  • Zone ID (system default)
  • + *
  • Locale (system default)
  • + *
  • Authorities (empty list)
  • + *
+ *

+ *

+ * Example usage: + * + *

+     * {@code
+     * DevUser user = DevUser.builder().preferredUsername("john.doe").fullName("John Doe").password("password123")
+     *         .email("john@example.com").roles("USER", "ADMIN").locale(Locale.US).build();
+     * }
+     * 
+ *

+ */ + static final class DevUserBuilder { + + private static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories + .createDelegatingPasswordEncoder(); + + private @Nullable UserId userId; + private @Nullable String preferredUsername; + private @Nullable String fullName; + private @Nullable String email; + private @Nullable String profileUrl; + private @Nullable String pictureUrl; + private ZoneId zoneInfo = ZoneId.systemDefault(); + private Locale locale = Locale.getDefault(); + private List authorities = Collections.emptyList(); + private @Nullable String password; + + /** + * Sets the user's ID. If left unset, a random UUID is generated when the user is built. + * + * @param userId + * the user ID (never {@code null}) + * @return this builder for method chaining + */ + public DevUserBuilder userId(UserId userId) { + this.userId = requireNonNull(userId); + return this; + } + + /** + * Sets the user's preferred username. + * + * @param preferredUsername + * the preferred username (never {@code null}) + * @return this builder for method chaining + */ + public DevUserBuilder preferredUsername(String preferredUsername) { + this.preferredUsername = requireNonNull(preferredUsername); + return this; + } + + /** + * Sets the user's full name. If left unset, the preferred username will be used. + * + * @param fullName + * the full name + * @return this builder for method chaining + */ + public DevUserBuilder fullName(@Nullable String fullName) { + this.fullName = fullName; + return this; + } + + /** + * Sets the user's email address. + * + * @param email + * the email address. + * @return this builder for method chaining. + */ + public DevUserBuilder email(@Nullable String email) { + this.email = email; + return this; + } + + /** + * Sets the user's profile URL. + * + * @param profileUrl + * the profile URL + * @return this builder for method chaining + */ + public DevUserBuilder profileUrl(@Nullable String profileUrl) { + this.profileUrl = profileUrl; + return this; + } + + /** + * Sets the user's profile picture URL. + * + * @param pictureUrl + * the picture URL + * @return this builder for method chaining + */ + public DevUserBuilder pictureUrl(@Nullable String pictureUrl) { + this.pictureUrl = pictureUrl; + return this; + } + + /** + * Sets the user's time zone. + * + * @param zoneInfo + * the time zone (never {@code null}) + * @return this builder for method chaining + */ + public DevUserBuilder zoneInfo(ZoneId zoneInfo) { + this.zoneInfo = requireNonNull(zoneInfo); + return this; + } + + /** + * Sets the user's locale. + * + * @param locale + * the locale (never {@code null}) + * @return this builder for method chaining + */ + public DevUserBuilder locale(Locale locale) { + this.locale = requireNonNull(locale); + return this; + } + + /** + * Sets the user's roles, which will be converted to authorities with the "ROLE_" prefix. + *

+ * For example, the role "ADMIN" will become the authority "ROLE_ADMIN". + *

+ * + * @param roles + * the roles to assign to the user + * @return this builder for method chaining + */ + public DevUserBuilder roles(String... roles) { + this.authorities = new ArrayList<>(roles.length); + for (var role : roles) { + this.authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); + } + return this; + } + + /** + * Sets the user's authorities directly. + * + * @param authorities + * the authority strings + * @return this builder for method chaining + */ + public DevUserBuilder authorities(String... authorities) { + return authorities(AuthorityUtils.createAuthorityList(authorities)); + } + + /** + * Sets the user's authorities directly. + * + * @param authorities + * the authority objects (never {@code null}) + * @return this builder for method chaining + */ + public DevUserBuilder authorities(Collection authorities) { + this.authorities = new ArrayList<>(authorities); + return this; + } + + /** + * Sets the user's password. + *

+ * The password will be encoded when the user is built. + *

+ * + * @param password + * the raw password (never {@code null}) + * @return this builder for method chaining + */ + public DevUserBuilder password(String password) { + this.password = requireNonNull(password); + return this; + } + + /** + * Builds the development user with the properties set on this builder. + * + * @return a new development user + * @throws IllegalStateException + * if the preferred username or password has not been set + */ + public DevUser build() { + if (preferredUsername == null) { + throw new IllegalStateException("Preferred username must be set before building the user"); + } + if (password == null) { + throw new IllegalStateException("Password must be set before building the user"); + } + var encodedPassword = PASSWORD_ENCODER.encode(password); + if (userId == null) { + userId = UserId.of(UUID.randomUUID().toString()); + } + if (fullName == null) { + fullName = preferredUsername; + } + return new DevUser(new DevUserInfo(userId, preferredUsername, fullName, profileUrl, pictureUrl, email, + zoneInfo, locale), authorities, encodedPassword); + } + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/dev/DevUserDetailsService.java b/walking-skeleton/src/main/java/com/example/application/security/dev/DevUserDetailsService.java new file mode 100644 index 0000000..4679c31 --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/dev/DevUserDetailsService.java @@ -0,0 +1,71 @@ +package com.example.application.security.dev; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Implementation of {@link UserDetailsService} for development environments. + *

+ * This class provides a simple in-memory implementation of Spring Security's {@link UserDetailsService}. It stores a + * collection of {@link DevUser} instances and allows looking them up by preferred username. + *

+ *

+ * This implementation is specifically designed for development and testing purposes and should not be used in + * production environments. It allows the application to function with predefined test users without needing external + * authentication services or databases. + *

+ *

+ * Example usage: + *

+ * {@code
+ * DevUserDetailsService userService = new DevUserDetailsService(List.of(
+ *     DevUser.builder("admin", "Admin User")
+ *         .password("password")
+ *         .email("admin@example.com")
+ *         .roles("ADMIN")
+ *         .build(),
+ *     DevUser.builder("user", "Regular User")
+ *         .password("password")
+ *         .email("user@example.com")
+ *         .roles("USER")
+ *         .build()
+ * ));
+ * }
+ * 
+ * + *

+ * + * @see DevUser The development user class stored in this service + * @see UserDetailsService Spring Security's interface for loading user authentication details + */ +final class DevUserDetailsService implements UserDetailsService { + + private final Map userByUsername; + + /** + * Creates a new service with the specified development users. + *

+ * This constructor stores the provided users in memory, indexing them by both email address and user ID for + * efficient lookups. + *

+ * + * @param users + * the development users to include in this service + */ + DevUserDetailsService(Collection users) { + userByUsername = new HashMap<>(); + users.forEach(user -> userByUsername.put(user.getAppUser().getPreferredUsername(), user)); + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return Optional.ofNullable(userByUsername.get(username)) + .orElseThrow(() -> new UsernameNotFoundException(username)); + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/dev/DevUserInfo.java b/walking-skeleton/src/main/java/com/example/application/security/dev/DevUserInfo.java new file mode 100644 index 0000000..dd0c7bf --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/dev/DevUserInfo.java @@ -0,0 +1,93 @@ +package com.example.application.security.dev; + +import com.example.application.security.AppUserInfo; +import com.example.application.security.domain.UserId; +import org.jspecify.annotations.Nullable; + +import java.time.ZoneId; +import java.util.Locale; + +import static java.util.Objects.requireNonNull; + +/** + * Implementation of {@link AppUserInfo} used by {@link DevUser} for development environments. + *

+ * This record provides a simple immutable implementation of the {@link AppUserInfo} interface, storing user information + * for development and testing purposes. It contains all the essential user profile data needed by the application, with + * appropriate null checks for required fields. + *

+ *

+ * This implementation is specifically designed for development and test environments and should not be used in + * production. It's primarily used by the {@link DevUser} class to represent test user information. + *

+ * + * @param userId + * the unique identifier for the user (never {@code null}) + * @param preferredUsername + * the user's preferred username (never {@code null}). + * @param fullName + * the user's full name (never {@code null}) + * @param profileUrl + * the URL to the user's profile page, or {@code null} if not available + * @param pictureUrl + * the URL to the user's profile picture, or {@code null} if not available + * @param email + * the user's email address, or {@code null} if not available + * @param zoneId + * the user's time zone (never {@code null}) + * @param locale + * the user's locale (never {@code null}) + * @see DevUser The development user class that uses this record + * @see AppUserInfo The interface this record implements + */ +record DevUserInfo(UserId userId, String preferredUsername, String fullName, @Nullable String profileUrl, + @Nullable String pictureUrl, @Nullable String email, ZoneId zoneId, Locale locale) implements AppUserInfo { + + DevUserInfo { + requireNonNull(userId); + requireNonNull(preferredUsername); + requireNonNull(fullName); + requireNonNull(zoneId); + requireNonNull(locale); + } + + @Override + public UserId getUserId() { + return userId; + } + + @Override + public String getPreferredUsername() { + return preferredUsername; + } + + @Override + public String getFullName() { + return fullName; + } + + @Override + public @Nullable String getProfileUrl() { + return profileUrl; + } + + @Override + public @Nullable String getPictureUrl() { + return pictureUrl; + } + + @Override + public @Nullable String getEmail() { + return email; + } + + @Override + public ZoneId getZoneId() { + return zoneId; + } + + @Override + public Locale getLocale() { + return locale; + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/dev/SampleUsers.java b/walking-skeleton/src/main/java/com/example/application/security/dev/SampleUsers.java new file mode 100644 index 0000000..8ea9de5 --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/dev/SampleUsers.java @@ -0,0 +1,142 @@ +package com.example.application.security.dev; + +import com.example.application.security.AppRoles; +import com.example.application.security.domain.UserId; + +import java.util.List; +import java.util.UUID; + +/** + * Provides predefined sample users for development and testing environments. + *

+ * This utility class contains constants and user objects that can be used consistently across development + * configurations, integration tests, and other testing scenarios. It ensures that the same test users are available in + * all contexts where they are needed, promoting consistency and reducing duplication. + *

+ *

+ * The class provides both the complete {@link DevUser} objects (for use in user details services) and individual + * constants for user IDs and preferred usernames (for use in tests and assertions). This allows tests to reference + * specific user properties without needing to extract them from the user objects. + *

+ *

+ * Usage in development configuration: + *

+ * {@code
+ * @Bean
+ * UserDetailsService userDetailsService() {
+ *     return new DevUserDetailsService(SampleUsers.ADMIN, SampleUsers.USER);
+ * }
+ * }
+ * 
+ * + *

+ *

+ * Usage in integration tests: + *

+ * {@code
+ * @Test
+ * @WithUserDetails(SampleUsers.USER_USERNAME)
+ * public void testUserFunctionality() {
+ *     // Test logic here
+ *     assertThat(result.getCreatedBy()).isEqualTo(SampleUsers.USER_ID);
+ * }
+ * }
+ * 
+ * + *

+ *

+ * Important: This class is intended only for development and testing purposes. The sample users have + * fixed, well-known credentials and should never be used in production environments. + *

+ * + * @see DevUser The development user implementation + * @see DevUserDetailsService The service that uses these sample users + */ +public final class SampleUsers { + + /** + * Private constructor to prevent instantiation of this utility class. + */ + private SampleUsers() { + } + + /** + * The raw, unencoded password used by all sample users. + */ + static final String SAMPLE_PASSWORD = "tops3cr3t"; + + /** + * The user ID for the admin sample user. + *

+ * This constant can be used in tests and assertions to verify that operations were performed by the admin user. + *

+ */ + public static final UserId ADMIN_ID = UserId.of(UUID.randomUUID().toString()); + + /** + * The preferred username of the admin sample user. + *

+ * This constant can be used with {@code @WithUserDetails} in tests to authenticate as the admin user. + *

+ */ + public static final String ADMIN_USERNAME = "admin"; + + /** + * The admin sample user with administrative privileges. + *

+ * This user has both the "ADMIN" and the "USER" role and can be used in development configurations and tests that + * require administrative access. + *

+ */ + static DevUser ADMIN = DevUser.builder().preferredUsername(ADMIN_USERNAME).fullName("Alice Administrator") + .userId(ADMIN_ID).password(SAMPLE_PASSWORD).email("alice@example.com").roles(AppRoles.ADMIN, AppRoles.USER) + .build(); + + /** + * The user ID for the regular sample user. + *

+ * This constant can be used in tests and assertions to verify that operations were performed by the regular user. + *

+ */ + public static final UserId USER_ID = UserId.of(UUID.randomUUID().toString()); + + /** + * The preferred username of the regular sample user. + *

+ * This constant can be used with {@code @WithUserDetails} in tests to authenticate as the regular user. + *

+ */ + public static final String USER_USERNAME = "user"; + + /** + * The regular sample user with standard privileges. + *

+ * This user has the "USER" role and can be used in development configurations and tests that require standard user + * access. + *

+ */ + static final DevUser USER = DevUser.builder().preferredUsername(USER_USERNAME).fullName("Ursula User") + .userId(USER_ID).password(SAMPLE_PASSWORD).email("ursula@example.com").roles(AppRoles.USER).build(); + + /** + * An unmodifiable list containing all sample users. + *

+ * This list provides a convenient way to access all sample users at once, which is particularly useful when + * creating a {@link DevUserDetailsService} that should include all available test users. Using this list ensures + * that any new sample users added to this class will automatically be included in services that use it. + *

+ *

+ * Example usage in development configuration: + *

+     * {@code
+     * @Bean
+     * UserDetailsService userDetailsService() {
+     *     return new DevUserDetailsService(SampleUsers.ALL_USERS);
+     * }
+     * }
+     * 
+ * + *

+ */ + static final List ALL_USERS = List.of(USER, ADMIN); +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/dev/package-info.java b/walking-skeleton/src/main/java/com/example/application/security/dev/package-info.java new file mode 100644 index 0000000..f33dbf1 --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/dev/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.example.application.security.dev; + +import org.jspecify.annotations.NullMarked; \ No newline at end of file diff --git a/walking-skeleton/src/main/java/com/example/application/security/domain/UserId.java b/walking-skeleton/src/main/java/com/example/application/security/domain/UserId.java new file mode 100644 index 0000000..8a0d2b7 --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/domain/UserId.java @@ -0,0 +1,87 @@ +package com.example.application.security.domain; + +import java.io.Serializable; +import java.util.Objects; + +import static java.util.Objects.requireNonNull; + +/** + * Domain primitive representing a user's unique identifier. + *

+ * This class encapsulates a user ID string value, providing type safety and validation. It follows the domain primitive + * pattern, ensuring that all user IDs in the application are validated consistently and can be used as immutable value + * objects. + *

+ *

+ * UserId objects are immutable, serializable, and implement proper equality semantics. They should be used throughout + * the application wherever a user ID is needed, rather than using raw strings. + *

+ *

+ * Example usage: + *

+ * {@code
+ * // Creating a UserId
+ * UserId userId = UserId.of("user-123");
+ *
+ * // Using it in a method
+ * Optional userInfo = userService.findUserInfo(userId);
+ *
+ * // Converting back to string when necessary
+ * String userIdString = userId.toString();
+ * }
+ * 
+ * + *

+ */ +public final class UserId implements Serializable { + + private final String userId; + + private UserId(String userId) { + // TODO If the userId has a specific format, validate it here. + this.userId = requireNonNull(userId); + } + + /** + * Creates a new {@code UserId} instance with the specified value. + *

+ * This factory method is the recommended way to create UserId objects. It ensures proper validation and + * encapsulation of the user ID value. + *

+ * + * @param userId + * the user ID string (never {@code null}) + * @return a new {@code UserId} instance + * @throws IllegalArgumentException + * if the user ID is invalid + */ + public static UserId of(String userId) { + return new UserId(userId); + } + + /** + * Returns the string representation of this user ID. + *

+ * This method returns the original user ID string that was provided when this object was created. + *

+ * + * @return the user ID as a string + */ + @Override + public String toString() { + return userId; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + UserId that = (UserId) o; + return Objects.equals(userId, that.userId); + } + + @Override + public int hashCode() { + return Objects.hashCode(userId); + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/domain/jpa/UserIdAttributeConverter.java b/walking-skeleton/src/main/java/com/example/application/security/domain/jpa/UserIdAttributeConverter.java new file mode 100644 index 0000000..2340dc7 --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/domain/jpa/UserIdAttributeConverter.java @@ -0,0 +1,56 @@ +package com.example.application.security.domain.jpa; + +import com.example.application.security.domain.UserId; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import org.jspecify.annotations.Nullable; + +/** + * JPA attribute converter for {@link UserId} domain primitives. + *

+ * This converter enables seamless persistence of {@link UserId} objects in JPA entities by converting them to and from + * their string representation in the database. It allows entity fields of type {@link UserId} to be automatically + * converted to {@code VARCHAR} columns and vice versa. + *

+ *

+ * The converter handles null values gracefully, converting null {@link UserId} objects to null database values and null + * database values back to null {@link UserId} objects. + *

+ *

+ * Example usage in JPA entities: + *

+ * {@code
+ * @Entity
+ * public class User {
+ *     @Convert(converter = UserIdAttributeConverter.class)
+ *     private UserId userId;
+ *
+ *     // Or use autoApply if configured globally:
+ *     // private UserId userId; // Automatically converted
+ * }
+ * }
+ * 
+ * + *

+ *

+ * To apply this converter automatically to all {@link UserId} fields without explicit {@code @Convert} annotations, add + * {@code autoApply = true} to the {@code @Converter} annotation. + *

+ * + * @see UserId The domain primitive this converter handles + * @see AttributeConverter The JPA interface this class implements + * @see jakarta.persistence.Convert The annotation used to apply this converter to entity fields + */ +@Converter +public class UserIdAttributeConverter implements AttributeConverter { + + @Override + public @Nullable String convertToDatabaseColumn(@Nullable UserId userId) { + return userId == null ? null : userId.toString(); + } + + @Override + public @Nullable UserId convertToEntityAttribute(@Nullable String s) { + return s == null ? null : UserId.of(s); + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/domain/jpa/package-info.java b/walking-skeleton/src/main/java/com/example/application/security/domain/jpa/package-info.java new file mode 100644 index 0000000..6c2d080 --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/domain/jpa/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.example.application.security.domain.jpa; + +import org.jspecify.annotations.NullMarked; \ No newline at end of file diff --git a/walking-skeleton/src/main/java/com/example/application/security/domain/package-info.java b/walking-skeleton/src/main/java/com/example/application/security/domain/package-info.java new file mode 100644 index 0000000..161e57b --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/domain/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package com.example.application.security.domain; + +import org.jspecify.annotations.NullMarked; \ No newline at end of file diff --git a/walking-skeleton/src/main/java/com/example/application/security/hilla/CurrentUserService.java b/walking-skeleton/src/main/java/com/example/application/security/hilla/CurrentUserService.java new file mode 100644 index 0000000..c952b9a --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/hilla/CurrentUserService.java @@ -0,0 +1,90 @@ +package com.example.application.security.hilla; + +import com.example.application.security.AppUserInfo; +import com.example.application.security.AppUserPrincipal; +import com.example.application.security.CurrentUser; +import com.vaadin.hilla.BrowserCallable; +import jakarta.annotation.security.PermitAll; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +/** + * Browser callable service for making the current {@link AppUserPrincipal} available to Hilla clients. + *

+ * This service provides a secure way for frontend applications to access information about the currently authenticated + * user. The service is accessible to all authenticated users without additional authorization checks. + *

+ *

+ * Java clients should not use this class, but interact with {@link CurrentUser} directly. + *

+ */ +@BrowserCallable +@PermitAll +class CurrentUserService { + + private final CurrentUser currentUser; + + CurrentUserService(CurrentUser currentUser) { + this.currentUser = currentUser; + } + + /** + * Data transfer object containing user information for client-side consumption. + *

+ * This record encapsulates all user data that is safe to expose to the frontend, including profile information, + * preferences, and security authorities. + *

+ * + * @param userId + * the user's {@linkplain AppUserInfo#getUserId() unique identifier} (never {@code null}) + * @param preferredUsername + * the user's {@linkplain AppUserInfo#getPreferredUsername() preferred username} (never {@code null}) + * @param fullName + * the user's {@linkplain AppUserInfo#getFullName() full name} (never {@code null}) + * @param profileUrl + * URL to the user's {@linkplain AppUserInfo#getProfileUrl() profile page}, or {@code null} if not + * available + * @param pictureUrl + * URL to the user's {@linkplain AppUserInfo#getPictureUrl() profile picture}, or {@code null} if not + * available + * @param email + * the user's {@linkplain AppUserInfo#getEmail() email address}, or {@code null} if not available + * @param zoneId + * the user's {@linkplain AppUserInfo#getZoneId() timezone identifier} (e.g., "Europe/Helsinki"; never + * {@code null}) + * @param locale + * the user's {@linkplain AppUserInfo#getLocale() locale preference} (e.g., "en-US"; never {@code null}) + * @param authorities + * collection of {@linkplain AppUserPrincipal#getAuthorities() granted authorities/roles} for this user + * (ever {@code null} but may be empty) + */ + public record UserInfo(@NonNull String userId, @NonNull String preferredUsername, @NonNull String fullName, + @Nullable String profileUrl, @Nullable String pictureUrl, @Nullable String email, @NonNull String zoneId, + @NonNull String locale, @NonNull Collection authorities) { + } + + /** + * Retrieves information about the currently authenticated user. + *

+ * This method extracts user data from the current security context and returns it in a format suitable for frontend + * consumption. All sensitive information is excluded, and only data that is safe to expose to the client is + * included. + * + * @return a {@link UserInfo} record containing the current user's information + * @throws AuthenticationCredentialsNotFoundException + * if there is no authenticated user, or the authenticated principal doesn't implement + * {@link AppUserPrincipal} + */ + public @NonNull UserInfo getUserInfo() { + var principal = currentUser.requirePrincipal(); + var user = principal.getAppUser(); + var authorities = principal.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(); + return new UserInfo(user.getUserId().toString(), user.getPreferredUsername(), user.getFullName(), + user.getProfileUrl(), user.getPictureUrl(), user.getEmail(), user.getZoneId().toString(), + user.getLocale().toString(), authorities); + } +} diff --git a/walking-skeleton/src/main/java/com/example/application/security/package-info.java b/walking-skeleton/src/main/java/com/example/application/security/package-info.java new file mode 100644 index 0000000..54b8937 --- /dev/null +++ b/walking-skeleton/src/main/java/com/example/application/security/package-info.java @@ -0,0 +1,23 @@ +/** + * This package contains an initial security configuration for your application. + *

+ * It provides the following features: + *

    + *
  • An application-specific user model for accessing user information regardless of the underlying identity + * management implementation ({@link com.example.application.security.AppUserInfo}, + * {@link com.example.application.security.CurrentUser})
  • + *
  • A value object for identifying users ({@link com.example.application.security.domain.UserId})
  • + *
  • Method-level security and JPA auditing ({@link com.example.application.security.CommonSecurityConfig})
  • + *
  • A development mode security configuration with simple login and in-memory users ({@code dev} package)
  • + *
  • A production mode security configuration for use with Vaadin Control Center ({@code cc} package)
  • + *
+ *

+ *

+ * You can use the package as-is in your application or extend and modify it to your needs. If you want to build your + * own security model from scratch, delete the package. + *

+ */ +@NullMarked +package com.example.application.security; + +import org.jspecify.annotations.NullMarked; \ No newline at end of file diff --git a/walking-skeleton/src/main/java/com/example/application/taskmanagement/service/TaskService.java b/walking-skeleton/src/main/java/com/example/application/taskmanagement/service/TaskService.java index 5012eb3..1abfb11 100644 --- a/walking-skeleton/src/main/java/com/example/application/taskmanagement/service/TaskService.java +++ b/walking-skeleton/src/main/java/com/example/application/taskmanagement/service/TaskService.java @@ -8,6 +8,7 @@ //#endif import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Pageable; +import org.springframework.security.access.prepost.PreAuthorize; //#if ui.framework == "flow" import org.springframework.stereotype.Service; //#endif @@ -23,9 +24,11 @@ //#endif //#if ui.framework == "hilla" @BrowserCallable +// Until https://github.com/vaadin/hilla/issues/3271 is fixed, @PreAuthorize needs to be combined with @AnonymousAllowed @AnonymousAllowed //#endif @Transactional(propagation = Propagation.REQUIRES_NEW) +@PreAuthorize("isAuthenticated()") public class TaskService { private final TaskRepository taskRepository; diff --git a/walking-skeleton/src/main/resources/application.properties b/walking-skeleton/src/main/resources/application.properties index ed4e652..33864de 100644 --- a/walking-skeleton/src/main/resources/application.properties +++ b/walking-skeleton/src/main/resources/application.properties @@ -11,4 +11,5 @@ vaadin.allowed-packages=com.vaadin,org.vaadin,com.flowingcode,com.example.applic spring.jpa.open-in-view=false # Initialize the JPA Entity Manager before considering data.sql so that the EM can create the schema and data.sql contain data +# If you intend to use Flyway to manage schemas in your application, you have to remove this line. spring.jpa.defer-datasource-initialization = true diff --git a/walking-skeleton/src/test/java/com/example/application/ArchitectureTest.java b/walking-skeleton/src/test/java/com/example/application/ArchitectureTest.java index 282b39e..7f30b43 100644 --- a/walking-skeleton/src/test/java/com/example/application/ArchitectureTest.java +++ b/walking-skeleton/src/test/java/com/example/application/ArchitectureTest.java @@ -2,6 +2,8 @@ import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; import org.junit.jupiter.api.Test; import org.springframework.data.repository.Repository; import org.springframework.transaction.annotation.Transactional; @@ -52,4 +54,12 @@ void application_services_should_not_depend_on_the_user_interface() { void there_should_not_be_circular_dependencies_between_feature_packages() { slices().matching(BASE_PACKAGE + ".(*)..").should().beFreeOfCycles().check(importedClasses); } + + @Test + void security_package_should_not_depend_on_other_application_classes() { + classes().that().resideInAPackage(BASE_PACKAGE + ".security..").should().onlyAccessClassesThat() + .resideOutsideOfPackage(BASE_PACKAGE + "..").orShould().accessClassesThat() + .resideInAPackage(BASE_PACKAGE + ".security..") + .because("Security classes should only depend on external libraries and other security classes"); + } } diff --git a/walking-skeleton/src/test/java/com/example/application/taskmanagement/service/TaskServiceIT.java b/walking-skeleton/src/test/java/com/example/application/taskmanagement/service/TaskServiceIT.java index 77c52f8..d3f97cc 100644 --- a/walking-skeleton/src/test/java/com/example/application/taskmanagement/service/TaskServiceIT.java +++ b/walking-skeleton/src/test/java/com/example/application/taskmanagement/service/TaskServiceIT.java @@ -1,6 +1,7 @@ package com.example.application.taskmanagement.service; import com.example.application.TestcontainersConfiguration; +import com.example.application.security.dev.SampleUsers; import com.example.application.taskmanagement.domain.Task; import com.example.application.taskmanagement.domain.TaskRepository; import jakarta.validation.ValidationException; @@ -10,6 +11,8 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.data.domain.PageRequest; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -22,6 +25,7 @@ @Import(TestcontainersConfiguration.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @Transactional(propagation = Propagation.NOT_SUPPORTED) +@ActiveProfiles("dev") // Activates DevSecurityConfig which makes the sample users available for the test class TaskServiceIT { @Autowired @@ -39,6 +43,7 @@ void cleanUp() { } @Test + @WithUserDetails(SampleUsers.USER_USERNAME) public void tasks_are_stored_in_the_database_with_the_current_timestamp() { var now = clock.instant(); var due = LocalDate.of(2025, 2, 7); @@ -49,6 +54,7 @@ public void tasks_are_stored_in_the_database_with_the_current_timestamp() { } @Test + @WithUserDetails(SampleUsers.ADMIN_USERNAME) public void tasks_are_validated_before_they_are_stored() { assertThatThrownBy(() -> taskService.createTask("X".repeat(Task.DESCRIPTION_MAX_LENGTH + 1), null)) .isInstanceOf(ValidationException.class);