Files
alterminal/README.md
T
2026-10-03 12:37:38 +08:00

24 KiB
Raw Blame History

alterminal

輕量級單一登入(SSO)服務,實作 OpenID Connect 協定。alterminal 扮演 OpenID Provider(OP / Identity Provider),讓多個應用程式(Relying Party, RP)透過標準協定完成身分認證,實現「登入一次,處處可用」。

狀態:開發中。 目前完成專案骨架(HTTP 服務、資料庫連線、健康檢查)、使用者帳號(CLI 建帳與重設密碼、argon2id 密碼雜湊、網頁自助更新密碼)、登入/登出(HTML 登入頁+JSON API、Session Cookie)、應用程式與金鑰管理頁,以及 OIDC 核心(Discovery、Authorization Code Flow+PKCE、Token 端點、UserInfo、Refresh Token 輪替、RP-Initiated Logout),其餘功能依下方 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 + GORM + PostgreSQL + Tailwind CSS(建置產物內嵌),單一執行檔即可部署

支援的 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 文件(端點與能力中繼資料,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,冪等) ✅ 已完成
/logout GET RP-Initiated Logout(id_token_hint 驗證、無 hint 或不符目前 Session 時顯示確認頁;post_logout_redirect_uri 精確比對註冊值後重導並回填 state) ✅ 已完成
/password GET 更新密碼頁(登入者自助變更;未登入導向 /login?next=/password) ✅ 已完成
/password POST 更新密碼(驗證目前密碼;表單+CSRF 與 JSON API。成功後於交易內撤銷全部 Session,再輪替目前瀏覽器的 Session——其他裝置立即登出) ✅ 已完成
/static/* GET 靜態檔(Tailwind 建置輸出的 CSS,go:embed 內嵌) ✅ 已完成
/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) ✅ 已完成
/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) ✅ 已完成
/admin/keys POST 產生新 RSA 簽章金鑰(表單+CSRF,PRG) ✅ 已完成
/admin/keys/{id}/retire POST 退休金鑰(最後一把使用中金鑰不可退休) ✅ 已完成
*(未匹配路徑) 任意 自訂 404 頁(HTML,不分方法;/static/ 下不存在的檔案仍由檔案伺服器回純文字 404) ✅ 已完成

快速開始

前置需求

  • Go 1.26+
  • PostgreSQL 14+(或直接用 Docker)

啟動資料庫

docker run -d --name alterminal-db \
  -e POSTGRES_USER=postgres \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=alterminal \
  -p 5432:5432 \
  postgres:17

啟動服務

go run ./cmd/alterminal
# 服務啟動於 http://localhost:8080

驗證

curl http://localhost:8080/health
# => ok

編譯執行檔

go build -o alterminal ./cmd/alterminal
./alterminal
# 服務啟動於 http://localhost:8080
  • HTML 模板與 Tailwind 建置輸出(main.css)皆以 go:embed 內嵌,產出為單一執行檔,部署時不需連同模板與樣式檔一併安裝
  • 依賴全為純 Go(PostgreSQL 驅動採 pgx,無 CGO),可直接交叉編譯。部署至 Linux 伺服器:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o alterminal ./cmd/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

建立使用者帳號

go run ./cmd/alterminal 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 仍繼續有效,重設便失去意義:

go run ./cmd/alterminal 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 小時):

curl -i -X POST http://localhost:8080/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"sup3r-secret"}'

成功(200):

{
  "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 亦視為成功:

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)列於 Roadmap

更新密碼

GET/POST /password 為登入者自助更新密碼(/ 帳號首頁有入口連結),須持有效 Session;未登入時表單流程導向 /login?next=/password,JSON 流程回 401。與 /login、/logout 相同依 Content-Type 分流(JSON 與表單+CSRF):

curl -i -X POST http://localhost:8080/password \
  -H 'Content-Type: application/json' \
  -b 'alterminal_session=<Session ID>' \
  -d '{"current_password":"sup3r-secret","new_password":"n3w-secret!"}'
# => 204 No Content,Set-Cookie 下發輪替後的新 Session
  • 驗證目前密碼無誤後,以 argon2id 重新雜湊新密碼(新 salt),密碼更新與撤銷該帳號全部 Session 於同一資料庫交易內完成(與 CLI update-password 共用邏輯)
  • 交易成功後為目前瀏覽器重建 Session(輪替 Session ID,避免 fixation)——其他裝置立即登出、本瀏覽器保持登入
  • 表單流程成功後 303 導回 /password?saved=1 顯示成功訊息(PRG);驗證失敗以對應狀態碼重繪表單,密碼欄位不回填

錯誤回應 JSON 流程為 {"error": "..."}(表單流程顯示於頁面):

狀態碼 情境
400 欄位缺漏、新密碼短於 8 字元、表單兩次輸入的新密碼不一致、新密碼與目前密碼相同
401 未登入(JSON 流程)或目前的密碼錯誤
403 表單 CSRF 驗證失敗
415 Content-Type 非 application/json 或表單

RP-Initiated Logout

GET /logout 實作 OpenID Connect RP-Initiated Logout 1.0(POST 亦支援,§2 要求),端點發佈於 Discovery 的 end_session_endpoint。RP 將使用者導向本端點並攜帶參數:

參數 說明
id_token_hint 先前取得的 ID Token。本服務驗證簽章與 iss(不檢查 exp——§2 要求 session 存在或近期存在時接受過期值),其 aud 用於辨識發起登出的 client;sub 用於比對目前 Session
post_logout_redirect_uri 登出後返回 URI,須與該 client 註冊的 post_logout_redirect_uris 精確比對(§3:未註冊值 MUST NOT 重導),於管理頁「登出後返回 URI」欄位註冊
client_id 無 id_token_hint 時辨識 client 用(供驗證返回 URI);與 hint 併用時須與其 aud 相符(§2)
state 原值回填於重導查詢字串(§2)

行為要點:

  • 確認頁(§2 MUST):未提供 id_token_hint、或 hint 的 sub 不屬於目前 Session 時,先顯示「您確定要登出嗎?」確認頁(CSRF 保護),避免跨站偽造的強迫登出;hint 屬於目前 Session 時直接登出。本服務 ID token 不含 sid claim,以 sub 比對 Session 使用者近似判斷
  • 重導:通過驗證時 303 至 post_logout_redirect_uri(附 state);指定但未通過註冊比對時不重導(§3),登出仍完成並顯示已登出頁與說明;未指定時 303 導向 /login(與本站既有登出一致)
  • 錯誤(§4):hint 無效、client_id 與 aud 不符等硬錯誤回 400,不執行登出也不重導
  • 未登入時造訪為冪等操作——仍清除 Cookie 並依驗證結果重導
  • 不帶 RP 參數的 POST /logout(側欄登出表單、JSON API)行為不變,由 auth.LogoutHandler 處理

登入頁面

瀏覽器開啟 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 到期時間),並套用側邊導覽版面(internal/auth/templates/layout.html,之後的頁面可重用):桌面版側欄固定展開,手機版以純 CSS checkbox 開合(CSP 不允許 JavaScript),側欄頁尾為使用者資訊與「登出」按鈕(POST /logout,同樣受 CSRF 驗證保護)

前端樣式(Tailwind CSS)

頁面樣式使用 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。

調整樣式後重新建置:

tools/tailwindcss -i internal/auth/assets/css/input.css -o internal/auth/assets/css/main.css --minify

Tailwind 為官方 standalone CLI(版本見 tools/tailwindcss-version.txt,tools/ 不納入版本控制),首次取得方式:

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,不可帶尾斜線;簽入 token 的 iss claim 與 Discovery 的 issuer 欄位須完全一致) http://localhost:8080 ✅

授權流程(Authorization Code Flow + PKCE)

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)。

/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 自動遷移)。

資料表 說明 狀態
users 使用者帳號(帳號、Email、密碼雜湊) ✅ 已完成(含 argon2id 密碼雜湊)
applications 已註冊的 RP 應用程式(client_id、client secret 雜湊、redirect URIs、登出後返回 URIs、grant types、scope、confidential/public) 🚧 模型與管理頁已完成(Application:argon2id secret 雜湊、redirect URI 格式驗證;/admin/applications 註冊/編輯/輪替/刪除),註冊 API 規劃中
sessions 使用者瀏覽器 Session(SSO 核心,HttpOnly Cookie,效期 24 小時) ✅ 已完成
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

  • 專案骨架:chi 路由、GORM + PostgreSQL 連線、/health 健康檢查
  • 使用者系統:註冊、登入/登出、密碼重設(撤銷 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/RS256)、ID Token、Refresh Token 簽發與驗證
  • UserInfo 端點(/userinfo)
  • Refresh Token 輪替與撤銷(重用偵測、整鏈撤銷)
  • RP-Initiated Logout(/logout:id_token_hint 驗證、確認頁、post_logout_redirect_uri 註冊比對與 state 回填)
  • Client Credentials Grant
  • Front-Channel / Back-Channel Logout(跨 RP 單一登出)
  • 管理 API 與簡易管理介面

專案結構

alterminal/
├── cmd/alterminal/
│   └── main.go             # 程式進入點、路由裝配、CLI 分派
└── internal/
    ├── 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
    └── oidc/               # OIDC 核心端點與憑證模型:Discovery、/authorize
                            # (同意頁)、/token、/userinfo、JWKS,以及
                            # authorization_codes/refresh_tokens/consents