Spring Boot by Building · Chapter 11
TaskFlow already has owners and projects, but the API still trusts caller-supplied ids. We add a guard at the door: register users with hashed passwords, make endpoints public or private, and let only an ADMIN do admin things — all on the TaskFlow project you already have. Hands-on; for the theory and request-lifecycle deep dive, pair this with the Spring Boot Framework collection's Security chapter.
Study the same Security: Spring Security & Basic Auth explanation with a different purpose: build and verify it in Learn deeply, or retrieve and defend it in Prepare for interviews.
The chapter below remains the canonical explanation for both goals. Keep its model, examples, and trade-offs shared; only the deliberate practice changes with the route.
Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.
Without notes, explain the boundary this chapter protects, one concrete implementation decision, and the cost of a tempting shortcut. Then answer one follow-up using the chapter’s own example.
Security is a guard that runs before your controllers — a chain of filters that inspects every request and asks two questions in order: authentication ("who are you?") and authorization ("are you allowed to do this?"). You don't sprinkle checks through your code; you declare the rules once in a config class, and Spring Security enforces them at the door. Your controllers stay as thin as ever.
Who are you? Prove identity — here, an email + password checked against stored, hashed credentials.
What may you do? Once known, your roles decide which endpoints you can reach (e.g. only ADMIN deletes).
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Restart. TaskFlow is now locked by default — every endpoint returns 401 Unauthorized, and
the console prints a generated password for a user named user:
Just adding the starter secured everything — Spring Security's default is "deny unless allowed." That is the right default: you open specific doors deliberately, rather than forgetting to close them. Our job now is to say which doors are public, and who may pass the rest.
The generated user is for demos. Real apps store users in the database. This is just another entity + repository — the layers you already know — plus one rule: never store a raw password.
package com.example.taskflow.model;
import jakarta.persistence.*;
import lombok.*;
@Entity @Table(name = "users")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class AppUser {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password; // stored as a BCrypt hash, never plaintext
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role; // USER or ADMIN
}
package com.example.taskflow.model;
public enum Role { USER, ADMIN }
package com.example.taskflow.repository;
import com.example.taskflow.model.AppUser;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface AppUserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByEmail(String email); // login looks users up by email
}
Passwords are stored hashed with BCrypt, never as text. A hash is one-way: you can check a
guess against it, but you can't reverse it back to the password. If your database leaks, hashes protect your
users. We wire the encoder next; the password column will only ever hold a
$2a$... BCrypt string.
Spring Security doesn't know about your AppUser table. You connect them with a
UserDetailsService — a single method that, given an email, returns the stored credentials + roles in
the shape Security understands.
package com.example.taskflow.security;
import com.example.taskflow.model.AppUser;
import com.example.taskflow.repository.AppUserRepository;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
@Service
public class AppUserDetailsService implements UserDetailsService {
private final AppUserRepository users;
public AppUserDetailsService(AppUserRepository users) { this.users = users; }
@Override
public UserDetails loadUserByUsername(String email) {
AppUser u = users.findByEmail(email).orElseThrow(() -> new UsernameNotFoundException("No user: " + email));
// hand Security the email, the stored hash, and the role as an authority
return User.withUsername(u.getEmail()).password(u.getPassword()) // the BCrypt hash
.roles(u.getRole().name()) // "USER" -> authority ROLE_USER
.build();
}
}
.roles("ADMIN") stores the authority ROLE_ADMIN (Security prepends
ROLE_). Later, hasRole("ADMIN") checks for ROLE_ADMIN. Match them or
access silently fails — the classic "my admin can't get in" bug.This is the heart of the chapter: a single @Configuration that declares the encoder and the access
rules. In modern Spring Security you define a SecurityFilterChain bean with a small lambda DSL.
package com.example.taskflow.security;
import org.springframework.context.annotation.*;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.config.Customizer;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // the hashing algorithm, as a bean
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http // APIs don't use browser form sessions/CSRF; turn CSRF off for a stateless JSON API
.csrf(AbstractHttpConfigurer::disable).authorizeHttpRequests(auth -> auth // PUBLIC: anyone can register
.requestMatchers(HttpMethod.POST, "/auth/register").permitAll() // ADMIN only: deleting tasks
.requestMatchers(HttpMethod.DELETE, "/tasks/**").hasRole("ADMIN") // any logged-in user: everything else under /tasks
.requestMatchers("/tasks/**").hasAnyRole("USER", "ADMIN") // everything not matched above must at least be authenticated
.anyRequest().authenticated()).httpBasic(Customizer.withDefaults()); // use HTTP Basic Auth
return http.build();
}
}
/tasks/**).permitAll() = public. hasRole("ADMIN") = only ADMINs.
hasAnyRole("USER","ADMIN") = any logged-in user. authenticated() = logged in, role
doesn't matter.httpBasic(...) turns on Basic Auth: the client sends
Authorization: Basic base64(email:password) on every request. Simple, and perfect for learning;
Chapter 12 replaces it with tokens.The filter chain is the bouncer. Authentication is checking your ID at the door (Basic Auth = showing
ID every single time you re-enter). Authorization is the wristband: a plain wristband gets you the main floor
(USER), the VIP band gets you upstairs (ADMIN). The config class is the bouncer's
written policy — you set it once, they enforce it for everyone.
New users need a way in. A public /auth/register endpoint hashes the password and saves the user
with a default role of USER. This is normal layered code — service holds the rule, controller is
thin — that happens to use the PasswordEncoder bean.
package com.example.taskflow.service;
import com.example.taskflow.model.*;
import com.example.taskflow.repository.AppUserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final AppUserRepository users;
private final PasswordEncoder encoder; // injected bean from SecurityConfig
public AuthService(AppUserRepository users, PasswordEncoder encoder) {
this.users = users; this.encoder = encoder;
}
public AppUser register(String email, String rawPassword, Role role) {
if (users.findByEmail(email).isPresent()) throw new IllegalStateException("Email already registered");
AppUser u = new AppUser();
u.setEmail(email);
u.setPassword(encoder.encode(rawPassword)); // hash it — the key line
u.setRole(role == null ? Role.USER : role); // default USER
return users.save(u);
}
}
public record RegisterRequest(String email, String password, Role role) { }
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthService auth;
public AuthController(AuthService auth) { this.auth = auth; }
@PostMapping("/register")
public Map<String,String> register(@RequestBody RegisterRequest req) {
auth.register(req.email(), req.password(), req.role());
return Map.of("status", "registered", "email", req.email());
}
}
Register a user and an admin, then use them:
401 Unauthorized = "I don't know who you are" (missing/wrong credentials — an authN failure). 403 Forbidden = "I know who you are, but you may not do this" (an authZ failure — e.g. a USER hitting an ADMIN route). When debugging, 401 → check your login; 403 → check the role rule.
Security shouldn't force you to type passwords in tests. @WithMockUser fakes a logged-in user with
whatever role you want, so you test the rule, not the login.
@WebMvcTest(TaskController.class)
class TaskSecurityTest {
@Autowired MockMvc mvc;
@MockitoBean TaskService service;
@Test
@WithMockUser(roles = "USER") // pretend a logged-in USER
void user_cannot_delete() throws Exception {
mvc.perform(delete("/tasks/1")).andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN") // pretend a logged-in ADMIN
void admin_can_delete() throws Exception {
mvc.perform(delete("/tasks/1")).andExpect(status().isOk());
}
}
Self-check: the two tests encode your authorization rule ("only ADMIN deletes") as executable proof — the same "match test to the rule" habit from Chapter 5, now for security.
Goal. On paper (then in a SecurityConfig), write the
authorizeHttpRequests block for the RentVideo spec: register/login public, browsing videos for any
logged-in user, and creating/updating/deleting videos for ADMIN only.
POST /auth/register and login are permitAll().GET /videos/** is hasAnyRole("CUSTOMER","ADMIN").POST/PUT/DELETE /videos/** are hasRole("ADMIN")./videos/**).requestMatchers(POST, "/videos/**").hasRole("ADMIN"),
same for PUT/DELETE) above the general requestMatchers("/videos/**").hasAnyRole(...), or
the general rule matches first and CUSTOMERs could write..authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/auth/**").permitAll()
.requestMatchers(HttpMethod.POST, "/videos/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/videos/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/videos/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/videos/**").hasAnyRole("CUSTOMER","ADMIN")
.anyRequest().authenticated()
)That's the whole RentVideo authorization layer. You'll build the rest of RentVideo as a capstone in Chapter 14.
Security on one page
spring-boot-starter-security → everything locked by defaultPasswordEncoder bean + SecurityFilterChain rulespermitAll / hasRole / hasAnyRole / authenticated; first match winshttpBasic() = send email:password each requestencoder.encode(...) (BCrypt)..roles("ADMIN") stores ROLE_ADMIN; hasRole("ADMIN")
checks it. Don't add the prefix yourself twice.Chapter 12 — JWT stateless auth. Basic Auth resends your password on every request. We swap it for a token you get once at login and carry thereafter — the mechanism RentVideo Advanced requires.
Current state: projects have owners but callers still claim ids. Outcome: an authorized, owner-scoped API. Prerequisite: owner queries. Java bridge: a filter intercepts requests before controllers. Estimate: 45 minutes.
Configure a filter chain, load users and roles, then prove one authorized request and one rejected request with curl.
Likely files: security/SecurityConfig.java, model/AppUser.java, service/ProjectService.java. Suggested commit: feat: secure taskflow owner access.
Before opening the solution: prove an anonymous denial, a permitted success, and a cross-owner denial.
The next workshop replaces repeated credentials with a verifiable bearer token while preserving the owner rule.