初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
githubep "golang.org/x/oauth2/github"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
)
|
||||
|
||||
// OAuth 流程错误;api 层映射为用户可读的回跳提示。
|
||||
var (
|
||||
// ErrOAuthNotConfigured 表示 provider 未配置(clientID 缺失)。
|
||||
ErrOAuthNotConfigured = errors.New("该登录方式未配置")
|
||||
// ErrOAuthNoAppURL 表示面板地址缺失,回调 URL 无从拼接。
|
||||
ErrOAuthNoAppURL = errors.New("面板地址未设置,请先在「设置 → 安全 → 网络与地址」保存面板地址")
|
||||
// ErrOAuthDisabled 表示 provider 已被禁用,登录入口不可用(绑定不受影响)。
|
||||
ErrOAuthDisabled = errors.New("该登录方式已禁用")
|
||||
// ErrOAuthState 表示 state 无效或已过期(CSRF 防护)。
|
||||
ErrOAuthState = errors.New("授权状态无效或已过期,请重新发起")
|
||||
// ErrOAuthNotBound 表示外部身份未绑定任何账号,拒绝登录。
|
||||
ErrOAuthNotBound = errors.New("该外部身份未绑定面板账号,请先登录后在设置中绑定")
|
||||
// ErrOAuthBound 表示身份已被绑定(重复绑定)。
|
||||
ErrOAuthBound = errors.New("该外部身份已绑定过")
|
||||
)
|
||||
|
||||
// oauthPendingTTL 是授权流程 state 的有效期。
|
||||
const oauthPendingTTL = 10 * time.Minute
|
||||
|
||||
// oauthPending 是一次进行中的授权流程上下文;state 一次性使用。
|
||||
type oauthPending struct {
|
||||
provider string
|
||||
mode string // "login" / "bind"
|
||||
username string // bind 模式的绑定目标账号
|
||||
nonce string // OIDC 防 id_token 重放
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// OAuthService 承接外部身份登录与绑定(OIDC / GitHub,探索文档主题二)。
|
||||
type OAuthService struct {
|
||||
db *gorm.DB
|
||||
settings *SettingService
|
||||
auth *AuthService
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]oauthPending
|
||||
}
|
||||
|
||||
// NewOAuthService 组装依赖。
|
||||
func NewOAuthService(db *gorm.DB, settings *SettingService, auth *AuthService) *OAuthService {
|
||||
return &OAuthService{db: db, settings: settings, auth: auth, pending: map[string]oauthPending{}}
|
||||
}
|
||||
|
||||
// ProviderInfo 是登录页公开的 provider 信息(displayName 空配置时为默认名)。
|
||||
type ProviderInfo struct {
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
|
||||
// Providers 返回可登录的 provider 列表(clientID 非空且未禁用),登录页据此显示按钮。
|
||||
func (o *OAuthService) Providers(ctx context.Context) []ProviderInfo {
|
||||
out := []ProviderInfo{}
|
||||
for _, p := range []string{"oidc", "github"} {
|
||||
id, _, _, err := o.settings.oauthClient(ctx, p)
|
||||
if err != nil || id == "" {
|
||||
continue
|
||||
}
|
||||
display, disabled, err := o.settings.oauthProviderMeta(ctx, p)
|
||||
if err != nil || disabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, ProviderInfo{Provider: p, DisplayName: display})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// randHex 生成 n 字节随机数的 hex 编码。
|
||||
func randHex(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("random: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// callbackURL 是 provider 回调地址,固定拼自面板地址(白名单即此一条)。
|
||||
func (o *OAuthService) callbackURL(provider string) string {
|
||||
return o.settings.EffectiveAppURL() + "/api/v1/auth/oauth/" + provider + "/callback"
|
||||
}
|
||||
|
||||
// oauth2Config 构造 provider 的 oauth2 配置;oidc 时一并返回已发现的 provider。
|
||||
func (o *OAuthService) oauth2Config(ctx context.Context, provider string) (*oauth2.Config, *oidc.Provider, error) {
|
||||
clientID, secret, issuer, err := o.settings.oauthClient(ctx, provider)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if clientID == "" {
|
||||
return nil, nil, ErrOAuthNotConfigured
|
||||
}
|
||||
if o.settings.EffectiveAppURL() == "" {
|
||||
return nil, nil, ErrOAuthNoAppURL
|
||||
}
|
||||
cfg := &oauth2.Config{ClientID: clientID, ClientSecret: secret, RedirectURL: o.callbackURL(provider)}
|
||||
if provider == "github" {
|
||||
cfg.Endpoint = githubep.Endpoint
|
||||
cfg.Scopes = []string{"read:user"}
|
||||
return cfg, nil, nil
|
||||
}
|
||||
op, err := oidc.NewProvider(ctx, issuer)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("oidc discovery: %w", err)
|
||||
}
|
||||
cfg.Endpoint = op.Endpoint()
|
||||
cfg.Scopes = []string{oidc.ScopeOpenID, "email", "profile"}
|
||||
return cfg, op, nil
|
||||
}
|
||||
|
||||
// AuthorizeURL 构造授权跳转 URL 并登记一次性 state;mode 为 bind 时 username 必填。
|
||||
// login 模式拒绝已禁用的 provider;bind 模式不受禁用影响(管理员仍可绑定)。
|
||||
func (o *OAuthService) AuthorizeURL(ctx context.Context, provider, mode, username string) (string, error) {
|
||||
if mode == "login" {
|
||||
if _, disabled, err := o.settings.oauthProviderMeta(ctx, provider); err == nil && disabled {
|
||||
return "", ErrOAuthDisabled
|
||||
}
|
||||
}
|
||||
cfg, _, err := o.oauth2Config(ctx, provider)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
state, err := randHex(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce, err := randHex(16)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
o.mu.Lock()
|
||||
o.gcPendingLocked()
|
||||
o.pending[state] = oauthPending{provider: provider, mode: mode, username: username, nonce: nonce, expires: time.Now().Add(oauthPendingTTL)}
|
||||
o.mu.Unlock()
|
||||
opts := []oauth2.AuthCodeOption{}
|
||||
if provider == "oidc" {
|
||||
opts = append(opts, oidc.Nonce(nonce))
|
||||
}
|
||||
return cfg.AuthCodeURL(state, opts...), nil
|
||||
}
|
||||
|
||||
// gcPendingLocked 清理过期流程;调用方须持锁。
|
||||
func (o *OAuthService) gcPendingLocked() {
|
||||
now := time.Now()
|
||||
for k, p := range o.pending {
|
||||
if now.After(p.expires) {
|
||||
delete(o.pending, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// takeState 取出并消费 state(一次性);provider 不匹配或过期视为无效。
|
||||
func (o *OAuthService) takeState(provider, state string) (oauthPending, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
p, ok := o.pending[state]
|
||||
delete(o.pending, state)
|
||||
if !ok || p.provider != provider || time.Now().After(p.expires) {
|
||||
return oauthPending{}, ErrOAuthState
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// externalIdentity 是从 provider 换回的外部身份。
|
||||
type externalIdentity struct {
|
||||
Subject string
|
||||
Display string
|
||||
}
|
||||
|
||||
// HandleCallback 完成授权码回调:换取身份后,bind 模式写绑定、login 模式签发 JWT;
|
||||
// token 仅 login 模式非空;mode 尽力返回(state 无效时为空),供 api 决定错误回跳页面。
|
||||
func (o *OAuthService) HandleCallback(ctx context.Context, provider, state, code string) (token, display, mode string, err error) {
|
||||
p, err := o.takeState(provider, state)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
ident, err := o.fetchIdentity(ctx, provider, code, p.nonce)
|
||||
if err != nil {
|
||||
return "", "", p.mode, err
|
||||
}
|
||||
if p.mode == "bind" {
|
||||
return "", ident.Display, p.mode, o.bind(ctx, p.username, provider, ident)
|
||||
}
|
||||
token, display, err = o.loginByIdentity(ctx, provider, ident)
|
||||
return token, display, p.mode, err
|
||||
}
|
||||
|
||||
// fetchIdentity 用授权码向 provider 换取稳定 subject 与展示名。
|
||||
func (o *OAuthService) fetchIdentity(ctx context.Context, provider, code, nonce string) (externalIdentity, error) {
|
||||
cfg, op, err := o.oauth2Config(ctx, provider)
|
||||
if err != nil {
|
||||
return externalIdentity{}, err
|
||||
}
|
||||
tok, err := cfg.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return externalIdentity{}, fmt.Errorf("exchange code: %w", err)
|
||||
}
|
||||
if provider == "github" {
|
||||
return githubIdentity(ctx, cfg, tok)
|
||||
}
|
||||
return oidcIdentity(ctx, cfg, op, tok, nonce)
|
||||
}
|
||||
|
||||
// githubIdentity 调 GitHub /user 拿数字 id(login 可改名,不可作 subject)。
|
||||
func githubIdentity(ctx context.Context, cfg *oauth2.Config, tok *oauth2.Token) (externalIdentity, error) {
|
||||
client := cfg.Client(ctx, tok)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil)
|
||||
if err != nil {
|
||||
return externalIdentity{}, fmt.Errorf("github user request: %w", err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return externalIdentity{}, fmt.Errorf("github user: %w", sanitizeURLError(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return externalIdentity{}, fmt.Errorf("github user: status %d", resp.StatusCode)
|
||||
}
|
||||
var u struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&u); err != nil || u.ID == 0 {
|
||||
return externalIdentity{}, fmt.Errorf("github user: invalid response")
|
||||
}
|
||||
return externalIdentity{Subject: fmt.Sprintf("%d", u.ID), Display: u.Login}, nil
|
||||
}
|
||||
|
||||
// oidcIdentity 验证 id_token 签名 / audience / nonce,取 sub 作 subject。
|
||||
func oidcIdentity(ctx context.Context, cfg *oauth2.Config, op *oidc.Provider, tok *oauth2.Token, nonce string) (externalIdentity, error) {
|
||||
raw, ok := tok.Extra("id_token").(string)
|
||||
if !ok || raw == "" {
|
||||
return externalIdentity{}, fmt.Errorf("oidc: id_token missing")
|
||||
}
|
||||
idToken, err := op.Verifier(&oidc.Config{ClientID: cfg.ClientID}).Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return externalIdentity{}, fmt.Errorf("oidc verify: %w", err)
|
||||
}
|
||||
if idToken.Nonce != nonce {
|
||||
return externalIdentity{}, fmt.Errorf("oidc: nonce mismatch")
|
||||
}
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
_ = idToken.Claims(&claims)
|
||||
display := claims.Email
|
||||
if display == "" {
|
||||
display = idToken.Subject
|
||||
}
|
||||
return externalIdentity{Subject: idToken.Subject, Display: display}, nil
|
||||
}
|
||||
|
||||
// bind 把外部身份绑定到账号;(provider, subject) 唯一,重复绑定报错。
|
||||
func (o *OAuthService) bind(ctx context.Context, username, provider string, ident externalIdentity) error {
|
||||
user, err := o.auth.findUser(ctx, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
err = o.db.WithContext(ctx).Model(&model.UserIdentity{}).
|
||||
Where("provider = ? AND subject = ?", provider, ident.Subject).Count(&count).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("check identity: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return ErrOAuthBound
|
||||
}
|
||||
row := model.UserIdentity{UserID: user.ID, Provider: provider, Subject: ident.Subject, Display: ident.Display}
|
||||
if err := o.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
return fmt.Errorf("bind identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginByIdentity 查绑定关系并签发面板 JWT;未绑定一律拒绝(不开放注册)。
|
||||
func (o *OAuthService) loginByIdentity(ctx context.Context, provider string, ident externalIdentity) (string, string, error) {
|
||||
var row model.UserIdentity
|
||||
err := o.db.WithContext(ctx).
|
||||
Where("provider = ? AND subject = ?", provider, ident.Subject).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", "", ErrOAuthNotBound
|
||||
}
|
||||
return "", "", fmt.Errorf("find identity: %w", err)
|
||||
}
|
||||
var user model.User
|
||||
if err := o.db.WithContext(ctx).First(&user, row.UserID).Error; err != nil {
|
||||
return "", "", fmt.Errorf("find bound user: %w", err)
|
||||
}
|
||||
token, _, err := o.auth.signToken(user.Username)
|
||||
return token, ident.Display, err
|
||||
}
|
||||
|
||||
// Identities 列出账号已绑定的外部身份。
|
||||
func (o *OAuthService) Identities(ctx context.Context, username string) ([]model.UserIdentity, error) {
|
||||
user, err := o.auth.findUser(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []model.UserIdentity{}
|
||||
err = o.db.WithContext(ctx).Where("user_id = ?", user.ID).Order("id").Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list identities: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Unbind 解绑外部身份(校验归属);密码登录被禁用时不允许解绑最后一个身份,防自锁。
|
||||
func (o *OAuthService) Unbind(ctx context.Context, username string, id uint) error {
|
||||
user, err := o.auth.findUser(ctx, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := o.auth.identityCount(ctx, user.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
if off, err := o.auth.PasswordLoginDisabled(ctx); err == nil && off {
|
||||
return ErrLastIdentity
|
||||
}
|
||||
}
|
||||
res := o.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserIdentity{})
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("unbind identity: %w", res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user