@@ -0,0 +1,308 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"oci-portal/internal/model"
|
||||
)
|
||||
|
||||
// 钱包(SIWE)流程错误;api 层映射为用户可读提示。
|
||||
var (
|
||||
// ErrWalletNoAppURL 表示面板地址缺失,EIP-4361 的 domain/URI 无从派生。
|
||||
ErrWalletNoAppURL = errors.New("面板地址未设置,请先在「设置 → 安全 → 网络与地址」保存面板地址")
|
||||
// ErrWalletAddress 表示地址格式非法(非 0x+40 hex)。
|
||||
ErrWalletAddress = errors.New("钱包地址格式不正确")
|
||||
// ErrWalletChallenge 表示挑战无效或已过期(一次性消费)。
|
||||
ErrWalletChallenge = errors.New("签名挑战无效或已过期,请重新发起")
|
||||
// ErrWalletSig 表示签名校验失败或恢复地址不匹配;统一文案防探测。
|
||||
ErrWalletSig = errors.New("钱包签名校验失败")
|
||||
// ErrWalletNotBound 表示地址未绑定任何账号,拒绝登录(不开放注册)。
|
||||
ErrWalletNotBound = errors.New("该钱包地址未绑定面板账号,请先登录后在设置中绑定")
|
||||
// ErrWalletBound 表示地址已被绑定(重复绑定)。
|
||||
ErrWalletBound = errors.New("该钱包地址已绑定过")
|
||||
)
|
||||
|
||||
// walletProvider 是钱包身份在 UserIdentity 表中的 provider 取值。
|
||||
const walletProvider = "wallet"
|
||||
|
||||
// walletPendingTTL 是签名挑战有效期,与消息中的 Expiration Time 一致。
|
||||
const walletPendingTTL = 5 * time.Minute
|
||||
|
||||
// walletPending 是一次进行中的签名挑战;nonce 一次性使用。
|
||||
type walletPending struct {
|
||||
message string // EIP-4361 消息全文,验签对象
|
||||
address string // EIP-55 规范地址
|
||||
mode string // "login" / "bind"
|
||||
username string // bind 模式的绑定目标账号
|
||||
token string // bind 模式发起时的 Bearer;verify 复验,防被盗令牌撤销后仍完成绑定
|
||||
proof TokenProof // 发起时的版本/jti 快照;绑定事务行锁下复核,覆盖撤销全部/注销/定点撤销
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// WalletService 承接以太坊钱包(EIP-4361)绑定与登录;身份复用 UserIdentity 通道。
|
||||
type WalletService struct {
|
||||
db *gorm.DB
|
||||
settings *SettingService
|
||||
auth *AuthService
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]walletPending
|
||||
}
|
||||
|
||||
// NewWalletService 组装依赖。
|
||||
func NewWalletService(db *gorm.DB, settings *SettingService, auth *AuthService) *WalletService {
|
||||
return &WalletService{db: db, settings: settings, auth: auth, pending: map[string]walletPending{}}
|
||||
}
|
||||
|
||||
// Challenge 生成 EIP-4361 消息全文并登记一次性 nonce;
|
||||
// 消息由后端持有并作为最终验签对象,前端仅负责原样签名。
|
||||
func (w *WalletService) Challenge(ctx context.Context, address, mode, username, bindToken string) (nonce, message string, err error) {
|
||||
app := w.settings.EffectiveAppURL()
|
||||
if app == "" {
|
||||
return "", "", ErrWalletNoAppURL
|
||||
}
|
||||
addr, err := normalizeEthAddress(address)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var proof TokenProof
|
||||
if mode == "bind" {
|
||||
if proof, err = w.bindTokenProof(ctx, username, bindToken); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
nonce, err = randHex(16)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
message = buildSiweMessage(app, addr, mode, nonce, time.Now())
|
||||
w.mu.Lock()
|
||||
w.gcWalletLocked()
|
||||
w.pending[nonce] = walletPending{message: message, address: addr, mode: mode, username: username, token: bindToken, proof: proof, expires: time.Now().Add(walletPendingTTL)}
|
||||
w.mu.Unlock()
|
||||
return nonce, message, nil
|
||||
}
|
||||
|
||||
// bindTokenProof 验证 bind 发起令牌的有效性与归属,返回其版本 / jti 快照;
|
||||
// 绑定事务行锁下复核该快照,发起后改密、撤销全部、注销或定点撤销均令绑定作废。
|
||||
func (w *WalletService) bindTokenProof(ctx context.Context, username, token string) (TokenProof, error) {
|
||||
name, proof, err := w.auth.ParseTokenProof(ctx, token)
|
||||
if err != nil || name != username {
|
||||
return TokenProof{}, ErrWalletChallenge
|
||||
}
|
||||
return proof, nil
|
||||
}
|
||||
|
||||
// buildSiweMessage 按 EIP-4361 模板拼装消息;Chain ID 固定 1
|
||||
// (personal_sign 与链无关,字段仅为满足标准的人类可读格式)。
|
||||
func buildSiweMessage(appURL, address, mode, nonce string, now time.Time) string {
|
||||
domain := appURL
|
||||
if u, err := url.Parse(appURL); err == nil && u.Host != "" {
|
||||
domain = u.Host
|
||||
}
|
||||
statement := "登录 OCI Portal 面板"
|
||||
if mode == "bind" {
|
||||
statement = "将此钱包绑定到 OCI Portal 账号"
|
||||
}
|
||||
return domain + " wants you to sign in with your Ethereum account:\n" +
|
||||
address + "\n\n" +
|
||||
statement + "\n\n" +
|
||||
"URI: " + appURL + "\n" +
|
||||
"Version: 1\n" +
|
||||
"Chain ID: 1\n" +
|
||||
"Nonce: " + nonce + "\n" +
|
||||
"Issued At: " + now.UTC().Format(time.RFC3339) + "\n" +
|
||||
"Expiration Time: " + now.Add(walletPendingTTL).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// gcWalletLocked 清理过期挑战;调用方须持锁。
|
||||
func (w *WalletService) gcWalletLocked() {
|
||||
now := time.Now()
|
||||
for k, p := range w.pending {
|
||||
if now.After(p.expires) {
|
||||
delete(w.pending, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// takeChallenge 取出并消费挑战(一次性);不存在或过期视为无效。
|
||||
func (w *WalletService) takeChallenge(nonce string) (walletPending, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
p, ok := w.pending[nonce]
|
||||
delete(w.pending, nonce)
|
||||
if !ok || time.Now().After(p.expires) {
|
||||
return walletPending{}, ErrWalletChallenge
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// WalletVerifyResult 是校验成功后的会话信息;Mode 在失败时也尽力携带,
|
||||
// 供 api 层留痕与决定响应形态。
|
||||
type WalletVerifyResult struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
Username string
|
||||
Mode string
|
||||
}
|
||||
|
||||
// Verify 校验签名并完成绑定或登录(落地会话);登录失败按「IP+地址」计入登录守卫,
|
||||
// 锁定期内一律 ErrLoginLocked。
|
||||
func (w *WalletService) Verify(ctx context.Context, nonce, signature string, meta SessionMeta) (WalletVerifyResult, error) {
|
||||
p, err := w.takeChallenge(nonce)
|
||||
if err != nil {
|
||||
return WalletVerifyResult{}, err
|
||||
}
|
||||
res := WalletVerifyResult{Mode: p.mode}
|
||||
if p.mode == "login" {
|
||||
meta.Method = walletProvider // 绑定不改登录方式,见 OAuth 侧同款注释
|
||||
}
|
||||
key := guardKey(meta.ClientIP, strings.ToLower(p.address))
|
||||
now := time.Now()
|
||||
sec := securityOf(w.auth.settings)
|
||||
if p.mode == "login" && w.auth.guard.locked(key, now, time.Duration(sec.LoginLockMinutes)*time.Minute) {
|
||||
return res, ErrLoginLocked
|
||||
}
|
||||
if err := verifyWalletSig(p, signature); err != nil {
|
||||
if p.mode == "login" {
|
||||
if lockErr := w.auth.failLogin(key, now, p.address, meta.ClientIP, sec); errors.Is(lockErr, ErrLoginLocked) {
|
||||
return res, ErrLoginLocked
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
if p.mode == "bind" {
|
||||
// 复验发起挑战时的令牌仍有效且归属一致:改密/撤销全部后挑战随之作废
|
||||
if name, tokenErr := w.auth.ParseToken(ctx, p.token); tokenErr != nil || name != p.username {
|
||||
return res, ErrWalletChallenge
|
||||
}
|
||||
res.Username = p.username
|
||||
res.Token, res.ExpiresAt, err = w.bind(ctx, p, meta)
|
||||
return res, err
|
||||
}
|
||||
res.Token, res.ExpiresAt, res.Username, err = w.login(ctx, p, key, meta)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// verifyWalletSig 验签并比对恢复地址(大小写不敏感);失败统一 ErrWalletSig。
|
||||
func verifyWalletSig(p walletPending, signature string) error {
|
||||
raw, err := hex.DecodeString(strings.TrimPrefix(signature, "0x"))
|
||||
if err != nil {
|
||||
return ErrWalletSig
|
||||
}
|
||||
recovered, err := recoverEthAddress(personalSignDigest([]byte(p.message)), raw)
|
||||
if err != nil {
|
||||
return ErrWalletSig
|
||||
}
|
||||
if !strings.EqualFold(recovered, p.address) {
|
||||
return ErrWalletSig
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bind 在单事务内完成绑定全程:行锁下比对发起时令牌版本(发起后被撤销即作废)、
|
||||
// 身份写入、版本递增、原会话行接续换发;任一失败整体回滚,不留半程状态。
|
||||
// 新令牌接续 p.token 的会话行(保留登录方式与创建时间),旧令牌无行时按 meta 新建。
|
||||
func (w *WalletService) bind(ctx context.Context, p walletPending, meta SessionMeta) (string, time.Time, error) {
|
||||
var token string
|
||||
var expires time.Time
|
||||
err := w.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
user, err := lockUserForAuthChange(tx, p.username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.auth.ensureTokenCurrentTx(tx, user, p.proof); err != nil {
|
||||
return ErrWalletChallenge
|
||||
}
|
||||
if err := createIdentityTx(tx, user.ID, walletProvider, p.address, shortEthAddress(p.address), ErrWalletBound); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bumpTokenVersionTx(tx, p.username); err != nil {
|
||||
return err
|
||||
}
|
||||
user.TokenVersion++
|
||||
token, expires, err = w.auth.renewSessionTx(tx, user, p.token, meta)
|
||||
return err
|
||||
})
|
||||
return token, expires, err
|
||||
}
|
||||
|
||||
// login 查绑定关系并签发 JWT(落地会话);未绑定一律拒绝(不开放注册)。
|
||||
func (w *WalletService) login(ctx context.Context, p walletPending, key string, meta SessionMeta) (string, time.Time, string, error) {
|
||||
row, err := w.findWalletIdentity(ctx, p.address)
|
||||
if err != nil {
|
||||
return "", time.Time{}, "", err
|
||||
}
|
||||
token, expires, username, err := w.loginWalletIdentity(ctx, row, p.address, meta)
|
||||
if err == nil {
|
||||
w.auth.guard.success(key)
|
||||
}
|
||||
return token, expires, username, err
|
||||
}
|
||||
|
||||
func (w *WalletService) loginWalletIdentity(
|
||||
ctx context.Context, row *model.UserIdentity, address string, meta SessionMeta,
|
||||
) (string, time.Time, string, error) {
|
||||
var token, username string
|
||||
var expires time.Time
|
||||
err := w.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
user, err := lockUserByIDForAuthChange(tx, row.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := identityStillBoundTx(tx, row.ID, user.ID, walletProvider, address); err != nil {
|
||||
if errors.Is(err, ErrOAuthNotBound) {
|
||||
return ErrWalletNotBound
|
||||
}
|
||||
return err
|
||||
}
|
||||
token, expires, err = w.auth.signSessionTokenTx(tx, user, meta)
|
||||
username = user.Username
|
||||
return err
|
||||
})
|
||||
return token, expires, username, err
|
||||
}
|
||||
|
||||
func (w *WalletService) findWalletIdentity(ctx context.Context, address string) (*model.UserIdentity, error) {
|
||||
var row model.UserIdentity
|
||||
err := w.db.WithContext(ctx).
|
||||
Where("provider = ? AND subject = ?", walletProvider, address).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrWalletNotBound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find wallet identity: %w", err)
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// HasAny 报告是否存在可登录的钱包身份;SIWE 依赖面板地址,缺失时不暴露入口。
|
||||
func (w *WalletService) HasAny(ctx context.Context) bool {
|
||||
if w.settings == nil || w.settings.EffectiveAppURL() == "" {
|
||||
return false
|
||||
}
|
||||
var count int64
|
||||
err := w.db.WithContext(ctx).Model(&model.UserIdentity{}).
|
||||
Where("provider = ?", walletProvider).Count(&count).Error
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// shortEthAddress 生成列表展示用的缩写地址(0x1234…abcd)。
|
||||
func shortEthAddress(addr string) string {
|
||||
if len(addr) < 12 {
|
||||
return addr
|
||||
}
|
||||
return addr[:6] + "…" + addr[len(addr)-4:]
|
||||
}
|
||||
Reference in New Issue
Block a user