Spring Security Integration Tutorial

This tutorial demonstrates how to secure OfficeFloor REST endpoints using Spring Security. The preferred approach is to declare the authorize expression directly in the REST YAML configuration — no annotations on service methods are needed. This keeps authorization policy in the same place as routing, and enables path-level inheritance so an entire section of the API can be secured in one line.

For applications migrating from Spring MVC, OfficeFloor also supports the familiar @PreAuthorize, @Secured, and @RolesAllowed annotations without any extra plumbing — see the compatibility section below.

The tutorial covers these endpoints:

YAML authorize (preferred)

  • GET /security/yaml — single endpoint secured by authorize: in its YAML file
  • GET /security/admin/report — endpoint that inherits authorize: from its parent path config
  • GET /security/admin/super — endpoint that overrides the inherited expression with a stricter rule

Spring integration

  • GET /security/public — open to everyone; no authentication required
  • GET /security/me — requires authentication; injects the current user via @AuthenticationPrincipal
  • GET /security/roles — requires authentication; reads the user's granted authorities
  • GET /security/auth — requires authentication; receives Authentication as a plain method parameter
  • GET /security/bean — requires authentication; receives a Spring Security bean (UserDetailsService) by type

Spring Boot annotation compatibility

  • GET /security/preauthorizeADMIN role enforced by @PreAuthorize
  • GET /security/securedADMIN role enforced by @Secured
  • GET /security/rolesallowedADMIN role enforced by @RolesAllowed

Tutorial Source

Maven dependencies

The only change compared to a non-secured application is adding spring-boot-starter-security alongside the OfficeFloor starter. No additional bridges, adapters, or OfficeFloor-specific security modules are needed:

		<dependency>
			<groupId>net.officefloor.springboot</groupId>
			<artifactId>officefloor-rest-spring-boot-4-starter</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>

Application class

@SpringBootApplication
public class SpringRestSecurityApplication {

	public static void main(String[] args) {
		SpringApplication.run(SpringRestSecurityApplication.class, args);
	}
}

Security configuration

The SecurityFilterChain is a standard Spring Security configuration. @EnableMethodSecurity is required to activate the SpEL expression evaluator used by authorize: in the YAML files, as well as any method-level annotations on service classes:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
public class SecurityConfig {

	@Bean
	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
			.authorizeHttpRequests(auth -> auth
				.requestMatchers("/security/public").permitAll()
				.anyRequest().authenticated()
			)
			.httpBasic(Customizer.withDefaults());
		return http.build();
	}

	@Bean
	@SuppressWarnings("deprecation")
	UserDetailsService userDetailsService() {
		UserDetails user = User.withDefaultPasswordEncoder()
			.username("user").password("password").roles("USER")
			.build();
		UserDetails admin = User.withDefaultPasswordEncoder()
			.username("admin").password("password").roles("USER", "ADMIN")
			.build();
		return new InMemoryUserDetailsManager(user, admin);
	}
}

YAML authorize — single endpoint

The simplest form places an authorize: expression inside the composition: block of the endpoint YAML file. The expression is a standard Spring Security SpEL string — hasRole(), isAuthenticated(), hasAuthority(), or any custom expression supported by a MethodSecurityExpressionHandler.

The expression is parsed and compiled once at startup, so there is no per-request parsing overhead. If the expression is syntactically invalid, the application fails to start immediately.

The service class carries no security annotation — authorization is entirely in the YAML:

composition:
  authorize: "hasRole('ADMIN')"
service:
  class: net.officefloor.tutorial.springrestsecurity.YamlAuthorizeService
public class YamlAuthorizeService {

	public void service(ObjectResponse<String> response) {
		response.send("Admin via YAML");
	}
}

If the expression evaluates to false, OfficeFloor returns 403 Forbidden before the service method is invoked.

YAML authorize — path-level inheritance

Place an authorize: key in a path-config YAML file (a .yml file named after a path segment with no HTTP method suffix) to secure every endpoint under that path with a single declaration. Child endpoint files need no authorize: entry at all — they inherit the expression automatically.

The path config for the /security/admin subtree:

authorize: "hasRole('ADMIN')"

The child endpoint for GET /security/admin/report simply names its service class. The ADMIN requirement is inherited from the parent:

service:
  class: net.officefloor.tutorial.springrestsecurity.AdminReportService
