forked from alterminal/alterminal
fixup
This commit is contained in:
@@ -17,15 +17,16 @@ import (
|
||||
|
||||
// adminApplicationRow 為應用程式管理頁表格的單列視圖。
|
||||
type adminApplicationRow struct {
|
||||
ID uint
|
||||
ClientID string
|
||||
Name string
|
||||
Type string // confidential / public
|
||||
RedirectURIs string // 以換行分隔(模板以 whitespace-pre-line 呈現)
|
||||
GrantTypes string // 以頓號分隔
|
||||
Scope string
|
||||
CreatedAt string // 本地時間顯示
|
||||
Confidential bool // 機密式才可輪替 client secret
|
||||
ID uint
|
||||
ClientID string
|
||||
Name string
|
||||
Type string // confidential / public
|
||||
RedirectURIs string // 以換行分隔(模板以 whitespace-pre-line 呈現)
|
||||
PostLogoutRedirectURIs string // 同上;空時模板顯示 —
|
||||
GrantTypes string // 以頓號分隔
|
||||
Scope string
|
||||
CreatedAt string // 本地時間顯示
|
||||
Confidential bool // 機密式才可輪替 client secret
|
||||
}
|
||||
|
||||
// newAdminApplicationRows 將應用程式模型轉為表格視圖。純函式,便於單元測試。
|
||||
@@ -37,15 +38,16 @@ func newAdminApplicationRows(apps []application.Application) []adminApplicationR
|
||||
grants[i] = string(g)
|
||||
}
|
||||
rows = append(rows, adminApplicationRow{
|
||||
ID: a.ID,
|
||||
ClientID: a.ClientID,
|
||||
Name: a.Name,
|
||||
Type: string(a.Type),
|
||||
RedirectURIs: strings.Join(a.RedirectURIs, "\n"),
|
||||
GrantTypes: strings.Join(grants, "、"),
|
||||
Scope: a.Scope,
|
||||
CreatedAt: a.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"),
|
||||
Confidential: !a.IsPublic(),
|
||||
ID: a.ID,
|
||||
ClientID: a.ClientID,
|
||||
Name: a.Name,
|
||||
Type: string(a.Type),
|
||||
RedirectURIs: strings.Join(a.RedirectURIs, "\n"),
|
||||
PostLogoutRedirectURIs: strings.Join(a.PostLogoutRedirectURIs, "\n"),
|
||||
GrantTypes: strings.Join(grants, "、"),
|
||||
Scope: a.Scope,
|
||||
CreatedAt: a.CreatedAt.Local().Format("2006-01-02 15:04:05 MST"),
|
||||
Confidential: !a.IsPublic(),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
@@ -54,13 +56,14 @@ func newAdminApplicationRows(apps []application.Application) []adminApplicationR
|
||||
// applicationForm 為註冊表單的視圖狀態:驗證失敗重繪時保留使用者輸入
|
||||
// (含核取方塊),初次顯示(GET)採 newApplicationForm 的預設值。
|
||||
type applicationForm struct {
|
||||
Name string
|
||||
Type string // confidential / public
|
||||
RedirectURIs string // textarea 原始內容(每行一個 URI)
|
||||
Scope string // 留空時使用預設
|
||||
GrantAuthCode bool
|
||||
GrantRefresh bool
|
||||
GrantClientCred bool
|
||||
Name string
|
||||
Type string // confidential / public
|
||||
RedirectURIs string // textarea 原始內容(每行一個 URI)
|
||||
PostLogoutRedirectURIs string // 同上(選填)
|
||||
Scope string // 留空時使用預設
|
||||
GrantAuthCode bool
|
||||
GrantRefresh bool
|
||||
GrantClientCred bool
|
||||
}
|
||||
|
||||
// newApplicationForm 回傳註冊表單的預設狀態:機密式、勾選授權碼流程。
|
||||
@@ -72,10 +75,11 @@ func newApplicationForm() applicationForm {
|
||||
// 其餘一律回復為 confidential;grant type 僅接受已知值。
|
||||
func applicationFormFromPost(r *http.Request) applicationForm {
|
||||
f := applicationForm{
|
||||
Name: r.PostFormValue("name"),
|
||||
Type: r.PostFormValue("type"),
|
||||
RedirectURIs: r.PostFormValue("redirect_uris"),
|
||||
Scope: r.PostFormValue("scope"),
|
||||
Name: r.PostFormValue("name"),
|
||||
Type: r.PostFormValue("type"),
|
||||
RedirectURIs: r.PostFormValue("redirect_uris"),
|
||||
PostLogoutRedirectURIs: r.PostFormValue("post_logout_redirect_uris"),
|
||||
Scope: r.PostFormValue("scope"),
|
||||
}
|
||||
if f.Type != string(application.ClientPublic) {
|
||||
f.Type = string(application.ClientConfidential)
|
||||
@@ -97,10 +101,11 @@ func applicationFormFromPost(r *http.Request) applicationForm {
|
||||
// redirect URI 以每行一個還原為 textarea 內容。
|
||||
func applicationFormFromApp(a *application.Application) applicationForm {
|
||||
f := applicationForm{
|
||||
Name: a.Name,
|
||||
Type: string(a.Type),
|
||||
RedirectURIs: strings.Join(a.RedirectURIs, "\n"),
|
||||
Scope: a.Scope,
|
||||
Name: a.Name,
|
||||
Type: string(a.Type),
|
||||
RedirectURIs: strings.Join(a.RedirectURIs, "\n"),
|
||||
PostLogoutRedirectURIs: strings.Join(a.PostLogoutRedirectURIs, "\n"),
|
||||
Scope: a.Scope,
|
||||
}
|
||||
for _, g := range a.GrantTypes {
|
||||
switch g {
|
||||
@@ -115,11 +120,21 @@ func applicationFormFromApp(a *application.Application) applicationForm {
|
||||
return f
|
||||
}
|
||||
|
||||
// redirectURIList 解析 textarea 內容:每行一個 URI,去首尾空白(含瀏覽器
|
||||
// 送出的 \r)後略過空行。
|
||||
// redirectURIList 解析 redirect URI textarea 內容:每行一個 URI,去首尾
|
||||
// 空白(含瀏覽器送出的 \r)後略過空行。
|
||||
func (f applicationForm) redirectURIList() []string {
|
||||
return uriLines(f.RedirectURIs)
|
||||
}
|
||||
|
||||
// postLogoutURIList 解析登出後返回 URI textarea 內容(同 redirectURIList)。
|
||||
func (f applicationForm) postLogoutURIList() []string {
|
||||
return uriLines(f.PostLogoutRedirectURIs)
|
||||
}
|
||||
|
||||
// uriLines 將 textarea 內容拆為非空行清單。
|
||||
func uriLines(raw string) []string {
|
||||
var uris []string
|
||||
for _, line := range strings.Split(f.RedirectURIs, "\n") {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
if u := strings.TrimSpace(line); u != "" {
|
||||
uris = append(uris, u)
|
||||
}
|
||||
@@ -322,7 +337,7 @@ func ApplicationsCreateHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
form := applicationFormFromPost(r)
|
||||
app, secret, err := application.NewApplication(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope)
|
||||
app, secret, err := application.NewApplication(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope, form.postLogoutURIList()...)
|
||||
if err != nil {
|
||||
renderAdminApplicationNewPage(w, r, http.StatusBadRequest, s, err.Error(), form, nil)
|
||||
return
|
||||
@@ -362,7 +377,7 @@ func ApplicationUpdateHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
form := applicationFormFromPost(r)
|
||||
if err := app.Update(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope); err != nil {
|
||||
if err := app.Update(form.Name, application.ClientType(form.Type), form.redirectURIList(), form.grantTypeList(), form.Scope, form.postLogoutURIList()...); err != nil {
|
||||
renderAdminApplicationEditPage(w, r, http.StatusBadRequest, s, err.Error(), "", app, form)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,16 +99,17 @@ func (g GrantTypes) Contains(gt GrantType) bool {
|
||||
// 與使用者密碼採同一套 argon2id 雜湊儲存,明文只在建立/輪替當下回傳
|
||||
// 一次;公開式 Client 不持有 secret。
|
||||
type Application struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ClientID string `gorm:"uniqueIndex;size:22;not null"` // 16 bytes 亂數的 base64url(公開識別碼,128 bits 熵已足夠)
|
||||
Name string `gorm:"size:255;not null"` // 顯示名稱(授權頁顯示「以 ○○ 登入」等)
|
||||
Type ClientType `gorm:"size:16;not null"` // confidential 或 public
|
||||
ClientSecretHash string `gorm:"size:255;not null"` // argon2id PHC 字串;public 為空字串
|
||||
RedirectURIs RedirectURIs `gorm:"serializer:json;not null"` // 允許的 redirect URI(精確比對)
|
||||
GrantTypes GrantTypes `gorm:"serializer:json;not null"` // 允許的 grant type
|
||||
Scope string `gorm:"size:255;not null"` // 允許的 scope,空格分隔
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ClientID string `gorm:"uniqueIndex;size:22;not null"` // 16 bytes 亂數的 base64url(公開識別碼,128 bits 熵已足夠)
|
||||
Name string `gorm:"size:255;not null"` // 顯示名稱(授權頁顯示「以 ○○ 登入」等)
|
||||
Type ClientType `gorm:"size:16;not null"` // confidential 或 public
|
||||
ClientSecretHash string `gorm:"size:255;not null"` // argon2id PHC 字串;public 為空字串
|
||||
RedirectURIs RedirectURIs `gorm:"serializer:json;not null"` // 允許的 redirect URI(精確比對)
|
||||
PostLogoutRedirectURIs RedirectURIs `gorm:"serializer:json"` // RP-Initiated Logout 的 post_logout_redirect_uri 白名單(精確比對);未註冊為空,不允許登出後重導
|
||||
GrantTypes GrantTypes `gorm:"serializer:json;not null"` // 允許的 grant type
|
||||
Scope string `gorm:"size:255;not null"` // 允許的 scope,空格分隔
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// IsPublic 回傳是否為公開式 Client(不持有 secret,授權流程必須使用 PKCE)。
|
||||
@@ -118,12 +119,16 @@ func (a *Application) IsPublic() bool {
|
||||
|
||||
// fill 套用註冊表單欄位並補上預設值(grantTypes 空時預設僅
|
||||
// authorization_code;scope 空時預設「openid profile email」)後驗證,
|
||||
// 供 NewApplication 與 Update 共用。驗證失敗時 a 可能已被部分修改,
|
||||
// 呼叫方不應將其儲存。
|
||||
func (a *Application) fill(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) error {
|
||||
// 供 NewApplication 與 Update 共用。postLogoutRedirectURIs 為選填的
|
||||
// RP-Initiated Logout 返回 URI(RP-Initiated Logout 1.0 §3.1 的
|
||||
// post_logout_redirect_uris 中繼資料),以 variadic 傳入——既有呼叫端
|
||||
// 不指定即維持空清單(不接受登出後重導)。驗證失敗時 a 可能已被部分
|
||||
// 修改,呼叫方不應將其儲存。
|
||||
func (a *Application) fill(name string, typ ClientType, redirectURIs, postLogoutRedirectURIs []string, grantTypes []GrantType, scope string) error {
|
||||
a.Name = strings.TrimSpace(name)
|
||||
a.Type = typ
|
||||
a.RedirectURIs = append(RedirectURIs{}, redirectURIs...) // 保證非 nil,序列化為 [] 而非 null
|
||||
a.RedirectURIs = append(RedirectURIs{}, redirectURIs...) // 保證非 nil,序列化為 [] 而非 null
|
||||
a.PostLogoutRedirectURIs = append(RedirectURIs{}, postLogoutRedirectURIs...) // 同上(欄位可空,寫入 [] 便於編輯頁還原)
|
||||
a.GrantTypes = grantTypes
|
||||
a.Scope = strings.TrimSpace(scope)
|
||||
if len(a.GrantTypes) == 0 {
|
||||
@@ -138,9 +143,9 @@ func (a *Application) fill(name string, typ ClientType, redirectURIs []string, g
|
||||
// NewApplication 建立新的應用程式註冊:先驗證內容,再產生全域唯一的
|
||||
// client_id;機密式 Client 另產生 client secret,明文僅經回傳值交付一
|
||||
// 次,呼叫方應立即提供給應用程式管理者,不得儲存明文。
|
||||
func NewApplication(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) (*Application, string, error) {
|
||||
func NewApplication(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string, postLogoutRedirectURIs ...string) (*Application, string, error) {
|
||||
a := &Application{}
|
||||
if err := a.fill(name, typ, redirectURIs, grantTypes, scope); err != nil {
|
||||
if err := a.fill(name, typ, redirectURIs, postLogoutRedirectURIs, grantTypes, scope); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
id, err := auth.NewToken(16)
|
||||
@@ -162,8 +167,8 @@ func NewApplication(name string, typ ClientType, redirectURIs []string, grantTyp
|
||||
// GenerateSecret)。由機密式改為公開式時一併清除既有 secret 雜湊——
|
||||
// 舊 secret 隨型別切換立即失效,日後改回機密式也不會復活,須重新輪替
|
||||
// 取得新 secret。驗證失敗時 a 可能已被部分修改,呼叫方不應將其儲存。
|
||||
func (a *Application) Update(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) error {
|
||||
if err := a.fill(name, typ, redirectURIs, grantTypes, scope); err != nil {
|
||||
func (a *Application) Update(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string, postLogoutRedirectURIs ...string) error {
|
||||
if err := a.fill(name, typ, redirectURIs, postLogoutRedirectURIs, grantTypes, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.IsPublic() {
|
||||
@@ -208,6 +213,13 @@ func (a *Application) Validate() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// post_logout_redirect_uri 沿用 redirect URI 的格式規則(RP-Initiated
|
||||
// Logout 1.0 §3.1 建議 https;http 僅 loopback 供本機開發)。
|
||||
for _, uri := range a.PostLogoutRedirectURIs {
|
||||
if err := validateRedirectURI(uri); err != nil {
|
||||
return fmt.Errorf("登出後返回 URI %q:%w", uri, err)
|
||||
}
|
||||
}
|
||||
if a.Scope == "" {
|
||||
return errors.New("scope 不可為空")
|
||||
}
|
||||
|
||||
@@ -420,3 +420,62 @@ func TestApplicationPersistence(t *testing.T) {
|
||||
t.Errorf("查無 client_id 應回 gorm.ErrRecordNotFound,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostLogoutRedirectURIs(t *testing.T) {
|
||||
t.Run("註冊並精確比對", func(t *testing.T) {
|
||||
a, _, err := NewApplication("示範應用", ClientConfidential,
|
||||
[]string{"https://app.example.com/cb"}, nil, "",
|
||||
"https://app.example.com/logged-out")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !a.PostLogoutRedirectURIs.Contains("https://app.example.com/logged-out") {
|
||||
t.Error("註冊的登出後返回 URI 應精確比對成功")
|
||||
}
|
||||
// 與 redirect URI 不互通(RP-Initiated Logout 1.0 §3:僅比對
|
||||
// post_logout_redirect_uris 註冊值)。
|
||||
if a.PostLogoutRedirectURIs.Contains("https://app.example.com/cb") {
|
||||
t.Error("redirect URI 不應混入登出後返回 URI 的比對")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未註冊時為非 nil 空清單", func(t *testing.T) {
|
||||
a, _, err := NewApplication("無返回", ClientConfidential,
|
||||
[]string{"https://app.example.com/cb"}, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.PostLogoutRedirectURIs == nil || len(a.PostLogoutRedirectURIs) != 0 {
|
||||
t.Errorf("未指定應為非 nil 空清單(序列化為 []),得到 %v", a.PostLogoutRedirectURIs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("格式驗證與 redirect URI 同規則", func(t *testing.T) {
|
||||
if _, _, err := NewApplication("示範應用", ClientConfidential,
|
||||
[]string{"https://app.example.com/cb"}, nil, "",
|
||||
"http://app.example.com/logged-out"); err == nil {
|
||||
t.Error("非 loopback 的 http 登出後返回 URI 應被拒")
|
||||
}
|
||||
if _, _, err := NewApplication("示範應用", ClientConfidential,
|
||||
[]string{"https://app.example.com/cb"}, nil, "",
|
||||
"https://app.example.com/logged-out#frag"); err == nil {
|
||||
t.Error("含 fragment 的登出後返回 URI 應被拒")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Update 可清空", func(t *testing.T) {
|
||||
a, _, err := NewApplication("示範應用", ClientConfidential,
|
||||
[]string{"https://app.example.com/cb"}, nil, "",
|
||||
"https://app.example.com/logged-out")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Update("示範應用", ClientConfidential,
|
||||
[]string{"https://app.example.com/cb"}, nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.PostLogoutRedirectURIs) != 0 {
|
||||
t.Errorf("Update 未指定時應清空,得到 %v", a.PostLogoutRedirectURIs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,14 +21,21 @@ var (
|
||||
// 第一個(根)模板,頁面模板僅定義 title/content 等區塊覆寫之,
|
||||
// 故 Execute 仍輸出版面本身。註冊頁與管理列表頁另解析 secretpanel.html
|
||||
// 的一次性成果面板;編輯頁無一次性面板,不在解析之列。
|
||||
loggedInTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/loggedin.html"))
|
||||
loggedInTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/loggedin.html"))
|
||||
// 更新密碼頁(登入者自助變更)以 layout 組合,密碼欄位不預填、
|
||||
// 無一次性面板。
|
||||
passwordTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/password.html"))
|
||||
AdminKeysTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminkeys.html"))
|
||||
AdminApplicationsTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplications.html", "templates/secretpanel.html"))
|
||||
AdminApplicationNewTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplicationnew.html", "templates/secretpanel.html"))
|
||||
AdminApplicationEditTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/adminapplicationedit.html"))
|
||||
// 授權同意頁供 oidc 套件的 /authorize 使用,與管理頁同以 layout 組合。
|
||||
ConsentTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/consent.html"))
|
||||
notFoundTmpl = template.Must(template.ParseFS(templateFS, "templates/notfound.html"))
|
||||
ConsentTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/consent.html"))
|
||||
// 登出確認頁(layout 組合)與已登出頁(獨立頁,使用者已無 Session
|
||||
// 脈絡)供 oidc 套件的 /logout(RP-Initiated Logout)使用。
|
||||
LogoutConfirmTmpl = template.Must(template.ParseFS(templateFS, "templates/layout.html", "templates/logoutconfirm.html"))
|
||||
LoggedOutTmpl = template.Must(template.ParseFS(templateFS, "templates/loggedout.html"))
|
||||
notFoundTmpl = template.Must(template.ParseFS(templateFS, "templates/notfound.html"))
|
||||
)
|
||||
|
||||
// CSRFCookieName 為登入表單 double-submit CSRF 防護的 Cookie 名稱:
|
||||
|
||||
+14
-6
@@ -37,12 +37,7 @@ func LogoutHandler(db *gorm.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
if err := DeleteSession(db, c.Value); err != nil {
|
||||
log.Printf("logout: %v", err)
|
||||
}
|
||||
}
|
||||
clearSessionCookie(w, r)
|
||||
ClearSession(db, w, r)
|
||||
|
||||
if isForm {
|
||||
// PRG:以 303 導向 /login 顯示登入表單,避免重新整理重複送出。
|
||||
@@ -53,6 +48,19 @@ func LogoutHandler(db *gorm.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ClearSession 刪除資料庫中的 Session 並清除瀏覽器 Cookie,冪等——查無
|
||||
// Session 亦清除 Cookie;資料庫刪除失敗僅記錄不中斷(Session 最遲於效期
|
||||
// 到期失效)。供 LogoutHandler 與 oidc 套件的 RP-Initiated Logout 端點
|
||||
// 共用同一套清理邏輯。
|
||||
func ClearSession(db *gorm.DB, w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
if err := DeleteSession(db, c.Value); err != nil {
|
||||
log.Printf("logout: %v", err)
|
||||
}
|
||||
}
|
||||
clearSessionCookie(w, r)
|
||||
}
|
||||
|
||||
// clearSessionCookie 以 Max-Age=0 清除瀏覽器的 Session Cookie(與
|
||||
// setSessionCookie 對稱,屬性一致以免因 Path 或 Secure 差異清不掉)。
|
||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MinPasswordLen 為密碼最小長度,網頁更新流程與 CLI 帳號作業共用。
|
||||
const MinPasswordLen = 8
|
||||
|
||||
// passwordPageData 為更新密碼頁的模板資料。
|
||||
type passwordPageData struct {
|
||||
Error string // 驗證失敗訊息;空字串表示不顯示
|
||||
Success string // PRG 成功訊息(?saved=1);空字串表示不顯示
|
||||
Username string // 側欄頁尾使用者資訊
|
||||
Email string
|
||||
IsAdmin bool // admin 另顯示管理頁導覽連結
|
||||
CSRF string // 表單隱藏欄位用 CSRF token,與 Cookie 成對輪替
|
||||
}
|
||||
|
||||
// PasswordPageHandler 處理 GET /password(更新密碼頁):持有效 Session 顯示
|
||||
// 表單,未登入導向 /login?next=/password(登入後返回)。?saved=1 為 PRG
|
||||
// 的成功旗標,顯示成功訊息。
|
||||
func PasswordPageHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
s, err := lookupSession(db, r)
|
||||
if err != nil {
|
||||
respondSessionError(w, r, err, true)
|
||||
return
|
||||
}
|
||||
var success string
|
||||
if r.URL.Query().Get("saved") == "1" {
|
||||
success = "密碼已更新,其他裝置的登入已全部登出。"
|
||||
}
|
||||
renderPasswordPage(w, r, http.StatusOK, s, "", success)
|
||||
}
|
||||
}
|
||||
|
||||
// PasswordChangeHandler 處理 POST /password,依 Content-Type 分流(與
|
||||
// 登入/登出一致):表單走瀏覽器流程(需通過 CSRF 驗證),JSON 走 API
|
||||
// 流程。驗證目前密碼後更新密碼,並於單一交易內撤銷該使用者所有
|
||||
// Session(與 CLI update-password 同一套交易邏輯),再為目前瀏覽器重建
|
||||
// Session(輪替 Session ID,避免 fixation)——其他裝置立即登出,目前
|
||||
// 瀏覽器保持登入。成功後表單流程以 303 導回 /password?saved=1(PRG)。
|
||||
func PasswordChangeHandler(db *gorm.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
var isForm bool
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "application/json"):
|
||||
case strings.HasPrefix(ct, "application/x-www-form-urlencoded"),
|
||||
strings.HasPrefix(ct, "multipart/form-data"):
|
||||
isForm = true
|
||||
default:
|
||||
WriteError(w, http.StatusUnsupportedMediaType, "Content-Type 須為 application/json 或表單")
|
||||
return
|
||||
}
|
||||
|
||||
s, err := lookupSession(db, r)
|
||||
if err != nil {
|
||||
respondSessionError(w, r, err, isForm)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
var in passwordRequest
|
||||
if isForm {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
renderPasswordPage(w, r, http.StatusBadRequest, s, "無法解析表單內容", "")
|
||||
return
|
||||
}
|
||||
if !VerifyCSRF(r) {
|
||||
renderPasswordPage(w, r, http.StatusForbidden, s, "表單驗證失敗,請重新整理頁面後再試", "")
|
||||
return
|
||||
}
|
||||
in = passwordRequest{
|
||||
CurrentPassword: r.PostFormValue("current_password"),
|
||||
NewPassword: r.PostFormValue("new_password"),
|
||||
Confirm: r.PostFormValue("confirm_password"),
|
||||
}
|
||||
} else if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
WriteError(w, http.StatusBadRequest, "無法解析請求內容")
|
||||
return
|
||||
}
|
||||
|
||||
fail := func(status int, msg string) {
|
||||
if isForm {
|
||||
renderPasswordPage(w, r, status, s, msg, "")
|
||||
return
|
||||
}
|
||||
WriteError(w, status, msg)
|
||||
}
|
||||
if err := in.validate(isForm); err != nil {
|
||||
fail(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !s.User.CheckPassword(in.CurrentPassword) {
|
||||
fail(http.StatusUnauthorized, "目前的密碼錯誤")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.User.SetPassword(in.NewPassword); err != nil {
|
||||
log.Printf("password: %v", err)
|
||||
fail(http.StatusInternalServerError, "內部錯誤")
|
||||
return
|
||||
}
|
||||
if _, err := UpdateUserPassword(db, &s.User); err != nil {
|
||||
log.Printf("password: %v", err)
|
||||
fail(http.StatusInternalServerError, "內部錯誤")
|
||||
return
|
||||
}
|
||||
// 舊 Session 已隨交易撤銷,重建目前瀏覽器的 Session;失敗時密碼
|
||||
// 已更新,只能請使用者以新密碼重新登入。
|
||||
ns, err := CreateSession(db, s.User.ID)
|
||||
if err != nil {
|
||||
log.Printf("password: %v", err)
|
||||
clearSessionCookie(w, r)
|
||||
if isForm {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
WriteError(w, http.StatusInternalServerError, "密碼已更新,請重新登入")
|
||||
return
|
||||
}
|
||||
setSessionCookie(w, r, ns)
|
||||
|
||||
if isForm {
|
||||
// PRG:以 303 導回本頁以 ?saved=1 顯示成功訊息,避免重新整理
|
||||
// 重複送出表單。
|
||||
http.Redirect(w, r, "/password?saved=1", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// passwordRequest 為 POST /password 的請求欄位(JSON 與表單共用);
|
||||
// 表單流程另以 Confirm 重複輸入新密碼,JSON 流程由呼叫端自行確認。
|
||||
type passwordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
Confirm string `json:"-"` // 僅表單流程的 confirm_password 欄位
|
||||
}
|
||||
|
||||
// validate 檢查欄位:目前與新密碼不可為空、新密碼長度須達最小值且不得
|
||||
// 與目前密碼相同;表單流程另檢查確認欄位與新密碼一致。
|
||||
func (in *passwordRequest) validate(isForm bool) error {
|
||||
if in.CurrentPassword == "" {
|
||||
return errors.New("目前的密碼不可為空")
|
||||
}
|
||||
if in.NewPassword == "" {
|
||||
return errors.New("新密碼不可為空")
|
||||
}
|
||||
if len(in.NewPassword) < MinPasswordLen {
|
||||
return fmt.Errorf("密碼長度至少 %d 字元", MinPasswordLen)
|
||||
}
|
||||
if isForm && in.Confirm != in.NewPassword {
|
||||
return errors.New("兩次輸入的新密碼不一致")
|
||||
}
|
||||
if in.NewPassword == in.CurrentPassword {
|
||||
return errors.New("新密碼不可與目前的密碼相同")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserPassword 於單一交易內寫入新密碼雜湊(須先 SetPassword)並
|
||||
// 刪除該使用者所有 Session,回傳撤銷的 Session 數;交易確保密碼與
|
||||
// Session 不會只更新一半。供網頁的更新密碼流程與 CLI update-password
|
||||
// 共用同一套邏輯。
|
||||
func UpdateUserPassword(db *gorm.DB, u *User) (int64, error) {
|
||||
var revoked int64
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(u).Update("password_hash", u.PasswordHash).Error; err != nil {
|
||||
return fmt.Errorf("update password: %w", err)
|
||||
}
|
||||
res := tx.Where("user_id = ?", u.ID).Delete(&Session{})
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("delete sessions: %w", res.Error)
|
||||
}
|
||||
revoked = res.RowsAffected
|
||||
return nil
|
||||
})
|
||||
return revoked, err
|
||||
}
|
||||
|
||||
// lookupSession 由 Cookie 查詢效期內 Session(含使用者);未帶 Cookie
|
||||
// 與 Session 無效一併回 ErrSessionExpired,不洩漏差異。
|
||||
func lookupSession(db *gorm.DB, r *http.Request) (*Session, error) {
|
||||
c, err := r.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
return GetSession(db, c.Value)
|
||||
}
|
||||
|
||||
// respondSessionError 依流程處理 lookupSession 的錯誤:未登入在表單
|
||||
// 流程導向 /login?next=/password(登入後返回原頁),JSON 流程回 401;
|
||||
// 其餘錯誤記錄後回 500。
|
||||
func respondSessionError(w http.ResponseWriter, r *http.Request, err error, isForm bool) {
|
||||
switch {
|
||||
case errors.Is(err, ErrSessionExpired):
|
||||
if isForm {
|
||||
http.Redirect(w, r, "/login?next="+url.QueryEscape("/password"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
WriteError(w, http.StatusUnauthorized, "須登入")
|
||||
default:
|
||||
log.Printf("password: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// renderPasswordPage 輸出更新密碼頁;每次輸出都輪替 CSRF token。密碼
|
||||
// 欄位一律不預填,驗證失敗重繪時也不保留輸入。
|
||||
func renderPasswordPage(w http.ResponseWriter, r *http.Request, status int, s *Session, errMsg, success string) {
|
||||
token, err := NewCSRFToken(w, r)
|
||||
if err != nil {
|
||||
log.Printf("csrf token: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
RenderHTML(w, status, passwordTmpl, passwordPageData{
|
||||
Error: errMsg,
|
||||
Success: success,
|
||||
Username: s.User.Username,
|
||||
Email: s.User.Email,
|
||||
IsAdmin: s.User.Role == RoleAdmin,
|
||||
CSRF: token,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPasswordRequestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in passwordRequest
|
||||
isForm bool
|
||||
wantErr string // 空字串表示應通過
|
||||
}{
|
||||
{"最小欄位", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!"}, false, ""},
|
||||
{"表單確認欄位相符", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "n3w-secret!"}, true, ""},
|
||||
{"缺目前密碼", passwordRequest{NewPassword: "n3w-secret!"}, false, "目前的密碼"},
|
||||
{"缺新密碼", passwordRequest{CurrentPassword: "sup3r-secret"}, false, "新密碼不可為空"},
|
||||
{"新密碼過短", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "short"}, false, "密碼長度至少"},
|
||||
{"表單確認欄位不一致", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "other-pass"}, true, "兩次輸入的新密碼不一致"},
|
||||
{"JSON 流程不檢查確認欄位", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "n3w-secret!", Confirm: "other-pass"}, false, ""},
|
||||
{"新密碼與目前密碼相同", passwordRequest{CurrentPassword: "sup3r-secret", NewPassword: "sup3r-secret"}, false, "不可與目前的密碼相同"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.in.validate(tt.isForm)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("validate() = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("validate() = %v, want error containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 未帶 Session Cookie 的路徑不會查詢資料庫,可用 nil db 測試。
|
||||
func TestPasswordHandlersRequireSession(t *testing.T) {
|
||||
t.Run("GET 未登入導向 /login 並攜回 next", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
PasswordPageHandler(nil)(rec, httptest.NewRequest(http.MethodGet, "/password", nil))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fpassword" {
|
||||
t.Fatalf("Location = %q, want /login?next=%%2Fpassword", loc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST 表單未登入導向 /login", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
PasswordChangeHandler(nil)(rec, formPost("csrf_token=token-A", nil))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fpassword" {
|
||||
t.Fatalf("Location = %q, want /login?next=%%2Fpassword", loc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST JSON 未登入回 401", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/password", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
PasswordChangeHandler(nil)(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"error"`) {
|
||||
t.Fatalf("JSON 流程應回錯誤: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不支援的 Content-Type 回 415", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/password", strings.NewReader("x=1"))
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
rec := httptest.NewRecorder()
|
||||
PasswordChangeHandler(nil)(rec, req)
|
||||
if rec.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("status = %d, want 415", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// renderPasswordPage 不查詢資料庫,可直接以虛構 Session 測試表單輸出。
|
||||
func TestRenderPasswordPage(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
s := &Session{
|
||||
ID: "test-session",
|
||||
User: User{Username: "alice", Email: "alice@example.com"},
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
renderPasswordPage(rec, httptest.NewRequest(http.MethodGet, "/password", nil), http.StatusOK, s, "", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`action="/password"`,
|
||||
`name="csrf_token"`,
|
||||
`name="current_password"`,
|
||||
`name="new_password"`,
|
||||
`name="confirm_password"`,
|
||||
`autocomplete="current-password"`,
|
||||
`autocomplete="new-password"`,
|
||||
`minlength="8"`,
|
||||
"<aside", // layout 側邊導覽欄
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("更新密碼頁缺少 %s", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Set-Cookie"), CSRFCookieName) {
|
||||
t.Fatal("輸出表單時應設定 CSRF Cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordChangeIntegration(t *testing.T) {
|
||||
db, err := openTestDB()
|
||||
if err != nil {
|
||||
t.Skipf("資料庫不可用,略過整合測試: %v", err)
|
||||
}
|
||||
suffix, err := NewToken(6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := &User{Username: "pw-" + suffix, Email: "pw-" + suffix + "@example.com", Name: "Password Test"}
|
||||
if err := u.SetPassword("sup3r-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(u).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Delete(&Session{}, "user_id = ?", u.ID)
|
||||
db.Delete(&User{}, u.ID)
|
||||
})
|
||||
|
||||
passwordPost := func(form url.Values, sessionID, csrf string) *http.Request {
|
||||
req := formPost(form.Encode(), &http.Cookie{Name: CSRFCookieName, Value: csrf})
|
||||
if sessionID != "" {
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionID})
|
||||
}
|
||||
return req
|
||||
}
|
||||
validForm := func() url.Values {
|
||||
return url.Values{
|
||||
"csrf_token": {"token-A"},
|
||||
"current_password": {"sup3r-secret"},
|
||||
"new_password": {"n3w-secret!"},
|
||||
"confirm_password": {"n3w-secret!"},
|
||||
}
|
||||
}
|
||||
// sessionCookieValue 從回應的 Set-Cookie 取出新的 Session ID。
|
||||
sessionCookieValue := func(rec *httptest.ResponseRecorder) string {
|
||||
for _, sc := range rec.Header().Values("Set-Cookie") {
|
||||
if strings.HasPrefix(sc, CookieName+"=") {
|
||||
return strings.SplitN(strings.SplitN(sc, ";", 2)[0], "=", 2)[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
t.Run("已登入者 GET 顯示表單;帳號首頁含更新密碼連結", func(t *testing.T) {
|
||||
s, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/password", nil)
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
|
||||
rec := httptest.NewRecorder()
|
||||
PasswordPageHandler(db)(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `name="current_password"`) {
|
||||
t.Fatal("更新密碼表單缺少目前密碼欄位")
|
||||
}
|
||||
|
||||
home := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
home.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
|
||||
homeRec := httptest.NewRecorder()
|
||||
AccountPageHandler(db)(homeRec, home)
|
||||
if !strings.Contains(homeRec.Body.String(), `href="/password"`) {
|
||||
t.Fatal("帳號首頁應含更新密碼連結")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("?saved=1 顯示成功訊息", func(t *testing.T) {
|
||||
s, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/password?saved=1", nil)
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: s.ID})
|
||||
rec := httptest.NewRecorder()
|
||||
PasswordPageHandler(db)(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), "密碼已更新") {
|
||||
t.Fatal("應顯示成功訊息")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CSRF 不符回 403 重繪表單", func(t *testing.T) {
|
||||
s, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
PasswordChangeHandler(db)(rec, passwordPost(validForm(), s.ID, "token-B"))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
|
||||
t.Fatal("應顯示 CSRF 錯誤訊息")
|
||||
}
|
||||
if _, err := GetSession(db, s.ID); err != nil {
|
||||
t.Fatalf("CSRF 失敗不應影響 Session: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("確認欄位不一致回 400 且不更新", func(t *testing.T) {
|
||||
s, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
form := validForm()
|
||||
form.Set("confirm_password", "other-pass")
|
||||
rec := httptest.NewRecorder()
|
||||
PasswordChangeHandler(db)(rec, passwordPost(form, s.ID, "token-A"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "兩次輸入的新密碼不一致") {
|
||||
t.Fatal("應顯示確認欄位錯誤訊息")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("目前密碼錯誤回 401 且不更新", func(t *testing.T) {
|
||||
s, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
form := validForm()
|
||||
form.Set("current_password", "wrong-secret")
|
||||
rec := httptest.NewRecorder()
|
||||
PasswordChangeHandler(db)(rec, passwordPost(form, s.ID, "token-A"))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "目前的密碼錯誤") {
|
||||
t.Fatal("應顯示目前密碼錯誤訊息")
|
||||
}
|
||||
var reloaded User
|
||||
if err := db.First(&reloaded, u.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reloaded.CheckPassword("sup3r-secret") {
|
||||
t.Fatal("密碼不應被更新")
|
||||
}
|
||||
if _, err := GetSession(db, s.ID); err != nil {
|
||||
t.Fatalf("Session 不應被撤銷: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("表單成功:更新密碼、撤銷全部 Session、輪替目前 Session", func(t *testing.T) {
|
||||
current, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := CreateSession(db, u.ID) // 模擬其他裝置的登入
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
PasswordChangeHandler(db)(rec, passwordPost(validForm(), current.ID, "token-A"))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/password?saved=1" {
|
||||
t.Fatalf("Location = %q, want /password?saved=1", loc)
|
||||
}
|
||||
|
||||
for _, old := range []string{current.ID, other.ID} {
|
||||
if _, err := GetSession(db, old); !errors.Is(err, ErrSessionExpired) {
|
||||
t.Fatalf("更新後舊 Session %q 應被撤銷, got %v", old, err)
|
||||
}
|
||||
}
|
||||
newID := sessionCookieValue(rec)
|
||||
if newID == "" || newID == current.ID {
|
||||
t.Fatalf("應下發輪替後的新 Session Cookie, got %q", newID)
|
||||
}
|
||||
ns, err := GetSession(db, newID)
|
||||
if err != nil {
|
||||
t.Fatalf("輪替後的 Session 應有效: %v", err)
|
||||
}
|
||||
if ns.UserID != u.ID {
|
||||
t.Fatalf("輪替後 Session 的 UserID = %d, want %d", ns.UserID, u.ID)
|
||||
}
|
||||
|
||||
var reloaded User
|
||||
if err := db.First(&reloaded, u.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.CheckPassword("sup3r-secret") {
|
||||
t.Fatal("舊密碼不應再通過驗證")
|
||||
}
|
||||
if !reloaded.CheckPassword("n3w-secret!") {
|
||||
t.Fatal("新密碼應可通過驗證")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("JSON 成功回 204;目前密碼錯誤回 401", func(t *testing.T) {
|
||||
s, err := CreateSession(db, u.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jsonPost := func(sessionID, body string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/password", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionID})
|
||||
return req
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
PasswordChangeHandler(db)(rec, jsonPost(s.ID, `{"current_password":"wrong-secret","new_password":"f1nal-secret!"}`))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
PasswordChangeHandler(db)(rec, jsonPost(s.ID, `{"current_password":"n3w-secret!","new_password":"f1nal-secret!"}`))
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := GetSession(db, s.ID); !errors.Is(err, ErrSessionExpired) {
|
||||
t.Fatal("JSON 流程亦應撤銷舊 Session")
|
||||
}
|
||||
if newID := sessionCookieValue(rec); newID == "" {
|
||||
t.Fatal("JSON 流程亦應下發輪替後的 Session Cookie")
|
||||
}
|
||||
var reloaded User
|
||||
if err := db.First(&reloaded, u.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reloaded.CheckPassword("f1nal-secret!") {
|
||||
t.Fatal("新密碼應可通過驗證")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -88,6 +88,12 @@
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 font-mono text-xs dark:border-neutral-600 dark:bg-neutral-900">{{.Form.RedirectURIs}}</textarea>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">須為絕對 URI;http 僅允許 localhost/127.0.0.1/::1,其餘請使用 https;原生應用可用自訂 scheme(如 com.example.app:/cb)。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="app-post-logout-uris" class="mb-1 block text-sm font-medium">登出後返回 URI(每行一個,選填)</label>
|
||||
<textarea id="app-post-logout-uris" name="post_logout_redirect_uris" rows="2"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 font-mono text-xs dark:border-neutral-600 dark:bg-neutral-900">{{.Form.PostLogoutRedirectURIs}}</textarea>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">RP-Initiated Logout 的 post_logout_redirect_uri 白名單(精確比對,格式規則同 redirect URI);留空表示不接受登出後重導回應用程式。</p>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend class="mb-1 text-sm font-medium">允許的 grant type</legend>
|
||||
<div class="space-y-1.5 text-sm">
|
||||
|
||||
@@ -75,6 +75,12 @@
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 font-mono text-xs dark:border-neutral-600 dark:bg-neutral-900">{{.Form.RedirectURIs}}</textarea>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">須為絕對 URI;http 僅允許 localhost/127.0.0.1/::1,其餘請使用 https;原生應用可用自訂 scheme(如 com.example.app:/cb)。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="app-post-logout-uris" class="mb-1 block text-sm font-medium">登出後返回 URI(每行一個,選填)</label>
|
||||
<textarea id="app-post-logout-uris" name="post_logout_redirect_uris" rows="2"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 font-mono text-xs dark:border-neutral-600 dark:bg-neutral-900">{{.Form.PostLogoutRedirectURIs}}</textarea>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">RP-Initiated Logout 的 post_logout_redirect_uri 白名單(精確比對,格式規則同 redirect URI);留空表示不接受登出後重導回應用程式。</p>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend class="mb-1 text-sm font-medium">允許的 grant type</legend>
|
||||
<div class="space-y-1.5 text-sm">
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">client_id</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">類型</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">redirect URI</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">登出後返回 URI</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">grant type</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">scope</th>
|
||||
<th scope="col" class="px-3 py-2 text-xs font-medium tracking-wide text-neutral-500 uppercase dark:text-neutral-400">建立時間</th>
|
||||
@@ -83,6 +84,7 @@
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="px-3 py-3 font-mono text-xs break-all whitespace-pre-line">{{.RedirectURIs}}</td>
|
||||
<td class="px-3 py-3 font-mono text-xs break-all whitespace-pre-line">{{if .PostLogoutRedirectURIs}}{{.PostLogoutRedirectURIs}}{{else}}<span class="font-sans text-neutral-400 dark:text-neutral-500">—</span>{{end}}</td>
|
||||
<td class="px-3 py-3 text-xs">{{.GrantTypes}}</td>
|
||||
<td class="px-3 py-3 font-mono text-xs break-all">{{.Scope}}</td>
|
||||
<td class="px-3 py-3 whitespace-nowrap text-neutral-500 dark:text-neutral-400">{{.CreatedAt}}</td>
|
||||
|
||||
@@ -17,6 +17,15 @@
|
||||
<dt class="text-sm text-neutral-500 dark:text-neutral-400">Session 到期</dt>
|
||||
<dd class="mt-0.5 mb-3.5 text-[15px] break-all">{{.ExpiresAt}}</dd>
|
||||
</dl>
|
||||
<div class="mt-6 flex flex-wrap gap-3">
|
||||
<a href="/password"
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-300 px-4 py-2.5 text-sm font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">
|
||||
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
|
||||
</svg>
|
||||
更新密碼
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-neutral-500 dark:text-neutral-400">授權流程(/authorize)完成後,登入將自動導回應用程式。</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>已登出 - alterminal</title>
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
</head>
|
||||
<body class="flex min-h-screen items-center justify-center bg-neutral-100 font-sans text-neutral-900 antialiased dark:bg-neutral-900 dark:text-neutral-100">
|
||||
<main class="m-4 w-full max-w-88 rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
|
||||
<h1 class="mb-1 text-xl font-semibold">您已登出</h1>
|
||||
<p class="mb-6 text-sm text-neutral-500 dark:text-neutral-400">單一登入服務</p>
|
||||
{{if .Warning}}<p class="mb-4 rounded-lg bg-amber-500/10 px-3 py-2.5 text-sm text-amber-700 dark:text-amber-400" role="alert">{{.Warning}}</p>{{end}}
|
||||
<a href="/login"
|
||||
class="block w-full rounded-lg border border-neutral-300 py-2.5 text-center text-base font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">重新登入</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{{/* 登出確認頁(GET /logout)。以 layout.html(側邊導覽欄版面)為根模板
|
||||
組合渲染,本檔僅定義區塊。RP-Initiated Logout 1.0 §2:未提供
|
||||
id_token_hint、或提示的 ID token 不屬於目前 Session 時,OP 必須先
|
||||
詢問 End-User 是否登出——使用者直接造訪 /logout 亦同。原始請求的
|
||||
每個參數以隱藏欄位原封帶回 POST /logout(decision=logout 確認登出;
|
||||
decision=cancel 維持登入並返回帳號首頁)。 */}}
|
||||
{{define "title"}}登出確認 - alterminal{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
|
||||
<h1 class="mb-1 text-xl font-semibold">您確定要登出嗎?</h1>
|
||||
<p class="mb-6 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{{if .AppName}}應用程式 <strong class="text-neutral-900 dark:text-neutral-100">{{.AppName}}</strong> 要求登出您在本服務的帳號。{{else}}登出將結束您在本服務的 Session。{{end}}
|
||||
</p>
|
||||
{{if .Error}}<p class="mb-4 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/logout">
|
||||
{{range $k, $vs := .Params}}{{range $vs}}<input type="hidden" name="{{$k}}" value="{{.}}">{{end}}{{end}}
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<button type="submit" name="decision" value="logout"
|
||||
class="rounded-lg bg-brand px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-strong">登出</button>
|
||||
<button type="submit" name="decision" value="cancel"
|
||||
class="rounded-lg border border-neutral-300 px-5 py-2.5 text-sm font-semibold hover:bg-neutral-100 dark:border-neutral-600 dark:hover:bg-neutral-700">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="mt-4 text-xs text-neutral-400 dark:text-neutral-500">登出後,所有以本帳號單一登入的應用程式都需要重新登入。</p>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,69 @@
|
||||
{{/* 更新密碼頁(登入者自助變更,GET/POST /password)。以 layout.html
|
||||
(側邊導覽欄版面)為根模板組合渲染,本檔僅定義區塊,不應單獨解析
|
||||
執行。導覽覆寫版面預設:「帳號資訊」不標記 aria-current(本頁非
|
||||
帳號首頁),admin 另顯示管理頁連結。密碼欄位一律不預填——驗證失敗
|
||||
重繪亦同;成功後 PRG 回本頁以 ?saved=1 顯示成功訊息。 */}}
|
||||
{{define "title"}}更新密碼 - alterminal{{end}}
|
||||
|
||||
{{define "navitems"}}
|
||||
<li>
|
||||
<a href="/"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/>
|
||||
</svg>
|
||||
帳號資訊
|
||||
</a>
|
||||
</li>
|
||||
{{if .IsAdmin}}
|
||||
<li>
|
||||
<a href="/admin/keys"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/>
|
||||
</svg>
|
||||
金鑰管理
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/applications"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-700/60">
|
||||
<svg class="size-5 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"/>
|
||||
</svg>
|
||||
應用程式管理
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="rounded-xl bg-white p-8 shadow-lg dark:bg-neutral-800 dark:shadow-black/40">
|
||||
<h1 class="mb-1 text-xl font-semibold">更新密碼</h1>
|
||||
<p class="mb-6 text-sm text-neutral-500 dark:text-neutral-400">驗證目前的密碼後設定新密碼</p>
|
||||
{{if .Error}}<p class="mb-4 rounded-lg bg-red-500/10 px-3 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{{.Error}}</p>{{end}}
|
||||
{{if .Success}}<p class="mb-4 rounded-lg bg-emerald-500/10 px-3 py-2.5 text-sm text-emerald-700 dark:text-emerald-400" role="status">{{.Success}}</p>{{end}}
|
||||
<form method="post" action="/password" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRF}}">
|
||||
<div>
|
||||
<label for="current-password" class="mb-1 block text-sm font-medium">目前的密碼</label>
|
||||
<input type="password" id="current-password" name="current_password" autocomplete="current-password" autofocus required
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-900">
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password" class="mb-1 block text-sm font-medium">新密碼</label>
|
||||
<input type="password" id="new-password" name="new_password" autocomplete="new-password" minlength="8" required
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-900">
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">至少 8 字元。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm-password" class="mb-1 block text-sm font-medium">確認新密碼</label>
|
||||
<input type="password" id="confirm-password" name="confirm_password" autocomplete="new-password" minlength="8" required
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-600 dark:bg-neutral-900">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="rounded-lg bg-brand px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-strong">更新密碼</button>
|
||||
</form>
|
||||
<p class="mt-4 text-xs text-neutral-400 dark:text-neutral-500">更新成功後,其他裝置的登入將全部登出;本瀏覽器會保持登入狀態。</p>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -117,10 +117,9 @@ func (in *accountInput) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPasswordLen = 8
|
||||
|
||||
// resolvePassword 回傳帳號密碼:有 -password 旗標時直接使用,
|
||||
// 否則須於終端機以無回顯方式輸入兩次;非終端機環境不得省略旗標。
|
||||
// 最小長度採 auth.MinPasswordLen(與網頁更新密碼流程一致)。
|
||||
func resolvePassword(flagPassword string) (string, error) {
|
||||
password := flagPassword
|
||||
if password == "" {
|
||||
@@ -133,8 +132,8 @@ func resolvePassword(flagPassword string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if len(password) < minPasswordLen {
|
||||
return "", fmt.Errorf("密碼長度至少 %d 字元", minPasswordLen)
|
||||
if len(password) < auth.MinPasswordLen {
|
||||
return "", fmt.Errorf("密碼長度至少 %d 字元", auth.MinPasswordLen)
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func runUpdatePassword(args []string) error {
|
||||
if err := u.SetPassword(pw); err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
revoked, err := updateUserPassword(db, u)
|
||||
revoked, err := auth.UpdateUserPassword(db, u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -62,21 +62,3 @@ func findUserByUsername(db *gorm.DB, username string) (*auth.User, error) {
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// updateUserPassword 於單一交易內寫入新密碼雜湊並刪除該使用者所有
|
||||
// Session,回傳撤銷的 Session 數;交易確保密碼與 Session 不會只更新一半。
|
||||
func updateUserPassword(db *gorm.DB, u *auth.User) (int64, error) {
|
||||
var revoked int64
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(u).Update("password_hash", u.PasswordHash).Error; err != nil {
|
||||
return fmt.Errorf("update password: %w", err)
|
||||
}
|
||||
res := tx.Where("user_id = ?", u.ID).Delete(&auth.Session{})
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("delete sessions: %w", res.Error)
|
||||
}
|
||||
revoked = res.RowsAffected
|
||||
return nil
|
||||
})
|
||||
return revoked, err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type discoveryDocument struct {
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"` // RP-Initiated Logout 1.0 §2.1:同時支援兩者時為 REQUIRED
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
@@ -42,6 +43,7 @@ func DiscoveryHandler(issuer string) http.HandlerFunc {
|
||||
AuthorizationEndpoint: issuer + "/authorize",
|
||||
TokenEndpoint: issuer + "/token",
|
||||
UserInfoEndpoint: issuer + "/userinfo",
|
||||
EndSessionEndpoint: issuer + "/logout",
|
||||
JWKSURI: issuer + "/.well-known/jwks.json",
|
||||
ScopesSupported: application.ScopesSupported(),
|
||||
ResponseTypesSupported: []string{"code"},
|
||||
|
||||
+17
-7
@@ -159,12 +159,12 @@ func scopeHas(scope, s string) bool {
|
||||
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) {
|
||||
// verifyJWTSignature 驗證 JWT 的外層結構與簽章:拆解三段、僅接受 RS256
|
||||
// 的 alg(RFC 8725 §3.4 的演算法混淆防護)、以 header kid 對應的簽章
|
||||
// 金鑰驗章(金鑰輪替過渡期仍可查得已退休金鑰),回傳解碼後的 claims
|
||||
// JSON,供 VerifyAccessToken 與 verifyIDTokenHint 共用。任何一項不符即回
|
||||
// 包裹 ErrInvalidToken 的錯誤,不洩漏細節。
|
||||
func verifyJWTSignature(db *gorm.DB, token string) ([]byte, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "結構")
|
||||
@@ -197,11 +197,21 @@ func VerifyAccessToken(db *gorm.DB, issuer, token string) (*AccessTokenClaims, e
|
||||
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")
|
||||
}
|
||||
return payloadJSON, nil
|
||||
}
|
||||
|
||||
// VerifyAccessToken 驗證 Access Token 並回傳其 claims:驗章同
|
||||
// verifyJWTSignature,另比對 issuer 與效期(OIDC Core §3.1.3.7 的 iss/
|
||||
// exp 驗證項)。任何一項不符即回 ErrInvalidToken,不洩漏細節。
|
||||
func VerifyAccessToken(db *gorm.DB, issuer, token string) (*AccessTokenClaims, error) {
|
||||
payloadJSON, err := verifyJWTSignature(db, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var claims AccessTokenClaims
|
||||
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
|
||||
return nil, fmt.Errorf("%w:%s", ErrInvalidToken, "claims")
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"alterminal/internal/application"
|
||||
"alterminal/internal/auth"
|
||||
)
|
||||
|
||||
// RP-Initiated Logout(OpenID Connect RP-Initiated Logout 1.0):RP 將
|
||||
// 使用者導向 OP 的登出端點,請求結束 End-User 在 OP 的 Session。本檔實作
|
||||
// /logout 的 GET 與 POST(§2 要求兩種方法皆須支援);不帶 RP 參數的
|
||||
// POST(帳號頁側欄登出表單)與 JSON API 登出仍由 auth.LogoutHandler
|
||||
// 處理,行為不變。
|
||||
|
||||
// logoutRequest 為 /logout 的 RP-Initiated Logout 請求參數(§2):GET
|
||||
// query 與確認頁 POST 表單共用;Decision 僅用於確認頁表單的兩顆按鈕。
|
||||
type logoutRequest struct {
|
||||
IDTokenHint string
|
||||
PostLogoutRedirectURI string
|
||||
ClientID string
|
||||
State string
|
||||
Decision string
|
||||
}
|
||||
|
||||
// logoutRequestFromValues 由 query 或表單值還原請求參數。
|
||||
func logoutRequestFromValues(v url.Values) logoutRequest {
|
||||
return logoutRequest{
|
||||
IDTokenHint: v.Get("id_token_hint"),
|
||||
PostLogoutRedirectURI: v.Get("post_logout_redirect_uri"),
|
||||
ClientID: v.Get("client_id"),
|
||||
State: v.Get("state"),
|
||||
Decision: v.Get("decision"),
|
||||
}
|
||||
}
|
||||
|
||||
// values 重建請求的原始參數(確認頁的隱藏欄位;Decision 不隱藏帶回)。
|
||||
func (req logoutRequest) values() url.Values {
|
||||
v := url.Values{}
|
||||
set := func(k, s string) {
|
||||
if s != "" {
|
||||
v.Set(k, s)
|
||||
}
|
||||
}
|
||||
set("id_token_hint", req.IDTokenHint)
|
||||
set("post_logout_redirect_uri", req.PostLogoutRedirectURI)
|
||||
set("client_id", req.ClientID)
|
||||
set("state", req.State)
|
||||
return v
|
||||
}
|
||||
|
||||
// isRPInitiated 回傳參數集是否為 RP-Initiated Logout 請求(帶任一 RP
|
||||
// 參數)。不帶者為本站既有登出(側欄表單、JSON API),委由 auth 套件。
|
||||
func isRPInitiated(v url.Values) bool {
|
||||
for _, k := range []string{"id_token_hint", "post_logout_redirect_uri", "client_id", "state", "decision"} {
|
||||
if v.Get(k) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// logoutContext 攜帶請求驗證後的決策輸入:發起登出的應用程式(由 hint
|
||||
// 的 aud 或 client_id 參數對應)、hint 的 sub,以及 post_logout_redirect_uri
|
||||
// 是否通過註冊驗證。
|
||||
type logoutContext struct {
|
||||
app *application.Application // 無法對應任何應用程式時為 nil
|
||||
hintSub string // id_token_hint 的 sub;未提供 hint 時為空字串
|
||||
redirectOK bool // post_logout_redirect_uri 已精確比對通過註冊值
|
||||
}
|
||||
|
||||
// resolveLogout 驗證請求並解析決策輸入:
|
||||
// - id_token_hint 驗章與 iss(§2:OP MUST 驗證其為本 OP 所簽發;效期
|
||||
// 不檢查——§2 要求 RP 對應 session 存在(或近期存在)時應接受過期值)。
|
||||
// - 同時提供 client_id 時須與 hint 的 aud 相符(§2 MUST)。
|
||||
// - post_logout_redirect_uri 須與註冊的 post_logout_redirect_uris 精確
|
||||
// 比對通過(§3 MUST NOT 重導至未註冊值)——比對對象為 hint(aud)或
|
||||
// client_id 參數對應的應用程式;無 hint 時以 client_id 參數辨識 client
|
||||
// (§2),查無此應用程式則不接受重導。
|
||||
//
|
||||
// 硬錯誤(hint 無效、client_id 與 aud 不符)以 logoutError 回 400 且不
|
||||
// 執行登出(§4:偵測到請求錯誤時 MUST 不重導);查無 client_id 對應的
|
||||
// 應用程式為軟失敗——登出仍可進行(經使用者確認),僅不重導。
|
||||
func resolveLogout(db *gorm.DB, issuer string, req logoutRequest) (*logoutContext, *logoutError, error) {
|
||||
ctx := &logoutContext{}
|
||||
clientID := req.ClientID
|
||||
if req.IDTokenHint != "" {
|
||||
claims, err := verifyIDTokenHint(db, issuer, req.IDTokenHint)
|
||||
if err != nil {
|
||||
return nil, &logoutError{"id_token_hint 無效或非本服務簽發"}, nil
|
||||
}
|
||||
if clientID != "" && clientID != claims.Aud {
|
||||
return nil, &logoutError{"client_id 與 id_token_hint 的 aud 不符"}, nil
|
||||
}
|
||||
clientID = claims.Aud
|
||||
ctx.hintSub = claims.Sub
|
||||
}
|
||||
if clientID != "" {
|
||||
app, err := application.GetByClientID(db, clientID)
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
if req.IDTokenHint != "" {
|
||||
// hint 指向的 client 已不存在:註冊資料已刪,請求無從驗證。
|
||||
return nil, &logoutError{"id_token_hint 對應的應用程式不存在"}, nil
|
||||
}
|
||||
case err != nil:
|
||||
return nil, nil, err
|
||||
default:
|
||||
ctx.app = app
|
||||
}
|
||||
}
|
||||
ctx.redirectOK = req.PostLogoutRedirectURI != "" &&
|
||||
ctx.app != nil && ctx.app.PostLogoutRedirectURIs.Contains(req.PostLogoutRedirectURI)
|
||||
return ctx, nil, nil
|
||||
}
|
||||
|
||||
// logoutError 為不可繼續的請求錯誤:以 400 顯示錯誤頁,不執行登出、
|
||||
// 不重導(RP-Initiated Logout 1.0 §4)。
|
||||
type logoutError struct{ msg string }
|
||||
|
||||
// LogoutHandler 處理 /logout:GET 與 RP 確認頁的 POST 走 RP-Initiated
|
||||
// Logout 流程;不帶 RP 參數的 POST(側欄登出表單、JSON API)委由
|
||||
// auth.LogoutHandler 維持既有行為。
|
||||
func LogoutHandler(db *gorm.DB, issuer string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
handleLogoutGet(db, issuer, w, r)
|
||||
case http.MethodPost:
|
||||
handleLogoutPostDispatch(db, issuer, w, r)
|
||||
default:
|
||||
auth.WriteError(w, http.StatusMethodNotAllowed, "不支援的方法")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleLogoutPostDispatch 分流 POST /logout:不帶 RP 參數者(側欄登出
|
||||
// 表單、JSON API)委由 auth.LogoutHandler 維持既有行為,其餘走 RP 確認
|
||||
// 流程。
|
||||
func handleLogoutPostDispatch(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(ct, "application/x-www-form-urlencoded") &&
|
||||
!strings.HasPrefix(ct, "multipart/form-data") {
|
||||
auth.LogoutHandler(db)(w, r) // JSON 等非表單:既有 API 登出(含 415 檢查)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil || !isRPInitiated(r.PostForm) {
|
||||
auth.LogoutHandler(db)(w, r) // 表單解析失敗或非 RP 請求:既有表單登出(重繪 400 錯誤頁)
|
||||
return
|
||||
}
|
||||
handleLogoutPost(db, issuer, w, r)
|
||||
}
|
||||
|
||||
// handleLogoutGet 處理 GET /logout(RP 導向或使用者直接造訪)。
|
||||
func handleLogoutGet(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request) {
|
||||
req := logoutRequestFromValues(r.URL.Query())
|
||||
ctx, lerr, err := resolveLogout(db, issuer, req)
|
||||
if !logoutValidated(w, r, lerr, err) {
|
||||
return
|
||||
}
|
||||
s, ok := logoutSession(db, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if s != nil && logoutNeedsConfirm(s, ctx) {
|
||||
renderLogoutConfirmPage(w, r, http.StatusOK, req, ctx, s, "")
|
||||
return
|
||||
}
|
||||
completeLogout(db, w, r, req, ctx)
|
||||
}
|
||||
|
||||
// handleLogoutPost 處理 POST /logout(確認頁決定,或 RP 直接以 POST 發起)。
|
||||
// decision 為 logout/cancel 時必來自本服務確認頁表單,須通過 CSRF 驗證;
|
||||
// 無 decision(RP 直接 POST)視同 GET 的初次請求,走相同的確認判斷。
|
||||
func handleLogoutPost(db *gorm.DB, issuer string, w http.ResponseWriter, r *http.Request) {
|
||||
req := logoutRequestFromValues(r.PostForm)
|
||||
ctx, lerr, err := resolveLogout(db, issuer, req)
|
||||
if !logoutValidated(w, r, lerr, err) {
|
||||
return
|
||||
}
|
||||
s, ok := logoutSession(db, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch req.Decision {
|
||||
case "logout":
|
||||
if !logoutCSRF(w, r, req, ctx, s) {
|
||||
return
|
||||
}
|
||||
completeLogout(db, w, r, req, ctx)
|
||||
case "cancel":
|
||||
if !logoutCSRF(w, r, req, ctx, s) {
|
||||
return
|
||||
}
|
||||
// 使用者選擇不登出:維持登入,回到帳號首頁。
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
default:
|
||||
if s != nil && logoutNeedsConfirm(s, ctx) {
|
||||
renderLogoutConfirmPage(w, r, http.StatusOK, req, ctx, s, "")
|
||||
return
|
||||
}
|
||||
completeLogout(db, w, r, req, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// logoutCSRF 驗證確認表單的 CSRF;失敗時重繪確認頁(403)。無 Session
|
||||
// 時無可保護的狀態(登出為冪等、重導目標已限註冊值),直接放行。
|
||||
func logoutCSRF(w http.ResponseWriter, r *http.Request, req logoutRequest, ctx *logoutContext, s *auth.Session) bool {
|
||||
if s == nil || auth.VerifyCSRF(r) {
|
||||
return true
|
||||
}
|
||||
renderLogoutConfirmPage(w, r, http.StatusForbidden, req, ctx, s, "表單驗證失敗,請重新操作")
|
||||
return false
|
||||
}
|
||||
|
||||
// logoutValidated 統一處理驗證結果:硬錯誤顯示 400 錯誤頁(§4 不重導),
|
||||
// 內部錯誤回 500。回傳是否繼續後續流程。
|
||||
func logoutValidated(w http.ResponseWriter, r *http.Request, lerr *logoutError, err error) bool {
|
||||
switch {
|
||||
case lerr != nil:
|
||||
log.Printf("logout: %s", lerr.msg)
|
||||
http.Error(w, "登出請求無效:"+lerr.msg, http.StatusBadRequest)
|
||||
return false
|
||||
case err != nil:
|
||||
log.Printf("logout: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// logoutSession 查詢目前 Session:未登入或已過期回 (nil, true)(登出冪
|
||||
// 等,視同無 Session 續走流程);查詢錯誤回 500 並回 (nil, false),呼叫
|
||||
// 方應立即返回。
|
||||
func logoutSession(db *gorm.DB, w http.ResponseWriter, r *http.Request) (*auth.Session, bool) {
|
||||
c, err := r.Cookie(auth.CookieName)
|
||||
if err != nil {
|
||||
return nil, true
|
||||
}
|
||||
s, err := auth.GetSession(db, c.Value)
|
||||
if errors.Is(err, auth.ErrSessionExpired) {
|
||||
return nil, true
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("logout: %v", err)
|
||||
http.Error(w, "內部錯誤", http.StatusInternalServerError)
|
||||
return nil, false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// logoutNeedsConfirm 回傳是否須先經使用者確認:未提供 id_token_hint、
|
||||
// 或 hint 的 sub 不屬於目前 Session 的使用者時必須詢問(§2 MUST——本
|
||||
// 服務的 ID token 不含 sid claim,以 sub 比對 Session 使用者近似判斷
|
||||
// 「hint 是否屬於目前 session」)。帳號頁的每次登出本就由使用者點擊
|
||||
// 發起,直接造訪 /logout 亦同此路徑。
|
||||
func logoutNeedsConfirm(s *auth.Session, ctx *logoutContext) bool {
|
||||
return ctx.hintSub == "" || ctx.hintSub != subject(s.UserID)
|
||||
}
|
||||
|
||||
// completeLogout 執行登出並完成回應:刪除 Session、清除 Cookie(冪等),
|
||||
// 之後依驗證結果——通過者 303 重導 post_logout_redirect_uri 並附 state
|
||||
// (§2);指定但未通過註冊驗證者不重導(§3 MUST NOT),顯示已登出頁
|
||||
// 與說明;未指定者 303 /login(與本站既有表單登出行為一致)。
|
||||
func completeLogout(db *gorm.DB, w http.ResponseWriter, r *http.Request, req logoutRequest, ctx *logoutContext) {
|
||||
auth.ClearSession(db, w, r)
|
||||
switch {
|
||||
case ctx.redirectOK:
|
||||
u, err := url.Parse(req.PostLogoutRedirectURI)
|
||||
if err != nil {
|
||||
log.Printf("logout: 解析 post_logout_redirect_uri: %v", err)
|
||||
renderLoggedOutPage(w, r, "返回網址無效,無法導回應用程式")
|
||||
return
|
||||
}
|
||||
if req.State != "" {
|
||||
q := u.Query()
|
||||
q.Set("state", req.State)
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
http.Redirect(w, r, u.String(), http.StatusSeeOther)
|
||||
case req.PostLogoutRedirectURI != "":
|
||||
renderLoggedOutPage(w, r, "返回網址未經應用程式註冊,無法導回;您已登出本服務。")
|
||||
default:
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// logoutConfirmPageData 為登出確認頁的模板資料。Params 保存原始請求
|
||||
// 參數以隱藏欄位帶回 POST /logout(與同意頁同做法)。
|
||||
type logoutConfirmPageData struct {
|
||||
Error string
|
||||
Username string
|
||||
Email string
|
||||
IsAdmin bool
|
||||
CSRF string
|
||||
AppName string // 發起登出的應用程式;無法辨識時為空字串
|
||||
Params url.Values
|
||||
}
|
||||
|
||||
// renderLogoutConfirmPage 輸出登出確認頁;每次輸出都輪替 CSRF token。
|
||||
func renderLogoutConfirmPage(w http.ResponseWriter, r *http.Request, status int, req logoutRequest, ctx *logoutContext, 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
|
||||
}
|
||||
appName := ""
|
||||
if ctx.app != nil {
|
||||
appName = ctx.app.Name
|
||||
}
|
||||
auth.RenderHTML(w, status, auth.LogoutConfirmTmpl, logoutConfirmPageData{
|
||||
Error: errMsg,
|
||||
Username: s.User.Username,
|
||||
Email: s.User.Email,
|
||||
IsAdmin: s.User.Role == auth.RoleAdmin,
|
||||
CSRF: token,
|
||||
AppName: appName,
|
||||
Params: req.values(),
|
||||
})
|
||||
}
|
||||
|
||||
// loggedOutPageData 為已登出頁的模板資料;Warning 在無法依 RP 請求導回
|
||||
// 應用程式時顯示原因(頁面獨立於側欄版面——使用者已登出,無帳號脈絡)。
|
||||
type loggedOutPageData struct {
|
||||
Warning string
|
||||
}
|
||||
|
||||
// renderLoggedOutPage 輸出已登出頁。
|
||||
func renderLoggedOutPage(w http.ResponseWriter, r *http.Request, warning string) {
|
||||
auth.RenderHTML(w, http.StatusOK, auth.LoggedOutTmpl, loggedOutPageData{Warning: warning})
|
||||
}
|
||||
|
||||
// verifyIDTokenHint 驗證 RP-Initiated Logout 的 id_token_hint(§2:OP
|
||||
// MUST 驗證其為本 OP 所簽發):拆解三段 JWT、僅接受 RS256(RFC 8725
|
||||
// §3.4)、以 header kid 對應金鑰驗章(含已退休金鑰)並比對 iss;不檢查
|
||||
// exp——§2 要求 RP(aud 對應的 client)的 session 存在或近期存在時應
|
||||
// 接受已過期的 ID token,登出提示通常在效期外送達。
|
||||
func verifyIDTokenHint(db *gorm.DB, issuer, token string) (*idTokenClaims, error) {
|
||||
payloadJSON, err := verifyJWTSignature(db, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var claims idTokenClaims
|
||||
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")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// 外部測試套件(與 helpers_test.go 同理):oidc 模型由 store 遷移,內部
|
||||
// 測試套件匯入 testdb 會形成循環。涵蓋 RP-Initiated Logout 1.0 的
|
||||
// /logout:id_token_hint 驗證、確認頁、post_logout_redirect_uri 註冊比對
|
||||
// 與 state 回填,及不帶 RP 參數時對既有登出行為的委派。
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"alterminal/internal/application"
|
||||
"alterminal/internal/auth"
|
||||
"alterminal/internal/oidc"
|
||||
)
|
||||
|
||||
// postLogoutURI 為測試應用程式註冊的登出後返回 URI。
|
||||
const postLogoutURI = "https://rp.example/logged-out"
|
||||
|
||||
// newLogoutEnv 建立已註冊登出後返回 URI 的測試環境。
|
||||
func newLogoutEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
e := newTestEnv(t)
|
||||
e.app.PostLogoutRedirectURIs = application.RedirectURIs{postLogoutURI}
|
||||
if err := e.db.Save(e.app).Error; err != nil {
|
||||
t.Fatal("註冊登出後返回 URI: ", err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// idTokenHint 為環境使用者簽發 ID token 供 id_token_hint 用。
|
||||
func idTokenHint(t *testing.T, e *testEnv) string {
|
||||
t.Helper()
|
||||
tok, err := oidc.IssueIDToken(e.db, testIssuer, e.user, e.app, "openid", "", e.session.CreatedAt)
|
||||
if err != nil {
|
||||
t.Fatal("簽發 ID token: ", err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// getLogout 對 GET /logout 發出請求(query 含前導 ?,可選帶 Cookie)。
|
||||
func getLogout(h http.HandlerFunc, query string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/logout"+query, nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// postLogoutForm 以表單送出 POST /logout(可選帶 Cookie)。
|
||||
func postLogoutForm(h http.HandlerFunc, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/logout", 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
|
||||
}
|
||||
|
||||
// sessionCookieCleared 檢查回應是否清除 Session Cookie(Max-Age<0)。
|
||||
func sessionCookieCleared(rec *httptest.ResponseRecorder) bool {
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == auth.CookieName && c.MaxAge < 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sessionAlive 回傳環境 Session 是否仍有效。
|
||||
func sessionAlive(t *testing.T, e *testEnv) bool {
|
||||
t.Helper()
|
||||
_, err := auth.GetSession(e.db, e.session.ID)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// logoutQuery 組出 RP-Initiated Logout 的 GET query(含前導 ?)。
|
||||
func logoutQuery(hint, redirectURI, clientID, state string) string {
|
||||
v := url.Values{}
|
||||
set := func(k, s string) {
|
||||
if s != "" {
|
||||
v.Set(k, s)
|
||||
}
|
||||
}
|
||||
set("id_token_hint", hint)
|
||||
set("post_logout_redirect_uri", redirectURI)
|
||||
set("client_id", clientID)
|
||||
set("state", state)
|
||||
return "?" + v.Encode()
|
||||
}
|
||||
|
||||
func TestLogoutDiscoveryEndSessionEndpoint(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)
|
||||
}
|
||||
// RP-Initiated Logout 1.0 §2.1:支援 Discovery 時須發佈 end_session_endpoint。
|
||||
if !strings.Contains(rec.Body.String(), `"end_session_endpoint":"`+testIssuer+`/logout"`) {
|
||||
t.Fatalf("Discovery 應發佈 end_session_endpoint: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPLogoutHintMatchingSessionRedirects(t *testing.T) {
|
||||
e := newLogoutEnv(t)
|
||||
h := oidc.LogoutHandler(e.db, testIssuer)
|
||||
hint := idTokenHint(t, e)
|
||||
|
||||
rec := getLogout(h, logoutQuery(hint, postLogoutURI, "", "st-123"), e.sessionCookie())
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// RP-Initiated Logout 1.0 §2:重導 post_logout_redirect_uri 並以 state
|
||||
// 回填原值。
|
||||
loc := redirectLocation(t, rec)
|
||||
if loc.String() != postLogoutURI+"?state=st-123" {
|
||||
t.Fatalf("Location = %q, want %q?state=st-123", loc.String(), postLogoutURI)
|
||||
}
|
||||
if !sessionCookieCleared(rec) {
|
||||
t.Fatal("應清除 Session Cookie")
|
||||
}
|
||||
if sessionAlive(t, e) {
|
||||
t.Fatal("登出後 Session 應已刪除")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPLogoutExpiredHintAccepted(t *testing.T) {
|
||||
e := newLogoutEnv(t)
|
||||
h := oidc.LogoutHandler(e.db, testIssuer)
|
||||
// 過期的 ID token:登出提示常在效期外送達,RP-Initiated Logout 1.0
|
||||
// §2 要求 RP 對應 session 存在(或近期存在)時應接受。
|
||||
past := time.Now().Add(-time.Hour).Unix()
|
||||
hint := forgeJWT(t, e.key,
|
||||
map[string]string{"alg": "RS256", "kid": e.key.Kid, "typ": "JWT"},
|
||||
map[string]any{"iss": testIssuer, "sub": subjectOf(e.user.ID), "aud": e.app.ClientID, "exp": past, "iat": past})
|
||||
|
||||
rec := getLogout(h, logoutQuery(hint, postLogoutURI, "", ""), e.sessionCookie())
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := redirectLocation(t, rec).String(); got != postLogoutURI {
|
||||
t.Fatalf("Location = %q, want %q", got, postLogoutURI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPLogoutHintOfOtherUserRequiresConfirmation(t *testing.T) {
|
||||
e := newLogoutEnv(t)
|
||||
h := oidc.LogoutHandler(e.db, testIssuer)
|
||||
|
||||
other := &auth.User{Username: "logout-other", Email: "logout-other@example.com", Name: "他人"}
|
||||
if err := e.db.Create(other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
e.db.Delete(&auth.Session{}, "user_id = ?", other.ID)
|
||||
e.db.Delete(&auth.User{}, other.ID)
|
||||
})
|
||||
otherHint, err := oidc.IssueIDToken(e.db, testIssuer, other, e.app, "openid", "", time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := getLogout(h, logoutQuery(otherHint, postLogoutURI, "", ""), 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(), "您確定要登出嗎") {
|
||||
t.Fatal("應顯示登出確認頁")
|
||||
}
|
||||
if !sessionAlive(t, e) {
|
||||
t.Fatal("未確認前不應登出")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPLogoutWithoutHintConfirmationFlow(t *testing.T) {
|
||||
e := newLogoutEnv(t)
|
||||
h := oidc.LogoutHandler(e.db, testIssuer)
|
||||
|
||||
// 無 id_token_hint:RP-Initiated Logout 1.0 §2 MUST 先詢問 End-User。
|
||||
rec := getLogout(h, logoutQuery("", postLogoutURI, e.app.ClientID, "st-9"), e.sessionCookie())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200(確認頁), body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "您確定要登出嗎") || !strings.Contains(body, e.app.Name) {
|
||||
t.Fatalf("確認頁應顯示標題與應用程式名稱: %s", body)
|
||||
}
|
||||
// 原始參數以隱藏欄位帶回。
|
||||
if got := hiddenFieldValue(t, body, "post_logout_redirect_uri"); got != postLogoutURI {
|
||||
t.Fatalf("隱藏欄位 post_logout_redirect_uri = %q, want %q", got, postLogoutURI)
|
||||
}
|
||||
if got := hiddenFieldValue(t, body, "state"); got != "st-9" {
|
||||
t.Fatalf("隱藏欄位 state = %q, want st-9", got)
|
||||
}
|
||||
csrf := csrfCookieOf(t, rec)
|
||||
|
||||
form := url.Values{
|
||||
"decision": {"logout"},
|
||||
"csrf_token": {csrf.Value},
|
||||
"client_id": {e.app.ClientID},
|
||||
"post_logout_redirect_uri": {postLogoutURI},
|
||||
"state": {"st-9"},
|
||||
}
|
||||
rec = postLogoutForm(h, form, e.sessionCookie(), csrf)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("確認後 status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
loc := redirectLocation(t, rec)
|
||||
if loc.String() != postLogoutURI+"?state=st-9" {
|
||||
t.Fatalf("Location = %q, want %q?state=st-9", loc.String(), postLogoutURI)
|
||||
}
|
||||
if sessionAlive(t, e) {
|
||||
t.Fatal("確認後 Session 應已刪除")
|
||||
}
|
||||
|
||||
// 取消:維持登入,返回帳號首頁。
|
||||
s, err := auth.CreateSession(e.db, e.user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.session = s
|
||||
rec2 := getLogout(h, logoutQuery("", postLogoutURI, e.app.ClientID, ""), e.sessionCookie())
|
||||
csrf2 := csrfCookieOf(t, rec2)
|
||||
form2 := url.Values{
|
||||
"decision": {"cancel"},
|
||||
"csrf_token": {csrf2.Value},
|
||||
"client_id": {e.app.ClientID},
|
||||
"post_logout_redirect_uri": {postLogoutURI},
|
||||
}
|
||||
rec2 = postLogoutForm(h, form2, e.sessionCookie(), csrf2)
|
||||
if rec2.Code != http.StatusSeeOther {
|
||||
t.Fatalf("取消 status = %d, want 303", rec2.Code)
|
||||
}
|
||||
if loc := redirectLocation(t, rec2).String(); loc != "/" {
|
||||
t.Fatalf("取消後 Location = %q, want /", loc)
|
||||
}
|
||||
if !sessionAlive(t, e) {
|
||||
t.Fatal("取消登出後 Session 應保持有效")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPLogoutConfirmCSRFRequired(t *testing.T) {
|
||||
e := newLogoutEnv(t)
|
||||
h := oidc.LogoutHandler(e.db, testIssuer)
|
||||
|
||||
form := url.Values{
|
||||
"decision": {"logout"},
|
||||
"csrf_token": {"wrong"},
|
||||
"client_id": {e.app.ClientID},
|
||||
"post_logout_redirect_uri": {postLogoutURI},
|
||||
}
|
||||
rec := postLogoutForm(h, form, e.sessionCookie(), &http.Cookie{Name: auth.CSRFCookieName, Value: "right"})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
|
||||
t.Fatal("應重繪確認頁並顯示錯誤")
|
||||
}
|
||||
if !sessionAlive(t, e) {
|
||||
t.Fatal("CSRF 失敗時不應登出")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPLogoutUnregisteredRedirectRejected(t *testing.T) {
|
||||
e := newLogoutEnv(t)
|
||||
h := oidc.LogoutHandler(e.db, testIssuer)
|
||||
hint := idTokenHint(t, e)
|
||||
|
||||
// 未註冊的返回 URI(含湊巧是 redirect URI 者):RP-Initiated Logout
|
||||
// 1.0 §3 MUST NOT 重導;登出仍完成並顯示說明。
|
||||
for _, uri := range []string{"https://evil.example/gotcha", e.app.RedirectURIs[0]} {
|
||||
rec := getLogout(h, logoutQuery(hint, uri, "", ""), e.sessionCookie())
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("uri = %s: status = %d, want 200(已登出頁), body = %s", uri, rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "" {
|
||||
t.Fatalf("uri = %s: 不應重導,得到 Location = %s", uri, loc)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "您已登出") || !strings.Contains(rec.Body.String(), "未經應用程式註冊") {
|
||||
t.Fatalf("uri = %s: 應顯示已登出頁與說明: %s", uri, rec.Body.String())
|
||||
}
|
||||
if sessionAlive(t, e) {
|
||||
t.Fatal("登出仍應執行")
|
||||
}
|
||||
// 下一輪以新 Session 測試(前輪已登出)。
|
||||
s, err := auth.CreateSession(e.db, e.user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.session = s
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPLogoutInvalidHintRejected(t *testing.T) {
|
||||
e := newLogoutEnv(t)
|
||||
h := oidc.LogoutHandler(e.db, testIssuer)
|
||||
|
||||
// 以未註冊於本服務的金鑰簽署:kid 查無 → hint 無效(§2 OP MUST 驗證
|
||||
// 為本 OP 簽發;§4 錯誤時 MUST 不重導)。
|
||||
other := mustNewKey(t, false)
|
||||
forged := forgeJWT(t, other,
|
||||
map[string]string{"alg": "RS256", "kid": other.Kid, "typ": "JWT"},
|
||||
map[string]any{"iss": testIssuer, "sub": subjectOf(e.user.ID), "aud": e.app.ClientID})
|
||||
|
||||
rec := getLogout(h, logoutQuery(forged, postLogoutURI, "", ""), e.sessionCookie())
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "" {
|
||||
t.Fatalf("錯誤時不應重導,得到 Location = %s", loc)
|
||||
}
|
||||
if !sessionAlive(t, e) {
|
||||
t.Fatal("無效 hint 不應執行登出")
|
||||
}
|
||||
|
||||
// client_id 與 hint 的 aud 不符(§2 MUST 驗證相符)。
|
||||
hint := idTokenHint(t, e)
|
||||
rec = getLogout(h, logoutQuery(hint, postLogoutURI, e.pub.ClientID, ""), e.sessionCookie())
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !sessionAlive(t, e) {
|
||||
t.Fatal("client_id 不符時不應執行登出")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPLogoutIdempotentWithoutSession(t *testing.T) {
|
||||
e := newLogoutEnv(t)
|
||||
h := oidc.LogoutHandler(e.db, testIssuer)
|
||||
hint := idTokenHint(t, e)
|
||||
|
||||
// 無 Session:冪等完成,仍重導至已註冊的返回 URI。
|
||||
rec := getLogout(h, logoutQuery(hint, postLogoutURI, "", "s"))
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := redirectLocation(t, rec).String(); got != postLogoutURI+"?state=s" {
|
||||
t.Fatalf("Location = %q, want %q?state=s", got, postLogoutURI)
|
||||
}
|
||||
if !sessionCookieCleared(rec) {
|
||||
t.Fatal("仍應清除(失效的)Session Cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutDirectVisit(t *testing.T) {
|
||||
e := newLogoutEnv(t)
|
||||
h := oidc.LogoutHandler(e.db, testIssuer)
|
||||
|
||||
// 已登入直接造訪:無 hint,須先確認。
|
||||
rec := getLogout(h, "", e.sessionCookie())
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "您確定要登出嗎") {
|
||||
t.Fatalf("已登入直接造訪應顯示確認頁, status = %d", rec.Code)
|
||||
}
|
||||
if !sessionAlive(t, e) {
|
||||
t.Fatal("未確認前不應登出")
|
||||
}
|
||||
|
||||
// 未登入直接造訪:冪等,導向 /login(與既有登出行為一致)。
|
||||
rec = getLogout(h, "")
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rec.Code)
|
||||
}
|
||||
if loc := redirectLocation(t, rec).String(); loc != "/login" {
|
||||
t.Fatalf("Location = %q, want /login", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutPostDelegatesLegacyBehavior(t *testing.T) {
|
||||
e := newLogoutEnv(t)
|
||||
h := oidc.LogoutHandler(e.db, testIssuer)
|
||||
|
||||
// 側欄登出表單(僅 csrf_token):委由 auth.LogoutHandler,303 /login。
|
||||
rec := postLogoutForm(h, url.Values{"csrf_token": {"t"}},
|
||||
e.sessionCookie(), &http.Cookie{Name: auth.CSRFCookieName, Value: "t"})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := redirectLocation(t, rec).String(); loc != "/login" {
|
||||
t.Fatalf("Location = %q, want /login", loc)
|
||||
}
|
||||
if sessionAlive(t, e) {
|
||||
t.Fatal("表單登出應刪除 Session")
|
||||
}
|
||||
|
||||
// JSON API 登出:204。
|
||||
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec2 := httptest.NewRecorder()
|
||||
h(rec2, req)
|
||||
if rec2.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec2.Code)
|
||||
}
|
||||
if !sessionCookieCleared(rec2) {
|
||||
t.Fatal("JSON 登出應清除 Session Cookie")
|
||||
}
|
||||
|
||||
// 不支援的 Content-Type:415(沿 auth.LogoutHandler 的檢查)。
|
||||
req = httptest.NewRequest(http.MethodPost, "/logout", strings.NewReader("x=1"))
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
rec3 := httptest.NewRecorder()
|
||||
h(rec3, req)
|
||||
if rec3.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("status = %d, want 415", rec3.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPLogoutClientIDWithoutHintSoftFailsRedirect(t *testing.T) {
|
||||
e := newLogoutEnv(t)
|
||||
h := oidc.LogoutHandler(e.db, testIssuer)
|
||||
|
||||
// 僅帶查無對應的 client_id:請求仍可進行(經確認頁),但不接受重導
|
||||
// ——無法確認返回網址的歸屬(RP-Initiated Logout 1.0 §3)。
|
||||
rec := getLogout(h, logoutQuery("", postLogoutURI, "no-such-client", ""), e.sessionCookie())
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "您確定要登出嗎") {
|
||||
t.Fatalf("應顯示確認頁, status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
csrf := csrfCookieOf(t, rec)
|
||||
form := url.Values{
|
||||
"decision": {"logout"},
|
||||
"csrf_token": {csrf.Value},
|
||||
"client_id": {"no-such-client"},
|
||||
"post_logout_redirect_uri": {postLogoutURI},
|
||||
}
|
||||
rec = postLogoutForm(h, form, e.sessionCookie(), csrf)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200(已登出頁), body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "" {
|
||||
t.Fatalf("查無 client 不得重導,得到 Location = %s", loc)
|
||||
}
|
||||
if sessionAlive(t, e) {
|
||||
t.Fatal("確認後應完成登出")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user