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

This commit is contained in:
2026-10-03 09:23:38 +08:00
commit f373cb8d37
45 changed files with 5332 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
# alterminal
輕量級單一登入(SSO)服務,實作 [OpenID Connect](https://openid.net/connect/) 協定。alterminal 扮演 **OpenID Provider(OP / Identity Provider)**,讓多個應用程式(Relying Party, RP)透過標準協定完成身分認證,實現「登入一次,處處可用」。
> **狀態:開發中。** 目前完成專案骨架(HTTP 服務、資料庫連線、健康檢查)、使用者帳號(CLI 建帳與重設密碼、argon2id 密碼雜湊)與登入/登出(HTML 登入頁+JSON API、Session Cookie),OIDC 核心功能依下方 Roadmap 推進。
## 特色
- **標準 OIDC Provider**
- Discovery(`/.well-known/openid-configuration`)與 JWKS(`/.well-known/jwks.json`)
- 以 RS256 簽發 ID Token,支援金鑰輪替(rotation)
- **OAuth 2.0 / OIDC 授權流程**
- Authorization Code Flow + PKCE(RFC 7636),支援機密式(confidential)與公開式(public)Client
- Refresh Token(含輪替與撤銷)
- Client Credentials Grant(機器對機器情境)
- **單一登入/單一登出**
- 已登入使用者在瀏覽器 Session 有效期間內,可直接通過新 RP 的授權請求
- RP-Initiated Logout(OIDC Front-/Back-Channel Logout 列於 Roadmap)
- **技術棧單純**:Go + [chi](https://github.com/go-chi/chi) + [GORM](https://gorm.io) + PostgreSQL + [Tailwind CSS](https://tailwindcss.com)(建置產物內嵌),單一執行檔即可部署
## 支援的 Scope
| Scope | 說明 |
| --- | --- |
| `openid` | 必選。要求簽發 ID Token |
| `profile` | 使用者基本資料(`name` 等 claim) |
| `email` | 使用者 Email(`email`、`email_verified`) |
| `offline_access` | 簽發 Refresh Token |
## 端點一覽
| 端點 | 方法 | 說明 | 狀態 |
| --- | --- | --- | --- |
| `/health` | GET | 健康檢查(含資料庫連線檢測) | ✅ 已完成 |
| `/.well-known/openid-configuration` | GET | OIDC Discovery 文件 | 🚧 規劃中 |
| `/.well-known/jwks.json` | GET | Token 簽署用公開金鑰(JWKS) | 🚧 規劃中 |
| `/login` | POST | 使用者登入(JSON API 與 HTML 表單提交) | ✅ 已完成 |
| `/login` | GET | 使用者登入頁(HTML 表單,供 `/authorize` 導向) | ✅ 已完成 |
| `/logout` | POST | 使用者登出(HTML 表單與 JSON API,冪等) | ✅ 已完成 |
| `/static/*` | GET | 靜態檔(Tailwind 建置輸出的 CSS,`go:embed` 內嵌) | ✅ 已完成 |
| `/authorize` | GET | 授權端點(Authorization Code Flow) | 🚧 規劃中 |
| `/token` | POST | 權杖端點(換發 Access / ID / Refresh Token) | 🚧 規劃中 |
| `/userinfo` | GET/POST | 以 Access Token 取得使用者資訊 | 🚧 規劃中 |
| `/logout` | GET | RP-Initiated Logout(OIDC:`id_token_hint`、`post_logout_redirect_uri` 等參數驗證) | 🚧 規劃中 |
| `/admin/applications` | GET | 應用程式管理頁(RP 註冊列表;僅 admin) | ✅ 已完成 |
| `/admin/applications/new` | GET | 註冊新應用程式頁(獨立表單頁;僅 admin) | ✅ 已完成 |
| `/admin/applications/new` | POST | 註冊應用程式(明文 client_secret 僅於本次回應顯示一次,表單+CSRF) | ✅ 已完成 |
| `/admin/applications/{id}/secret` | POST | 輪替 client secret(舊 secret 立即失效,明文僅顯示一次) | ✅ 已完成 |
| `/admin/applications/{id}/delete` | POST | 刪除應用程式註冊(表單+CSRF,PRG) | ✅ 已完成 |
| `/admin/keys` | GET | 金鑰管理頁(kid、狀態、輪替操作;僅 admin) | ✅ 已完成 |
| `/admin/keys` | POST | 產生新 RSA 簽章金鑰(表單+CSRF,PRG) | ✅ 已完成 |
| `/admin/keys/{id}/retire` | POST | 退休金鑰(最後一把使用中金鑰不可退休) | ✅ 已完成 |
| `*`(未匹配路徑) | 任意 | 自訂 404 頁(HTML,不分方法;`/static/` 下不存在的檔案仍由檔案伺服器回純文字 404) | ✅ 已完成 |
## 快速開始
### 前置需求
- Go 1.26+
- PostgreSQL 14+(或直接用 Docker)
### 啟動資料庫
```bash
docker run -d --name alterminal-db \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=alterminal \
-p 5432:5432 \
postgres:17
```
### 啟動服務
```bash
go run .
# 服務啟動於 http://localhost:8080
```
### 驗證
```bash
curl http://localhost:8080/health
# => ok
```
### 編譯執行檔
```bash
go build -o alterminal .
./alterminal
# 服務啟動於 http://localhost:8080
```
- HTML 模板與 Tailwind 建置輸出(`main.css`)皆以 `go:embed` 內嵌,產出為**單一執行檔**,部署時不需連同 `templates/`、`assets/` 一併安裝
- 依賴全為純 Go(PostgreSQL 驅動採 pgx,無 CGO),可直接交叉編譯。部署至 Linux 伺服器:
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o alterminal .
# ARM 伺服器改用 GOARCH=arm64
```
- 編譯前建議先跑測試:`go test ./...`
### 帳號管理 CLI
帳號相關操作由 CLI 子指令完成,共通行為:
- 不需啟動伺服器,指令會自行連線資料庫(連線參數同 `DB_*` 環境變數)
- 密碼一律以 argon2id 重新雜湊(每次產生新 salt),明文不落地
- 密碼至少 8 字元;省略 `-password` 旗標時於終端機無回顯輸入兩次,非互動環境(cron、CI)必須提供旗標
- 失敗時以 `log.Fatal` 結束(exit code 1);未知子指令 exit code 2 並列出用法
| 子指令 | 說明 |
| --- | --- |
| `create-account` | 建立使用者帳號 |
| `update-password` | 重設帳號密碼(管理用途,不驗證舊密碼),成功後撤銷該帳號所有 Session |
### 建立使用者帳號
```bash
go run . create-account -username alice -email alice@example.com -name "Alice"
輸入密碼: ********
再次輸入密碼: ********
帳號建立成功:id=1 username=alice email=alice@example.com role=user
```
| 旗標 | 說明 |
| --- | --- |
| `-username` | 登入帳號(必填、唯一;僅英數與 `. _ -`,最長 64) |
| `-email` | Email(必填、唯一) |
| `-name` | 顯示名稱(選填) |
| `-role` | 角色(選填,`admin` 或 `user`,預設 `user`) |
| `-email-verified` | 將 Email 標記為已驗證(選填,預設 `false`) |
| `-password` | 密碼(至少 8 字元;省略時於終端機無回顯輸入兩次,非互動環境必須提供) |
### 更新密碼
管理用密碼重設(不驗證舊密碼)。密碼更新與 Session 撤銷於**同一資料庫交易**內完成,成功後該帳號所有 Session 立即失效——對 SSO Provider 而言,若密碼重設(例如帳號外洩的處置)後既有 Session 仍繼續有效,重設便失去意義:
```bash
go run . update-password -username alice
輸入密碼: ********
再次輸入密碼: ********
密碼更新成功:id=1 username=alice(已撤銷 2 個 Session)
```
| 旗標 | 說明 |
| --- | --- |
| `-username` | 要重設密碼的帳號(必填) |
| `-password` | 新密碼(至少 8 字元;省略時於終端機無回顯輸入兩次,非互動環境必須提供) |
錯誤情境(exit code 1,訊息前綴 `update-password: `):
| 錯誤訊息 | 情境 |
| --- | --- |
| `username 不可為空` | 未提供 `-username`,或值僅空白 |
| `username "alice" 不存在` | 查無該帳號 |
| `密碼長度至少 8 字元` | 新密碼過短 |
| `兩次輸入的密碼不一致` | 終端機兩次輸入不同 |
| `非互動環境無法提示輸入密碼,請以 -password 提供` | 省略 `-password` 且 stdin 非終端機 |
### 登入 API
`POST /login` 以 JSON 驗證帳密,成功時建立瀏覽器 Session 並以 `Set-Cookie` 下發(`alterminal_session`,HttpOnly、SameSite=Lax,效期 24 小時):
```bash
curl -i -X POST http://localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"sup3r-secret"}'
```
成功(200):
```json
{
"user": {"id": 1, "username": "alice", "email": "alice@example.com", "email_verified": false, "name": "Alice"},
"expires_at": "2026-10-03T09:00:00Z"
}
```
錯誤回應皆為 `{"error": "..."}`:
| 狀態碼 | 情境 |
| --- | --- |
| `400` | JSON 無法解析、`username`/`password` 缺漏 |
| `401` | 帳號不存在或密碼錯誤(訊息一致,不洩漏帳號是否存在;查無帳號時仍執行等時的 argon2 比對) |
| `415` | `Content-Type` 非 `application/json` |
### 登出 API
`POST /logout` 刪除資料庫中的 Session 並以 `Max-Age=0` 清除瀏覽器 Cookie。與 `/login` 相同依 `Content-Type` 分流,登出為**冪等**操作——查無有效 Session 亦視為成功:
```bash
curl -i -X POST http://localhost:8080/logout \
-H 'Content-Type: application/json' \
-b 'alterminal_session=<Session ID>'
# => 204 No Content,Set-Cookie 以 Max-Age=0 清除 alterminal_session
```
- **JSON 流程**:成功回 `204`,無回應內容
- **表單流程**(瀏覽器):需通過 double-submit CSRF 驗證(與登入表單同一機制),成功後 `303` 導向 `/login`(PRG);CSRF 不符回 `403` 並重繪目前狀態頁
- 資料庫刪除失敗僅記錄,仍完成 Cookie 清除(Session 最遲於效期到期自動失效)
- 跨應用程式單一登出(Front-/Back-Channel Logout)與 RP-Initiated Logout(`GET /logout`,含 OIDC 參數驗證)列於 Roadmap
### 登入頁面
瀏覽器開啟 <http://localhost:8080/login> 即為 HTML 登入頁(Go `html/template`,模板位於 `templates/`,以 `go:embed` 打進執行檔):
- 表單提交(`application/x-www-form-urlencoded`)與 JSON API 共用同一套帳密驗證與 Session 流程
- 表單附 double-submit CSRF token(Cookie 與隱藏欄位比對),不符時回 `403` 並重新輸出表單
- 帳密錯誤時重繪表單(`401`),保留帳號輸入並顯示錯誤訊息
- 登入成功採 PRG 模式:`303` 導向 `/login`,持有效 Session 時該頁顯示帳號資訊(帳號、Email、Session 到期時間),並套用側邊導覽版面(`templates/layout.html`,之後的頁面可重用):桌面版側欄固定展開,手機版以純 CSS checkbox 開合(CSP 不允許 JavaScript),側欄頁尾為使用者資訊與「登出」按鈕(`POST /logout`,同樣受 CSRF 驗證保護)
### 前端樣式(Tailwind CSS)
頁面樣式使用 Tailwind CSS v4。模板(`templates/*.html`)直接寫 utility class,樣式進入點在 `assets/css/input.css`(含 `@theme` 自訂品牌色與中文字型),建置輸出 `assets/css/main.css` 已提交並以 `go:embed` 內嵌,經 `/static/css/main.css` 提供——**一般開發與部署不需 Node**。
調整樣式後重新建置:
```bash
tools/tailwindcss -i assets/css/input.css -o assets/css/main.css --minify
```
Tailwind 為官方 [standalone CLI](https://tailwindlabs.github.io/tailwindcss/)(版本見 `tools/tailwindcss-version.txt`,`tools/` 不納入版本控制),首次取得方式:
```bash
mkdir -p tools
curl -sL -o tools/tailwindcss \
https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64
chmod +x tools/tailwindcss
```
### 環境變數
| 變數 | 說明 | 預設值 | 狀態 |
| --- | --- | --- | --- |
| `DB_HOST` | PostgreSQL 位址 | `localhost` | ✅ |
| `DB_PORT` | PostgreSQL 埠號 | `5432` | ✅ |
| `DB_USER` | 資料庫使用者 | `postgres` | ✅ |
| `DB_PASSWORD` | 資料庫密碼 | `postgres` | ✅ |
| `DB_NAME` | 資料庫名稱 | `alterminal` | ✅ |
| `PORT` | 服務監聽埠號 | `8080` | 🚧 規劃中 |
| `ISSUER` | OIDC Issuer URL(對外完整網址,須含 scheme,不可帶尾斜線) | `http://localhost:8080` | 🚧 規劃中 |
## 授權流程(Authorization Code Flow + PKCE)
```mermaid
sequenceDiagram
participant U as 使用者(瀏覽器)
participant RP as 應用程式(RP)
participant OP as alterminal(OP)
RP->>U: 重新導向至 /authorize(附 client_id、redirect_uri、scope、code_challenge)
U->>OP: GET /authorize
OP->>U: 未登入 → 導向 /login
U->>OP: 輸入帳密,完成驗證並建立 Session
OP->>U: 確認授權後,攜帶 code 重新導向回 redirect_uri
U->>RP: 回呼 redirect_uri?code=...
RP->>OP: POST /token(附 code、client_id、client_secret、code_verifier)
OP-->>RP: Access Token、ID Token(JWT / RS256)、(可選)Refresh Token
RP->>OP: GET /userinfo(附 Access Token)
OP-->>RP: 使用者 Claims
RP-->>U: 登入完成
```
之後其他 RP 發起授權時,因瀏覽器 Session 仍有效,使用者無須再次輸入帳密,即為單一登入(SSO)。
## 資料模型
Access Token 採用自包含的 JWT,不落庫儲存;其餘狀態儲存於 PostgreSQL(GORM 自動遷移)。
| 資料表 | 說明 | 狀態 |
| --- | --- | --- |
| `users` | 使用者帳號(帳號、Email、密碼雜湊) | ✅ 已完成(含 argon2id 密碼雜湊) |
| `applications` | 已註冊的 RP 應用程式(client_id、client secret 雜湊、redirect URIs、grant types、scope、confidential/public) | 🚧 模型與管理頁已完成(`Application`:argon2id secret 雜湊、redirect URI 格式驗證;`/admin/applications` 註冊/輪替/刪除),註冊 API 規劃中 |
| `sessions` | 使用者瀏覽器 Session(SSO 核心,HttpOnly Cookie,效期 24 小時) | ✅ 已完成 |
| `authorization_codes` | 授權碼(一次性、短時效、綁定 PKCE challenge) | 🚧 規劃中 |
| `refresh_tokens` | Refresh Token(支援輪替與撤銷偵測) | 🚧 規劃中 |
| `signing_keys` | RSA 簽章金鑰(供 JWKS 輪替) | 🚧 模型與管理頁已完成(`internal/jwk`:PKCS#8 儲存、RFC 7638 kid、RFC 7517 JWK/JWKS 公開形式;`/admin/keys` 產生/退休),JWKS 端點與輪替排程規劃中 |
## Roadmap
- [x] 專案骨架:chi 路由、GORM + PostgreSQL 連線、`/health` 健康檢查
- [x] 使用者系統:註冊、登入/登出、密碼重設(撤銷 Session)、密碼雜湊(argon2id)、瀏覽器 Session
- [ ] Client 管理:RP 註冊 API(管理頁 `/admin/applications` 已完成:註冊、client_secret 發配與輪替、刪除,僅 admin)
- [ ] 金鑰管理:RSA 金鑰產生、`/.well-known/jwks.json`、金鑰輪替
- [ ] OIDC Discovery:`/.well-known/openid-configuration`
- [ ] Authorization Code Flow + PKCE(`/authorize`)
- [ ] Token 端點:Access Token(JWT)、ID Token、Refresh Token 簽發與驗證
- [ ] UserInfo 端點(`/userinfo`)
- [ ] Refresh Token 輪替與撤銷
- [ ] RP-Initiated Logout(`/logout`)
- [ ] Client Credentials Grant
- [ ] Front-Channel / Back-Channel Logout(跨 RP 單一登出)
- [ ] 管理 API 與簡易管理介面
## 專案結構(目標)
```
alterminal/
├── main.go # 程式進入點、路由裝配
├── db.go # 資料庫連線與自動遷移
├── user.go # 使用者帳號(Account)模型與 argon2id 密碼雜湊
├── createaccount.go # create-account CLI 子指令(建立使用者帳號)
├── updatepassword.go # update-password CLI 子指令(重設密碼並撤銷 Session)
├── login.go # POST /login(JSON API 與表單共用流程)
├── loginpage.go # GET /login 登入頁(html/template + CSRF)
├── logout.go # POST /logout(Session 刪除與 Cookie 清除)
├── adminkeys.go # /admin/keys 金鑰管理頁(僅 admin:產生/退休)
├── notfound.go # 自訂 404 頁(chi NotFound handler)
├── session.go # 瀏覽器 Session 模型與管理
├── static.go # /static/ 靜態檔服務(go:embed)
├── templates/ # HTML 模板(Tailwind utility class)
├── assets/css/ # Tailwind 進入點(input.css)與建置輸出(main.css)
└── internal/
├── auth/ # 使用者認證、Session、密碼雜湊
├── client/ # RP Client 註冊與驗證
├── oidc/ # OIDC 核心:authorize / token / userinfo / logout
├── jwk/ # 簽章金鑰與 JWKS
└── httpx/ # 共用 HTTP 工具(錯誤回應、middleware)
```