public class AdminReportService {

	public void service(ObjectResponse<String> response) {
		response.send("Admin Report");
	}
}

YAML authorize — overriding an inherited expression

A child endpoint can tighten or replace the inherited expression by putting its own authorize: inside its composition: block. The most specific (deepest) expression wins.

The super endpoint requires a stricter SUPER role even though its parent only requires ADMIN:

composition:
  authorize: "hasRole('SUPER')"
service:
  class: net.officefloor.tutorial.springrestsecurity.SuperAdminService
public class SuperAdminService {

	public void service(ObjectResponse<String> response) {
		response.send("Super Admin Only");
	}
}

Public endpoint

A method that needs no authentication is written exactly as any other OfficeFloor service method. The YAML simply names the class — no security annotation is needed:

service:
  class: net.officefloor.tutorial.springrestsecurity.PublicGreetingService
public class PublicGreetingService {

	public void service(ObjectResponse<String> response) {
		response.send("Hello, World!");
	}
}

Accessing the authenticated user — @AuthenticationPrincipal

For a protected endpoint, the YAML is the same — just a class name:

service:
  class: net.officefloor.tutorial.springrestsecurity.CurrentUserService

The service method receives the authenticated user by declaring a UserDetails parameter annotated with @AuthenticationPrincipal. OfficeFloor recognises this as a Spring argument annotation and delegates its resolution to Spring MVC's argument resolvers, which read the value from the SecurityContext:

public class CurrentUserService {

	public void service(@AuthenticationPrincipal UserDetails user, ObjectResponse<String> response) {
		response.send("Hello, " + user.getUsername() + "!");
	}
}

This is the same mechanism Spring MVC uses internally — OfficeFloor does not implement a separate security integration; it reuses Spring's existing argument resolution infrastructure.

Reading granted authorities

Granted authorities are accessed through the same UserDetails object:

service:
  class: net.officefloor.tutorial.springrestsecurity.UserRolesService
public class UserRolesService {

	public void service(@AuthenticationPrincipal UserDetails user, ObjectResponse<String> response) {
		String roles = user.getAuthorities().stream()
			.map(GrantedAuthority::getAuthority)
			.sorted()
			.collect(Collectors.joining(", "));
		response.send(roles);
	}
}

Receiving Authentication as a direct parameter

The Authentication object from the SecurityContext is also available as a plain method parameter — no annotation required. OfficeFloor recognises it by type and supplies it automatically:

service:
  class: net.officefloor.tutorial.springrestsecurity.AuthenticationService
public class AuthenticationService {

	public void service(Authentication authentication, ObjectResponse<String> response) {
		response.send("Authenticated as: " + authentication.getName());
	}
}

Injecting Spring Security beans as method parameters

OfficeFloor registers every bean in the Spring application context as a managed object available by type. Spring Security beans — including UserDetailsService, PasswordEncoder, and the SecurityFilterChain itself — are therefore available as plain parameters with no annotation:

service:
  class: net.officefloor.tutorial.springrestsecurity.SecurityBeanService
public class SecurityBeanService {

	public void service(UserDetailsService userDetailsService, ObjectResponse<String> response) {
		UserDetails user = userDetailsService.loadUserByUsername("user");
		response.send("Loaded: " + user.getUsername());
	}
}

This is the same mechanism used for any Spring @Service or @Component — there is no special handling for security beans.

Spring Boot annotation compatibility

OfficeFloor fully supports @PreAuthorize, @Secured, and @RolesAllowed on service methods. These annotations are evaluated by Spring Security's AOP infrastructure before the method is invoked, exactly as they would be in a Spring MVC controller.

Use annotation-based security when porting an existing Spring Boot application to OfficeFloor, or when the service class is shared between an OfficeFloor REST endpoint and other callers that rely on the annotation. For new OfficeFloor-first code, prefer the authorize: YAML key so that security policy is declared alongside routing.

@PreAuthorize

@PreAuthorize on a service method is evaluated by OfficeFloor before the method is invoked. The full Spring Security SpEL expression language is available — hasRole(), hasAuthority(), isAuthenticated(), and any custom expressions configured via a custom MethodSecurityExpressionHandler:

service:
  class: net.officefloor.tutorial.springrestsecurity.PreAuthorizeService
