Files
alterminal/internal/application/application.go
T
2026-10-03 10:44:29 +08:00

303 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package application
import (
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
"gorm.io/gorm"
"alterminal/internal/auth"
)
// ClientType 為 OAuth 2.0 Client 類型(RFC 6749 §2.1):confidential 能
// 安全保管 client secret(後端網頁應用),public 不能(SPA、行動應用),
// 授權流程必須以 PKCE 彌補,不簽發 client secret。
type ClientType string
// 允許的類型值。
const (
ClientConfidential ClientType = "confidential"
ClientPublic ClientType = "public"
)
// valid 回傳類型是否為允許的值。
func (t ClientType) valid() bool {
return t == ClientConfidential || t == ClientPublic
}
// GrantType 為 OAuth 2.0 grant type。
type GrantType string
// 允許的 grant type 值。
const (
GrantAuthorizationCode GrantType = "authorization_code" // 授權碼流程(建議搭配 PKCE)
GrantRefreshToken GrantType = "refresh_token" // 以 Refresh Token 換發新權杖
GrantClientCredentials GrantType = "client_credentials" // 機器對機器,僅機密式 Client 可用
)
// valid 回傳 grant type 是否為允許的值。
func (g GrantType) valid() bool {
return g == GrantAuthorizationCode || g == GrantRefreshToken || g == GrantClientCredentials
}
// supportedScopes 為本服務支援的 scope(與 README「支援的 Scope」一致)。
var supportedScopes = map[string]bool{
"openid": true,
"profile": true,
"email": true,
"offline_access": true,
}
// defaultScope 為註冊時未指定 scope 的預設值。
const defaultScope = "openid profile email"
// ScopesSupported 回傳支援的 scope 清單(已排序),供 Discovery 端點的
// scopes_supported 發佈(與本套件的註冊驗證共用同一份清單)。
func ScopesSupported() []string {
out := make([]string, 0, len(supportedScopes))
for s := range supportedScopes {
out = append(out, s)
}
sort.Strings(out)
return out
}
// RedirectURIs 為已註冊的 redirect URI 清單(JSON 陣列儲存)。RFC 6749
// §3.1.2.3 要求端點比對時與註冊值完全相同(字串相等,不做正規化),
// 故以字串清單逐一比對。
type RedirectURIs []string
// Contains 回傳 uri 是否與任一註冊的 redirect URI 完全相同。
func (r RedirectURIs) Contains(uri string) bool {
for _, u := range r {
if u == uri {
return true
}
}
return false
}
// GrantTypes 為允許的 grant type 清單(JSON 陣列儲存)。
type GrantTypes []GrantType
// Contains 回傳 gt 是否為允許的 grant type。
func (g GrantTypes) Contains(gt GrantType) bool {
for _, x := range g {
if x == gt {
return true
}
}
return false
}
// Application 為接入 OIDC 的應用程式(Relying Party)註冊資料,對應
// applications 資料表。ClientID 由本服務產生、全域唯一;client secret
// 與使用者密碼採同一套 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
}
// IsPublic 回傳是否為公開式 Client(不持有 secret,授權流程必須使用 PKCE)。
func (a *Application) IsPublic() bool {
return a.Type == ClientPublic
}
// 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 {
a.Name = strings.TrimSpace(name)
a.Type = typ
a.RedirectURIs = append(RedirectURIs{}, redirectURIs...) // 保證非 nil,序列化為 [] 而非 null
a.GrantTypes = grantTypes
a.Scope = strings.TrimSpace(scope)
if len(a.GrantTypes) == 0 {
a.GrantTypes = GrantTypes{GrantAuthorizationCode}
}
if a.Scope == "" {
a.Scope = defaultScope
}
return a.Validate()
}
// NewApplication 建立新的應用程式註冊:先驗證內容,再產生全域唯一的
// client_id;機密式 Client 另產生 client secret,明文僅經回傳值交付一
// 次,呼叫方應立即提供給應用程式管理者,不得儲存明文。
func NewApplication(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) (*Application, string, error) {
a := &Application{}
if err := a.fill(name, typ, redirectURIs, grantTypes, scope); err != nil {
return nil, "", err
}
id, err := auth.NewToken(16)
if err != nil {
return nil, "", fmt.Errorf("generate client id: %w", err)
}
a.ClientID = id
secret := ""
if !a.IsPublic() {
if secret, err = a.GenerateSecret(); err != nil {
return nil, "", err
}
}
return a, secret, nil
}
// Update 以新的註冊內容更新既有應用程式:client_id 為公開識別碼,已
// 嵌入各 RP 的設定,不可變更;client secret 亦不受影響(輪替另經
// 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 {
return err
}
if a.IsPublic() {
a.ClientSecretHash = ""
}
return nil
}
// Validate 檢查註冊內容:名稱與類型必填、grant type 受支援且組合合法
// (client_credentials 僅限機密式 Client(RFC 6749 §4.4.3)、
// refresh_token 須伴隨授權碼流程)、使用授權碼流程時至少註冊一個格式
// 正確的 redirect URI、scope 皆受支援且 offline_access 須有
// refresh_token grant。
func (a *Application) Validate() error {
if a.Name == "" {
return errors.New("應用程式名稱不可為空")
}
if !a.Type.valid() {
return fmt.Errorf("不支援的 client 類型 %q", a.Type)
}
if len(a.GrantTypes) == 0 {
return errors.New("至少須啟用一種 grant type")
}
for _, g := range a.GrantTypes {
if !g.valid() {
return fmt.Errorf("不支援的 grant type %q", g)
}
}
if a.GrantTypes.Contains(GrantClientCredentials) && a.IsPublic() {
return errors.New("公開式 Client 不可使用 client_credentials(RFC 6749 §4.4.3)")
}
if a.GrantTypes.Contains(GrantRefreshToken) && !a.GrantTypes.Contains(GrantAuthorizationCode) {
return errors.New("refresh_token 須伴隨 authorization_code 使用")
}
if a.GrantTypes.Contains(GrantAuthorizationCode) {
if len(a.RedirectURIs) == 0 {
return errors.New("使用授權碼流程須至少註冊一個 redirect URI")
}
for _, uri := range a.RedirectURIs {
if err := validateRedirectURI(uri); err != nil {
return fmt.Errorf("redirect URI %q:%w", uri, err)
}
}
}
if a.Scope == "" {
return errors.New("scope 不可為空")
}
for _, s := range strings.Fields(a.Scope) {
if !supportedScopes[s] {
return fmt.Errorf("不支援的 scope %q", s)
}
if s == "offline_access" && !a.GrantTypes.Contains(GrantRefreshToken) {
return errors.New("offline_access 須啟用 refresh_token grant")
}
}
return nil
}
// GenerateSecret 產生並雜湊新的 client secret(明文為 32 bytes 亂數的
// base64url,43 字元),回傳明文——僅此一次,資料庫只存雜湊。再次呼叫
// 即輪替,舊 secret 立即失效。公開式 Client 不持有 secret,回傳錯誤。
func (a *Application) GenerateSecret() (string, error) {
if a.IsPublic() {
return "", errors.New("公開式 Client 不持有 client secret")
}
secret, err := auth.NewToken(32)
if err != nil {
return "", fmt.Errorf("generate client secret: %w", err)
}
hash, err := auth.HashPassword(secret)
if err != nil {
return "", fmt.Errorf("hash client secret: %w", err)
}
a.ClientSecretHash = hash
return secret, nil
}
// CheckSecret 回傳 client secret 是否相符;公開式 Client 一律不相符,
// 雜湊格式無效時亦視為不相符。
func (a *Application) CheckSecret(secret string) bool {
if a.IsPublic() {
return false
}
ok, err := auth.VerifyPassword(secret, a.ClientSecretHash)
return err == nil && ok
}
// validateRedirectURI 檢查 redirect URI 格式:須為絕對 URI 且不含
// fragment 與 userinfo(RFC 6749 §3.1.2);http 僅允許 loopback(本機
// 開發,RFC 8252 §7.3),Web 應用一律使用 https;非 http(s) 的自訂
// scheme(如 com.example.app:/cb)供原生應用程式使用。
func validateRedirectURI(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("解析失敗:%w", err)
}
if !u.IsAbs() {
return errors.New("須為絕對 URI(含 scheme)")
}
if u.Fragment != "" || u.RawFragment != "" {
return errors.New("不可包含 fragment")
}
if u.User != nil {
return errors.New("不可包含 userinfo")
}
switch u.Scheme {
case "http", "https":
if u.Host == "" {
return errors.New("缺少 host")
}
if u.Scheme == "http" {
switch u.Hostname() {
case "localhost", "127.0.0.1", "::1":
default:
return errors.New("http 僅允許 loopback(localhost、127.0.0.1、::1),其餘請使用 https")
}
}
default:
// 自訂 scheme:僅有 scheme 而無其餘部分(如 "myapp:")無法作為回呼位址。
if u.Opaque == "" && u.Host == "" && u.Path == "" {
return errors.New("自訂 scheme 的 URI 須包含 scheme 以外的部分")
}
}
return nil
}
// GetByClientID 以 client_id 查詢應用程式,供 /authorize、
// /token 驗證 Client 身分;查無資料時回傳包裹 gorm.ErrRecordNotFound
// 的錯誤(以 errors.Is 判斷)。
func GetByClientID(db *gorm.DB, clientID string) (*Application, error) {
var a Application
if err := db.Where("client_id = ?", clientID).First(&a).Error; err != nil {
return nil, fmt.Errorf("query application: %w", err)
}
return &a, nil
}