Data API 5 — Xác thực, phân quyền & bảo mật

13 thg 7, 2026 3 lượt xem
#security
#authentication
#python
#fastapi
#jwt
#oauth2

Ba bài trước đã dựng được một service FastAPI biết validate dữ liệunối database. Nhưng một API tra cứu dữ liệu khách hàng ngân hàng mà ai gọi cũng trả kết quả thì không khác gì rò rỉ dữ liệu qua HTTP. Bài này bàn về hai câu hỏi cốt lõi: bạn là aibạn được làm gì — rồi tới các lớp phòng thủ vận hành để API không trở thành cửa sau vào kho dữ liệu nhạy cảm.

1. Authentication vs Authorization

Hai khái niệm hay bị gộp làm một nhưng khác nhau về bản chất:

Authentication (xác thực)Authorization (phân quyền)
Câu hỏiBạn là ai?Bạn được làm gì?
Xảy ra khiĐầu request, xác minh danh tínhSau khi biết danh tính, trước khi truy cập tài nguyên
Sai thì trả401 Unauthorized403 Forbidden
Ví dụKiểm tra token hợp lệUser A không được xem tài khoản của khách hàng B

Một request có thể authenticated nhưng không authorized: nhân viên teller đăng nhập hợp lệ (401 pass) nhưng cố gọi endpoint xoá giao dịch của phòng khác (403). Trong FastAPI, mã trạng thái đúng rất quan trọng cho audit: 401 nghĩa là "chưa đăng nhập / token hỏng", 403 nghĩa là "đã biết bạn là ai nhưng không đủ quyền".

2. Các cơ chế xác thực

API key — cho nội bộ / service-to-service

API key là một chuỗi bí mật dài, ngẫu nhiên, gắn với một client (thường là một hệ thống khác chứ không phải người dùng cuối). Đơn giản, phù hợp cho gọi máy-với-máy trong mạng nội bộ.

# Minh hoạ — không chạy được
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import APIKeyHeader
import secrets

api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)

# Trong thực tế: nạp từ secret manager, lưu dạng đã băm
VALID_KEYS = {"batch-etl-service", "report-service"}


def require_api_key(key: str = Security(api_key_header)) -> str:
    # so sánh hằng-thời-gian để tránh timing attack
    for known in VALID_KEYS:
        if secrets.compare_digest(key or "", known):
            return known
    raise HTTPException(status.HTTP_401_UNAUTHORIZED, "API key không hợp lệ")

Điểm yếu: key thường sống lâu, không có cơ chế hết hạn tự nhiên, và nếu lộ thì lộ toàn quyền. Vì vậy chỉ dùng cho dịch vụ nội bộ, phải rotate định kỳ và không bao giờ nhúng vào URL (URL bị ghi log ở proxy, browser history).

HTTP Basic — hạn chế dùng

Basic Auth gửi Authorization: Basic base64(user:pass) trên mỗi request. Base64 không phải mã hoá — chỉ là encode, ai bắt được gói tin cũng đọc ra mật khẩu. Chỉ chấp nhận được khi có TLS và cho công cụ nội bộ tạm thời. Với API dữ liệu khách hàng, gần như luôn nên chọn OAuth2 + token thay vì gửi mật khẩu ở mọi request.

OAuth2 + JWT — bearer token

Đây là chuẩn thực chiến cho API hiện đại. Người dùng đăng nhập một lần, nhận về một token, rồi đính token đó vào header của các request sau: Authorization: Bearer <token>. Server không cần lưu session — token tự mang thông tin.

JWT (JSON Web Token) là định dạng token phổ biến, gồm ba phần nối bằng dấu chấm header.payload.signature, mỗi phần là Base64URL:

  • Header: thuật toán ký, ví dụ {"alg": "HS256", "typ": "JWT"}.
  • Payload (claims): dữ liệu như sub (chủ thể — user id), exp (thời điểm hết hạn), scope/role. Payload chỉ được encode chứ không mã hoá — đừng bao giờ nhét mật khẩu hay số thẻ vào đây.
  • Signature: chữ ký của header.payload bằng secret (HS256) hoặc private key (RS256). Chữ ký đảm bảo token không bị sửa; ai không có khoá thì không giả được.

