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