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 不可為空")
}