Mini-challenge: TaskFlow v3 — JWT + role-based access control
Migrate TaskFlow Module 04 (JPA Postgres) sang Module 05 — bảo vệ với Spring Security JWT, role-based authz (USER/MANAGER/ADMIN), ownership check, register/login/refresh/logout endpoints, integration test với @WithMockUser, secure Actuator.
Module 05 đã bóc 6 layer Spring Security. Bài cuối này build full pipeline auth + authz trên TaskFlow Module 04 — tạo TaskFlow v3 production-grade.
Sau bài này, TaskFlow là enterprise-ready REST API: JWT-based auth, RBAC (USER/MANAGER/ADMIN), ownership check, secure Actuator. Tier 2 sẵn sàng cho Module 06+ (testing, observability).
🎯 Đề bài
Migrate TaskFlow Module 04 sang Module 05 với security:
Domain mở rộng
User (Module 04 đã 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 Module 04)
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ừ Module 04 + 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 (Module 04 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 (DB-stored, revokable).
@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 | Module |
|---|---|
| BCrypt password encoding | M05 bài 03 |
| UserDetailsService DB | M05 bài 03 |
| SecurityFilterChain DSL | M05 bài 02 |
| JWT issue + validate | M05 bài 04 |
| Refresh token DB store | M05 bài 04 |
| @PreAuthorize SpEL | M05 bài 05 |
| Ownership check query filter | M05 bài 05 |
| CORS config | M05 bài 06 |
| Custom auth entry point | M05 bài 02 |
| Test với @WithMockUser | M05 bài 05 |
▶️ 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 Module 04
│ │ │ ├── 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ữ Module 04 + add InvalidTokenException)
│ └── resources/
│ ├── application.yml
│ └── db/migration/
│ ├── V1-V3 (giữ Module 04)
│ ├── 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 Module 04):
@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(), "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(), "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 (Module 05 bài 03 hint).
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.
✨ Điều bạn vừa làm được
Hoàn thành mini-challenge này, bạn đã:
- Production-grade auth pipeline: register → BCrypt hash → JWT issue → refresh rotation → logout revoke.
- 3 layer security: filter chain (CORS, JWT validate), URL rules (
requestMatchers), method security (@PreAuthorize). - Ownership check: query filter at DB cho efficient + secure (404 hide existence).
- CORS + CSRF correct: stateless API → CORS allow specific origin, CSRF disable.
- Custom error handling: 401/403 trả Problem Details JSON cho frontend SPA.
- Integration test với JWT mock: verify URL rules + method security.
- Secret management: JWT secret qua env var, not hardcoded.
TaskFlow v3 = Tier 2 production-ready. Module 06 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 Module 05! TaskFlow đã có authentication + authorization production-grade. Module 06 (Testing strategy) khép Tier 2 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?
Bình luận (0)
Đang tải...