Xác minh token = tính lại chữ ký và so khớp, đồng thời kiểm exp. Nếu payload bị đổi một ký tự, chữ ký lệch ngay.

Access token vs refresh token: access token sống ngắn (5–15 phút) để nếu lộ thì thiệt hại giới hạn; refresh token sống lâu hơn (giờ/ngày), chỉ dùng để đổi lấy access token mới mà không bắt đăng nhập lại. Refresh token nên lưu an toàn, có thể thu hồi (lưu jti vào blacklist/DB), còn access token thì stateless nên gần như không thu hồi được trước khi hết hạn — đó là lý do phải giữ nó ngắn.

# Minh hoạ — không chạy được
from datetime import datetime, timedelta, timezone
import jwt  # PyJWT

SECRET = "..."  # nạp từ env/secret manager, KHÔNG hardcode
ALGO = "HS256"


def create_access_token(sub: str, scopes: list[str]) -> str:
    payload = {
        "sub": sub,
        "scopes": scopes,
        "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
        "type": "access",
    }
    return jwt.encode(payload, SECRET, algorithm=ALGO)


def decode_token(token: str) -> dict:
    # jwt.decode tự kiểm chữ ký + exp, ném exception nếu sai
    return jwt.decode(token, SECRET, algorithms=[ALGO])

3. Luồng OAuth2 / JWT

4. Security utilities của FastAPI

FastAPI cung cấp sẵn các lớp security tích hợp với hệ thống Depends. OAuth2PasswordBearer khai báo rằng endpoint cần bearer token và tự sinh nút "Authorize" trong tài liệu OpenAPI.

# Minh hoạ — không chạy được
from fastapi import Depends, FastAPI, HTTPException, Security, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
import jwt

app = FastAPI()

# tokenUrl trỏ tới endpoint cấp token; scopes mô tả quyền
oauth2 = OAuth2PasswordBearer(
    tokenUrl="token",
    scopes={
        "customers:read": "Xem thông tin khách hàng",
        "transactions:read": "Xem giao dịch",
        "admin": "Toàn quyền",
    },
)


@app.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends()):
    user = authenticate(form.username, form.password)  # verify hash
    if not user:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Sai thông tin đăng nhập")
    return {
        "access_token": create_access_token(user.id, user.scopes),
        "token_type": "bearer",
    }


def current_user(token: str = Depends(oauth2)) -> dict:
    try:
        return jwt.decode(token, SECRET, algorithms=["HS256"])
    except jwt.PyJWTError:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token không hợp lệ")

Phân quyền theo scope

FastAPI có SecurityScopes để một dependency biết endpoint đang yêu cầu scope nào và kiểm tra token có chứa scope đó không.

# Minh hoạ — không chạy được
from fastapi.security import SecurityScopes


def require_scopes(
    scopes: SecurityScopes,
    token: str = Depends(oauth2),
) -> dict:
    payload = decode_token(token)  # 401 nếu hỏng
    granted = set(payload.get("scopes", []))
    for need in scopes.scopes:
        if need not in granted and "admin" not in granted:
            raise HTTPException(status.HTTP_403_FORBIDDEN, f"Thiếu quyền: {need}")
    return payload


@app.get("/customers/{cid}")
def get_customer(
    cid: int,
    user: dict = Security(require_scopes, scopes=["customers:read"]),
):
    return load_customer(cid)  # đã qua kiểm scope

Security(dep, scopes=[...]) là biến thể của Depends cho phép khai báo scope cần cho từng endpoint. Đây là nền để làm phân quyền chi tiết mà không nhét logic kiểm quyền vào thân hàm.

5. Băm mật khẩu

Nguyên tắc bất di bất dịch: không bao giờ lưu mật khẩu dạng plaintext, và cũng không dùng hash nhanh như MD5/SHA-256 (bị brute-force hàng tỷ lần/giây). Dùng thuật toán băm mật khẩu chuyên dụng, có "cost factor" làm chậm cố ý và tự sinh salt: bcrypt hoặc argon2 (argon2id là khuyến nghị hiện đại).

