This commit is contained in:
2026-10-03 12:37:38 +08:00
parent bcf3d3769c
commit 5fc1755c17
22 changed files with 1845 additions and 100 deletions
+30 -18
View File
@@ -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 不可為空")
}
+59
View File
@@ -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)
}
})
}