forked from alterminal/alterminal
268 lines
9.3 KiB
Go
268 lines
9.3 KiB
Go
package main
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// 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"
|
||
|
||
// 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
|
||
}
|
||
|
||
// NewApplication 建立新的應用程式註冊:先驗證內容,再產生全域唯一的
|
||
// client_id;機密式 Client 另產生 client secret,明文僅經回傳值交付一
|
||
// 次,呼叫方應立即提供給應用程式管理者,不得儲存明文。grantTypes 為
|
||
// 空時預設僅 authorization_code;scope 為空時預設「openid profile email」。
|
||
func NewApplication(name string, typ ClientType, redirectURIs []string, grantTypes []GrantType, scope string) (*Application, string, error) {
|
||
a := &Application{
|
||
Name: strings.TrimSpace(name),
|
||
Type: typ,
|
||
RedirectURIs: append(RedirectURIs{}, redirectURIs...), // 保證非 nil,序列化為 [] 而非 null
|
||
GrantTypes: grantTypes,
|
||
Scope: strings.TrimSpace(scope),
|
||
}
|
||
if len(a.GrantTypes) == 0 {
|
||
a.GrantTypes = GrantTypes{GrantAuthorizationCode}
|
||
}
|
||
if a.Scope == "" {
|
||
a.Scope = defaultScope
|
||
}
|
||
if err := a.Validate(); err != nil {
|
||
return nil, "", err
|
||
}
|
||
id, err := newRandomToken(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
|
||
}
|
||
|
||
// 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 := newRandomToken(32)
|
||
if err != nil {
|
||
return "", fmt.Errorf("generate client secret: %w", err)
|
||
}
|
||
hash, err := 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 := 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
|
||
}
|
||
|
||
// getApplicationByClientID 以 client_id 查詢應用程式,供 /authorize、
|
||
// /token 驗證 Client 身分;查無資料時回傳包裹 gorm.ErrRecordNotFound
|
||
// 的錯誤(以 errors.Is 判斷)。
|
||
func getApplicationByClientID(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
|
||
}
|