Spring Boot/Mini-challenge: TaskFlow v3 — JWT + role-based access control
~45 phútSpring Security cơ bảnMiễn phí

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

  1. Spring Security 6 + JWT (HS256, in-app issuer + validator).
  2. BCrypt password encoding (strength 12).
  3. Database User + Role (Module 04 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 (DB-stored, revokable).
  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

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:#d1fae5

3 security boundary:

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

📦 Concept dùng trong bài

ConceptModule
BCrypt password encodingM05 bài 03
UserDetailsService DBM05 bài 03
SecurityFilterChain DSLM05 bài 02
JWT issue + validateM05 bài 04
Refresh token DB storeM05 bài 04
@PreAuthorize SpELM05 bài 05
Ownership check query filterM05 bài 05
CORS configM05 bài 06
Custom auth entry pointM05 bài 02
Test với @WithMockUserM05 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

💡 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 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

✅ 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 (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...