Mini-challenge: TaskFlow v3 — JWT + role-based access control
Migrate TaskFlow từ course Spring REST & Data (JPA Postgres) sang course Spring Security — bảo vệ với JWT, role-based authz (USER/MANAGER/ADMIN), ownership check, register/login/refresh/logout endpoints, integration test với @WithMockUser, secure Actuator.
TL;DR: Bài capstone của course: migrate TaskFlow sang JWT-based security với đầy đủ register/login/refresh/logout, RBAC ba role (USER/MANAGER/ADMIN), và ownership check tại DB. Trọng tâm bảo mật là refresh token rotation + revoke server-side — token cũ bị vô hiệu trong DB ngay khi exchange, dùng lại phải nhận 401. Ba tầng phòng thủ phối hợp: filter chain (CORS, JWT validate), URL rules, method security @PreAuthorize. Dành 45-60 phút build theo hint từng layer bên dưới.
Course Spring Security đã bóc từng layer kiến trúc filter chain, authentication và authorization. Bài cuối này build full pipeline auth + authz trên TaskFlow đã có từ course Spring REST & Data — tạo TaskFlow v3 production-grade, sẵn sàng cho module Testing.
✅ Checklist hoàn thành
Mục tiêu tối thiểu để coi là xong — 6 mục đầu bắt buộc, 2 mục cuối bonus:
- (Bắt buộc) Register + login trả cặp access token (15 phút) + refresh token (30 ngày); password hash BCrypt strength 12.
- (Bắt buộc) Refresh rotation: token cũ bị revoke trong DB ngay khi exchange — gửi lại refresh token cũ phải nhận 401, không phải cặp token mới.
- (Bắt buộc) Logout revoke refresh token server-side (đánh dấu
revokedtrong DB), không chỉ xoá token phía client. - (Bắt buộc) URL rules +
@PreAuthorizeđúng ma trận quyền: USER xoá project nhận 403, ADMIN nhận 204. - (Bắt buộc) Ownership check bằng query filter tại DB (
findByIdAndOwnerEmail) — project của người khác trả 404, che giấu tồn tại. - (Bắt buộc) CORS origin cụ thể + CSRF disable; 401/403 trả Problem Details JSON.
- (Bonus) Actuator secure:
/actuator/healthpublic, còn lại ROLE_ADMIN. - (Bonus) 5+ integration test với
jwt()post-processor verify cả URL rule lẫn method security.
🎯 Đề bài
Migrate TaskFlow (từ course Spring REST & Data) sang course Spring Security với security:
Domain mở rộng
User (TaskFlow v2 đã có)
├── id: Long
├── email: String (unique)
├── password: String (BCrypt hash) — NEW
├── name: String
├── active: Boolean
├── locked: Boolean
└── roles: Set<Role> — NEW
Role
├── id: Long
└── name: String (USER, MANAGER, ADMIN)
(Project + Task + Comment giữ nguyên TaskFlow v2)
7 endpoint authz
POST /api/auth/register Public — register new user (default ROLE_USER)
POST /api/auth/login Public — return JWT access + refresh
POST /api/auth/refresh Public — exchange refresh → new access
POST /api/auth/logout Authenticated — revoke refresh token
GET /api/v1/me Authenticated — return current user info
# Protected endpoints (giữ từ TaskFlow v2 + add authz)
GET /api/v1/projects Authenticated — list owned projects
POST /api/v1/projects ROLE_USER+ — create project
GET /api/v1/projects/{id} Owner OR ROLE_ADMIN — view
PUT /api/v1/projects/{id} Owner OR ROLE_ADMIN — update
DELETE /api/v1/projects/{id} ROLE_ADMIN only — delete
# Tasks endpoints (project owner OR assignee OR ADMIN)
...
# Admin
GET /api/admin/users ROLE_ADMIN — list all users
PUT /api/admin/users/{id}/role ROLE_ADMIN — change user role
Yêu cầu kỹ thuật
- Spring Security 6 + JWT (HS256, in-app issuer + validator).
- BCrypt password encoding (strength 12).
- Database User + Role (TaskFlow v2 entity + migration).
- 3 roles: USER (default), MANAGER (manage projects), ADMIN (full access).
- JWT contains: subject (email), roles, exp, iat, jti.
- Access token 15 min, refresh token 30 days — lưu DB, revoke server-side bắt buộc: rotation revoke token cũ ngay khi exchange, logout revoke token hiện tại; dùng lại token đã revoke phải trả 401 (cơ chế và lý do ở JWT issue & refresh).
@PreAuthorizecho method-level security (ADMIN only methods).- Ownership check: query filter at DB hoặc custom security bean.
- CORS allow frontend origin. CSRF disable (stateless API).
- Actuator secure:
/actuator/healthpublic, others ROLE_ADMIN. - Custom exception handler: 401/403 trả Problem Details.
- Integration test với
@WithMockUsercho 5+ scenario.
🔍 Architecture
flowchart LR
Client[HTTP Client]
CORS[CorsFilter]
JWT[BearerTokenAuthFilter]
Authz[AuthorizationFilter]
Ctrl[Controllers]
Svc[Services @PreAuthorize]
Repo[Repositories -- query filter ownership]
DB[(Postgres)]
Client --> CORS --> JWT --> Authz --> Ctrl --> Svc --> Repo --> DB
style JWT fill:#fef3c7
style Svc fill:#d1fae53 security boundary:
- Filter chain — CORS, JWT validate.
- URL rules —
requestMatchers().hasRole(...). - Method security —
@PreAuthorizecho fine-grained authz.
📦 Concept dùng trong bài
| Concept | Bài nguồn |
|---|---|
| BCrypt password encoding | UserDetailsService & BCrypt |
| UserDetailsService DB | UserDetailsService & BCrypt |
| SecurityFilterChain DSL | SecurityFilterChain DSL |
| JWT issue + validate | JWT structure & validation |
| Refresh token DB store | JWT issue, refresh & revoke |
| @PreAuthorize SpEL | @PreAuthorize & AOP proxy |
| Ownership check query filter | Method security filtering |
| CORS config | CORS |
| Custom auth entry point | SecurityFilterChain DSL |
| Test với @WithMockUser | module Testing (sắp tới) |
▶️ Cấu trúc project mới
taskflow-api-v3/
├── pom.xml
├── docker-compose.yml
└── src/
├── main/
│ ├── java/com/olhub/taskflow/
│ │ ├── App.java
│ │ ├── config/
│ │ │ ├── SecurityConfig.java # NEW
│ │ │ ├── JwtConfig.java # NEW
│ │ │ ├── CorsConfig.java # NEW
│ │ │ ├── SecurityAuditor.java # @CreatedBy from JWT
│ │ │ └── ...
│ │ ├── auth/ # NEW
│ │ │ ├── AuthController.java
│ │ │ ├── JwtIssuer.java
│ │ │ ├── AppUserDetailsService.java
│ │ │ ├── RefreshTokenService.java
│ │ │ ├── CurrentUser.java # @CurrentUser annotation
│ │ │ ├── CurrentUserResolver.java
│ │ │ └── dto/
│ │ │ ├── RegisterRequest.java
│ │ │ ├── LoginRequest.java
│ │ │ ├── RefreshRequest.java
│ │ │ └── TokenResponse.java
│ │ ├── domain/
│ │ │ ├── AppUser.java # extend TaskFlow v2
│ │ │ ├── Role.java # NEW
│ │ │ ├── RefreshToken.java # NEW
│ │ │ └── (others giữ M04)
│ │ ├── api/
│ │ │ ├── ProjectController.java # add @PreAuthorize
│ │ │ ├── TaskController.java
│ │ │ ├── UserController.java # NEW
│ │ │ ├── AdminController.java # NEW
│ │ │ └── GlobalExceptionHandler.java # add 401/403
│ │ ├── service/
│ │ │ ├── ProjectService.java # ownership check
│ │ │ └── ... (refactor for security)
│ │ ├── repository/
│ │ │ ├── ProjectRepository.java # add findByIdAndOwnerEmail
│ │ │ ├── RefreshTokenRepository.java # NEW
│ │ │ └── ...
│ │ └── exception/
│ │ └── (giữ TaskFlow v2 + add InvalidTokenException)
│ └── resources/
│ ├── application.yml
│ └── db/migration/
│ ├── V1-V3 (giữ TaskFlow v2)
│ ├── V4__add_user_password_roles.sql # NEW
│ ├── V5__add_refresh_tokens.sql # NEW
│ └── dev/V100__seed_users_admin.sql # NEW
└── test/
└── java/com/olhub/taskflow/
├── auth/AuthControllerIT.java
├── api/ProjectControllerSecurityTest.java
└── ...
Dành 45-60 phút build. Hint chi tiết dưới.
💡 Hint — code chính
pom.xml — add dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
Migration V4 — add password + roles:
-- V4__add_user_password_roles.sql
ALTER TABLE users ADD COLUMN password VARCHAR(60) NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN active BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE users ADD COLUMN locked BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id)
);
INSERT INTO roles (name) VALUES ('USER'), ('MANAGER'), ('ADMIN');
Migration V5 — refresh tokens:
-- V5__add_refresh_tokens.sql
CREATE TABLE refresh_tokens (
id VARCHAR(36) PRIMARY KEY, -- JTI (UUID)
username VARCHAR(100) NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_username ON refresh_tokens(username);
CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens(expires_at);
Dev seed admin user:
-- db/migration/dev/V100__seed_users_admin.sql
-- Password: admin123 (BCrypt strength 12)
INSERT INTO users (email, name, password, active)
VALUES (
'[email protected]',
'Admin',
'$2a$12$EhSpMo65qH3R2v7nFuT9uesgpEv0bA5DVUKa8dCb3l4G4EkD9Xpwu',
TRUE
);
-- Assign ADMIN role
INSERT INTO user_roles (user_id, role_id)
SELECT u.id, r.id FROM users u, roles r WHERE u.email = '[email protected]' AND r.name = 'ADMIN';
AppUser.java (entity, extend TaskFlow v2):
@Entity
@Table(name = "users")
@Getter @Setter
public class AppUser extends AuditableEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private boolean active = true;
@Column(nullable = false)
private boolean locked = false;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id"))
private Set<Role> roles = new HashSet<>();
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof AppUser u)) return false;
return id != null && id.equals(u.id);
}
@Override
public int hashCode() { return getClass().hashCode(); }
}
@Entity
@Table(name = "roles")
@Getter @Setter
public class Role {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 50)
private String name; // USER, MANAGER, ADMIN
}
AppUserDetailsService.java:
@Service
@RequiredArgsConstructor
public class AppUserDetailsService implements UserDetailsService {
private final UserRepository userRepo;
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
AppUser user = userRepo.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
return User.builder()
.username(user.getEmail())
.password(user.getPassword())
.authorities(user.getRoles().stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r.getName()))
.toList())
.disabled(!user.isActive())
.accountLocked(user.isLocked())
.build();
}
}
SecurityConfig.java:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/actuator/**").hasRole("ADMIN")
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))
)
.exceptionHandling(eh -> eh
.authenticationEntryPoint(restAuthEntryPoint())
.accessDeniedHandler(restAccessDeniedHandler())
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
@Bean
public AuthenticationManager authManager(AppUserDetailsService uds, PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(uds);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthoritiesClaimName("roles");
authoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
converter.setPrincipalClaimName("sub");
return converter;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://olhub.org", "http://localhost:3000"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setExposedHeaders(List.of("X-Total-Count", "Location"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
@Bean
public AuthenticationEntryPoint restAuthEntryPoint() {
return (req, res, ex) -> {
res.setStatus(HttpStatus.UNAUTHORIZED.value());
res.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
Map<String, Object> body = Map.of(
"type", "https://api.olhub.org/errors/unauthorized",
"title", "Unauthorized",
"status", 401,
"detail", "Authentication required",
"instance", req.getRequestURI()
);
new ObjectMapper().writeValue(res.getOutputStream(), body);
};
}
@Bean
public AccessDeniedHandler restAccessDeniedHandler() {
return (req, res, ex) -> {
res.setStatus(HttpStatus.FORBIDDEN.value());
res.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
Map<String, Object> body = Map.of(
"type", "https://api.olhub.org/errors/forbidden",
"title", "Forbidden",
"status", 403,
"detail", "Insufficient permission",
"instance", req.getRequestURI()
);
new ObjectMapper().writeValue(res.getOutputStream(), body);
};
}
}
JwtConfig.java:
@Configuration
@RequiredArgsConstructor
public class JwtConfig {
@Value("${app.jwt.secret}")
private String secret;
@Bean
public JwtEncoder jwtEncoder() {
SecretKey key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
JWKSource<SecurityContext> source = (sel, ctx) ->
sel.select(new JWKSet(new OctetSequenceKey.Builder(key).build()));
return new NimbusJwtEncoder(source);
}
@Bean
public JwtDecoder jwtDecoder() {
SecretKey key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
return NimbusJwtDecoder.withSecretKey(key)
.macAlgorithm(MacAlgorithm.HS256)
.build();
}
}
JwtIssuer.java:
@Service
@RequiredArgsConstructor
public class JwtIssuer {
private final JwtEncoder encoder;
public String issueAccessToken(UserDetails user) {
Instant now = Instant.now();
List<String> roles = user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(a -> a.replaceFirst("^ROLE_", ""))
.toList();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("olhub.org")
.subject(user.getUsername())
.audience(List.of("olhub-api"))
.issuedAt(now)
.expiresAt(now.plus(Duration.ofMinutes(15)))
.id(UUID.randomUUID().toString())
.claim("roles", roles)
.build();
return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
public String issueRefreshToken(UserDetails user) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("olhub.org")
.subject(user.getUsername())
.issuedAt(now)
.expiresAt(now.plus(Duration.ofDays(30)))
.id(UUID.randomUUID().toString())
.claim("type", "refresh")
.build();
return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
AuthController.java:
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authManager;
private final AppUserDetailsService uds;
private final UserRepository userRepo;
private final RoleRepository roleRepo;
private final PasswordEncoder encoder;
private final JwtIssuer jwtIssuer;
private final RefreshTokenService refreshTokenService;
private final JwtDecoder jwtDecoder;
@PostMapping("/register")
public ResponseEntity<UserDto> register(@Valid @RequestBody RegisterRequest req) {
if (userRepo.existsByEmail(req.email())) {
throw new DuplicateException("Email already registered");
}
Role userRole = roleRepo.findByName("USER").orElseThrow();
AppUser user = new AppUser();
user.setEmail(req.email());
user.setName(req.name());
user.setPassword(encoder.encode(req.password()));
user.setRoles(Set.of(userRole));
user.setActive(true);
AppUser saved = userRepo.save(user);
return ResponseEntity.created(URI.create("/api/v1/users/" + saved.getId()))
.body(UserDto.from(saved));
}
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody LoginRequest req) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(req.email(), req.password())
);
UserDetails user = (UserDetails) auth.getPrincipal();
String accessToken = jwtIssuer.issueAccessToken(user);
String refreshToken = jwtIssuer.issueRefreshToken(user);
// Store refresh token in DB
Jwt refreshJwt = jwtDecoder.decode(refreshToken);
refreshTokenService.issue(user.getUsername(), refreshJwt.getId(), refreshJwt.getExpiresAt());
return new TokenResponse(accessToken, refreshToken, "Bearer", 900L); // 15 min
}
@PostMapping("/refresh")
public TokenResponse refresh(@Valid @RequestBody RefreshRequest req) {
Jwt jwt;
try {
jwt = jwtDecoder.decode(req.refreshToken());
} catch (JwtException e) {
throw new InvalidTokenException("Invalid refresh token");
}
if (!"refresh".equals(jwt.getClaim("type"))) {
throw new InvalidTokenException("Not a refresh token");
}
if (!refreshTokenService.isValid(jwt.getId())) {
throw new InvalidTokenException("Refresh token revoked or expired");
}
String username = jwt.getSubject();
UserDetails user = uds.loadUserByUsername(username);
// Rotate: revoke old, issue new
refreshTokenService.revoke(jwt.getId());
String newAccessToken = jwtIssuer.issueAccessToken(user);
String newRefreshToken = jwtIssuer.issueRefreshToken(user);
Jwt newRefreshJwt = jwtDecoder.decode(newRefreshToken);
refreshTokenService.issue(username, newRefreshJwt.getId(), newRefreshJwt.getExpiresAt());
return new TokenResponse(newAccessToken, newRefreshToken, "Bearer", 900L);
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(@Valid @RequestBody RefreshRequest req) {
try {
Jwt jwt = jwtDecoder.decode(req.refreshToken());
refreshTokenService.revoke(jwt.getId());
} catch (JwtException e) {
// Token invalid — accept logout silently (idempotent)
}
return ResponseEntity.noContent().build();
}
}
public record RegisterRequest(@NotBlank @Email String email, @NotBlank String name, @NotBlank @Size(min = 8) String password) {}
public record LoginRequest(@NotBlank @Email String email, @NotBlank String password) {}
public record RefreshRequest(@NotBlank String refreshToken) {}
public record TokenResponse(String accessToken, String refreshToken, String tokenType, Long expiresIn) {}
ProjectService.java (refactored với security):
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProjectService {
private final ProjectRepository projectRepo;
private final UserRepository userRepo;
@Transactional
public Project create(CreateProjectRequest req, String ownerEmail) {
if (projectRepo.existsByName(req.name())) {
throw new DuplicateException("Project name '" + req.name() + "' exists");
}
AppUser owner = userRepo.findByEmail(ownerEmail).orElseThrow();
Project project = new Project(req.name(), req.description(), req.status(), owner);
return projectRepo.save(project);
}
public Project findByIdForUser(Long id, String userEmail, boolean isAdmin) {
if (isAdmin) {
return projectRepo.findById(id)
.orElseThrow(() -> new ProjectNotFoundException(id));
}
return projectRepo.findByIdAndOwnerEmail(id, userEmail)
.orElseThrow(() -> new ProjectNotFoundException(id)); // 404 hide existence
}
public Page<ProjectSummary> listOwnedByUser(String userEmail, Pageable pageable) {
return projectRepo.findSummariesByOwnerEmail(userEmail, pageable);
}
@Transactional
public void delete(Long id) {
Project project = projectRepo.findById(id)
.orElseThrow(() -> new ProjectNotFoundException(id));
projectRepo.delete(project);
}
}
ProjectController.java:
@RestController
@RequestMapping("/api/v1/projects")
@RequiredArgsConstructor
@Validated
public class ProjectController {
private final ProjectService service;
@GetMapping
@PreAuthorize("authenticated")
public Page<ProjectSummary> list(
@AuthenticationPrincipal Jwt jwt,
@PageableDefault(size = 20) Pageable pageable
) {
return service.listOwnedByUser(jwt.getSubject(), pageable);
}
@PostMapping
@PreAuthorize("hasRole('USER')")
public ResponseEntity<ProjectDto> create(
@Valid @RequestBody CreateProjectRequest req,
@AuthenticationPrincipal Jwt jwt
) {
Project created = service.create(req, jwt.getSubject());
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.getId()).toUri();
return ResponseEntity.created(location).body(ProjectDto.from(created));
}
@GetMapping("/{id}")
@PreAuthorize("authenticated")
public ProjectDto get(
@PathVariable Long id,
@AuthenticationPrincipal Jwt jwt,
Authentication auth
) {
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
Project project = service.findByIdForUser(id, jwt.getSubject(), isAdmin);
return ProjectDto.from(project);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
service.delete(id);
}
}
application.yml add:
app:
jwt:
secret: ${JWT_SECRET:dev-secret-must-be-at-least-32-chars-long-for-hmac-sha256}
spring:
security:
user:
# Disable default Boot user (we use DB users)
name: disabled
password: disabled
Production env var (K8s Secret):
env:
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: jwt-secret
key: value
✅ Test workflow
# Start app
docker compose up -d
mvn spring-boot:run
# 1. Register
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","name":"Alice","password":"secure123"}'
# 201
# 2. Login
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"secure123"}'
# 200, returns:
# { "accessToken":"eyJ...", "refreshToken":"eyJ...", "tokenType":"Bearer", "expiresIn":900 }
# 3. Use API with token
ACCESS_TOKEN="eyJ..."
curl http://localhost:8080/api/v1/projects -H "Authorization: Bearer $ACCESS_TOKEN"
# 200, list projects (empty initially)
# 4. Create project
curl -X POST http://localhost:8080/api/v1/projects \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Mobile App"}'
# 201
# 5. Try admin endpoint as USER
curl -X DELETE http://localhost:8080/api/v1/projects/1 \
-H "Authorization: Bearer $ACCESS_TOKEN"
# 403 Forbidden — only ADMIN can delete
# 6. Login as admin
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"admin123"}'
# Get admin token
ADMIN_TOKEN="eyJ..."
curl -X DELETE http://localhost:8080/api/v1/projects/1 \
-H "Authorization: Bearer $ADMIN_TOKEN"
# 204 No Content — admin can delete
# 7. Refresh token
curl -X POST http://localhost:8080/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"eyJ..."}'
# 200, new tokens
# 8. Logout
curl -X POST http://localhost:8080/api/auth/logout \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"refreshToken":"eyJ..."}'
# 204
Decode JWT trên jwt.io verify claims:
{
"iss": "olhub.org",
"sub": "[email protected]",
"aud": ["olhub-api"],
"iat": 1713788800,
"exp": 1713789700,
"jti": "a1b2c3...",
"roles": ["USER"]
}
Integration test với @WithMockUser:
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
@ActiveProfiles("test")
class ProjectControllerSecurityTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired MockMvc mockMvc;
@Test
void list_unauthenticated_returns401() throws Exception {
mockMvc.perform(get("/api/v1/projects"))
.andExpect(status().isUnauthorized());
}
@Test
void list_withUserRole_returns200() throws Exception {
mockMvc.perform(get("/api/v1/projects")
.with(jwt().jwt(j -> j.subject("[email protected]").claim("roles", List.of("USER")))
.authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isOk());
}
@Test
void delete_withUserRole_returns403() throws Exception {
mockMvc.perform(delete("/api/v1/projects/1")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isForbidden());
}
@Test
void delete_withAdminRole_returns204() throws Exception {
mockMvc.perform(delete("/api/v1/projects/1")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isNoContent());
}
}
🎓 Extension levels
Level 1 — OAuth2 social login: Google/GitHub login qua Spring Security OAuth2 client.
Level 2 — Two-factor authentication: TOTP (Google Authenticator) sau password login.
Level 3 — Account lockout: brute-force protection với Redis counter (gợi ý từ bài UserDetailsService & BCrypt).
Level 4 — Audit log: log mọi auth event (login, logout, failed attempt) → audit_log table.
Level 5 — RBAC mở rộng: permission-based authz thay role-based. User → Roles → Permissions → fine-grained authority.
Tự kiểm tra — các quyết định bảo mật
Q1Refresh endpoint revoke token cũ ngay khi cấp cặp mới, thay vì để nó tự hết hạn sau 30 ngày. Quyết định này phòng kịch bản tấn công nào, và tín hiệu gì xuất hiện khi token cũ bị dùng lần thứ hai?▸
Kịch bản phòng: attacker đánh cắp refresh token (XSS, log leak) và dùng song song với user thật. Không revoke, cả hai bên cùng exchange một token để mint access token mới liên tục — server không phân biệt được ai hợp lệ trong suốt 30 ngày.
Với rotation, người exchange đầu tiên làm token cũ bị đánh dấu revoked = true trong DB. Người thứ hai (dù là user thật hay attacker) gửi token cũ sẽ nhận 401.
Tín hiệu phát hiện: một token đã revoke mà vẫn bị gửi lên nghĩa là có hai bên cùng cầm token — chắc chắn đã leak. Phản ứng đúng là revokeAllForUser(): khoá toàn bộ session của user và bắt đăng nhập lại, theo khuyến nghị RFC 6749 §6 và OAuth 2.0 Security BCP.
Q2findByIdForUser trả 404 (không phải 403) khi user xem project của người khác. Vì sao chọn 404, và trade-off của lựa chọn này là gì?▸
findByIdForUser trả 404 (không phải 403) khi user xem project của người khác. Vì sao chọn 404, và trade-off của lựa chọn này là gì?403 nói với caller: "resource này tồn tại, nhưng bạn không có quyền". Đó là một bit thông tin miễn phí cho attacker — họ có thể enumerate ID (/api/v1/projects/1..1000) để map ra toàn bộ resource đang tồn tại trong hệ thống, dù không đọc được nội dung.
404 che giấu sự tồn tại: project của người khác và project không tồn tại trông giống hệt nhau. Query findByIdAndOwnerEmail làm điều này tự nhiên — không match thì coi như không có.
Trade-off: debug khó hơn (user "mất quyền" và user "gõ sai ID" nhận cùng response), và frontend không phân biệt được hai trường hợp để hiển thị thông điệp khác nhau. Với resource nhạy cảm, ẩn tồn tại đáng giá hơn; với resource công khai một phần, 403 có thể hợp lý hơn.
Q3TaskFlow v3 disable CSRF nhưng config CORS với origin cụ thể. Giải thích vì sao cặp quyết định này đúng cho thiết kế hiện tại — và thay đổi nào trong thiết kế sẽ khiến nó trở thành lỗ hổng?▸
CSRF disable đúng vì credential là Authorization: Bearer ... header — browser không bao giờ tự đính kèm header này cross-site, nên không có bề mặt tấn công CSRF. CORS origin cụ thể vẫn cần vì SPA chạy ở origin khác API — browser cần server opt-in để JS đọc được response.
Quyết định này sụp đổ nếu chuyển JWT sang HttpOnly cookie (để tránh XSS đọc localStorage): cookie auto-attach cross-site, CSRF surface xuất hiện trở lại — phải bật CookieCsrfTokenRepository. Tương tự, đổi allowedOrigins thành wildcard cộng allowCredentials(true) bị browser reject theo Fetch Standard, và nếu được phép sẽ là universal account takeover.
Bài học: cấu hình security không đúng/sai tuyệt đối — nó đúng với một thiết kế credential cụ thể. Đổi cách lưu credential thì phải xét lại cả CSRF lẫn CORS.
Q4TaskFlow v3 có cả URL rule (requestMatchers), method security (@PreAuthorize), lẫn query filter ownership. Vì sao cần cả ba tầng thay vì chọn một?▸
requestMatchers), method security (@PreAuthorize), lẫn query filter ownership. Vì sao cần cả ba tầng thay vì chọn một?Mỗi tầng bắt một loại lỗi mà tầng khác bỏ sót. URL rule fail-fast ở filter chain — chặn sớm, rẻ, nhưng chỉ nhìn thấy URL pattern, không biết logic nghiệp vụ. @PreAuthorize bảo vệ ở method — vẫn có hiệu lực khi service được gọi từ entry point khác (scheduled job, message listener) nơi URL rule không tồn tại.
Query filter ownership là tầng dữ liệu: hai tầng trên chỉ trả lời "user có được gọi endpoint này không", còn "user được thấy row nào" phải nằm trong SQL — vừa đúng về quyền vừa hiệu quả (DB dùng index, không load thừa như @PostFilter).
Đây là defense in depth: một tầng bị cấu hình sai (ví dụ thêm endpoint mới quên khai URL rule) thì tầng sau vẫn chặn. Bỏ một tầng "cho gọn" là đánh đổi an toàn lấy vài dòng code.
TaskFlow v3 đã production-ready. Module Testing thêm test pyramid full với Testcontainers + flaky test hardening — finalize confidence cho production deploy.
Chúc mừng — bạn đã hoàn thành phần Security của course! TaskFlow đã có authentication + authorization production-grade. Module Testing (Testing strategy) khép lại course với test pyramid: unit → slice → integration → e2e với MockMvc + AssertJ DSL Boot 3.4 + Testcontainers @ServiceConnection.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên