From f373cb8d37567b9b4ffecae3afeae3edb7ff44f4 Mon Sep 17 00:00:00 2001 From: ChenYunDa Date: Sat, 3 Oct 2026 09:23:38 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E7=B5=84=E5=89=8D=E6=AA=A2=E6=9F=A5?= =?UTF-8?q?=E9=BB=9E:=E6=A0=B9=E7=9B=AE=E9=8C=84=20main=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 32 + ...ss_8d85875f-99ca-4894-a4ac-06b73d667e1d.md | 72 +++ ...ss_de9dbda7-7f39-4521-9978-101f4bf6beb3.md | 40 ++ .zcodeignore | 63 ++ README.md | 322 ++++++++++ adminapplications.go | 346 +++++++++++ adminapplications_test.go | 554 ++++++++++++++++++ adminkeys.go | 209 +++++++ adminkeys_test.go | 377 ++++++++++++ application.go | 267 +++++++++ application_test.go | 291 +++++++++ assets/css/input.css | 20 + assets/css/main.css | 2 + createaccount.go | 186 ++++++ createaccount_test.go | 102 ++++ db.go | 40 ++ go.mod | 23 + go.sum | 46 ++ internal/jwk/jwk.go | 26 + internal/jwk/signingkey.go | 117 ++++ internal/jwk/signingkey_test.go | 175 ++++++ login.go | 190 ++++++ login_test.go | 156 +++++ loginpage.go | 173 ++++++ loginpage_test.go | 170 ++++++ logout.go | 68 +++ logout_test.go | 203 +++++++ main.go | 71 +++ notfound.go | 10 + notfound_test.go | 51 ++ session.go | 76 +++ static.go | 25 + static_test.go | 39 ++ templates/adminapplicationnew.html | 97 +++ templates/adminapplications.html | 111 ++++ templates/adminkeys.html | 99 ++++ templates/layout.html | 104 ++++ templates/loggedin.html | 22 + templates/login.html | 27 + templates/notfound.html | 23 + templates/secretpanel.html | 33 ++ updatepassword.go | 79 +++ updatepassword_test.go | 28 + user.go | 110 ++++ user_test.go | 57 ++ 45 files changed, 5332 insertions(+) create mode 100644 .gitignore create mode 100644 .zcode/plans/plan-sess_8d85875f-99ca-4894-a4ac-06b73d667e1d.md create mode 100644 .zcode/plans/plan-sess_de9dbda7-7f39-4521-9978-101f4bf6beb3.md create mode 100644 .zcodeignore create mode 100644 README.md create mode 100644 adminapplications.go create mode 100644 adminapplications_test.go create mode 100644 adminkeys.go create mode 100644 adminkeys_test.go create mode 100644 application.go create mode 100644 application_test.go create mode 100644 assets/css/input.css create mode 100644 assets/css/main.css create mode 100644 createaccount.go create mode 100644 createaccount_test.go create mode 100644 db.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/jwk/jwk.go create mode 100644 internal/jwk/signingkey.go create mode 100644 internal/jwk/signingkey_test.go create mode 100644 login.go create mode 100644 login_test.go create mode 100644 loginpage.go create mode 100644 loginpage_test.go create mode 100644 logout.go create mode 100644 logout_test.go create mode 100644 main.go create mode 100644 notfound.go create mode 100644 notfound_test.go create mode 100644 session.go create mode 100644 static.go create mode 100644 static_test.go create mode 100644 templates/adminapplicationnew.html create mode 100644 templates/adminapplications.html create mode 100644 templates/adminkeys.html create mode 100644 templates/layout.html create mode 100644 templates/loggedin.html create mode 100644 templates/login.html create mode 100644 templates/notfound.html create mode 100644 templates/secretpanel.html create mode 100644 updatepassword.go create mode 100644 updatepassword_test.go create mode 100644 user.go create mode 100644 user_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cf9e29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Binaries +alterminal +*.exe + +# Build tools (Tailwind standalone CLI,下載方式見 README) +tools/ + +# Test and coverage +*.test +*.out + +# Go workspace +go.work +go.work.sum + +# Dependencies +vendor/ + +# Environment variables (contains DB credentials) +.env +.env.* + +# Logs +*.log + +# Editor / IDE +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db diff --git a/.zcode/plans/plan-sess_8d85875f-99ca-4894-a4ac-06b73d667e1d.md b/.zcode/plans/plan-sess_8d85875f-99ca-4894-a4ac-06b73d667e1d.md new file mode 100644 index 0000000..5736d5c --- /dev/null +++ b/.zcode/plans/plan-sess_8d85875f-99ca-4894-a4ac-06b73d667e1d.md @@ -0,0 +1,72 @@ +# 重組 alterminal:根目錄 main 拆分為 cmd/ + internal/ 套件 + +採「精簡 ~6 包」方案,進入點移至 `cmd/alterminal/`。模組路徑 `alterminal` 不變,所有新套件在 `internal/` 下。 + +## 目標結構 + +``` +alterminal/ +├── cmd/alterminal/main.go # 新:進入點(chi 路由裝配、/health、CLI 分派) +├── internal/ +│ ├── auth/ # 認證領域 + 所有非管理頁 HTTP +│ │ ├── user.go + user_test.go # User、Role、argon2id(原 user.go) +│ │ ├── session.go # Session、CookieName、Create/Get/Delete +│ │ ├── login.go + login_test.go # POST /login(JSON+表單) +│ │ ├── loginpage.go + loginpage_test.go # 模板 embed、CSRF、GET /login、GET / +│ │ ├── logout.go + logout_test.go # POST /logout +│ │ ├── static.go + static_test.go # /static/(embed assets) +│ │ ├── notfound.go + notfound_test.go # 自訂 404 +│ │ ├── dbtest_test.go # 新:auth 專用測試 DB helper(見下) +│ │ ├── templates/ # 原 templates/ 整批搬入 +│ │ └── assets/ # 原 assets/ 整批搬入 +│ ├── application/ +│ │ └── application.go + application_test.go # Application(RP 註冊)模型 +│ ├── store/ +│ │ └── db.go # Open(openDB)、EnvOr(envOr)、AutoMigrate +│ ├── admin/ +│ │ ├── adminkeys.go + adminkeys_test.go +│ │ └── adminapplications.go + adminapplications_test.go # requireAdmin 兩檔同包,維持私有 +│ ├── cli/ +│ │ ├── createaccount.go + createaccount_test.go +│ │ └── updatepassword.go + updatepassword_test.go +│ ├── testdb/ +│ │ └── testdb.go # 新:New(t)(原 adminkeys_test.go 的 newTestDB) +│ └── jwk/ # 不動 +├── go.mod / go.sum / README.md / tools/ +``` + +依賴方向(無循環):`auth`(僡 stdlib + x/crypto)← `application`(密碼雜湊、Token)← `store`(AutoMigrate)← `cli`/`testdb`;`admin` → `auth` + `application` + `jwk`;`cmd` → 全部。 + +## 識別字重新命名(跨套件需要者才 export) + +**auth 對外**:`hashPassword`→`HashPassword`、`verifyPassword`→`VerifyPassword`(application 需要)、`newRandomToken`→`NewToken`(application 需要)、`sessionCookieName`→`CookieName`、`createSession/getSession/deleteSession`→`CreateSession/GetSession/DeleteSession`(admin 測試與 handler 需要)、`renderHTML`→`RenderHTML`、`newCSRFToken/verifyCSRF`→`NewCSRFToken/VerifyCSRF`、`csrfCookieName`→`CSRFCookieName`、模板單例 `adminKeysTmpl/adminApplicationsTmpl/adminApplicationNewTmpl`→`AdminKeysTmpl/AdminApplicationsTmpl/AdminApplicationNewTmpl`(admin 套件渲染用;全部模板仍集中 embed 於 auth,layout.html 共用不拆)、handler 工廠 `LoginPageHandler/AccountPageHandler/LoginHandler/LogoutHandler/StaticHandler/NotFoundHandler`。 + +**auth 維持私有**:`authenticateUser`、`dummyPasswordHash`、`ErrInvalidCredentials`、`writeJSON/writeError`(login+logout 同包;未來 OIDC 需要時再提升)、`renderAccountPage/renderLoginPage/renderLoggedInPage`、cookie 設定/清除、`loginTmpl/loggedInTmpl/notFoundTmpl`、`csrfTTL/sessionTTL`、argon2 常數。 + +**application**:`getApplicationByClientID`→`GetByClientID`(原註解即標明供未來 /authorize、/token 使用),其餘已 exported 不動。 + +**store**:`openDB`→`Open`、`envOr`→`EnvOr`。**admin**:八個 handler 維持原名但改 exported(如 `KeysPageHandler`、`ApplicationsCreateHandler`),`requireAdmin` 私有。**cli**:`runCommand`→`Run(args []string)`,其餘私有。**testdb**:`New(t *testing.T) *gorm.DB`(行為同原 newTestDB:連不上 PG 則 t.Skipf)。 + +## Import cycle 處理(關鍵) + +`auth` 的整合測試**不可**匯入 `store`/`testdb`(它們會匯入 auth 模型做 AutoMigrate,形成測試循環)。因此在 auth 套件內新增 `dbtest_test.go`:自建測試 DB 連線,只 migrate + truncate `users`、`sessions` 兩表(login/logout/loginpage 整合測試只需要這兩張);admin 與 application 的測試用 `testdb.New(t)`(完整四表)。 + +## 其他配合調整 + +1. **go:embed**:`//go:embed templates/*.html`、`//go:embed assets` 語法不變(embed 為套件目錄相對),`templates/`、`assets/` 實體搬入 `internal/auth/`;模板內 `/static/css/main.css` URL 不受影響。 +2. **Tailwind**:建置指令改為 `tools/tailwindcss -i internal/auth/assets/css/input.css -o internal/auth/assets/css/main.css --minify`,實際執行一次驗證 v4 自動掃描仍涵蓋新模板路徑(比對輸出與現況)。 +3. **README.md**:重寫「專案結構(目標)」為實際新結構;`go run .`→`go run ./cmd/alterminal`、`go build -o alterminal .`→`go build -o alterminal ./cmd/alterminal`、CLI 範例、模板路徑說明同步更新。 +4. **main.go**:原 `/users/{name}` demo 路由與 `/health` 內聯 handler 原樣搬入 `cmd/alterminal/main.go`。 +5. 根目錄既有編譯產物 `alterminal` binary 不動(下次 build 覆蓋)。 + +## 實施步驟 + +0. (可選,建議)目前不是 git repo:先 `git init` + initial commit 做檢查點,方便事後 diff 與回退。**若你不要此步,請在核准時註明刪除。** +1. `mkdir` + `mv` 搬移檔案與 templates/、assets/;根目錄只留 go.mod、go.sum、README.md、.gitignore、.zcodeignore、tools/、(可選 git init)。 +2. 逐套件改 package 宣告、匯入路徑(`alterminal/internal/...`)與上表識別字重新命名;搬移 `newTestDB`→testdb、新增 auth 的 dbtest_test.go。 +3. `gofmt -l .`、`go vet ./...`、`go build ./...`。 +4. `go test ./...`(本機 PostgreSQL 有起就會跑整合測試,否則按原設計 skip)。 +5. Tailwind 重建驗證 + smoke test:`go run ./cmd/alterminal` 起服務,curl `/health`、`/login`(應回 HTML 登入頁)、`/static/css/main.css`。 +6. 更新 README;更新記憶檔 alterminal-progress.md(結構重組完成、下一步 OIDC 不變)。 + +行為完全不變:路由、模板、Cookie 名稱、CSRF 機制、CLI 介面、環境變數都照舊,純粹移動 + 重新命名。 \ No newline at end of file diff --git a/.zcode/plans/plan-sess_de9dbda7-7f39-4521-9978-101f4bf6beb3.md b/.zcode/plans/plan-sess_de9dbda7-7f39-4521-9978-101f4bf6beb3.md new file mode 100644 index 0000000..abbebe3 --- /dev/null +++ b/.zcode/plans/plan-sess_de9dbda7-7f39-4521-9978-101f4bf6beb3.md @@ -0,0 +1,40 @@ +# 實作 `create-account` CLI 子指令 + +## 背景 + +alterminal(Go + chi + GORM + PostgreSQL 的 OIDC 服務)目前 `User` 模型(user.go)與 argon2id 密碼雜湊已完成,但沒有任何建立帳號的入口。要在二進位檔加入 CLI 子指令:`./alterminal create-account ...`,不帶參數時行為不變(啟動 HTTP 伺服器)。 + +## 指令介面 + +``` +alterminal create-account -username alice -email alice@example.com [-name "Alice"] [-email-verified] [-password secret] +``` + +- `-username`(必填):登入帳號,限制 `^[A-Za-z0-9._-]+$`、長度 ≤ 64 +- `-email`(必填):以 `net/mail.ParseAddress` 驗證格式,長度 ≤ 255 +- `-name`(選填):顯示名稱,長度 ≤ 255 +- `-email-verified`(選填,預設 false):設定 OIDC `email_verified` claim +- `-password`(選填):密碼,最小長度 8(OWASP 建議)。**省略時以互動式無回顯提示輸入兩次**(用 `golang.org/x/term.ReadPassword`),兩次不一致則報錯;非終端機環境(管線)未提供旗標時直接報錯提示改用 `-password` + +## 檔案變更 + +1. **新增依賴**:`go get golang.org/x/term`(與既有 x/crypto 同屬 golang.org/x) +2. **main.go**:在 `main()` 開頭加入子指令分派——`len(os.Args) > 1` 時交給 `runCommand(os.Args[1:])`,否則照常啟動伺服器;伺服器部分程式碼不動 +3. **新增 `createaccount.go`**(沿用根目錄、`package main` 的現有風格): + - `runCommand`:分派子指令;僅有 `create-account`,未知子指令印用法後離開 + - `runCreateAccount(args)`: + 1. `flag.NewFlagSet("create-account", flag.ExitOnError)` 解析旗標,欄位 `strings.TrimSpace` + 2. `validateAccountInput` 驗證 username/email/name(可單元測試的純函式) + 3. `resolvePassword`:旗標優先,否則互動輸入兩次 + 4. 重用 `openDB()`(含 AutoMigrate,確保資料表存在) + 5. 建立 `User` 並呼叫現有的 `SetPassword`(argon2id) + 6. 重複檢查:先以查詢提供友善錯誤(帳號已存在 / Email 已存在),`db.Create` 再以 `gorm.ErrDuplicatedRows` 兜底(並發保護) + 7. 成功輸出 `帳號建立成功:id=1 username=alice email=alice@example.com`(不印密碼);失敗經 `log.Fatal("create-account: ", err)` 離開(與現有 main 錯誤風格一致) + - 使用者面向訊息採繁體中文(與 README、程式註解一致) +4. **新增 `createaccount_test.go`**:仿照 `user_test.go` 的 table-driven 純邏輯測試——`validateAccountInput` 各種非法輸入、密碼長度檢查(不含需要 DB 或 TTY 的部分,專案目前無 DB 測試基礎設施) +5. **README.md**:在「快速開始」加入「建立使用者帳號」小節(指令、旗標、互動輸入說明);Roadmap 的「使用者系統」僅完成一環,維持未勾選 + +## 驗證 + +- `go build ./...`、`go vet ./...`、`go test ./...` +- 煙霧測試:若本機 PostgreSQL 有啟動,執行 `go run . create-account -username smoke -email smoke@example.com -password testpass1`,確認成功輸出、重複執行收到「已存在」錯誤、`CheckPassword` 可驗證;無 DB 時以單元測試與建置結果為準 \ No newline at end of file diff --git a/.zcodeignore b/.zcodeignore new file mode 100644 index 0000000..60719ee --- /dev/null +++ b/.zcodeignore @@ -0,0 +1,63 @@ +# Binaries +alterminal +*.exe + +# Test and coverage +*.test +*.out + +# Go workspace +go.work +go.work.sum + +# Dependencies +vendor/ + +# Environment variables (contains DB credentials) +.env +.env.* + +# Logs +*.log + +# Editor / IDE +.idea/ +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)===== +.git/ +.hg/ +.svn/ +node_modules/ +bower_components/ +jspm_packages/ +__pycache__/ +site-packages/ +venv/ +coverage/ +htmlcov/ +lcov-report/ +cmakefiles/ +cmake-build-*/ +bazel-*/ +pods/ +deriveddata/ +storybook-static/ +playwright-report/ +test-results/ +allure-results/ +allure-report/ +cdk.out/ +*.egg-info/ +*.dist-info/ +eggs/ +pip-wheel-metadata/ +wheels/ +# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)----- +# 自定义规则写在下方(本行提示可删除) +# Build tools (Tailwind standalone CLI binary) +tools/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4aba2de --- /dev/null +++ b/README.md @@ -0,0 +1,322 @@ +# alterminal + +輕量級單一登入(SSO)服務,實作 [OpenID Connect](https://openid.net/connect/) 協定。alterminal 扮演 **OpenID Provider(OP / Identity Provider)**,讓多個應用程式(Relying Party, RP)透過標準協定完成身分認證,實現「登入一次,處處可用」。 + +> **狀態:開發中。** 目前完成專案骨架(HTTP 服務、資料庫連線、健康檢查)、使用者帳號(CLI 建帳與重設密碼、argon2id 密碼雜湊)與登入/登出(HTML 登入頁+JSON API、Session Cookie),OIDC 核心功能依下方 Roadmap 推進。 + +## 特色 + +- **標準 OIDC Provider** + - Discovery(`/.well-known/openid-configuration`)與 JWKS(`/.well-known/jwks.json`) + - 以 RS256 簽發 ID Token,支援金鑰輪替(rotation) +- **OAuth 2.0 / OIDC 授權流程** + - Authorization Code Flow + PKCE(RFC 7636),支援機密式(confidential)與公開式(public)Client + - Refresh Token(含輪替與撤銷) + - Client Credentials Grant(機器對機器情境) +- **單一登入/單一登出** + - 已登入使用者在瀏覽器 Session 有效期間內,可直接通過新 RP 的授權請求 + - RP-Initiated Logout(OIDC Front-/Back-Channel Logout 列於 Roadmap) +- **技術棧單純**:Go + [chi](https://github.com/go-chi/chi) + [GORM](https://gorm.io) + PostgreSQL + [Tailwind CSS](https://tailwindcss.com)(建置產物內嵌),單一執行檔即可部署 + +## 支援的 Scope + +| Scope | 說明 | +| --- | --- | +| `openid` | 必選。要求簽發 ID Token | +| `profile` | 使用者基本資料(`name` 等 claim) | +| `email` | 使用者 Email(`email`、`email_verified`) | +| `offline_access` | 簽發 Refresh Token | + +## 端點一覽 + +| 端點 | 方法 | 說明 | 狀態 | +| --- | --- | --- | --- | +| `/health` | GET | 健康檢查(含資料庫連線檢測) | ✅ 已完成 | +| `/.well-known/openid-configuration` | GET | OIDC Discovery 文件 | 🚧 規劃中 | +| `/.well-known/jwks.json` | GET | Token 簽署用公開金鑰(JWKS) | 🚧 規劃中 | +| `/login` | POST | 使用者登入(JSON API 與 HTML 表單提交) | ✅ 已完成 | +| `/login` | GET | 使用者登入頁(HTML 表單,供 `/authorize` 導向) | ✅ 已完成 | +| `/logout` | POST | 使用者登出(HTML 表單與 JSON API,冪等) | ✅ 已完成 | +| `/static/*` | GET | 靜態檔(Tailwind 建置輸出的 CSS,`go:embed` 內嵌) | ✅ 已完成 | +| `/authorize` | GET | 授權端點(Authorization Code Flow) | 🚧 規劃中 | +| `/token` | POST | 權杖端點(換發 Access / ID / Refresh Token) | 🚧 規劃中 | +| `/userinfo` | GET/POST | 以 Access Token 取得使用者資訊 | 🚧 規劃中 | +| `/logout` | GET | RP-Initiated Logout(OIDC:`id_token_hint`、`post_logout_redirect_uri` 等參數驗證) | 🚧 規劃中 | +| `/admin/applications` | GET | 應用程式管理頁(RP 註冊列表;僅 admin) | ✅ 已完成 | +| `/admin/applications/new` | GET | 註冊新應用程式頁(獨立表單頁;僅 admin) | ✅ 已完成 | +| `/admin/applications/new` | POST | 註冊應用程式(明文 client_secret 僅於本次回應顯示一次,表單+CSRF) | ✅ 已完成 | +| `/admin/applications/{id}/secret` | POST | 輪替 client secret(舊 secret 立即失效,明文僅顯示一次) | ✅ 已完成 | +| `/admin/applications/{id}/delete` | POST | 刪除應用程式註冊(表單+CSRF,PRG) | ✅ 已完成 | +| `/admin/keys` | GET | 金鑰管理頁(kid、狀態、輪替操作;僅 admin) | ✅ 已完成 | +| `/admin/keys` | POST | 產生新 RSA 簽章金鑰(表單+CSRF,PRG) | ✅ 已完成 | +| `/admin/keys/{id}/retire` | POST | 退休金鑰(最後一把使用中金鑰不可退休) | ✅ 已完成 | +| `*`(未匹配路徑) | 任意 | 自訂 404 頁(HTML,不分方法;`/static/` 下不存在的檔案仍由檔案伺服器回純文字 404) | ✅ 已完成 | + +## 快速開始 + +### 前置需求 + +- Go 1.26+ +- PostgreSQL 14+(或直接用 Docker) + +### 啟動資料庫 + +```bash +docker run -d --name alterminal-db \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=alterminal \ + -p 5432:5432 \ + postgres:17 +``` + +### 啟動服務 + +```bash +go run . +# 服務啟動於 http://localhost:8080 +``` + +### 驗證 + +```bash +curl http://localhost:8080/health +# => ok +``` + +### 編譯執行檔 + +```bash +go build -o alterminal . +./alterminal +# 服務啟動於 http://localhost:8080 +``` + +- HTML 模板與 Tailwind 建置輸出(`main.css`)皆以 `go:embed` 內嵌,產出為**單一執行檔**,部署時不需連同 `templates/`、`assets/` 一併安裝 +- 依賴全為純 Go(PostgreSQL 驅動採 pgx,無 CGO),可直接交叉編譯。部署至 Linux 伺服器: + +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o alterminal . +# ARM 伺服器改用 GOARCH=arm64 +``` + +- 編譯前建議先跑測試:`go test ./...` + +### 帳號管理 CLI + +帳號相關操作由 CLI 子指令完成,共通行為: + +- 不需啟動伺服器,指令會自行連線資料庫(連線參數同 `DB_*` 環境變數) +- 密碼一律以 argon2id 重新雜湊(每次產生新 salt),明文不落地 +- 密碼至少 8 字元;省略 `-password` 旗標時於終端機無回顯輸入兩次,非互動環境(cron、CI)必須提供旗標 +- 失敗時以 `log.Fatal` 結束(exit code 1);未知子指令 exit code 2 並列出用法 + +| 子指令 | 說明 | +| --- | --- | +| `create-account` | 建立使用者帳號 | +| `update-password` | 重設帳號密碼(管理用途,不驗證舊密碼),成功後撤銷該帳號所有 Session | + +### 建立使用者帳號 + +```bash +go run . create-account -username alice -email alice@example.com -name "Alice" +輸入密碼: ******** +再次輸入密碼: ******** +帳號建立成功:id=1 username=alice email=alice@example.com role=user +``` + +| 旗標 | 說明 | +| --- | --- | +| `-username` | 登入帳號(必填、唯一;僅英數與 `. _ -`,最長 64) | +| `-email` | Email(必填、唯一) | +| `-name` | 顯示名稱(選填) | +| `-role` | 角色(選填,`admin` 或 `user`,預設 `user`) | +| `-email-verified` | 將 Email 標記為已驗證(選填,預設 `false`) | +| `-password` | 密碼(至少 8 字元;省略時於終端機無回顯輸入兩次,非互動環境必須提供) | + +### 更新密碼 + +管理用密碼重設(不驗證舊密碼)。密碼更新與 Session 撤銷於**同一資料庫交易**內完成,成功後該帳號所有 Session 立即失效——對 SSO Provider 而言,若密碼重設(例如帳號外洩的處置)後既有 Session 仍繼續有效,重設便失去意義: + +```bash +go run . update-password -username alice +輸入密碼: ******** +再次輸入密碼: ******** +密碼更新成功:id=1 username=alice(已撤銷 2 個 Session) +``` + +| 旗標 | 說明 | +| --- | --- | +| `-username` | 要重設密碼的帳號(必填) | +| `-password` | 新密碼(至少 8 字元;省略時於終端機無回顯輸入兩次,非互動環境必須提供) | + +錯誤情境(exit code 1,訊息前綴 `update-password: `): + +| 錯誤訊息 | 情境 | +| --- | --- | +| `username 不可為空` | 未提供 `-username`,或值僅空白 | +| `username "alice" 不存在` | 查無該帳號 | +| `密碼長度至少 8 字元` | 新密碼過短 | +| `兩次輸入的密碼不一致` | 終端機兩次輸入不同 | +| `非互動環境無法提示輸入密碼,請以 -password 提供` | 省略 `-password` 且 stdin 非終端機 | + +### 登入 API + +`POST /login` 以 JSON 驗證帳密,成功時建立瀏覽器 Session 並以 `Set-Cookie` 下發(`alterminal_session`,HttpOnly、SameSite=Lax,效期 24 小時): + +```bash +curl -i -X POST http://localhost:8080/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"sup3r-secret"}' +``` + +成功(200): + +```json +{ + "user": {"id": 1, "username": "alice", "email": "alice@example.com", "email_verified": false, "name": "Alice"}, + "expires_at": "2026-10-03T09:00:00Z" +} +``` + +錯誤回應皆為 `{"error": "..."}`: + +| 狀態碼 | 情境 | +| --- | --- | +| `400` | JSON 無法解析、`username`/`password` 缺漏 | +| `401` | 帳號不存在或密碼錯誤(訊息一致,不洩漏帳號是否存在;查無帳號時仍執行等時的 argon2 比對) | +| `415` | `Content-Type` 非 `application/json` | + +### 登出 API + +`POST /logout` 刪除資料庫中的 Session 並以 `Max-Age=0` 清除瀏覽器 Cookie。與 `/login` 相同依 `Content-Type` 分流,登出為**冪等**操作——查無有效 Session 亦視為成功: + +```bash +curl -i -X POST http://localhost:8080/logout \ + -H 'Content-Type: application/json' \ + -b 'alterminal_session=' +# => 204 No Content,Set-Cookie 以 Max-Age=0 清除 alterminal_session +``` + +- **JSON 流程**:成功回 `204`,無回應內容 +- **表單流程**(瀏覽器):需通過 double-submit CSRF 驗證(與登入表單同一機制),成功後 `303` 導向 `/login`(PRG);CSRF 不符回 `403` 並重繪目前狀態頁 +- 資料庫刪除失敗僅記錄,仍完成 Cookie 清除(Session 最遲於效期到期自動失效) +- 跨應用程式單一登出(Front-/Back-Channel Logout)與 RP-Initiated Logout(`GET /logout`,含 OIDC 參數驗證)列於 Roadmap + +### 登入頁面 + +瀏覽器開啟 即為 HTML 登入頁(Go `html/template`,模板位於 `templates/`,以 `go:embed` 打進執行檔): + +- 表單提交(`application/x-www-form-urlencoded`)與 JSON API 共用同一套帳密驗證與 Session 流程 +- 表單附 double-submit CSRF token(Cookie 與隱藏欄位比對),不符時回 `403` 並重新輸出表單 +- 帳密錯誤時重繪表單(`401`),保留帳號輸入並顯示錯誤訊息 +- 登入成功採 PRG 模式:`303` 導向 `/login`,持有效 Session 時該頁顯示帳號資訊(帳號、Email、Session 到期時間),並套用側邊導覽版面(`templates/layout.html`,之後的頁面可重用):桌面版側欄固定展開,手機版以純 CSS checkbox 開合(CSP 不允許 JavaScript),側欄頁尾為使用者資訊與「登出」按鈕(`POST /logout`,同樣受 CSRF 驗證保護) + +### 前端樣式(Tailwind CSS) + +頁面樣式使用 Tailwind CSS v4。模板(`templates/*.html`)直接寫 utility class,樣式進入點在 `assets/css/input.css`(含 `@theme` 自訂品牌色與中文字型),建置輸出 `assets/css/main.css` 已提交並以 `go:embed` 內嵌,經 `/static/css/main.css` 提供——**一般開發與部署不需 Node**。 + +調整樣式後重新建置: + +```bash +tools/tailwindcss -i assets/css/input.css -o assets/css/main.css --minify +``` + +Tailwind 為官方 [standalone CLI](https://tailwindlabs.github.io/tailwindcss/)(版本見 `tools/tailwindcss-version.txt`,`tools/` 不納入版本控制),首次取得方式: + +```bash +mkdir -p tools +curl -sL -o tools/tailwindcss \ + https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64 +chmod +x tools/tailwindcss +``` + +### 環境變數 + +| 變數 | 說明 | 預設值 | 狀態 | +| --- | --- | --- | --- | +| `DB_HOST` | PostgreSQL 位址 | `localhost` | ✅ | +| `DB_PORT` | PostgreSQL 埠號 | `5432` | ✅ | +| `DB_USER` | 資料庫使用者 | `postgres` | ✅ | +| `DB_PASSWORD` | 資料庫密碼 | `postgres` | ✅ | +| `DB_NAME` | 資料庫名稱 | `alterminal` | ✅ | +| `PORT` | 服務監聽埠號 | `8080` | 🚧 規劃中 | +| `ISSUER` | OIDC Issuer URL(對外完整網址,須含 scheme,不可帶尾斜線) | `http://localhost:8080` | 🚧 規劃中 | + +## 授權流程(Authorization Code Flow + PKCE) + +```mermaid +sequenceDiagram + participant U as 使用者(瀏覽器) + participant RP as 應用程式(RP) + participant OP as alterminal(OP) + + RP->>U: 重新導向至 /authorize(附 client_id、redirect_uri、scope、code_challenge) + U->>OP: GET /authorize + OP->>U: 未登入 → 導向 /login + U->>OP: 輸入帳密,完成驗證並建立 Session + OP->>U: 確認授權後,攜帶 code 重新導向回 redirect_uri + U->>RP: 回呼 redirect_uri?code=... + RP->>OP: POST /token(附 code、client_id、client_secret、code_verifier) + OP-->>RP: Access Token、ID Token(JWT / RS256)、(可選)Refresh Token + RP->>OP: GET /userinfo(附 Access Token) + OP-->>RP: 使用者 Claims + RP-->>U: 登入完成 +``` + +之後其他 RP 發起授權時,因瀏覽器 Session 仍有效,使用者無須再次輸入帳密,即為單一登入(SSO)。 + +## 資料模型 + +Access Token 採用自包含的 JWT,不落庫儲存;其餘狀態儲存於 PostgreSQL(GORM 自動遷移)。 + +| 資料表 | 說明 | 狀態 | +| --- | --- | --- | +| `users` | 使用者帳號(帳號、Email、密碼雜湊) | ✅ 已完成(含 argon2id 密碼雜湊) | +| `applications` | 已註冊的 RP 應用程式(client_id、client secret 雜湊、redirect URIs、grant types、scope、confidential/public) | 🚧 模型與管理頁已完成(`Application`:argon2id secret 雜湊、redirect URI 格式驗證;`/admin/applications` 註冊/輪替/刪除),註冊 API 規劃中 | +| `sessions` | 使用者瀏覽器 Session(SSO 核心,HttpOnly Cookie,效期 24 小時) | ✅ 已完成 | +| `authorization_codes` | 授權碼(一次性、短時效、綁定 PKCE challenge) | 🚧 規劃中 | +| `refresh_tokens` | Refresh Token(支援輪替與撤銷偵測) | 🚧 規劃中 | +| `signing_keys` | RSA 簽章金鑰(供 JWKS 輪替) | 🚧 模型與管理頁已完成(`internal/jwk`:PKCS#8 儲存、RFC 7638 kid、RFC 7517 JWK/JWKS 公開形式;`/admin/keys` 產生/退休),JWKS 端點與輪替排程規劃中 | + +## Roadmap + +- [x] 專案骨架:chi 路由、GORM + PostgreSQL 連線、`/health` 健康檢查 +- [x] 使用者系統:註冊、登入/登出、密碼重設(撤銷 Session)、密碼雜湊(argon2id)、瀏覽器 Session +- [ ] Client 管理:RP 註冊 API(管理頁 `/admin/applications` 已完成:註冊、client_secret 發配與輪替、刪除,僅 admin) +- [ ] 金鑰管理:RSA 金鑰產生、`/.well-known/jwks.json`、金鑰輪替 +- [ ] OIDC Discovery:`/.well-known/openid-configuration` +- [ ] Authorization Code Flow + PKCE(`/authorize`) +- [ ] Token 端點:Access Token(JWT)、ID Token、Refresh Token 簽發與驗證 +- [ ] UserInfo 端點(`/userinfo`) +- [ ] Refresh Token 輪替與撤銷 +- [ ] RP-Initiated Logout(`/logout`) +- [ ] Client Credentials Grant +- [ ] Front-Channel / Back-Channel Logout(跨 RP 單一登出) +- [ ] 管理 API 與簡易管理介面 + +## 專案結構(目標) + +``` +alterminal/ +├── main.go # 程式進入點、路由裝配 +├── db.go # 資料庫連線與自動遷移 +├── user.go # 使用者帳號(Account)模型與 argon2id 密碼雜湊 +├── createaccount.go # create-account CLI 子指令(建立使用者帳號) +├── updatepassword.go # update-password CLI 子指令(重設密碼並撤銷 Session) +├── login.go # POST /login(JSON API 與表單共用流程) +├── loginpage.go # GET /login 登入頁(html/template + CSRF) +├── logout.go # POST /logout(Session 刪除與 Cookie 清除) +├── adminkeys.go # /admin/keys 金鑰管理頁(僅 admin:產生/退休) +├── notfound.go # 自訂 404 頁(chi NotFound handler) +├── session.go # 瀏覽器 Session 模型與管理 +├── static.go # /static/ 靜態檔服務(go:embed) +├── templates/ # HTML 模板(Tailwind utility class) +├── assets/css/ # Tailwind 進入點(input.css)與建置輸出(main.css) +└── internal/ + ├── auth/ # 使用者認證、Session、密碼雜湊 + ├── client/ # RP Client 註冊與驗證 + ├── oidc/ # OIDC 核心:authorize / token / userinfo / logout + ├── jwk/ # 簽章金鑰與 JWKS + └── httpx/ # 共用 HTTP 工具(錯誤回應、middleware) +``` diff --git a/adminapplications.go b/adminapplications.go new file mode 100644 index 0000000..58f655c --- /dev/null +++ b/adminapplications.go @@ -0,0 +1,346 @@ +package main + +import ( + "errors" + "log" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "gorm.io/gorm" +) + +// adminApplicationRow 為應用程式管理頁表格的單列視圖。 +type adminApplicationRow struct { + ID uint + ClientID string + Name string + Type string // confidential / public + RedirectURIs string // 以換行分隔(模板以 whitespace-pre-line 呈現) + GrantTypes string // 以頓號分隔 + Scope string + CreatedAt string // 本地時間顯示 + Confidential bool // 機密式才可輪替 client secret +} + +// newAdminApplicationRows 將應用程式模型轉為表格視圖。純函式,便於單元測試。 +func newAdminApplicationRows(apps []Application) []adminApplicationRow { + rows := make([]adminApplicationRow, 0, len(apps)) + for _, a := range apps { + grants := make([]string, len(a.GrantTypes)) + for i, g := range a.GrantTypes { + grants[i] = string(g) + } + rows = append(rows, adminApplicationRow{ + ID: a.ID, + ClientID: a.ClientID, + Name: a.Name, + Type: string(a.Type), + RedirectURIs: strings.Join(a.RedirectURIs, "\n"), + GrantTypes: strings.Join(grants, "、"), + Scope: a.Scope, + CreatedAt: a.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"), + Confidential: !a.IsPublic(), + }) + } + return rows +} + +// applicationForm 為註冊表單的視圖狀態:驗證失敗重繪時保留使用者輸入 +// (含核取方塊),初次顯示(GET)採 newApplicationForm 的預設值。 +type applicationForm struct { + Name string + Type string // confidential / public + RedirectURIs string // textarea 原始內容(每行一個 URI) + Scope string // 留空時使用預設 + GrantAuthCode bool + GrantRefresh bool + GrantClientCred bool +} + +// newApplicationForm 回傳註冊表單的預設狀態:機密式、勾選授權碼流程。 +func newApplicationForm() applicationForm { + return applicationForm{Type: string(ClientConfidential), GrantAuthCode: true} +} + +// applicationFormFromPost 由已解析的表單還原視圖狀態。類型限選單兩值, +// 其餘一律回復為 confidential;grant type 僅接受已知值。 +func applicationFormFromPost(r *http.Request) applicationForm { + f := applicationForm{ + Name: r.PostFormValue("name"), + Type: r.PostFormValue("type"), + RedirectURIs: r.PostFormValue("redirect_uris"), + Scope: r.PostFormValue("scope"), + } + if f.Type != string(ClientPublic) { + f.Type = string(ClientConfidential) + } + for _, g := range r.PostForm["grant_types"] { + switch GrantType(g) { + case GrantAuthorizationCode: + f.GrantAuthCode = true + case GrantRefreshToken: + f.GrantRefresh = true + case GrantClientCredentials: + f.GrantClientCred = true + } + } + return f +} + +// redirectURIList 解析 textarea 內容:每行一個 URI,去首尾空白(含瀏覽器 +// 送出的 \r)後略過空行。 +func (f applicationForm) redirectURIList() []string { + var uris []string + for _, line := range strings.Split(f.RedirectURIs, "\n") { + if u := strings.TrimSpace(line); u != "" { + uris = append(uris, u) + } + } + return uris +} + +// grantTypeList 依核取狀態列出要啟用的 grant type。 +func (f applicationForm) grantTypeList() []GrantType { + var gts []GrantType + if f.GrantAuthCode { + gts = append(gts, GrantAuthorizationCode) + } + if f.GrantRefresh { + gts = append(gts, GrantRefreshToken) + } + if f.GrantClientCred { + gts = append(gts, GrantClientCredentials) + } + return gts +} + +// secretPanel 為註冊與輪替成功的一次性成果面板:直接渲染於 POST 回應 +// (資料庫僅存雜湊,明文無法重現,故不採 PRG)。Rotated 區分輪替與註冊 +// 的標題文案;公開式 Client 註冊時 Public 為 true 且 Secret 為空,面板 +// 改顯示 PKCE 提示而非明文。 +type secretPanel struct { + Name string + ClientID string + Secret string // 明文,僅顯示這一次;公開式註冊時為空 + Rotated bool // true=client secret 輪替;false=應用程式註冊 + Public bool // 公開式 Client(不持有 secret) +} + +// adminApplicationsPageData 為應用程式管理頁(列表)的模板資料。 +type adminApplicationsPageData struct { + Error string + Username string // 側欄頁尾使用者資訊 + Email string + CSRF string // 輪替/刪除表單的 CSRF token + Apps []adminApplicationRow + Secret *secretPanel // 非空時顯示一次性明文 client secret 面板(輪替) +} + +// adminApplicationNewPageData 為註冊新應用程式頁的模板資料。 +type adminApplicationNewPageData struct { + Error string + Username string // 側欄頁尾使用者資訊 + Email string + CSRF string // 註冊表單的 CSRF token + Form applicationForm // 表單狀態(重繪時保留輸入) + Secret *secretPanel // 非空時顯示一次性成果面板(註冊) +} + +// adminApplicationsPageHandler 處理 GET /admin/applications:列出已註冊 +// 應用程式,僅管理員可存取;註冊表單獨立於 /admin/applications/new。 +func adminApplicationsPageHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, ok := requireAdmin(db, w, r) + if !ok { + return + } + renderAdminApplicationsPage(w, r, db, http.StatusOK, s, "", nil) + } +} + +// adminApplicationNewPageHandler 處理 GET /admin/applications/new:顯示 +// 註冊表單(預設值),僅管理員可存取。 +func adminApplicationNewPageHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, ok := requireAdmin(db, w, r) + if !ok { + return + } + renderAdminApplicationNewPage(w, r, http.StatusOK, s, "", newApplicationForm(), nil) + } +} + +// renderAdminApplicationNewPage 輸出註冊頁;errMsg 非空時以指定 status +// 重繪表單並顯示錯誤(此時 form 保留使用者輸入);secret 非空時顯示一次 +// 性成果面板(機密式含明文 client secret)。頁面不查詢列表,不需資料庫。 +func renderAdminApplicationNewPage(w http.ResponseWriter, r *http.Request, status int, s *Session, errMsg string, form applicationForm, secret *secretPanel) { + token, err := newCSRFToken(w, r) + if err != nil { + log.Printf("admin applications: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + renderHTML(w, status, adminApplicationNewTmpl, adminApplicationNewPageData{ + Error: errMsg, + Username: s.User.Username, + Email: s.User.Email, + CSRF: token, + Form: form, + Secret: secret, + }) +} + +// renderAdminApplicationsPage 查詢應用程式並輸出管理列表頁;errMsg 非空時 +// 以指定 status 重繪頁面並顯示錯誤,secret 非空時顯示一次性明文面板 +// (輪替)。s 供側欄頁尾顯示使用者資訊。新註冊的排前。 +func renderAdminApplicationsPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *Session, errMsg string, secret *secretPanel) { + var apps []Application + if err := db.Order("created_at DESC").Find(&apps).Error; err != nil { + log.Printf("admin applications: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + token, err := newCSRFToken(w, r) + if err != nil { + log.Printf("csrf token: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + renderHTML(w, status, adminApplicationsTmpl, adminApplicationsPageData{ + Error: errMsg, + Username: s.User.Username, + Email: s.User.Email, + CSRF: token, + Apps: newAdminApplicationRows(apps), + Secret: secret, + }) +} + +// adminApplicationsCreateHandler 處理 POST /admin/applications/new:驗證並 +// 儲存新註冊。成功時直接渲染註冊頁(200)顯示 client_id 與明文 client +// secret——secret 只在本次回應出現,重新整理後即無法再查看,故不適用 PRG。 +func adminApplicationsCreateHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, ok := requireAdmin(db, w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + renderAdminApplicationNewPage(w, r, http.StatusBadRequest, s, "無法解析表單內容", newApplicationForm(), nil) + return + } + if !verifyCSRF(r) { + renderAdminApplicationNewPage(w, r, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", newApplicationForm(), nil) + return + } + form := applicationFormFromPost(r) + app, secret, err := NewApplication(form.Name, ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope) + if err != nil { + renderAdminApplicationNewPage(w, r, http.StatusBadRequest, s, err.Error(), form, nil) + return + } + if err := db.Create(app).Error; err != nil { + log.Printf("admin applications: %v", err) + renderAdminApplicationNewPage(w, r, http.StatusInternalServerError, s, "應用程式儲存失敗,請稍後再試", form, nil) + return + } + // 公開式 Client 不發配 secret,面板改以 PKCE 提示。 + panel := &secretPanel{Name: app.Name, ClientID: app.ClientID, Secret: secret, Public: secret == ""} + renderAdminApplicationNewPage(w, r, http.StatusOK, s, "", newApplicationForm(), panel) + } +} + +// adminApplicationsRotateSecretHandler 處理 POST /admin/applications/{id}/secret: +// 輪替機密式 Client 的 client secret(舊 secret 立即失效),並同面板直接 +// 渲染一次性明文;公開式 Client 不持有 secret,回 409。 +func adminApplicationsRotateSecretHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, ok := requireAdmin(db, w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + renderAdminApplicationsPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容", nil) + return + } + if !verifyCSRF(r) { + renderAdminApplicationsPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", nil) + return + } + app, ok := applicationByID(w, r, db, s) + if !ok { + return + } + if app.IsPublic() { + renderAdminApplicationsPage(w, r, db, http.StatusConflict, s, "公開式 Client 不持有 client secret,無法輪替", nil) + return + } + secret, err := app.GenerateSecret() + if err != nil { + log.Printf("admin applications: %v", err) + renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤", nil) + return + } + if err := db.Model(app).Update("client_secret_hash", app.ClientSecretHash).Error; err != nil { + log.Printf("admin applications: %v", err) + renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "client secret 更新失敗,請稍後再試", nil) + return + } + renderAdminApplicationsPage(w, r, db, http.StatusOK, s, "", + &secretPanel{Name: app.Name, ClientID: app.ClientID, Secret: secret, Rotated: true}) + } +} + +// adminApplicationsDeleteHandler 處理 POST /admin/applications/{id}/delete: +// 刪除應用程式註冊(連同其 client_id/secret 一併失效),成功後 PRG 導回 +// 管理頁。 +func adminApplicationsDeleteHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, ok := requireAdmin(db, w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + renderAdminApplicationsPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容", nil) + return + } + if !verifyCSRF(r) { + renderAdminApplicationsPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", nil) + return + } + app, ok := applicationByID(w, r, db, s) + if !ok { + return + } + if err := db.Delete(app).Error; err != nil { + log.Printf("admin applications: %v", err) + renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "應用程式刪除失敗,請稍後再試", nil) + return + } + http.Redirect(w, r, "/admin/applications", http.StatusSeeOther) + } +} + +// applicationByID 依路徑參數 {id} 查詢應用程式;id 格式錯誤或查無資料時 +// 以 404 重繪管理頁(訊息「應用程式不存在」),其他錯誤以 500 重繪。 +// 回傳應用程式與是否繼續處理。 +func applicationByID(w http.ResponseWriter, r *http.Request, db *gorm.DB, s *Session) (*Application, bool) { + id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + if err != nil { + renderAdminApplicationsPage(w, r, db, http.StatusNotFound, s, "應用程式不存在", nil) + return nil, false + } + var a Application + switch err := db.First(&a, id).Error; { + case errors.Is(err, gorm.ErrRecordNotFound): + renderAdminApplicationsPage(w, r, db, http.StatusNotFound, s, "應用程式不存在", nil) + return nil, false + case err != nil: + log.Printf("admin applications: %v", err) + renderAdminApplicationsPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤", nil) + return nil, false + } + return &a, true +} diff --git a/adminapplications_test.go b/adminapplications_test.go new file mode 100644 index 0000000..b8e9027 --- /dev/null +++ b/adminapplications_test.go @@ -0,0 +1,554 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "regexp" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" +) + +// 未帶 Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫, +// 因此 handler 可傳入 nil db。 +func TestAdminApplicationsHandlersRequireLogin(t *testing.T) { + handlers := map[string]http.HandlerFunc{ + "GET 列表": adminApplicationsPageHandler(nil), + "GET 註冊頁": adminApplicationNewPageHandler(nil), + "POST 註冊": adminApplicationsCreateHandler(nil), + "POST 輪替": adminApplicationsRotateSecretHandler(nil), + "POST 刪除": adminApplicationsDeleteHandler(nil), + } + for name, h := range handlers { + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/admin/applications", nil)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "/login" { + t.Fatalf("Location = %q, want /login", loc) + } + }) + } +} + +func TestNewAdminApplicationRows(t *testing.T) { + apps := []Application{ + { + ID: 1, ClientID: "cid-a", Name: "官方網站", Type: ClientConfidential, + RedirectURIs: RedirectURIs{"https://a.example.com/cb", "https://a.example.com/alt"}, + GrantTypes: GrantTypes{GrantAuthorizationCode, GrantRefreshToken}, + Scope: "openid offline_access", CreatedAt: time.Now(), + }, + { + ID: 2, ClientID: "cid-b", Name: "行動 App", Type: ClientPublic, + RedirectURIs: RedirectURIs{"com.example.app:/cb"}, + GrantTypes: GrantTypes{GrantAuthorizationCode}, + CreatedAt: time.Now(), + }, + } + rows := newAdminApplicationRows(apps) + if len(rows) != 2 { + t.Fatalf("rows = %d 筆, want 2", len(rows)) + } + if rows[0].RedirectURIs != "https://a.example.com/cb\nhttps://a.example.com/alt" { + t.Errorf("RedirectURIs 應以換行分隔,得到 %q", rows[0].RedirectURIs) + } + if rows[0].GrantTypes != "authorization_code、refresh_token" { + t.Errorf("GrantTypes 應以頓號分隔,得到 %q", rows[0].GrantTypes) + } + if rows[0].CreatedAt == "" { + t.Error("CreatedAt 應格式化為本地時間字串") + } + if !rows[0].Confidential { + t.Error("機密式應標記 Confidential(顯示輪替表單)") + } + if rows[1].Confidential || rows[1].Type != "public" { + t.Errorf("公開式 row 不應標記 Confidential,Type = %q", rows[1].Type) + } + if rows[1].GrantTypes != "authorization_code" { + t.Errorf("單一 grant type 不應有分隔符,得到 %q", rows[1].GrantTypes) + } +} + +func TestNewApplicationFormDefaults(t *testing.T) { + f := newApplicationForm() + if f.Type != string(ClientConfidential) { + t.Errorf("預設類型應為 confidential,得到 %q", f.Type) + } + if !f.GrantAuthCode || f.GrantRefresh || f.GrantClientCred { + t.Error("預設應僅勾選 authorization_code") + } +} + +func TestApplicationFormFromPost(t *testing.T) { + vals := url.Values{ + "name": {"示範應用"}, + "type": {"public"}, + "redirect_uris": {"https://a.example.com/cb\r\ncom.example.app:/cb\r\n\r\n https://b.example.com/cb \n"}, + "grant_types": {"refresh_token"}, + "scope": {"openid"}, + } + vals.Add("grant_types", "client_credentials") + vals.Add("grant_types", "implicit") // 未知值應略過 + req := httptest.NewRequest(http.MethodPost, "/admin/applications", strings.NewReader(vals.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + f := applicationFormFromPost(req) + if f.Name != "示範應用" || f.Type != "public" || f.Scope != "openid" { + t.Errorf("基本欄位還原不符:%+v", f) + } + if !f.GrantRefresh || !f.GrantClientCred || f.GrantAuthCode { + t.Errorf("核取狀態還原不符:%+v", f) + } + wantURIs := []string{"https://a.example.com/cb", "com.example.app:/cb", "https://b.example.com/cb"} + if got := f.redirectURIList(); !reflect.DeepEqual(got, wantURIs) { + t.Errorf("redirectURIList = %v, want %v(每行一個、去空白、略過空行)", got, wantURIs) + } + wantGrants := []GrantType{GrantRefreshToken, GrantClientCredentials} + if got := f.grantTypeList(); !reflect.DeepEqual(got, wantGrants) { + t.Errorf("grantTypeList = %v, want %v", got, wantGrants) + } +} + +// 類型選單僅兩值,偽造的值一律回復為 confidential。 +func TestApplicationFormFromPostInvalidType(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/admin/applications", + strings.NewReader("name=A&type=webapp")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if f := applicationFormFromPost(req); f.Type != string(ClientConfidential) { + t.Errorf("非法類型應回復 confidential,得到 %q", f.Type) + } +} + +// renderAdminApplicationsPage 需要資料庫,模板輸出直接以假資料渲染測試。 +func TestAdminApplicationsTemplate(t *testing.T) { + data := adminApplicationsPageData{ + Username: "alice", Email: "alice@example.com", CSRF: "token-A", + Apps: []adminApplicationRow{ + {ID: 9, ClientID: "cid-conf", Name: "官方網站", Type: "confidential", + RedirectURIs: "https://app.example.com/cb", GrantTypes: "authorization_code、refresh_token", + Scope: "openid offline_access", CreatedAt: "2026-10-02 12:00:00 +08:00", Confidential: true}, + {ID: 5, ClientID: "cid-pub", Name: "行動 App", Type: "public", + RedirectURIs: "com.example.app:/cb", GrantTypes: "authorization_code", + Scope: "openid", CreatedAt: "2026-10-01 12:00:00 +08:00"}, + }, + } + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, adminApplicationsTmpl, data) + body := rec.Body.String() + for _, want := range []string{ + "應用程式管理", // 標題 + `href="/admin/applications/new"`, // 註冊新應用程式按鈕(獨立頁) + `value="token-A"`, // CSRF 隱藏欄位 + `action="/admin/applications/9/secret"`, // 機密式的輪替表單 + `action="/admin/applications/9/delete"`, // 刪除表單 + `action="/admin/applications/5/delete"`, + "cid-conf", "cid-pub", // client_id 欄 + "機密式", "公開式", // 類型徽章 + `href="/admin/applications" aria-current="page"`, // 導覽(目前頁) + `href="/admin/keys"`, // 導覽(金鑰管理) + `href="/login"`, // 導覽(帳號資訊) + `action="/logout"`, // 側欄頁尾登出表單(版面預設) + "alice@example.com", + } { + if !strings.Contains(body, want) { + t.Errorf("應用程式管理頁缺少 %s", want) + } + } + for _, absent := range []string{ + "/admin/applications/5/secret", // 公開式無 secret,不應出現輪替表單 + "尚無應用程式", + "只顯示這一次", // 未輪替 secret 時不出現明文面板 + `name="redirect_uris"`, // 註冊表單已獨立於 /admin/applications/new + `action="/admin/applications"`, // 註冊不再 POST 到列表頁 + } { + if strings.Contains(body, absent) { + t.Errorf("頁面不應出現 %s", absent) + } + } +} + +// 註冊頁模板:表單欄位與送出目標,導覽同管理頁。 +func TestAdminApplicationNewTemplate(t *testing.T) { + data := adminApplicationNewPageData{ + Username: "alice", Email: "alice@example.com", CSRF: "token-N", + Form: newApplicationForm(), + } + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, adminApplicationNewTmpl, data) + body := rec.Body.String() + for _, want := range []string{ + "註冊新應用程式", // 標題 + `action="/admin/applications/new"`, // 表單送回本頁 + `value="token-N"`, // CSRF 隱藏欄位 + `name="name"`, + `name="redirect_uris"`, + `value="authorization_code"`, // grant type 核取方塊(預設勾選) + `checked`, // 預設勾選狀態 + `href="/admin/applications" aria-current="page"`, // 導覽(目前頁同管理頁) + `href="/admin/keys"`, + `href="/login"`, + `action="/logout"`, + "alice@example.com", + } { + if !strings.Contains(body, want) { + t.Errorf("註冊頁缺少 %s", want) + } + } + if strings.Contains(body, "只顯示這一次") { + t.Error("未註冊成功時不應出現明文面板") + } +} + +// 註冊機密式成功的一次性明文 client secret 面板(渲染於註冊頁)。 +func TestAdminApplicationNewTemplateSecretPanel(t *testing.T) { + data := adminApplicationNewPageData{ + Username: "alice", Email: "alice@example.com", CSRF: "token-N", + Form: newApplicationForm(), + Secret: &secretPanel{Name: "官方網站", ClientID: "cid-conf", Secret: "plain-secret-value"}, + } + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, adminApplicationNewTmpl, data) + body := rec.Body.String() + for _, want := range []string{"已註冊", "只顯示這一次", "cid-conf", "plain-secret-value"} { + if !strings.Contains(body, want) { + t.Errorf("secret 面板缺少 %s", want) + } + } + if strings.Contains(body, "已輪替") { + t.Error("註冊面板不應出現輪替文案") + } +} + +// 註冊公開式成功的面板:無明文 secret,改顯示 PKCE 提示。 +func TestAdminApplicationNewTemplatePublicPanel(t *testing.T) { + data := adminApplicationNewPageData{ + Username: "alice", Email: "alice@example.com", CSRF: "token-N", + Form: newApplicationForm(), + Secret: &secretPanel{Name: "行動 App", ClientID: "cid-pub", Public: true}, + } + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, adminApplicationNewTmpl, data) + body := rec.Body.String() + for _, want := range []string{"行動 App 已註冊", "PKCE", "cid-pub"} { + if !strings.Contains(body, want) { + t.Errorf("公開式面板缺少 %s", want) + } + } + for _, absent := range []string{"只顯示這一次", "client_secret"} { + if strings.Contains(body, absent) { + t.Errorf("公開式面板不應出現 %s", absent) + } + } +} + +// 輪替成功的一次性明文面板(渲染於管理列表頁)。 +func TestAdminApplicationsTemplateRotatePanel(t *testing.T) { + data := adminApplicationsPageData{ + Username: "alice", Email: "alice@example.com", CSRF: "token-A", + Secret: &secretPanel{Name: "官方網站", ClientID: "cid-conf", Secret: "plain-secret-value", Rotated: true}, + } + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, adminApplicationsTmpl, data) + body := rec.Body.String() + for _, want := range []string{"已輪替", "只顯示這一次", "cid-conf", "plain-secret-value"} { + if !strings.Contains(body, want) { + t.Errorf("輪替面板缺少 %s", want) + } + } +} + +// 無應用程式時顯示空狀態提示(註冊表單已獨立,不再內嵌於列表頁)。 +func TestAdminApplicationsTemplateEmpty(t *testing.T) { + data := adminApplicationsPageData{ + Username: "alice", Email: "alice@example.com", CSRF: "token-A", + } + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, adminApplicationsTmpl, data) + body := rec.Body.String() + if !strings.Contains(body, "尚無應用程式") { + t.Error("應顯示空狀態提示") + } + if !strings.Contains(body, `href="/admin/applications/new"`) { + t.Error("空狀態仍應提供前往註冊頁的按鈕") + } + if strings.Contains(body, `name="redirect_uris"`) { + t.Error("列表頁不應內嵌註冊表單") + } +} + +// --- 整合測試:需要本機 PostgreSQL,連不上時跳過 --- + +// secretInBody 從頁面抽出一次性明文 client secret:面板以 /
包裹 +// 43 字元 base64url(CSRF token 在屬性值內、client_id 僅 22 字元,皆不符)。 +var secretInBody = regexp.MustCompile(`>([A-Za-z0-9_-]{43})<`) + +func TestAdminApplicationsIntegration(t *testing.T) { + db := newTestDB(t) + + admin := &User{Username: "appadmin", Email: "appadmin@example.com", Role: RoleAdmin} + if err := admin.SetPassword("sup3r-secret"); err != nil { + t.Fatal(err) + } + if err := db.Create(admin).Error; err != nil { + t.Fatal(err) + } + member := &User{Username: "appuser", Email: "appuser@example.com", Role: RoleUser} + if err := member.SetPassword("sup3r-secret"); err != nil { + t.Fatal(err) + } + if err := db.Create(member).Error; err != nil { + t.Fatal(err) + } + adminSess, err := createSession(db, admin.ID) + if err != nil { + t.Fatal(err) + } + memberSess, err := createSession(db, member.ID) + if err != nil { + t.Fatal(err) + } + + r := chi.NewRouter() + r.Get("/admin/applications", adminApplicationsPageHandler(db)) + r.Get("/admin/applications/new", adminApplicationNewPageHandler(db)) + r.Post("/admin/applications/new", adminApplicationsCreateHandler(db)) + r.Post("/admin/applications/{id}/secret", adminApplicationsRotateSecretHandler(db)) + r.Post("/admin/applications/{id}/delete", adminApplicationsDeleteHandler(db)) + + appCount := func(t *testing.T) int64 { + t.Helper() + var n int64 + if err := db.Model(&Application{}).Count(&n).Error; err != nil { + t.Fatal(err) + } + return n + } + + t.Run("非 admin 存取回 403", func(t *testing.T) { + for _, path := range []string{"/admin/applications", "/admin/applications/new"} { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminGet(path, memberSess)) + if rec.Code != http.StatusForbidden { + t.Fatalf("GET %s status = %d, want 403", path, rec.Code) + } + } + }) + + t.Run("admin 首次檢視為空狀態", func(t *testing.T) { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminGet("/admin/applications", adminSess)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "尚無應用程式") { + t.Fatalf("應顯示空狀態提示:%s", rec.Body.String()) + } + }) + + t.Run("admin 檢視註冊頁含表單", func(t *testing.T) { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminGet("/admin/applications/new", adminSess)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, `action="/admin/applications/new"`) || !strings.Contains(body, `name="redirect_uris"`) { + t.Fatal("註冊頁應含送回本頁的表單") + } + }) + + // currentCSRF 以一次 GET 取得最新的 CSRF Cookie 與頁面 token(每次 + // 渲染都會輪替);註冊表單位於 /admin/applications/new。 + currentCSRF := func(t *testing.T) *http.Cookie { + t.Helper() + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminGet("/admin/applications/new", adminSess)) + if rec.Code != http.StatusOK { + t.Fatalf("GET /admin/applications/new status = %d", rec.Code) + } + return csrfCookieOf(t, rec) + } + + postForm := func(t *testing.T, path string, vals url.Values) *httptest.ResponseRecorder { + t.Helper() + cookie := currentCSRF(t) + vals.Set("csrf_token", cookie.Value) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminPost(path, vals.Encode(), adminSess, cookie)) + return rec + } + + t.Run("CSRF 不符回 403", func(t *testing.T) { + cookie := currentCSRF(t) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminPost("/admin/applications/new", "csrf_token=wrong", adminSess, cookie)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if !strings.Contains(rec.Body.String(), "表單驗證失敗") { + t.Fatal("應顯示 CSRF 錯誤訊息") + } + }) + + var secret1 string + t.Run("註冊機密式應用程式顯示一次性 secret", func(t *testing.T) { + rec := postForm(t, "/admin/applications/new", url.Values{ + "name": {"官方網站"}, + "type": {"confidential"}, + "redirect_uris": {"https://app.example.com/oidc/callback"}, + "grant_types": {"authorization_code", "refresh_token"}, + "scope": {"openid offline_access"}, + }) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "只顯示這一次") { + t.Fatal("應顯示一次性 secret 面板") + } + m := secretInBody.FindStringSubmatch(body) + if m == nil { + t.Fatal("頁面應包含 43 字元明文 client secret") + } + secret1 = m[1] + + var app Application + if err := db.First(&app).Error; err != nil { + t.Fatal(err) + } + if app.Name != "官方網站" || app.IsPublic() || !app.CheckSecret(secret1) { + t.Errorf("儲存的應用程式與表單輸入不符或 secret 驗證失敗:%+v", app) + } + if !app.GrantTypes.Contains(GrantRefreshToken) { + t.Errorf("應啟用 refresh_token,得到 %v", app.GrantTypes) + } + if !strings.Contains(body, app.ClientID) { + t.Error("頁面應顯示新註冊的 client_id") + } + if n := appCount(t); n != 1 { + t.Fatalf("資料庫應用程式數 = %d, want 1", n) + } + }) + + t.Run("註冊驗證失敗回 400 並保留輸入", func(t *testing.T) { + rec := postForm(t, "/admin/applications/new", url.Values{ + "name": {"後台系統"}, + "type": {"confidential"}, + "redirect_uris": {"http://app.example.com/cb"}, // 非 loopback 的 http + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "loopback") { + t.Fatal("應顯示 redirect URI 驗證錯誤") + } + if !strings.Contains(body, `value="後台系統"`) { + t.Fatal("重繪時應保留已輸入的名稱") + } + if n := appCount(t); n != 1 { + t.Fatalf("驗證失敗不應寫入,資料庫應用程式數 = %d, want 1", n) + } + }) + + var publicApp Application + t.Run("註冊公開式應用程式顯示 PKCE 面板", func(t *testing.T) { + rec := postForm(t, "/admin/applications/new", url.Values{ + "name": {"行動 App"}, + "type": {"public"}, + "redirect_uris": {"com.example.app:/cb"}, + }) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.Contains(body, "行動 App 已註冊") || !strings.Contains(body, "PKCE") { + t.Fatal("公開式註冊成功應顯示 PKCE 面板") + } + if strings.Contains(body, "只顯示這一次") { + t.Fatal("公開式無 client secret,不應顯示明文警告") + } + if err := db.Where("type = ?", ClientPublic).First(&publicApp).Error; err != nil { + t.Fatal(err) + } + if !strings.Contains(body, publicApp.ClientID) { + t.Error("面板應顯示新註冊的 client_id") + } + if n := appCount(t); n != 2 { + t.Fatalf("資料庫應用程式數 = %d, want 2", n) + } + }) + + t.Run("輪替機密式 secret", func(t *testing.T) { + var conf Application + if err := db.Where("type = ?", ClientConfidential).First(&conf).Error; err != nil { + t.Fatal(err) + } + rec := postForm(t, fmt.Sprintf("/admin/applications/%d/secret", conf.ID), url.Values{}) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + m := secretInBody.FindStringSubmatch(rec.Body.String()) + if m == nil { + t.Fatal("輪替後應顯示新的明文 client secret") + } + + var reloaded Application + if err := db.First(&reloaded, conf.ID).Error; err != nil { + t.Fatal(err) + } + if reloaded.CheckSecret(secret1) { + t.Error("輪替後舊 client secret 應失效") + } + if !reloaded.CheckSecret(m[1]) { + t.Error("新 client secret 應可驗證") + } + }) + + t.Run("輪替公開式回 409", func(t *testing.T) { + rec := postForm(t, fmt.Sprintf("/admin/applications/%d/secret", publicApp.ID), url.Values{}) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "公開式 Client 不持有 client secret") { + t.Fatal("應顯示公開式不可輪替的訊息") + } + }) + + t.Run("刪除應用程式後 PRG 導回", func(t *testing.T) { + rec := postForm(t, fmt.Sprintf("/admin/applications/%d/delete", publicApp.ID), url.Values{}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "/admin/applications" { + t.Fatalf("Location = %q, want /admin/applications", loc) + } + if n := appCount(t); n != 1 { + t.Fatalf("刪除後資料庫應用程式數 = %d, want 1", n) + } + + rec = httptest.NewRecorder() + r.ServeHTTP(rec, adminGet("/admin/applications", adminSess)) + if strings.Contains(rec.Body.String(), "行動 App") { + t.Error("刪除後列表不應再出現該應用程式") + } + }) + + t.Run("刪除不存在的應用程式回 404", func(t *testing.T) { + rec := postForm(t, "/admin/applications/99999/delete", url.Values{}) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + if !strings.Contains(rec.Body.String(), "應用程式不存在") { + t.Fatal("應顯示應用程式不存在") + } + }) +} diff --git a/adminkeys.go b/adminkeys.go new file mode 100644 index 0000000..db59cd5 --- /dev/null +++ b/adminkeys.go @@ -0,0 +1,209 @@ +package main + +import ( + "errors" + "log" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "gorm.io/gorm" + + "alterminal/internal/jwk" +) + +// adminKeyRow 為金鑰管理頁表格的單列視圖。 +type adminKeyRow struct { + ID uint + Kid string + Algorithm string + CreatedAt string // 本地時間顯示 + Active bool // 使用中(未退休) + LastActive bool // 使用中且為最後一把:退休按鈕停用 +} + +// adminKeysPageData 為金鑰管理頁的模板資料。 +type adminKeysPageData struct { + Error string + Username string // 側欄頁尾使用者資訊 + Email string + CSRF string // 產生/退休表單的 CSRF token + Keys []adminKeyRow + ActiveCount int // 使用中金鑰數;0 時頁面顯示警告 +} + +// newAdminKeyRows 將金鑰模型轉為表格視圖,並回傳使用中的金鑰數。 +// 純函式,便於單元測試。 +func newAdminKeyRows(keys []jwk.SigningKey) (rows []adminKeyRow, active int) { + for _, k := range keys { + if k.Active() { + active++ + } + } + rows = make([]adminKeyRow, 0, len(keys)) + for _, k := range keys { + rows = append(rows, adminKeyRow{ + ID: k.ID, + Kid: k.Kid, + Algorithm: k.Algorithm, + CreatedAt: k.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"), + Active: k.Active(), + // 僅剩一把使用中金鑰時禁止退休,確保隨時都有金鑰可簽發 JWT。 + LastActive: k.Active() && active == 1, + }) + } + return rows, active +} + +// requireAdmin 驗證請求來自持有效 Session 的管理員:未登入或 Session +// 過期時導向 /login(登入後可再試),已登入但非管理員回 403。 +// 回傳 Session(含 User)與是否繼續處理。 +func requireAdmin(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*Session, bool) { + c, err := r.Cookie(sessionCookieName) + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return nil, false + } + s, err := getSession(db, c.Value) + switch { + case errors.Is(err, ErrSessionExpired): + http.Redirect(w, r, "/login", http.StatusSeeOther) + return nil, false + case err != nil: + log.Printf("admin: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return nil, false + } + if s.User.Role != RoleAdmin { + log.Printf("admin: 非 admin 存取(user=%q)", s.User.Username) + http.Error(w, "需要管理員權限", http.StatusForbidden) + return nil, false + } + return s, true +} + +// adminKeysPageHandler 處理 GET /admin/keys:列出簽章金鑰(kid、演算法、 +// 建立時間、狀態)與產生/退休表單,僅管理員可存取。 +func adminKeysPageHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, ok := requireAdmin(db, w, r) + if !ok { + return + } + renderAdminKeysPage(w, r, db, http.StatusOK, s, "") + } +} + +// renderAdminKeysPage 查詢金鑰並輸出管理頁;errMsg 非空時以指定 status +// 重繪頁面並顯示錯誤(表單驗證失敗等)。s 供側欄頁尾顯示使用者資訊。 +// 使用中的金鑰排前、新者在前。 +func renderAdminKeysPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, s *Session, errMsg string) { + var keys []jwk.SigningKey + if err := db.Order("retired_at IS NULL DESC, created_at DESC").Find(&keys).Error; err != nil { + log.Printf("admin keys: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + token, err := newCSRFToken(w, r) + if err != nil { + log.Printf("csrf token: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + rows, active := newAdminKeyRows(keys) + renderHTML(w, status, adminKeysTmpl, adminKeysPageData{ + Error: errMsg, + Username: s.User.Username, + Email: s.User.Email, + CSRF: token, + Keys: rows, + ActiveCount: active, + }) +} + +// adminKeysCreateHandler 處理 POST /admin/keys:產生並儲存新的 RSA +// 簽章金鑰,成功後 PRG 導回管理頁。 +func adminKeysCreateHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, ok := requireAdmin(db, w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容") + return + } + if !verifyCSRF(r) { + renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試") + return + } + k, err := jwk.NewSigningKey() + if err != nil { + log.Printf("admin keys: %v", err) + renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰產生失敗,請稍後再試") + return + } + if err := db.Create(k).Error; err != nil { + log.Printf("admin keys: %v", err) + renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰儲存失敗,請稍後再試") + return + } + http.Redirect(w, r, "/admin/keys", http.StatusSeeOther) + } +} + +// adminKeysRetireHandler 處理 POST /admin/keys/{id}/retire:退休金鑰。 +// 最後一把使用中金鑰不可退休(否則將無金鑰可簽發 JWT);已退休或不存在 +// 的金鑰以錯誤訊息重繪頁面。 +func adminKeysRetireHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, ok := requireAdmin(db, w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + renderAdminKeysPage(w, r, db, http.StatusBadRequest, s, "無法解析表單內容") + return + } + if !verifyCSRF(r) { + renderAdminKeysPage(w, r, db, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試") + return + } + id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + if err != nil { + renderAdminKeysPage(w, r, db, http.StatusNotFound, s, "金鑰不存在") + return + } + var k jwk.SigningKey + switch err := db.First(&k, id).Error; { + case errors.Is(err, gorm.ErrRecordNotFound): + renderAdminKeysPage(w, r, db, http.StatusNotFound, s, "金鑰不存在") + return + case err != nil: + log.Printf("admin keys: %v", err) + renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤") + return + } + if !k.Active() { + renderAdminKeysPage(w, r, db, http.StatusConflict, s, "金鑰已處於退休狀態") + return + } + var active int64 + if err := db.Model(&jwk.SigningKey{}).Where("retired_at IS NULL").Count(&active).Error; err != nil { + log.Printf("admin keys: %v", err) + renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "內部錯誤") + return + } + if active <= 1 { + renderAdminKeysPage(w, r, db, http.StatusConflict, s, "至少須保留一把使用中的金鑰") + return + } + if err := db.Model(&k).Update("retired_at", time.Now()).Error; err != nil { + log.Printf("admin keys: %v", err) + renderAdminKeysPage(w, r, db, http.StatusInternalServerError, s, "金鑰更新失敗,請稍後再試") + return + } + http.Redirect(w, r, "/admin/keys", http.StatusSeeOther) + } +} diff --git a/adminkeys_test.go b/adminkeys_test.go new file mode 100644 index 0000000..b0756ad --- /dev/null +++ b/adminkeys_test.go @@ -0,0 +1,377 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "alterminal/internal/jwk" +) + +// 未帶 Session Cookie 的請求在 requireAdmin 即導向 /login,不觸及資料庫, +// 因此 handler 可傳入 nil db。 +func TestAdminKeysHandlersRequireLogin(t *testing.T) { + handlers := map[string]http.HandlerFunc{ + "GET 列表": adminKeysPageHandler(nil), + "POST 產生": adminKeysCreateHandler(nil), + "POST 退休": adminKeysRetireHandler(nil), + } + for name, h := range handlers { + t.Run(name, func(t *testing.T) { + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/admin/keys", nil)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "/login" { + t.Fatalf("Location = %q, want /login", loc) + } + }) + } +} + +func TestNewAdminKeyRows(t *testing.T) { + retired := time.Now().Add(-24 * time.Hour) + keys := []jwk.SigningKey{ + {ID: 1, Kid: "kid-a", Algorithm: "RS256", RetiredAt: &retired}, + {ID: 2, Kid: "kid-b", Algorithm: "RS256"}, + {ID: 3, Kid: "kid-c", Algorithm: "RS256"}, + } + rows, active := newAdminKeyRows(keys) + if active != 2 { + t.Fatalf("active = %d, want 2", active) + } + if len(rows) != 3 { + t.Fatalf("rows = %d 筆, want 3", len(rows)) + } + for _, r := range rows { + if r.Active != (r.Kid != "kid-a") { + t.Errorf("row %q Active = %v 與退休狀態不符", r.Kid, r.Active) + } + if r.LastActive { + t.Errorf("兩把使用中金鑰時 row %q 不應標記 LastActive", r.Kid) + } + } + + rows, active = newAdminKeyRows(keys[:2]) // 一把使用中+一把退休 + if active != 1 { + t.Fatalf("active = %d, want 1", active) + } + if !rows[1].LastActive { + t.Error("僅剩一把使用中金鑰時應標記 LastActive") + } +} + +// renderAdminKeysPage 需要資料庫,模板輸出直接以假資料渲染測試。 +func TestAdminKeysTemplate(t *testing.T) { + data := adminKeysPageData{ + Username: "alice", Email: "alice@example.com", CSRF: "token-A", ActiveCount: 2, + Keys: []adminKeyRow{ + {ID: 7, Kid: "kid-active", Algorithm: "RS256", CreatedAt: "2026-10-02 12:00:00 +08:00", Active: true}, + {ID: 3, Kid: "kid-retired", Algorithm: "RS256", CreatedAt: "2026-09-01 12:00:00 +08:00"}, + }, + } + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, adminKeysTmpl, data) + body := rec.Body.String() + for _, want := range []string{ + "金鑰管理", // 標題 + `action="/admin/keys"`, // 產生新金鑰表單 + `value="token-A"`, // CSRF 隱藏欄位 + `action="/admin/keys/7/retire"`, // 使用中金鑰的退休表單 + "kid-active", "kid-retired", // kid 欄 + "使用中", "已退休", // 狀態徽章 + `href="/admin/keys" aria-current="page"`, // 導覽(目前頁) + `href="/login"`, // 導覽(帳號資訊) + `action="/logout"`, // 側欄頁尾登出表單(版面預設) + "alice@example.com", + } { + if !strings.Contains(body, want) { + t.Errorf("金鑰管理頁缺少 %s", want) + } + } + if strings.Contains(body, "/admin/keys/3/retire") { + t.Error("已退休的金鑰不應出現退休表單") + } + if !strings.Contains(body, "使用中 2 把 / 共 2 把") { + t.Error("應顯示使用中/總數統計") + } +} + +// LastActive(唯一使用中金鑰)不輸出退休表單,改顯示提示。 +func TestAdminKeysTemplateLastActive(t *testing.T) { + data := adminKeysPageData{ + Username: "alice", Email: "alice@example.com", CSRF: "token-A", ActiveCount: 1, + Keys: []adminKeyRow{{ID: 7, Kid: "kid-only", Algorithm: "RS256", Active: true, LastActive: true}}, + } + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, adminKeysTmpl, data) + body := rec.Body.String() + if strings.Contains(body, "/retire") { + t.Error("唯一使用中金鑰不應出現退休表單") + } + if !strings.Contains(body, "唯一使用中金鑰") { + t.Error("應顯示無法退休的提示") + } +} + +// 無使用中金鑰時顯示警告。 +func TestAdminKeysTemplateNoActiveWarning(t *testing.T) { + data := adminKeysPageData{ + Username: "alice", Email: "alice@example.com", CSRF: "token-A", + Keys: []adminKeyRow{{ID: 7, Kid: "kid-old", Algorithm: "RS256"}}, + } + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, adminKeysTmpl, data) + if !strings.Contains(rec.Body.String(), "目前沒有使用中的金鑰") { + t.Error("無使用中金鑰時應顯示警告") + } +} + +// --- 整合測試:需要本機 PostgreSQL,連不上時跳過 --- + +// newTestDB 連線本機 PostgreSQL 並準備專用的 alterminal_test 資料庫 +// (與開發資料庫 alterminal 隔離),供整合測試使用。 +func newTestDB(t *testing.T) *gorm.DB { + t.Helper() + admin, err := gorm.Open(postgres.Open(fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable TimeZone=UTC", + envOr("DB_HOST", "localhost"), envOr("DB_PORT", "5432"), + envOr("DB_USER", "postgres"), envOr("DB_PASSWORD", "postgres"), + )), &gorm.Config{}) + if err != nil { + t.Skipf("本機 PostgreSQL 不可用,跳過整合測試:%v", err) + } + if err := admin.Exec("CREATE DATABASE alterminal_test").Error; err != nil && !strings.Contains(err.Error(), "already exists") { + t.Skipf("無法建立測試資料庫:%v", err) + } + t.Setenv("DB_NAME", "alterminal_test") + db, err := openDB() + if err != nil { + t.Skipf("連線測試資料庫失敗:%v", err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + sqlDB.Close() + } + }) + if err := db.Exec("TRUNCATE users, sessions, signing_keys, applications RESTART IDENTITY CASCADE").Error; err != nil { + t.Fatalf("清空測試資料失敗:%v", err) + } + return db +} + +func csrfCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie { + t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == csrfCookieName { + return c + } + } + t.Fatal("回應未設定 CSRF Cookie") + return nil +} + +// adminGet 建立帶 Session Cookie 的 GET 請求(管理頁共用)。 +func adminGet(path string, sess *Session) *http.Request { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sess.ID}) + return req +} + +// adminPost 建立帶 Session Cookie(與可選 CSRF Cookie)的表單 POST 請求。 +func adminPost(path, body string, sess *Session, csrf *http.Cookie) *http.Request { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sess.ID}) + if csrf != nil { + req.AddCookie(csrf) + } + return req +} + +func TestAdminKeysIntegration(t *testing.T) { + db := newTestDB(t) + + admin := &User{Username: "keyadmin", Email: "keyadmin@example.com", Role: RoleAdmin} + if err := admin.SetPassword("sup3r-secret"); err != nil { + t.Fatal(err) + } + if err := db.Create(admin).Error; err != nil { + t.Fatal(err) + } + member := &User{Username: "keyuser", Email: "keyuser@example.com", Role: RoleUser} + if err := member.SetPassword("sup3r-secret"); err != nil { + t.Fatal(err) + } + if err := db.Create(member).Error; err != nil { + t.Fatal(err) + } + adminSess, err := createSession(db, admin.ID) + if err != nil { + t.Fatal(err) + } + memberSess, err := createSession(db, member.ID) + if err != nil { + t.Fatal(err) + } + + r := chi.NewRouter() + r.Get("/admin/keys", adminKeysPageHandler(db)) + r.Post("/admin/keys", adminKeysCreateHandler(db)) + r.Post("/admin/keys/{id}/retire", adminKeysRetireHandler(db)) + + t.Run("非 admin 存取回 403", func(t *testing.T) { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminGet("/admin/keys", memberSess)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "需要管理員權限") { + t.Fatalf("應回需要管理員權限:%s", rec.Body.String()) + } + }) + + t.Run("admin 首次檢視為空狀態", func(t *testing.T) { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminGet("/admin/keys", adminSess)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "尚無簽章金鑰") { + t.Fatalf("應顯示空狀態提示:%s", rec.Body.String()) + } + }) + + // currentCSRF 以一次 GET 取得最新的 CSRF Cookie 與頁面 token(每次 + // 渲染都會輪替)。 + currentCSRF := func(t *testing.T) *http.Cookie { + t.Helper() + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminGet("/admin/keys", adminSess)) + if rec.Code != http.StatusOK { + t.Fatalf("GET /admin/keys status = %d", rec.Code) + } + return csrfCookieOf(t, rec) + } + + t.Run("CSRF 不符回 403", func(t *testing.T) { + cookie := currentCSRF(t) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminPost("/admin/keys", "csrf_token=wrong", adminSess, cookie)) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if !strings.Contains(rec.Body.String(), "表單驗證失敗") { + t.Fatal("應顯示 CSRF 錯誤訊息") + } + }) + + t.Run("產生新金鑰", func(t *testing.T) { + cookie := currentCSRF(t) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminPost("/admin/keys", "csrf_token="+cookie.Value, adminSess, cookie)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "/admin/keys" { + t.Fatalf("Location = %q, want /admin/keys", loc) + } + var count int64 + db.Model(&jwk.SigningKey{}).Count(&count) + if count != 1 { + t.Fatalf("資料庫金鑰數 = %d, want 1", count) + } + }) + + t.Run("退休最後一把使用中金鑰回 409", func(t *testing.T) { + var k jwk.SigningKey + if err := db.First(&k).Error; err != nil { + t.Fatal(err) + } + cookie := currentCSRF(t) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminPost(fmt.Sprintf("/admin/keys/%d/retire", k.ID), "csrf_token="+cookie.Value, adminSess, cookie)) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", rec.Code) + } + if !strings.Contains(rec.Body.String(), "至少須保留一把使用中的金鑰") { + t.Fatal("應顯示最後一把不可退休的訊息") + } + }) + + t.Run("產生第二把後可退休舊金鑰", func(t *testing.T) { + cookie := currentCSRF(t) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminPost("/admin/keys", "csrf_token="+cookie.Value, adminSess, cookie)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("產生第二把 status = %d, body = %s", rec.Code, rec.Body.String()) + } + + var old, latest jwk.SigningKey + if err := db.Order("id").First(&old).Error; err != nil { + t.Fatal(err) + } + if err := db.Order("id DESC").First(&latest).Error; err != nil { + t.Fatal(err) + } + + cookie = currentCSRF(t) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, adminPost(fmt.Sprintf("/admin/keys/%d/retire", old.ID), "csrf_token="+cookie.Value, adminSess, cookie)) + if rec.Code != http.StatusSeeOther { + t.Fatalf("退休 status = %d, body = %s", rec.Code, rec.Body.String()) + } + if err := db.First(&old, old.ID).Error; err != nil { + t.Fatal(err) + } + if old.RetiredAt == nil { + t.Fatal("退休後 RetiredAt 應有值") + } + + // 頁面顯示兩種狀態與統計。 + rec = httptest.NewRecorder() + r.ServeHTTP(rec, adminGet("/admin/keys", adminSess)) + body := rec.Body.String() + for _, want := range []string{old.Kid, latest.Kid, "使用中 1 把 / 共 2 把", "已退休", "唯一使用中金鑰"} { + if !strings.Contains(body, want) { + t.Errorf("管理頁缺少 %s", want) + } + } + }) + + t.Run("退休不存在的金鑰回 404", func(t *testing.T) { + cookie := currentCSRF(t) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminPost("/admin/keys/99999/retire", "csrf_token="+cookie.Value, adminSess, cookie)) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + if !strings.Contains(rec.Body.String(), "金鑰不存在") { + t.Fatal("應顯示金鑰不存在") + } + }) + + t.Run("再退休已退休金鑰回 409", func(t *testing.T) { + var old jwk.SigningKey + if err := db.Where("retired_at IS NOT NULL").First(&old).Error; err != nil { + t.Fatal(err) + } + cookie := currentCSRF(t) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, adminPost(fmt.Sprintf("/admin/keys/%d/retire", old.ID), "csrf_token="+cookie.Value, adminSess, cookie)) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", rec.Code) + } + if !strings.Contains(rec.Body.String(), "已處於退休狀態") { + t.Fatal("應顯示已退休訊息") + } + }) +} diff --git a/application.go b/application.go new file mode 100644 index 0000000..45fdd31 --- /dev/null +++ b/application.go @@ -0,0 +1,267 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" + + "gorm.io/gorm" +) + +// ClientType 為 OAuth 2.0 Client 類型(RFC 6749 §2.1):confidential 能 +// 安全保管 client secret(後端網頁應用),public 不能(SPA、行動應用), +// 授權流程必須以 PKCE 彌補,不簽發 client secret。 +type ClientType string + +// 允許的類型值。 +const ( + ClientConfidential ClientType = "confidential" + ClientPublic ClientType = "public" +) + +// valid 回傳類型是否為允許的值。 +func (t ClientType) valid() bool { + return t == ClientConfidential || t == ClientPublic +} + +// GrantType 為 OAuth 2.0 grant type。 +type GrantType string + +// 允許的 grant type 值。 +const ( + GrantAuthorizationCode GrantType = "authorization_code" // 授權碼流程(建議搭配 PKCE) + GrantRefreshToken GrantType = "refresh_token" // 以 Refresh Token 換發新權杖 + GrantClientCredentials GrantType = "client_credentials" // 機器對機器,僅機密式 Client 可用 +) + +// valid 回傳 grant type 是否為允許的值。 +func (g GrantType) valid() bool { + return g == GrantAuthorizationCode || g == GrantRefreshToken || g == GrantClientCredentials +} + +// supportedScopes 為本服務支援的 scope(與 README「支援的 Scope」一致)。 +var supportedScopes = map[string]bool{ + "openid": true, + "profile": true, + "email": true, + "offline_access": true, +} + +// defaultScope 為註冊時未指定 scope 的預設值。 +const defaultScope = "openid profile email" + +// RedirectURIs 為已註冊的 redirect URI 清單(JSON 陣列儲存)。RFC 6749 +// §3.1.2.3 要求端點比對時與註冊值完全相同(字串相等,不做正規化), +// 故以字串清單逐一比對。 +type RedirectURIs []string + +// Contains 回傳 uri 是否與任一註冊的 redirect URI 完全相同。 +func (r RedirectURIs) Contains(uri string) bool { + for _, u := range r { + if u == uri { + return true + } + } + return false +} + +// GrantTypes 為允許的 grant type 清單(JSON 陣列儲存)。 +type GrantTypes []GrantType + +// Contains 回傳 gt 是否為允許的 grant type。 +func (g GrantTypes) Contains(gt GrantType) bool { + for _, x := range g { + if x == gt { + return true + } + } + return false +} + +// Application 為接入 OIDC 的應用程式(Relying Party)註冊資料,對應 +// applications 資料表。ClientID 由本服務產生、全域唯一;client secret +// 與使用者密碼採同一套 argon2id 雜湊儲存,明文只在建立/輪替當下回傳 +// 一次;公開式 Client 不持有 secret。 +type Application struct { + ID uint `gorm:"primaryKey"` + ClientID string `gorm:"uniqueIndex;size:22;not null"` // 16 bytes 亂數的 base64url(公開識別碼,128 bits 熵已足夠) + Name string `gorm:"size:255;not null"` // 顯示名稱(授權頁顯示「以 ○○ 登入」等) + Type ClientType `gorm:"size:16;not null"` // confidential 或 public + ClientSecretHash string `gorm:"size:255;not null"` // argon2id PHC 字串;public 為空字串 + RedirectURIs RedirectURIs `gorm:"serializer:json;not null"` // 允許的 redirect URI(精確比對) + GrantTypes GrantTypes `gorm:"serializer:json;not null"` // 允許的 grant type + Scope string `gorm:"size:255;not null"` // 允許的 scope,空格分隔 + CreatedAt time.Time + UpdatedAt time.Time +} + +// IsPublic 回傳是否為公開式 Client(不持有 secret,授權流程必須使用 PKCE)。 +func (a *Application) IsPublic() bool { + return a.Type == ClientPublic +} + +// NewApplication 建立新的應用程式註冊:先驗證內容,再產生全域唯一的 +// client_id;機密式 Client 另產生 client secret,明文僅經回傳值交付一 +// 次,呼叫方應立即提供給應用程式管理者,不得儲存明文。grantTypes 為 +// 空時預設僅 authorization_code;scope 為空時預設「openid profile email」。 +func NewApplication(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) (*Application, string, error) { + a := &Application{ + Name: strings.TrimSpace(name), + Type: typ, + RedirectURIs: append(RedirectURIs{}, redirectURIs...), // 保證非 nil,序列化為 [] 而非 null + GrantTypes: grantTypes, + Scope: strings.TrimSpace(scope), + } + if len(a.GrantTypes) == 0 { + a.GrantTypes = GrantTypes{GrantAuthorizationCode} + } + if a.Scope == "" { + a.Scope = defaultScope + } + if err := a.Validate(); err != nil { + return nil, "", err + } + id, err := newRandomToken(16) + if err != nil { + return nil, "", fmt.Errorf("generate client id: %w", err) + } + a.ClientID = id + secret := "" + if !a.IsPublic() { + if secret, err = a.GenerateSecret(); err != nil { + return nil, "", err + } + } + return a, secret, nil +} + +// Validate 檢查註冊內容:名稱與類型必填、grant type 受支援且組合合法 +// (client_credentials 僅限機密式 Client(RFC 6749 §4.4.3)、 +// refresh_token 須伴隨授權碼流程)、使用授權碼流程時至少註冊一個格式 +// 正確的 redirect URI、scope 皆受支援且 offline_access 須有 +// refresh_token grant。 +func (a *Application) Validate() error { + if a.Name == "" { + return errors.New("應用程式名稱不可為空") + } + if !a.Type.valid() { + return fmt.Errorf("不支援的 client 類型 %q", a.Type) + } + if len(a.GrantTypes) == 0 { + return errors.New("至少須啟用一種 grant type") + } + for _, g := range a.GrantTypes { + if !g.valid() { + return fmt.Errorf("不支援的 grant type %q", g) + } + } + if a.GrantTypes.Contains(GrantClientCredentials) && a.IsPublic() { + return errors.New("公開式 Client 不可使用 client_credentials(RFC 6749 §4.4.3)") + } + if a.GrantTypes.Contains(GrantRefreshToken) && !a.GrantTypes.Contains(GrantAuthorizationCode) { + return errors.New("refresh_token 須伴隨 authorization_code 使用") + } + if a.GrantTypes.Contains(GrantAuthorizationCode) { + if len(a.RedirectURIs) == 0 { + return errors.New("使用授權碼流程須至少註冊一個 redirect URI") + } + for _, uri := range a.RedirectURIs { + if err := validateRedirectURI(uri); err != nil { + return fmt.Errorf("redirect URI %q:%w", uri, err) + } + } + } + if a.Scope == "" { + return errors.New("scope 不可為空") + } + for _, s := range strings.Fields(a.Scope) { + if !supportedScopes[s] { + return fmt.Errorf("不支援的 scope %q", s) + } + if s == "offline_access" && !a.GrantTypes.Contains(GrantRefreshToken) { + return errors.New("offline_access 須啟用 refresh_token grant") + } + } + return nil +} + +// GenerateSecret 產生並雜湊新的 client secret(明文為 32 bytes 亂數的 +// base64url,43 字元),回傳明文——僅此一次,資料庫只存雜湊。再次呼叫 +// 即輪替,舊 secret 立即失效。公開式 Client 不持有 secret,回傳錯誤。 +func (a *Application) GenerateSecret() (string, error) { + if a.IsPublic() { + return "", errors.New("公開式 Client 不持有 client secret") + } + secret, err := newRandomToken(32) + if err != nil { + return "", fmt.Errorf("generate client secret: %w", err) + } + hash, err := hashPassword(secret) + if err != nil { + return "", fmt.Errorf("hash client secret: %w", err) + } + a.ClientSecretHash = hash + return secret, nil +} + +// CheckSecret 回傳 client secret 是否相符;公開式 Client 一律不相符, +// 雜湊格式無效時亦視為不相符。 +func (a *Application) CheckSecret(secret string) bool { + if a.IsPublic() { + return false + } + ok, err := verifyPassword(secret, a.ClientSecretHash) + return err == nil && ok +} + +// validateRedirectURI 檢查 redirect URI 格式:須為絕對 URI 且不含 +// fragment 與 userinfo(RFC 6749 §3.1.2);http 僅允許 loopback(本機 +// 開發,RFC 8252 §7.3),Web 應用一律使用 https;非 http(s) 的自訂 +// scheme(如 com.example.app:/cb)供原生應用程式使用。 +func validateRedirectURI(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("解析失敗:%w", err) + } + if !u.IsAbs() { + return errors.New("須為絕對 URI(含 scheme)") + } + if u.Fragment != "" || u.RawFragment != "" { + return errors.New("不可包含 fragment") + } + if u.User != nil { + return errors.New("不可包含 userinfo") + } + switch u.Scheme { + case "http", "https": + if u.Host == "" { + return errors.New("缺少 host") + } + if u.Scheme == "http" { + switch u.Hostname() { + case "localhost", "127.0.0.1", "::1": + default: + return errors.New("http 僅允許 loopback(localhost、127.0.0.1、::1),其餘請使用 https") + } + } + default: + // 自訂 scheme:僅有 scheme 而無其餘部分(如 "myapp:")無法作為回呼位址。 + if u.Opaque == "" && u.Host == "" && u.Path == "" { + return errors.New("自訂 scheme 的 URI 須包含 scheme 以外的部分") + } + } + return nil +} + +// getApplicationByClientID 以 client_id 查詢應用程式,供 /authorize、 +// /token 驗證 Client 身分;查無資料時回傳包裹 gorm.ErrRecordNotFound +// 的錯誤(以 errors.Is 判斷)。 +func getApplicationByClientID(db *gorm.DB, clientID string) (*Application, error) { + var a Application + if err := db.Where("client_id = ?", clientID).First(&a).Error; err != nil { + return nil, fmt.Errorf("query application: %w", err) + } + return &a, nil +} diff --git a/application_test.go b/application_test.go new file mode 100644 index 0000000..28be401 --- /dev/null +++ b/application_test.go @@ -0,0 +1,291 @@ +package main + +import ( + "errors" + "reflect" + "strings" + "testing" + + "gorm.io/gorm" +) + +func TestNewApplicationConfidential(t *testing.T) { + a, secret, err := NewApplication("示範應用", ClientConfidential, + []string{"https://app.example.com/oidc/callback"}, + []GrantType{GrantAuthorizationCode, GrantRefreshToken}, + "openid profile offline_access") + if err != nil { + t.Fatal("NewApplication: ", err) + } + if len(a.ClientID) != 22 { + t.Errorf("ClientID 應為 16 bytes 亂數的 base64url(22 字元),得到 %d 字元", len(a.ClientID)) + } + if a.IsPublic() { + t.Error("機密式 Client 的 IsPublic() 應為 false") + } + if len(secret) != 43 { + t.Errorf("client secret 應為 32 bytes 亂數的 base64url(43 字元),得到 %d 字元", len(secret)) + } + if !strings.HasPrefix(a.ClientSecretHash, "$argon2id$") { + t.Errorf("ClientSecretHash 應為 argon2id PHC 字串,得到 %q", a.ClientSecretHash) + } + if strings.Contains(a.ClientSecretHash, secret) { + t.Error("client secret 不應以明文出現在雜湊欄位") + } + if !a.CheckSecret(secret) { + t.Error("正確的 client secret 應驗證成功") + } + if a.CheckSecret("wrong-secret") { + t.Error("錯誤的 client secret 不應驗證成功") + } + if err := a.Validate(); err != nil { + t.Error("新建立的註冊資料應通過驗證: ", err) + } +} + +func TestNewApplicationPublic(t *testing.T) { + a, secret, err := NewApplication("行動應用", ClientPublic, + []string{"com.example.app:/oidc/callback"}, nil, "") + if err != nil { + t.Fatal("NewApplication: ", err) + } + if !a.IsPublic() { + t.Error("公開式 Client 的 IsPublic() 應為 true") + } + if secret != "" { + t.Errorf("公開式 Client 不應簽發 client secret,得到 %q", secret) + } + if a.ClientSecretHash != "" { + t.Errorf("公開式 Client 不應存 secret 雜湊,得到 %q", a.ClientSecretHash) + } + for _, s := range []string{"", "anything"} { + if a.CheckSecret(s) { + t.Errorf("公開式 Client 的 CheckSecret(%q) 應為 false", s) + } + } + if _, err := a.GenerateSecret(); err == nil { + t.Error("公開式 Client 呼叫 GenerateSecret 應回傳錯誤") + } +} + +func TestNewApplicationDefaults(t *testing.T) { + a, _, err := NewApplication(" 示範應用 ", ClientConfidential, + []string{"https://app.example.com/cb"}, nil, "") + if err != nil { + t.Fatal(err) + } + if a.Name != "示範應用" { + t.Errorf("名稱應去除首尾空白,得到 %q", a.Name) + } + if !reflect.DeepEqual(a.GrantTypes, GrantTypes{GrantAuthorizationCode}) { + t.Errorf("未指定 grant type 應預設 authorization_code,得到 %v", a.GrantTypes) + } + if a.Scope != defaultScope { + t.Errorf("未指定 scope 應預設 %q,得到 %q", defaultScope, a.Scope) + } +} + +func TestNewApplicationClientIDUnique(t *testing.T) { + a, _, err := NewApplication("A", ClientPublic, []string{"https://a.example.com/cb"}, nil, "") + if err != nil { + t.Fatal(err) + } + b, _, err := NewApplication("B", ClientPublic, []string{"https://b.example.com/cb"}, nil, "") + if err != nil { + t.Fatal(err) + } + if a.ClientID == b.ClientID { + t.Error("兩次建立的 client_id 不應相同") + } +} + +func TestNewApplicationClientCredentialsWithoutRedirectURIs(t *testing.T) { + // 僅 client_credentials 的機器對機器應用不經過瀏覽器,無須 redirect URI。 + a, secret, err := NewApplication("批次服務", ClientConfidential, nil, + []GrantType{GrantClientCredentials}, "openid") + if err != nil { + t.Fatal("僅 client_credentials 註冊不應要求 redirect URI: ", err) + } + if secret == "" || !a.CheckSecret(secret) { + t.Error("機密式 Client 應簽發可驗證的 client secret") + } +} + +func TestNewApplicationInvalid(t *testing.T) { + cases := []struct { + desc string + name string + typ ClientType + uris []string + grants []GrantType + scope string + want string // 錯誤訊息應包含的子字串 + }{ + {"空名稱", "", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "", "名稱"}, + {"不支援的類型", "A", "webapp", []string{"https://a.example.com/cb"}, nil, "", "類型"}, + {"不支援的 grant type", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{"implicit"}, "", "grant type"}, + {"公開式使用 client_credentials", "A", ClientPublic, []string{"https://a.example.com/cb"}, []GrantType{GrantClientCredentials}, "", "client_credentials"}, + {"refresh_token 未伴隨授權碼", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{GrantRefreshToken}, "", "refresh_token"}, + {"授權碼流程無 redirect URI", "A", ClientConfidential, nil, []GrantType{GrantAuthorizationCode}, "", "redirect URI"}, + {"相對 URI", "A", ClientConfidential, []string{"app.example.com/cb"}, nil, "", "絕對 URI"}, + {"非 loopback 的 http", "A", ClientConfidential, []string{"http://app.example.com/cb"}, nil, "", "loopback"}, + {"含 fragment", "A", ClientConfidential, []string{"https://app.example.com/cb#frag"}, nil, "", "fragment"}, + {"含 userinfo", "A", ClientConfidential, []string{"https://user@app.example.com/cb"}, nil, "", "userinfo"}, + {"缺少 host", "A", ClientConfidential, []string{"https:///cb"}, nil, "", "host"}, + {"不支援的 scope", "A", ClientConfidential, []string{"https://a.example.com/cb"}, nil, "openid admin", "scope"}, + {"offline_access 無 refresh_token grant", "A", ClientConfidential, []string{"https://a.example.com/cb"}, []GrantType{GrantAuthorizationCode}, "openid offline_access", "offline_access"}, + } + for _, c := range cases { + _, _, err := NewApplication(c.name, c.typ, c.uris, c.grants, c.scope) + if err == nil { + t.Errorf("%s:應回傳錯誤", c.desc) + continue + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("%s:錯誤訊息 %q 應包含 %q", c.desc, err.Error(), c.want) + } + } +} + +func TestApplicationSecretRotation(t *testing.T) { + a, secret1, err := NewApplication("示範應用", ClientConfidential, + []string{"https://app.example.com/cb"}, nil, "") + if err != nil { + t.Fatal(err) + } + secret2, err := a.GenerateSecret() + if err != nil { + t.Fatal("GenerateSecret: ", err) + } + if secret1 == secret2 { + t.Error("輪替後的 client secret 不應與舊值相同") + } + if a.CheckSecret(secret1) { + t.Error("輪替後舊 client secret 應立即失效") + } + if !a.CheckSecret(secret2) { + t.Error("新 client secret 應驗證成功") + } +} + +func TestApplicationCheckSecretMalformedHash(t *testing.T) { + for _, hash := range []string{"", "not-a-phc-hash", "$argon2id$v=19$incomplete"} { + a := &Application{Type: ClientConfidential, ClientSecretHash: hash} + if a.CheckSecret("whatever") { + t.Errorf("格式無效的雜湊 %q 不應驗證成功", hash) + } + } +} + +func TestRedirectURIsContains(t *testing.T) { + uris := RedirectURIs{"https://app.example.com/cb", "com.example.app:/cb"} + for _, uri := range []string{"https://app.example.com/cb", "com.example.app:/cb"} { + if !uris.Contains(uri) { + t.Errorf("已註冊的 %q 應比對成功", uri) + } + } + for _, uri := range []string{ + "https://app.example.com/cb?x=1", // 未註冊的 query + "https://app.example.com/cb/", // 結尾斜線不同即不同字串 + "https://evil.example.com/cb", + "HTTPS://APP.EXAMPLE.COM/cb", + "", + } { + if uris.Contains(uri) { + t.Errorf("未註冊的 %q 不應比對成功(須完全相同)", uri) + } + } +} + +func TestGrantTypesContains(t *testing.T) { + gts := GrantTypes{GrantAuthorizationCode, GrantRefreshToken} + if !gts.Contains(GrantAuthorizationCode) || !gts.Contains(GrantRefreshToken) { + t.Error("已啟用的 grant type 應比對成功") + } + if gts.Contains(GrantClientCredentials) { + t.Error("未啟用的 grant type 不應比對成功") + } +} + +func TestValidateRedirectURI(t *testing.T) { + valid := []string{ + "https://app.example.com/oidc/callback", + "https://app.example.com", // 無 path + "https://app.example.com:8443/cb", // 帶 port + "http://localhost:8080/cb", // loopback 例外 + "http://127.0.0.1/cb", // loopback IP + "http://[::1]:8080/cb", // IPv6 loopback + "com.example.app:/oidc/callback", // 原生應用自訂 scheme + "urn:ietf:wg:oauth:2.0:oob", // opaque URI + } + for _, uri := range valid { + if err := validateRedirectURI(uri); err != nil { + t.Errorf("redirect URI %q 應有效,得到錯誤:%v", uri, err) + } + } + invalid := []string{ + "", // 空字串 + "app.example.com/cb", // 相對 URI(無 scheme) + "/cb", // path only + "https://app.example.com/cb#frag", // fragment(RFC 6749 §3.1.2 禁止) + "https://user@app.example.com/cb", // userinfo + "https:///cb", // 無 host + "http://app.example.com/cb", // 非 loopback 的 http + "myapp:", // 僅有 scheme + } + for _, uri := range invalid { + if err := validateRedirectURI(uri); err == nil { + t.Errorf("redirect URI %q 應無效", uri) + } + } +} + +// --- 整合測試:需要本機 PostgreSQL,連不上時跳過 --- + +func TestApplicationPersistence(t *testing.T) { + db := newTestDB(t) + a, secret, err := NewApplication("示範應用", ClientConfidential, + []string{"https://app.example.com/oidc/callback", "https://app.example.com/other"}, + []GrantType{GrantAuthorizationCode, GrantRefreshToken}, + "openid profile email offline_access") + if err != nil { + t.Fatal(err) + } + if err := db.Create(a).Error; err != nil { + t.Fatalf("建立應用程式失敗:%v", err) + } + + got, err := getApplicationByClientID(db, a.ClientID) + if err != nil { + t.Fatalf("以 client_id 查詢失敗:%v", err) + } + if got.ID == 0 || got.Name != a.Name || got.Type != a.Type || got.Scope != a.Scope { + t.Errorf("基本欄位往返不一致:got %+v", got) + } + if !reflect.DeepEqual(got.RedirectURIs, a.RedirectURIs) { + t.Errorf("RedirectURIs 往返不一致:got %v want %v", got.RedirectURIs, a.RedirectURIs) + } + if !reflect.DeepEqual(got.GrantTypes, a.GrantTypes) { + t.Errorf("GrantTypes 往返不一致:got %v want %v", got.GrantTypes, a.GrantTypes) + } + if !got.CheckSecret(secret) { + t.Error("資料庫往返後 client secret 應仍可驗證") + } + if !got.RedirectURIs.Contains("https://app.example.com/oidc/callback") { + t.Error("往返後 redirect URI 比對應仍可用") + } + + dup, _, err := NewApplication("重複測試", ClientPublic, + []string{"https://dup.example.com/cb"}, nil, "") + if err != nil { + t.Fatal(err) + } + dup.ClientID = a.ClientID + if err := db.Create(dup).Error; !errors.Is(err, gorm.ErrDuplicatedKey) { + t.Errorf("重複的 client_id 應回 gorm.ErrDuplicatedKey,得到 %v", err) + } + + if _, err := getApplicationByClientID(db, "no-such-client"); !errors.Is(err, gorm.ErrRecordNotFound) { + t.Errorf("查無 client_id 應回 gorm.ErrRecordNotFound,得到 %v", err) + } +} diff --git a/assets/css/input.css b/assets/css/input.css new file mode 100644 index 0000000..6bcc8c8 --- /dev/null +++ b/assets/css/input.css @@ -0,0 +1,20 @@ +/* + * Tailwind 進入點。建置(輸出 assets/css/main.css,已提交並由 go:embed 內嵌): + * + * tools/tailwindcss -i assets/css/input.css -o assets/css/main.css --minify + * + * CLI 為官方 standalone 執行檔(v4,見 tools/tailwindcss-version.txt), + * 一般開發不需 Node;僅在調整樣式時需要重新建置。 + */ +@import "tailwindcss"; + +/* 模板在此目錄,掃描它以產生用到的 utility class。 */ +@source "../../templates/*.html"; + +@theme { + /* 中文字型優先,兼顧 zh-Hant 顯示品質 */ + --font-sans: system-ui, -apple-system, "PingFang TC", "Microsoft JhengHei", sans-serif; + /* 品牌色:延續原登入頁的藍 */ + --color-brand: #0071e3; + --color-brand-strong: #0077ed; +} diff --git a/assets/css/main.css b/assets/css/main.css new file mode 100644 index 0000000..167f3ef --- /dev/null +++ b/assets/css/main.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial;--tw-ease:initial;--tw-outline-style:solid}}}@layer theme{:root,:host{--font-sans:system-ui, -apple-system, "PingFang TC", "Microsoft JhengHei", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-700:oklch(55.5% .163 48.998);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-700:oklch(50.8% .118 165.612);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-700:oklch(48.8% .243 264.376);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-700:oklch(49.1% .27 292.581);--color-neutral-100:oklch(97% 0 none);--color-neutral-200:oklch(92.2% 0 none);--color-neutral-300:oklch(87% 0 none);--color-neutral-400:oklch(70.8% 0 none);--color-neutral-500:oklch(55.6% 0 none);--color-neutral-600:oklch(43.9% 0 none);--color-neutral-700:oklch(37.1% 0 none);--color-neutral-800:oklch(26.9% 0 none);--color-neutral-900:oklch(20.5% 0 none);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--leading-tight:1.25;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-brand:#0071e3;--color-brand-strong:#0077ed}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.inset-0{inset:0}.inset-y-0{inset-block:0}.top-4{top:calc(var(--spacing) * 4)}.left-0{left:0}.left-4{left:calc(var(--spacing) * 4)}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.m-0{margin:0}.m-4{margin:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-3\.5{margin-bottom:calc(var(--spacing) * 3.5)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.block{display:block}.flex{display:flex}.hidden{display:none}.inline-block{display:inline-block}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-11{width:calc(var(--spacing) * 11);height:calc(var(--spacing) * 11)}.min-h-screen{min-height:100vh}.w-72{width:calc(var(--spacing) * 72)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-88{max-width:calc(var(--spacing) * 88)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.cursor-pointer{cursor:pointer}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-neutral-100>:not(:last-child)){border-color:var(--color-neutral-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-emerald-500\/40{border-color:#00bb7f66}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/40{border-color:color-mix(in oklab, var(--color-emerald-500) 40%, transparent)}}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.border-red-300{border-color:var(--color-red-300)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-brand{background-color:var(--color-brand)}.bg-brand\/10{background-color:#0071e31a}@supports (color:color-mix(in lab, red, red)){.bg-brand\/10{background-color:color-mix(in oklab, var(--color-brand) 10%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-neutral-100{background-color:var(--color-neutral-100)}.bg-neutral-500\/10{background-color:#7373731a}@supports (color:color-mix(in lab, red, red)){.bg-neutral-500\/10{background-color:color-mix(in oklab, var(--color-neutral-500) 10%, transparent)}}.bg-neutral-900\/40{background-color:#17171766}@supports (color:color-mix(in lab, red, red)){.bg-neutral-900\/40{background-color:color-mix(in oklab, var(--color-neutral-900) 40%, transparent)}}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-transparent{background-color:#0000}.bg-violet-500\/10{background-color:#8d54ff1a}@supports (color:color-mix(in lab, red, red)){.bg-violet-500\/10{background-color:color-mix(in oklab, var(--color-violet-500) 10%, transparent)}}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.p-8{padding:calc(var(--spacing) * 8)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[15px\]{font-size:15px}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.text-amber-700{color:var(--color-amber-700)}.text-blue-700{color:var(--color-blue-700)}.text-brand{color:var(--color-brand)}.text-emerald-700{color:var(--color-emerald-700)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-neutral-700{color:var(--color-neutral-700)}.text-neutral-900{color:var(--color-neutral-900)}.text-red-600{color:var(--color-red-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.peer-checked\:block:is(:where(.peer):checked~*){display:block}.peer-checked\:hidden:is(:where(.peer):checked~*){display:none}.peer-checked\:translate-x-0:is(:where(.peer):checked~*){--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-focus-visible\:outline-2:is(:where(.peer):focus-visible~*){outline-style:var(--tw-outline-style);outline-width:2px}.peer-focus-visible\:outline-offset-2:is(:where(.peer):focus-visible~*){outline-offset:2px}.peer-focus-visible\:outline-brand:is(:where(.peer):focus-visible~*){outline-color:var(--color-brand)}@media (hover:hover){.hover\:bg-brand-strong:hover{background-color:var(--color-brand-strong)}.hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}}.focus\:border-transparent:focus{border-color:#0000}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:outline-offset-1:focus{outline-offset:1px}.focus\:outline-brand:focus{outline-color:var(--color-brand)}@media (min-width:48rem){.md\:hidden{display:none}.md\:hidden\!{display:none!important}.md\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.md\:p-10{padding:calc(var(--spacing) * 10)}.md\:pl-72{padding-left:calc(var(--spacing) * 72)}}@media (prefers-color-scheme:dark){:where(.dark\:divide-neutral-700\/60>:not(:last-child)){border-color:#40404099}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-neutral-700\/60>:not(:last-child)){border-color:color-mix(in oklab, var(--color-neutral-700) 60%, transparent)}}.dark\:border-neutral-600{border-color:var(--color-neutral-600)}.dark\:border-neutral-700{border-color:var(--color-neutral-700)}.dark\:border-red-500\/60{border-color:#fb2c3699}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/60{border-color:color-mix(in oklab, var(--color-red-500) 60%, transparent)}}.dark\:bg-brand\/25{background-color:#0071e340}@supports (color:color-mix(in lab, red, red)){.dark\:bg-brand\/25{background-color:color-mix(in oklab, var(--color-brand) 25%, transparent)}}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-neutral-900{background-color:var(--color-neutral-900)}.dark\:text-amber-400{color:var(--color-amber-400)}.dark\:text-blue-200{color:var(--color-blue-200)}.dark\:text-blue-400{color:var(--color-blue-400)}.dark\:text-emerald-400{color:var(--color-emerald-400)}.dark\:text-neutral-100{color:var(--color-neutral-100)}.dark\:text-neutral-300{color:var(--color-neutral-300)}.dark\:text-neutral-400{color:var(--color-neutral-400)}.dark\:text-neutral-500{color:var(--color-neutral-500)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-violet-400{color:var(--color-violet-400)}.dark\:shadow-black\/40{--tw-shadow-color:#0006}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}@media (hover:hover){.dark\:hover\:bg-neutral-700:hover{background-color:var(--color-neutral-700)}.dark\:hover\:bg-neutral-700\/60:hover{background-color:#40404099}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-neutral-700\/60:hover{background-color:color-mix(in oklab, var(--color-neutral-700) 60%, transparent)}}.dark\:hover\:bg-red-500\/10:hover{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-500\/10:hover{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}}}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid} \ No newline at end of file diff --git a/createaccount.go b/createaccount.go new file mode 100644 index 0000000..34b6914 --- /dev/null +++ b/createaccount.go @@ -0,0 +1,186 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "log" + "net/mail" + "os" + "regexp" + "strings" + + "golang.org/x/term" + "gorm.io/gorm" +) + +// runCommand 分派 CLI 子指令;不帶任何參數時 main 直接啟動 HTTP 伺服器。 +func runCommand(args []string) { + switch args[0] { + case "create-account": + if err := runCreateAccount(args[1:]); err != nil { + log.Fatal("create-account: ", err) + } + case "update-password": + if err := runUpdatePassword(args[1:]); err != nil { + log.Fatal("update-password: ", err) + } + default: + fmt.Fprintf(os.Stderr, "未知指令 %q\n\n用法:\n alterminal create-account [-username 帳號] [-email Email] [-name 顯示名稱] [-email-verified] [-role admin|user] [-password 密碼]\n alterminal update-password -username 帳號 [-password 新密碼]\n", args[0]) + os.Exit(2) + } +} + +// runCreateAccount 解析旗標、驗證輸入並建立使用者帳號。 +func runCreateAccount(args []string) error { + fs := flag.NewFlagSet("create-account", flag.ExitOnError) + username := fs.String("username", "", "登入帳號(必填)") + email := fs.String("email", "", "Email(必填)") + name := fs.String("name", "", "顯示名稱(選填)") + emailVerified := fs.Bool("email-verified", false, "Email 已驗證(選填)") + role := fs.String("role", "user", "角色:admin 或 user(選填,預設 user)") + password := fs.String("password", "", "密碼(選填;省略時於終端機輸入)") + fs.Parse(args) + + in := accountInput{ + Username: strings.TrimSpace(*username), + Email: strings.TrimSpace(*email), + Name: strings.TrimSpace(*name), + Role: Role(strings.TrimSpace(*role)), + } + if err := in.validate(); err != nil { + return err + } + pw, err := resolvePassword(*password) + if err != nil { + return err + } + + db, err := openDB() + if err != nil { + return fmt.Errorf("database: %w", err) + } + + u := &User{Username: in.Username, Email: in.Email, Name: in.Name, EmailVerified: *emailVerified, Role: in.Role} + if err := u.SetPassword(pw); err != nil { + return fmt.Errorf("hash password: %w", err) + } + if err := createUser(db, u); err != nil { + return err + } + fmt.Printf("帳號建立成功:id=%d username=%s email=%s role=%s\n", u.ID, u.Username, u.Email, u.Role) + return nil +} + +// accountInput 為 create-account 的輸入欄位,長度限制對應 users 資料表欄位定義。 +type accountInput struct { + Username string + Email string + Name string + Role Role +} + +var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +func (in *accountInput) validate() error { + if in.Username == "" { + return errors.New("username 不可為空") + } + if len(in.Username) > 64 { + return errors.New("username 長度不可超過 64") + } + if !usernamePattern.MatchString(in.Username) { + return errors.New("username 僅接受英文字母、數字與 . _ -") + } + if in.Email == "" { + return errors.New("email 不可為空") + } + if len(in.Email) > 255 { + return errors.New("email 長度不可超過 255") + } + // 僅接受純位址,排除 "Alice " 這類含顯示名稱的寫法。 + if addr, err := mail.ParseAddress(in.Email); err != nil || addr.Address != in.Email { + return errors.New("email 格式無效") + } + if len(in.Name) > 255 { + return errors.New("name 長度不可超過 255") + } + if in.Role == "" { + in.Role = RoleUser // 未指定時預設一般使用者 + } + if !in.Role.valid() { + return errors.New("role 僅接受 admin 或 user") + } + return nil +} + +const minPasswordLen = 8 + +// resolvePassword 回傳帳號密碼:有 -password 旗標時直接使用, +// 否則須於終端機以無回顯方式輸入兩次;非終端機環境不得省略旗標。 +func resolvePassword(flagPassword string) (string, error) { + password := flagPassword + if password == "" { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", errors.New("非互動環境無法提示輸入密碼,請以 -password 提供") + } + var err error + password, err = promptPasswordTwice(readHiddenLine) + if err != nil { + return "", err + } + } + if len(password) < minPasswordLen { + return "", fmt.Errorf("密碼長度至少 %d 字元", minPasswordLen) + } + return password, nil +} + +// promptPasswordTwice 以 read 提示讀取密碼兩次,一致時回傳。 +func promptPasswordTwice(read func(string) ([]byte, error)) (string, error) { + first, err := read("輸入密碼: ") + if err != nil { + return "", fmt.Errorf("讀取密碼: %w", err) + } + second, err := read("再次輸入密碼: ") + if err != nil { + return "", fmt.Errorf("讀取密碼: %w", err) + } + if string(first) != string(second) { + return "", errors.New("兩次輸入的密碼不一致") + } + return string(first), nil +} + +// readHiddenLine 在終端機顯示 prompt 並無回顯讀取一行輸入。 +func readHiddenLine(prompt string) ([]byte, error) { + fmt.Print(prompt) + b, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + return b, err +} + +// createUser 將帳號寫入資料庫;寫入前預查提供友善的重複錯誤, +// 寫入時再以唯一索引(gorm.ErrDuplicatedRows)兜底並發情境。 +func createUser(db *gorm.DB, u *User) error { + var count int64 + if err := db.Model(&User{}).Where("username = ?", u.Username).Count(&count).Error; err != nil { + return fmt.Errorf("query username: %w", err) + } + if count > 0 { + return fmt.Errorf("username %q 已被使用", u.Username) + } + if err := db.Model(&User{}).Where("email = ?", u.Email).Count(&count).Error; err != nil { + return fmt.Errorf("query email: %w", err) + } + if count > 0 { + return fmt.Errorf("email %q 已被使用", u.Email) + } + if err := db.Create(u).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fmt.Errorf("username %q 或 email %q 已被使用", u.Username, u.Email) + } + return fmt.Errorf("create user: %w", err) + } + return nil +} diff --git a/createaccount_test.go b/createaccount_test.go new file mode 100644 index 0000000..ef709d5 --- /dev/null +++ b/createaccount_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "errors" + "strings" + "testing" +) + +func TestAccountInputValidate(t *testing.T) { + tests := []struct { + name string + in accountInput + wantErr string // 空字串表示應通過 + }{ + {"最小欄位", accountInput{Username: "alice", Email: "alice@example.com"}, ""}, + {"含顯示名稱", accountInput{Username: "alice", Email: "alice@example.com", Name: "Alice"}, ""}, + {"role 為 admin", accountInput{Username: "alice", Email: "alice@example.com", Role: RoleAdmin}, ""}, + {"role 為 user", accountInput{Username: "alice", Email: "alice@example.com", Role: RoleUser}, ""}, + {"role 為空", accountInput{Username: "alice", Email: "alice@example.com"}, ""}, + {"role 不允許的值", accountInput{Username: "alice", Email: "alice@example.com", Role: "superuser"}, "role"}, + {"role 為 Admin(大寫)", accountInput{Username: "alice", Email: "alice@example.com", Role: "Admin"}, "role"}, + {"username 允許的符號", accountInput{Username: "a_li-ce.01", Email: "alice@example.com"}, ""}, + {"缺 username", accountInput{Email: "alice@example.com"}, "username"}, + {"username 過長", accountInput{Username: strings.Repeat("a", 65), Email: "alice@example.com"}, "64"}, + {"username 含空白", accountInput{Username: "alice wang", Email: "alice@example.com"}, "username"}, + {"username 含 @", accountInput{Username: "alice@example.com", Email: "alice@example.com"}, "username"}, + {"缺 email", accountInput{Username: "alice"}, "email"}, + {"email 過長", accountInput{Username: "alice", Email: strings.Repeat("a", 250) + "@example.com"}, "255"}, + {"email 格式無效", accountInput{Username: "alice", Email: "example.com"}, "email"}, + {"email 帶顯示名稱", accountInput{Username: "alice", Email: "Alice "}, "email"}, + {"name 過長", accountInput{Username: "alice", Email: "alice@example.com", Name: strings.Repeat("名", 256)}, "255"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.in.validate() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("validate() = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("validate() = %v, want error containing %q", err, tt.wantErr) + } + }) + } +} + +func TestResolvePasswordFromFlag(t *testing.T) { + got, err := resolvePassword("sup3r-secret") + if err != nil { + t.Fatalf("resolvePassword() = %v, want nil", err) + } + if got != "sup3r-secret" { + t.Fatalf("resolvePassword() = %q, want %q", got, "sup3r-secret") + } +} + +func TestResolvePasswordTooShort(t *testing.T) { + _, err := resolvePassword("1234567") + if err == nil || !strings.Contains(err.Error(), "8") { + t.Fatalf("resolvePassword(\"1234567\") = %v, want 長度錯誤", err) + } +} + +// go test 執行時 stdin 不是終端機,省略 -password 應直接報錯而非等待輸入。 +func TestResolvePasswordRequiresFlagWithoutTerminal(t *testing.T) { + _, err := resolvePassword("") + if err == nil || !strings.Contains(err.Error(), "-password") { + t.Fatalf("resolvePassword(\"\") = %v, want 提示改用 -password 的錯誤", err) + } +} + +func TestPromptPasswordTwice(t *testing.T) { + t.Run("兩次一致", func(t *testing.T) { + got, err := promptPasswordTwice(func(string) ([]byte, error) { return []byte("sup3r-secret"), nil }) + if err != nil { + t.Fatalf("promptPasswordTwice() = %v, want nil", err) + } + if got != "sup3r-secret" { + t.Fatalf("promptPasswordTwice() = %q, want %q", got, "sup3r-secret") + } + }) + t.Run("兩次不一致", func(t *testing.T) { + inputs := []string{"sup3r-secret", "sup3r-secret2"} + calls := 0 + _, err := promptPasswordTwice(func(string) ([]byte, error) { + b := []byte(inputs[calls]) + calls++ + return b, nil + }) + if err == nil || !strings.Contains(err.Error(), "不一致") { + t.Fatalf("promptPasswordTwice() = %v, want 不一致錯誤", err) + } + }) + t.Run("讀取失敗", func(t *testing.T) { + _, err := promptPasswordTwice(func(string) ([]byte, error) { return nil, errors.New("boom") }) + if err == nil { + t.Fatal("promptPasswordTwice() = nil, want error") + } + }) +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..e033c9b --- /dev/null +++ b/db.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "alterminal/internal/jwk" +) + +func openDB() (*gorm.DB, error) { + dsn := fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable TimeZone=UTC", + envOr("DB_HOST", "localhost"), + envOr("DB_PORT", "5432"), + envOr("DB_USER", "postgres"), + envOr("DB_PASSWORD", "postgres"), + envOr("DB_NAME", "alterminal"), + ) + + // TranslateError 讓唯一鍵違規轉為 gorm.ErrDuplicatedKey,create-account 等指令以此辨識重複。 + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}) + if err != nil { + return nil, err + } + + if err := db.AutoMigrate(&User{}, &Session{}, &jwk.SigningKey{}, &Application{}); err != nil { + return nil, fmt.Errorf("auto migrate: %w", err) + } + return db, nil +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a8dd1e8 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module alterminal + +go 1.26.5 + +require ( + github.com/go-chi/chi/v5 v5.3.2 + golang.org/x/crypto v0.57.0 + golang.org/x/term v0.46.0 + gorm.io/driver/postgres v1.6.3 + gorm.io/gorm v1.31.2 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7257bf7 --- /dev/null +++ b/go.sum @@ -0,0 +1,46 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0= +gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/jwk/jwk.go b/internal/jwk/jwk.go new file mode 100644 index 0000000..8d9225f --- /dev/null +++ b/internal/jwk/jwk.go @@ -0,0 +1,26 @@ +// Package jwk 提供簽發 JWT(ID Token/Access Token)所用金鑰的資料模型, +// 以及其 RFC 7517 JWK/JWKS 公開表示法,供 /.well-known/jwks.json 發佈。 +package jwk + +// JWK 參數的註冊值(RFC 7517 的 kty/use、RFC 7518 的 alg)。 +const ( + KeyTypeRSA = "RSA" // kty:金鑰類型 + KeyUseSig = "sig" // use:簽章用途 + AlgRS256 = "RS256" // alg:RSASSA-PKCS1-v1_5 搭配 SHA-256 +) + +// JWK 為單一把金鑰的公開形式(RFC 7517),僅含 RP 驗證 JWT 簽章所需的 +// 參數;私有參數(d、p、q、dp、dq、qi)依規範與安全考量絕不序列化。 +type JWK struct { + Kty string `json:"kty"` // 金鑰類型(RSA) + Use string `json:"use"` // 用途(sig) + Kid string `json:"kid"` // 金鑰 ID,對應 JWT header 的 kid + Alg string `json:"alg,omitempty"` // 建議演算法(RS256) + N string `json:"n"` // RSA modulus,base64url 無填充 + E string `json:"e"` // RSA 公開指數,同上(65537 時為 "AQAB") +} + +// JWKS 為 JWK Set(RFC 7517 §5),/.well-known/jwks.json 的回應格式。 +type JWKS struct { + Keys []JWK `json:"keys"` +} diff --git a/internal/jwk/signingkey.go b/internal/jwk/signingkey.go new file mode 100644 index 0000000..5bc675d --- /dev/null +++ b/internal/jwk/signingkey.go @@ -0,0 +1,117 @@ +package jwk + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "time" +) + +// rsaKeyBits 為簽章金鑰位元數;RFC 7518 §3.5 規定 RS256 至少 2048 bits。 +const rsaKeyBits = 2048 + +// SigningKey 為簽發 JWT 的 RSA 金鑰,對應 signing_keys 資料表。私鑰以 +// PKCS#8 PEM 存於資料庫;Kid 為 RFC 7638 thumbprint,同時作為 JWKS 的 +// kid 與 JWT header 的 kid,讓 RP 得以對應兩者。RetiredAt 為 nil 表示 +// 使用中;輪替時舊金鑰先保留於 JWKS 一段時間(供已簽發的 token 驗證), +// 之後才退休停發,實現無縫金鑰輪替。 +type SigningKey struct { + ID uint `gorm:"primaryKey"` + Kid string `gorm:"uniqueIndex;size:43;not null"` // RFC 7638 thumbprint(32 bytes 的 base64url,43 字元) + Algorithm string `gorm:"size:8;not null;default:RS256"` + PrivateKeyPEM string `gorm:"type:text;not null"` // PKCS#8 PEM 私鑰 + RetiredAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// Active 回傳金鑰是否使用中(未退休)。 +func (k *SigningKey) Active() bool { + return k.RetiredAt == nil +} + +// NewSigningKey 產生新的 RSA-2048 簽章金鑰,並以公鑰的 RFC 7638 +// SHA-256 thumbprint 作為 Kid。 +func NewSigningKey() (*SigningKey, error) { + key, err := rsa.GenerateKey(rand.Reader, rsaKeyBits) + if err != nil { + return nil, fmt.Errorf("generate rsa key: %w", err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, fmt.Errorf("marshal private key: %w", err) + } + kid, err := thumbprint(&key.PublicKey) + if err != nil { + return nil, err + } + return &SigningKey{ + Kid: kid, + Algorithm: AlgRS256, + PrivateKeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), + }, nil +} + +// PrivateKey 解析並回傳 RSA 私鑰,供簽發 JWT 使用。 +func (k *SigningKey) PrivateKey() (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(k.PrivateKeyPEM)) + if block == nil { + return nil, errors.New("invalid PEM block") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("not an RSA private key: %T", parsed) + } + return key, nil +} + +// PublicJWK 回傳金鑰的公開 JWK(僅 kty、use、kid、alg、n、e), +// 供 JWKS 端點發佈。 +func (k *SigningKey) PublicJWK() (*JWK, error) { + key, err := k.PrivateKey() + if err != nil { + return nil, err + } + return &JWK{ + Kty: KeyTypeRSA, + Use: KeyUseSig, + Kid: k.Kid, + Alg: k.Algorithm, + N: base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()), + }, nil +} + +// thumbprint 依 RFC 7638 計算 RSA 公鑰的 SHA-256 JWK thumbprint:對必要 +// 參數依字典序(e、kty、n)組成的正規化 JSON 做 SHA-256,再以無填充 +// base64url 編碼;此值即 JWK/JWT 的 kid。 +func thumbprint(pub *rsa.PublicKey) (string, error) { + // 欄位依字典序宣告,json.Marshal 的輸出即 RFC 7638 要求的正規化形式; + // 成員僅含 base64url 字元,不會有 JSON 跳脫的差異。 + canonical := struct { + E string `json:"e"` + Kty string `json:"kty"` + N string `json:"n"` + }{ + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + Kty: KeyTypeRSA, + N: base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + } + b, err := json.Marshal(canonical) + if err != nil { + return "", fmt.Errorf("marshal canonical jwk: %w", err) + } + sum := sha256.Sum256(b) + return base64.RawURLEncoding.EncodeToString(sum[:]), nil +} diff --git a/internal/jwk/signingkey_test.go b/internal/jwk/signingkey_test.go new file mode 100644 index 0000000..034f9fe --- /dev/null +++ b/internal/jwk/signingkey_test.go @@ -0,0 +1,175 @@ +package jwk + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "strings" + "testing" + "time" +) + +func TestNewSigningKey(t *testing.T) { + k, err := NewSigningKey() + if err != nil { + t.Fatal("NewSigningKey: ", err) + } + if len(k.Kid) != 43 { + t.Errorf("Kid 應為 32 bytes SHA-256 的無填充 base64url(43 字元),得到 %d 字元", len(k.Kid)) + } + if k.Algorithm != AlgRS256 { + t.Errorf("Algorithm = %q, want %q", k.Algorithm, AlgRS256) + } + if !k.Active() { + t.Error("新產生的金鑰應為使用中(RetiredAt 為 nil)") + } + priv, err := k.PrivateKey() + if err != nil { + t.Fatal("PrivateKey: ", err) + } + if priv.N.BitLen() != rsaKeyBits { + t.Errorf("金鑰長度 = %d bits, want %d", priv.N.BitLen(), rsaKeyBits) + } + if err := priv.Validate(); err != nil { + t.Error("產生的私鑰未通過自檢: ", err) + } + want, err := thumbprint(&priv.PublicKey) + if err != nil { + t.Fatal("thumbprint: ", err) + } + if k.Kid != want { + t.Errorf("Kid = %q, want 公鑰 thumbprint %q", k.Kid, want) + } +} + +func TestNewSigningKeyUniqueKid(t *testing.T) { + a, err := NewSigningKey() + if err != nil { + t.Fatal(err) + } + b, err := NewSigningKey() + if err != nil { + t.Fatal(err) + } + if a.Kid == b.Kid { + t.Error("兩把新產生的金鑰不應有相同 Kid") + } +} + +// RFC 7638 §3.1 的測試向量(與 RFC 7517 A.1 同一把 RSA 金鑰)。 +func TestThumbprintRFC7638Vector(t *testing.T) { + pub := &rsa.PublicKey{ + N: mustBigFromBase64URL(t, "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw"), + E: 65537, + } + got, err := thumbprint(pub) + if err != nil { + t.Fatal("thumbprint: ", err) + } + if want := "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"; got != want { + t.Errorf("thumbprint = %q, want RFC 7638 §3.1 的 %q", got, want) + } +} + +func TestActive(t *testing.T) { + k := &SigningKey{} + if !k.Active() { + t.Error("RetiredAt 為 nil 時應為使用中") + } + now := time.Now() + k.RetiredAt = &now + if k.Active() { + t.Error("RetiredAt 已設定時不應為使用中") + } +} + +func TestPublicJWK(t *testing.T) { + k, err := NewSigningKey() + if err != nil { + t.Fatal(err) + } + j, err := k.PublicJWK() + if err != nil { + t.Fatal("PublicJWK: ", err) + } + priv, err := k.PrivateKey() + if err != nil { + t.Fatal(err) + } + if j.Kty != "RSA" || j.Use != "sig" || j.Alg != "RS256" { + t.Errorf("JWK 參數 = kty:%q use:%q alg:%q", j.Kty, j.Use, j.Alg) + } + if j.Kid != k.Kid { + t.Errorf("JWK.Kid = %q, want %q", j.Kid, k.Kid) + } + if j.E != "AQAB" { + t.Errorf("E = %q, want AQAB(65537 的 base64url)", j.E) + } + if mustBigFromBase64URL(t, j.N).Cmp(priv.N) != 0 { + t.Error("N 應等於私鑰的 modulus") + } +} + +func TestPublicJWKJSONShape(t *testing.T) { + k, err := NewSigningKey() + if err != nil { + t.Fatal(err) + } + j, err := k.PublicJWK() + if err != nil { + t.Fatal(err) + } + b, err := json.Marshal(JWKS{Keys: []JWK{*j}}) + if err != nil { + t.Fatal(err) + } + s := string(b) + if !strings.HasPrefix(s, `{"keys":[{`) || !strings.HasSuffix(s, `}]}`) { + t.Errorf(`JWKS 應為 {"keys":[…]} 形式,得到 %s`, s) + } + var m struct { + Keys []map[string]any `json:"keys"` + } + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + got := m.Keys[0] + want := []string{"alg", "e", "kid", "kty", "n", "use"} + if len(got) != len(want) { + t.Errorf("JWK 應恰含參數 %v,得到 %v", want, got) + } + for _, f := range want { + if _, ok := got[f]; !ok { + t.Errorf("JWK 缺少參數 %q", f) + } + } + // 私有參數絕不得出現 + for _, p := range []string{`"d":`, `"p":`, `"q":`, `"dp":`, `"dq":`, `"qi":`} { + if strings.Contains(s, p) { + t.Errorf("JWKS 不應含私有參數 %s", p) + } + } +} + +func TestPrivateKeyMalformedPEM(t *testing.T) { + for _, pemStr := range []string{ + "", + "not a pem", + "-----BEGIN PRIVATE KEY-----\nYm9ndXMK\n-----END PRIVATE KEY-----", + } { + k := &SigningKey{PrivateKeyPEM: pemStr} + if _, err := k.PrivateKey(); err == nil { + t.Errorf("格式無效的 PEM %q 不應解析成功", pemStr) + } + } +} + +func mustBigFromBase64URL(t *testing.T, s string) *big.Int { + t.Helper() + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + t.Fatalf("decode base64url %q: %v", s, err) + } + return new(big.Int).SetBytes(b) +} diff --git a/login.go b/login.go new file mode 100644 index 0000000..4c3536a --- /dev/null +++ b/login.go @@ -0,0 +1,190 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "strings" + "sync" + "time" + + "gorm.io/gorm" +) + +// sessionCookieName 為存放 Session ID 的 Cookie 名稱。 +const sessionCookieName = "alterminal_session" + +// loginRequest 為 POST /login 的請求欄位(JSON 與表單共用)。 +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// validate 正規化並檢查欄位:username 去除首尾空白後不可為空,password 不可為空。 +func (in *loginRequest) validate() error { + in.Username = strings.TrimSpace(in.Username) + if in.Username == "" { + return errors.New("username 不可為空") + } + if in.Password == "" { + return errors.New("password 不可為空") + } + return nil +} + +// publicUser 為對外暴露的使用者欄位,不含 PasswordHash 等內部資訊。 +type publicUser struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Role Role `json:"role"` +} + +// loginResponse 為登入成功回應;ExpiresAt 對應 Session 與 Cookie 的到期時間。 +type loginResponse struct { + User publicUser `json:"user"` + ExpiresAt time.Time `json:"expires_at"` +} + +// loginHandler 處理 POST /login,依 Content-Type 分流:application/json 走 +// API 流程(回 JSON),表單走瀏覽器流程(回 HTML)。兩者共用帳密驗證與 +// Session 建立;帳密錯誤一律回 401,不洩漏帳號是否存在。 +func loginHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ct := r.Header.Get("Content-Type") + var isForm bool + switch { + case strings.HasPrefix(ct, "application/json"): + case strings.HasPrefix(ct, "application/x-www-form-urlencoded"), + strings.HasPrefix(ct, "multipart/form-data"): + isForm = true + default: + writeError(w, http.StatusUnsupportedMediaType, "Content-Type 須為 application/json 或表單") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + var in loginRequest + if isForm { + if err := r.ParseForm(); err != nil { + renderLoginPage(w, r, http.StatusBadRequest, "無法解析表單內容", "") + return + } + if !verifyCSRF(r) { + renderLoginPage(w, r, http.StatusForbidden, "表單驗證失敗,請重新整理頁面後再試", "") + return + } + in = loginRequest{Username: r.PostFormValue("username"), Password: r.PostFormValue("password")} + } else if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "無法解析請求內容") + return + } + + fail := func(status int, msg string) { + if isForm { + renderLoginPage(w, r, status, msg, in.Username) + return + } + writeError(w, status, msg) + } + if err := in.validate(); err != nil { + fail(http.StatusBadRequest, err.Error()) + return + } + + u, err := authenticateUser(db, in.Username, in.Password) + switch { + case errors.Is(err, ErrInvalidCredentials): + fail(http.StatusUnauthorized, err.Error()) + return + case err != nil: + log.Printf("login: %v", err) + fail(http.StatusInternalServerError, "內部錯誤") + return + } + + s, err := createSession(db, u.ID) + if err != nil { + log.Printf("login: %v", err) + fail(http.StatusInternalServerError, "內部錯誤") + return + } + setSessionCookie(w, r, s) + + if isForm { + // PRG:以 303 導向帳號首頁 / 顯示已登入狀態,避免重新整理重複送出表單。 + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + writeJSON(w, http.StatusOK, loginResponse{User: newPublicUser(u), ExpiresAt: s.ExpiresAt}) + } +} + +// setSessionCookie 將 Session ID 寫入 HttpOnly Cookie(表單與 API 流程共用)。 +func setSessionCookie(w http.ResponseWriter, r *http.Request, s *Session) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: s.ID, + Path: "/", + Expires: s.ExpiresAt, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + // 本機 http 開發環境不設 Secure;請求經 TLS 服務時啟用。 + Secure: r.TLS != nil, + }) +} + +// ErrInvalidCredentials 表示帳號不存在或密碼錯誤,對外訊息一致。 +var ErrInvalidCredentials = errors.New("帳號或密碼錯誤") + +// dummyPasswordHash 供查無帳號時使用:對它做一次完整的 argon2 比對, +// 讓回應時間與真實驗證一致,避免以時間差枚舉有效帳號。 +var dummyPasswordHash = sync.OnceValues(func() (string, error) { + return hashPassword("alterminal-timing-equalizer") +}) + +// authenticateUser 以 username 查詢使用者並驗證密碼。 +func authenticateUser(db *gorm.DB, username, password string) (*User, error) { + var u User + err := db.Where("username = ?", username).First(&u).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + h, _ := dummyPasswordHash() + verifyPassword(password, h) // 結果丟棄,僅為消耗同等運算時間 + return nil, ErrInvalidCredentials + } + if err != nil { + return nil, fmt.Errorf("query user: %w", err) + } + if !u.CheckPassword(password) { + return nil, ErrInvalidCredentials + } + return &u, nil +} + +// newPublicUser 轉出可對外暴露的使用者欄位。 +func newPublicUser(u *User) publicUser { + return publicUser{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + EmailVerified: u.EmailVerified, + Name: u.Name, + Role: u.Role, + } +} + +// writeJSON 以 JSON 寫出回應。 +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +// writeError 寫出 {"error": ...} 格式的錯誤回應。 +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/login_test.go b/login_test.go new file mode 100644 index 0000000..b0f7710 --- /dev/null +++ b/login_test.go @@ -0,0 +1,156 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestLoginRequestValidate(t *testing.T) { + tests := []struct { + name string + in loginRequest + wantErr string // 空字串表示應通過 + }{ + {"最小欄位", loginRequest{Username: "alice", Password: "sup3r-secret"}, ""}, + {"username 帶首尾空白", loginRequest{Username: " alice ", Password: "sup3r-secret"}, ""}, + {"缺 username", loginRequest{Password: "sup3r-secret"}, "username"}, + {"username 僅空白", loginRequest{Username: " ", Password: "sup3r-secret"}, "username"}, + {"缺 password", loginRequest{Username: "alice"}, "password"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.in.validate() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("validate() = %v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("validate() = %v, want error containing %q", err, tt.wantErr) + } + }) + } +} + +func TestLoginRequestValidateTrimsUsername(t *testing.T) { + in := loginRequest{Username: " alice\t", Password: "sup3r-secret"} + if err := in.validate(); err != nil { + t.Fatal("validate: ", err) + } + if in.Username != "alice" { + t.Fatalf("validate 後 username = %q, want %q", in.Username, "alice") + } +} + +func TestNewRandomToken(t *testing.T) { + for _, n := range []int{16, 32} { + wantLen := (n*8 + 5) / 6 // base64url 無填充的編碼長度 + seen := make(map[string]bool) + for i := 0; i < 100; i++ { + token, err := newRandomToken(n) + if err != nil { + t.Fatal("newRandomToken: ", err) + } + if len(token) != wantLen { + t.Fatalf("n=%d token 長度 = %d, want %d", n, len(token), wantLen) + } + if seen[token] { + t.Fatalf("n=%d token 重複: %s", n, token) + } + seen[token] = true + } + } +} + +// 無效請求應在查詢資料庫前就回應,因此 handler 可以傳入 nil db 進行測試。 +func TestLoginHandlerRejectsInvalidInput(t *testing.T) { + h := loginHandler(nil) + plainReq := httptest.NewRequest(http.MethodPost, "/login", + strings.NewReader(`{"username":"alice","password":"sup3r-secret"}`)) + jsonReq := func(body string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req + } + tests := []struct { + name string + req *http.Request + wantStatus int + }{ + {"Content-Type 非 JSON", plainReq, http.StatusUnsupportedMediaType}, + {"JSON 格式錯誤", jsonReq(`{username:`), http.StatusBadRequest}, + {"缺 username", jsonReq(`{"password":"sup3r-secret"}`), http.StatusBadRequest}, + {"缺 password", jsonReq(`{"username":"alice"}`), http.StatusBadRequest}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h(rec, tt.req) + if rec.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d, body = %s", rec.Code, tt.wantStatus, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + if !strings.Contains(rec.Body.String(), `"error"`) { + t.Fatalf("回應應為 JSON error 格式: %s", rec.Body.String()) + } + }) + } +} + +func TestNewPublicUserOmitsPasswordHash(t *testing.T) { + u := &User{ID: 7, Username: "alice", Email: "alice@example.com", Name: "Alice", Role: RoleAdmin, PasswordHash: "$argon2id$secret"} + pu := newPublicUser(u) + if pu.ID != 7 || pu.Username != "alice" || pu.Email != "alice@example.com" || pu.Name != "Alice" || pu.Role != RoleAdmin { + t.Fatalf("newPublicUser() = %+v, 欄位不符", pu) + } + b, err := json.Marshal(pu) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "argon2") { + t.Fatalf("回應不得含密碼雜湊: %s", b) + } +} + +// 表單登入成功後以 303 導向帳號首頁 /,而非停留在 /login。 +func TestLoginHandlerFormSuccessRedirectsHome(t *testing.T) { + db, err := openDB() + if err != nil { + t.Skipf("資料庫不可用,略過整合測試: %v", err) + } + suffix, err := newRandomToken(6) + if err != nil { + t.Fatal(err) + } + u := &User{Username: "login-" + suffix, Email: "login-" + suffix + "@example.com", Name: "Login Test"} + if err := u.SetPassword("sup3r-secret"); err != nil { + t.Fatal(err) + } + if err := db.Create(u).Error; err != nil { + t.Fatalf("create user: %v", err) + } + t.Cleanup(func() { + db.Delete(&Session{}, "user_id = ?", u.ID) + db.Delete(&User{}, u.ID) + }) + + body := "csrf_token=token-A&username=" + u.Username + "&password=sup3r-secret" + req := formPost(body, &http.Cookie{Name: csrfCookieName, Value: "token-A"}) + rec := httptest.NewRecorder() + loginHandler(db)(rec, req) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String()) + } + if loc := rec.Header().Get("Location"); loc != "/" { + t.Fatalf("Location = %q, want /", loc) + } + if !strings.Contains(rec.Header().Get("Set-Cookie"), sessionCookieName) { + t.Fatalf("登入成功應設定 Session Cookie, Set-Cookie = %v", rec.Header().Values("Set-Cookie")) + } +} diff --git a/loginpage.go b/loginpage.go new file mode 100644 index 0000000..3f35c0c --- /dev/null +++ b/loginpage.go @@ -0,0 +1,173 @@ +package main + +import ( + "crypto/subtle" + "embed" + "errors" + "html/template" + "log" + "net/http" + "time" + + "gorm.io/gorm" +) + +//go:embed templates/*.html +var templateFS embed.FS + +var ( + loginTmpl = template.Must(template.ParseFS(templateFS, "templates/login.html")) + // 已登入頁與管理頁透過 layout.html(側邊導覽欄版面)組合:layout 為 + // 第一個(根)模板,頁面模板僅定義 title/content 等區塊覆寫之, + // 故 Execute 仍輸出版面本身。應用程式相關頁面另解析 secretpanel.html + // 的一次性成果面板區塊。 + loggedInTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/loggedin.html")) + adminKeysTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminkeys.html")) + adminApplicationsTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplications.html", "templates/secretpanel.html")) + adminApplicationNewTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplicationnew.html", "templates/secretpanel.html")) + notFoundTmpl = template.Must(template.ParseFS(templateFS, "templates/notfound.html")) +) + +// csrfCookieName 為登入表單 double-submit CSRF 防護的 Cookie 名稱: +// token 同時存在 Cookie 與表單隱藏欄位,送出時兩者必須相符。 +const ( + csrfCookieName = "alterminal_csrf" + csrfTTL = time.Hour +) + +// loginPageData 為登入表單頁的模板資料。 +type loginPageData struct { + Error string // 驗證失敗訊息;空字串表示不顯示 + Username string // 驗證失敗時保留使用者輸入的帳號 + CSRF string // 表單隱藏欄位用 CSRF token,與 Cookie 成對輪替 +} + +// loggedInPageData 為已登入狀態頁的模板資料。 +type loggedInPageData struct { + Error string // 錯誤訊息(如登出表單驗證失敗);空字串表示不顯示 + Username string + Email string + ExpiresAt string + IsAdmin bool // admin 另顯示管理頁(金鑰/應用程式)導覽連結 + CSRF string // 登出表單隱藏欄位用 CSRF token,與 Cookie 成對輪替 +} + +// loginPageHandler 處理 GET /login(POST /login 的瀏覽器入口):登入頁 +// 僅供未登入者使用——持有效 Session 時導向帳號首頁 /,否則顯示登入表單。 +func loginPageHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookieName); err == nil { + _, err = getSession(db, c.Value) + switch { + case err == nil: + http.Redirect(w, r, "/", http.StatusSeeOther) + return + case errors.Is(err, ErrSessionExpired): + // Session 過期,顯示登入表單 + default: + log.Printf("login page: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + } + renderLoginPage(w, r, http.StatusOK, "", "") + } +} + +// accountPageHandler 處理 GET /(帳號首頁):持有效 Session 顯示已登入 +// 狀態(含登出表單),否則顯示登入表單。 +func accountPageHandler(db *gorm.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + renderAccountPage(w, r, db, http.StatusOK, "") + } +} + +// renderAccountPage 依 Session 狀態輸出帳號頁:持有效 Session 顯示已登入 +// 狀態(含登出表單),否則顯示登入表單。errMsg 非空時顯示於輸出的頁面, +// 供登出表單驗證失敗等錯誤以指定 status 重繪目前狀態。 +func renderAccountPage(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, errMsg string) { + if c, err := r.Cookie(sessionCookieName); err == nil { + s, err := getSession(db, c.Value) + switch { + case err == nil: + renderLoggedInPage(w, r, status, s, errMsg) + return + case errors.Is(err, ErrSessionExpired): + // Session 過期,回到登入表單 + default: + log.Printf("login page: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + } + renderLoginPage(w, r, status, errMsg, "") +} + +// renderLoggedInPage 輸出已登入狀態頁;每次輸出都輪替 CSRF token, +// 供登出表單 double-submit 驗證。 +func renderLoggedInPage(w http.ResponseWriter, r *http.Request, status int, s *Session, errMsg string) { + token, err := newCSRFToken(w, r) + if err != nil { + log.Printf("csrf token: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + renderHTML(w, status, loggedInTmpl, loggedInPageData{ + Error: errMsg, + Username: s.User.Username, + Email: s.User.Email, + ExpiresAt: s.ExpiresAt.Local().Format("2006-01-02 15:04:05 MST"), + IsAdmin: s.User.Role == RoleAdmin, + CSRF: token, + }) +} + +// renderLoginPage 輸出登入表單頁;每次輸出都輪替 CSRF token 並重設對應 Cookie。 +func renderLoginPage(w http.ResponseWriter, r *http.Request, status int, errMsg, username string) { + token, err := newCSRFToken(w, r) + if err != nil { + log.Printf("csrf token: %v", err) + http.Error(w, "內部錯誤", http.StatusInternalServerError) + return + } + renderHTML(w, status, loginTmpl, loginPageData{Error: errMsg, Username: username, CSRF: token}) +} + +// newCSRFToken 產生新 CSRF token 並設定對應 Cookie,與表單隱藏欄位成對。 +func newCSRFToken(w http.ResponseWriter, r *http.Request) (string, error) { + token, err := newRandomToken(32) + if err != nil { + return "", err + } + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/", + MaxAge: int(csrfTTL.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil, + }) + return token, nil +} + +// verifyCSRF 以 constant-time 比對表單隱藏欄位與 Cookie 中的 CSRF token。 +func verifyCSRF(r *http.Request) bool { + c, err := r.Cookie(csrfCookieName) + if err != nil || c.Value == "" { + return false + } + token := r.PostFormValue("csrf_token") + return token != "" && subtle.ConstantTimeCompare([]byte(token), []byte(c.Value)) == 1 +} + +// renderHTML 以 text/html 輸出模板;模板執行錯誤僅記錄(此時表頭已送出)。 +// CSP 停用外部資源載入(樣式僅允許本站 /static/),表單僅可送出到本站。 +func renderHTML(w http.ResponseWriter, status int, tmpl *template.Template, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; form-action 'self'") + w.WriteHeader(status) + if err := tmpl.Execute(w, data); err != nil { + log.Printf("render template: %v", err) + } +} diff --git a/loginpage_test.go b/loginpage_test.go new file mode 100644 index 0000000..b80d09f --- /dev/null +++ b/loginpage_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func formPost(body string, cookie *http.Cookie) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if cookie != nil { + req.AddCookie(cookie) + } + return req +} + +// 未帶 Session Cookie 時不會查詢資料庫,因此 handler 可以傳入 nil db。 +func TestLoginPageRendersForm(t *testing.T) { + h := loginPageHandler(nil) + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Fatalf("Content-Type = %q, want text/html", ct) + } + if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "style-src 'self'") { + t.Fatalf("Content-Security-Policy = %q, 應停用外部資源載入且樣式僅允許本站", csp) + } + for _, want := range []string{`alert(1)") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + body := rec.Body.String() + if strings.Contains(body, "