Files
alterminal/README.md
T

323 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```