first commit

This commit is contained in:
2026-10-03 10:44:29 +08:00
parent f373cb8d37
commit bcf3d3769c
58 changed files with 4313 additions and 487 deletions
+72 -49
View File
@@ -2,7 +2,7 @@
輕量級單一登入(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 推進。
> **狀態:開發中。** 目前完成專案骨架(HTTP 服務、資料庫連線、健康檢查)、使用者帳號(CLI 建帳與重設密碼、argon2id 密碼雜湊)、登入/登出(HTML 登入頁+JSON API、Session Cookie)、應用程式與金鑰管理頁,以及 OIDC 核心(Discovery、Authorization Code Flow+PKCE、Token 端點、UserInfo、Refresh Token 輪替),其餘功能依下方 Roadmap 推進。
## 特色
@@ -32,19 +32,22 @@
| 端點 | 方法 | 說明 | 狀態 |
| --- | --- | --- | --- |
| `/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` 導向) | ✅ 已完成 |
| `/.well-known/openid-configuration` | GET | OIDC Discovery 文件(端點與能力中繼資料,`Cache-Control: max-age=3600`) | ✅ 已完成 |
| `/.well-known/jwks.json` | GET | Token 簽署用公開金鑰(JWKS,僅發佈使用中金鑰,`Cache-Control: max-age=3600`) | ✅ 已完成 |
| `/login` | POST | 使用者登入(JSON API 與 HTML 表單提交;表單支援 `next` 返回路徑) | ✅ 已完成 |
| `/login` | GET | 使用者登入頁(HTML 表單,供 `/authorize` 導向並以 `next` 攜回授權請求) | ✅ 已完成 |
| `/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 取得使用者資訊 | 🚧 規劃中 |
| `/authorize` | GET | 授權端點(Authorization Code Flow+PKCE;未登入導向 `/login?next=...`,首次授權顯示同意頁) | ✅ 已完成 |
| `/authorize` | POST | 同意頁決定(同意記錄於 `consents`,同範圍之後靜默通過;拒絕回 `access_denied`) | ✅ 已完成 |
| `/token` | POST | 權杖端點(`authorization_code`+PKCE 與 `refresh_token` 兩種 grant;Basic/POST client 認證) | ✅ 已完成 |
| `/userinfo` | GET/POST | 以 Bearer Access Token 取得使用者 claims(依授權 scope) | ✅ 已完成 |
| `/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}` | GET | 編輯應用程式頁(預填既有註冊內容;client_id 唯讀,僅 admin) | ✅ 已完成 |
| `/admin/applications/{id}` | POST | 更新應用程式註冊內容(表單+CSRF,PRG;client_id 與 secret 不變,改為公開式時清除 secret 雜湊) | ✅ 已完成 |
| `/admin/applications/{id}/secret` | POST | 輪替 client secret(舊 secret 立即失效,明文僅顯示一次) | ✅ 已完成 |
| `/admin/applications/{id}/delete` | POST | 刪除應用程式註冊(表單+CSRF,PRG) | ✅ 已完成 |
| `/admin/keys` | GET | 金鑰管理頁(kid、狀態、輪替操作;僅 admin) | ✅ 已完成 |
@@ -73,7 +76,7 @@ docker run -d --name alterminal-db \
### 啟動服務
```bash
go run .
go run ./cmd/alterminal
# 服務啟動於 http://localhost:8080
```
@@ -87,16 +90,16 @@ curl http://localhost:8080/health
### 編譯執行檔
```bash
go build -o alterminal .
go build -o alterminal ./cmd/alterminal
./alterminal
# 服務啟動於 http://localhost:8080
```
- HTML 模板與 Tailwind 建置輸出(`main.css`)皆以 `go:embed` 內嵌,產出為**單一執行檔**,部署時不需連同 `templates/`、`assets/` 一併安裝
- HTML 模板與 Tailwind 建置輸出(`main.css`)皆以 `go:embed` 內嵌,產出為**單一執行檔**,部署時不需連同模板與樣式檔一併安裝
- 依賴全為純 Go(PostgreSQL 驅動採 pgx,無 CGO),可直接交叉編譯。部署至 Linux 伺服器:
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o alterminal .
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o alterminal ./cmd/alterminal
# ARM 伺服器改用 GOARCH=arm64
```
@@ -119,7 +122,7 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o alterminal .
### 建立使用者帳號
```bash
go run . create-account -username alice -email alice@example.com -name "Alice"
go run ./cmd/alterminal create-account -username alice -email alice@example.com -name "Alice"
輸入密碼: ********
再次輸入密碼: ********
帳號建立成功:id=1 username=alice email=alice@example.com role=user
@@ -139,7 +142,7 @@ go run . create-account -username alice -email alice@example.com -name "Alice"
管理用密碼重設(不驗證舊密碼)。密碼更新與 Session 撤銷於**同一資料庫交易**內完成,成功後該帳號所有 Session 立即失效——對 SSO Provider 而言,若密碼重設(例如帳號外洩的處置)後既有 Session 仍繼續有效,重設便失去意義:
```bash
go run . update-password -username alice
go run ./cmd/alterminal update-password -username alice
輸入密碼: ********
再次輸入密碼: ********
密碼更新成功:id=1 username=alice(已撤銷 2 個 Session)
@@ -205,21 +208,21 @@ curl -i -X POST http://localhost:8080/logout \
### 登入頁面
瀏覽器開啟 <http://localhost:8080/login> 即為 HTML 登入頁(Go `html/template`,模板位於 `templates/`,以 `go:embed` 打進執行檔):
瀏覽器開啟 <http://localhost:8080/login> 即為 HTML 登入頁(Go `html/template`,模板位於 `internal/auth/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 驗證保護)
- 登入成功採 PRG 模式:`303` 導向 `/login`,持有效 Session 時該頁顯示帳號資訊(帳號、Email、Session 到期時間),並套用側邊導覽版面(`internal/auth/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**。
頁面樣式使用 Tailwind CSS v4。模板(`internal/auth/templates/*.html`)直接寫 utility class,樣式進入點在 `internal/auth/assets/css/input.css`(含 `@theme` 自訂品牌色與中文字型),建置輸出 `internal/auth/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
tools/tailwindcss -i internal/auth/assets/css/input.css -o internal/auth/assets/css/main.css --minify
```
Tailwind 為官方 [standalone CLI](https://tailwindlabs.github.io/tailwindcss/)(版本見 `tools/tailwindcss-version.txt`,`tools/` 不納入版本控制),首次取得方式:
@@ -241,7 +244,7 @@ chmod +x tools/tailwindcss
| `DB_PASSWORD` | 資料庫密碼 | `postgres` | ✅ |
| `DB_NAME` | 資料庫名稱 | `alterminal` | ✅ |
| `PORT` | 服務監聽埠號 | `8080` | 🚧 規劃中 |
| `ISSUER` | OIDC Issuer URL(對外完整網址,須含 scheme,不可帶尾斜線) | `http://localhost:8080` | 🚧 規劃中 |
| `ISSUER` | OIDC Issuer URL(對外完整網址,須含 scheme,不可帶尾斜線;簽入 token 的 `iss` claim 與 Discovery 的 `issuer` 欄位須完全一致) | `http://localhost:8080` | ✅ |
## 授權流程(Authorization Code Flow + PKCE)
@@ -266,6 +269,28 @@ sequenceDiagram
之後其他 RP 發起授權時,因瀏覽器 Session 仍有效,使用者無須再次輸入帳密,即為單一登入(SSO)。
### /authorize
- 參數:`response_type=code`、`client_id`、`redirect_uri`、`scope`(必含 `openid` 且不得超出應用程式註冊範圍)、`state`、`nonce`、`code_challenge`+`code_challenge_method=S256`(RFC 7636;公開式 Client 必須提供,不支援 `plain`)
- `client_id`/`redirect_uri` 無法確認時直接回 `400` 錯誤頁、不重導(RFC 6749 §4.1.2.1,防止作為開放重導向器);其餘錯誤以 `302` 重導回 `redirect_uri?error=...&state=...`
- 未登入:`303` 導向 `/login?next=<完整授權請求>`,登入成功後回到本端點繼續(`next` 經站內路徑驗證,阻擋 open redirect)
- 同意頁:首次授權顯示應用程式名稱與 scope 清單(表單+CSRF);同意記錄為 scope 聯集,之後請求範圍未擴大時靜默通過,範圍擴大時再次詢問;拒絕以 `access_denied` 重導回 RP
- 授權碼:32 bytes 亂數(資料庫僅存 SHA-256 雜湊)、效期 5 分鐘、一次性,發行當下的 redirect URI/scope/nonce/PKCE challenge/auth_time 隨碼凍結供兌換時逐項比對
### /token
- 僅接受 `POST`+`application/x-www-form-urlencoded`(RFC 6749 §2.3.1)
- Client 認證:HTTP Basic(`client_id:client_secret`)或表單欄位(`client_secret_post`);機密式 Client 必驗 secret(argon2id 比對),公開式 Client 以 client_id 識別、由 PKCE 承擔防護;認證失敗回 `401 invalid_client`
- `grant_type=authorization_code`:逐項比對授權碼綁定內容(client、redirect_uri、PKCE `S256(verifier)`);重用授權碼除回 `invalid_grant` 外並撤銷其發行的所有 refresh token
- `grant_type=refresh_token`:兌換即輪替(舊 token 作廢、發新 token),scope 僅可縮小不可擴大;偵測到重用已輪替的 token 時撤銷該使用者於該應用程式的全部 refresh token(OAuth 2.0 Security BCP)
- 回應:`access_token`(JWT/RS256,效期 15 分鐘、自包含不落庫)、`id_token`(scope 含 `openid` 時;含 `iss`/`sub`/`aud`/`nonce`/`auth_time` 及依 scope 的 `name`/`preferred_username`/`email`/`email_verified`)、`refresh_token`(scope 含 `offline_access` 時,效期 30 天)、`scope`(正規化排序形式);成功與錯誤回應皆附 `Cache-Control: no-store`
### /userinfo
- Bearer Access Token 取自 `Authorization` 標頭(RFC 6750;POST 亦接受表單 `access_token` 欄位)
- 驗證:RS256 簽章(header `kid` 對應 `signing_keys`,拒絕其他 `alg`)、`iss`、`exp`;無效回 `401`+`WWW-Authenticate: Bearer error="invalid_token"`,缺少 `openid` scope 回 `403 insufficient_scope`
- 回應依授權 scope:`sub` 恆有;`profile` 加 `name`/`preferred_username`;`email` 加 `email`/`email_verified`(OIDC Core §5.4)
## 資料模型
Access Token 採用自包含的 JWT,不落庫儲存;其餘狀態儲存於 PostgreSQL(GORM 自動遷移)。
@@ -273,50 +298,48 @@ Access Token 採用自包含的 JWT,不落庫儲存;其餘狀態儲存於 Po
| 資料表 | 說明 | 狀態 |
| --- | --- | --- |
| `users` | 使用者帳號(帳號、Email、密碼雜湊) | ✅ 已完成(含 argon2id 密碼雜湊) |
| `applications` | 已註冊的 RP 應用程式(client_id、client secret 雜湊、redirect URIs、grant types、scope、confidential/public) | 🚧 模型與管理頁已完成(`Application`:argon2id secret 雜湊、redirect URI 格式驗證;`/admin/applications` 註冊/輪替/刪除),註冊 API 規劃中 |
| `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 端點與輪替排程規劃中 |
| `authorization_codes` | 授權碼(SHA-256 雜湊儲存、一次性、效期 5 分鐘、凍結授權當下的 redirect URI/scope/nonce/PKCE challenge/auth_time) | ✅ 已完成 |
| `refresh_tokens` | Refresh Token(SHA-256 雜湊儲存、效期 30 天、兌換即輪替、重用時整鏈撤銷) | ✅ 已完成 |
| `consents` | 使用者×應用程式的同意記錄(scope 聯集;同範圍靜默通過) | ✅ 已完成 |
| `signing_keys` | RSA 簽章金鑰(供 JWKS 輪替) | ✅ 模型與管理頁已完成(`internal/jwk`:PKCS#8 儲存、RFC 7638 kid、RFC 7517 JWK/JWKS 公開形式;`/admin/keys` 產生/退休;`/.well-known/jwks.json` 已上線),輪替排程規劃中 |
## 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 輪替與撤銷
- [ ] Client 管理:RP 註冊 API(管理頁 `/admin/applications` 已完成:註冊、編輯、client_secret 發配與輪替、刪除,僅 admin)
- [x] 金鑰管理:RSA 金鑰產生、`/.well-known/jwks.json`、金鑰輪替(手動:產生新鑰後退休舊鑰;自動排程規劃中)
- [x] OIDC Discovery:`/.well-known/openid-configuration`
- [x] Authorization Code Flow + PKCE(`/authorize`,含同意頁與首次同意後記住)
- [x] Token 端點:Access Token(JWT/RS256)、ID Token、Refresh Token 簽發與驗證
- [x] UserInfo 端點(`/userinfo`)
- [x] 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)
├── cmd/alterminal/
│ └── main.go # 程式進入點、路由裝配、CLI 分派
└── internal/
├── auth/ # 使用者認證、Session、密碼雜湊
├── client/ # RP Client 註冊與驗證
├── oidc/ # OIDC 核心:authorize / token / userinfo / logout
├── auth/ # 認證領域與非管理頁 HTTP:User/Session 模型、
│ │ # argon2id 密碼雜湊、登入/登出、模板與 CSRF、
│ │ # 靜態檔、自訂 404
│ ├── templates/ # HTML 模板(Tailwind utility class)
│ └── assets/css/ # Tailwind 進入點(input.css)與建置輸出(main.css)
├── application/ # Application 模型(RP 註冊與驗證)
├── store/ # 資料庫連線與自動遷移
├── admin/ # 管理頁:/admin/keys 金鑰、/admin/applications 應用程式
├── cli/ # create-account / update-password 子指令
├── testdb/ # 整合測試共用資料庫(alterminal_test)
├── jwk/ # 簽章金鑰與 JWKS
└── httpx/ # 共用 HTTP 工具(錯誤回應、middleware)
└── oidc/ # OIDC 核心端點與憑證模型:Discovery、/authorize
# (同意頁)、/token、/userinfo、JWKS,以及
# authorization_codes/refresh_tokens/consents
```