Ngày Tôi Nhận Ra Authentication Là Điểm Yếu Lớn Nhất
Sau khi server bị tấn công brute-force SSH lúc nửa đêm, tôi học được bài học phải ưu tiên bảo mật từ ngày đầu. Sự cố đó buộc tôi phải khóa chặt mọi điểm vào — SSH key, firewall, fail2ban. Nhưng khi bắt đầu xây dựng ứng dụng web và API, tôi phát hiện ra một lỗ hổng lớn hơn nhiều: tầng xác thực mới là nơi hầu hết các vụ tấn công thực sự xảy ra. Không phải SSH. Mà là OAuth.
Cấu hình sai OAuth 2.0 đứng sau một số lượng đáng kinh ngạc các vụ rò rỉ dữ liệu nghiêm trọng — không phải vì lập trình viên thiếu cẩn thận, mà vì giao thức này có hàng chục cài đặt tinh tế. Hầu hết các hướng dẫn chỉ cho bạn thấy “con đường hạnh phúc”. Bỏ qua một tùy chọn và mọi thứ trông ổn — cho đến khi pen tester hoặc kẻ tấn công tìm ra lỗ hổng.
Hướng dẫn này đề cập đến những gì thường đi sai, các khái niệm cốt lõi thực sự có nghĩa là gì, và cách triển khai chúng đúng cách.
OAuth 2.0 và OpenID Connect Thực Sự Là Gì
Trước khi có thể khắc phục vấn đề, bạn cần có mô hình tư duy rõ ràng về từng giao thức thực sự làm gì.
OAuth 2.0 — Ủy Quyền, Không Phải Xác Thực
OAuth 2.0 là một giao thức ủy quyền. Nó cho phép người dùng cấp cho ứng dụng bên thứ ba quyền truy cập giới hạn vào tài nguyên của họ — mà không cần chia sẻ mật khẩu. Hãy nghĩ về nó như chìa khóa dành cho bồi đậu xe: họ có thể đậu xe của bạn, nhưng không thể mở cốp xe hay vào ngăn đựng đồ.
Bốn vai trò chính:
- Resource Owner — người dùng sở hữu dữ liệu
- Client — ứng dụng yêu cầu quyền truy cập
- Authorization Server — phát hành token (ví dụ: Google, GitHub, Keycloak của bạn)
- Resource Server — API lưu trữ dữ liệu được bảo vệ
OpenID Connect — Xác Thực Xây Trên Nền OAuth 2.0
OpenID Connect (OIDC) bổ sung tầng nhận dạng lên trên OAuth 2.0. OAuth 2.0 trả lời câu hỏi “ứng dụng này có thể truy cập tài nguyên này không?” OIDC trả lời một câu hỏi khác: “người dùng này chính xác là ai?”
OIDC giới thiệu ID Token — một JWT được ký chứa các claims về người dùng đã xác thực: subject ID, email, tên, và thời điểm họ đăng nhập. Chữ ký là thứ làm cho các claims đó đáng tin cậy.
Đang xây dựng nút “Đăng nhập bằng Google”? Bạn đang dùng OIDC, dù bạn có biết hay không.
Các Lỗ Hổng Phổ Biến Nhất — và Tại Sao Chúng Xảy Ra
1. Thiếu hoặc Yếu Tham Số State
Tham số state tồn tại để ngăn chặn tấn công CSRF vào luồng ủy quyền. Không có nó, kẻ tấn công có thể lừa trình duyệt của người dùng hoàn thành một giao dịch OAuth do kẻ tấn công khởi tạo — và server của bạn không có cách nào phân biệt được.
Một request dễ bị tấn công trông như thế này:
# Dễ bị tấn công — không có tham số state
https://accounts.google.com/o/oauth2/auth?
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
response_type=code&
scope=openid email
Khắc phục bằng cách tạo giá trị ngẫu nhiên an toàn về mặt mật mã, gắn nó với session của người dùng, và xác minh khi nhận về:
import secrets
import hashlib
def generate_state():
# State ngẫu nhiên an toàn về mặt mật mã
state = secrets.token_urlsafe(32)
# Lưu vào session phía server trước khi redirect
session['oauth_state'] = state
return state
def verify_state(received_state):
expected_state = session.get('oauth_state')
if not expected_state or not secrets.compare_digest(received_state, expected_state):
raise ValueError("State không khớp — có thể là tấn công CSRF")
del session['oauth_state'] # Chỉ dùng một lần
2. Open Redirect Do redirect_uri Không Được Kiểm Tra
Authorization server của bạn phải thực thi danh sách cho phép chính xác các redirect URI. Chấp nhận khớp một phần hoặc ký tự đại diện và kẻ tấn công có thể chuyển hướng authorization code thẳng đến server do chúng kiểm soát.
# Phía server: xác thực redirect_uri một cách nghiêm ngặt
ALLOWED_REDIRECT_URIS = [
"https://yourapp.com/callback",
"https://yourapp.com/auth/callback",
]
def validate_redirect_uri(uri: str) -> bool:
return uri in ALLOWED_REDIRECT_URIS
# Đừng bao giờ dùng khớp một phần như:
# uri.startswith("https://yourapp.com") <-- dễ bị tấn công qua subdomain
3. Đánh Chặn Authorization Code — PKCE Giải Quyết Vấn Đề Này
Public client — ứng dụng trang đơn (SPA), ứng dụng di động — không thể lưu trữ client secret một cách an toàn. Nếu kẻ tấn công đánh chặn authorization code trước khi ứng dụng của bạn đổi nó, chúng có thể tự đổi lấy token.
PKCE (Proof Key for Code Exchange, đọc là “pixie”) lấp đầy lỗ hổng đó. Client tạo ra code_verifier ngẫu nhiên và hash nó thành code_challenge. Nó gửi challenge lên trước cùng với authorization request. Khi đổi code để lấy token, nó gửi verifier gốc. Chỉ client hợp lệ mới có thể cung cấp cả hai — một code bị đánh chặn một mình là vô dụng.
import secrets
import hashlib
import base64
def generate_pkce_pair():
# Bước 1: tạo code_verifier (43-128 ký tự, URL-safe)
code_verifier = secrets.token_urlsafe(64)
# Bước 2: tính code_challenge = BASE64URL(SHA256(code_verifier))
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
return code_verifier, code_challenge
# Dùng trong authorization request:
# &code_challenge=CODE_CHALLENGE
# &code_challenge_method=S256
# Khi đổi code lấy token, gửi:
# &code_verifier=CODE_VERIFIER
Đáng lưu ý: PKCE hiện được khuyến nghị cho tất cả OAuth client, không chỉ public client. Các confidential server-side client cũng được hưởng lợi — đây là lớp phòng thủ chuyên sâu ngay cả khi đã có client secret.
4. Xác Thực ID Token Không Đúng Cách
Đang dùng OIDC? Bạn phải xác thực ID Token trước khi tin tưởng bất kỳ claim nào bên trong nó. Bỏ qua điều này và bạn hoàn toàn dễ bị tấn công bằng token giả mạo hoặc token bị phát lại.
Đây là tất cả các trường bạn cần kiểm tra:
iss(issuer) — phải khớp chính xác với URL issuer dự kiến của provideraud(audience) — phải chứa client ID của bạnexp(expiration) — token không được hết hạniat(issued at) — không nên quá xa trong quá khứ; cũng tính đến độ lệch đồng hồ nhỏnonce— phải khớp với nonce bạn đã gửi trong authorization request (ngăn chặn tấn công replay)- Chữ ký — được xác minh bằng endpoint JWKS công khai của provider
from authlib.integrations.requests_client import OAuth2Session
from authlib.jose import jwt
import requests
def validate_id_token(id_token: str, nonce: str, client_id: str, issuer: str):
# Lấy public key của provider
jwks_uri = f"{issuer}/.well-known/openid-configuration"
oidc_config = requests.get(jwks_uri).json()
jwks = requests.get(oidc_config['jwks_uri']).json()
# Giải mã và xác minh
claims = jwt.decode(id_token, jwks)
claims.validate() # xác thực exp, nbf
# Kiểm tra claim thủ công
assert claims['iss'] == issuer, "Issuer không khớp"
assert client_id in claims['aud'], "Audience không khớp"
assert claims.get('nonce') == nonce, "Nonce không khớp — có thể là tấn công replay?"
return claims
Hãy dùng thư viện được bảo trì — authlib hoặc python-jose — thay vì tự viết code phân tích JWT. Các giao thức này có nhiều trường hợp đặc biệt dễ mắc sai lầm và gần như không thể phát hiện cho đến khi có sự cố trên môi trường production.
5. Lưu Trữ Token Không An Toàn Phía Client
Vị trí lưu trữ là một trong những quyết định bị đánh giá thấp nhất trong các ứng dụng chạy trên trình duyệt:
- localStorage / sessionStorage — có thể đọc được bởi bất kỳ JavaScript nào trên trang. Một lỗ hổng XSS và tất cả token đều biến mất.
- HttpOnly cookie — JavaScript không thể truy cập được. Đây là lựa chọn đúng đắn cho access token trong ứng dụng web.
Đang xử lý SPA? Hãy xem xét mô hình BFF (Backend For Frontend). Trình duyệt giao tiếp với backend của bạn, backend giữ token trong session phía server và proxy các API call. Token không bao giờ đến được trình duyệt.
Ghép Tất Cả Lại: Một Luồng OAuth 2.0 An Toàn
Đây là triển khai OAuth 2.0 + OIDC phía server hoàn chỉnh sử dụng Python, Flask, và authlib. State, PKCE và nonce đều được xử lý — không bỏ sót điều gì:
from flask import Flask, session, redirect, url_for, request
from authlib.integrations.flask_client import OAuth
import secrets
app = Flask(__name__)
app.secret_key = secrets.token_hex(32) # Secret key mạnh
oauth = OAuth(app)
google = oauth.register(
name='google',
client_id='YOUR_CLIENT_ID',
client_secret='YOUR_CLIENT_SECRET',
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email profile',
'code_challenge_method': 'S256', # Bật PKCE
}
)
@app.route('/login')
def login():
# Tạo nonce để bảo vệ ID token khỏi tấn công replay
nonce = secrets.token_urlsafe(16)
session['oauth_nonce'] = nonce
# authlib tự động xử lý state + PKCE
redirect_uri = url_for('callback', _external=True)
return google.authorize_redirect(redirect_uri, nonce=nonce)
@app.route('/callback')
def callback():
# authlib tự động xác minh state
token = google.authorize_access_token()
# Xác thực ID token với nonce
nonce = session.pop('oauth_nonce', None)
user_info = google.parse_id_token(token, nonce=nonce)
# Chỉ lưu những gì cần thiết vào session, không lưu token thô
session['user_id'] = user_info['sub']
session['email'] = user_info['email']
return redirect('/')
Thời Hạn Token và Chiến Lược Refresh
Giữ access token có thời hạn ngắn. Mười lăm phút đến một giờ là khoảng thời gian phổ biến — đủ dài để có ích, đủ ngắn để hạn chế thiệt hại nếu bị rò rỉ. Refresh token xử lý việc tái xác thực âm thầm ở chế độ nền. Lưu trữ chúng phía server và xoay vòng mỗi lần sử dụng:
def refresh_access_token(refresh_token: str):
response = requests.post(
'https://oauth2.googleapis.com/token',
data={
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
'client_id': CLIENT_ID,
'client_secret': CLIENT_SECRET,
}
)
data = response.json()
# Lưu access token mới và có thể cả refresh token mới
return data['access_token'], data.get('refresh_token', refresh_token)
Một Số Nguyên Tắc Cần Ghi Nhớ
- Đừng bao giờ đặt client secret trong code frontend — biến môi trường trên server, không có ngoại lệ
- Luôn dùng HTTPS — OAuth qua HTTP thuần làm lộ token trong quá trình truyền
- Tối thiểu hóa scope — chỉ yêu cầu những quyền bạn thực sự cần
- Kiểm tra scope token của bạn — access token bị đánh cắp với scope tối thiểu giới hạn mức độ thiệt hại
- Triển khai thu hồi token — khi đăng xuất, thu hồi tại authorization server; chỉ xóa session là chưa đủ
def revoke_token(token: str):
requests.post(
'https://oauth2.googleapis.com/revoke',
params={'token': token},
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
Triển Khai OAuth An Toàn Trông Như Thế Nào
Danh sách kiểm tra rất ngắn gọn: dùng PKCE, xác thực state và nonce, kiểm tra mọi claim trong ID token, lưu token trong HttpOnly cookie hoặc session phía server, giữ access token có thời hạn ngắn. Không có gì phức tạp cả. Nhưng tất cả đều quan trọng.
Những sai lầm trên không chỉ là lý thuyết. Chúng xuất hiện trong các ứng dụng production — kể cả những ứng dụng được xây dựng bởi các lập trình viên có kinh nghiệm, những người đã tin tưởng vào hướng dẫn “quick start” và không bao giờ đọc qua phần “con đường hạnh phúc”. Hiểu mô hình bảo mật là thứ phân biệt một triển khai thực sự an toàn với một triển khai trông có vẻ an toàn — cho đến khi không còn an toàn nữa.
Sự cố SSH lúc nửa đêm của tôi đã dạy tôi rằng các vấn đề bảo mật không gửi email cảnh báo. Với authentication, hãy làm đúng trước khi người khác thực hiện bài kiểm tra thay bạn.