# Minh hoạ — không chạy được
from passlib.context import CryptContext

pwd = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")

hashed = pwd.hash("mat-khau-nguoi-dung")     # lưu chuỗi này vào DB
ok = pwd.verify("mat-khau-nguoi-dung", hashed)  # True/False

Salt được nhúng ngay trong chuỗi hash nên mỗi mật khẩu có salt riêng, chống rainbow table. Khi đổi cost factor, deprecated="auto" giúp phát hiện hash cũ để rehash khi người dùng đăng nhập lần sau.

6. RBAC ở tầng API

RBAC (Role-Based Access Control) gán quyền cho vai trò, gán vai trò cho người dùng — thay vì gán quyền trực tiếp cho từng người. Với ngân hàng, mô hình vai trò rất tự nhiên: teller, kiểm soát viên, cán bộ phân tích rủi ro, quản trị hệ thống.

Vai tròcustomers:readtransactions:readexportadmin
teller✓ (chi nhánh mình)
analyst
auditor
admin

Ở tầng API, role thường mã hoá thành scope trong token. Lưu ý phân biệt hai mức: kiểm quyền chức năng (được gọi endpoint này không) làm bằng scope; còn kiểm quyền dữ liệu theo hàng (teller chỉ xem khách của chi nhánh mình) phải áp thêm điều kiện lọc trong query — không thể dựa vào scope. Chủ đề này bài kiểm soát truy cập dữ liệu đào sâu ở tầng governance.

7. Bảo mật vận hành

Xác thực đúng vẫn chưa đủ nếu tầng vận hành hở. Các lớp bắt buộc:

  • HTTPS/TLS bắt buộc: mọi traffic phải mã hoá. Không TLS thì token và dữ liệu khách hàng bay dưới dạng rõ. Thường terminate TLS ở reverse proxy (nginx) hoặc load balancer, và ép redirect HTTP→HTTPS, dùng HSTS.
  • CORS đúng cách: chỉ whitelist origin cụ thể của frontend nội bộ, không dùng allow_origins=["*"] cho API có xác thực. Cấu hình qua CORSMiddleware.
  • Rate limiting / throttling: giới hạn số request/phút theo IP hoặc theo token để chống lạm dụng, brute-force mật khẩu và DoS. Ví dụ: 5 lần đăng nhập sai/phút thì khoá tạm; endpoint export giới hạn chặt hơn endpoint tra cứu.
  • Input validation chống injection: dùng Pydantic để ép kiểu/định dạng (bài validation), và luôn dùng truy vấn tham số hoá (parameterized query) chứ không nối chuỗi SQL — chống SQL injection.
  • Không lộ chi tiết lỗi / secret: production tắt trả stack trace, trả thông báo lỗi chung chung (500 Internal Server Error) và log chi tiết ở phía server. Thông báo đăng nhập sai nên mơ hồ ("sai thông tin đăng nhập") để không tiết lộ username có tồn tại hay không.
  • Security headers: Strict-Transport-Security, X-Content-Type-Options: nosniff, Content-Security-Policy, X-Frame-Options.
  • Audit log truy cập dữ liệu nhạy cảm: ghi lại ai (sub), lúc nào, gọi gì, trên hàng dữ liệu nào, kết quả (200/403). Đây là yêu cầu tuân thủ trong ngân hàng, không phải tuỳ chọn.
# Minh hoạ — không chạy được
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://portal.ncb.internal"],  # KHÔNG "*"
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["Authorization", "Content-Type"],
)

8. OWASP API Security Top 10 — điểm qua

OWASP duy trì danh sách rủi ro phổ biến nhất cho API (bản 2023). Vài mục sát với API dữ liệu:

Rủi roBiện pháp
API1Broken Object Level Authorization (BOLA) — đổi id trong URL để xem của người khácKiểm quyền theo từng đối tượng, lọc theo owner
API2Broken AuthenticationToken ngắn hạn, băm mật khẩu, chống brute-force
API3Broken Object Property Level Auth — trả/nhận thừa fieldresponse_model giới hạn field trả ra
API4Unrestricted Resource ConsumptionRate limiting, giới hạn page size
API5Broken Function Level AuthKiểm scope/role cho mọi endpoint quản trị
API8Security MisconfigurationCORS chặt, tắt debug, security headers

