forked from alterminal/alterminal
first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user