Web Security Fundamentals — Authentication vs Authorization
Before diving into Spring Security, you need to understand two concepts that form the foundation of all web security.
Authentication — “Who are you?”
Authentication verifies the identity of a user. When you log in to any website with a username and password, the server authenticates you by checking if the credentials are correct.
Common authentication methods:
- Username + Password — The most common method. The user provides credentials, the server checks them against stored values.
- OAuth 2.0 / Social Login — “Login with Google/GitHub/Facebook.” A third party vouches for the user’s identity.
- API Keys — A secret key sent with each request. Common for server-to-server communication.
- JWT (JSON Web Tokens) — A self-contained token that proves identity. We will implement this in Lecture 12.
Authorization — “What are you allowed to do?”
Authorization determines what an authenticated user can access. After the server knows who you are, it checks your permissions.
Examples in our blog application:
- Any visitor (unauthenticated) can read published posts
- Authenticated users (ROLE_USER) can create comments
- Authors (ROLE_AUTHOR) can create and edit their own posts
- Admins (ROLE_ADMIN) can edit/delete any post and manage users
The Security Flow
Client sends request
↓
Authentication: "Who is this?"
↓ (valid credentials)
Authorization: "Can they do this?"
↓ (has permission)
Request reaches the controller
↓
Response sent back
If authentication fails → 401 Unauthorized. If authorization fails → 403 Forbidden.
The difference matters: 401 means “I don’t know who you are,” 403 means “I know who you are, but you’re not allowed to do this.”
Spring Security Architecture Overview
Spring Security is a powerful and highly customizable framework. It can feel overwhelming at first because of its many moving parts. Let us break it down.
The Big Picture
When an HTTP request arrives at your Spring Boot application, it passes through a chain of security filters before reaching your controller. These filters handle authentication, authorization, CSRF protection, session management, and more.
HTTP Request
↓
┌──────────────────────────────────┐
│ Security Filter Chain │
│ │
│ 1. CORS Filter │
│ 2. CSRF Filter │
│ 3. Authentication Filter │ ← Checks credentials
│ 4. Authorization Filter │ ← Checks permissions
│ 5. Exception Translation Filter │ ← Converts security exceptions
│ ... │
└──────────────────────────────────┘
↓
Controller (if all filters pass)
Key Components
SecurityFilterChain — The ordered chain of filters that processes every request. You configure it to define your security rules.
AuthenticationManager — The component that validates credentials. It delegates to one or more AuthenticationProvider implementations.
UserDetailsService — An interface you implement to load user data from your database. Spring Security calls it during authentication.
PasswordEncoder — Encodes (hashes) passwords for secure storage and compares submitted passwords against stored hashes.
SecurityContext — Holds the authentication information of the current user. After successful authentication, the user’s details are stored here and available throughout the request.
GrantedAuthority — Represents a permission or role (e.g., ROLE_ADMIN, ROLE_USER). Users can have multiple authorities.
How They Work Together
1. User sends POST /api/auth/login with {username, password}
2. Authentication Filter intercepts the request
3. AuthenticationManager calls UserDetailsService.loadUserByUsername("alice")
4. UserDetailsService queries MariaDB → returns UserDetails (username, hashed password, roles)
5. PasswordEncoder.matches(submittedPassword, storedHash) → true/false
6. If match: Authentication object created with user details + authorities
If no match: AuthenticationException thrown → 401 response
7. Authentication stored in SecurityContext → available for the rest of the request
8. On subsequent requests: the filter checks the SecurityContext (via session or token)
Adding Spring Security Dependency — What Happens by Default
Add the Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
The Immediate Effect
The moment you add this dependency and restart, your entire application is locked down. Every endpoint requires authentication. Spring Security applies these defaults:
- All endpoints are protected — every request returns 401 Unauthorized
- HTTP Basic authentication is enabled — the browser shows a login popup
- A default user is created — username is
user, password is printed in the console - CSRF protection is enabled — POST/PUT/DELETE requests are blocked without a CSRF token
- Session-based authentication — the server creates an HTTP session after login
Look for this line in the console output:
Using generated security password: a1b2c3d4-e5f6-7890-abcd-ef1234567890
You can test with curl:
# Without authentication — 401 Unauthorized
curl http://localhost:8080/api/posts
# {"status":401,"error":"Unauthorized"}
# With HTTP Basic authentication — works!
curl -u user:a1b2c3d4-e5f6-7890-abcd-ef1234567890 http://localhost:8080/api/posts
# [{"id":1,"title":"..."}]
This default behavior is intentional — Spring Security follows a “secure by default” philosophy. You opt out of protections rather than opt in.
We Need to Customize This
The defaults are not suitable for a REST API:
- We want some endpoints to be public (reading posts)
- We want to use our own users from MariaDB, not the generated password
- We want JWT tokens instead of sessions (covered in Lecture 12)
- We want to disable CSRF for our stateless API
Let us build the proper configuration step by step.
Security Filter Chain Explained
What is a Filter Chain?
A filter chain is a sequence of filters that process HTTP requests. Each filter has a specific job and passes the request to the next filter in the chain. If any filter rejects the request, the chain stops and an error response is sent.
Think of it like airport security:
Passenger (HTTP Request)
↓
1. Ticket check (CORS filter — is this request allowed from this origin?)
↓
2. ID verification (Authentication filter — who is this person?)
↓
3. Security screening (Authorization filter — are they allowed in this area?)
↓
4. Boarding gate (Controller — process the request)
Spring Security’s Default Filter Order
Spring Security registers about 15 filters. Here are the most important ones in order:
| Order | Filter | Purpose |
|---|---|---|
| 1 | CorsFilter |
Handles Cross-Origin Resource Sharing |
| 2 | CsrfFilter |
Validates CSRF tokens (we will disable this) |
| 3 | LogoutFilter |
Processes logout requests |
| 4 | UsernamePasswordAuthenticationFilter |
Processes form login |
| 5 | BasicAuthenticationFilter |
Processes HTTP Basic authentication |
| 6 | AuthorizationFilter |
Checks if the authenticated user has permission |
| 7 | ExceptionTranslationFilter |
Converts security exceptions to HTTP responses |
In Lecture 12, we will add our own JWT Authentication Filter at position 5, replacing the Basic authentication filter.
User Entity with Roles — Storing Credentials in MariaDB
Our User entity from previous lectures already has the fields we need for security: username, email, passwordHash, role, and isActive. Let us add a Flyway migration for any missing security-related columns.
Flyway Migration for Roles
If you need a more flexible role system, you can create a separate roles table. For our blog, a single role column on the users table is sufficient since each user has one role.
File: V11__ensure_user_security_fields.sql
-- V11: Ensure user security fields exist
-- This is a safety migration — these columns should already exist from V1.
-- If your V1 migration already includes these, this migration is a no-op.
-- Add role column if it doesn't exist (MariaDB doesn't support IF NOT EXISTS for columns)
-- If V1 already created this column, this migration can be left empty or used for other security setup.
-- Create index on role for efficient role-based queries
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
The User Entity (Security-Relevant Fields)
Our existing User entity already has what we need:
@Entity
@Table(name = "users")
public class User extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false, unique = true)
private String email;
// The password is stored as a BCrypt hash — NEVER plain text.
// BCrypt hashes are 60 characters long and look like:
// $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
@Column(name = "password_hash", nullable = false)
private String passwordHash;
// Role for authorization: ROLE_USER, ROLE_AUTHOR, ROLE_ADMIN
// Spring Security expects roles to start with "ROLE_" prefix.
@Column(nullable = false, length = 20)
private String role = "ROLE_USER";
// Inactive users cannot log in
@Column(name = "is_active", nullable = false)
private boolean active = true;
// ... other fields, constructors, getters, setters
}
Why the ROLE_ Prefix?
Spring Security distinguishes between roles and authorities:
- Authority: A specific permission like
READ_POSTS,WRITE_POSTS,DELETE_USERS - Role: A group of authorities like
ROLE_ADMIN,ROLE_USER
When you use hasRole("ADMIN") in security configuration, Spring automatically adds the ROLE_ prefix and checks for ROLE_ADMIN. This is a convention you must follow — store roles with the ROLE_ prefix in the database.
UserDetailsService Implementation
Spring Security does not know how to load users from your database. You need to implement UserDetailsService to bridge Spring Security with your UserRepository.
The Interface
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
Spring Security calls loadUserByUsername() during authentication. You load the user from your database and return a UserDetails object that Spring Security understands.
The Implementation
File: src/main/java/com/example/blogapi/security/CustomUserDetailsService.java
package com.example.blogapi.security;
import com.example.blogapi.model.User;
import com.example.blogapi.repository.UserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.Collections;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// Load the user from MariaDB
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found with username: " + username));
// Convert our User entity into Spring Security's UserDetails.
// UserDetails is the interface Spring Security uses to represent
// an authenticated user — it contains username, password, authorities,
// and account status flags.
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPasswordHash(),
user.isActive(), // enabled — inactive users cannot log in
true, // accountNonExpired
true, // credentialsNonExpired
true, // accountNonLocked
Collections.singletonList(
new SimpleGrantedAuthority(user.getRole())
)
);
}
}
Let us trace through what happens:
- User submits
{"username": "alice", "password": "mypassword"} - Spring Security calls
loadUserByUsername("alice") - We query MariaDB:
SELECT * FROM users WHERE username = 'alice' - We create a
UserDetailsobject with the username, hashed password, and role - Spring Security compares the submitted password against the hash using
PasswordEncoder - If it matches → authentication succeeds. If not → 401 Unauthorized.
Loading User by Email (Alternative)
Some applications authenticate with email instead of username. You can support both:
@Override
public UserDetails loadUserByUsername(String usernameOrEmail) throws UsernameNotFoundException {
// Try username first, then email
User user = userRepository.findByUsername(usernameOrEmail)
.or(() -> userRepository.findByEmail(usernameOrEmail))
.orElseThrow(() -> new UsernameNotFoundException(
"User not found: " + usernameOrEmail));
return buildUserDetails(user);
}
private UserDetails buildUserDetails(User user) {
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPasswordHash(),
user.isActive(),
true, true, true,
Collections.singletonList(new SimpleGrantedAuthority(user.getRole()))
);
}
Password Encoding with BCrypt
Why Not Store Plain Text Passwords?
If an attacker gains access to your database (through SQL injection, a backup leak, or a compromised server), they see every user’s password in plain text. Those users probably use the same password on other sites — now the attacker has access to their email, banking, and social media accounts.
Never store plain text passwords. Store a one-way hash instead.
What is BCrypt?
BCrypt is a password hashing algorithm designed specifically for passwords. It has three important properties:
1. One-way — You can hash a password, but you cannot reverse the hash to get the original password.
2. Salted — BCrypt automatically generates a random salt for each password. Two users with the same password get different hashes.
3. Slow by design — BCrypt is intentionally slow (configurable via a “strength” parameter). This makes brute-force attacks impractical. Hashing one password takes ~100ms instead of nanoseconds.
Password: "mypassword"
BCrypt hash: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
Structure: $2a$10$salt_and_hash
├─┘ ├┘ └──────────┘
│ │ │
│ │ └── Salt (22 chars) + Hash (31 chars)
│ └── Cost factor (10 = 2^10 = 1024 rounds)
└── Algorithm version (2a)
Configuring the Password Encoder
File: src/main/java/com/example/blogapi/security/SecurityConfig.java (partial — we will complete this in Section 8)
package com.example.blogapi.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
// Register BCryptPasswordEncoder as a Spring bean.
// Spring Security uses this bean to:
// 1. Hash passwords during registration (encode)
// 2. Compare submitted passwords during login (matches)
@Bean
public PasswordEncoder passwordEncoder() {
// The default strength is 10 (2^10 = 1024 rounds).
// Higher values are more secure but slower.
// 10 is the recommended balance for most applications.
return new BCryptPasswordEncoder();
}
}
Using the Password Encoder
@Service
public class AuthService {
private final PasswordEncoder passwordEncoder;
public AuthService(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
// During registration: hash the password before storing
public void registerUser(String username, String email, String rawPassword) {
String hashedPassword = passwordEncoder.encode(rawPassword);
// hashedPassword = "$2a$10$N9qo8uLO..."
// Store this hash in the database — never the raw password!
User user = new User(username, email, hashedPassword);
userRepository.save(user);
}
// During login: compare the submitted password against the stored hash
public boolean verifyPassword(String rawPassword, String storedHash) {
return passwordEncoder.matches(rawPassword, storedHash);
// BCrypt extracts the salt from the stored hash,
// hashes the raw password with the same salt,
// and compares the results.
}
}
You never call verifyPassword manually during login — Spring Security handles this automatically using the PasswordEncoder bean and the UserDetails returned by UserDetailsService.
Configuring SecurityFilterChain
Now let us configure Spring Security for our REST API.
The Complete Security Configuration
File: src/main/java/com/example/blogapi/security/SecurityConfig.java
package com.example.blogapi.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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;
@Configuration
@EnableWebSecurity // Enables Spring Security's web security support
@EnableMethodSecurity // Enables @PreAuthorize, @PostAuthorize on methods
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// Expose the AuthenticationManager as a bean.
// We will need this in Lecture 12 for the login endpoint.
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 1. Disable CSRF — our API is stateless (no cookies/sessions).
// CSRF protection is for browser-based apps with sessions.
// REST APIs with token-based auth don't need it.
.csrf(csrf -> csrf.disable())
// 2. Set session management to STATELESS.
// Spring Security will not create or use HTTP sessions.
// Every request must be authenticated independently.
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 3. Define authorization rules for each endpoint.
.authorizeHttpRequests(auth -> auth
// Public endpoints — anyone can access without authentication
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/categories/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/tags/**").permitAll()
// Authenticated endpoints — any logged-in user
.requestMatchers(HttpMethod.POST, "/api/posts/*/comments").authenticated()
// Author endpoints — ROLE_AUTHOR or ROLE_ADMIN
.requestMatchers(HttpMethod.POST, "/api/posts").hasAnyRole("AUTHOR", "ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/posts/**").hasAnyRole("AUTHOR", "ADMIN")
.requestMatchers(HttpMethod.PATCH, "/api/posts/**").hasAnyRole("AUTHOR", "ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/posts/**").hasAnyRole("AUTHOR", "ADMIN")
// Admin endpoints — ROLE_ADMIN only
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/users/**").hasRole("ADMIN")
// Everything else requires authentication
.anyRequest().authenticated()
)
// 4. Use HTTP Basic authentication for now.
// In Lecture 12, we will replace this with JWT.
.httpBasic(basic -> {});
return http.build();
}
}
Understanding the Rules
Let us trace through a few requests:
GET /api/posts
→ Matches ".requestMatchers(GET, /api/posts/**).permitAll()"
→ No authentication needed → reaches controller
POST /api/posts (no credentials)
→ Matches ".requestMatchers(POST, /api/posts).hasAnyRole(AUTHOR, ADMIN)"
→ No authentication → 401 Unauthorized
POST /api/posts (with ROLE_USER credentials)
→ Matches hasAnyRole("AUTHOR", "ADMIN")
→ User has ROLE_USER, not ROLE_AUTHOR or ROLE_ADMIN → 403 Forbidden
POST /api/posts (with ROLE_AUTHOR credentials)
→ Matches hasAnyRole("AUTHOR", "ADMIN")
→ User has ROLE_AUTHOR → allowed → reaches controller
DELETE /api/users/5 (with ROLE_AUTHOR credentials)
→ Matches ".requestMatchers(DELETE, /api/users/**).hasRole(ADMIN)"
→ User has ROLE_AUTHOR, not ROLE_ADMIN → 403 Forbidden
Important: hasRole() vs hasAuthority()
// hasRole("ADMIN") → checks for authority "ROLE_ADMIN" (adds "ROLE_" prefix automatically)
.requestMatchers("/admin/**").hasRole("ADMIN")
// hasAuthority("ROLE_ADMIN") → checks for exact authority "ROLE_ADMIN"
.requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
// Both are equivalent — choose one style and be consistent.
// hasRole() is more common and more readable.
Role-Based Access Control — @PreAuthorize, hasRole()
URL-Based Security vs Method-Based Security
In Section 8, we defined security rules in the SecurityFilterChain based on URL patterns. This is URL-based security. It works well for broad rules but cannot express conditions like “authors can edit only their own posts.”
Method-based security with @PreAuthorize lets you define fine-grained access control directly on service methods:
@Service
public class PostService {
// Only users with ROLE_AUTHOR or ROLE_ADMIN can create posts
@PreAuthorize("hasAnyRole('AUTHOR', 'ADMIN')")
@Transactional
public PostResponse createPost(CreatePostRequest request) {
// ...
}
// Anyone can read posts (public)
// No @PreAuthorize → no restriction at the method level.
// URL-level permitAll() handles this.
@Transactional(readOnly = true)
public PostResponse getPostById(Long id) {
// ...
}
// Authors can only edit their own posts.
// Admins can edit any post.
// #id refers to the method parameter named "id".
@PreAuthorize("hasRole('ADMIN') or @postSecurity.isAuthor(#id, authentication.name)")
@Transactional
public PostResponse updatePost(Long id, UpdatePostRequest request) {
// ...
}
// Only admins can delete posts
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public void deletePost(Long id) {
// ...
}
}
Custom Security Expressions
The @postSecurity.isAuthor(#id, authentication.name) expression above calls a Spring bean. Let us create it:
File: src/main/java/com/example/blogapi/security/PostSecurity.java
package com.example.blogapi.security;
import com.example.blogapi.repository.PostRepository;
import org.springframework.stereotype.Component;
// The bean name "postSecurity" is what @PreAuthorize references with @postSecurity
@Component("postSecurity")
public class PostSecurity {
private final PostRepository postRepository;
public PostSecurity(PostRepository postRepository) {
this.postRepository = postRepository;
}
// Check if the given user is the author of the given post
public boolean isAuthor(Long postId, String username) {
return postRepository.findById(postId)
.map(post -> post.getAuthor().getUsername().equals(username))
.orElse(false);
}
}
Now @PreAuthorize("hasRole('ADMIN') or @postSecurity.isAuthor(#id, authentication.name)") means:
- If the user has ROLE_ADMIN → allowed (admins can edit any post)
- If the user is the author of the post → allowed (authors can edit their own posts)
- Otherwise → 403 Forbidden
Getting the Current User
In controllers and services, you often need to know who the current user is:
@RestController
@RequestMapping("/api/posts")
public class PostController {
// Method 1: Inject Authentication directly
@PostMapping
public ResponseEntity<PostResponse> createPost(
@Valid @RequestBody CreatePostRequest request,
Authentication authentication) {
String username = authentication.getName();
// Use the username to find the author in the database
// ...
}
// Method 2: Use @AuthenticationPrincipal
@GetMapping("/my-posts")
public List<PostResponse> getMyPosts(
@AuthenticationPrincipal UserDetails userDetails) {
String username = userDetails.getUsername();
// ...
}
}
Common @PreAuthorize Expressions
// Only authenticated users
@PreAuthorize("isAuthenticated()")
// Only specific role
@PreAuthorize("hasRole('ADMIN')")
// Multiple roles
@PreAuthorize("hasAnyRole('AUTHOR', 'ADMIN')")
// Custom expression with method parameters
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
// Calling a Spring bean method
@PreAuthorize("@postSecurity.isAuthor(#postId, authentication.name)")
// Combining conditions
@PreAuthorize("hasRole('ADMIN') or (hasRole('AUTHOR') and @postSecurity.isAuthor(#id, authentication.name))")
// Deny all (useful for temporarily disabling an endpoint)
@PreAuthorize("denyAll()")
Hands-on: Add User Registration & Login to Blog API
Let us build the authentication endpoints for our blog.
Step 1: Auth DTOs
File: src/main/java/com/example/blogapi/dto/RegisterRequest.java
package com.example.blogapi.dto;
import jakarta.validation.constraints.*;
public class RegisterRequest {
@NotBlank(message = "Username is required")
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
@Pattern(regexp = "^[a-zA-Z0-9_]+$",
message = "Username can only contain letters, numbers, and underscores")
private String username;
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
private String password;
private String fullName;
public RegisterRequest() { }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getFullName() { return fullName; }
public void setFullName(String fullName) { this.fullName = fullName; }
}
File: src/main/java/com/example/blogapi/dto/LoginRequest.java
package com.example.blogapi.dto;
import jakarta.validation.constraints.NotBlank;
public class LoginRequest {
@NotBlank(message = "Username is required")
private String username;
@NotBlank(message = "Password is required")
private String password;
public LoginRequest() { }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
File: src/main/java/com/example/blogapi/dto/AuthResponse.java
package com.example.blogapi.dto;
public class AuthResponse {
private String message;
private String username;
private String role;
// In Lecture 12, we will add a JWT token here:
// private String token;
public AuthResponse() { }
public AuthResponse(String message, String username, String role) {
this.message = message;
this.username = username;
this.role = role;
}
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}
Step 2: Auth Service
File: src/main/java/com/example/blogapi/service/AuthService.java
package com.example.blogapi.service;
import com.example.blogapi.dto.AuthResponse;
import com.example.blogapi.dto.LoginRequest;
import com.example.blogapi.dto.RegisterRequest;
import com.example.blogapi.exception.BadRequestException;
import com.example.blogapi.exception.DuplicateResourceException;
import com.example.blogapi.model.User;
import com.example.blogapi.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
public AuthService(UserRepository userRepository,
PasswordEncoder passwordEncoder,
AuthenticationManager authenticationManager) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.authenticationManager = authenticationManager;
}
@Transactional
public AuthResponse register(RegisterRequest request) {
log.info("Registering new user: username='{}'", request.getUsername());
// Check for duplicate username
if (userRepository.existsByUsername(request.getUsername())) {
throw new DuplicateResourceException("User", "username", request.getUsername());
}
// Check for duplicate email
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateResourceException("User", "email", request.getEmail());
}
// Create user with hashed password
User user = new User(
request.getUsername(),
request.getEmail().toLowerCase(),
passwordEncoder.encode(request.getPassword()) // Hash the password!
);
user.setFullName(request.getFullName());
user.setRole("ROLE_USER"); // Default role for new registrations
userRepository.save(user);
log.info("User registered successfully: username='{}', id={}", user.getUsername(), user.getId());
return new AuthResponse("Registration successful", user.getUsername(), user.getRole());
}
public AuthResponse login(LoginRequest request) {
log.info("Login attempt: username='{}'", request.getUsername());
try {
// AuthenticationManager delegates to:
// 1. CustomUserDetailsService.loadUserByUsername() → loads user from DB
// 2. PasswordEncoder.matches() → compares password with hash
// If either fails, AuthenticationException is thrown.
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// If we get here, authentication succeeded.
User user = userRepository.findByUsername(request.getUsername())
.orElseThrow();
log.info("Login successful: username='{}'", request.getUsername());
// In Lecture 12, we will generate and return a JWT token here.
return new AuthResponse("Login successful", user.getUsername(), user.getRole());
} catch (AuthenticationException ex) {
log.warn("Login failed for username='{}': {}", request.getUsername(), ex.getMessage());
throw new BadRequestException("Invalid username or password");
}
}
}
Step 3: Auth Controller
File: src/main/java/com/example/blogapi/controller/AuthController.java
package com.example.blogapi.controller;
import com.example.blogapi.dto.AuthResponse;
import com.example.blogapi.dto.LoginRequest;
import com.example.blogapi.dto.RegisterRequest;
import com.example.blogapi.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(
@Valid @RequestBody RegisterRequest request) {
AuthResponse response = authService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@Valid @RequestBody LoginRequest request) {
AuthResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
}
Step 4: Test the Authentication
# Register a new user
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "alice",
"email": "alice@example.com",
"password": "securepass123",
"fullName": "Alice Johnson"
}'
# Expected 201:
# {"message":"Registration successful","username":"alice","role":"ROLE_USER"}
# Login with the registered user
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "securepass123"}'
# Expected 200:
# {"message":"Login successful","username":"alice","role":"ROLE_USER"}
# Login with wrong password
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "wrongpassword"}'
# Expected 400:
# {"status":400,"error":"Bad Request","message":"Invalid username or password",...}
# Public endpoint — works without authentication
curl http://localhost:8080/api/posts
# Protected endpoint with HTTP Basic auth
curl -u alice:securepass123 -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{"title":"My First Post","content":"Hello from a secured API!","authorId":1}'
# Protected endpoint without auth — 401
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{"title":"My First Post","content":"Hello!","authorId":1}'
# Register a duplicate username — 409
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "alice", "email": "alice2@example.com", "password": "pass12345678"}'
# Expected 409:
# {"status":409,"error":"Conflict","message":"User already exists with username: alice",...}
Step 5: Exercises
- Create an admin user setup: Write a migration or a
CommandLineRunnerbean that creates an admin user on first startup if no admin exists. Hash the password with BCrypt. - Add a “me” endpoint: Create
GET /api/auth/methat returns the current authenticated user’s profile. Use@AuthenticationPrincipalto get the username, then load full user details from the database. - Implement role upgrade: Create
PATCH /api/admin/users/{id}/role(admin-only) that changes a user’s role. Test that only admins can access it. - Test authorization: Register users with different roles (USER, AUTHOR, ADMIN). Verify that each role can only access the endpoints they are authorized for. Document the results in a table.
- Add password change: Create
POST /api/auth/change-passwordthat accepts the old password and new password. Verify the old password matches before updating.
Summary
This lecture secured your blog API with authentication and authorization:
- Authentication vs Authorization: Authentication verifies identity (who are you?). Authorization checks permissions (what can you do?). 401 = not authenticated, 403 = not authorized.
- Spring Security architecture: Requests pass through a filter chain. Each filter has a specific security role. The chain can reject requests at any point.
- Default behavior: Adding
spring-boot-starter-securitylocks down everything immediately. You customize by configuring theSecurityFilterChain. - UserDetailsService: Bridge between Spring Security and your database. Load user by username, return
UserDetailswith credentials and authorities. - BCrypt password encoding: One-way hash, salted, intentionally slow. Never store plain text passwords. Use
passwordEncoder.encode()for registration and let Spring Security handle comparison during login. - SecurityFilterChain configuration: Disable CSRF for stateless APIs, set session policy to STATELESS, define URL-based access rules with
requestMatchers(). - @PreAuthorize: Method-level security for fine-grained control. Supports SpEL expressions, method parameters (
#id), and custom security beans (@postSecurity.isAuthor()). - Auth endpoints: Registration hashes the password and creates the user. Login delegates to
AuthenticationManagerwhich usesUserDetailsService+PasswordEncoder.
What is Next
In Lecture 12, we will replace HTTP Basic authentication with JWT (JSON Web Tokens) — the industry standard for REST API authentication. You will learn how JWTs work, build a JWT utility class, create authentication and refresh token flows, and secure your API with token-based authentication.
Quick Reference
| Concept | Description |
|---|---|
| Authentication | Verifying user identity (who are you?) |
| Authorization | Checking user permissions (what can you do?) |
| 401 Unauthorized | Authentication required or credentials invalid |
| 403 Forbidden | Authenticated but not authorized |
| Security Filter Chain | Ordered chain of filters processing every HTTP request |
UserDetailsService |
Interface to load user data from your database |
UserDetails |
Spring Security’s representation of an authenticated user |
PasswordEncoder |
Hashes passwords (BCrypt) and compares hashes |
| BCrypt | Password hashing algorithm — one-way, salted, slow by design |
SecurityFilterChain |
Bean that configures security rules |
.permitAll() |
Allow access without authentication |
.authenticated() |
Require any authenticated user |
.hasRole("ADMIN") |
Require specific role (adds ROLE_ prefix) |
.hasAnyRole(...) |
Require any of the listed roles |
SessionCreationPolicy.STATELESS |
No HTTP sessions — every request authenticated independently |
@EnableMethodSecurity |
Enables @PreAuthorize on methods |
@PreAuthorize |
Method-level security with SpEL expressions |
@AuthenticationPrincipal |
Injects the current user’s details into a method parameter |
AuthenticationManager |
Validates credentials using UserDetailsService + PasswordEncoder |
GrantedAuthority |
Represents a role or permission |
| ROLE_ prefix | Convention: store roles as ROLE_USER, ROLE_ADMIN in database |
