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
+347
View File
@@ -0,0 +1,347 @@
package oidc
import (
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"gorm.io/gorm"
"alterminal/internal/application"
"alterminal/internal/auth"
)
// scopeDescriptions 為同意頁上各 scope 的人類可讀說明。
var scopeDescriptions = map[string]string{
"openid": "確認您的身分(取得登入狀態)",
"profile": "讀取您的顯示名稱與帳號",
"email": "讀取您的電子郵件地址",
"offline_access": "您離線時持續存取(換發新權杖)",
}
// authorizeRequest 為 /authorize 的請求參數(RFC 6749 §4.1.1 與 OIDC
// Core §3.1.2.1 的授權請求參數;GET query 與同意表單 POST 共用)。
type authorizeRequest struct {
ResponseType string
ClientID string
RedirectURI string
Scope string
State string
Nonce string
CodeChallenge string
CodeChallengeMethod string
}
// authorizeRequestFromValues 由 query 或表單值還原請求參數。
func authorizeRequestFromValues(v url.Values) authorizeRequest {
return authorizeRequest{
ResponseType: v.Get("response_type"),
ClientID: v.Get("client_id"),
RedirectURI: v.Get("redirect_uri"),
Scope: v.Get("scope"),
State: v.Get("state"),
Nonce: v.Get("nonce"),
CodeChallenge: v.Get("code_challenge"),
CodeChallengeMethod: v.Get("code_challenge_method"),
}
}
// values 重建請求的原始參數(同意表單的隱藏欄位與登入後返回時使用)。
func (req authorizeRequest) values() url.Values {
v := url.Values{}
set := func(k, s string) {
if s != "" {
v.Set(k, s)
}
}
set("response_type", req.ResponseType)
set("client_id", req.ClientID)
set("redirect_uri", req.RedirectURI)
set("scope", req.Scope)
set("state", req.State)
set("nonce", req.Nonce)
set("code_challenge", req.CodeChallenge)
set("code_challenge_method", req.CodeChallengeMethod)
return v
}
// query 回傳重建的授權請求 query 字串(不含 ?)。
func (req authorizeRequest) query() string {
return req.values().Encode()
}
// redirectError 為可安全重導回 redirect_uri 的授權請求錯誤(RFC 6749
// §4.1.2.1:凡 client_id 與 redirect_uri 可確認者,錯誤以重導回傳)。
type redirectError struct {
Code string
Description string
}
// validateAuthorizeRequest 驗證授權請求並載入應用程式註冊資料。驗證
// 順序刻意安排:client_id 與 redirect_uri 無法確認時呼叫方必須直接
// 顯示錯誤頁、不得重導(RFC 6749 §4.1.2.1,防止授權請求做為開放
// 重導向器);redirect_uri 通過精確比對(§3.1.2.3,字串相等不正规化)
// 後,其餘錯誤才以 redirectError 重導回 RP。
func validateAuthorizeRequest(db *gorm.DB, req authorizeRequest) (*application.Application, *redirectError, error) {
app, err := application.GetByClientID(db, req.ClientID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, fmt.Errorf("未知或不存在的 client_id %q", req.ClientID)
}
return nil, nil, err
}
if !app.RedirectURIs.Contains(req.RedirectURI) {
return nil, nil, fmt.Errorf("redirect_uri 未註冊於 client_id %s", req.ClientID)
}
if req.ResponseType != "code" {
return app, &redirectError{Code: "unsupported_response_type", Description: "僅支援 response_type=code"}, nil
}
if !app.GrantTypes.Contains(application.GrantAuthorizationCode) {
return app, &redirectError{Code: "unauthorized_client", Description: "應用程式未啟用授權碼流程"}, nil
}
// scope:必含 openid(OIDC Core §3.1.2.1),且每個請求的 scope 皆
// 鈙於應用程式註冊範圍。
if !scopeHas(req.Scope, "openid") {
return app, &redirectError{Code: "invalid_scope", Description: "scope 必須包含 openid"}, nil
}
for _, s := range strings.Fields(req.Scope) {
if !scopeHas(app.Scope, s) {
return app, &redirectError{Code: "invalid_scope", Description: "scope " + s + " 未授權此應用程式"}, nil
}
}
// PKCE(RFC 7636 §4.2、§4.3):code_challenge_method 僅允許 S256
// (plain 不安全,本服務不接受,亦不採規格的 plain 預設——省略
// method 視同無效)。公開式 Client 無 client secret 可驗,PKCE 為
// 必要防護。
switch {
case req.CodeChallengeMethod != "" && req.CodeChallengeMethod != "S256":
return app, &redirectError{Code: "invalid_request", Description: "code_challenge_method 僅支援 S256"}, nil
case req.CodeChallengeMethod == "S256" && req.CodeChallenge == "":
return app, &redirectError{Code: "invalid_request", Description: "code_challenge 不可為空"}, nil
case app.IsPublic() && req.CodeChallenge == "":
return app, &redirectError{Code: "invalid_request", Description: "公開式 Client 必須使用 PKCE"}, nil
case req.CodeChallenge != "" && req.CodeChallengeMethod == "":
return app, &redirectError{Code: "invalid_request", Description: "提供 code_challenge 時必須指定 code_challenge_method=S256"}, nil
}
return app, nil, nil
}
// AuthorizeHandler 處理 /authorize(RFC 6749 §4.1.1 授權碼流程的授權
// 端點):GET 驗證請求後依登入與同意狀態發碼或顯示同意頁,POST 處理
// 同意頁的決定。
func AuthorizeHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handleAuthorizeGet(db, w, r)
case http.MethodPost:
handleAuthorizePost(db, w, r)
default:
auth.WriteError(w, http.StatusMethodNotAllowed, "不支援的方法")
}
}
}
// handleAuthorizeGet 處理 GET /authorize。
func handleAuthorizeGet(db *gorm.DB, w http.ResponseWriter, r *http.Request) {
req := authorizeRequestFromValues(r.URL.Query())
app, rerr, err := validateAuthorizeRequest(db, req)
if !authorizeValidated(w, r, req, rerr, err) {
return
}
s, ok := authorizeSession(db, w, r, req)
if !ok {
return
}
// 已同意的 scope 涵蓋本次請求時靜默通過,直接發碼;否則顯示同意頁。
c, err := GetConsent(db, s.UserID, app.ID)
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
// 首次授權,顯示同意頁
case err != nil:
log.Printf("authorize: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
case scopeCovered(c.Scope, req.Scope):
issueCodeAndRedirect(db, w, r, req, app, s)
return
}
renderConsentPage(w, r, http.StatusOK, req, app, s, "")
}
// handleAuthorizePost 處理 POST /authorize(同意頁決定)。
func handleAuthorizePost(db *gorm.DB, w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "無法解析表單內容", http.StatusBadRequest)
return
}
req := authorizeRequestFromValues(r.PostForm)
app, rerr, err := validateAuthorizeRequest(db, req)
if !authorizeValidated(w, r, req, rerr, err) {
return
}
// POST 期間 Session 失效時,以原始參數重建 GET 回到授權流程開頭
// (會再導向登入頁),不直接渲染需要登入脈絡的同意頁。
s, ok := authorizeSession(db, w, r, req)
if !ok {
return
}
if !auth.VerifyCSRF(r) {
renderConsentPage(w, r, http.StatusForbidden, req, app, s, "表單驗證失敗,請重新操作")
return
}
switch r.PostFormValue("decision") {
case "allow":
if err := SaveConsent(db, s.UserID, app.ID, req.Scope); err != nil {
log.Printf("authorize: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
issueCodeAndRedirect(db, w, r, req, app, s)
case "deny":
// 使用者拒絕授權(RFC 6749 §4.1.2.1 access_denied)。
redirectAuthorizeError(w, r, req, "access_denied", "使用者拒絕授權")
default:
renderConsentPage(w, r, http.StatusBadRequest, req, app, s, "請選擇同意或拒絕")
}
}
// authorizeValidated 統一處理驗證結果:無法確認 client/redirect_uri 的
// 錯誤直接顯示 400 錯誤頁(不重導);可重導的錯誤回到 redirect_uri。
// 回傳是否繼續後續流程。
func authorizeValidated(w http.ResponseWriter, r *http.Request, req authorizeRequest, rerr *redirectError, err error) bool {
if err != nil {
log.Printf("authorize: %v", err)
http.Error(w, "授權請求無效:"+err.Error(), http.StatusBadRequest)
return false
}
if rerr != nil {
redirectAuthorizeError(w, r, req, rerr.Code, rerr.Description)
return false
}
return true
}
// authorizeSession 檢查使用者 Session:有效回傳 (session, true);未登入
// 時 303 導向 /login?next=<完整授權請求 URL> 後回傳 (nil, false);查詢
// 錯誤回 500。POST 同意表單時改為 303 導回重建的 GET /authorize,
// 讓流程重新從登入檢查開始。
func authorizeSession(db *gorm.DB, w http.ResponseWriter, r *http.Request, req authorizeRequest) (*auth.Session, bool) {
c, err := r.Cookie(auth.CookieName)
if errors.Is(err, http.ErrNoCookie) {
authorizeLoginRedirect(w, r, req)
return nil, false
}
s, err := auth.GetSession(db, c.Value)
if errors.Is(err, auth.ErrSessionExpired) {
authorizeLoginRedirect(w, r, req)
return nil, false
}
if err != nil {
log.Printf("authorize: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return nil, false
}
return s, true
}
// authorizeLoginRedirect 依請求方法把使用者送往登入頁:GET 直接以原始
// URI 為 next;POST 以表單參數重建 query,讓登入後回到等效的 GET。
func authorizeLoginRedirect(w http.ResponseWriter, r *http.Request, req authorizeRequest) {
next := "/authorize?" + req.query()
if r.Method == http.MethodGet {
next = r.URL.RequestURI()
}
http.Redirect(w, r, "/login?next="+url.QueryEscape(next), http.StatusSeeOther)
}
// consentPageData 為同意頁的模板資料。Params 保存原始授權請求參數,
// 模板以隱藏欄位逐項帶回 POST /authorize。IsAdmin/Username/Email/CSRF
// 供 layout 側欄版面使用(與其他已登入頁面一致)。
type consentPageData struct {
Error string
Username string
Email string
IsAdmin bool
CSRF string
AppName string
Scopes []scopeItem
Params url.Values
}
// scopeItem 為同意頁清單中的單一 scope 及其說明。
type scopeItem struct {
Scope string
Description string
}
// renderConsentPage 輸出授權同意頁;每次輸出都輪替 CSRF token。
func renderConsentPage(w http.ResponseWriter, r *http.Request, status int, req authorizeRequest, app *application.Application, s *auth.Session, errMsg string) {
token, err := auth.NewCSRFToken(w, r)
if err != nil {
log.Printf("csrf token: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
scopes := make([]scopeItem, 0, 4)
for _, s := range normalizeScope(req.Scope) {
scopes = append(scopes, scopeItem{Scope: s, Description: scopeDescriptions[s]})
}
auth.RenderHTML(w, status, auth.ConsentTmpl, consentPageData{
Error: errMsg,
Username: s.User.Username,
Email: s.User.Email,
IsAdmin: s.User.Role == auth.RoleAdmin,
CSRF: token,
AppName: app.Name,
Scopes: scopes,
Params: req.values(),
})
}
// issueCodeAndRedirect 產生授權碼並 302 重導回 redirect_uri(附加 code
// 與原 state;RFC 6749 §4.1.2 與 §3.1.2 的回呼格式)。
func issueCodeAndRedirect(db *gorm.DB, w http.ResponseWriter, r *http.Request, req authorizeRequest, app *application.Application, s *auth.Session) {
_, code, err := NewAuthorizationCode(db, app.ID, s.UserID, req.RedirectURI, strings.Join(normalizeScope(req.Scope), " "), req.Nonce, req.CodeChallenge, req.CodeChallengeMethod, s.CreatedAt)
if err != nil {
log.Printf("authorize: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
redirectWithParams(w, r, req, "code", code)
}
// redirectAuthorizeError 以 302 將錯誤重導回 redirect_uri(RFC 6749
// §4.1.2.1:error、error_description 與原 state)。
func redirectAuthorizeError(w http.ResponseWriter, r *http.Request, req authorizeRequest, code, description string) {
redirectWithParams(w, r, req, "error", code, "error_description", description)
}
// redirectWithParams 在 redirect_uri 既有 query 之外附加 key/value 對
// (值成對出現:key1, val1, key2, val2),state 非空時一併回填,最後
// 302 重導。
func redirectWithParams(w http.ResponseWriter, r *http.Request, req authorizeRequest, kv ...string) {
u, err := url.Parse(req.RedirectURI)
if err != nil {
log.Printf("authorize: 解析 redirect_uri: %v", err)
http.Error(w, "內部錯誤", http.StatusInternalServerError)
return
}
q := u.Query()
for i := 0; i+1 < len(kv); i += 2 {
q.Set(kv[i], kv[i+1])
}
if req.State != "" {
q.Set("state", req.State)
}
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}
+235
View File
@@ -0,0 +1,235 @@
// 外部測試套件:見 jwks_test.go 開頭說明。
package oidc_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"alterminal/internal/oidc"
)
// client_id 或 redirect_uri 無法確認時不得重導(RFC 6749 §4.1.2.1),
// 直接回 400 錯誤頁。
func TestAuthorizeRejectsWithoutRedirect(t *testing.T) {
e := newTestEnv(t)
h := oidc.AuthorizeHandler(e.db)
t.Run("未知 client_id", func(t *testing.T) {
q := authorizeQuery(e.app, "openid", "", "", "")
q = strings.Replace(q, url.QueryEscape(e.app.ClientID), url.QueryEscape("no-such-client"), 1)
rec := getAuthorize(h, q, e.sessionCookie())
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if rec.Header().Get("Location") != "" {
t.Fatalf("不得重導: %s", rec.Header().Get("Location"))
}
})
t.Run("redirect_uri 未註冊", func(t *testing.T) {
q := authorizeQuery(e.app, "openid", "", "", "")
q = strings.Replace(q, url.QueryEscape(e.app.RedirectURIs[0]), url.QueryEscape("https://evil.example/cb"), 1)
rec := getAuthorize(h, q, e.sessionCookie())
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if rec.Header().Get("Location") != "" {
t.Fatalf("不得重導: %s", rec.Header().Get("Location"))
}
})
}
// redirect_uri 可確認後,其餘請求錯誤以 302 重導回 RP,附 error 與
// 原 state(RFC 6749 §4.1.2.1)。
func TestAuthorizeRedirectsParameterErrors(t *testing.T) {
e := newTestEnv(t)
h := oidc.AuthorizeHandler(e.db)
redirectURI := e.app.RedirectURIs[0]
tests := []struct {
name string
query string
wantErrCode string
wantRedirect string
}{
{
"response_type 不支援",
"response_type=token&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&scope=openid&state=xyz",
"unsupported_response_type",
redirectURI,
},
{
"scope 缺 openid",
authorizeQuery(e.app, "profile email", "xyz", "", ""),
"invalid_scope",
redirectURI,
},
{
"scope 超出註冊範圍",
authorizeQuery(e.app, "openid profile email offline_access unknown-scope", "xyz", "", ""),
"invalid_scope",
redirectURI,
},
{
"code_challenge_method=plain",
"response_type=code&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) +
"&scope=openid&state=xyz&code_challenge=whatever&code_challenge_method=plain",
"invalid_request",
redirectURI,
},
{
"有 challenge 未指定 method",
"response_type=code&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) +
"&scope=openid&state=xyz&code_challenge=whatever",
"invalid_request",
redirectURI,
},
{
"公開式 Client 未使用 PKCE",
authorizeQuery(e.pub, "openid", "xyz", "", ""),
"invalid_request",
e.pub.RedirectURIs[0],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := getAuthorize(h, tt.query, e.sessionCookie())
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
loc := redirectLocation(t, rec)
if base := loc.Scheme + "://" + loc.Host + loc.Path; base != tt.wantRedirect {
t.Fatalf("Location 基準 URL = %q, want %q", base, tt.wantRedirect)
}
if got := loc.Query().Get("error"); got != tt.wantErrCode {
t.Errorf("error = %q, want %q", got, tt.wantErrCode)
}
if got := loc.Query().Get("state"); got != "xyz" {
t.Errorf("state 應原樣回填, got %q", got)
}
})
}
}
// 未登入時導向 /login,next 攜帶完整授權請求(OIDC Core §3.1.2.2)。
func TestAuthorizeRedirectsToLoginWhenNotLoggedIn(t *testing.T) {
e := newTestEnv(t)
q := authorizeQuery(e.app, "openid profile", "xyz", "n-1", "")
rec := getAuthorize(oidc.AuthorizeHandler(e.db), q) // 不帶 Session Cookie
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
loc := redirectLocation(t, rec)
if loc.Path != "/login" {
t.Fatalf("應導向 /login, got %q", loc)
}
next, err := url.QueryUnescape(loc.Query().Get("next"))
if err != nil {
t.Fatalf("next 未編碼: %v", err)
}
if !strings.HasPrefix(next, "/authorize?") || !strings.Contains(next, "state=xyz") || !strings.Contains(next, "nonce=n-1") {
t.Fatalf("next 應為完整 /authorize URL: %q", next)
}
}
// 首次授權顯示同意頁;同意後記住,同範圍的後續請求靜默通過;範圍
// 擴大時再次詢問。
func TestAuthorizeConsentFlow(t *testing.T) {
e := newTestEnv(t)
h := oidc.AuthorizeHandler(e.db)
t.Run("首次顯示同意頁", func(t *testing.T) {
rec := getAuthorize(h, authorizeQuery(e.app, "openid profile", "xyz", "", ""), e.sessionCookie())
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"授權存取", "機密式測試應用", "openid", "profile", `value="allow"`, `value="deny"`} {
if !strings.Contains(body, want) {
t.Errorf("同意頁缺少 %q", want)
}
}
// offline_access 未請求,不應出現於說明清單。
if strings.Count(body, "offline_access") != 0 {
t.Error("未請求的 scope 不應顯示")
}
})
t.Run("同意後發碼並記住", func(t *testing.T) {
loc, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile", "xyz", "", ""))
if base := loc.Scheme + "://" + loc.Host + loc.Path; base != e.app.RedirectURIs[0] {
t.Fatalf("Location 基準 URL = %q, want %q", base, e.app.RedirectURIs[0])
}
if loc.Query().Get("state") != "xyz" {
t.Errorf("state 應原樣回填, got %q", loc.Query().Get("state"))
}
if len(code) < 20 {
t.Errorf("code 長度 %d 過短", len(code))
}
})
t.Run("同範圍再請求靜默通過", func(t *testing.T) {
rec := getAuthorize(h, authorizeQuery(e.app, "openid", "s2", "", ""), e.sessionCookie())
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
loc := redirectLocation(t, rec)
if loc.Query().Get("code") == "" || loc.Query().Get("state") != "s2" {
t.Fatalf("應直接發碼: %s", loc)
}
})
t.Run("範圍擴大再次詢問", func(t *testing.T) {
rec := getAuthorize(h, authorizeQuery(e.app, "openid email", "s3", "", ""), e.sessionCookie())
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200(應再顯示同意頁), body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "email") {
t.Fatal("同意頁應顯示新請求的 scope")
}
})
t.Run("拒絕授權回 access_denied", func(t *testing.T) {
q := authorizeQuery(e.pub, "openid", "xyz", "", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
rec := getAuthorize(h, q, e.sessionCookie())
if rec.Code != http.StatusOK {
t.Fatalf("公開式首授權也應先顯示同意頁, status = %d", rec.Code)
}
rec = postAuthorize(h, q, "deny", e.sessionCookie(), csrfCookieOf(t, rec))
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rec.Code)
}
loc := redirectLocation(t, rec)
if loc.Query().Get("error") != "access_denied" || loc.Query().Get("state") != "xyz" {
t.Fatalf("應回 access_denied 與原 state: %s", loc)
}
// 拒絕不應記錄同意:再次請求仍顯示同意頁。
rec = getAuthorize(h, q, e.sessionCookie())
if rec.Code != http.StatusOK {
t.Fatalf("拒絕後不應記住, status = %d, want 200", rec.Code)
}
})
t.Run("CSRF 不符回 403", func(t *testing.T) {
q := authorizeQuery(e.app, "openid email", "", "", "") // email 尚未同意
rec := getAuthorize(h, q, e.sessionCookie())
csrf := csrfCookieOf(t, rec)
// Cookie 保持正確值,但表單送出不符的 token。
form, _ := url.ParseQuery(q)
form.Set("decision", "allow")
form.Set("csrf_token", "wrong-token")
req := httptest.NewRequest(http.MethodPost, "/authorize", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(e.sessionCookie())
req.AddCookie(csrf)
rec = httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
}
+63
View File
@@ -0,0 +1,63 @@
package oidc
import (
"fmt"
"net/http"
"alterminal/internal/application"
"alterminal/internal/auth"
)
// discoveryMaxAge 為 Discovery 文件的建議快取秒數,與 JWKS 一致:內容
// 僅在部署設定變更時改變。
const discoveryMaxAge = 3600
// discoveryDocument 為 OIDC Discovery 文件(OIDC Discovery 1.0 §3)。
// 本服務僅支援授權碼流程(RFC 6749 §4.1.1)與 refresh token grant
// (§6);subject type 僅 public(sub 對使用者恆為同一值)。
type discoveryDocument struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
JWKSURI string `json:"jwks_uri"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
ResponseModesSupported []string `json:"response_modes_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
SubjectTypesSupported []string `json:"subject_types_supported"`
IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"`
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
ClaimsSupported []string `json:"claims_supported"`
}
// DiscoveryHandler 處理 GET /.well-known/openid-configuration:發佈本
// 服務的 OIDC 端點與能力中繼資料,供 RP 以標準方式取得組態。issuer 於
// main 讀取 ISSUER 環境變數後注入——issuer 字串須與簽入 token 的 iss
// claim 完全一致(OIDC Core §3.1.3.7 的 issuer 驗證)。
func DiscoveryHandler(issuer string) http.HandlerFunc {
doc := discoveryDocument{
Issuer: issuer,
AuthorizationEndpoint: issuer + "/authorize",
TokenEndpoint: issuer + "/token",
UserInfoEndpoint: issuer + "/userinfo",
JWKSURI: issuer + "/.well-known/jwks.json",
ScopesSupported: application.ScopesSupported(),
ResponseTypesSupported: []string{"code"},
ResponseModesSupported: []string{"query"},
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
SubjectTypesSupported: []string{"public"},
IDTokenSigningAlgValuesSupported: []string{"RS256"},
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "none"},
CodeChallengeMethodsSupported: []string{"S256"},
ClaimsSupported: []string{
"sub", "iss", "aud", "exp", "iat", "auth_time", "nonce",
"name", "preferred_username", "email", "email_verified",
},
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", discoveryMaxAge))
auth.WriteJSON(w, http.StatusOK, doc)
}
}
+62
View File
@@ -0,0 +1,62 @@
// 外部測試套件:見 jwks_test.go 開頭說明。
package oidc_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"alterminal/internal/oidc"
)
// Discovery 文件應揭露本服務的全部端點與能力。
func TestDiscoveryHandler(t *testing.T) {
rec := httptest.NewRecorder()
oidc.DiscoveryHandler(testIssuer)(rec, httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=3600" {
t.Errorf("Cache-Control = %q, want public, max-age=3600", cc)
}
var doc map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatalf("解析 Discovery 文件: %v", err)
}
endpoints := map[string]string{
"issuer": testIssuer,
"authorization_endpoint": testIssuer + "/authorize",
"token_endpoint": testIssuer + "/token",
"userinfo_endpoint": testIssuer + "/userinfo",
"jwks_uri": testIssuer + "/.well-known/jwks.json",
}
for field, want := range endpoints {
got, _ := doc[field].(string)
if got != want {
t.Errorf("%s = %q, want %q", field, got, want)
}
}
lists := map[string][]string{
"scopes_supported": {"email", "offline_access", "openid", "profile"},
"response_types_supported": {"code"},
"grant_types_supported": {"authorization_code", "refresh_token"},
"subject_types_supported": {"public"},
"id_token_signing_alg_values_supported": {"RS256"},
"token_endpoint_auth_methods_supported": {"client_secret_basic", "client_secret_post", "none"},
"code_challenge_methods_supported": {"S256"},
}
for field, want := range lists {
got, _ := doc[field].([]any)
if len(got) != len(want) {
t.Errorf("%s = %v, want %v", field, got, want)
continue
}
for i, w := range want {
if got[i] != w {
t.Errorf("%s[%d] = %v, want %v", field, i, got[i], w)
}
}
}
}
+321
View File
@@ -0,0 +1,321 @@
// 外部測試套件(與 jwks_test.go 同理):oidc 模型由 store 遷移,內部
// 測試套件匯入 testdb 會形成循環。
package oidc_test
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gorm.io/gorm"
"alterminal/internal/application"
"alterminal/internal/auth"
"alterminal/internal/jwk"
"alterminal/internal/oidc"
"alterminal/internal/testdb"
)
// testIssuer 為測試用 issuer(與本機 http 開發環境一致)。
const testIssuer = "http://localhost:8080"
// testEnv 打包端點整合測試的共用物件:簽章金鑰、使用者與 Session、
// 一個機密式與一個公開式應用程式。
type testEnv struct {
db *gorm.DB
key *jwk.SigningKey
user *auth.User
session *auth.Session
app *application.Application // 機密式:openid profile email offline_access
secret string // app 的 client secret 明文
pub *application.Application // 公開式:預設 grant 與 scope
}
// newTestEnv 建立測試環境(含所有相依資料列)。
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
db := testdb.New(t)
key := mustNewKey(t, false)
if err := db.Create(key).Error; err != nil {
t.Fatal("建立測試金鑰: ", err)
}
user := &auth.User{
Username: "oidc-test", Email: "oidc-test@example.com",
Name: "測試使用者", EmailVerified: true,
}
if err := db.Create(user).Error; err != nil {
t.Fatal("建立測試使用者: ", err)
}
session, err := auth.CreateSession(db, user.ID)
if err != nil {
t.Fatal("建立測試 Session: ", err)
}
app, secret, err := application.NewApplication(
"機密式測試應用", application.ClientConfidential,
[]string{"https://rp.example/callback"},
[]application.GrantType{application.GrantAuthorizationCode, application.GrantRefreshToken},
"openid profile email offline_access",
)
if err != nil {
t.Fatal("建立測試應用程式: ", err)
}
if err := db.Create(app).Error; err != nil {
t.Fatal("建立測試應用程式: ", err)
}
pub, _, err := application.NewApplication(
"公開式測試應用", application.ClientPublic,
[]string{"http://localhost:3000/cb"},
nil, "", // fill 會補預設值:grant 僅 authorization_code、scope 為 openid profile email
)
if err != nil {
t.Fatal("建立公開式測試應用: ", err)
}
if err := db.Create(pub).Error; err != nil {
t.Fatal("建立公開式測試應用: ", err)
}
return &testEnv{db: db, key: key, user: user, session: session, app: app, secret: secret, pub: pub}
}
// sessionCookie 回傳環境使用者的 Session Cookie。
func (e *testEnv) sessionCookie() *http.Cookie {
return &http.Cookie{Name: auth.CookieName, Value: e.session.ID}
}
// authorizeQuery 組出對指定應用程式的授權請求 query(redirect URI 取
// 第一個註冊值)。challenge 為空時不帶 PKCE 參數。
func authorizeQuery(app *application.Application, scope, state, nonce, challenge string) string {
v := url.Values{}
v.Set("response_type", "code")
v.Set("client_id", app.ClientID)
v.Set("redirect_uri", app.RedirectURIs[0])
v.Set("scope", scope)
if state != "" {
v.Set("state", state)
}
if nonce != "" {
v.Set("nonce", nonce)
}
if challenge != "" {
v.Set("code_challenge", challenge)
v.Set("code_challenge_method", "S256")
}
return v.Encode()
}
// getAuthorize 對 GET /authorize 發出請求(可選帶 Cookie)並回傳記錄器。
func getAuthorize(h http.HandlerFunc, query string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/authorize?"+query, nil)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
// csrfCookieOf 從回應的 Set-Cookie 取得輪替後的 CSRF token。
func csrfCookieOf(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CSRFCookieName {
return c
}
}
t.Fatal("回應未設定 CSRF Cookie")
return nil
}
// hiddenFieldValue 由表單頁 HTML 取出指定隱藏欄位的 value( simplistic
// 剖析,僅供測試使用)。
func hiddenFieldValue(t *testing.T, body, name string) string {
t.Helper()
marker := `name="` + name + `" value="`
i := strings.Index(body, marker)
if i < 0 {
t.Fatalf("頁面缺少隱藏欄位 %s", name)
}
rest := body[i+len(marker):]
return rest[:strings.Index(rest, `"`)]
}
// postAuthorize 送出同意頁決定(帶原始授權參數與 CSRF),回傳記錄器。
func postAuthorize(h http.HandlerFunc, query, decision string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
form, err := url.ParseQuery(query) // 正確解碼一次,Encode 時再編碼
if err != nil {
panic(err)
}
form.Set("decision", decision)
var csrf string
for _, c := range cookies {
if c.Name == auth.CSRFCookieName {
csrf = c.Value
}
}
form.Set("csrf_token", csrf)
req := httptest.NewRequest(http.MethodPost, "/authorize", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
// consentAllow 走授權流程取得授權碼,回傳 redirect Location 與其中的
// code:scope 已同意過時直接使用靜默通過的 302,否則顯示同意頁後按
// 同意。登入 Session 由 e 提供。
func consentAllow(t *testing.T, e *testEnv, query string) (*url.URL, string) {
t.Helper()
h := oidc.AuthorizeHandler(e.db)
rec := getAuthorize(h, query, e.sessionCookie())
if rec.Code == http.StatusFound {
loc := redirectLocation(t, rec)
if code := loc.Query().Get("code"); code != "" {
return loc, code // 已同意,靜默通過
}
t.Fatalf("未預期的 302(無 code): %s", loc)
}
if rec.Code != http.StatusOK {
t.Fatalf("同意頁 status = %d, want 200, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "授權存取") {
t.Fatalf("應顯示同意頁: %s", rec.Body.String())
}
csrf := csrfCookieOf(t, rec)
rec = postAuthorize(h, query, "allow", e.sessionCookie(), csrf)
if rec.Code != http.StatusFound {
t.Fatalf("同意後 status = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
loc := redirectLocation(t, rec)
code := loc.Query().Get("code")
if code == "" {
t.Fatalf("redirect URI 缺少 code: %s", loc)
}
return loc, code
}
// redirectLocation 解析 302/303 回應的 Location 標頭。
func redirectLocation(t *testing.T, rec *httptest.ResponseRecorder) *url.URL {
t.Helper()
raw := rec.Header().Get("Location")
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("解析 Location %q: %v", raw, err)
}
return u
}
// postToken 對 POST /token 送出表單;basicID/basicSecret 非空時改用
// HTTP Basic 認證。
func postToken(h http.HandlerFunc, form url.Values, basicID, basicSecret string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if basicID != "" {
req.SetBasicAuth(basicID, basicSecret)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
// tokenErrorBody 解析 token 錯誤回應。
type tokenErrorBody struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// decodeTokenError 解析錯誤回應 JSON。
func decodeTokenError(t *testing.T, rec *httptest.ResponseRecorder) tokenErrorBody {
t.Helper()
var e tokenErrorBody
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatalf("解析錯誤回應: %v, body = %s", err, rec.Body.String())
}
return e
}
// jwtParts 拆解 JWT 三段。
func jwtParts(t *testing.T, token string) (header, payload []byte) {
t.Helper()
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("JWT 應有三段: %s", token)
}
var err error
if header, err = base64.RawURLEncoding.DecodeString(parts[0]); err != nil {
t.Fatalf("解碼 header: %v", err)
}
if payload, err = base64.RawURLEncoding.DecodeString(parts[1]); err != nil {
t.Fatalf("解碼 payload: %v", err)
}
return header, payload
}
// forgeJWT 以指定金鑰與自訂 header/claims 造出 JWT(供負面測試:
// alg 混淆、過期 claims 等)。
func forgeJWT(t *testing.T, key *jwk.SigningKey, header map[string]string, claims any) string {
t.Helper()
priv, err := key.PrivateKey()
if err != nil {
t.Fatal(err)
}
hb, err := json.Marshal(header)
if err != nil {
t.Fatal(err)
}
pb, err := json.Marshal(claims)
if err != nil {
t.Fatal(err)
}
signingInput := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(pb)
digest := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(nil, priv, crypto.SHA256, digest[:])
if err != nil {
t.Fatal(err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// idTokenClaims 為測試斷言用的 ID token claims。
type idTokenClaims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud string `json:"aud"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
AuthTime int64 `json:"auth_time"`
Nonce string `json:"nonce"`
Name string `json:"name"`
Email string `json:"email"`
EmailVerf *bool `json:"email_verified"`
}
// userInfoBody 為測試斷言用的 /userinfo 回應。
type userInfoBody struct {
Sub string `json:"sub"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
EmailVerified *bool `json:"email_verified"`
}
// subjectOf 回傳使用者 ID 的字串形式(與正式碼的 sub 生成一致)。
func subjectOf(id uint) string {
return fmt.Sprintf("%d", id)
}
+46
View File
@@ -0,0 +1,46 @@
// Package oidc 實作 OIDC 端點:/.well-known/jwks.json,以及之後的
// Discovery、/authorize、/token 等,供 RP(Application)整合。
package oidc
import (
"fmt"
"log"
"net/http"
"gorm.io/gorm"
"alterminal/internal/auth"
"alterminal/internal/jwk"
)
// jwksMaxAge 為 JWKS 回應的建議快取秒數。金鑰輪替流程為「先產生並
// 發佈新金鑰,舊金鑰退休前仍留在 JWKS 供已簽發的 token 驗證」,因此
// RP 快取一小時並不影響驗證:快取期間內新舊金鑰皆可取得。
const jwksMaxAge = 3600
// JWKSHandler 處理 GET /.well-known/jwks.json(RFC 7517 §5):發佈所有
// 使用中簽章金鑰的公開 JWK,供 RP 驗證 ID Token/Access Token 的
// 簽章。已退休金鑰不再發佈;無使用中金鑰時回應空的 keys 陣列。
func JWKSHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var keys []jwk.SigningKey
if err := db.Where("retired_at IS NULL").Order("created_at DESC").Find(&keys).Error; err != nil {
log.Printf("jwks: %v", err)
auth.WriteError(w, http.StatusInternalServerError, "內部錯誤")
return
}
set := jwk.JWKS{Keys: make([]jwk.JWK, 0, len(keys))}
for i := range keys {
k, err := keys[i].PublicJWK()
if err != nil {
// 單一金鑰的私鑰儲存毀損時跳過該金鑰並記錄待查,不讓整個
// 端點失靈——其餘金鑰照常發佈,RP 仍可驗證其簽發的 token。
log.Printf("jwks: 金鑰 %d(kid=%s)無法轉為公開 JWK: %v", keys[i].ID, keys[i].Kid, err)
continue
}
set.Keys = append(set.Keys, *k)
}
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", jwksMaxAge))
auth.WriteJSON(w, http.StatusOK, set)
}
}
+137
View File
@@ -0,0 +1,137 @@
// 外部測試套件(package oidc_test):store 為遷移 OIDC 模型而匯入
// oidc 套件,內部測試套件匯入 testdb(→ store → oidc)會形成循環。
package oidc_test
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"alterminal/internal/jwk"
"alterminal/internal/oidc"
"alterminal/internal/testdb"
)
// jwksGet 對 handler 發出 GET /.well-known/jwks.json 並回傳回應記錄器。
func jwksGet(h http.HandlerFunc) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil))
return rec
}
// mustNewKey 產生一把簽章金鑰;retired 為 true 時標記為已退休。
func mustNewKey(t *testing.T, retired bool) *jwk.SigningKey {
t.Helper()
k, err := jwk.NewSigningKey()
if err != nil {
t.Fatal("NewSigningKey: ", err)
}
if retired {
now := time.Now()
k.RetiredAt = &now
}
return k
}
func TestJWKSHandlerIntegration(t *testing.T) {
db := testdb.New(t)
active1 := mustNewKey(t, false)
active2 := mustNewKey(t, false)
retired := mustNewKey(t, true)
for _, k := range []*jwk.SigningKey{active1, active2, retired} {
if err := db.Create(k).Error; err != nil {
t.Fatal("建立測試金鑰: ", err)
}
}
h := oidc.JWKSHandler(db)
t.Run("僅發佈使用中的金鑰", func(t *testing.T) {
rec := jwksGet(h)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct)
}
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=3600" {
t.Errorf("Cache-Control = %q, want public, max-age=3600", cc)
}
var set jwk.JWKS
if err := json.Unmarshal(rec.Body.Bytes(), &set); err != nil {
t.Fatalf("解析 JWKS: %v", err)
}
if len(set.Keys) != 2 {
t.Fatalf("keys = %d 把, want 2(退休金鑰不應發佈): %s", len(set.Keys), rec.Body.String())
}
kids := map[string]bool{}
for _, k := range set.Keys {
kids[k.Kid] = true
if k.Kty != jwk.KeyTypeRSA || k.Use != jwk.KeyUseSig || k.Alg != jwk.AlgRS256 {
t.Errorf("kid %s 參數 = kty:%q use:%q alg:%q", k.Kid, k.Kty, k.Use, k.Alg)
}
if k.E != "AQAB" {
t.Errorf("kid %s e = %q, want AQAB", k.Kid, k.E)
}
n, err := base64.RawURLEncoding.DecodeString(k.N)
if err != nil || len(n) == 0 {
t.Errorf("kid %s 的 n 應為可解碼的非空 base64url: %q (err=%v)", k.Kid, k.N, err)
}
}
if !kids[active1.Kid] || !kids[active2.Kid] {
t.Errorf("應發佈兩把使用中金鑰 %q、%q,實際 %v", active1.Kid, active2.Kid, kids)
}
if kids[retired.Kid] {
t.Error("退休金鑰不應出現於 JWKS")
}
})
t.Run("GET 為冪等", func(t *testing.T) {
first, second := jwksGet(h), jwksGet(h)
if first.Body.String() != second.Body.String() {
t.Error("兩次 GET 的 JWKS 應相同")
}
})
}
// 空資料庫時 keys 為空陣列而非 null(RFC 7517 §5.1:keys 必要)。
func TestJWKSHandlerEmpty(t *testing.T) {
db := testdb.New(t)
rec := jwksGet(oidc.JWKSHandler(db))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String())
}
if s := rec.Body.String(); s != "{\"keys\":[]}\n" && s != `{"keys":[]}` {
t.Fatalf("空 JWKS 應為 {\"keys\":[]},得到 %s", s)
}
}
// 私鑰儲存毀損的金鑰被跳過,其餘金鑰照常發佈。
func TestJWKSHandlerSkipsCorruptKey(t *testing.T) {
db := testdb.New(t)
good := mustNewKey(t, false)
if err := db.Create(good).Error; err != nil {
t.Fatal("建立測試金鑰: ", err)
}
bad := &jwk.SigningKey{Kid: "corrupt-kid", Algorithm: jwk.AlgRS256, PrivateKeyPEM: "not a pem"}
if err := db.Create(bad).Error; err != nil {
t.Fatal("建立毀損金鑰: ", err)
}
rec := jwksGet(oidc.JWKSHandler(db))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String())
}
var set jwk.JWKS
if err := json.Unmarshal(rec.Body.Bytes(), &set); err != nil {
t.Fatalf("解析 JWKS: %v", err)
}
if len(set.Keys) != 1 || set.Keys[0].Kid != good.Kid {
t.Fatalf("應僅發佈完好的 %q,得到 %s", good.Kid, rec.Body.String())
}
}
+216
View File
@@ -0,0 +1,216 @@
package oidc
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"alterminal/internal/application"
"alterminal/internal/auth"
"alterminal/internal/jwk"
)
// jwtHeader 為 JWT 的 protected header(RFC 7515 §4.1;alg 固定 RS256,
// kid 對應 JWKS 的金鑰識別碼,typ 標示為 JWT)。
type jwtHeader struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
// AccessTokenClaims 為 Access Token(JWT profile,RFC 9068)的 claims:
// 自包含、不落庫,資源端點(/userinfo)以 JWKS 驗證簽章與效期。Sub 為
// 使用者 ID 的字串形式(OIDC Core §2 的 sub claim),Aud 為 client_id。
type AccessTokenClaims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud string `json:"aud"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
Scope string `json:"scope,omitempty"`
ClientID string `json:"client_id,omitempty"`
}
// idTokenClaims 為 ID Token 的 claims(OIDC Core §2)。Name/Email 等
// 個人資料 claim 僅在授權 scope 含對應值時加入;Nonce 回填授權請求的
// 原值供 RP 綁結(OIDC Core §3.1.3.7.4),AuthTime 為使用者本次
// Session 的建立時間(§2 的 auth_time claim,單位秒)。
type idTokenClaims struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud string `json:"aud"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
AuthTime int64 `json:"auth_time,omitempty"`
Nonce string `json:"nonce,omitempty"`
Name string `json:"name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified *bool `json:"email_verified,omitempty"`
}
// ErrInvalidToken 表示 Access Token 無效(格式、簽章、金鑰或效期不符)。
var ErrInvalidToken = errors.New("access token 無效")
// signJWT 以金鑰簽發 RS256 JWT:header 與 claims 各自 JSON 序列化為
// 無填充 base64url,再對兩段連結值以 RSASSA-PKCS1-v1_5 + SHA-256
// 簽章(RFC 7518 §3.3),輸出 header.payload.signature 三段。
func signJWT(key *jwk.SigningKey, claims any) (string, error) {
priv, err := key.PrivateKey()
if err != nil {
return "", fmt.Errorf("parse signing key: %w", err)
}
header, err := json.Marshal(jwtHeader{Alg: jwk.AlgRS256, Kid: key.Kid, Typ: "JWT"})
if err != nil {
return "", fmt.Errorf("marshal jwt header: %w", err)
}
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("marshal jwt claims: %w", err)
}
signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload)
digest := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(nil, priv, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("sign jwt: %w", err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
// currentSigningKey 取最新的使用中簽章金鑰供簽發(輪替時新金鑰在前)。
func currentSigningKey(db *gorm.DB) (*jwk.SigningKey, error) {
var k jwk.SigningKey
if err := db.Where("retired_at IS NULL").Order("created_at DESC").First(&k).Error; err != nil {
return nil, fmt.Errorf("query signing key: %w", err)
}
return &k, nil
}
// subject 為使用者的 sub claim 值:使用者 ID 的十進位字串(OIDC Core
// §2 要求 sub 在 issuer 範圍內穩定且唯一)。
func subject(userID uint) string {
return fmt.Sprintf("%d", userID)
}
// IssueAccessToken 簽發 Access Token(效期 15 分鐘)。
func IssueAccessToken(db *gorm.DB, issuer string, userID uint, app *application.Application, scope string) (string, error) {
key, err := currentSigningKey(db)
if err != nil {
return "", err
}
now := time.Now()
return signJWT(key, AccessTokenClaims{
Iss: issuer,
Sub: subject(userID),
Aud: app.ClientID,
Exp: now.Add(accessTokenTTL).Unix(),
Iat: now.Unix(),
Scope: scope,
ClientID: app.ClientID,
})
}
// IssueIDToken 簽發 ID Token(效期 15 分鐘)。authTime 為使用者
// Session 的建立時間;nonce 為授權請求攜帶的原值(無則空)。個人資料
// claim 依授權 scope 決定(profile:name、preferred_username;email:
// email、email_verified——OIDC Core §5.4)。
func IssueIDToken(db *gorm.DB, issuer string, u *auth.User, app *application.Application, scope, nonce string, authTime time.Time) (string, error) {
key, err := currentSigningKey(db)
if err != nil {
return "", err
}
now := time.Now()
claims := idTokenClaims{
Iss: issuer,
Sub: subject(u.ID),
Aud: app.ClientID,
Exp: now.Add(idTokenTTL).Unix(),
Iat: now.Unix(),
AuthTime: authTime.Unix(),
Nonce: nonce,
}
if scopeHas(scope, "profile") {
claims.Name = u.Name
claims.PreferredUsername = u.Username
}
if scopeHas(scope, "email") {
claims.Email = u.Email
verified := u.EmailVerified
claims.EmailVerified = &verified
}
return signJWT(key, claims)
}
// scopeHas 回傳 scope 集合是否包含 s。
func scopeHas(scope, s string) bool {
for _, f := range strings.Fields(scope) {
if f == s {
return true
}
}
return false
}
// VerifyAccessToken 驗證 Access Token 並回傳其 claims:拆解三段 JWT、
// 拒絕非 RS256 的 alg(RFC 8725 §3.4 的演算法混淆防護)、以 header kid
// 對應的簽章金鑰驗章(金鑰輪替過渡期仍可查得已退休金鑰)、比對 issuer
// 與效期(OIDC Core §3.1.3.7 的 iss/exp 驗證項)。任何一項不符即回
// ErrInvalidToken,不洩漏細節。
func VerifyAccessToken(db *gorm.DB, issuer, token string) (*AccessTokenClaims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "結構")
}
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "header")
}
var h jwtHeader
if err := json.Unmarshal(headerJSON, &h); err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "header")
}
if h.Alg != jwk.AlgRS256 || h.Kid == "" {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "alg")
}
var key jwk.SigningKey
if err := db.Where("kid = ?", h.Kid).First(&key).Error; err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "kid")
}
priv, err := key.PrivateKey()
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "kid")
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "signature")
}
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(&priv.PublicKey, crypto.SHA256, digest[:], sig); err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "signature")
}
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims")
}
var claims AccessTokenClaims
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims")
}
if claims.Iss != issuer {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "issuer")
}
if claims.Exp <= time.Now().Unix() {
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "exp")
}
return &claims, nil
}
+112
View File
@@ -0,0 +1,112 @@
// 外部測試套件:見 jwks_test.go 開頭說明。
package oidc_test
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
"alterminal/internal/oidc"
)
// IssueAccessToken 簽出的 token 應可由 VerifyAccessToken 驗回,且 claims
// 正確(iss/sub/aud/scope)。
func TestAccessTokenRoundTrip(t *testing.T) {
e := newTestEnv(t)
token, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid profile")
if err != nil {
t.Fatal("IssueAccessToken: ", err)
}
claims, err := oidc.VerifyAccessToken(e.db, testIssuer, token)
if err != nil {
t.Fatal("VerifyAccessToken: ", err)
}
if claims.Iss != testIssuer {
t.Errorf("iss = %q, want %q", claims.Iss, testIssuer)
}
if claims.Sub != subjectOf(e.user.ID) {
t.Errorf("sub = %q, want %q", claims.Sub, subjectOf(e.user.ID))
}
if claims.Aud != e.app.ClientID || claims.ClientID != e.app.ClientID {
t.Errorf("aud/client_id = %q/%q, want %q", claims.Aud, claims.ClientID, e.app.ClientID)
}
if claims.Scope != "openid profile" {
t.Errorf("scope = %q", claims.Scope)
}
// header 應含正確的 alg 與 kid(RP 以 kid 對應 JWKS)。
header, _ := jwtParts(t, token)
var h struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
Typ string `json:"typ"`
}
if err := json.Unmarshal(header, &h); err != nil {
t.Fatal("解析 header: ", err)
}
if h.Alg != "RS256" || h.Kid != e.key.Kid || h.Typ != "JWT" {
t.Errorf("header = %+v", h)
}
}
// 各種無效 token 都應回 ErrInvalidToken,不洩漏細節。
func TestVerifyAccessTokenRejectsInvalid(t *testing.T) {
e := newTestEnv(t)
good, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid")
if err != nil {
t.Fatal(err)
}
rs256Header := func(kid string) map[string]string {
return map[string]string{"alg": "RS256", "kid": kid, "typ": "JWT"}
}
// 竄改 payload:改動第一個字元後以原簽章送出,驗章應失敗。
parts := strings.Split(good, ".")
payloadBytes := []byte(parts[1])
if payloadBytes[0] == 'e' {
payloadBytes[0] = 'e' + 1
} else {
payloadBytes[0] = 'e'
}
parts[1] = string(payloadBytes)
tampered := strings.Join(parts, ".")
tests := []struct {
name string
token string
}{
{"非 JWT 結構", "not-a-jwt"},
{"alg=none(演算法混淆)", forgeJWT(t, e.key, map[string]string{"alg": "none", "kid": e.key.Kid}, map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})},
{"alg=HS256", forgeJWT(t, e.key, map[string]string{"alg": "HS256", "kid": e.key.Kid}, map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})},
{"kid 不存在", forgeJWT(t, e.key, rs256Header("unknown-kid"), map[string]any{"iss": testIssuer, "exp": time.Now().Add(time.Hour).Unix()})},
{"issuer 不符", forgeJWT(t, e.key, rs256Header(e.key.Kid), map[string]any{"iss": "https://other.example", "exp": time.Now().Add(time.Hour).Unix()})},
{"已過期", forgeJWT(t, e.key, rs256Header(e.key.Kid), map[string]any{"iss": testIssuer, "exp": time.Now().Add(-time.Minute).Unix()})},
{"竄改 payload", tampered},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := oidc.VerifyAccessToken(e.db, testIssuer, tt.token)
if !errors.Is(err, oidc.ErrInvalidToken) {
t.Fatalf("err = %v, want ErrInvalidToken", err)
}
})
}
}
// 已退休金鑰簽發的 token 在效期內仍應驗證成功(輪替過渡期,JWKS 同步
// 發佈新舊金鑰的模型)。
func TestVerifyAccessTokenRetiredKey(t *testing.T) {
e := newTestEnv(t)
token, err := oidc.IssueAccessToken(e.db, testIssuer, e.user.ID, e.app, "openid")
if err != nil {
t.Fatal(err)
}
now := time.Now()
if err := e.db.Model(e.key).Update("retired_at", now).Error; err != nil {
t.Fatal("退休金鑰: ", err)
}
if _, err := oidc.VerifyAccessToken(e.db, testIssuer, token); err != nil {
t.Fatalf("退休金鑰在效期內仍應可驗證: %v", err)
}
}
+280
View File
@@ -0,0 +1,280 @@
package oidc
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"sort"
"strings"
"time"
"gorm.io/gorm"
"alterminal/internal/auth"
)
// 各種憑證的有效時間:授權碼為一次性短時效憑證(RFC 6749 §4.1.2 建議
// 最長 10 分鐘,取 5 分鐘);Access/ID Token 15 分鐘為業界常見值;
// Refresh Token 30 天,兌換時輪替。
const (
authorizationCodeTTL = 5 * time.Minute
accessTokenTTL = 15 * time.Minute
idTokenTTL = 15 * time.Minute
refreshTokenTTL = 30 * 24 * time.Hour
)
// sha256Token 回傳字串的 SHA-256 雜湊(無填充 base64url,43 字元)。
// 授權碼與 refresh token 本身即高熵亂數,兌換時僅能以憑證值查詢、無
// 其他鍵可用(不同於 client secret 以 client_id 為鍵後再做慢雜湊),
// 故以 SHA-256 作為可索引的確定性雜湊儲存;資料庫外洩時攻擊者亦無法
// 還原明文憑證(RFC 6819 §5.2.2.1 的憑證儲存建議)。
func sha256Token(s string) string {
sum := sha256.Sum256([]byte(s))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// normalizeScope 將空格分隔的 scope 拆解、去重並排序後回傳。比對與
// 儲存皆使用正規化形式,避免「openid profile」與「profile openid」
// 被視為不同集合。
func normalizeScope(scope string) []string {
fields := strings.Fields(scope)
sort.Strings(fields)
seen := make(map[string]bool, len(fields))
out := make([]string, 0, len(fields))
for _, f := range fields {
if !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out
}
// scopeCovered 回傳 requested 中的每個 scope 皆存在於 granted。
func scopeCovered(granted, requested string) bool {
g := make(map[string]bool)
for _, s := range strings.Fields(granted) {
g[s] = true
}
for _, s := range strings.Fields(requested) {
if !g[s] {
return false
}
}
return true
}
// AuthorizationCode 為授權碼流程的一次性憑證(RFC 6749 §4.1.2),對應
// authorization_codes 資料表。CodeHash 為授權碼明文的 SHA-256,明文僅
// 在發行當下出現於 redirect URI 一次;兌換後設定 UsedAt,之後再次兌換
// 即為重用——除拒絕外並撤銷該碼發行的一切 refresh token。RedirectURI、
// Scope、CodeChallenge 等發行當下的授權內容隨碼凍結,兌換時逐項比對。
type AuthorizationCode struct {
ID uint `gorm:"primaryKey"`
CodeHash string `gorm:"uniqueIndex;size:43;not null"`
ApplicationID uint `gorm:"not null;index"`
UserID uint `gorm:"not null;index"`
RedirectURI string `gorm:"size:2048;not null"`
Scope string `gorm:"size:255;not null"`
Nonce string `gorm:"size:255;not null;default:''"` // OIDC Core §3.1.2.1 nonce,未提供為空
CodeChallenge string `gorm:"size:255;not null;default:''"` // RFC 7636 §4.3 的 challenge(S256),未使用 PKCE 為空
CodeChallengeMethod string `gorm:"size:16;not null;default:''"` // "S256" 或空字串
AuthTime time.Time `gorm:"not null"` // 使用者 Session 建立時間(ID token auth_time 的依據,OIDC Core §2)
ExpiresAt time.Time `gorm:"not null"`
UsedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// NewAuthorizationCode 產生並儲存授權碼,回傳模型與明文——明文僅此一次,
// 呼叫方隨即放入 redirect URI,不得留存。authTime 為使用者 Session 的
// 建立時間,隨碼保存供兌換時簽入 ID token。順帶刪除已過期的授權碼
// (最佳清除,失敗不影響發碼)。
func NewAuthorizationCode(db *gorm.DB, applicationID, userID uint, redirectURI, scope, nonce, codeChallenge, codeChallengeMethod string, authTime time.Time) (*AuthorizationCode, string, error) {
code, err := auth.NewToken(32)
if err != nil {
return nil, "", fmt.Errorf("generate code: %w", err)
}
ac := &AuthorizationCode{
CodeHash: sha256Token(code),
ApplicationID: applicationID,
UserID: userID,
RedirectURI: redirectURI,
Scope: scope,
Nonce: nonce,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
AuthTime: authTime,
ExpiresAt: time.Now().Add(authorizationCodeTTL),
}
if err := db.Create(ac).Error; err != nil {
return nil, "", fmt.Errorf("create authorization code: %w", err)
}
db.Where("expires_at < ?", time.Now()).Delete(&AuthorizationCode{})
return ac, code, nil
}
// GetAuthorizationCode 以授權碼明文(雜湊後)查詢對應資料列;查無資料
// 時回傳包裹 gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。
func GetAuthorizationCode(db *gorm.DB, code string) (*AuthorizationCode, error) {
var ac AuthorizationCode
if err := db.Where("code_hash = ?", sha256Token(code)).First(&ac).Error; err != nil {
return nil, fmt.Errorf("query authorization code: %w", err)
}
return &ac, nil
}
// ConsumeAuthorizationCode 以條件更新(used_at 仍為 NULL 且未過期)標記
// 授權碼已兌換,回傳是否成功。條件更新保證並發的第二次兌換必然失敗
// (RFC 6749 §4.1.2 的一次性要求;先查後寫在並發下會有競態)。
func ConsumeAuthorizationCode(db *gorm.DB, id uint) (bool, error) {
now := time.Now()
res := db.Model(&AuthorizationCode{}).
Where("id = ? AND used_at IS NULL AND expires_at > ?", id, now).
Update("used_at", now)
if res.Error != nil {
return false, fmt.Errorf("consume authorization code: %w", res.Error)
}
return res.RowsAffected == 1, nil
}
// RefreshToken 為換發新權杖的長效憑證(RFC 6749 §6),對應
// refresh_tokens 資料表。TokenHash 為明文的 SHA-256。輪替模型為
// 「兌換即作廢舊 token 並發行新 token」(OAuth 2.0 Security BCP
// §4.14.2):RotatedAt 標記已輪替、RevokedAt 標記已撤銷;兌換已輪替
// 的 token 視為重用,撤銷該使用者於該應用程式的全部 refresh token。
// AuthorizationID 記錄發行來源的授權碼,授權碼重用時據此撤銷。
type RefreshToken struct {
ID uint `gorm:"primaryKey"`
TokenHash string `gorm:"uniqueIndex;size:43;not null"`
ApplicationID uint `gorm:"not null;index"`
UserID uint `gorm:"not null;index"`
AuthorizationID uint `gorm:"not null;index"`
Scope string `gorm:"size:255;not null"`
AuthTime time.Time `gorm:"not null"` // 沿用發行來源授權碼的值;來源授權碼到期清除後仍可簽發 ID token
ExpiresAt time.Time `gorm:"not null"`
RotatedAt *time.Time
RevokedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// NewRefreshToken 產生並儲存 refresh token,回傳模型與明文——明文僅在
// token 回應中出現一次。順帶刪除已過期的 refresh token(最佳清除,
// 失敗不影響發行)。
func NewRefreshToken(db *gorm.DB, applicationID, userID, authorizationID uint, scope string, authTime time.Time) (*RefreshToken, string, error) {
token, err := auth.NewToken(32)
if err != nil {
return nil, "", fmt.Errorf("generate refresh token: %w", err)
}
rt := &RefreshToken{
TokenHash: sha256Token(token),
ApplicationID: applicationID,
UserID: userID,
AuthorizationID: authorizationID,
Scope: scope,
AuthTime: authTime,
ExpiresAt: time.Now().Add(refreshTokenTTL),
}
if err := db.Create(rt).Error; err != nil {
return nil, "", fmt.Errorf("create refresh token: %w", err)
}
db.Where("expires_at < ?", time.Now()).Delete(&RefreshToken{})
return rt, token, nil
}
// GetRefreshToken 以 refresh token 明文(雜湊後)查詢對應資料列;查無
// 資料時回傳包裹 gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。
func GetRefreshToken(db *gorm.DB, token string) (*RefreshToken, error) {
var rt RefreshToken
if err := db.Where("token_hash = ?", sha256Token(token)).First(&rt).Error; err != nil {
return nil, fmt.Errorf("query refresh token: %w", err)
}
return &rt, nil
}
// RotateRefreshToken 以條件更新(rotated_at 與 revoked_at 仍為 NULL 且
// 未過期)標記 refresh token 已輪替,回傳是否成功;並發的重複兌換僅
// 一個成功,失敗方即為重用。
func RotateRefreshToken(db *gorm.DB, id uint) (bool, error) {
now := time.Now()
res := db.Model(&RefreshToken{}).
Where("id = ? AND rotated_at IS NULL AND revoked_at IS NULL AND expires_at > ?", id, now).
Update("rotated_at", now)
if res.Error != nil {
return false, fmt.Errorf("rotate refresh token: %w", res.Error)
}
return res.RowsAffected == 1, nil
}
// RevokeRefreshTokensByAuthorization 撤銷指定授權碼發行的所有 refresh
// token(授權碼重用時的防護,RFC 6749 §4.1.2)。
func RevokeRefreshTokensByAuthorization(db *gorm.DB, authorizationID uint) error {
if err := db.Model(&RefreshToken{}).
Where("authorization_id = ? AND revoked_at IS NULL", authorizationID).
Update("revoked_at", time.Now()).Error; err != nil {
return fmt.Errorf("revoke refresh tokens: %w", err)
}
return nil
}
// RevokeRefreshTokensFor 撤銷使用者於指定應用程式的所有 refresh token
// (refresh token 重用偵測時的整鏈撤銷,OAuth 2.0 Security BCP §4.14.2)。
func RevokeRefreshTokensFor(db *gorm.DB, userID, applicationID uint) error {
if err := db.Model(&RefreshToken{}).
Where("user_id = ? AND application_id = ? AND revoked_at IS NULL", userID, applicationID).
Update("revoked_at", time.Now()).Error; err != nil {
return fmt.Errorf("revoke refresh tokens: %w", err)
}
return nil
}
// Consent 為使用者對應用程式的授權同意記錄,對應 consents 資料表:
// 同意頁首次同意後記住 scope 聯集,之後請求的 scope 全部涵蓋於已同意
// 集合時靜默通過,不再顯示同意頁;請求範圍擴大時再次詢問。
type Consent struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"not null;uniqueIndex:idx_consents_user_application,priority:1"`
ApplicationID uint `gorm:"not null;uniqueIndex:idx_consents_user_application,priority:2"`
Scope string `gorm:"size:255;not null"`
CreatedAt time.Time
UpdatedAt time.Time
}
// GetConsent 查詢使用者對應用程式的同意記錄;查無資料時回傳包裹
// gorm.ErrRecordNotFound 的錯誤(以 errors.Is 判斷)。
func GetConsent(db *gorm.DB, userID, applicationID uint) (*Consent, error) {
var c Consent
if err := db.Where("user_id = ? AND application_id = ?", userID, applicationID).First(&c).Error; err != nil {
return nil, fmt.Errorf("query consent: %w", err)
}
return &c, nil
}
// SaveConsent 記錄同意:首次建立,之後將新的 scope 併入既有聯集(同意
// 頁勾選一律代表「允許全部請求的 scope」)。
func SaveConsent(db *gorm.DB, userID, applicationID uint, scope string) error {
var c Consent
err := db.Where("user_id = ? AND application_id = ?", userID, applicationID).First(&c).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
c = Consent{
UserID: userID,
ApplicationID: applicationID,
Scope: strings.Join(normalizeScope(scope), " "),
}
if err := db.Create(&c).Error; err != nil {
return fmt.Errorf("create consent: %w", err)
}
return nil
case err != nil:
return fmt.Errorf("query consent: %w", err)
}
merged := normalizeScope(c.Scope + " " + scope)
c.Scope = strings.Join(merged, " ")
if err := db.Save(&c).Error; err != nil {
return fmt.Errorf("update consent: %w", err)
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package oidc
import (
"strings"
"testing"
)
// normalizeScope 應拆解、去重並排序 scope。
func TestNormalizeScope(t *testing.T) {
tests := []struct {
name string
scope string
want string
}{
{"空字串", "", ""},
{"多餘空白", " openid profile ", "openid profile"},
{"去除重複", "profile openid profile", "openid profile"},
{"排序", "email openid", "email openid"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := strings.Join(normalizeScope(tt.scope), " "); got != tt.want {
t.Fatalf("normalizeScope(%q) = %q, want %q", tt.scope, got, tt.want)
}
})
}
}
// scopeCovered 判斷請求 scope 是否全數涵蓋於已同意集合。
func TestScopeCovered(t *testing.T) {
tests := []struct {
name string
granted string
requested string
want bool
}{
{"完全相同", "openid profile", "openid profile", true},
{"請求子集", "openid profile email", "openid email", true},
{"請求超出", "openid", "openid email", false},
{"完全無關", "openid", "profile", false},
{"空請求", "openid", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := scopeCovered(tt.granted, tt.requested); got != tt.want {
t.Fatalf("scopeCovered(%q, %q) = %v, want %v", tt.granted, tt.requested, got, tt.want)
}
})
}
}
// sha256Token 輸出長度應為 43 字元(32 bytes 的 base64url)。
func TestSha256Token(t *testing.T) {
got := sha256Token("test")
if len(got) != 43 {
t.Fatalf("SHA-256 base64url 長度 = %d, want 43", len(got))
}
if sha256Token("test") != got {
t.Fatal("同一輸入應得相同雜湊")
}
if sha256Token("other") == got {
t.Fatal("不同輸入應得不同雜湊")
}
}
+345
View File
@@ -0,0 +1,345 @@
package oidc
import (
"crypto/sha256"
"encoding/base64"
"errors"
"log"
"net/http"
"net/url"
"strings"
"time"
"gorm.io/gorm"
"alterminal/internal/application"
"alterminal/internal/auth"
)
// tokenResponse 為 token 端點的成功回應(RFC 6749 §5.1;ID token 與
// refresh token 僅在對應條件成立時出現——ID token 於簽發對象為使用者
// 且 scope 含 openid 時、refresh token 於 scope 含 offline_access 時,
// OIDC Core §3.1.3.3)。
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Scope string `json:"scope"`
IDToken string `json:"id_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
}
// tokenError 為 RFC 6749 §5.2 的錯誤回應格式。
type tokenError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// writeTokenError 輸出 token 端點錯誤;client 認證失敗(invalid_client)
// 回 401,其餘依規格回 400。
func writeTokenError(w http.ResponseWriter, status int, code, description string) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
if status == http.StatusUnauthorized {
// 以 Basic 認證的請求須提示 Basic(RFC 6749 §5.2),一律附上不影響。
w.Header().Set("WWW-Authenticate", `Basic realm="alterminal"`)
}
auth.WriteJSON(w, status, tokenError{Error: code, ErrorDescription: description})
}
// TokenHandler 處理 POST /token(RFC 6749 §3.2):以授權碼(§4.1.3)
// 或 refresh token(§6)換發 Access/ID/Refresh Token。
func TokenHandler(db *gorm.DB, issuer string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeTokenError(w, http.StatusMethodNotAllowed, "invalid_request", "僅支援 POST")
return
}
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "Content-Type 須為 application/x-www-form-urlencoded")
return
}
if err := r.ParseForm(); err != nil {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "無法解析表單內容")
return
}
app := authenticateTokenClient(db, w, r)
if app == nil {
return
}
switch r.PostFormValue("grant_type") {
case "authorization_code":
tokenAuthorizationCode(db, issuer, w, r, app)
case "refresh_token":
tokenRefreshToken(db, issuer, w, r, app)
case "":
writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 grant_type")
default:
writeTokenError(w, http.StatusBadRequest, "unsupported_grant_type", "不支援的 grant_type")
}
}
}
// authenticateTokenClient 驗證 Client 身分(RFC 6749 §2.3.1):client
// 認證可經 HTTP Basic(推薦)或表單欄位,兩處同時出現的 client_id 必須
// 一致(§3.2.1)。機密式 Client 必須提出正確的 client secret;公開式
// Client 不持有 secret,僅以 client_id 識別(PKCE 承擔防護)。驗證失敗
// 已寫出 401 回應並回傳 nil。
func authenticateTokenClient(db *gorm.DB, w http.ResponseWriter, r *http.Request) *application.Application {
basicID, basicSecret, hasBasic := r.BasicAuth()
postID := r.PostFormValue("client_id")
postSecret := r.PostFormValue("client_secret")
// RFC 6749 §2.3.1 要求 Basic 中的 client_id/secret 先以表單編碼;
// 本服務產生的識別值僅含 base64url 字元,解碼失敗時退回原值以相容
// 未編碼的實作。
if basicID != "" {
if unescaped, err := url.QueryUnescape(basicID); err == nil {
basicID = unescaped
}
if unescaped, err := url.QueryUnescape(basicSecret); err == nil {
basicSecret = unescaped
}
}
if hasBasic && postID != "" && basicID != postID {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "Basic 與表單的 client_id 不一致")
return nil
}
clientID := postID
if clientID == "" {
clientID = basicID
}
if clientID == "" {
writeTokenError(w, http.StatusUnauthorized, "invalid_client", "缺少 client_id")
return nil
}
app, err := application.GetByClientID(db, clientID)
if err != nil {
// 查無 client 或查詢失敗一律 401,不洩漏 client 是否存在。
if !errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("token: %v", err)
}
writeTokenError(w, http.StatusUnauthorized, "invalid_client", "client 認證失敗")
return nil
}
secret := postSecret
if hasBasic && secret == "" {
secret = basicSecret
}
if !app.IsPublic() && !app.CheckSecret(secret) {
writeTokenError(w, http.StatusUnauthorized, "invalid_client", "client 認證失敗")
return nil
}
return app
}
// tokenAuthorizationCode 處理 grant_type=authorization_code(RFC 6749
// §4.1.3):兌換一次性授權碼,逐項比對兌換條件後簽發權杖。
func tokenAuthorizationCode(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request, app *application.Application) {
if !app.GrantTypes.Contains(application.GrantAuthorizationCode) {
writeTokenError(w, http.StatusBadRequest, "unauthorized_client", "應用程式未啟用授權碼流程")
return
}
code := r.PostFormValue("code")
if code == "" {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 code")
return
}
ac, err := GetAuthorizationCode(db, code)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("token: %v", err)
}
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
return
}
if ac.UsedAt != nil {
// 授權碼重用:撤銷其發行的一切 refresh token(RFC 6749 §4.1.2
// 一次性要求;OAuth 2.0 Security BCP §4.5.3.3 的防護)。
if err := RevokeRefreshTokensByAuthorization(db, ac.ID); err != nil {
log.Printf("token: %v", err)
}
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
return
}
if ac.ExpiresAt.Before(time.Now()) {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼已過期")
return
}
// 授權碼與 client 及 redirect_uri 的綁定逐項比對(RFC 6749 §4.1.3);
// 不符一律回 invalid_grant,不洩漏原因。
if ac.ApplicationID != app.ID || r.PostFormValue("redirect_uri") != ac.RedirectURI {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
return
}
// PKCE(RFC 7636 §4.6):發碼時有 challenge 者,兌換必須提出比對
// 相符的 code_verifier。
if ac.CodeChallenge != "" {
verifier := r.PostFormValue("code_verifier")
if !validCodeVerifier(verifier) {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "code_verifier 格式無效")
return
}
if pkceChallenge(verifier) != ac.CodeChallenge {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "PKCE 驗證失敗")
return
}
}
ok, err := ConsumeAuthorizationCode(db, ac.ID)
if err != nil {
log.Printf("token: %v", err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
if !ok {
// 並發兌換的輸家;勝者已完成撤銷防護,比照重用處理。
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "授權碼無效")
return
}
var u auth.User
if err := db.First(&u, ac.UserID).Error; err != nil {
log.Printf("token: 查詢使用者 %d: %v", ac.UserID, err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
issueTokenResponse(db, issuer, w, &u, app, ac.Scope, ac.Nonce, ac.ID, ac.AuthTime)
}
// tokenRefreshToken 處理 grant_type=refresh_token(RFC 6749 §6):以
// refresh token 換發新權杖組,舊 token 立即輪替作廢;偵測到重用已輪替
// 的 token 時撤銷該使用者於該應用程式的全部 refresh token。
func tokenRefreshToken(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request, app *application.Application) {
if !app.GrantTypes.Contains(application.GrantRefreshToken) {
writeTokenError(w, http.StatusBadRequest, "unauthorized_client", "應用程式未啟用 refresh_token")
return
}
token := r.PostFormValue("refresh_token")
if token == "" {
writeTokenError(w, http.StatusBadRequest, "invalid_request", "缺少 refresh_token")
return
}
rt, err := GetRefreshToken(db, token)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
log.Printf("token: %v", err)
}
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效")
return
}
if rt.RotatedAt != nil {
// 已輪替的 token 再次出現即為重用:整鏈撤銷(OAuth 2.0
// Security BCP §4.14.2)。
if err := RevokeRefreshTokensFor(db, rt.UserID, rt.ApplicationID); err != nil {
log.Printf("token: %v", err)
}
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 重用,相關權杖已撤銷")
return
}
if rt.RevokedAt != nil || rt.ExpiresAt.Before(time.Now()) {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 已失效")
return
}
if rt.ApplicationID != app.ID {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效")
return
}
// RFC 6749 §6:請求可縮小 scope,不可擴大。
scope := rt.Scope
if req := strings.TrimSpace(r.PostFormValue("scope")); req != "" {
if !scopeCovered(rt.Scope, req) {
writeTokenError(w, http.StatusBadRequest, "invalid_scope", "請求的 scope 超出原授權範圍")
return
}
scope = strings.Join(normalizeScope(req), " ")
}
ok, err := RotateRefreshToken(db, rt.ID)
if err != nil {
log.Printf("token: %v", err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
if !ok {
writeTokenError(w, http.StatusBadRequest, "invalid_grant", "refresh token 無效")
return
}
var u auth.User
if err := db.First(&u, rt.UserID).Error; err != nil {
log.Printf("token: 查詢使用者 %d: %v", rt.UserID, err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
issueTokenResponse(db, issuer, w, &u, app, scope, "", rt.AuthorizationID, rt.AuthTime)
}
// issueTokenResponse 簽發權杖組並寫出成功回應:Access Token 必發;scope
// 含 openid 時簽發 ID token;scope 含 offline_access 時簽發 refresh
// token 並作廢舊授權碼鏈的後繼(由輪替模型保證單一現行 token)。
// authorizationID 為本次授權鏈的源頭授權碼 ID,refresh token 沿用記錄。
func issueTokenResponse(db *gorm.DB, issuer string, w http.ResponseWriter, u *auth.User, app *application.Application, scope, nonce string, authorizationID uint, authTime time.Time) {
access, err := IssueAccessToken(db, issuer, u.ID, app, scope)
if err != nil {
log.Printf("token: %v", err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
resp := tokenResponse{
AccessToken: access,
TokenType: "Bearer",
ExpiresIn: int64(accessTokenTTL.Seconds()),
Scope: scope,
}
if scopeHas(scope, "openid") {
idToken, err := IssueIDToken(db, issuer, u, app, scope, nonce, authTime)
if err != nil {
log.Printf("token: %v", err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
resp.IDToken = idToken
}
if scopeHas(scope, "offline_access") {
_, plain, err := NewRefreshToken(db, app.ID, u.ID, authorizationID, scope, authTime)
if err != nil {
log.Printf("token: %v", err)
writeTokenError(w, http.StatusInternalServerError, "", "")
return
}
resp.RefreshToken = plain
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
auth.WriteJSON(w, http.StatusOK, resp)
}
// validCodeVerifier 檢查 code_verifier 格式(RFC 7636 §4.1):
// 43–128 個字元,僅含 [A-Za-z0-9-._~]。
func validCodeVerifier(v string) bool {
if len(v) < 43 || len(v) > 128 {
return false
}
for _, c := range v {
switch {
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9':
case c == '-' || c == '.' || c == '_' || c == '~':
default:
return false
}
}
return true
}
// pkceChallenge 計算 code_verifier 的 S256 challenge(RFC 7636 §4.2):
// BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))。
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
+378
View File
@@ -0,0 +1,378 @@
// 外部測試套件:見 jwks_test.go 開頭說明。
package oidc_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"alterminal/internal/oidc"
"alterminal/internal/testdb"
)
// RFC 7636 附錄 B 的官方測試向量:code_verifier 與其 S256 challenge。
const (
testVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
testChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
wrongVerifier = "wJ-B4LdB4kNOXK32ONwPccn9YMHcGgnbHDB1jXtsCXc" // 格式合法但與 challenge 不符
)
// tokenBody 為成功回應的斷言結構。
type tokenBody struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Scope string `json:"scope"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
}
// exchangeCode 兌換授權碼,回傳記錄器。basic=true 時以 HTTP Basic 認證
// (表單不帶 client 欄位),否則以 client_secret_post 送出。
func exchangeCode(h http.HandlerFunc, code, redirectURI, clientID, clientSecret, verifier string, basic bool) *httptest.ResponseRecorder {
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
}
if verifier != "" {
form.Set("code_verifier", verifier)
}
if basic {
return postToken(h, form, clientID, clientSecret)
}
form.Set("client_id", clientID)
if clientSecret != "" {
form.Set("client_secret", clientSecret)
}
return postToken(h, form, "", "")
}
// 完整兌換:機密式 Client + PKCE + Basic 認證,核發 Access/ID/Refresh
// Token,ID token 各 claim 依授權內容簽入(OIDC Core §3.1.3.3、§5.4)。
func TestTokenAuthorizationCodeFull(t *testing.T) {
e := newTestEnv(t)
h := oidc.TokenHandler(e.db, testIssuer)
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile email offline_access", "xyz", "nonce-42", testChallenge))
rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, true)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String())
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", cc)
}
var body tokenBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal("解析回應: ", err)
}
if body.AccessToken == "" || body.TokenType != "Bearer" || body.ExpiresIn != 900 {
t.Errorf("access token 欄位不符: %+v", body)
}
if body.Scope != "email offline_access openid profile" {
t.Errorf("scope = %q(應為正規化排序形式)", body.Scope)
}
if body.IDToken == "" {
t.Fatal("scope 含 openid 應核發 id_token")
}
if body.RefreshToken == "" {
t.Fatal("scope 含 offline_access 應核發 refresh_token")
}
_, payload := jwtParts(t, body.IDToken)
var idc idTokenClaims
if err := json.Unmarshal(payload, &idc); err != nil {
t.Fatal("解析 ID token: ", err)
}
if idc.Iss != testIssuer || idc.Aud != e.app.ClientID {
t.Errorf("iss/aud = %q/%q", idc.Iss, idc.Aud)
}
if idc.Sub != subjectOf(e.user.ID) {
t.Errorf("sub = %q, want %q", idc.Sub, subjectOf(e.user.ID))
}
if idc.Nonce != "nonce-42" {
t.Errorf("nonce = %q, want nonce-42", idc.Nonce)
}
if idc.AuthTime == 0 {
t.Error("auth_time 應簽入 Session 建立時間")
}
if idc.Name != e.user.Name || idc.Email != e.user.Email || idc.EmailVerf == nil || !*idc.EmailVerf {
t.Errorf("profile/email claims 不符: %+v", idc)
}
// 無 offline_access 的 scope 不應拿到 refresh token。
_, code2 := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge))
rec = exchangeCode(h, code2, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, false)
if rec.Code != http.StatusOK {
t.Fatalf("第二次兌換 status = %d, body = %s", rec.Code, rec.Body.String())
}
var body2 tokenBody
json.Unmarshal(rec.Body.Bytes(), &body2)
if body2.RefreshToken != "" {
t.Error("未請求 offline_access 不應核發 refresh_token")
}
if body2.IDToken == "" {
t.Error("scope 含 openid 應核發 id_token")
}
}
// 公開式 Client 無 secret,以 PKCE 兌換(client_secret_post 欄位不送)。
func TestTokenPublicClientPKCE(t *testing.T) {
e := newTestEnv(t)
h := oidc.TokenHandler(e.db, testIssuer)
_, code := consentAllow(t, e, authorizeQuery(e.pub, "openid", "", "", testChallenge))
rec := exchangeCode(h, code, e.pub.RedirectURIs[0], e.pub.ClientID, "", testVerifier, false)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String())
}
var body tokenBody
json.Unmarshal(rec.Body.Bytes(), &body)
if body.AccessToken == "" {
t.Fatal("應核發 access_token")
}
}
// client 認證失敗與參數錯誤。
func TestTokenClientAuthentication(t *testing.T) {
e := newTestEnv(t)
h := oidc.TokenHandler(e.db, testIssuer)
t.Run("client secret 錯誤回 401 invalid_client", func(t *testing.T) {
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge))
rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, "wrong-secret", testVerifier, true)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if got := decodeTokenError(t, rec).Error; got != "invalid_client" {
t.Errorf("error = %q, want invalid_client", got)
}
if rec.Header().Get("WWW-Authenticate") == "" {
t.Error("Basic 認證失敗應附 WWW-Authenticate")
}
})
t.Run("未知 client_id 回 401", func(t *testing.T) {
rec := exchangeCode(h, "any", e.app.RedirectURIs[0], "no-such", "x", "", false)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
})
t.Run("Basic 與表單 client_id 不一致", func(t *testing.T) {
form := url.Values{"grant_type": {"authorization_code"}, "code": {"x"}, "client_id": {e.app.ClientID}}
rec := postToken(h, form, "no-such", "secret")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if got := decodeTokenError(t, rec).Error; got != "invalid_request" {
t.Errorf("error = %q, want invalid_request", got)
}
})
t.Run("不支援的 grant_type", func(t *testing.T) {
form := url.Values{"grant_type": {"password"}, "client_id": {e.app.ClientID}, "client_secret": {e.secret}}
rec := postToken(h, form, "", "")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if got := decodeTokenError(t, rec).Error; got != "unsupported_grant_type" {
t.Errorf("error = %q", got)
}
})
t.Run("Content-Type 非 form 回 400", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/token", nil)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
})
}
// 授權碼兌換的條件比對與一次性(RFC 6749 §4.1.3)。
func TestTokenCodeRedemptionErrors(t *testing.T) {
e := newTestEnv(t)
h := oidc.TokenHandler(e.db, testIssuer)
redirectURI := e.app.RedirectURIs[0]
mustCode := func(t *testing.T) string {
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge))
return code
}
t.Run("code_verifier 不符回 invalid_grant", func(t *testing.T) {
rec := exchangeCode(h, mustCode(t), redirectURI, e.app.ClientID, e.secret, wrongVerifier, true)
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
t.Fatalf("error = %q, want invalid_grant, body = %s", got, rec.Body.String())
}
})
t.Run("code_verifier 格式無效回 invalid_request", func(t *testing.T) {
rec := exchangeCode(h, mustCode(t), redirectURI, e.app.ClientID, e.secret, "short", true)
if got := decodeTokenError(t, rec).Error; got != "invalid_request" {
t.Fatalf("error = %q, want invalid_request", got)
}
})
t.Run("redirect_uri 與發碼時不符回 invalid_grant", func(t *testing.T) {
rec := exchangeCode(h, mustCode(t), "https://rp.example/other", e.app.ClientID, e.secret, testVerifier, true)
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
t.Fatalf("error = %q, want invalid_grant", got)
}
})
t.Run("換別的 client 也回 invalid_grant", func(t *testing.T) {
rec := exchangeCode(h, mustCode(t), redirectURI, e.pub.ClientID, "", testVerifier, false)
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
t.Fatalf("error = %q, want invalid_grant", got)
}
})
t.Run("不存在的 code", func(t *testing.T) {
rec := exchangeCode(h, "no-such-code", redirectURI, e.app.ClientID, e.secret, "", true)
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
t.Fatalf("error = %q, want invalid_grant", got)
}
})
t.Run("重用撤銷其 refresh token", func(t *testing.T) {
code := mustCode(t)
form := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, "code_verifier": {testVerifier}}
rec := postToken(h, form, e.app.ClientID, e.secret)
if rec.Code != http.StatusOK {
t.Fatalf("首次兌換失敗: %s", rec.Body.String())
}
var first tokenBody
json.Unmarshal(rec.Body.Bytes(), &first)
// 同一碼再兌換:invalid_grant,且首次拿到的 refresh token 應被撤銷。
rec = postToken(h, form, e.app.ClientID, e.secret)
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
t.Fatalf("重用 error = %q, want invalid_grant", got)
}
refreshForm := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {first.RefreshToken}}
rec = postToken(h, refreshForm, e.app.ClientID, e.secret)
if rec.Code != http.StatusBadRequest {
t.Fatalf("被撤銷的 refresh token 不應可用: %s", rec.Body.String())
}
})
}
// Refresh token 輪替與重用整鏈撤銷(OAuth 2.0 Security BCP §4.14.2)。
func TestTokenRefreshRotationAndReuse(t *testing.T) {
e := newTestEnv(t)
h := oidc.TokenHandler(e.db, testIssuer)
// 取得一組含 offline_access 的權杖。
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile offline_access", "", "", testChallenge))
rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, true)
if rec.Code != http.StatusOK {
t.Fatalf("兌換失敗: %s", rec.Body.String())
}
var first tokenBody
json.Unmarshal(rec.Body.Bytes(), &first)
refresh := func(token, scope string) *httptest.ResponseRecorder {
form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {token}}
if scope != "" {
form.Set("scope", scope)
}
return postToken(h, form, e.app.ClientID, e.secret)
}
t.Run("輪替發新權杖組", func(t *testing.T) {
rec := refresh(first.RefreshToken, "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var second tokenBody
json.Unmarshal(rec.Body.Bytes(), &second)
if second.AccessToken == "" || second.RefreshToken == "" || second.RefreshToken == first.RefreshToken {
t.Fatalf("應核發新的 access 與 refresh token: %+v", second)
}
if second.Scope != "offline_access openid profile" {
t.Errorf("scope 應沿用原授權: %q", second.Scope)
}
if second.IDToken == "" {
t.Error("原 scope 含 openid 應續發 id_token")
}
// 舊 token 重用:invalid_grant,且整鏈(含新 token)撤銷。
rec = refresh(first.RefreshToken, "")
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
t.Fatalf("重用 error = %q, body = %s", got, rec.Body.String())
}
rec = refresh(second.RefreshToken, "")
if rec.Code != http.StatusBadRequest {
t.Fatalf("重用偵測後整鏈應撤銷(新 token 亦不可用): %s", rec.Body.String())
}
})
t.Run("scope 僅可縮小", func(t *testing.T) {
// 取一組原授權為「openid profile offline_access」的鏈。
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile offline_access", "", "", ""))
rec := postToken(h, url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {e.app.RedirectURIs[0]}}, e.app.ClientID, e.secret)
if rec.Code != http.StatusOK {
t.Fatalf("兌換失敗: %s", rec.Body.String())
}
var body tokenBody
json.Unmarshal(rec.Body.Bytes(), &body)
// 縮小為不含 profile:成功,新鏈的授權範圍即縮小後的值。
rec = refresh(body.RefreshToken, "openid offline_access")
if rec.Code != http.StatusOK {
t.Fatalf("縮小 scope 應成功: %s", rec.Body.String())
}
var narrowed tokenBody
json.Unmarshal(rec.Body.Bytes(), &narrowed)
if narrowed.Scope != "offline_access openid" {
t.Errorf("縮小後 scope = %q", narrowed.Scope)
}
// 對縮小後的鏈再請求原範圍(含 profile)即為擴大:invalid_scope。
rec = refresh(narrowed.RefreshToken, "openid profile offline_access")
if got := decodeTokenError(t, rec).Error; got != "invalid_scope" {
t.Fatalf("擴大 scope error = %q, want invalid_scope, body = %s", got, rec.Body.String())
}
})
t.Run("過期 refresh token 回 invalid_grant", func(t *testing.T) {
_, code := consentAllow(t, e, authorizeQuery(e.app, "openid offline_access", "", "", ""))
rec := postToken(h, url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {e.app.RedirectURIs[0]}}, e.app.ClientID, e.secret)
var body tokenBody
json.Unmarshal(rec.Body.Bytes(), &body)
// 直接把最新一筆 refresh token 的效期改為過去。
if err := e.db.Model(&oidc.RefreshToken{}).
Where("id = (SELECT MAX(id) FROM refresh_tokens)").
Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil {
t.Fatal(err)
}
rec = refresh(body.RefreshToken, "")
if got := decodeTokenError(t, rec).Error; got != "invalid_grant" {
t.Fatalf("error = %q, want invalid_grant, body = %s", got, rec.Body.String())
}
})
t.Run("未啟用 refresh grant 的應用回 unauthorized_client", func(t *testing.T) {
rec := postToken(h, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {"x"}}, e.pub.ClientID, "")
if got := decodeTokenError(t, rec).Error; got != "unauthorized_client" {
t.Fatalf("error = %q, want unauthorized_client", got)
}
})
}
// 空資料庫時 token 端點仍應正常拒絕(不 panic)。
func TestTokenHandlerEmptyDB(t *testing.T) {
db := testdb.New(t)
rec := postToken(oidc.TokenHandler(db, testIssuer), url.Values{"grant_type": {"authorization_code"}, "code": {"x"}, "client_id": {"nobody"}, "client_secret": {"s"}}, "", "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
+115
View File
@@ -0,0 +1,115 @@
package oidc
import (
"errors"
"log"
"net/http"
"strconv"
"strings"
"gorm.io/gorm"
"alterminal/internal/auth"
)
// UserInfoHandler 處理 GET/POST /userinfo(OIDC Core §5.3):以 Bearer
// Access Token 取得已授權的使用者 claims。token 取自 Authorization
// 標頭(RFC 6750 §2.1),POST 另接受表單的 access_token 欄位(§2.2)。
func UserInfoHandler(db *gorm.DB, issuer string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodPost:
default:
w.Header().Set("Allow", "GET, POST")
writeBearerError(w, http.StatusMethodNotAllowed, "", "僅支援 GET 與 POST")
return
}
token := bearerToken(r)
if token == "" {
writeBearerError(w, http.StatusUnauthorized, "", "缺少 Access Token")
return
}
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
writeBearerError(w, http.StatusBadRequest, "invalid_request", "無法解析表單內容")
return
}
if t := r.PostFormValue("access_token"); t != "" {
token = t
}
}
claims, err := VerifyAccessToken(db, issuer, token)
if err != nil {
if !errors.Is(err, ErrInvalidToken) {
log.Printf("userinfo: %v", err)
}
writeBearerError(w, http.StatusUnauthorized, "invalid_token", "Access Token 無效")
return
}
userID, err := strconv.ParseUint(claims.Sub, 10, 64)
if err != nil {
writeBearerError(w, http.StatusUnauthorized, "invalid_token", "Access Token 無效")
return
}
var u auth.User
if err := db.First(&u, userID).Error; err != nil {
log.Printf("userinfo: 查詢使用者 %d: %v", userID, err)
writeBearerError(w, http.StatusUnauthorized, "invalid_token", "Access Token 無效")
return
}
// claims 依授權 scope 決定(OIDC Core §5.4):sub 恆有;profile
// 加 name 與 preferred_username;email 加 email 與
// email_verified。Access Token 未含 openid scope(非授權碼流程
// 核發)者不得存取(RFC 6750 insufficient_scope)。
if !scopeHas(claims.Scope, "openid") {
writeBearerError(w, http.StatusForbidden, "insufficient_scope", "缺少 openid scope")
return
}
out := struct {
Sub string `json:"sub"`
Name string `json:"name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified *bool `json:"email_verified,omitempty"`
}{Sub: claims.Sub}
if scopeHas(claims.Scope, "profile") {
out.Name = u.Name
out.PreferredUsername = u.Username
}
if scopeHas(claims.Scope, "email") {
out.Email = u.Email
verified := u.EmailVerified
out.EmailVerified = &verified
}
auth.WriteJSON(w, http.StatusOK, out)
}
}
// bearerToken 剖析 Authorization: Bearer 標頭(RFC 6750 §2.1)。
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
const scheme = "bearer "
if len(h) < len(scheme) || !strings.EqualFold(h[:len(scheme)], scheme) {
return ""
}
return strings.TrimSpace(h[len(scheme):])
}
// writeBearerError 輸出 /userinfo 的 Bearer 錯誤,並以
// WWW-Authenticate 標頭回報錯誤細節(RFC 6750 §3)。
func writeBearerError(w http.ResponseWriter, status int, code, description string) {
if status != http.StatusBadRequest {
challenge := `Bearer realm="alterminal"`
if code != "" {
challenge += `, error="` + code + `"`
if description != "" {
challenge += `, error_description="` + description + `"`
}
}
w.Header().Set("WWW-Authenticate", challenge)
}
auth.WriteError(w, status, description)
}
+128
View File
@@ -0,0 +1,128 @@
// 外部測試套件:見 jwks_test.go 開頭說明。
package oidc_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"alterminal/internal/oidc"
)
// getUserinfo 以 Bearer token 呼叫 /userinfo。
func getUserinfo(h http.HandlerFunc, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/userinfo", nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
// obtainAccessToken 走完授權碼流程(PKCE)並回傳 access token。
func obtainAccessToken(t *testing.T, e *testEnv, scope string) string {
t.Helper()
_, code := consentAllow(t, e, authorizeQuery(e.app, scope, "", "", testChallenge))
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {e.app.RedirectURIs[0]},
"code_verifier": {testVerifier},
}
rec := postToken(oidc.TokenHandler(e.db, testIssuer), form, e.app.ClientID, e.secret)
if rec.Code != http.StatusOK {
t.Fatalf("兌換失敗: %s", rec.Body.String())
}
var body tokenBody
json.Unmarshal(rec.Body.Bytes(), &body)
return body.AccessToken
}
// 有效 token 回依 scope 的 claims(OIDC Core §5.4)。
func TestUserInfoClaims(t *testing.T) {
e := newTestEnv(t)
h := oidc.UserInfoHandler(e.db, testIssuer)
t.Run("profile 與 email scope", func(t *testing.T) {
token := obtainAccessToken(t, e, "openid profile email")
rec := getUserinfo(h, token)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var u userInfoBody
if err := json.Unmarshal(rec.Body.Bytes(), &u); err != nil {
t.Fatal("解析回應: ", err)
}
if u.Sub != subjectOf(e.user.ID) {
t.Errorf("sub = %q, want %q", u.Sub, subjectOf(e.user.ID))
}
if u.Name != e.user.Name || u.PreferredUsername != e.user.Username {
t.Errorf("profile claims = %q/%q", u.Name, u.PreferredUsername)
}
if u.Email != e.user.Email || u.EmailVerified == nil || !*u.EmailVerified {
t.Errorf("email claims = %q/%v", u.Email, u.EmailVerified)
}
})
t.Run("僅 openid 不含個人資料 claims", func(t *testing.T) {
token := obtainAccessToken(t, e, "openid")
rec := getUserinfo(h, token)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := rec.Body.String()
if strings.Contains(body, `"email"`) || strings.Contains(body, `"name"`) {
t.Errorf("未授權的 scope 不應輸出對應 claim: %s", body)
}
})
}
// token 缺失、無效或授權不足時的錯誤回應(RFC 6750 §3)。
func TestUserInfoErrors(t *testing.T) {
e := newTestEnv(t)
h := oidc.UserInfoHandler(e.db, testIssuer)
t.Run("缺少 token 回 401 與 WWW-Authenticate", func(t *testing.T) {
rec := getUserinfo(h, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if wa := rec.Header().Get("WWW-Authenticate"); !strings.HasPrefix(wa, "Bearer") {
t.Errorf("WWW-Authenticate = %q", wa)
}
})
t.Run("無效 token 回 401 invalid_token", func(t *testing.T) {
rec := getUserinfo(h, "not-a-token")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if wa := rec.Header().Get("WWW-Authenticate"); !strings.Contains(wa, `error="invalid_token"`) {
t.Errorf("WWW-Authenticate = %q", wa)
}
})
t.Run("scope 無 openid 回 403", func(t *testing.T) {
// 手造僅 profile scope 的 token(正式流程必含 openid,此處模擬
// 其他來源的 token)。
token := forgeJWT(t, e.key,
map[string]string{"alg": "RS256", "kid": e.key.Kid, "typ": "JWT"},
oidc.AccessTokenClaims{
Iss: testIssuer, Sub: subjectOf(e.user.ID), Aud: e.app.ClientID,
Exp: time.Now().Add(time.Hour).Unix(), Iat: time.Now().Unix(),
Scope: "profile", ClientID: e.app.ClientID,
})
rec := getUserinfo(h, token)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
}
if wa := rec.Header().Get("WWW-Authenticate"); !strings.Contains(wa, "insufficient_scope") {
t.Errorf("WWW-Authenticate = %q", wa)
}
})
}