Cognito và JWT Authorizer: Chỉ Người Đăng Nhập Mới Tạo Được Link
Tới cuối bài trước, mọi link đều tạo dưới danh nghĩa user-001, một chuỗi gán cứng trong code. Không có ai thật ở đó, và ai gọi API cũng tạo được link. Bài này thêm người dùng thật bằng Cognito, rồi dựng một cánh cửa: chỉ request mang token hợp lệ mới qua được route tạo link, còn route mở link vẫn để công khai vì cả thế giới cần mở được link ngắn.
Mục tiêu
Dựng một Cognito user pool phát JWT khi đăng nhập, gắn JWT authorizer của HTTP API để bảo vệ POST /links, giữ GET /{code} công khai, và cho create-link đọc danh tính người dùng từ claim trong token. Ta tạo user thật, lấy token thật, và gọi API ở cả hai trạng thái có và không có token. Cognito có bậc miễn phí rộng, chi phí phần này không đáng kể.
Cognito user pool là gì
Một user pool là một thư mục người dùng do AWS quản lý: nó lưu tài khoản, lo việc đăng ký, xác minh email, đăng nhập, đổi mật khẩu, và quan trọng nhất với ta, nó phát token khi đăng nhập thành công. Token đó là một JSON Web Token (JWT), một chuỗi ký số chứa các claim về người dùng (như sub, mã định danh duy nhất của user). Vì token được ký, bất kỳ ai có khóa công khai của pool đều kiểm được nó thật hay giả mà không cần hỏi lại Cognito.
Đây là chỗ khớp với HTTP API. Bài 03 đã chọn HTTP API một phần vì nó có JWT authorizer gốc. Giờ ta dùng đúng tính năng đó.
JWT authorizer kiểm gì
Khi một route được gắn JWT authorizer, API Gateway tự kiểm token trước khi gọi Lambda. Tài liệu mô tả các bước: lấy token từ header, giải mã, "Check the token's algorithm and signature by using the public key that is fetched from the issuer's jwks_uri", rồi validate các claim, gồm iss (issuer phải khớp pool đã cấu hình), aud (audience phải khớp app client id), và exp (chưa hết hạn). Nếu bất kỳ bước nào fail, "API Gateway denies the API request."
Điểm quan trọng cho code: sau khi token qua, "API Gateway passes the claims in the token to the API route's integration." Lambda đọc được các claim ở event.requestContext.authorizer.jwt.claims. Nghĩa là handler không phải tự giải mã hay kiểm token; tới lúc code chạy, danh tính đã được xác thực và nằm sẵn trong event.
1. Client đăng nhập Cognito ──▶ Cognito phát JWT (ID token, ký số)
│
2. Client gọi API kèm Authorization: Bearer <JWT>
header Authorization ──────────────┐
▼
3. API Gateway (JWT authorizer): kiểm chữ ký qua jwks_uri,
kiểm iss/aud/exp │
├── fail ─▶ 401, KHÔNG goi Lambda
└── pass ─▶ truyen claims vao event
▼
4. Lambda doc event.requestContext.authorizer.jwt.claims.sub
Dựng Cognito trong SAM
Hai tài nguyên: user pool, và một app client (đại diện ứng dụng đăng nhập vào pool):
UserPool:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName: url-shortener-users
UsernameAttributes: [email]
AutoVerifiedAttributes: [email]
Policies:
PasswordPolicy:
MinimumLength: 8
RequireUppercase: true
RequireLowercase: true
RequireNumbers: true
RequireSymbols: false
UserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
UserPoolId: !Ref UserPool
ClientName: url-shortener-client
GenerateSecret: false
ExplicitAuthFlows:
- ALLOW_USER_PASSWORD_AUTH
- ALLOW_ADMIN_USER_PASSWORD_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
UsernameAttributes: [email] cho người dùng đăng nhập bằng email. GenerateSecret: false tạo một public client không có client secret, hợp với ứng dụng chạy ở trình duyệt (nơi không giữ bí mật được). ExplicitAuthFlows bật các luồng đăng nhập ta sẽ dùng để lấy token.
Gắn authorizer, bảo vệ đúng route
Khai một authorizer trên HTTP API, trỏ vào issuer của pool và audience là app client:
HttpApi:
Type: AWS::Serverless::HttpApi
Properties:
Auth:
Authorizers:
CognitoAuthorizer:
IdentitySource: "$request.header.Authorization"
JwtConfiguration:
issuer: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}"
audience:
- !Ref UserPoolClient
Cố ý không đặt DefaultAuthorizer. Nếu đặt mặc định, mọi route đều bị bảo vệ, kể cả GET /{code}, và không ai mở được link ngắn. Thay vào đó chỉ gắn authorizer vào đúng route tạo link:
CreateLinkFunction:
...
Events:
Create:
Type: HttpApi
Properties:
Path: /links
Method: POST
Auth:
Authorizer: CognitoAuthorizer
ResolveLinkFunction không khai Auth, nên route GET /{code} công khai. Đây là quyết định thiết kế đúng cho một URL shortener: tạo link cần đăng nhập, mở link thì không.
Đọc danh tính từ claim
Handler tạo link giờ lấy ownerId từ claim sub thay vì hằng số:
export const handler = async (
event: APIGatewayProxyEventV2WithJWTAuthorizer
): Promise<APIGatewayProxyResultV2> => {
const OWNER = event.requestContext.authorizer?.jwt?.claims?.sub as string;
if (!OWNER) return json(401, { error: "thieu danh tinh nguoi dung" });
// ...phan con lai nhu bai 06, dung OWNER cho ownerId va GSI1PK...
};
sub là mã định danh ổn định của user trong pool, không đổi kể cả khi họ đổi email, nên dùng nó làm khóa chủ sở hữu là đúng.
Thử thật
Gọi POST /links khi chưa có token bị chặn ngay ở tầng gateway với 401, Lambda không hề được gọi:
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST "$API/links" \
-H 'content-type: application/json' -d '{"url":"https://aws.amazon.com/"}'
401
Tạo một user demo và đặt mật khẩu cố định để bỏ qua bước buộc đổi mật khẩu lần đầu:
aws cognito-idp admin-create-user --user-pool-id "$POOL" \
--username demo@example.com --message-action SUPPRESS
aws cognito-idp admin-set-user-password --user-pool-id "$POOL" \
--username demo@example.com --password 'Demo-Pw-12345' --permanent
Đăng nhập để lấy ID token:
IDTOKEN=$(aws cognito-idp admin-initiate-auth --user-pool-id "$POOL" --client-id "$CLIENT" \
--auth-flow ADMIN_USER_PASSWORD_AUTH \
--auth-parameters USERNAME=demo@example.com,PASSWORD=Demo-Pw-12345 \
--query 'AuthenticationResult.IdToken' --output text)
Token là một JWT; phần giữa (payload) giải base64 ra chứa các claim, trong đó có sub:
$ echo "$IDTOKEN" | cut -d. -f2 | base64 -d | sed 's/.*"sub":"//;s/".*//'
e9dae5dc-e001-7019-c242-53fd9434f6b3
Giờ gọi lại POST /links kèm token. Lần này qua:
$ curl -s -X POST "$API/links" -H "Authorization: Bearer $IDTOKEN" \
-H 'content-type: application/json' -d '{"url":"https://aws.amazon.com/cognito/"}'
{"code":"EFgrT5P","shortUrl":".../EFgrT5P","target":"https://aws.amazon.com/cognito/"}
Bằng chứng rằng handler thực sự đọc danh tính từ token: item vừa tạo có ownerId đúng bằng sub của user demo, và GSI1PK cũng dựng từ đó:
$ aws dynamodb get-item --table-name url-shortener \
--key '{"PK":{"S":"LINK#EFgrT5P"},"SK":{"S":"META"}}' \
--query 'Item.{ownerId:ownerId.S,GSI1PK:GSI1PK.S}'
{
"ownerId": "e9dae5dc-e001-7019-c242-53fd9434f6b3",
"GSI1PK": "USER#e9dae5dc-e001-7019-c242-53fd9434f6b3"
}
Và một token bịa bị từ chối vì chữ ký không khớp khóa công khai của pool:
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST "$API/links" \
-H "Authorization: Bearer not.a.realtoken" -H 'content-type: application/json' -d '{"url":"https://x.com/"}'
401
API Gateway làm toàn bộ việc kiểm token; handler chỉ thấy những request đã qua cửa, và thấy luôn danh tính người gọi.
🧹 Dọn dẹp
Xóa user và link demo, giữ lại user pool cho bài sau:
aws dynamodb delete-item --table-name url-shortener \
--key '{"PK":{"S":"LINK#EFgrT5P"},"SK":{"S":"META"}}'
aws cognito-idp admin-delete-user --user-pool-id "$POOL" --username demo@example.com
Tổng kết
URL shortener giờ có người dùng thật. Cognito phát JWT khi đăng nhập, JWT authorizer của HTTP API kiểm token ngay tại gateway và chỉ cho request hợp lệ chạm Lambda, và handler đọc danh tính từ claim sub thay vì gán cứng. Route tạo link được bảo vệ, route mở link vẫn công khai, đúng như một URL shortener cần.
Mỗi link giờ gắn với một chủ thật, nhưng ta chưa dùng điều đó để phân tách dữ liệu. Bài sau biến hệ thống thành multi-tenant đúng nghĩa: liệt kê link theo người dùng qua GSI đã dựng ở bài 05, và quan trọng hơn, đảm bảo một người không sửa hay xóa được link của người khác, kể cả khi họ đoán đúng mã, tức là chặn lỗ hổng IDOR ngay trong tầng truy cập dữ liệu.