Spring REST API & Data JPA/OpenAPI nâng cao — security scheme, GroupedOpenApi, ẩn endpoint, production deploy, API-first
19/46
Bài 19 / 46~14 phútError, Validation & API DocsMiễn phí lượt xem

OpenAPI nâng cao — security scheme, GroupedOpenApi, ẩn endpoint, production deploy, API-first

Bài này đào sâu OpenAPI/springdoc nâng cao: khai báo security scheme JWT/OAuth2 trong spec để Swagger UI có nút Authorize, tách doc thành nhiều nhóm với GroupedOpenApi, ẩn endpoint nội bộ khỏi spec, chiến lược deploy production (tắt hay bảo vệ?), và phân tích trade-off API-first vs code-first cho team song song.

TL;DR: Bốn kỹ thuật nâng cao của springdoc: (1) @SecurityScheme khai báo JWT/OAuth2 trong spec — Swagger UI hiển thị nút "Authorize" để inject token vào mọi request "Try it out"; (2) GroupedOpenApi tách doc thành nhiều nhóm độc lập (public/admin/internal), mỗi nhóm có spec riêng tại /v3/api-docs/{group}; (3) @Hidden hoặc springdoc.paths-to-exclude ẩn endpoint nội bộ khỏi spec public; (4) production nên disable hoặc auth-protect Swagger UI — không để public vì lộ toàn bộ API surface. Cuối bài: so sánh API-first (design spec trước → backend + frontend implement song song) vs code-first (springdoc default) — biết chọn đúng theo quy mô team.

Bài trước (OpenAPI & springdoc) đã bóc cơ chế auto-generate spec và các annotation customize cơ bản (@Operation, @Schema, @ApiResponses). Bài này đi tiếp bốn bài toán thực tế mà mọi production app đều gặp: bảo mật doc, chia nhóm doc, ẩn endpoint nội bộ, và triết lý thiết kế API-first.

1. Security scheme — inject token vào Swagger UI

Vì sao cần khai báo security scheme trong spec?

Khi app có JWT authentication, developer cần test endpoint protected từ Swagger UI. Không có security scheme, UI chỉ gửi request bare — server từ chối 401. Developer phải mở Postman, copy-paste header thủ công — mất thời gian, dễ sai.

Security scheme là phần khai báo trong OpenAPI spec mô tả cơ chế xác thực mà API yêu cầu: loại scheme (HTTP Bearer, OAuth2, API Key...), vị trí token (header, cookie, query), và format (JWT, opaque). Khi spec có phần này, Swagger UI render nút "Authorize" — user paste token một lần, mọi "Try it out" sau đó tự inject header Authorization: Bearer <token>.

JWT Bearer scheme

// Khai bao scheme o cap @Configuration — springdoc doc vao components.securitySchemes
@Configuration
@SecurityScheme(
    name = "bearerAuth",                     // ten de ref trong @SecurityRequirement
    type = SecuritySchemeType.HTTP,
    scheme = "bearer",
    bearerFormat = "JWT",                    // chi doc, khong validate
    in = SecuritySchemeIn.HEADER,
    description = "JWT token tu /api/auth/login"
)
public class OpenApiConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
            .info(new Info().title("TaskFlow API").version("v1.0"))
            // Ap dung global cho moi endpoint
            .addSecurityItem(new SecurityRequirement().addList("bearerAuth"));
    }
}

Khai báo trên thêm vào spec JSON tại components.securitySchemes:

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
security:
  - bearerAuth: []     # global requirement

Endpoint login không cần auth — override global bằng @SecurityRequirements rỗng:

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @SecurityRequirements   // empty — override global, khong can token
    @PostMapping("/login")
    public TokenResponse login(@Valid @RequestBody LoginRequest req) { ... }

    @SecurityRequirements   // tuong tu
    @PostMapping("/register")
    public UserDto register(@Valid @RequestBody RegisterRequest req) { ... }
}

// Protected controller — ke thua global hoac explicit
@SecurityRequirement(name = "bearerAuth")
@RestController
@RequestMapping("/api/projects")
public class ProjectController { ... }