public class PreAuthorizeService {

	@PreAuthorize("hasRole('ADMIN')")
	public void service(ObjectResponse<String> response) {
		response.send("Admin access via @PreAuthorize");
	}
}

@PreAuthorize with SpEL bean references

Spring Security SpEL supports referencing any Spring bean by name using the @beanName syntax. A common pattern centralises role constants in a @Component so that role names are never duplicated as string literals across service classes:

@Component("roles")
public class Roles {
	public final String ADMIN = "ADMIN";
	public final String USER  = "USER";
}
service:
  class: net.officefloor.tutorial.springrestsecurity.RolesBeanService
public class RolesBeanService {

	@PreAuthorize("hasRole(@roles.ADMIN)")
	public void service(ObjectResponse<String> response) {
		response.send("Admin access via @PreAuthorize SpEL bean reference");
	}
}

The expression @roles.ADMIN resolves the roles bean and reads its ADMIN field at evaluation time. This is standard Spring Security SpEL — OfficeFloor does not require any additional configuration to support it.

@Secured

@Secured accepts one or more role names (including the ROLE_ prefix). Enable it in the security configuration with securedEnabled = true:

service:
  class: net.officefloor.tutorial.springrestsecurity.SecuredService
public class SecuredService {

	@Secured("ROLE_ADMIN")
	public void service(ObjectResponse<String> response) {
		response.send("Admin access via @Secured");
	}
}

@RolesAllowed

The JSR-250 @RolesAllowed annotation works the same way. Enable it with jsr250Enabled = true:

service:
  class: net.officefloor.tutorial.springrestsecurity.RolesAllowedService
public class RolesAllowedService {

	@RolesAllowed("ADMIN")
	public void service(ObjectResponse<String> response) {
		response.send("Admin access via @RolesAllowed");
	}
}

Testing

Tests use the standard Spring Security test support. No OfficeFloor-specific test utilities are required.

@WithMockUser creates a synthetic user in the SecurityContext without touching UserDetailsService. @WithUserDetails("user") instead loads the real UserDetails object from the configured UserDetailsService — useful when the service method inspects granted authorities or other fields populated by the real implementation.

The tests cover YAML authorize (endpoint-level, inherited, and overridden), the Spring integration endpoints, and the annotation-compatibility endpoints:

