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 @@
+ * If you need to use explicit profile management, remove the custom initializer from the {@link #main(String[])} method
+ * and use standard Spring profile activation:
+ *
+ * This method is called during Spring context initialization and will activate the "dev" profile if and only if:
+ *
+ * Note: If you prefer explicit profile management, remove this initializer from the main method
+ * and use standard Spring profile activation methods.
+ *
+ * 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.
+ *
+ * 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:
+ * Automatic Development Profile Activation
This application automatically activates the "dev" Spring profile
+ * when:
+ *
+ *
+ *
+ *
+ * {@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.
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * 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 extends GrantedAuthority> 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: + *
+ * Method security allows the use of the following annotations throughout the application: + *
+ * 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+ * 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+ * 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+ * 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: + *
+ *+ * 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: + *
+ * The mapping process: + *
+ *+ * 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: + *
+ * 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: + *
+ * 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+ * This configuration simplifies authentication during development by: + *
+ * 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+ * 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()}: + *
+ * Optional properties with default values: + *
+ * 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+ * 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 extends GrantedAuthority> 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+ * 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+ * 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+ * 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+ * 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+ * 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: + *
+ * 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);