重組前檢查點:根目錄 main package

This commit is contained in:
2026-10-03 09:23:38 +08:00
commit f373cb8d37
45 changed files with 5332 additions and 0 deletions
+32
View File
@@ -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
@@ -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 介面、環境變數都照舊,純粹移動 + 重新命名。
@@ -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 時以單元測試與建置結果為準
+63
View File
@@ -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/
+322
View File
@@ -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=<Session ID>'
# => 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
### 登入頁面
瀏覽器開啟 <http://localhost:8080/login> 即為 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)
```
+346
View File
@@ -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
}
+554
View File
@@ -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:面板以 <code>/<dd> 包裹
// 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("應顯示應用程式不存在")
}
})
}
+209
View File
@@ -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)
}
}
+377
View File
@@ -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("應顯示已退休訊息")
}
})
}
+267
View File
@@ -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
}
+291
View File
@@ -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)
}
}
+20
View File
@@ -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;
}
File diff suppressed because one or more lines are too long
+186
View File
@@ -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 <alice@example.com>" 這類含顯示名稱的寫法。
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
}
+102
View File
@@ -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 <alice@example.com>"}, "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")
}
})
}
+40
View File
@@ -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
}
+23
View File
@@ -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
)
+46
View File
@@ -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=
+26
View File
@@ -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"`
}
+117
View File
@@ -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
}
+175
View File
@@ -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)
}
+190
View File
@@ -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})
}
+156
View File
@@ -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"))
}
}
+173
View File
@@ -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)
}
}
+170
View File
@@ -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{`<form`, `name="username"`, `name="password"`, `name="csrf_token"`, `/static/css/main.css`} {
if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("登入表單缺少 %s", want)
}
}
if !strings.Contains(rec.Header().Get("Set-Cookie"), csrfCookieName) {
t.Fatal("輸出表單時應設定 CSRF Cookie")
}
}
// renderLoggedInPage 不查詢資料庫,可直接以虛構 Session 測試側邊導覽欄版面。
func TestRenderLoggedInPageSidebar(t *testing.T) {
rec := httptest.NewRecorder()
s := &Session{
ID: "test-session",
User: User{Username: "alice", Email: "alice@example.com"},
ExpiresAt: time.Now().Add(24 * time.Hour),
}
renderLoggedInPage(rec, httptest.NewRequest(http.MethodGet, "/login", nil), http.StatusOK, s, "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"<aside", // 側邊導覽欄
`aria-label="側邊導覽列"`,
"alterminal", // 品牌區
`href="/"`, // 導覽項目(帳號首頁)
`aria-current="page"`,
"帳號資訊",
`id="sidebar-toggle"`, // 手機版純 CSS 開合(CSP 不允許 JS)
`action="/logout"`, // 側欄頁尾的登出表單
`name="csrf_token"`,
"alice@example.com",
} {
if !strings.Contains(body, want) {
t.Errorf("已登入頁缺少 %s", want)
}
}
}
func TestRenderLoginPageStaysStandalone(t *testing.T) {
rec := httptest.NewRecorder()
renderLoginPage(rec, httptest.NewRequest(http.MethodGet, "/login", nil), http.StatusOK, "", "")
if strings.Contains(rec.Body.String(), "<aside") {
t.Fatal("登入表單頁應維持獨立版面,不含側邊導覽欄")
}
}
func TestRenderLoginPageEscapesPrefill(t *testing.T) {
rec := httptest.NewRecorder()
renderLoginPage(rec, httptest.NewRequest(http.MethodGet, "/login", nil),
http.StatusUnauthorized, "帳號或密碼錯誤", "<script>alert(1)</script>")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, "<script>") {
t.Fatal("預填帳號須經 HTML 轉義")
}
if !strings.Contains(body, "&lt;script&gt;") {
t.Fatal("預填帳號應以轉義後的值輸出")
}
if !strings.Contains(body, "帳號或密碼錯誤") {
t.Fatal("應顯示錯誤訊息")
}
}
func TestVerifyCSRF(t *testing.T) {
cookie := &http.Cookie{Name: csrfCookieName, Value: "token-A"}
tests := []struct {
name string
req *http.Request
want bool
}{
{"相符", formPost("csrf_token=token-A&username=a&password=b", cookie), true},
{"不相符", formPost("csrf_token=token-B&username=a&password=b", cookie), false},
{"缺少 Cookie", formPost("csrf_token=token-A&username=a&password=b", nil), false},
{"缺少欄位", formPost("username=a&password=b", cookie), false},
{"空欄位", formPost("csrf_token=&username=a&password=b", cookie), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := verifyCSRF(tt.req); got != tt.want {
t.Fatalf("verifyCSRF() = %v, want %v", got, tt.want)
}
})
}
}
// 表單流程的錯誤路徑都在查詢資料庫前回應,可用 nil db 測試。
func TestLoginHandlerFormRejections(t *testing.T) {
h := loginHandler(nil)
cookie := &http.Cookie{Name: csrfCookieName, Value: "token-A"}
t.Run("CSRF 不符回 403 表單", func(t *testing.T) {
rec := httptest.NewRecorder()
h(rec, formPost("csrf_token=wrong&username=alice&password=sup3r-secret", cookie))
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("Content-Type = %q, want text/html", rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
t.Fatal("應在表單中顯示 CSRF 錯誤訊息")
}
})
t.Run("缺 password 回 400 表單並保留帳號", func(t *testing.T) {
rec := httptest.NewRecorder()
h(rec, formPost("csrf_token=token-A&username=alice&password=", cookie))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "password 不可為空") {
t.Fatal("應顯示驗證錯誤訊息")
}
if !strings.Contains(body, `value="alice"`) {
t.Fatal("應保留使用者輸入的帳號")
}
})
t.Run("不支援的 Content-Type 回 JSON 415", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader("x=1"))
req.Header.Set("Content-Type", "text/plain")
h(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status = %d, want 415", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"error"`) {
t.Fatalf("非表單流程應回 JSON 錯誤: %s", rec.Body.String())
}
})
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"log"
"net/http"
"strings"
"gorm.io/gorm"
)
// logoutHandler 處理 POST /logout,依 Content-Type 分流(與登入一致):
// 表單走瀏覽器流程(需通過 CSRF 驗證,失敗時以 403 重繪目前狀態頁),
// JSON 走 API 流程。登出為冪等操作——查無 Session 亦視為成功;資料庫
// 刪除失敗僅記錄,仍清除 Cookie 並回應成功(Session 最遲於效期到期失效)。
func logoutHandler(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
}
if isForm {
if err := r.ParseForm(); err != nil {
renderAccountPage(w, r, db, http.StatusBadRequest, "無法解析表單內容")
return
}
if !verifyCSRF(r) {
renderAccountPage(w, r, db, http.StatusForbidden, "表單驗證失敗,請重新整理頁面後再試")
return
}
}
if c, err := r.Cookie(sessionCookieName); err == nil {
if err := deleteSession(db, c.Value); err != nil {
log.Printf("logout: %v", err)
}
}
clearSessionCookie(w, r)
if isForm {
// PRG:以 303 導向 /login 顯示登入表單,避免重新整理重複送出。
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// clearSessionCookie 以 Max-Age=0 清除瀏覽器的 Session Cookie(與
// setSessionCookie 對稱,屬性一致以免因 Path 或 Secure 差異清不掉)。
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
})
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// sessionCookieCleared 檢查回應是否以 Max-Age=0 清除 Session Cookie。
func sessionCookieCleared(rec *httptest.ResponseRecorder) bool {
for _, sc := range rec.Header().Values("Set-Cookie") {
if strings.HasPrefix(sc, sessionCookieName+"=") && strings.Contains(sc, "Max-Age=0") {
return true
}
}
return false
}
// 以下拒絕路徑皆不觸及資料庫,可用 nil db 測試。
func TestLogoutHandlerJSONWithoutCookie(t *testing.T) {
h := logoutHandler(nil)
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204, body = %s", rec.Code, rec.Body.String())
}
if !sessionCookieCleared(rec) {
t.Fatalf("應清除 Session Cookie, Set-Cookie = %v", rec.Header().Values("Set-Cookie"))
}
}
func TestLogoutHandlerRejectsUnsupportedContentType(t *testing.T) {
h := logoutHandler(nil)
req := httptest.NewRequest(http.MethodPost, "/logout", strings.NewReader("x=1"))
req.Header.Set("Content-Type", "text/plain")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status = %d, want 415", rec.Code)
}
if !strings.Contains(rec.Body.String(), `"error"`) {
t.Fatalf("應回 JSON 錯誤: %s", rec.Body.String())
}
}
func TestLogoutHandlerFormCSRFRejections(t *testing.T) {
h := logoutHandler(nil)
t.Run("CSRF 不符回 403 並重繪登入表單", func(t *testing.T) {
cookie := &http.Cookie{Name: csrfCookieName, Value: "token-A"}
rec := httptest.NewRecorder()
h(rec, formPost("csrf_token=token-B", cookie))
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("Content-Type = %q, want text/html", rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
t.Fatal("應顯示 CSRF 錯誤訊息")
}
if !strings.Contains(rec.Header().Get("Set-Cookie"), csrfCookieName) {
t.Fatal("重繪表單時應輪替 CSRF Cookie")
}
})
t.Run("缺 CSRF Cookie 回 403", func(t *testing.T) {
rec := httptest.NewRecorder()
h(rec, formPost("csrf_token=token-A", nil))
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
t.Run("表單無法解析回 400", func(t *testing.T) {
cookie := &http.Cookie{Name: csrfCookieName, Value: "token-A"}
rec := httptest.NewRecorder()
h(rec, formPost("csrf_token=%zz", cookie))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if !strings.Contains(rec.Body.String(), "無法解析表單內容") {
t.Fatal("應顯示解析錯誤訊息")
}
})
}
func TestLogoutIntegration(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: "logout-" + suffix, Email: "logout-" + suffix + "@example.com", Name: "Logout 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)
})
t.Run("已登入首頁含登出表單", func(t *testing.T) {
s, err := createSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: s.ID})
rec := httptest.NewRecorder()
accountPageHandler(db)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{`action="/logout"`, `name="csrf_token"`, "登出"} {
if !strings.Contains(body, want) {
t.Fatalf("已登入頁缺少 %s", want)
}
}
})
t.Run("已登入者造訪 /login 導向 /", func(t *testing.T) {
s, err := createSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: s.ID})
rec := httptest.NewRecorder()
loginPageHandler(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)
}
})
t.Run("表單登出刪除 Session 並導向 /login", func(t *testing.T) {
s, err := createSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
req := formPost("csrf_token=token-A", &http.Cookie{Name: csrfCookieName, Value: "token-A"})
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: s.ID})
rec := httptest.NewRecorder()
logoutHandler(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 != "/login" {
t.Fatalf("Location = %q, want /login", loc)
}
if !sessionCookieCleared(rec) {
t.Fatalf("應清除 Session Cookie, Set-Cookie = %v", rec.Header().Values("Set-Cookie"))
}
if _, err := getSession(db, s.ID); !errors.Is(err, ErrSessionExpired) {
t.Fatalf("登出後 getSession() = %v, want ErrSessionExpired", err)
}
})
t.Run("重複登出冪等", func(t *testing.T) {
req := formPost("csrf_token=token-A", &http.Cookie{Name: csrfCookieName, Value: "token-A"})
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "already-deleted"})
rec := httptest.NewRecorder()
logoutHandler(db)(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
})
t.Run("JSON 登出回 204", func(t *testing.T) {
s, err := createSession(db, u.ID)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: s.ID})
rec := httptest.NewRecorder()
logoutHandler(db)(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204, body = %s", rec.Code, rec.Body.String())
}
if !sessionCookieCleared(rec) {
t.Fatal("應清除 Session Cookie")
}
if _, err := getSession(db, s.ID); !errors.Is(err, ErrSessionExpired) {
t.Fatalf("登出後 getSession() = %v, want ErrSessionExpired", err)
}
})
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"log"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
if len(os.Args) > 1 {
runCommand(os.Args[1:])
return
}
db, err := openDB()
if err != nil {
log.Fatal("database: ", err)
}
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/", accountPageHandler(db))
r.Get("/users/{name}", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, " + chi.URLParam(r, "name") + "!"))
})
r.Get("/login", loginPageHandler(db))
r.Post("/login", loginHandler(db))
r.Post("/logout", logoutHandler(db))
r.Get("/admin/keys", adminKeysPageHandler(db))
r.Post("/admin/keys", adminKeysCreateHandler(db))
r.Post("/admin/keys/{id}/retire", adminKeysRetireHandler(db))
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))
r.Handle("/static/*", staticHandler())
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
sqlDB, err := db.DB()
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("db: " + err.Error()))
return
}
if err := sqlDB.Ping(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("db: " + err.Error()))
return
}
w.Write([]byte("ok"))
})
// 未匹配任何路由的路徑(不分方法)輸出自訂 404 頁。
r.NotFound(notFoundHandler)
if err := http.ListenAndServe(":8080", r); err != nil {
log.Fatal(err)
}
}
+10
View File
@@ -0,0 +1,10 @@
package main
import "net/http"
// notFoundHandler 回應自訂 404 頁,作為 chi 的 NotFound handler:僅在
// 沒有任何路由匹配時觸發(如 /static/ 下不存在的檔案由檔案伺服器
// 自行回應純文字 404),且不分方法——POST 到未知路徑同樣輸出本頁。
func notFoundHandler(w http.ResponseWriter, r *http.Request) {
renderHTML(w, http.StatusNotFound, notFoundTmpl, nil)
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func TestNotFoundHandlerRendersPage(t *testing.T) {
rec := httptest.NewRecorder()
notFoundHandler(rec, httptest.NewRequest(http.MethodGet, "/no-such-page", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", 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)
}
body := rec.Body.String()
for _, want := range []string{"404", "找不到頁面", `href="/login"`, `/static/css/main.css`} {
if !strings.Contains(body, want) {
t.Errorf("404 頁缺少 %s", want)
}
}
if strings.Contains(body, "<aside") {
t.Fatal("404 頁應為獨立版面,不含側邊導覽欄")
}
}
func TestRouterNotFoundUsesCustomPage(t *testing.T) {
// chi 的 NotFound 不分方法:GET 與 POST 到未匹配路徑都應輸出自訂頁。
r := chi.NewRouter()
r.Get("/login", func(w http.ResponseWriter, r *http.Request) {})
r.NotFound(notFoundHandler)
for _, method := range []string{http.MethodGet, http.MethodPost} {
rec := httptest.NewRecorder()
r.ServeHTTP(rec, httptest.NewRequest(method, "/definitely-not-a-route", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("%s status = %d, want 404", method, rec.Code)
}
if !strings.Contains(rec.Body.String(), "找不到頁面") {
t.Fatalf("%s 應輸出自訂 404 頁,body = %s", method, rec.Body.String())
}
}
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"gorm.io/gorm"
)
// Session 為使用者瀏覽器 Session(SSO 核心):ID 為加密安全亂數,
// 存於 HttpOnly Cookie,效期內使用者再經任何 RP 發起授權請求時
// 無須重新輸入帳密。
type Session struct {
ID string `gorm:"primaryKey;size:43"` // 32 bytes 亂數的 base64url
UserID uint `gorm:"not null;index"`
User User
ExpiresAt time.Time `gorm:"not null"`
CreatedAt time.Time
UpdatedAt time.Time
}
// sessionTTL 為 Session 有效時間,到期後 Cookie 失效、列為可清除。
const sessionTTL = 24 * time.Hour
// ErrSessionExpired 表示 Session 不存在或已過期。
var ErrSessionExpired = errors.New("session 不存在或已過期")
// newRandomToken 產生 n bytes 加密安全亂數的 base64url 字串(無填充;
// n=32 時為 43 字元),供 Session ID 與 CSRF token 共用。
func newRandomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("read random: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// createSession 為使用者建立新 Session,順帶刪除所有已過期 Session
// (最佳清除,失敗不影響登入結果)。
func createSession(db *gorm.DB, userID uint) (*Session, error) {
id, err := newRandomToken(32)
if err != nil {
return nil, err
}
s := &Session{ID: id, UserID: userID, ExpiresAt: time.Now().Add(sessionTTL)}
if err := db.Create(s).Error; err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
db.Where("expires_at < ?", time.Now()).Delete(&Session{})
return s, nil
}
// deleteSession 以 ID 刪除 Session(登出用)。查無該 Session 不視為
// 錯誤,讓登出維持冪等。
func deleteSession(db *gorm.DB, id string) error {
if err := db.Delete(&Session{}, "id = ?", id).Error; err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
// getSession 以 ID 查詢效期內的 Session(含所屬使用者)。
func getSession(db *gorm.DB, id string) (*Session, error) {
var s Session
err := db.Preload("User").Where("id = ? AND expires_at > ?", id, time.Now()).First(&s).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrSessionExpired
}
if err != nil {
return nil, fmt.Errorf("query session: %w", err)
}
return &s, nil
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"embed"
"io/fs"
"net/http"
)
//go:embed assets
var assetsFS embed.FS
// staticHandler 以 /static/ 前綴提供 assets 內的靜態檔案
// (Tailwind 建置輸出的 CSS 等),並允許瀏覽器快取。
func staticHandler() http.Handler {
sub, err := fs.Sub(assetsFS, "assets")
if err != nil {
panic(err) // embed 路徑固定,僅防呆
}
fileServer := http.StripPrefix("/static/", http.FileServerFS(sub))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 內容隨執行檔重建,過期重抓即可。
w.Header().Set("Cache-Control", "public, max-age=3600")
fileServer.ServeHTTP(w, r)
})
}
+39
View File
@@ -0,0 +1,39 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestStaticHandlerServesCSS(t *testing.T) {
h := staticHandler()
req := httptest.NewRequest(http.MethodGet, "/static/css/main.css", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") {
t.Fatalf("Content-Type = %q, want text/css", ct)
}
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=3600" {
t.Fatalf("Cache-Control = %q, want public, max-age=3600", cc)
}
if rec.Body.Len() == 0 {
t.Fatal("CSS 內容不應為空")
}
}
func TestStaticHandlerRejectsTraversal(t *testing.T) {
h := staticHandler()
// FileServer 以路徑對應 embed FS,目錄外不存在任何檔案,穿越應得到 404。
req := httptest.NewRequest(http.MethodGet, "/static/../main.go", nil)
req.URL.Path = "/static/../main.go"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
+97
View File
@@ -0,0 +1,97 @@
{{/* 註冊新應用程式頁(僅 admin,獨立於管理列表頁)。以 layout.html(側邊
導覽欄版面)為根模板組合渲染:本檔僅定義區塊,不應單獨解析執行;
並引用 secretpanel.html 的一次性成果面板。導覽覆寫同管理列表頁
(「應用程式管理」標記 aria-current)。註冊成功時於 POST 回應直接
顯示成果(明文 client secret 僅此一次,故不採 PRG);驗證失敗重繪
時保留輸入(含核取方塊)。 */}}
{{define "title"}}註冊新應用程式 - alterminal{{end}}
{{define "navitems"}}
<li>
<a href="/login"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
</svg>
帳號資訊
</a>
</li>
<li>
<a href="/admin/keys"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/>
</svg>
金鑰管理
</a>
</li>
<li>
<a href="/admin/applications" aria-current="page"
class="flex items-center gap-3 rounded-lg bg-brand/10 px-3 py-2 text-sm font-medium text-brand dark:bg-brand/25 dark:text-blue-200">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"/>
</svg>
應用程式管理
</a>
</li>
{{end}}
{{define "content"}}
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="mb-1 text-xl font-semibold">註冊新應用程式</h1>
<p class="text-sm text-neutral-500 dark:text-neutral-400">接入 OIDC 的應用程式(Relying Party)註冊</p>
</div>
<a href="/admin/applications"
class="flex items-center gap-2 rounded-lg border border-neutral-300 px-4 py-2.5 text-sm font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/>
</svg>
返回列表
</a>
</div>
{{if .Error}}<p class="mb-4 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
{{if .Secret}}{{template "secretpanel" .Secret}}{{end}}
<form method="post" action="/admin/applications/new" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<div>
<label for="app-name" class="mb-1 block text-sm font-medium">名稱</label>
<input id="app-name" name="name" type="text" required maxlength="255" value="{{.Form.Name}}"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-900">
</div>
<div>
<label for="app-type" class="mb-1 block text-sm font-medium">類型</label>
<select id="app-type" name="type"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-900">
<option value="confidential" {{if eq .Form.Type "confidential"}}selected{{end}}>機密式 confidential(後端應用,發配 client secret)</option>
<option value="public" {{if eq .Form.Type "public"}}selected{{end}}>公開式 public(SPA/行動應用,無 secret,須用 PKCE)</option>
</select>
</div>
<div>
<label for="app-redirect-uris" class="mb-1 block text-sm font-medium">Redirect URI(每行一個)</label>
<textarea id="app-redirect-uris" name="redirect_uris" rows="3"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 font-mono text-xs dark:border-neutral-600 dark:bg-neutral-900">{{.Form.RedirectURIs}}</textarea>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">須為絕對 URI;http 僅允許 localhost/127.0.0.1/::1,其餘請使用 https;原生應用可用自訂 scheme(如 com.example.app:/cb)。</p>
</div>
<fieldset>
<legend class="mb-1 text-sm font-medium">允許的 grant type</legend>
<div class="space-y-1.5 text-sm">
<label class="flex items-center gap-2"><input type="checkbox" name="grant_types" value="authorization_code" class="size-4" {{if .Form.GrantAuthCode}}checked{{end}}> authorization_code(授權碼流程)</label>
<label class="flex items-center gap-2"><input type="checkbox" name="grant_types" value="refresh_token" class="size-4" {{if .Form.GrantRefresh}}checked{{end}}> refresh_token(Refresh Token)</label>
<label class="flex items-center gap-2"><input type="checkbox" name="grant_types" value="client_credentials" class="size-4" {{if .Form.GrantClientCred}}checked{{end}}> client_credentials(機器對機器,僅機密式)</label>
</div>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">全不勾選時預設 authorization_code。</p>
</fieldset>
<div>
<label for="app-scope" class="mb-1 block text-sm font-medium">Scope</label>
<input id="app-scope" name="scope" type="text" value="{{.Form.Scope}}" placeholder="openid profile email"
class="w-full rounded-lg border border-neutral-300 px-3 py-2 font-mono text-xs dark:border-neutral-600 dark:bg-neutral-900">
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">空格分隔,可用:openid、profile、email、offline_access;留空使用預設 openid profile email(offline_access 須勾選 refresh_token)。</p>
</div>
<button type="submit"
class="rounded-lg bg-brand px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-strong">註冊應用程式</button>
</form>
</section>
{{end}}
+111
View File
@@ -0,0 +1,111 @@
{{/* 應用程式管理頁(僅 admin)。以 layout.html(側邊導覽欄版面)為根模板
組合渲染:本檔僅定義區塊,不應單獨解析執行;並引用 secretpanel.html
的一次性成果面板。導覽覆寫為「帳號資訊+金鑰管理+應用程式管理」
(後者標記 aria-current),側欄頁尾沿用版面預設。註冊表單獨立於
/admin/applications/new(本頁按鈕進入);輪替成功時於 POST 回應直接
顯示明文 client secret(僅此一次,故不採 PRG)。 */}}
{{define "title"}}應用程式管理 - alterminal{{end}}
{{define "navitems"}}
<li>
<a href="/login"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
</svg>
帳號資訊
</a>
</li>
<li>
<a href="/admin/keys"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/>
</svg>
金鑰管理
</a>
</li>
<li>
<a href="/admin/applications" aria-current="page"
class="flex items-center gap-3 rounded-lg bg-brand/10 px-3 py-2 text-sm font-medium text-brand dark:bg-brand/25 dark:text-blue-200">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"/>
</svg>
應用程式管理
</a>
</li>
{{end}}
{{define "content"}}
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="mb-1 text-xl font-semibold">應用程式管理</h1>
<p class="text-sm text-neutral-500 dark:text-neutral-400">接入 OIDC 的應用程式(Relying Party)註冊</p>
</div>
<a href="/admin/applications/new"
class="flex items-center gap-2 rounded-lg bg-brand px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-strong">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
</svg>
註冊新應用程式
</a>
</div>
{{if .Error}}<p class="mb-4 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
{{if .Secret}}{{template "secretpanel" .Secret}}{{end}}
{{if .Apps}}
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-neutral-200 dark:border-neutral-700">
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">名稱</th>
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">client_id</th>
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">類型</th>
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">redirect URI</th>
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">grant type</th>
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">scope</th>
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">建立時間</th>
<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-100 dark:divide-neutral-700/60">
{{range .Apps}}
<tr>
<td class="px-3 py-3 font-medium">{{.Name}}</td>
<td class="px-3 py-3 font-mono text-xs break-all">{{.ClientID}}</td>
<td class="px-3 py-3 whitespace-nowrap">
{{if .Confidential}}
<span class="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-400">機密式</span>
{{else}}
<span class="rounded-full bg-violet-500/10 px-2.5 py-0.5 text-xs font-medium text-violet-700 dark:text-violet-400">公開式</span>
{{end}}
</td>
<td class="px-3 py-3 font-mono text-xs break-all whitespace-pre-line">{{.RedirectURIs}}</td>
<td class="px-3 py-3 text-xs">{{.GrantTypes}}</td>
<td class="px-3 py-3 font-mono text-xs break-all">{{.Scope}}</td>
<td class="px-3 py-3 whitespace-nowrap text-neutral-500 dark:text-neutral-400">{{.CreatedAt}}</td>
<td class="px-3 py-3">
{{if .Confidential}}
<form method="post" action="/admin/applications/{{.ID}}/secret" class="mb-1">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<button type="submit" title="產生新 client secret(舊的立即失效)"
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-xs font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">輪替 secret</button>
</form>
{{end}}
<form method="post" action="/admin/applications/{{.ID}}/delete">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<button type="submit" title="刪除後此應用程式無法再登入"
class="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50 dark:border-red-500/60 dark:text-red-400 dark:hover:bg-red-500/10">刪除</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="py-6 text-sm text-neutral-500 dark:text-neutral-400">尚無應用程式,點選上方「註冊新應用程式」建立第一個。</p>
{{end}}
</section>
{{end}}
+99
View File
@@ -0,0 +1,99 @@
{{/* 金鑰管理頁(僅 admin)。以 layout.html(側邊導覽欄版面)為根模板組合
渲染:本檔僅定義區塊,不應單獨解析執行。導覽覆寫為「帳號資訊+金鑰
管理(aria-current)+應用程式管理」,側欄頁尾沿用版面預設(使用者
資訊與登出表單)。 */}}
{{define "title"}}金鑰管理 - alterminal{{end}}
{{define "navitems"}}
<li>
<a href="/login"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
</svg>
帳號資訊
</a>
</li>
<li>
<a href="/admin/keys" aria-current="page"
class="flex items-center gap-3 rounded-lg bg-brand/10 px-3 py-2 text-sm font-medium text-brand dark:bg-brand/25 dark:text-blue-200">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/>
</svg>
金鑰管理
</a>
</li>
<li>
<a href="/admin/applications"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"/>
</svg>
應用程式管理
</a>
</li>
{{end}}
{{define "content"}}
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
<div class="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="mb-1 text-xl font-semibold">金鑰管理</h1>
<p class="text-sm text-neutral-500 dark:text-neutral-400">JWT 簽章金鑰(RSA-2048 · RS256)</p>
</div>
<form method="post" action="/admin/keys">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit"
class="rounded-lg bg-brand px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-strong">產生新金鑰</button>
</form>
</div>
{{if .Error}}<p class="mb-4 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
{{if not .ActiveCount}}<p class="mb-4 rounded-lg bg-amber-500/10 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-400" role="alert">目前沒有使用中的金鑰,將無法簽發 JWT,請立即產生新金鑰。</p>{{end}}
{{if .Keys}}
<p class="mb-3 text-sm text-neutral-500 dark:text-neutral-400">使用中 {{.ActiveCount}} 把 / 共 {{len .Keys}} 把</p>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-neutral-200 dark:border-neutral-700">
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">kid</th>
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">演算法</th>
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">建立時間</th>
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">狀態</th>
<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-100 dark:divide-neutral-700/60">
{{range .Keys}}
<tr>
<td class="px-3 py-3 font-mono text-xs break-all">{{.Kid}}</td>
<td class="px-3 py-3 whitespace-nowrap">{{.Algorithm}}</td>
<td class="px-3 py-3 whitespace-nowrap text-neutral-500 dark:text-neutral-400">{{.CreatedAt}}</td>
<td class="px-3 py-3 whitespace-nowrap">
{{if .Active}}
<span class="rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-xs font-medium text-emerald-700 dark:text-emerald-400">使用中</span>
{{else}}
<span class="rounded-full bg-neutral-500/10 px-2.5 py-0.5 text-xs font-medium text-neutral-600 dark:text-neutral-400">已退休</span>
{{end}}
</td>
<td class="px-3 py-3 whitespace-nowrap">
{{if .Active}}{{if .LastActive}}
<span class="text-xs text-neutral-400 dark:text-neutral-500" title="最後一把使用中金鑰,無法退休">唯一使用中金鑰</span>
{{else}}
<form method="post" action="/admin/keys/{{.ID}}/retire">
<input type="hidden" name="csrf_token" value="{{$.CSRF}}">
<button type="submit"
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-xs font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">退休</button>
</form>
{{end}}{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="mt-4 text-sm text-neutral-500 dark:text-neutral-400">輪替方式:先「產生新金鑰」並以新金鑰簽發,舊金鑰確認無人使用後再「退休」(退休後仍發佈於 JWKS 一段時間供驗證)。</p>
{{else}}
<p class="py-6 text-sm text-neutral-500 dark:text-neutral-400">尚無簽章金鑰,點上方「產生新金鑰」建立第一把。</p>
{{end}}
</section>
{{end}}
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>{{block "title" .}}alterminal{{end}}</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
{{/*
側邊導覽欄版面(app shell):桌面版(md+)側欄固定展開,主內容以
md:pl-72 偏移;手機版側欄預設移出畫面外,以隱藏 checkbox(peer)搭配
peer-checked: 變體開合——CSP 停用 JavaScript,故開合必須是純 CSS。
頁面模板需定義 "content",可另定義 "title"、"navitems"、"sidebarfooter"
(後三者未定義時使用此處的預設)。
*/}}
<body class="min-h-screen bg-neutral-100 font-sans text-neutral-900 antialiased dark:bg-neutral-900 dark:text-neutral-100">
<div class="min-h-screen">
<input type="checkbox" id="sidebar-toggle" class="peer sr-only" aria-label="切換側邊導覽列">
<label for="sidebar-toggle" title="開啟導覽列"
class="fixed left-4 top-4 z-50 flex size-11 cursor-pointer items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-600 shadow-sm md:hidden peer-checked:hidden peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-brand dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
<svg class="size-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
</label>
<label for="sidebar-toggle" aria-hidden="true"
class="fixed inset-0 z-30 hidden cursor-pointer bg-neutral-900/40 peer-checked:block md:hidden!"></label>
<aside aria-label="側邊導覽列"
class="fixed inset-y-0 left-0 z-40 flex w-72 -translate-x-full flex-col border-r border-neutral-200 bg-white transition-transform duration-200 ease-in-out peer-checked:translate-x-0 md:translate-x-0 dark:border-neutral-700 dark:bg-neutral-800">
<div class="flex items-center gap-3 border-b border-neutral-200 px-6 py-5 dark:border-neutral-700">
<span class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand text-white">
<svg class="size-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/>
</svg>
</span>
<span class="min-w-0">
<span class="block text-base font-semibold leading-tight">alterminal</span>
<span class="block text-xs text-neutral-500 dark:text-neutral-400">單一登入服務</span>
</span>
</div>
<nav aria-label="主要導覽" class="flex-1 overflow-y-auto px-3 py-4">
<ul class="space-y-1">
{{block "navitems" .}}
<li>
<a href="/" aria-current="page"
class="flex items-center gap-3 rounded-lg bg-brand/10 px-3 py-2 text-sm font-medium text-brand dark:bg-brand/25 dark:text-blue-200">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
</svg>
帳號資訊
</a>
</li>
{{if .IsAdmin}}
<li>
<a href="/admin/keys"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/>
</svg>
金鑰管理
</a>
</li>
<li>
<a href="/admin/applications"
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"/>
</svg>
應用程式管理
</a>
</li>
{{end}}
{{end}}
</ul>
</nav>
<div class="border-t border-neutral-200 px-4 py-4 dark:border-neutral-700">
{{/* 預設頁尾:使用者資訊與登出表單(頁面資料需含 Username/Email/CSRF),
未登入脈絡的頁面不應使用本版面。 */}}
{{block "sidebarfooter" .}}
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{.Username}}</p>
<p class="truncate text-xs text-neutral-500 dark:text-neutral-400">{{.Email}}</p>
</div>
<form method="post" action="/logout" class="mt-3">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<button type="submit"
class="flex w-full items-center justify-center gap-2 rounded-lg border border-neutral-300 py-2 text-sm font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0L18 12m0 0 2.25-2.25M18 12H9"/>
</svg>
登出
</button>
</form>
{{end}}
</div>
</aside>
<div class="flex min-h-screen flex-col md:pl-72">
<main class="mx-auto w-full max-w-3xl flex-1 p-4 md:p-10">
{{template "content" .}}
</main>
</div>
</div>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{{/* 已登入狀態頁。以 layout.html(側邊導覽欄版面)為根模板組合渲染:
本檔僅定義區塊,不應單獨解析執行。導覽與側欄頁尾沿用版面預設
(帳號資訊標記 aria-current;admin 另顯示金鑰管理連結;
頁尾為使用者資訊與登出表單)。 */}}
{{define "title"}}帳號資訊 - alterminal{{end}}
{{define "content"}}
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
<h1 class="mb-1 text-xl font-semibold">帳號資訊</h1>
<p class="mb-6 text-sm text-neutral-500 dark:text-neutral-400">已登入:單一登入服務</p>
{{if .Error}}<p class="mb-2 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
<dl class="m-0">
<dt class="text-sm text-neutral-500 dark:text-neutral-400">帳號</dt>
<dd class="mt-0.5 mb-3.5 text-[15px] break-all">{{.Username}}</dd>
<dt class="text-sm text-neutral-500 dark:text-neutral-400">Email</dt>
<dd class="mt-0.5 mb-3.5 text-[15px] break-all">{{.Email}}</dd>
<dt class="text-sm text-neutral-500 dark:text-neutral-400">Session 到期</dt>
<dd class="mt-0.5 mb-3.5 text-[15px] break-all">{{.ExpiresAt}}</dd>
</dl>
<p class="mt-4 text-sm text-neutral-500 dark:text-neutral-400">授權流程(/authorize)完成後,登入將自動導回應用程式。</p>
</section>
{{end}}
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>登入 - alterminal</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body class="flex min-h-screen items-center justify-center bg-neutral-100 font-sans text-neutral-900 antialiased dark:bg-neutral-900 dark:text-neutral-100">
<main class="m-4 w-full max-w-88 rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
<h1 class="mb-1 text-xl font-semibold">登入 alterminal</h1>
<p class="mb-6 text-sm text-neutral-500 dark:text-neutral-400">單一登入服務</p>
{{if .Error}}<p class="mb-2 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
<label for="username" class="mb-1 mt-4 block text-sm">帳號</label>
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" autofocus required
class="w-full rounded-lg border border-neutral-300 bg-transparent px-3 py-2.5 text-base focus:border-transparent focus:outline-2 focus:outline-offset-1 focus:outline-brand dark:border-neutral-600">
<label for="password" class="mb-1 mt-4 block text-sm">密碼</label>
<input type="password" id="password" name="password" autocomplete="current-password" required
class="w-full rounded-lg border border-neutral-300 bg-transparent px-3 py-2.5 text-base focus:border-transparent focus:outline-2 focus:outline-offset-1 focus:outline-brand dark:border-neutral-600">
<button type="submit" class="mt-6 w-full rounded-lg bg-brand py-2.5 text-base font-semibold text-white hover:bg-brand-strong">登入</button>
</form>
</main>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>找不到頁面 - alterminal</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
{{/*
404 頁採獨立版面(同登入頁):訪客可能未登入,無法提供側邊導覽欄
版面所需的 Session 資料。CSP 停用 JavaScript,無法用 history.back(),
僅提供回到 /login 的連結(未登入顯示登入表單、已登入顯示帳號資訊)。
*/}}
<body class="flex min-h-screen items-center justify-center bg-neutral-100 font-sans text-neutral-900 antialiased dark:bg-neutral-900 dark:text-neutral-100">
<main class="m-4 w-full max-w-88 rounded-xl bg-white p-8 text-center shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
<p class="text-5xl font-bold tracking-tight text-brand">404</p>
<h1 class="mb-1 mt-3 text-xl font-semibold">找不到頁面</h1>
<p class="mb-6 text-sm text-neutral-500 dark:text-neutral-400">要求的頁面不存在,可能已被移動或網址有誤。</p>
<a href="/login" class="inline-block w-full rounded-lg bg-brand px-4 py-2.5 text-base font-semibold text-white hover:bg-brand-strong">回到登入頁</a>
</main>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{{/* 一次性成果面板(共用片段):註冊應用程式與輪替 client secret 成功時,
於 POST 回應直接渲染——資料庫僅存雜湊,明文無法重現,故不採 PRG。
僅定義 "secretpanel" 區塊供頁面模板以 {{template "secretpanel" .Secret}}
引用,不應單獨解析執行。機密式顯示明文 client_secret 與保存警告;
公開式無 secret,改提示以 PKCE 驗證授權請求。 */}}
{{define "secretpanel"}}
<div class="mb-6 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-4" role="status">
{{if .Rotated}}
<h2 class="mb-2 text-sm font-semibold text-emerald-700 dark:text-emerald-400">{{.Name}}:client secret 已輪替</h2>
{{else if .Public}}
<h2 class="mb-2 text-sm font-semibold text-emerald-700 dark:text-emerald-400">{{.Name}} 已註冊(公開式 Client)</h2>
{{else}}
<h2 class="mb-2 text-sm font-semibold text-emerald-700 dark:text-emerald-400">{{.Name}} 已註冊:client secret 已發配</h2>
{{end}}
<dl class="space-y-2 text-sm">
<div class="flex flex-wrap items-center gap-2">
<dt class="font-medium text-neutral-700 dark:text-neutral-300">client_id</dt>
<dd class="font-mono text-xs break-all">{{.ClientID}}</dd>
</div>
{{if .Secret}}
<div class="flex flex-wrap items-center gap-2">
<dt class="font-medium text-neutral-700 dark:text-neutral-300">client_secret</dt>
<dd class="font-mono text-xs break-all">{{.Secret}}</dd>
</div>
{{end}}
</dl>
{{if .Secret}}
<p class="mt-3 text-sm font-medium text-red-600 dark:text-red-400">此 client secret 只顯示這一次,關閉或重新整理頁面後將無法再查看,請立即交付給應用程式管理者妥善保存。</p>
{{else}}
<p class="mt-3 text-sm text-neutral-600 dark:text-neutral-300">公開式 Client 不持有 client secret,授權請求須以 PKCE(code_challenge)驗證。</p>
{{end}}
</div>
{{end}}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"errors"
"flag"
"fmt"
"strings"
"gorm.io/gorm"
)
// runUpdatePassword 解析旗標並重設指定帳號的密碼。此為管理用指令,
// 不需驗證舊密碼;密碼更新成功後一併撤銷該使用者所有 Session,
// 避免既有登入在密碼重設後續存。
func runUpdatePassword(args []string) error {
fs := flag.NewFlagSet("update-password", flag.ExitOnError)
username := fs.String("username", "", "登入帳號(必填)")
password := fs.String("password", "", "新密碼(選填;省略時於終端機輸入)")
fs.Parse(args)
name := strings.TrimSpace(*username)
if name == "" {
return errors.New("username 不可為空")
}
pw, err := resolvePassword(*password)
if err != nil {
return err
}
db, err := openDB()
if err != nil {
return fmt.Errorf("database: %w", err)
}
u, err := findUserByUsername(db, name)
if err != nil {
return err
}
if err := u.SetPassword(pw); err != nil {
return fmt.Errorf("hash password: %w", err)
}
revoked, err := updateUserPassword(db, u)
if err != nil {
return err
}
fmt.Printf("密碼更新成功:id=%d username=%s(已撤銷 %d 個 Session)\n", u.ID, u.Username, revoked)
return nil
}
// findUserByUsername 以帳號查詢使用者,查無時回傳可讀的錯誤。
func findUserByUsername(db *gorm.DB, username string) (*User, error) {
var u User
err := db.Where("username = ?", username).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("username %q 不存在", username)
}
if err != nil {
return nil, fmt.Errorf("query user: %w", err)
}
return &u, nil
}
// updateUserPassword 於單一交易內寫入新密碼雜湊並刪除該使用者所有
// Session,回傳撤銷的 Session 數;交易確保密碼與 Session 不會只更新一半。
func updateUserPassword(db *gorm.DB, u *User) (int64, error) {
var revoked int64
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(u).Update("password_hash", u.PasswordHash).Error; err != nil {
return fmt.Errorf("update password: %w", err)
}
res := tx.Where("user_id = ?", u.ID).Delete(&Session{})
if res.Error != nil {
return fmt.Errorf("delete sessions: %w", res.Error)
}
revoked = res.RowsAffected
return nil
})
return revoked, err
}
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"strings"
"testing"
)
// go test 執行時 stdin 不是終端機,輸入驗證應在連線資料庫前就失敗。
func TestRunUpdatePasswordValidatesInput(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{"缺 username", nil, "username"},
{"username 僅空白", []string{"-username", " "}, "username"},
{"省略 -password 且非終端機", []string{"-username", "alice"}, "-password"},
{"新密碼過短", []string{"-username", "alice", "-password", "1234567"}, "8"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := runUpdatePassword(tt.args)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("runUpdatePassword(%v) = %v, want error containing %q", tt.args, err, tt.wantErr)
}
})
}
}
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/argon2"
)
// Role 為使用者角色:admin 具管理權限,user 為一般權限。
type Role string
// 允許的角色值。
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
// valid 回傳角色是否為允許的值。
func (r Role) valid() bool {
return r == RoleAdmin || r == RoleUser
}
// User 為使用者帳號模型,對應 users 資料表。
// Username 與 Email 皆為唯一;密碼以 argon2id(PHC 格式)雜湊儲存,永不存明文。
type User struct {
ID uint `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex;size:64;not null"` // 登入帳號
Email string `gorm:"uniqueIndex;size:255;not null"` // OIDC email scope
EmailVerified bool `gorm:"not null;default:false"` // OIDC email_verified claim
PasswordHash string `gorm:"size:255;not null"` // argon2id PHC 字串
Name string `gorm:"size:255"` // 顯示名稱(profile scope 的 name claim)
Role Role `gorm:"size:16;not null;default:user"` // admin 或 user
CreatedAt time.Time
UpdatedAt time.Time
}
// SetPassword 以 argon2id 雜湊密碼並寫入 PasswordHash。
func (u *User) SetPassword(password string) error {
hash, err := hashPassword(password)
if err != nil {
return err
}
u.PasswordHash = hash
return nil
}
// CheckPassword 回傳密碼是否與 PasswordHash 相符;雜湊格式無效時一律視為不相符。
func (u *User) CheckPassword(password string) bool {
ok, err := verifyPassword(password, u.PasswordHash)
return err == nil && ok
}
// 參數採 OWASP 對 Argon2id 的建議:m=19 MiB、t=2、p=1,salt 16 bytes、key 32 bytes。
const (
argon2MemoryKB = 19 * 1024
argon2Time = 2
argon2Threads = 1
argon2SaltLen = 16
argon2KeyLen = 32
)
// hashPassword 產生格式如 $argon2id$v=19$m=19456,t=2,p=1$<salt>$<key> 的 PHC 字串。
func hashPassword(password string) (string, error) {
salt := make([]byte, argon2SaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("read salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argon2Time, argon2MemoryKB, argon2Threads, argon2KeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argon2MemoryKB, argon2Time, argon2Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
// verifyPassword 解析 PHC 字串並以 constant-time 比對重算結果。
func verifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("malformed password hash")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, fmt.Errorf("parse version: %w", err)
}
if version != argon2.Version {
return false, fmt.Errorf("unsupported argon2id version %d", version)
}
var memoryKB, timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memoryKB, &timeCost, &threads); err != nil {
return false, fmt.Errorf("parse parameters: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, fmt.Errorf("decode salt: %w", err)
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, fmt.Errorf("decode key: %w", err)
}
got := argon2.IDKey([]byte(password), salt, timeCost, memoryKB, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
+57
View File
@@ -0,0 +1,57 @@
package main
import (
"strings"
"testing"
)
func TestSetAndCheckPassword(t *testing.T) {
u := &User{}
if err := u.SetPassword("correct horse battery staple"); err != nil {
t.Fatal("SetPassword: ", err)
}
if u.PasswordHash == "" || strings.Contains(u.PasswordHash, "correct horse") {
t.Fatalf("密碼不應以明文儲存: %q", u.PasswordHash)
}
if !u.CheckPassword("correct horse battery staple") {
t.Error("正確密碼應驗證成功")
}
if u.CheckPassword("Tr0ub4dor&3") {
t.Error("錯誤密碼不應驗證成功")
}
}
func TestSetPasswordUsesRandomSalt(t *testing.T) {
a, b := &User{}, &User{}
if err := a.SetPassword("same password"); err != nil {
t.Fatal(err)
}
if err := b.SetPassword("same password"); err != nil {
t.Fatal(err)
}
if a.PasswordHash == b.PasswordHash {
t.Error("相同密碼應因隨機 salt 產生不同雜湊")
}
}
func TestCheckPasswordMalformedHash(t *testing.T) {
for _, hash := range []string{"", "not-a-phc-hash", "$argon2id$v=19$incomplete"} {
u := &User{PasswordHash: hash}
if u.CheckPassword("whatever") {
t.Errorf("格式無效的雜湊 %q 不應驗證成功", hash)
}
}
}
func TestRoleValid(t *testing.T) {
for _, r := range []Role{RoleAdmin, RoleUser} {
if !r.valid() {
t.Errorf("Role(%q).valid() = false, want true", r)
}
}
for _, r := range []Role{"", "Admin", "superuser", "root"} {
if r.valid() {
t.Errorf("Role(%q).valid() = true, want false", r)
}
}
}