@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestSecurityTest {

	@Autowired
	private MockMvc mvc;

	// --- YAML authorize (preferred approach) ---

	@Test
	@WithMockUser(username = "admin", roles = "ADMIN")
	public void yaml_authorize_grants_admin() throws Exception {
		mvc.perform(get("/security/yaml"))
			.andExpect(status().isOk())
			.andExpect(content().string("Admin via YAML"));
	}

	@Test
	@WithMockUser(username = "user", roles = "USER")
	public void yaml_authorize_denies_non_admin() throws Exception {
		mvc.perform(get("/security/yaml"))
			.andExpect(status().isForbidden());
	}

	@Test
	@WithMockUser(username = "admin", roles = "ADMIN")
	public void inherited_authorize_grants_admin() throws Exception {
		mvc.perform(get("/security/admin/report"))
			.andExpect(status().isOk())
			.andExpect(content().string("Admin Report"));
	}

	@Test
	@WithMockUser(username = "user", roles = "USER")
	public void inherited_authorize_denies_non_admin() throws Exception {
		mvc.perform(get("/security/admin/report"))
			.andExpect(status().isForbidden());
	}

	@Test
	@WithMockUser(username = "superadmin", roles = "SUPER")
	public void override_authorize_grants_super() throws Exception {
		mvc.perform(get("/security/admin/super"))
			.andExpect(status().isOk())
			.andExpect(content().string("Super Admin Only"));
	}

	@Test
	@WithMockUser(username = "admin", roles = "ADMIN")
	public void override_authorize_denies_admin_without_super() throws Exception {
		mvc.perform(get("/security/admin/super"))
			.andExpect(status().isForbidden());
	}

	@Test
	@WithMockUser(username = "user", roles = "USER")
	public void disable_inherited_authorize_grants_non_admin() throws Exception {
		mvc.perform(get("/security/admin/open"))
			.andExpect(status().isOk())
			.andExpect(content().string("Open under Admin"));
	}

	@Test
	@WithMockUser(username = "admin", roles = "ADMIN")
	public void disable_inherited_authorize_grants_admin() throws Exception {
		mvc.perform(get("/security/admin/open"))
			.andExpect(status().isOk())
			.andExpect(content().string("Open under Admin"));
	}

	// --- Spring Security integration ---

	@Test
	public void public_endpoint_no_auth_required() throws Exception {
		mvc.perform(get("/security/public"))
			.andExpect(status().isOk())
			.andExpect(content().string("Hello, World!"));
	}

	@Test
	public void protected_endpoint_requires_authentication() throws Exception {
		mvc.perform(get("/security/me"))
			.andExpect(status().isUnauthorized());
	}

	@Test
	@WithMockUser(username = "daniel", roles = "USER")
	public void current_user_via_authentication_principal() throws Exception {
		mvc.perform(get("/security/me"))
			.andExpect(status().isOk())
			.andExpect(content().string("Hello, daniel!"));
	}

	@Test
	@WithUserDetails("user")
	public void with_user_details_loads_real_user() throws Exception {
		mvc.perform(get("/security/me"))
			.andExpect(status().isOk())
			.andExpect(content().string("Hello, user!"));
	}

	@Test
	@WithMockUser(username = "daniel", roles = {"USER", "ADMIN"})
	public void user_roles_from_granted_authorities() throws Exception {
		mvc.perform(get("/security/roles"))
			.andExpect(status().isOk())
			.andExpect(content().string("ROLE_ADMIN, ROLE_USER"));
	}

	@Test
	@WithMockUser(username = "daniel", roles = "USER")
	public void authentication_as_direct_parameter() throws Exception {
		mvc.perform(get("/security/auth"))
			.andExpect(status().isOk())
			.andExpect(content().string("Authenticated as: daniel"));
	}

	@Test
	@WithMockUser(username = "daniel", roles = "USER")
	public void spring_security_bean_injected_as_parameter() throws Exception {
		mvc.perform(get("/security/bean"))
			.andExpect(status().isOk())
			.andExpect(content().string("Loaded: user"));
	}

	@Test
	@WithMockUser(username = "admin", roles = "ADMIN")
	public void pre_authorize_grants_admin() throws Exception {
		mvc.perform(get("/security/preauthorize"))
			.andExpect(status().isOk())
			.andExpect(content().string("Admin access via @PreAuthorize"));
	}

	@Test
	@WithMockUser(username = "user", roles = "USER")
	public void pre_authorize_denies_non_admin() throws Exception {
		mvc.perform(get("/security/preauthorize"))
			.andExpect(status().isForbidden());
	}

	@Test
	@WithMockUser(username = "admin", roles = "ADMIN")
	public void secured_grants_admin() throws Exception {
		mvc.perform(get("/security/secured"))
			.andExpect(status().isOk())
			.andExpect(content().string("Admin access via @Secured"));
	}

	@Test
	@WithMockUser(username = "user", roles = "USER")
	public void secured_denies_non_admin() throws Exception {
		mvc.perform(get("/security/secured"))
			.andExpect(status().isForbidden());
	}

	@Test
	@WithMockUser(username = "admin", roles = "ADMIN")
	public void roles_allowed_grants_admin() throws Exception {
		mvc.perform(get("/security/rolesallowed"))
			.andExpect(status().isOk())
			.andExpect(content().string("Admin access via @RolesAllowed"));
	}

	@Test
	@WithMockUser(username = "user", roles = "USER")
	public void roles_allowed_denies_non_admin() throws Exception {
		mvc.perform(get("/security/rolesallowed"))
			.andExpect(status().isForbidden());
	}

	@Test
	@WithMockUser(username = "admin", roles = "ADMIN")
	public void spel_bean_reference_grants_admin() throws Exception {
		mvc.perform(get("/security/rolebean"))
			.andExpect(status().isOk())
			.andExpect(content().string("Admin access via @PreAuthorize SpEL bean reference"));
	}

	@Test
	@WithMockUser(username = "user", roles = "USER")
	public void spel_bean_reference_denies_non_admin() throws Exception {
		mvc.perform(get("/security/rolebean"))
			.andExpect(status().isForbidden());
	}
}

Next

The Spring REST CORS tutorial demonstrates the five ways to configure Cross-Origin Resource Sharing for OfficeFloor REST endpoints.