Spring Security & Testing/Mini-challenge: TaskFlow v3 — JWT + role-based access control
18/20
Bài 18 / 20~45 phútAuthorization & Web SecurityMiễn phí lượt xem

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 revoked trong 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/health public, 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

  1. Spring Security 6 + JWT (HS256, in-app issuer + validator).
  2. BCrypt password encoding (strength 12).
  3. Database User + Role (TaskFlow v2 entity + migration).
  4. 3 roles: USER (default), MANAGER (manage projects), ADMIN (full access).
  5. JWT contains: subject (email), roles, exp, iat, jti.
  6. 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).
  7. @PreAuthorize cho method-level security (ADMIN only methods).
  8. Ownership check: query filter at DB hoặc custom security bean.
  9. CORS allow frontend origin. CSRF disable (stateless API).
  10. Actuator secure: /actuator/health public, others ROLE_ADMIN.
  11. Custom exception handler: 401/403 trả Problem Details.
  12. Integration test với @WithMockUser cho 5+ scenario.

🔍 Architecture

Một DELETE /api/v1/projects/42 kèm Bearer token đi dọc qua bốn cửa, mỗi cửa hỏng ra một mã khác nhau: CorsFilter kiểm origin và sai thì browser chặn còn server không ghi lỗi nào, BearerTokenAuthenticationFilter kiểm chữ ký và exp nên token hỏng trả 401, AuthorizationFilter áp URL rule hasRole ADMIN nên USER nhận 403 ngay tại filter, @PreAuthorize trên service method là chốt 403 lần hai bắt cả caller không đi qua HTTP, và cuối cùng repository findByIdAndOwnerEmail trả 404 chứ không 403 cho project của người khác trước khi kết thúc bằng 204 No Content

3 security boundary:

  1. Filter chain — CORS, JWT validate.
  2. URL rulesrequestMatchers().hasRole(...).
  3. Method security@PreAuthorize cho fine-grained authz.

Điều cần chú ý khi build: mỗi cửa trả một mã khác nhau, và cửa cuối cố tình trả 404 chứ không 403 — một cái 403 là lời xác nhận rằng project đó tồn tại, đủ để dò id của người khác. Checklist ở trên yêu cầu đúng điều này.

📦 Concept dùng trong bài

ConceptBài nguồn
BCrypt password encodingUserDetailsService & BCrypt
UserDetailsService DBUserDetailsService & BCrypt
SecurityFilterChain DSLSecurityFilterChain DSL
JWT issue + validateJWT structure & validation
Refresh token DB storeJWT issue, refresh & revoke
@PreAuthorize SpEL@PreAuthorize & AOP proxy
Ownership check query filterMethod security filtering
CORS configCORS
Custom auth entry pointSecurityFilterChain DSL
Test với @WithMockUsermodule 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

💡 Hint per layer

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

✅ Test full pipeline
# 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

Tự kiểm tra
0/4 câu đã trả lời
  1. Q1
    Refresh 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?
  2. Q2
    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ì?
  3. Q3
    TaskFlow 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?
  4. Q4
    TaskFlow 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?

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 đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

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

Đặt 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

Bài tiếp theo

Authorization & Web Security — tổng kết