BOLA (API1) là lỗi số một và cực dễ mắc: endpoint GET /accounts/{id} kiểm token hợp lệ nhưng quên kiểm id có thuộc về người gọi không — thế là bất kỳ user nào cũng duyệt được mọi tài khoản chỉ bằng cách tăng id.

9. Quản lý secret

SECRET ký JWT, mật khẩu DB, API key của bên thứ ba — tất cả là secret. Nguyên tắc:

  • Không hardcode trong source, không commit vào git.
  • Nạp qua biến môi trường (dùng pydantic-settings để load), hoặc secret manager (HashiCorp Vault, cloud KMS) cho production.
  • Rotate định kỳ; nếu nghi lộ thì rotate ngay và vô hiệu token cũ.
  • Phân tách secret theo môi trường (dev/staging/prod dùng khoá khác nhau).

Use case thực tế

API /customers/{id}/transactions phục vụ cổng tra cứu nội bộ NCB, có ba nhóm dùng: teller, analyst, auditor. Sau một đợt rà soát bảo mật, đội quyết định:

  1. Thay API key dùng chung bằng OAuth2 + JWT theo từng nhân viên. Access token 15 phút, refresh token 8 giờ (đúng một ca làm việc). Trước đây một key dùng chung không truy được ai đã tra cứu; giờ mỗi request mang sub = mã nhân viên.
  2. Scope hoá quyền: teller nhận scope customers:read transactions:read, analyst thêm export, auditor chỉ đọc. Endpoint export gắn Security(..., scopes=["export"]) nên teller gọi bị 403.
  3. Vá BOLA: query lọc theo chi nhánh của người gọi. Teller chi nhánh Hà Nội gọi id của khách chi nhánh Đà Nẵng trả 403 thay vì dữ liệu.
  4. Rate limit + audit: mỗi token tối đa 120 request/phút; endpoint export 10/phút. Mỗi lần đọc dữ liệu khách ghi một dòng audit (sub, customer_id, ts, status) vào bảng log riêng.

Kết quả sau một tháng: audit log bắt được một tài khoản analyst gọi export 400 lần trong 3 phút — vượt ngưỡng, bị chặn tự động và cảnh báo; điều tra ra là script cào dữ liệu chạy sai. Nếu vẫn dùng API key chung, sự cố này sẽ vô hình.

Ghi nhớ

  • Authentication (401) = bạn là ai; Authorization (403) = bạn được làm gì. Trả đúng mã trạng thái.
  • OAuth2 + JWT bearer là chuẩn thực chiến: token header.payload.signature, payload chỉ encode chứ không mã hoá — không nhét bí mật vào.
  • Access token ngắn hạn (5–15 phút), refresh token dài hơn và thu hồi được.
  • FastAPI: OAuth2PasswordBearer + Depends/Security + SecurityScopes để xác thực và phân quyền theo scope.
  • Mật khẩu luôn băm bằng bcrypt/argon2 có salt, không plaintext, không MD5/SHA-256.
  • BOLA (OWASP API1) là lỗi phổ biến nhất: phải kiểm quyền theo từng đối tượng và lọc dữ liệu theo owner, không chỉ kiểm token.
  • Vận hành: TLS bắt buộc, CORS whitelist (không *), rate limiting, parameterized query, ẩn chi tiết lỗi, security headers, audit log dữ liệu nhạy cảm.
  • Secret nạp từ env/secret manager, không hardcode, rotate định kỳ.

Bài viết liên quan

Exception handling, context manager (with), đọc/ghi file, JSON/CSV và logging đúng cách.

13 thg 7, 2026 4

Định nghĩa hàm, tham số, *args/**kwargs, lambda, module/package, pip và virtualenv.

13 thg 7, 2026 4

Lớp, kế thừa, đa hình, dunder methods, dataclass, type hints và nguyên tắc viết code sạch.

13 thg 7, 2026 4

Kết nối CSDL với SQLAlchemy, gọi REST API với requests/httpx, và lập trình bất đồng bộ asyncio.

13 thg 7, 2026 4