Through Day 33 every endpoint in OrderHub was wide open — anyone who could reach the service could place an order, browse every order, or hit the actuator. Day 34 puts Spring Security in front of the app: each request first authenticates (who are you? — HTTP Basic against BCrypt-hashed users), then authorizes (are you allowed? — reads need ROLE_USER, writes need ROLE_ADMIN, health and docs are public). I built a page that sends the same request as three identities and shows exactly where each one stops, plus a plaintext-vs-BCrypt panel. Here's the shape of it.
The security filter chain is the heart of it
Spring Security works by inserting a chain of servlet filters in front of your app; the SecurityFilterChain bean declares what that chain does. But there's a trap: just adding spring-boot-starter-security to the classpath flips the default to "deny all, HTTP Basic, random generated password" — which would instantly 401 every existing test. So the whole feature ships behind a flag, orderhub.security.enabled, defaulting to false, with two chains selected by @ConditionalOnProperty:
@Bean
@ConditionalOnProperty(prefix="orderhub.security", name="enabled", havingValue="true")
SecurityFilterChain securedFilterChain(HttpSecurity http) throws Exception {
http.csrf(c -> c.disable())
.authorizeHttpRequests(/* role rules */)
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean // default -> permit everything, app unchanged (Day 33 behaviour)
@ConditionalOnProperty(prefix="orderhub.security", name="enabled",
havingValue="false", matchIfMissing=true)
SecurityFilterChain openFilterChain(HttpSecurity http) throws Exception {
http.csrf(c -> c.disable()).authorizeHttpRequests(a -> a.anyRequest().permitAll());
return http.build();
}
With the flag off, the open chain permits all and every prior test stays green — the starter is present but inert. CSRF is disabled in both because this is a stateless per-request API (Basic, later JWT), not a cookie-session browser app.
Authentication vs authorization — two different questions
Authentication asks who are you? Today that's HTTP Basic: the client sends Authorization: Basic base64(user:pass), and Spring verifies it against a UserDetailsService. For a first cut I use an in-memory store with two users — a plain user (ROLE_USER) and an admin (ROLE_ADMIN + ROLE_USER, so admin can do everything a user can plus writes).
Authorization asks are you allowed? — checking the authenticated identity's roles against a per-endpoint rule, evaluated top-to-bottom, first match wins:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/health/**").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/**").hasRole("USER") // reads
.requestMatchers(HttpMethod.POST, "/api/**").hasRole("ADMIN") // writes
.anyRequest().authenticated());
Why 401 and 403 are different on purpose
This is the concept worth internalising. The two failures come from the two different steps, and they mean different things:
-
401 Unauthorized — no identity at all. Spring can't establish who you are, so the entry point challenges with a
WWW-Authenticateheader. It's a prompt to log in, not a permanent refusal — add valid credentials and you advance. -
403 Forbidden — a valid identity that lacks the required role. The credentials matched, authentication passed, but a
userposting to an admin-only write is refused. Retrying with the same identity never helps.
In the demo, a write makes all three visible: anonymous stops at authenticate (401), a user passes authentication but stops at authorize (403), and only admin reaches the handler (201). Collapsing both codes into "something went wrong" is a classic UX bug — it either traps an authorized user in a login loop or hides that they simply lack the role.
BCrypt — passwords hashed, never in the clear
The password encoder turns "user-pw" into something safe to store. BCryptPasswordEncoder is one-way, per-user-salted, and deliberately slow — encode() produces a 60-char string holding the algorithm, a cost factor, the salt, and the hash, and matches(raw, stored) re-hashes the presented password with that salt and compares; the original is never recoverable:
@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
// stored: admin -> $2a$10$Qk7....v2Lb1 (never the plaintext)
This matters because databases leak. With plaintext (or reversible "encryption") a single dump hands over every password — and since people reuse passwords, their email and bank too. The salt means identical passwords hash differently, defeating rainbow tables; the tunable cost lets you make guessing slower as hardware speeds up. BCrypt turns a leak from "instant catastrophe" into "expensive per-user brute force." Day 34 is the foundation; Day 35 swaps per-request Basic credentials for a stateless signed JWT.
Send the same request as three identities and watch the chain:
https://dev48v.infy.uk/orderhub.php
Top comments (0)