#5 Bước 4 — Dựng module `identity` (Quản lý User & Auth) Phần 1
Tài liệu này nối tiếp Bước 3 — Migrations & Code Gen. Kiến trúc tổng thể: ARCHITECTURE.md.
Đây là bước đầu tiên có nghiệp vụ thật. Ba bước trước dựng hạ tầng; bước này dùng hạ tầng đó để làm một việc cụ thể: một người lạ gửi request đăng ký, hệ thống tạo tài khoản, cấp token, và ghi một event vào bảng outbox trong cùng một transaction.
Mục tiêu
Kết thúc bước này bạn có:
POST /auth/register— đăng ký, trả về người dùng + JWTPOST /auth/login— đăng nhập, trả về JWTGET /me— endpoint được bảo vệ bằng bearer token- Mật khẩu được băm bằng bcrypt, không bao giờ rời khỏi server dưới dạng gốc
- Mỗi lần đăng ký thành công sinh đúng một dòng
identity.user.registered.v1trong bảngoutbox - Nếu bất kỳ bước nào lỗi: không có user, và cũng không có event — Luật 3 chạy thật, không còn là lý thuyết
Và quan trọng không kém — bạn thấy rõ ranh giới module: identity tạo user nhưng không đụng vào bảng user_stats, dù bảng đó cần một dòng mới. Vì sao lại thế là nội dung §18.
Mục lục
- Phạm vi bước này
- Cài thư viện
- Nhìn từ trên xuống
platform/configplatform/loggerplatform/requestidvàplatform/httpxplatform/passwordplatform/token— JWTplatform/outbox/writer.gocontracts/v1/user_events.goidentity/domainidentity/repositoryidentity/serviceidentity/transport/http- Lắp ráp:
module.govàcmd/api - Chạy thử đầu-cuối
- Kiểm chứng bằng test
- Ai tạo dòng
user_stats? - Những thứ cố ý chưa có
- Checklist hoàn thành
1. Phạm vi bước này
1.1 Cái đã có sẵn
| Thứ | Xong ở | Ghi chú |
|---|---|---|
Bảng users (000001) |
Bước 3 | Đủ mọi cột module này cần |
Bảng outbox (000003) |
Bước 3 | Chỗ event đi vào |
Query CreateUser, GetUserByID, GetUserByEmail |
Bước 3 | Đã sqlc generate |
contracts.Envelope, TopicIdentity |
Bước 2 | |
postgres.TxManager |
Bước 2 | Trái tim của Luật 3 |
1.2 ⚠️ Bước này không thêm migration nào
Đề bài của bước ghi "Tạo DB Schema & Migration: xây dựng bảng users và các migration liên quan". Bảng users đã được tạo ở bước 3, và khi rà lại từng thứ module này cần, không có cột nào thiếu:
| Nhu cầu của module | Cột đã có |
|---|---|
| Đăng nhập bằng email | email + CONSTRAINT users_email_key UNIQUE |
| Kiểm tra mật khẩu | password_hash VARCHAR(255) |
| Định danh công khai | username + CONSTRAINT users_username_key UNIQUE |
| Hiển thị profile | name, avatar_url, reputation |
aggregate_id cho event |
id UUID PRIMARY KEY |
Nên tôi không tạo migration mới. Thêm cột chỉ vì đề bài có mục "Migration" là cách nhanh nhất để có schema đầy cột không ai đọc, và mỗi cột thừa là một thứ phải giữ tương thích mãi mãi.
Nếu sau này bạn thêm refresh token hay xác thực email, lúc đó mới cần migration — và Phụ lục có sẵn mẫu.
Điều cần xác nhận trước khi viết code là schema đang chạy khớp với schema bạn tưởng:
docker compose -f deployments/docker-compose.yml exec -T postgres `
psql -U app -d community -c "\d users"
Phải thấy đủ hai ràng buộc users_email_key và users_username_key. Đây không phải thủ tục cho có: §12 phân biệt "trùng email" với "trùng username" bằng chính tên hai ràng buộc này. Tên sai thì code vẫn biên dịch được, chỉ là người dùng nhận sai thông báo lỗi.
1.3 Cái sẽ viết
internal/
├── contracts/v1/user_events.go ← MỚI
├── platform/
│ ├── config/config.go ← MỚI
│ ├── logger/logger.go ← MỚI
│ ├── requestid/requestid.go ← MỚI
│ ├── httpx/{response.go, errors.go} ← MỚI
│ ├── httpx/middleware/{request_id.go, recover.go, auth.go} ← MỚI
│ ├── password/password.go ← MỚI
│ ├── token/token.go ← MỚI
│ ├── postgres/errors.go ← thêm 1 hàm
│ └── outbox/writer.go ← MỚI
├── modules/identity/
│ ├── domain/{user.go, errors.go} ← MỚI
│ ├── repository/postgres.go ← MỚI
│ ├── service/{service.go, register.go, login.go} ← MỚI
│ ├── transport/http/{handler.go, router.go, dto.go} ← MỚI
│ └── module.go ← MỚI
└── cmd/api/main.go ← MỚI
Nhiều file, nhưng phần lớn là platform/ — viết một lần, mọi module sau dùng lại. Module post ở bước sau sẽ chỉ còn bốn thư mục nghiệp vụ.
2. Cài thư viện
go get github.com/go-chi/chi/v5
go get github.com/golang-jwt/jwt/v5
go get github.com/caarlos0/env/v11
go get golang.org/x/crypto/bcrypt
| Thư viện | Dùng để | Vì sao chọn nó |
|---|---|---|
go-chi/chi/v5 |
HTTP router | Dùng thẳng http.Handler chuẩn, không có kiểu context riêng. Middleware viết cho chi chạy được ở mọi nơi và ngược lại |
golang-jwt/jwt/v5 |
Ký / kiểm JWT | v5 bắt buộc khai báo thuật toán hợp lệ — chặn sẵn lỗ hổng alg ở §8.3 |
caarlos0/env/v11 |
Đọc biến môi trường | Khai báo bằng tag, tự ép kiểu, tự báo thiếu biến bắt buộc |
golang.org/x/crypto/bcrypt |
Băm mật khẩu | Chậm có chủ đích, tự sinh salt, tự lưu cost trong chuỗi hash |
Vì sao bcrypt mà không phải SHA-256?
Vì SHA-256 được thiết kế để nhanh — GPU hiện nay thử được hàng tỷ chuỗi mỗi giây. Với bcrypt cost 12, mỗi lần thử tốn khoảng 250 mili giây, tức chậm hơn chừng mười triệu lần. Cùng một danh sách mật khẩu bị lộ: SHA-256 vỡ trong vài giờ, bcrypt cần nhiều năm.
Argon2id hiện đại hơn và cũng là lựa chọn đúng. Tôi chọn bcrypt vì API chỉ có hai hàm, không có tham số nào để cấu hình sai — mà cấu hình sai Argon2 (đặt bộ nhớ quá thấp) thì trông vẫn "an toàn" và không có gì báo cho bạn biết.
3. Nhìn từ trên xuống
Trước khi đi vào từng file, đây là toàn bộ hành trình của một request đăng ký:
POST /auth/register
│
├─ middleware.RequestID → sinh/nhận X-Request-ID, gắn vào ctx
├─ middleware.Recover → panic không làm sập server
│
├─ transport/http → giải mã JSON, đổi DTO thành domain.Registration
│
└─ service.Register
├─ 1. domain.Registration.Validate() (không chạm DB)
├─ 2. password.Hash() (~250ms CPU — NGOÀI transaction)
│
└─ 3. txm.Do ─────────────── BEGIN ──────────────────────┐
├─ repo.Insert(tx, user) → INSERT INTO users │
├─ contracts.NewEnvelope(...) → dựng event │
└─ outbox.Write(tx, ...) → INSERT INTO outbox │
────── COMMIT ───────────────┘
│
├─ 4. tokens.Issue() (sau khi commit)
└─ trả 201 + user + JWT
Ba chi tiết trong sơ đồ này quyết định chất lượng của cả module:
Băm mật khẩu nằm ngoài transaction. bcrypt cost 12 tốn khoảng 250ms CPU. Nếu đặt nó bên trong txm.Do, mỗi request đăng ký sẽ giữ một kết nối DB và một transaction mở suốt 250ms mà không làm gì với database cả. Với MaxConns = 10 (bước 2 §3.2), chỉ cần 40 lượt đăng ký mỗi giây là pool cạn sạch — và triệu chứng lại trông y hệt "database quá tải", dù database đang rảnh rỗi. Đây là loại lỗi rất khó tìm vì đoạn code trông hoàn toàn hợp lý.
Cấp token nằm sau COMMIT. Token là thứ ta trả cho client, không phải dữ liệu cần bảo toàn. Đưa nó vào transaction chỉ kéo dài thời gian giữ khoá.
INSERT users và INSERT outbox nằm chung một transaction. Đây là Luật 3, và là toàn bộ lý do bảng outbox tồn tại.
4. platform/config
Tạo internal/platform/config/config.go:
// Package config đọc cấu hình từ biến môi trường.
//
// Nguyên tắc: đọc MỘT LẦN lúc khởi động, sai thì dừng ngay.
// Không có hàm nào đọc os.Getenv rải rác trong code nghiệp vụ.
package config
import (
"fmt"
"time"
"github.com/caarlos0/env/v11"
)
type Config struct {
DatabaseURL string `env:"DATABASE_URL,required"`
KafkaBrokers []string `env:"KAFKA_BROKERS" envSeparator:","`
HTTPPort int `env:"HTTP_PORT" envDefault:"8000"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
// JWTSecret ký token HS256. KHÔNG có giá trị mặc định — xem ghi chú dưới.
JWTSecret string `env:"JWT_SECRET,required"`
// JWTTTL là thời gian sống của access token.
JWTTTL time.Duration `env:"JWT_TTL" envDefault:"24h"`
}
// MinJWTSecretLen là độ dài tối thiểu cho khoá HS256.
// HMAC-SHA256 có khối 256 bit; khoá ngắn hơn làm giảm sức mạnh thực tế
// mà không có gì cảnh báo bạn.
const MinJWTSecretLen = 32
func Load() (Config, error) {
var cfg Config
if err := env.Parse(&cfg); err != nil {
return Config{}, fmt.Errorf("config: %w", err)
}
if len(cfg.JWTSecret) < MinJWTSecretLen {
return Config{}, fmt.Errorf(
"config: JWT_SECRET phải dài ít nhất %d ký tự (đang có %d)",
MinJWTSecretLen, len(cfg.JWTSecret))
}
if cfg.JWTTTL <= 0 {
return Config{}, fmt.Errorf("config: JWT_TTL phải lớn hơn 0")
}
return cfg, nil
}
4.1 Vì sao JWT_SECRET không có giá trị mặc định
Đây là quyết định có chủ đích, dù nó làm bạn khó chịu ở lần chạy đầu tiên.
Một giá trị mặc định kiểu envDefault:"dev-secret" sẽ khiến ứng dụng chạy được ngay — và cũng chạy được y hệt trên production, nơi không ai nhớ đặt biến đó. Lúc ấy bất kỳ ai đọc mã nguồn đều tự ký được token cho bất kỳ tài khoản nào.
Bí mật thì phải thiếu là dừng. Cấu hình tiện lợi thì mới có mặc định.
4.2 Bổ sung .env
Thêm vào .env.example (file được commit) và .env (file không commit):
# Auth — sinh giá trị mới cho mỗi môi trường, KHÔNG dùng lại giữa dev và prod
JWT_SECRET=doi-chuoi-nay-thanh-gia-tri-ngau-nhien-32-ky-tu-tro-len
JWT_TTL=24h
Sinh một secret thật:
$bytes = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
[Convert]::ToBase64String($bytes)
4.3 Nạp .env khi chạy go run
Go không tự đọc file .env — chỉ docker compose làm việc đó. Chạy go run ./cmd/api mà không nạp gì, bạn sẽ nhận đúng lỗi DATABASE_URL is required.
Thêm vào dev.ps1 (đã tạo ở bước 1 §8):
function Import-DotEnv {
param([string]$Path = ".env")
if (-not (Test-Path $Path)) { throw "Không thấy $Path" }
Get-Content $Path | ForEach-Object {
if ($_ -match '^\s*([^#=\s][^=]*)=(.*)$') {
[Environment]::SetEnvironmentVariable($Matches[1].Trim(), $Matches[2].Trim(), 'Process')
}
}
}
Dùng:
. .\dev.ps1
Import-DotEnv
go run ./cmd/api
Có thư viện
github.com/joho/godotenvlàm việc này ngay trong Go. Tôi không dùng vì nó tạo ra một khác biệt giữa dev và production: trên production không có file.env, cấu hình đến từ orchestrator. Giữ ứng dụng chỉ biết đọc biến môi trường thì hai môi trường chạy cùng một đường đi.
5. platform/logger
Tạo internal/platform/logger/logger.go:
// Package logger cấu hình log/slog cho toàn hệ thống.
package logger
import (
"log/slog"
"os"
"strings"
)
// New trả về logger ghi JSON ra stdout.
//
// JSON chứ không phải text, vì log của hệ thống event-driven chỉ có ích khi
// bạn lọc được: "cho tôi mọi dòng có event_id = X". Với log dạng câu chữ,
// việc đó là grep và cầu may.
func New(level string) *slog.Logger {
var lv slog.Level
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug":
lv = slog.LevelDebug
case "warn", "warning":
lv = slog.LevelWarn
case "error":
lv = slog.LevelError
default:
lv = slog.LevelInfo
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: lv,
}))
}
Ba quy ước dùng log trong dự án này, đáng thống nhất ngay từ bây giờ:
| Quy ước | Vì sao |
|---|---|
Luôn kèm request_id hoặc event_id |
Một request đi qua HTTP → outbox → Kafka → consumer. Không có ID xuyên suốt thì không nối lại được |
Không bao giờ log mật khẩu, password_hash, hay token |
Log thường được gửi sang hệ thống khác và giữ rất lâu |
| Lỗi 500 log đầy đủ, trả về client chung chung | Chi tiết lỗi là thông tin cho kẻ tấn công, không phải cho người dùng |
6. platform/requestid và platform/httpx
6.1 requestid — sợi chỉ xuyên suốt
Tạo internal/platform/requestid/requestid.go:
// Package requestid mang định danh của một request qua context.
//
// Package riêng (thay vì nằm trong httpx) vì cả tầng HTTP lẫn outbox writer
// đều cần tới nó, mà outbox không nên phải import cả gói HTTP để lấy một chuỗi.
package requestid
import (
"context"
"github.com/google/uuid"
)
// Header là tên header dùng để nhận và trả lại định danh.
const Header = "X-Request-ID"
// ctxKey là kiểu riêng tư — bảo đảm không package nào khác ghi đè
// giá trị này dù có dùng trùng tên khoá.
type ctxKey struct{}
func New() string { return uuid.NewString() }
func NewContext(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
// From trả về "" nếu context không mang định danh nào.
func From(ctx context.Context) string {
id, _ := ctx.Value(ctxKey{}).(string)
return id
}
Vì sao
type ctxKey struct{}mà không phảiconst key = "request_id"?
context.WithValueso khớp khoá bằng cả kiểu lẫn giá trị. Với khoá kiểustring, một thư viện bên thứ ba dùng đúng chuỗi"request_id"sẽ ghi đè giá trị của bạn — âm thầm. Với một kiểu không xuất khẩu, chuyện đó là bất khả thi: không ai bên ngoài package tạo được giá trịctxKey{}.Đây cũng là lý do
go vetcảnh báo khi bạn dùng kiểu cơ bản làm khoá context.
6.2 httpx — trả lời có khuôn
Tạo internal/platform/httpx/response.go:
// Package httpx chứa tiện ích HTTP dùng chung. Không có nghiệp vụ.
package httpx
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
// MaxBodyBytes giới hạn kích thước body nhận vào.
const MaxBodyBytes = 1 << 20 // 1 MB
// JSON ghi body dạng JSON kèm status code.
func JSON(w http.ResponseWriter, status int, body any) {
if body == nil {
w.WriteHeader(status)
return
}
// Mã hoá vào buffer TRƯỚC khi ghi header. Nếu mã hoá thẳng ra
// ResponseWriter và nửa chừng gặp lỗi, header 200 đã gửi đi rồi —
// client nhận một JSON cụt kèm mã thành công.
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(body); err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal","message":"lỗi hệ thống"}}`))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(buf.Bytes())
}
// DecodeJSON đọc body vào dst, có chặn kích thước.
func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
r.Body = http.MaxBytesReader(w, r.Body, MaxBodyBytes)
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
return fmt.Errorf("body không phải JSON hợp lệ: %w", err)
}
return nil
}
http.MaxBytesReaderkhông phải chi tiết thừa. Không có nó,json.Decodersẽ ngoan ngoãn đọc hết một body 5 GB vào RAM. Một request là đủ để giết tiến trình, và không cần kỹ năng gì để gửi.
Tạo internal/platform/httpx/errors.go:
package httpx
import (
"net/http"
"github.com/yourname/community/internal/platform/requestid"
)
type ErrorBody struct {
// Code là mã máy đọc được: "email_taken", "invalid_credentials"...
// Client dựa vào nó để xử lý, KHÔNG dựa vào Message.
Code string `json:"code"`
// Message dành cho con người đọc.
Message string `json:"message"`
}
type ErrorResponse struct {
Error ErrorBody `json:"error"`
// RequestID để người dùng gửi kèm khi báo lỗi — bạn tra log ra ngay.
RequestID string `json:"request_id,omitempty"`
}
func Fail(w http.ResponseWriter, r *http.Request, status int, code, message string) {
JSON(w, status, ErrorResponse{
Error: ErrorBody{Code: code, Message: message},
RequestID: requestid.From(r.Context()),
})
}
httpxkhông được biết lỗi nghiệp vụBạn sẽ rất muốn viết một hàm ngay tại đây:
// ❌ KHÔNG viết hàm này trong platform func StatusFor(err error) int { if errors.Is(err, domain.ErrEmailTaken) { return http.StatusConflict } ... }Nó buộc
platform/httpxphải importmodules/identity/domain— vi phạm thẳngARCHITECTURE.md§4.5: platform không được import modules. Và ngày thêm module thứ tư, cáiswitchđó phải sửa lại lần nữa.Việc ánh xạ lỗi → HTTP status thuộc về tầng transport của từng module. Bạn sẽ thấy nó ở §14.3.
6.3 Middleware
Tạo internal/platform/httpx/middleware/request_id.go:
package middleware
import (
"net/http"
"github.com/yourname/community/internal/platform/requestid"
)
// RequestID nhận X-Request-ID từ client hoặc sinh mới, rồi gắn vào context
// và trả lại trong response header.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get(requestid.Header)
// Nhận ID từ client cho phép nối vết qua nhiều service, nhưng đây là
// dữ liệu người ngoài gửi vào: chặn độ dài để nó không thành một
// chuỗi 10 MB nằm trong mọi dòng log.
if id == "" || len(id) > 128 {
id = requestid.New()
}
w.Header().Set(requestid.Header, id)
next.ServeHTTP(w, r.WithContext(requestid.NewContext(r.Context(), id)))
})
}
Tạo internal/platform/httpx/middleware/recover.go:
package middleware
import (
"log/slog"
"net/http"
"runtime/debug"
"github.com/yourname/community/internal/platform/httpx"
"github.com/yourname/community/internal/platform/requestid"
)
// Recover biến panic trong handler thành 500 thay vì làm sập cả tiến trình.
func Recover(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
p := recover()
if p == nil {
return
}
// http.ErrAbortHandler là panic CÓ CHỦ ĐÍCH của net/http
// để bỏ dở một response. Nuốt nó là làm hỏng cơ chế đó.
if p == http.ErrAbortHandler {
panic(p)
}
log.Error("panic trong handler",
"panic", p,
"method", r.Method,
"path", r.URL.Path,
"request_id", requestid.From(r.Context()),
"stack", string(debug.Stack()))
httpx.Fail(w, r, http.StatusInternalServerError, "internal", "lỗi hệ thống")
}()
next.ServeHTTP(w, r)
})
}
}
Tạo internal/platform/httpx/middleware/auth.go:
package middleware
import (
"context"
"net/http"
"strings"
"github.com/yourname/community/internal/platform/httpx"
)
// Verifier do bên gọi cung cấp. Nhờ interface này, middleware không biết
// gì về JWT — đổi sang phiên (session) hay khoá API chỉ cần một implement khác.
type Verifier interface {
// Verify trả về subject (ở đây là user ID) nếu token hợp lệ.
Verify(rawToken string) (subject string, err error)
}
type subjectKey struct{}
// Auth chặn request không có bearer token hợp lệ.
func Auth(v Verifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, ok := bearer(r)
if !ok {
httpx.Fail(w, r, http.StatusUnauthorized, "unauthorized", "thiếu bearer token")
return
}
sub, err := v.Verify(raw)
if err != nil {
// KHÔNG trả err ra ngoài: thông báo "token hết hạn" và
// "chữ ký sai" là hai mẩu thông tin khác nhau cho kẻ tấn công.
httpx.Fail(w, r, http.StatusUnauthorized, "unauthorized", "token không hợp lệ")
return
}
ctx := context.WithValue(r.Context(), subjectKey{}, sub)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Subject lấy user ID đã xác thực khỏi context.
func Subject(ctx context.Context) (string, bool) {
s, ok := ctx.Value(subjectKey{}).(string)
return s, ok && s != ""
}
func bearer(r *http.Request) (string, bool) {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
return "", false
}
token := strings.TrimSpace(h[len(prefix):])
return token, token != ""
}
7. platform/password
Tạo internal/platform/password/password.go:
// Package password băm và kiểm tra mật khẩu.
//
// Không có nghiệp vụ nào ở đây — chép package này sang dự án Go khác
// là chạy được ngay, nên nó thuộc về platform (ARCHITECTURE.md §4.5).
package password
import (
"errors"
"fmt"
"sync"
"golang.org/x/crypto/bcrypt"
)
const (
// Cost 12 ≈ 250ms trên CPU máy bàn năm 2024.
// Thấp hơn thì dễ bị dò; cao hơn thì chính bạn tự tạo điểm nghẽn.
Cost = 12
// MaxLen là giới hạn CỨNG của thuật toán bcrypt, không phải lựa chọn
// của chúng ta. Xem ghi chú bên dưới.
MaxLen = 72
)
var (
ErrTooLong = errors.New("password: dài quá 72 byte")
ErrMismatch = errors.New("password: không khớp")
)
func Hash(plain string) (string, error) {
if len(plain) > MaxLen {
return "", ErrTooLong
}
h, err := bcrypt.GenerateFromPassword([]byte(plain), Cost)
if err != nil {
return "", fmt.Errorf("password: băm thất bại: %w", err)
}
return string(h), nil
}
// Verify trả nil nếu đúng, ErrMismatch nếu sai.
// Cost được đọc từ chính chuỗi hash, nên đổi Cost không làm hỏng hash cũ.
func Verify(hash, plain string) error {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
switch {
case err == nil:
return nil
case errors.Is(err, bcrypt.ErrMismatchedHashAndPassword):
return ErrMismatch
default:
return fmt.Errorf("password: kiểm tra thất bại: %w", err)
}
}
// dummyHash được tạo lười, chỉ khi lần đầu có ai đăng nhập bằng email không
// tồn tại. Tính sẵn lúc khởi động sẽ làm mọi lần chạy test chậm thêm 250ms.
var dummyHash = sync.OnceValue(func() []byte {
h, err := bcrypt.GenerateFromPassword([]byte("chuoi-nay-khong-bao-gio-trung-khop"), Cost)
if err != nil {
panic(err) // chỉ xảy ra nếu Cost nằm ngoài khoảng hợp lệ — lỗi lập trình
}
return h
})
// Burn tiêu tốn đúng lượng thời gian như một lần Verify thật.
//
// Gọi nó khi KHÔNG tìm thấy người dùng, để thời gian phản hồi của
// "email không tồn tại" và "mật khẩu sai" giống nhau. Lý do đầy đủ ở §7.2.
func Burn(plain string) {
_ = bcrypt.CompareHashAndPassword(dummyHash(), []byte(plain))
}
7.1 ⚠️ Giới hạn 72 byte của bcrypt
bcrypt chỉ nhìn 72 byte đầu tiên của mật khẩu. Đây là đặc điểm của thuật toán, không phải của thư viện.
Hệ quả nếu không xử lý: hai mật khẩu dài giống nhau ở 72 byte đầu nhưng khác nhau ở đuôi sẽ được coi là một. Người dùng cẩn thận nhất — người đặt passphrase 100 ký tự — lại là người bị ảnh hưởng.
Lưu ý thêm: 72 byte, không phải 72 ký tự. Tiếng Việt có dấu chiếm 2–3 byte mỗi ký tự trong UTF-8, nên "mật khẩu rất dài của tôi..." chạm giới hạn sớm hơn nhiều so với cảm giác. Đó là lý do §11 kiểm tra bằng len(password) (đếm byte) chứ không phải utf8.RuneCountInString.
Các phiên bản golang.org/x/crypto gần đây đã trả về lỗi khi mật khẩu quá 72 byte; các phiên bản cũ thì cắt bớt trong im lặng. Ta kiểm tra tường minh để hành vi giống nhau ở mọi phiên bản, và để thông báo lỗi là tiếng Việt của mình.
Có một mẹo phổ biến: băm SHA-256 trước rồi mới đưa vào bcrypt để bỏ giới hạn 72 byte. Nó có hiệu quả, nhưng phải mã hoá base64 kết quả SHA-256 trước, vì bcrypt dừng ở byte
0x00đầu tiên và digest nhị phân rất dễ chứa byte đó. Làm sai bước này thì entropy thực tế sụt thảm hại mà không có gì báo lỗi. Với ứng dụng thông thường, đặt giới hạn 72 byte đơn giản và an toàn hơn nhiều.
7.2 ⚠️ Burn — vì sao phải đốt thời gian một cách cố ý
Cách viết đăng nhập tự nhiên nhất có một lỗ hổng:
u, err := repo.FindByEmail(ctx, email)
if err != nil {
return ErrInvalidCredentials // ← trả về sau ~2ms
}
if err := password.Verify(u.PasswordHash, plain); err != nil {
return ErrInvalidCredentials // ← trả về sau ~250ms
}
Cả hai nhánh trả cùng một thông báo lỗi, nên nhìn qua thì kín. Nhưng chúng khác nhau hơn một trăm lần về thời gian, và thời gian thì đo được từ bên ngoài:
| Thời gian phản hồi | Kẻ tấn công suy ra |
|---|---|
| ~2ms | Email này chưa đăng ký |
| ~250ms | Email này đã đăng ký, chỉ sai mật khẩu |
Với chênh lệch lớn như vậy, không cần thống kê tinh vi gì — một vòng lặp curl là đủ để lọc ra danh sách email có thật trong hệ thống. Danh sách đó là nguyên liệu cho email lừa đảo nhắm đúng đối tượng, hoặc để thử mật khẩu rò rỉ từ nơi khác.
Burn làm nhánh "không tìm thấy" tốn đúng lượng thời gian như nhánh "sai mật khẩu". Cả hai cùng ~250ms, và kênh rò rỉ đóng lại.
Đây không phải cân bằng thời gian tuyệt đối — truy vấn DB vẫn lệch vài mili giây. Nhưng nó biến khoảng cách từ 100 lần xuống còn vài phần trăm, tức là từ "đo bằng mắt" thành "phải có hàng nghìn mẫu và mạng cực ổn định".
8. platform/token — JWT
Tạo internal/platform/token/token.go:
// Package token cấp và kiểm tra JSON Web Token.
//
// Package này chỉ biết "chủ thể" là một chuỗi. Chuỗi đó là user ID hay
// thứ gì khác là việc của module gọi tới nó.
package token
import (
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
var ErrInvalidToken = errors.New("token không hợp lệ")
// MinSecretLen — khoá HS256 ngắn hơn 32 byte làm yếu chữ ký.
const MinSecretLen = 32
type Issuer struct {
secret []byte
ttl time.Duration
issuer string
}
func NewIssuer(secret string, ttl time.Duration, issuerName string) (*Issuer, error) {
if len(secret) < MinSecretLen {
return nil, fmt.Errorf("token: secret phải dài ít nhất %d byte", MinSecretLen)
}
if ttl <= 0 {
return nil, errors.New("token: ttl phải lớn hơn 0")
}
if issuerName == "" {
return nil, errors.New("token: thiếu tên issuer")
}
return &Issuer{secret: []byte(secret), ttl: ttl, issuer: issuerName}, nil
}
// Issue nhận now làm tham số thay vì gọi time.Now() bên trong —
// nhờ vậy test kiểm tra được token hết hạn mà không phải chờ thật.
func (i *Issuer) Issue(subject string, now time.Time) (string, time.Time, error) {
if subject == "" {
return "", time.Time{}, errors.New("token: thiếu subject")
}
expiresAt := now.Add(i.ttl)
claims := jwt.RegisteredClaims{
Subject: subject,
Issuer: i.issuer,
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
ID: uuid.NewString(), // để sau này thu hồi từng token nếu cần
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(i.secret)
if err != nil {
return "", time.Time{}, fmt.Errorf("token: ký thất bại: %w", err)
}
return signed, expiresAt, nil
}
// Verify kiểm tra chữ ký, hạn dùng và issuer. Trả về subject nếu hợp lệ.
func (i *Issuer) Verify(raw string) (string, error) {
var claims jwt.RegisteredClaims
_, err := jwt.ParseWithClaims(raw, &claims,
func(*jwt.Token) (any, error) { return i.secret, nil },
// Ba tuỳ chọn dưới đây là phần quan trọng nhất của cả file. Xem §8.3.
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
jwt.WithIssuer(i.issuer),
jwt.WithExpirationRequired(),
)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidToken, err)
}
if claims.Subject == "" {
return "", fmt.Errorf("%w: thiếu subject", ErrInvalidToken)
}
return claims.Subject, nil
}
8.1 Vì sao JWT nằm ở platform chứ không ở identity
Phép thử của ARCHITECTURE.md §4.5: chép package này sang một dự án Go hoàn toàn khác, nó có chạy được không?
token chỉ biết ký một chuỗi và kiểm tra lại chuỗi đó. Nó không biết "user" là gì. → platform.
Đổi lại, identity là nơi quyết định cái gì được đặt vào subject (ở đây là user.ID), và tầng transport là nơi quyết định endpoint nào cần token. Ranh giới này giữ cho service của identity không phải import thư viện JWT nào — nên test nghiệp vụ chạy mà không cần biết JWT tồn tại.
8.2 Vì sao chỉ dùng RegisteredClaims
Bạn sẽ muốn nhét thêm vào token: email, username, vai trò, avatar...
Đừng, ít nhất là chưa. Ba lý do:
- Token không cập nhật được. Người dùng đổi username lúc 10:00, token cấp lúc 9:00 vẫn còn hiệu lực tới 9:00 hôm sau và vẫn mang tên cũ. Mọi dữ liệu đặt trong token đều là bản chụp có thể đã lỗi thời.
- Token nằm ở trình duyệt. Nội dung JWT chỉ được base64, không mã hoá — ai cầm token đều đọc được mọi claim bằng mắt thường.
- Token đi kèm mọi request. Mỗi claim thêm vào là vài chục byte nhân với mọi lần gọi API.
Cần thông tin người dùng thì gọi GET /me. Chỉ đưa vào token thứ bắt buộc phải có để xác thực.
8.3 ⚠️ Hai lỗ hổng JWT kinh điển
Lỗ hổng 1 — alg: none. Đặc tả JWT có thuật toán none, nghĩa là "token này không cần chữ ký". Một thư viện ngây thơ sẽ chấp nhận:
{"alg":"none","typ":"JWT"}.{"sub":"id-cua-admin"}.
Kẻ tấn công tự viết claim, bỏ trống phần chữ ký, và đăng nhập thành bất kỳ ai.
Lỗ hổng 2 — nhầm lẫn thuật toán. Nguy hiểm hơn vì tinh vi hơn. Nếu server dùng RS256 (khoá công khai + khoá riêng), kẻ tấn công đổi header thành HS256 rồi ký bằng chính khoá công khai — thứ ai cũng lấy được. Server nào chỉ nhìn header để chọn cách kiểm tra sẽ dùng khoá công khai làm khoá HMAC, và chữ ký khớp.
Cả hai đều bị chặn bởi đúng một dòng:
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})
Dòng này nói: "tôi chỉ chấp nhận HS256, bất kể header của token ghi gì." Header do kẻ tấn công kiểm soát, nên không bao giờ để header quyết định cách xác minh.
jwt.WithExpirationRequired() đóng nốt cái bẫy thứ ba: nếu thiếu nó, một token không có trường exp sẽ được coi là hợp lệ vĩnh viễn.
8.4 Cái giá của JWT: không thu hồi được
JWT là không trạng thái — server không lưu gì cả, chỉ kiểm chữ ký. Đó là ưu điểm (không cần tra DB mỗi request) và cũng là nhược điểm:
| Bạn muốn | Với phiên lưu ở DB | Với JWT |
|---|---|---|
| Đăng xuất ngay lập tức | Xoá dòng phiên → xong | Không làm được — token vẫn hợp lệ tới lúc hết hạn |
| Khoá tài khoản vi phạm | Xoá phiên → xong | Không làm được |
| Xem ai đang đăng nhập | SELECT là ra |
Không có gì để xem |
Cách xử lý theo mức độ:
- TTL ngắn — 15 phút cho access token, cộng refresh token lưu ở DB. Cửa sổ rủi ro co lại còn 15 phút.
- Danh sách đen — lưu
jticủa token bị thu hồi vào Redis tới khi nó hết hạn. Đây là lý do trườngIDcó mặt trong claim ở trên. - Chấp nhận — với dự án cộng đồng, TTL 24 giờ thường là đủ.
Tôi chọn cách 3 cho bước này và ghi rõ ra đây, vì đó là một quyết định chứ không phải một thiếu sót. Phụ lục có mẫu migration cho refresh token khi bạn cần nâng lên cách 1.
9. platform/outbox/writer.go
Bước 3 đã sinh sẵn hàm EnqueueEvent. Việc còn lại là bọc nó sao cho chỗ gọi chỉ cần nghĩ bằng contracts.Envelope.
Tạo internal/platform/outbox/writer.go:
// Package outbox ghi event vào bảng outbox và đẩy chúng lên broker.
package outbox
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yourname/community/internal/contracts"
"github.com/yourname/community/internal/platform/outbox/gen"
"github.com/yourname/community/internal/platform/requestid"
)
// Writer ghi event vào bảng outbox.
//
// Hiện chưa có trường nào, nhưng đây là kiểu mà service phụ thuộc vào —
// giữ nó là struct để sau này thêm logger hay metric mà không phải sửa
// chữ ký ở mọi chỗ gọi.
type Writer struct{}
func NewWriter() *Writer { return &Writer{} }
// Write ghi envelope vào outbox TRONG transaction đang mở.
//
// Tham số tx là bắt buộc và đó là chủ đích: không có transaction thì
// không gọi được hàm này. Luật 3 được trình biên dịch canh giữ,
// không phải bằng một dòng comment (xem bước 2 §4.3).
func (w *Writer) Write(ctx context.Context, tx pgx.Tx, topic string, e contracts.Envelope) error {
if topic == "" {
return fmt.Errorf("outbox: thiếu topic (%s)", e.EventType)
}
// Kiểm tra ở BIÊN GIỚI ghi. Một envelope hỏng lọt vào bảng sẽ làm
// relay thất bại mãi mãi, và lúc đó rất khó lần ra ai đã ghi nó.
if err := e.Validate(); err != nil {
return fmt.Errorf("outbox: %w", err)
}
// Cột event_id kiểu UUID. Envelope giữ nó dạng chuỗi để không ràng buộc
// consumer vào một thư viện UUID cụ thể, nên phải đổi kiểu ở đây.
eventID, err := uuid.Parse(e.EventID)
if err != nil {
return fmt.Errorf("outbox: event_id không phải UUID (%q): %w", e.EventID, err)
}
// Nếu chỗ gọi chưa gắn correlation, lấy request ID đang có trong context.
if e.CorrelationID == "" {
e.CorrelationID = requestid.From(ctx)
}
var correlation *string
if e.CorrelationID != "" {
correlation = &e.CorrelationID
}
return gen.New(tx).EnqueueEvent(ctx, gen.EnqueueEventParams{
EventID: eventID,
EventType: e.EventType,
Topic: topic,
AggregateID: e.AggregateID,
CorrelationID: correlation,
Payload: []byte(e.Payload),
OccurredAt: e.OccurredAt,
})
}
gen.New(tx)— đây là chỗ mảnh ghépDBTXtừ bước 3 §10.2 phát huy tác dụng. Cùng một hàmEnqueueEventchạy được với*pgxpool.Pool(ngoài transaction) hoặcpgx.Tx(trong transaction). Ở đây ta bắt buộc dùng vế thứ hai.
10. contracts/v1/user_events.go
Tạo internal/contracts/v1/user_events.go:
package v1
import "time"
const (
// TypeUserRegistered được phát khi một tài khoản mới được tạo THÀNH CÔNG
// (tức là transaction đã commit).
TypeUserRegistered = "identity.user.registered.v1"
)
// UserRegistered — dữ liệu tối thiểu để consumer làm việc của mình
// mà KHÔNG phải truy vấn ngược sang bảng users của module identity.
//
// ⚠️ TUYỆT ĐỐI KHÔNG thêm PasswordHash vào struct này. Xem tài liệu §10.1.
type UserRegistered struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
RegisteredAt time.Time `json:"registered_at"`
}
Chỉ một hằng số, dù cây thư mục trong ARCHITECTURE.md có nhắc tới on_user_deleted.go. Lý do giống như ở bước 2 §5.4: một event chưa ai phát và chưa ai nghe là code chết — không có test nào chạm tới, không có gì chứng minh nó đúng, mà vẫn phải giữ tương thích ngược.
10.1 ⚠️ Cái gì được phép nằm trong payload
Payload event có vòng đời rất dài:
bảng outbox (giữ 7 ngày) → Kafka (giữ 7 ngày) → log của mọi consumer
→ DLQ nếu xử lý lỗi
→ bản sao lưu database
Nghĩa là mọi thứ bạn đặt vào đây sẽ được nhân bản ra nhiều nơi mà bạn không kiểm soát trực tiếp, và tồn tại lâu hơn bạn tưởng.
| Trường | Quyết định | Vì sao |
|---|---|---|
UserID |
✅ Bắt buộc | Khoá để consumer làm việc |
Username, Name |
✅ Có | Vốn đã công khai trên profile |
Email |
⚠️ Có, cân nhắc | Consumer gửi email chào mừng cần nó. Nhưng đây là dữ liệu cá nhân — nếu bạn phải tuân thủ quy định về xoá dữ liệu, hãy nhớ nó đang nằm trong Kafka |
RegisteredAt |
✅ Có | Consumer thống kê cần mốc thời gian |
PasswordHash |
❌ Không bao giờ | Xem dưới |
| Mật khẩu gốc | ❌ Không bao giờ | Không tồn tại ở bất kỳ đâu sau khi băm |
Về PasswordHash: bcrypt không thể đảo ngược, nên phản xạ đầu tiên là "để vào cũng chẳng sao". Nhưng bạn vừa nhân bản toàn bộ hash mật khẩu của mọi người dùng sang Kafka, sang log của consumer, sang DLQ — những nơi gần như chắc chắn có quyền truy cập lỏng hơn bảng users. Từ chỗ một nơi cần bảo vệ, bạn tạo ra năm.
Nguyên tắc: payload chỉ chứa thứ consumer thật sự cần để làm việc của nó. Consumer nào cũng không cần hash mật khẩu.
§17.4 có một test giữ nguyên tắc này thay bạn, cho ngày có người thêm trường vào struct mà quên đọc đoạn này.
11. identity/domain
Tạo internal/modules/identity/domain/errors.go:
// Package domain chứa thực thể và quy tắc nghiệp vụ của module identity.
//
// Package này không import DB, không import HTTP, không import JWT.
// Nó là phần có thể suy luận đúng/sai mà không cần chạy thứ gì.
package domain
import "errors"
var (
// ErrInvalidInput — dữ liệu người dùng gửi lên không hợp lệ.
ErrInvalidInput = errors.New("dữ liệu không hợp lệ")
ErrEmailTaken = errors.New("email đã được sử dụng")
ErrUsernameTaken = errors.New("username đã được sử dụng")
ErrUserNotFound = errors.New("không tìm thấy người dùng")
// ErrInvalidCredentials cố ý KHÔNG phân biệt "sai email" với "sai mật khẩu".
// Xem tài liệu §7.2 — cùng lý do với password.Burn.
ErrInvalidCredentials = errors.New("email hoặc mật khẩu không đúng")
)
Tạo internal/modules/identity/domain/user.go:
package domain
import (
"fmt"
"net/mail"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
)
// User là thực thể nghiệp vụ. Không có tag `db` hay `json`:
// cách lưu trữ và cách hiển thị là việc của tầng khác.
type User struct {
ID uuid.UUID
Name string
Username string
Email string
PasswordHash string
AvatarURL *string
Reputation int32
CreatedAt time.Time
UpdatedAt time.Time
}
// usernameRe áp dụng SAU khi đã chuẩn hoá chữ thường.
// Chỉ cho phép a-z, 0-9 và gạch dưới để username luôn an toàn trong URL.
var usernameRe = regexp.MustCompile(`^[a-z0-9_]{3,30}$`)
const (
MinPasswordLen = 8
// MaxPasswordLen tính bằng BYTE, không phải ký tự — đây là giới hạn
// của bcrypt (xem tài liệu §7.1).
MaxPasswordLen = 72
)
// NormalizeEmail — bảng users có UNIQUE phân biệt hoa thường, nên mọi email
// phải đi qua đây trước khi chạm DB (bước 3 §3.3).
func NormalizeEmail(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func NormalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// Registration là dữ liệu thô cho một lần đăng ký.
type Registration struct {
Name string
Username string
Email string
Password string
}
// Validate kiểm tra dữ liệu đầu vào, không chạm tới DB.
// Những thứ chỉ database biết (email đã tồn tại chưa) được xử lý ở §12.
func (r Registration) Validate() error {
name := strings.TrimSpace(r.Name)
if n := utf8.RuneCountInString(name); n < 1 || n > 255 {
return fmt.Errorf("%w: name phải từ 1 đến 255 ký tự", ErrInvalidInput)
}
if !usernameRe.MatchString(NormalizeUsername(r.Username)) {
return fmt.Errorf("%w: username chỉ gồm a-z, 0-9, _ và dài 3–30 ký tự", ErrInvalidInput)
}
email := NormalizeEmail(r.Email)
addr, err := mail.ParseAddress(email)
// So sánh addr.Address với email để loại dạng `Tên <a@b.com>` —
// mail.ParseAddress chấp nhận dạng đó, nhưng ta chỉ muốn địa chỉ trần.
if err != nil || addr.Address != email {
return fmt.Errorf("%w: email không đúng định dạng", ErrInvalidInput)
}
if n := len(r.Password); n < MinPasswordLen || n > MaxPasswordLen {
return fmt.Errorf("%w: mật khẩu phải từ %d đến %d byte",
ErrInvalidInput, MinPasswordLen, MaxPasswordLen)
}
return nil
}
// NewUser dựng thực thể từ dữ liệu đã hợp lệ và hash đã tính sẵn.
//
// ID do ứng dụng sinh chứ không để DEFAULT gen_random_uuid() —
// service cần ID ngay để đặt vào AggregateID của event, trong cùng
// transaction với lệnh INSERT (bước 3 §3.1).
func NewUser(r Registration, passwordHash string) User {
return User{
ID: uuid.New(),
Name: strings.TrimSpace(r.Name),
Username: NormalizeUsername(r.Username),
Email: NormalizeEmail(r.Email),
PasswordHash: passwordHash,
}
}
Vì sao dùng
net/mailthay vì regex email?Vì regex email đúng theo RFC 5322 dài hơn 6000 ký tự, và mọi regex ngắn hơn đều từ chối sai một số địa chỉ hợp lệ.
mail.ParseAddressnằm trong thư viện chuẩn, được viết đúng theo đặc tả, và bạn không phải bảo trì nó.Nó vẫn không kiểm tra được hộp thư có tồn tại thật hay không — chỉ có gửi một email xác nhận mới làm được điều đó.
12. identity/repository
Bước 3 đã sinh CreateUser, GetUserByID, GetUserByEmail. Không cần thêm query nào, không cần chạy lại sqlc generate. Việc của tầng này là dịch giữa hai thế giới:
gen.User (kiểu của database) ⇄ domain.User (kiểu của nghiệp vụ)
lỗi Postgres 23505 → domain.ErrEmailTaken
pgx.ErrNoRows → domain.ErrUserNotFound
12.1 Thêm một hàm vào platform/postgres/errors.go
Để phân biệt "trùng email" với "trùng username", ta cần biết ràng buộc nào bị vi phạm. Thêm vào cuối internal/platform/postgres/errors.go:
// ConstraintName trả về tên ràng buộc bị vi phạm.
// Trả "" nếu err không phải lỗi đến từ Postgres.
func ConstraintName(err error) string {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.ConstraintName
}
return ""
}
12.2 repository/postgres.go
// Package repository lưu trữ user trong PostgreSQL.
package repository
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yourname/community/internal/modules/identity/domain"
"github.com/yourname/community/internal/modules/identity/repository/gen"
"github.com/yourname/community/internal/platform/postgres"
)
// Tên ràng buộc UNIQUE trong migration 000001. Nếu đổi tên trong migration
// thì phải đổi ở đây — hai hằng số này là điểm nối duy nhất giữa hai file.
const (
constraintEmail = "users_email_key"
constraintUsername = "users_username_key"
)
type Postgres struct {
pool *pgxpool.Pool
}
func NewPostgres(pool *pgxpool.Pool) *Postgres {
return &Postgres{pool: pool}
}
// Insert ghi user mới TRONG transaction do service mở.
func (r *Postgres) Insert(ctx context.Context, tx pgx.Tx, u domain.User) (domain.User, error) {
row, err := gen.New(tx).CreateUser(ctx, gen.CreateUserParams{
ID: u.ID,
Name: u.Name,
Username: u.Username,
Email: u.Email,
PasswordHash: u.PasswordHash,
AvatarUrl: u.AvatarURL,
})
if err != nil {
if postgres.IsUniqueViolation(err) {
switch postgres.ConstraintName(err) {
case constraintEmail:
return domain.User{}, domain.ErrEmailTaken
case constraintUsername:
return domain.User{}, domain.ErrUsernameTaken
}
}
return domain.User{}, fmt.Errorf("identity: tạo user: %w", err)
}
return toDomain(row), nil
}
// FindByEmail đọc ngoài transaction — đăng nhập không ghi gì cả.
func (r *Postgres) FindByEmail(ctx context.Context, email string) (domain.User, error) {
row, err := gen.New(r.pool).GetUserByEmail(ctx, email)
return mapRow(row, err)
}
func (r *Postgres) FindByID(ctx context.Context, id uuid.UUID) (domain.User, error) {
row, err := gen.New(r.pool).GetUserByID(ctx, id)
return mapRow(row, err)
}
func mapRow(row gen.User, err error) (domain.User, error) {
switch {
case errors.Is(err, pgx.ErrNoRows):
return domain.User{}, domain.ErrUserNotFound
case err != nil:
return domain.User{}, fmt.Errorf("identity: đọc user: %w", err)
}
return toDomain(row), nil
}
func toDomain(u gen.User) domain.User {
return domain.User{
ID: u.ID,
Name: u.Name,
Username: u.Username,
Email: u.Email,
PasswordHash: u.PasswordHash,
AvatarURL: u.AvatarUrl,
Reputation: u.Reputation,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
AvatarUrlhayAvatarURL?
sqlcchỉ viết hoa toàn bộ vớiid→ID. Các từ viết tắt khác giữ dạng thường:avatar_url→AvatarUrl. Kiểudomain.Usercủa ta dùngAvatarURLđúng quy ước Go, và hàmtoDomainlà chỗ duy nhất phải biết sự khác biệt đó.Muốn thống nhất,
sqlc.yamlcórename:— nhưng đổi một tên ở đó sinh lại toàn bộgen/của cả bốn package. Với một trường, dịch tay tạitoDomainrẻ hơn.
12.3 ⚠️ Không kiểm tra trước rồi mới ghi
Đoạn code mà gần như ai cũng viết ở lần đầu:
// ❌ SAI — có khoảng trống giữa hai lệnh
if _, err := repo.FindByEmail(ctx, email); err == nil {
return domain.ErrEmailTaken
}
repo.Insert(ctx, tx, user)
Hai người cùng đăng ký một email trong cùng một phần nghìn giây: cả hai đều thấy "chưa có ai", cả hai đều INSERT, và một người nhận lỗi 500 khó hiểu từ database thay vì thông báo "email đã được dùng".
Cách đúng là cứ ghi, và dịch lỗi của database thành lỗi nghiệp vụ — chính là những gì Insert ở trên làm. Ràng buộc UNIQUE là một khoá thật do database giữ; đoạn if trong Go thì không.
Đây cũng chính là nguyên tắc đã dùng cho MarkConsumed ở bước 3 §9.2: để database xử lý tranh chấp đồng thời. Cùng một bài học, xuất hiện lần thứ hai ở một chỗ hoàn toàn khác — đó là dấu hiệu nó đáng nhớ.
13. identity/service
13.1 Khai báo phụ thuộc
Tạo internal/modules/identity/service/service.go:
// Package service chứa nghiệp vụ của module identity.
//
// Nó không biết HTTP, không biết Kafka, không biết sqlc.
// Mọi thứ nó cần được khai báo bằng interface ngay trong file này.
package service
import (
"context"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/yourname/community/internal/contracts"
"github.com/yourname/community/internal/modules/identity/domain"
)
// Transactor chạy một hàm trong transaction. *postgres.TxManager thoả interface này.
type Transactor interface {
Do(ctx context.Context, fn func(tx pgx.Tx) error) error
}
// Repository — đúng ba việc service cần, không hơn.
type Repository interface {
Insert(ctx context.Context, tx pgx.Tx, u domain.User) (domain.User, error)
FindByEmail(ctx context.Context, email string) (domain.User, error)
FindByID(ctx context.Context, id uuid.UUID) (domain.User, error)
}
// EventWriter ghi event vào outbox. *outbox.Writer thoả interface này.
type EventWriter interface {
Write(ctx context.Context, tx pgx.Tx, topic string, e contracts.Envelope) error
}
// TokenIssuer cấp token. *token.Issuer thoả interface này.
type TokenIssuer interface {
Issue(subject string, now time.Time) (string, time.Time, error)
}
type Service struct {
tx Transactor
repo Repository
events EventWriter
tokens TokenIssuer
log *slog.Logger
// now là hàm để test kiểm soát thời gian mà không phải chờ thật.
now func() time.Time
}
func New(tx Transactor, repo Repository, events EventWriter, tokens TokenIssuer, log *slog.Logger) *Service {
return &Service{
tx: tx,
repo: repo,
events: events,
tokens: tokens,
log: log,
now: func() time.Time { return time.Now().UTC() },
}
}
// Profile trả về thông tin người dùng cho endpoint GET /me.
func (s *Service) Profile(ctx context.Context, id uuid.UUID) (domain.User, error) {
return s.repo.FindByID(ctx, id)
}
Bốn interface này đều do bên gọi định nghĩa
Không phải
outboxkhaiEventWriterrồi service dùng nhờ, mà ngược lại: service khai đúng cái nó cần, và*outbox.Writertình cờ thoả mãn.Khác biệt tưởng nhỏ nhưng đổi chiều phụ thuộc:
servicekhông importoutboxchỉ để lấy interface; và fake trong test chỉ cần cài đúng một phương thức thay vì cả gói.Vì sao
Transactormà không dùng thẳng*postgres.TxManager? Vì kiểu cụ thể bắt mọi test của service phải có PostgreSQL đang chạy. Với interface, §17.3 chạy hết nghiệp vụ đăng ký/đăng nhập trong vài mili giây, không cần Docker.Đổi lại, ta mất hàm generic
postgres.DoValue— Go không cho method có tham số kiểu, nênDoValuelà hàm cấp package nhận*TxManagercụ thể. Giải pháp: dùngDovà gán kết quả vào biến bên ngoài closure, đúng nhưregister.godưới đây.
13.2 register.go — nơi Luật 3 thành code
Tạo internal/modules/identity/service/register.go:
package service
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/yourname/community/internal/contracts"
v1 "github.com/yourname/community/internal/contracts/v1"
"github.com/yourname/community/internal/modules/identity/domain"
"github.com/yourname/community/internal/platform/password"
)
type AuthResult struct {
User domain.User
Token string
ExpiresAt time.Time
}
func (s *Service) Register(ctx context.Context, in domain.Registration) (AuthResult, error) {
// 1. Kiểm tra dữ liệu — không chạm DB, không tốn gì.
if err := in.Validate(); err != nil {
return AuthResult{}, err
}
// 2. Băm mật khẩu NGOÀI transaction.
// bcrypt tốn ~250ms CPU; làm việc này trong transaction là giữ một
// kết nối DB rảnh rỗi suốt 250ms cho mỗi lượt đăng ký (xem §3).
hash, err := password.Hash(in.Password)
if err != nil {
return AuthResult{}, fmt.Errorf("identity: băm mật khẩu: %w", err)
}
user := domain.NewUser(in, hash)
// 3. Ghi user và ghi event trong CÙNG một transaction.
var created domain.User
err = s.tx.Do(ctx, func(tx pgx.Tx) error {
saved, err := s.repo.Insert(ctx, tx, user)
if err != nil {
return err // ErrEmailTaken / ErrUsernameTaken đi thẳng ra ngoài
}
event, err := contracts.NewEnvelope(
v1.TypeUserRegistered,
saved.ID.String(), // AggregateID → Kafka partition key
v1.UserRegistered{
UserID: saved.ID.String(),
Username: saved.Username,
Name: saved.Name,
Email: saved.Email,
RegisteredAt: saved.CreatedAt,
},
)
if err != nil {
return fmt.Errorf("identity: dựng event: %w", err)
}
if err := s.events.Write(ctx, tx, contracts.TopicIdentity, event); err != nil {
return fmt.Errorf("identity: ghi outbox: %w", err)
}
created = saved
return nil
})
if err != nil {
return AuthResult{}, err
}
// 4. Cấp token SAU khi commit. Token không phải dữ liệu cần bảo toàn,
// không có lý do gì để nó kéo dài thời gian giữ transaction.
now := s.now()
tok, expiresAt, err := s.tokens.Issue(created.ID.String(), now)
if err != nil {
// Tài khoản ĐÃ được tạo thành công. Người dùng chỉ cần đăng nhập lại.
return AuthResult{}, fmt.Errorf("identity: cấp token: %w", err)
}
s.log.Info("đăng ký thành công",
"user_id", created.ID.String(),
"username", created.Username)
return AuthResult{User: created, Token: tok, ExpiresAt: expiresAt}, nil
}
Đọc lại bước 3 một lượt: repo.Insert và s.events.Write nhận cùng một tx. Không có cách nào để một cái thành công còn cái kia thất bại. Nếu Write lỗi, TxManager.Do rollback và dòng users cũng biến mất.
Đây chính là điều mà test TestLoiNghiepVuKeoTheoOutboxRollback ở bước 3 đã chứng minh trước bằng SQL trần — giờ nó là code nghiệp vụ thật.
13.3 login.go
Tạo internal/modules/identity/service/login.go:
package service
import (
"context"
"errors"
"fmt"
"github.com/yourname/community/internal/modules/identity/domain"
"github.com/yourname/community/internal/platform/password"
)
// Login KHÔNG mở transaction: nó không ghi gì cả.
func (s *Service) Login(ctx context.Context, email, plain string) (AuthResult, error) {
normalized := domain.NormalizeEmail(email)
user, err := s.repo.FindByEmail(ctx, normalized)
if err != nil {
if errors.Is(err, domain.ErrUserNotFound) {
// Đốt đúng lượng thời gian như một lần kiểm mật khẩu thật,
// để bên ngoài không đo được email nào có tồn tại (§7.2).
password.Burn(plain)
return AuthResult{}, domain.ErrInvalidCredentials
}
return AuthResult{}, err
}
if err := password.Verify(user.PasswordHash, plain); err != nil {
if errors.Is(err, password.ErrMismatch) {
// Cùng một lỗi với nhánh "không tìm thấy" — có chủ đích.
return AuthResult{}, domain.ErrInvalidCredentials
}
return AuthResult{}, fmt.Errorf("identity: kiểm mật khẩu: %w", err)
}
tok, expiresAt, err := s.tokens.Issue(user.ID.String(), s.now())
if err != nil {
return AuthResult{}, fmt.Errorf("identity: cấp token: %w", err)
}
s.log.Info("đăng nhập thành công", "user_id", user.ID.String())
return AuthResult{User: user, Token: tok, ExpiresAt: expiresAt}, nil
}
13.4 Vì sao đăng nhập không phát event
Câu hỏi hợp lý: đăng ký phát event, sao đăng nhập lại không?
Vì Luật 2: event tồn tại để module khác phản ứng. Hiện chưa có module nào cần biết ai vừa đăng nhập.
Ngày bạn cần ghi nhật ký đăng nhập hay phát hiện truy cập bất thường, identity.user.logged_in.v1 sẽ được thêm vào — cùng lúc với consumer đầu tiên của nó. Phát một event không ai nghe chỉ tạo ra rác trong Kafka và một hợp đồng phải giữ tương thích mãi mãi.
Phép thử trước khi thêm event mới: có ai đang chờ nghe nó không? Không → chưa thêm.
14. identity/transport/http
14.1 DTO
Tạo internal/modules/identity/transport/http/dto.go:
package http
import (
"time"
"github.com/yourname/community/internal/modules/identity/domain"
)
type RegisterRequest struct {
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// UserResponse là hình dạng user mà thế giới bên ngoài được thấy.
//
// ⚠️ Struct này tồn tại CHÍNH VÌ domain.User có trường PasswordHash.
// Xem §14.2.
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
AvatarURL *string `json:"avatar_url"`
Reputation int32 `json:"reputation"`
CreatedAt string `json:"created_at"`
}
type AuthResponse struct {
User UserResponse `json:"user"`
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
func newUserResponse(u domain.User) UserResponse {
return UserResponse{
ID: u.ID.String(),
Name: u.Name,
Username: u.Username,
Email: u.Email,
AvatarURL: u.AvatarURL,
Reputation: u.Reputation,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
}
}
14.2 ⚠️ Không bao giờ trả thẳng struct của tầng dưới
Đây là kiểu rò rỉ dữ liệu phổ biến nhất trong các API viết bằng Go:
// ❌ Trông tiện, và nó rò rỉ password_hash của mọi người dùng
httpx.JSON(w, 200, user)
Nếu domain.User (hoặc tệ hơn: gen.User sinh bởi sqlc) có tag json, hàm trên sẽ mã hoá mọi trường, kể cả PasswordHash. Không có lỗi nào, không có cảnh báo nào — chỉ là hash mật khẩu nằm trong response JSON.
Hai lớp phòng thủ trong dự án này:
domain.Userkhông có tagjsonnào. Nó sẽ được mã hoá thành{"ID":"...","PasswordHash":"$2a$12$..."}— xấu và sai rõ ràng, khiến người viết phải dừng lại.UserResponseliệt kê tường minh từng trường được phép ra ngoài. Thêm cột mới vào bảnguserssẽ không tự động lộ ra API; ai đó phải chủ động thêm nó vào đây.
Cùng lý lẽ đó áp dụng cho chiều ngược lại: RegisterRequest là struct riêng chứ không phải domain.User, nên client không thể tự đặt reputation: 9999 cho mình.
14.3 Ánh xạ lỗi sang HTTP status
Tạo internal/modules/identity/transport/http/handler.go:
// Package http là tầng vào ra HTTP của module identity.
package http
import (
"errors"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
"github.com/yourname/community/internal/modules/identity/domain"
"github.com/yourname/community/internal/modules/identity/service"
"github.com/yourname/community/internal/platform/httpx"
"github.com/yourname/community/internal/platform/httpx/middleware"
"github.com/yourname/community/internal/platform/requestid"
)
type Handler struct {
svc *service.Service
verifier middleware.Verifier
log *slog.Logger
}
func NewHandler(svc *service.Service, verifier middleware.Verifier, log *slog.Logger) *Handler {
return &Handler{svc: svc, verifier: verifier, log: log}
}
func (h *Handler) handleRegister(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest
if err := httpx.DecodeJSON(w, r, &req); err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "bad_request", err.Error())
return
}
res, err := h.svc.Register(r.Context(), domain.Registration{
Name: req.Name,
Username: req.Username,
Email: req.Email,
Password: req.Password,
})
if err != nil {
h.fail(w, r, err)
return
}
httpx.JSON(w, http.StatusCreated, AuthResponse{
User: newUserResponse(res.User),
Token: res.Token,
ExpiresAt: res.ExpiresAt.Format(time.RFC3339),
})
}
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := httpx.DecodeJSON(w, r, &req); err != nil {
httpx.Fail(w, r, http.StatusBadRequest, "bad_request", err.Error())
return
}
res, err := h.svc.Login(r.Context(), req.Email, req.Password)
if err != nil {
h.fail(w, r, err)
return
}
httpx.JSON(w, http.StatusOK, AuthResponse{
User: newUserResponse(res.User),
Token: res.Token,
ExpiresAt: res.ExpiresAt.Format(time.RFC3339),
})
}
func (h *Handler) handleMe(w http.ResponseWriter, r *http.Request) {
subject, ok := middleware.Subject(r.Context())
if !ok {
httpx.Fail(w, r, http.StatusUnauthorized, "unauthorized", "chưa xác thực")
return
}
id, err := uuid.Parse(subject)
if err != nil {
// Token hợp lệ về chữ ký nhưng subject không phải UUID —
// chỉ xảy ra nếu secret bị dùng để ký thứ khác.
httpx.Fail(w, r, http.StatusUnauthorized, "unauthorized", "token không hợp lệ")
return
}
user, err := h.svc.Profile(r.Context(), id)
if err != nil {
h.fail(w, r, err)
return
}
httpx.JSON(w, http.StatusOK, newUserResponse(user))
}
// fail dịch lỗi nghiệp vụ sang mã HTTP.
//
// Hàm này nằm ở module, KHÔNG nằm ở platform/httpx: chỉ module mới
// biết lỗi của nó có nghĩa gì (xem §6.2).
func (h *Handler) fail(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, domain.ErrInvalidInput):
// An toàn khi lộ ra ngoài: đây là thông báo do ta tự viết.
httpx.Fail(w, r, http.StatusUnprocessableEntity, "invalid_input", err.Error())
case errors.Is(err, domain.ErrEmailTaken):
httpx.Fail(w, r, http.StatusConflict, "email_taken", "email đã được sử dụng")
case errors.Is(err, domain.ErrUsernameTaken):
httpx.Fail(w, r, http.StatusConflict, "username_taken", "username đã được sử dụng")
case errors.Is(err, domain.ErrInvalidCredentials):
httpx.Fail(w, r, http.StatusUnauthorized, "invalid_credentials", "email hoặc mật khẩu không đúng")
case errors.Is(err, domain.ErrUserNotFound):
httpx.Fail(w, r, http.StatusNotFound, "not_found", "không tìm thấy người dùng")
default:
// Log ĐẦY ĐỦ cho mình, trả CHUNG CHUNG cho client.
// err ở đây có thể chứa tên bảng, tên cột, đoạn SQL — thông tin
// quý giá cho kẻ đang dò hệ thống.
h.log.Error("lỗi không lường trước",
"err", err,
"method", r.Method,
"path", r.URL.Path,
"request_id", requestid.From(r.Context()))
httpx.Fail(w, r, http.StatusInternalServerError, "internal", "lỗi hệ thống")
}
}
All rights reserved