This project covers Spring Security in two contexts: a form-login MVC app (mtitek-spring-security-mvc)
and a stateless HTTP Basic REST API (mtitek-spring-security-rest).
Both use an in-memory user store with BCrypt-encoded passwords and a custom UserDetails implementation.
Security is configured exclusively via a SecurityFilterChain bean.
spring-boot-starter-security adds Spring Security to both projects. On the classpath, it auto-configures a default filter chain that secures all endpoints — the SecurityFilterChain bean in this project overrides that default entirely.spring-boot-starter-security-test and spring-boot-starter-webmvc-test for test scope. spring-boot-starter-thymeleaf-test is also included — required for Thymeleaf template rendering in @WebMvcTest slices.<!-- both projects -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- MVC project test scope only -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
AppUser implements UserDetails directly rather than wrapping Spring's User builder. This gives full control over the identity model while satisfying Spring Security's authentication contract.getAuthorities() hardcodes ROLE_USER for all users via SimpleGrantedAuthority. The role string must be prefixed with ROLE_ — the hasRole("USER") matcher in the filter chain strips this prefix when comparing, so hasRole("USER") matches ROLE_USER.AppUser uses @RequiredArgsConstructor + @NoArgsConstructor(access = PRIVATE, force = true): username and password are final, so the required-args constructor is the public API and the no-arg constructor is hidden (needed by Jackson for deserialization if the model is ever used in a REST body).AppUser additionally has a non-final Long id field — it uses a plain @Data with no @RequiredArgsConstructor, so the two-arg constructor (username, password) is written explicitly. The id field is unused in this project.@Data
@NoArgsConstructor(access = AccessLevel.PRIVATE, force = true)
@RequiredArgsConstructor
public class AppUser implements UserDetails {
private final String username;
private final String password;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"));
}
}
UserDetailsService is a @Bean that takes PasswordEncoder as a parameter — Spring injects the BCryptPasswordEncoder bean defined in the same class. Passwords are encoded at startup; plain-text passwords are never stored.InMemoryUserDetailsManager holds users in memory only — no database, no persistence across restarts. Suitable for development and testing; not for production..formLogin(...).loginPage("/login") disables Spring Security's auto-generated login page and delegates to the custom LoginController. Without .loginPage(), Spring generates a default login page at /login automatically..defaultSuccessUrl("/", true): the true flag forces redirect to / after login regardless of the originally requested URL. Without true, Spring redirects to the saved request (the URL that triggered the 302 to login)..anyRequest().hasRole("USER") covers all URLs not explicitly matched above. The /login permit-all rule must come before anyRequest — rules are evaluated in declaration order.@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login").permitAll()
.anyRequest().hasRole("USER")
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/", true)
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login")
.permitAll()
)
.build();
}
.securityMatcher("/api/**") scopes this filter chain to /api/** requests only. Requests outside this pattern are not processed by this chain — if no other chain matches, Spring Security's default (deny all) applies..httpBasic(Customizer.withDefaults()) enables HTTP Basic authentication. Credentials are sent as a Base64-encoded Authorization header on every request — no session is created or expected.SessionCreationPolicy.STATELESS instructs Spring Security to never create an HttpSession and never use one to retrieve the SecurityContext. Without this, Spring Security would create a session after the first authenticated request, which is wrong for a stateless REST API..csrf(csrf -> csrf.disable()): CSRF protection is session-based (it relies on a token stored in the session or cookie). Stateless APIs using HTTP Basic or token auth have no session, so CSRF does not apply and must be explicitly disabled — otherwise POST/PUT/DELETE requests are rejected with 403.@Bean
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/appProfiles/**").hasRole("USER")
.anyRequest().permitAll()
)
.httpBasic(Customizer.withDefaults())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(csrf -> csrf.disable())
.build();
}
th:action="@{/login}" (Thymeleaf expression) rather than a hardcoded action="/login". This ensures the context path is prepended correctly if the app is deployed under a sub-path.POST /login directly — no controller method handles it. The LoginController only handles GET /login to render the form.th:if="${error}" renders the error message when Spring Security redirects back to /login?error after a failed authentication. The error parameter is added by Spring Security automatically; Thymeleaf exposes it as a model attribute.// LoginController handles GET only — POST /login is intercepted by Spring Security
@Controller
@RequestMapping("/login")
public class LoginController {
@GetMapping
public String loginForm() {
return "login";
}
}
@WebMvcTest(HomeController.class) loads only the web layer — no full application context, no SecurityConfig bean. Spring Security's auto-configuration still applies in the slice, but the custom SecurityFilterChain is not loaded.status().is4xxClientError() on GET / — this is 401 Unauthorized, because the default Spring Security auto-configuration (applied in the slice) requires authentication. The custom config's hasRole("USER") rule is irrelevant here.SecurityConfig, add @Import(SecurityConfig.class) to the test class. Without it, security behavior in @WebMvcTest reflects Spring Security defaults, not the application's configuration.@WebMvcTest(HomeController.class)
public class HomeControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testHomePage() throws Exception {
mockMvc.perform(get("/")).andExpect(status().is4xxClientError());
}
}