forked from alterminal/alterminal
重組前檢查點:根目錄 main package
This commit is contained in:
+32
@@ -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 時以單元測試與建置結果為準
|
||||
@@ -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/
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "<script>") {
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user