Luồng sử dụng trong UI: mở /swagger-ui.html → bấm nút Authorize → dán JWT token vào modal → bấm Authorize. Từ đó mọi lần "Try it out" đều được UI tự chèn header Authorization: Bearer kèm token, không phải dán lại từng endpoint.

OAuth2 Authorization Code scheme

Khi app dùng OAuth2/OIDC (ví dụ tích hợp Google, Keycloak), khai báo scheme phức tạp hơn — spec mô tả đầy đủ các URL của auth server để UI thực hiện flow authorization code:

@SecurityScheme(
    name = "oauth2",
    type = SecuritySchemeType.OAUTH2,
    flows = @OAuthFlows(
        authorizationCode = @OAuthFlow(
            authorizationUrl = "https://auth.olhub.org/oauth2/authorize",
            tokenUrl = "https://auth.olhub.org/oauth2/token",
            scopes = {
                @OAuthScope(name = "read",  description = "Doc du lieu"),
                @OAuthScope(name = "write", description = "Tao va sua du lieu")
            }
        )
    )
)

UI redirect user sang auth server, nhận code callback, exchange lấy token — toàn bộ OAuth2 flow chạy trong trình duyệt qua Swagger UI.

Chọn scheme nào?

App internal dùng JWT Bearer: đơn giản, developer tự login lấy token rồi paste. App tích hợp identity provider bên ngoài (Google, Azure AD, Keycloak): dùng OAuth2 scheme để UI tự thực hiện redirect flow — không cần copy-paste token thủ công.

2. GroupedOpenApi — chia doc thành nhiều nhóm

Vì sao cần nhóm tách biệt?

App lớn có nhiều loại endpoint: public API cho khách hàng, admin API cho internal team, internal API cho service-to-service call. Nếu gộp hết vào một spec, developer public nhìn thấy toàn bộ admin endpoint — nguy hiểm về thông tin. Hơn nữa, spec khổng lồ (200+ endpoint) khó điều hướng.

GroupedOpenApi là bean springdoc tạo ra một spec độc lập cho một tập endpoint, filter theo path pattern hoặc package. Mỗi nhóm có URL spec riêng tại /v3/api-docs/{groupName}. Swagger UI hiển thị dropdown "Select Definition" — user chọn nhóm.

@Configuration
public class OpenApiGroupConfig {

    @Bean
    public GroupedOpenApi publicApi() {
        return GroupedOpenApi.builder()
            .group("public")
            .displayName("Public API v1")
            .pathsToMatch("/api/v1/**")
            .build();
    }

    @Bean
    public GroupedOpenApi adminApi() {
        return GroupedOpenApi.builder()
            .group("admin")
            .displayName("Admin API (Internal)")
            .pathsToMatch("/api/admin/**")
            .addOpenApiCustomizer(openApi ->
                openApi.info(new Info()
                    .title("TaskFlow Admin API")
                    .version("v1")
                    .description("INTERNAL USE ONLY")))
            .build();
    }

    @Bean
    public GroupedOpenApi internalApi() {
        return GroupedOpenApi.builder()
            .group("internal")
            .displayName("Service-to-Service")
            .pathsToMatch("/internal/**")
            .build();
    }
}

Kết quả — ba spec endpoint độc lập:

Path patternGroupURL spec
/api/v1/**publicGET /v3/api-docs/public
/api/admin/**adminGET /v3/api-docs/admin
/internal/**internalGET /v3/api-docs/internal

Swagger UI có dropdown "Select Definition" top-right — chọn nhóm nào, spec tương ứng nhóm đó được load. Mỗi nhóm có thể có security scheme, server, info title riêng qua addOpenApiCustomizer.

Bảo vệ selectively theo group — production best practice:

// Chỉ public spec không cần auth; admin + internal yêu cầu ROLE_ADMIN
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/v3/api-docs/public").permitAll()
        .requestMatchers("/v3/api-docs/admin", "/v3/api-docs/internal")
            .hasRole("ADMIN")
        .anyRequest().authenticated()
    );
    return http.build();
}

3. Ẩn endpoint khỏi spec — @Hidden và config exclude

Không phải mọi endpoint đều cần xuất hiện trong doc. Actuator endpoints, debug endpoints, migration scripts endpoint — tất cả nên ẩn khỏi spec trước khi deploy.

@Hidden — ẩn chọn lọc

// An toan bo mot endpoint
@Hidden
@GetMapping("/internal/health-check-verbose")
public Map<String, Object> verboseHealth() { ... }

// An toan bo mot controller
@Hidden
@RestController
@RequestMapping("/debug")
public class DebugController {
    // Khong co endpoint nao trong class nay xuat hien trong spec
}

@Hidden từ io.swagger.v3.oas.annotations — springdoc skip endpoint này khi build spec, dù controller vẫn hoạt động bình thường ở runtime.

Config exclude — ẩn theo pattern

Khi cần ẩn nhiều endpoint theo pattern, dùng config thay vì annotate từng method:

springdoc:
  paths-to-exclude: /internal/**, /debug/**, /actuator/**
  packages-to-exclude: com.olhub.internal, com.olhub.debug
  show-actuator: false   # Boot actuator endpoints luon an
Pitfall — @Hidden che giấu khỏi doc, không phải khỏi truy cập

@Hidden chỉ loại endpoint ra khỏi OpenAPI spec — endpoint vẫn hoạt động và ai biết URL đều gọi được. Để bảo vệ thật sự, cần Spring Security URL rule hoặc method security. Xem bài Spring Security khi học module security.

4. Production deploy — tắt hay bảo vệ Swagger UI?

Vì sao đây là quyết định bảo mật, không phải preference?

Swagger UI trên production public có nghĩa: bất kỳ ai trên internet cũng thấy toàn bộ API surface của hệ thống — mọi endpoint, parameter, schema, constraint validation. Đây là thông tin attacker dùng để:

  • Enumerate attack surface: biết chính xác endpoint nào tồn tại, method nào hợp lệ, format payload ra sao.
  • Leak validation rule: spec expose constraint (minLength=8, pattern=...) giúp attacker design payload bypass hoặc brute-force có hướng.
  • Try-it-out as probe tool: "Try it out" gửi request thật đến production server — attacker dùng UI như công cụ fuzzing miễn phí.

Ba chiến lược, mức bảo vệ tăng dần:

Option 1 — Disable hoàn toàn trong production:

# application-prod.yml
springdoc:
  api-docs:
    enabled: false
  swagger-ui:
    enabled: false

Doc public được generate từ snapshot staging, host trên CDN riêng (Redocly, Stoplight Elements, GitHub Pages). App server không expose spec endpoint nào.

Option 2 — Auth-protect (recommended khi cần internal access):

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth
        .requestMatchers(
            "/v3/api-docs/**",
            "/swagger-ui/**",
            "/swagger-ui.html")
        .hasRole("DEVELOPER")        // chỉ internal team
        .anyRequest().authenticated()
    );
    return http.build();
}

Internal developer login với account DEVELOPER role mới xem được doc. Không public.

Option 3 — Path obscurity (yếu nhất, không recommend):

springdoc:
  swagger-ui:
    path: /internal-docs-k9x2q   # URL kho doan
  api-docs:
    path: /internal-spec-k9x2q

Security through obscurity — không phải defense thật sự. Attacker scan phát hiện được.

Khuyến nghị cho TaskFlow capstone:

EnvironmentChiến lược
Local devEnabled, không auth
StagingEnabled, auth-protect (DEVELOPER role)
ProductionDisabled. CI/CD snapshot spec từ staging → host CDN

Snapshot spec trong CI/CD

# .github/workflows/deploy.yml (đoạn them vao sau build)
- name: Snapshot OpenAPI spec
  run: |
    # Chay app staging, lay spec
    curl -f http://staging.olhub.org/v3/api-docs > docs/api/openapi-v${{ env.VERSION }}.json
    git add docs/api/openapi-v${{ env.VERSION }}.json

Track diff giữa version bằng openapi-diff — phát hiện breaking change (xoá endpoint, đổi field bắt buộc).

5. API-first vs code-first — trade-off theo quy mô team

Hai workflow thiết kế API

Đây không phải lựa chọn đúng-sai — là lựa chọn phù hợp ngữ cảnh.

Code-first (springdoc default): developer code @RestController trước, springdoc auto-generate spec từ annotation. Spec là output của quá trình implement.

API-first: team design OpenAPI spec (openapi.yaml) trước — đây là contract. Tool sinh interface Java từ spec. Backend implement interface. Frontend dev song song với mock server đọc spec.

Vì sao API-first ra đời — bài toán parallel team

Code-first có vấn đề căn bản: frontend phải đợi backend code xong mới có spec để implement. Với team 2-3 người, đây chấp nhận được. Với team 10+ người gồm frontend và backend riêng, mất 1-2 tuần đợi backend scaffold là lãng phí.

API-first giải quyết bằng cách đảo thứ tự: spec ra đời trước, cả hai team dùng spec làm nguồn sự thật. Frontend dev với mock server (Prism chạy spec như real server), backend implement interface do generator sinh ra từ spec. Hai team song song từ ngày 1.

Hai lịch trình trên cùng một trục thời gian ngang: hàng trên code-first đi backend code controller rồi springdoc sinh spec rồi frontend mới bắt đầu sau khi chờ hết hai bước, hàng dưới API-first bắt đầu bằng cả nhóm chốt openapi.yaml rồi tách thành backend hiện thực interface và frontend dev trên mock server Prism chạy song song từ ngày một

Trục ngang là thời gian, nên chỗ khác nhau hiện ra rất rõ: cùng một tập công việc, chỉ khác chỗ đặt spec trong lịch.

So sánh trade-off

Tiêu chíCode-firstAPI-first
Khi có specSau khi code xongTrước khi code
Frontend đợi backendKhông — mock server
Contract driftKhông — code là source of truthCó — spec và code có thể lệch nếu không enforce
Setup1 dependencyopenapi-generator plugin + spec.yaml
Chất lượng specAuto, có thể không idiomaticViết tay — có thể rất tốt hoặc rất xấu
Refactor codeSpec tự updatePhải update spec → regenerate → fix backend
Lint specKhó (spec là output)Dễ — Spectral lint openapi.yaml CI
Phù hợpTeam 1-5 người, iteration nhanhTeam 5+ người, API public/external, contract critical

API-first với openapi-generator-maven-plugin

<!-- pom.xml -->
<plugin>
    <groupId>org.openapitools</groupId>
    <artifactId>openapi-generator-maven-plugin</artifactId>
    <version>7.10.0</version>
    <executions>
        <execution>
            <goals><goal>generate</goal></goals>
            <configuration>
                <inputSpec>${project.basedir}/src/main/resources/openapi.yaml</inputSpec>
                <generatorName>spring</generatorName>
                <apiPackage>com.olhub.generated.api</apiPackage>
                <modelPackage>com.olhub.generated.model</modelPackage>
                <configOptions>
                    <interfaceOnly>true</interfaceOnly>      <!-- chi sinh interface, khong impl -->
                    <useSpringBoot3>true</useSpringBoot3>
                    <useJakartaEe>true</useJakartaEe>
                </configOptions>
            </configuration>
        </execution>
    </executions>
</plugin>

Generator sinh interface ProjectsApi từ spec. Controller implement interface — nếu spec thay đổi, compile fail ngay:

// Generated tu spec — KHONG edit file nay
public interface ProjectsApi {
    ResponseEntity<ProjectDto> getProject(@PathVariable Long id);
    ResponseEntity<ProjectDto> createProject(@Valid @RequestBody ProjectRequest req);
    ResponseEntity<Void> deleteProject(@PathVariable Long id);
}

// Controller implement interface — code do team viet
@RestController
public class ProjectController implements ProjectsApi {

    @Override
    public ResponseEntity<ProjectDto> getProject(Long id) {
        return ResponseEntity.ok(projectService.findById(id));
    }

    // ... implement cac method khac
}
Khoá này dùng code-first

TaskFlow capstone (bài tiếp theo) dùng code-first — đơn giản, phù hợp học một mình. API-first sẽ xuất hiện ở course Spring Reactive & Microservices khi hai service cần contract chặt chẽ và team chia ra. Hiểu trade-off ngay từ đây để nhận ra lúc nào nên chuyển.

Pitfall thực tế

Nhầm 1 — @Hidden là security control:

// SAI: tuong rang @Hidden bao ve endpoint
@Hidden
@GetMapping("/admin/delete-all")
public void deleteAll() { ... }

@Hidden chỉ ẩn khỏi doc. Endpoint vẫn hoạt động — ai đoán được URL vẫn gọi được. Bảo vệ thật sự cần @PreAuthorize hoặc Spring Security URL rule.

✅ Dùng @Hidden để giữ doc gọn, dùng security annotation/config để bảo vệ thật sự.

Nhầm 2 — GroupedOpenApi không filter, spec vẫn expose hết:

// SAI: group khong co pathsToMatch -> include tat ca endpoint
@Bean
public GroupedOpenApi publicApi() {
    return GroupedOpenApi.builder()
        .group("public")
        .build();  // thieu pathsToMatch
}

✅ Luôn khai báo pathsToMatch hoặc packagesToScan rõ ràng cho mỗi group.

Nhầm 3 — Để security scheme global nhưng quên exempt public endpoint:

Khi dùng .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) global, endpoint login cũng yêu cầu token trong spec — Swagger UI lock nút "Try it out" cho /auth/login. Phải thêm @SecurityRequirements rỗng cho endpoint không cần auth.

Liên hệ các bài khác

  • OpenAPI & springdoc: bài trước — cơ chế auto-generate spec, annotation @Operation/@Schema/@ApiResponses. Bài này là layer nâng cao lên trên nền đó.
  • Mini-challenge TaskFlow v1: capstone module — áp dụng toàn bộ kiến thức module 03 bao gồm security scheme JWT và config production. Thực hành ngay sau bài này.
  • Exception handling và validation (các bài trước trong module 03): spec tự document ProblemDetail response từ @RestControllerAdvice — thấy rõ liên hệ khi setup @ApiResponses cho error codes.

Tóm tắt

  • Security scheme (@SecurityScheme + @SecurityRequirement) khai báo JWT/OAuth2 trong spec — Swagger UI hiển thị nút "Authorize", inject token tự động vào mọi "Try it out". Endpoint public override bằng @SecurityRequirements rỗng.
  • GroupedOpenApi tách spec thành nhiều nhóm (public/admin/internal) — mỗi nhóm có URL spec riêng /v3/api-docs/{group}, có thể auth-protect chọn lọc.
  • @Hidden ẩn endpoint khỏi spec (không phải khỏi truy cập). Config springdoc.paths-to-exclude ẩn theo pattern. @Hidden không phải security control.
  • Production: disable Swagger UI hoặc auth-protect. Public doc host trên CDN từ snapshot staging. Không expose spec endpoint public trên app server production.
  • Code-first (springdoc): spec là output của code — phù hợp team nhỏ, iteration nhanh. API-first (openapi-generator): spec là contract thiết kế trước — backend + frontend implement song song, phù hợp team lớn, API public.

Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Developer paste JWT vào Swagger UI và click "Authorize". Cơ chế nào trong spec khiến UI biết cần hiển thị ô nhập token, và token đó được gắn vào request ra sao?
  2. Q2
    Tại sao @Hidden không đủ để bảo vệ endpoint admin? Cần thêm gì để bảo vệ thật sự?
  3. Q3
    App có 150 endpoint: 80 public, 50 admin, 20 internal service-to-service. Giải thích cách setup GroupedOpenApi và chiến lược bảo vệ spec endpoint trong production.
  4. Q4
    Team gồm 3 backend và 3 frontend developer cần deliver TaskFlow v2 trong 2 tuần. Backend ước tính 1 tuần để scaffold API. Code-first hay API-first phù hợp hơn? Vì sao?
  5. Q5
    Production app của bạn vừa bị pentest phát hiện lỗ hổng: Swagger UI public accessible tại /swagger-ui.html, expose toàn bộ 120 endpoint. Liệt kê 3 rủi ro cụ thể và 2 cách khắc phục có thể apply ngay.

Bài tiếp theo: Mini-challenge — TaskFlow REST API v1

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

Mini-challenge: TaskFlow REST API v1 — capstone phần REST API