Smail/backend/services/credentials/client.go
smail 8899e74a66
Some checks are pending
Security Checks / security (push) Waiting to run
修复邮件授权码撤销后旧协议连接仍可操作并补充回归验收
2026-09-21 01:57:23 +08:00

390 lines
12 KiB
Go
Raw Permalink 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 credentials
import (
"errors"
"fmt"
"strings"
"time"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/credential"
"git.pcux.cn/xuans/Smail/utils/password"
)
var (
ErrClientNameRequired = errors.New("client name is required")
ErrClientNotFound = errors.New("client credential not found")
)
// ClientCredentialItem 表示返回给前端的客户端授权码视图
type ClientCredentialItem struct {
ID int `json:"id"`
ClientName string `json:"client_name"`
Configured bool `json:"configured"`
CanReveal bool `json:"can_reveal"`
CodeVersion int `json:"code_version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}
// CleanAuthCode 统一授权码格式:去除短横线、空格并转大写,提升各类客户端容错率
func CleanAuthCode(raw string) string {
raw = strings.ToUpper(strings.TrimSpace(raw))
raw = strings.ReplaceAll(raw, "-", "")
raw = strings.ReplaceAll(raw, " ", "")
return raw
}
// FormatAuthCode 将清洗后的授权码格式化为 4 字符一组带横线的易读格式
func FormatAuthCode(raw string) string {
cleaned := CleanAuthCode(raw)
if len(cleaned) == 0 {
return ""
}
var groups []string
for len(cleaned) > 4 {
groups = append(groups, cleaned[:4])
cleaned = cleaned[4:]
}
if len(cleaned) > 0 {
groups = append(groups, cleaned)
}
return strings.Join(groups, "-")
}
// ListClientCredentials 返回指定用户的所有有效(未撤销)客户端授权凭证
func (s *Service) ListClientCredentials(userID int) ([]ClientCredentialItem, error) {
if s == nil || s.engine == nil {
return nil, ErrUnavailable
}
if userID <= 0 {
return nil, ErrInvalidUser
}
var items []models.ClientCredential
if err := s.engine.Where("user_id = ? AND revoked_at IS NULL", userID).OrderBy("id ASC").Find(&items); err != nil {
return nil, fmt.Errorf("load client credentials: %w", err)
}
result := make([]ClientCredentialItem, 0, len(items))
for _, item := range items {
result = append(result, ClientCredentialItem{
ID: item.ID,
ClientName: item.ClientName,
Configured: item.CodeHash != "",
CanReveal: item.CodeCiphertext != "",
CodeVersion: item.CodeVersion,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
LastUsedAt: item.LastUsedAt,
})
}
return result, nil
}
// CreateClientCredential 为指定客户端名称创建专属授权码
func (s *Service) CreateClientCredential(userID int, clientName string) (string, *ClientCredentialItem, error) {
if s == nil || s.engine == nil {
return "", nil, ErrUnavailable
}
if userID <= 0 {
return "", nil, ErrInvalidUser
}
clientName = strings.TrimSpace(clientName)
if clientName == "" {
return "", nil, ErrClientNameRequired
}
if len([]rune(clientName)) > 32 {
return "", nil, errors.New("client name is too long (max 32 chars)")
}
plain, err := newProtocolCode()
if err != nil {
return "", nil, err
}
normalizedCode := CleanAuthCode(plain)
ciphertext := ""
if s.vaultRoot != "" {
ciphertext, err = credential.Encrypt(s.vaultRoot, plain)
if err != nil {
return "", nil, fmt.Errorf("protect client authorization code: %w", err)
}
}
hash, err := password.Hash(normalizedCode)
if err != nil {
return "", nil, fmt.Errorf("hash client authorization code: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
var existing models.ClientCredential
has, err := s.engine.Where("user_id = ? AND client_name = ?", userID, clientName).Get(&existing)
if err != nil {
return "", nil, fmt.Errorf("check existing client credential: %w", err)
}
if has {
// 如果此前被撤销,重新启用并更新密钥;如果未被撤销则不允许重名创建
if existing.RevokedAt == nil {
return "", nil, errors.New("client name already exists")
}
version := existing.CodeVersion + 1
existing.CodeHash = hash
existing.CodeCiphertext = ciphertext
existing.CodeVersion = version
existing.RevokedAt = nil
existing.LastUsedAt = nil
existing.UpdatedAt = s.now()
if _, err := s.engine.ID(existing.ID).Cols("code_hash", "code_ciphertext", "code_version", "revoked_at", "last_used_at", "updated_at").Update(&existing); err != nil {
return "", nil, fmt.Errorf("re-activate client credential: %w", err)
}
item := &ClientCredentialItem{
ID: existing.ID,
ClientName: existing.ClientName,
Configured: true,
CanReveal: existing.CodeCiphertext != "",
CodeVersion: existing.CodeVersion,
CreatedAt: existing.CreatedAt,
UpdatedAt: existing.UpdatedAt,
}
return plain, item, nil
}
record := models.ClientCredential{
UserID: userID,
ClientName: clientName,
CodeHash: hash,
CodeCiphertext: ciphertext,
CodeVersion: 1,
CreatedAt: s.now(),
UpdatedAt: s.now(),
}
if _, err := s.engine.Insert(&record); err != nil {
return "", nil, fmt.Errorf("insert client credential: %w", err)
}
item := &ClientCredentialItem{
ID: record.ID,
ClientName: record.ClientName,
Configured: true,
CanReveal: record.CodeCiphertext != "",
CodeVersion: record.CodeVersion,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
}
return plain, item, nil
}
// RegenerateClientCredential 重新生成已有客户端专属授权码
func (s *Service) RegenerateClientCredential(userID int, clientID int) (string, error) {
if s == nil || s.engine == nil {
return "", ErrUnavailable
}
if userID <= 0 || clientID <= 0 {
return "", ErrInvalidUser
}
plain, err := newProtocolCode()
if err != nil {
return "", err
}
normalizedCode := CleanAuthCode(plain)
ciphertext := ""
if s.vaultRoot != "" {
ciphertext, err = credential.Encrypt(s.vaultRoot, plain)
if err != nil {
return "", fmt.Errorf("protect client authorization code: %w", err)
}
}
hash, err := password.Hash(normalizedCode)
if err != nil {
return "", fmt.Errorf("hash client authorization code: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
var existing models.ClientCredential
has, err := s.engine.Where("id = ? AND user_id = ? AND revoked_at IS NULL", clientID, userID).Get(&existing)
if err != nil {
return "", fmt.Errorf("load client credential: %w", err)
}
if !has {
return "", ErrClientNotFound
}
version := existing.CodeVersion + 1
existing.CodeHash = hash
existing.CodeCiphertext = ciphertext
existing.CodeVersion = version
existing.LastUsedAt = nil
existing.UpdatedAt = s.now()
if _, err := s.engine.ID(existing.ID).Cols("code_hash", "code_ciphertext", "code_version", "last_used_at", "updated_at").Update(&existing); err != nil {
return "", fmt.Errorf("update client authorization code: %w", err)
}
return plain, nil
}
// RevokeClientCredential 撤销指定客户端授权码
func (s *Service) RevokeClientCredential(userID int, clientID int) error {
if s == nil || s.engine == nil {
return ErrUnavailable
}
if userID <= 0 || clientID <= 0 {
return ErrInvalidUser
}
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
affected, err := s.engine.Where("id = ? AND user_id = ? AND revoked_at IS NULL", clientID, userID).
Cols("revoked_at", "code_hash", "code_ciphertext", "updated_at").
Update(&models.ClientCredential{RevokedAt: &now, CodeHash: "", CodeCiphertext: ""})
if err != nil {
return fmt.Errorf("revoke client credential: %w", err)
}
if affected == 0 {
return ErrClientNotFound
}
return nil
}
// RevealClientCredentials 解密当前用户所有有效客户端授权码明文,仅在密码验证后调用
func (s *Service) RevealClientCredentials(userID int) (map[int]string, error) {
if s == nil || s.engine == nil {
return nil, ErrUnavailable
}
if userID <= 0 {
return nil, ErrInvalidUser
}
s.mu.Lock()
defer s.mu.Unlock()
vaultRoot := s.vaultRoot
if vaultRoot == "" {
return make(map[int]string), nil
}
var items []models.ClientCredential
if err := s.engine.Where("user_id = ? AND revoked_at IS NULL", userID).Find(&items); err != nil {
return nil, fmt.Errorf("load client credentials for reveal: %w", err)
}
result := make(map[int]string, len(items))
for _, item := range items {
if item.CodeCiphertext == "" || item.CodeHash == "" {
continue
}
plain, err := credential.Decrypt(vaultRoot, item.CodeCiphertext)
if err != nil {
continue
}
result[item.ID] = plain
}
return result, nil
}
// VerifyClientOrProtocolCode 同时支持客户端专属授权码以及遗留的协议授权码
func (s *Service) VerifyClientOrProtocolCode(userID int, protocol string, rawPlain string) bool {
matched, _ := s.CheckClientOrProtocolCode(userID, protocol, rawPlain)
return matched
}
// CheckClientOrProtocolCode 将计算过载向上返回,避免被当成密码错误累计封禁。
func (s *Service) CheckClientOrProtocolCode(userID int, protocol string, rawPlain string) (bool, error) {
grant, err := s.CheckClientOrProtocolGrant(userID, protocol, rawPlain)
return grant != nil, err
}
// CheckClientOrProtocolGrant 返回本次实际匹配的凭据快照,不保存明文授权码。
func (s *Service) CheckClientOrProtocolGrant(userID int, protocol string, rawPlain string) (*ProtocolGrant, error) {
if s == nil || s.engine == nil || userID <= 0 || rawPlain == "" || len([]byte(rawPlain)) > 256 {
return nil, password.CheckDummy(rawPlain)
}
cleanedPlain := CleanAuthCode(rawPlain)
if cleanedPlain == "" {
return nil, password.CheckDummy(rawPlain)
}
protocol, err := NormalizeProtocol(protocol)
if err != nil {
return nil, password.CheckDummy(rawPlain)
}
// 1. 优先校验客户端专属授权码
var clients []models.ClientCredential
err = s.engine.Where("user_id = ? AND revoked_at IS NULL AND code_hash != ''", userID).Find(&clients)
if err == nil && len(clients) > 0 {
for _, client := range clients {
// 比对清洗后的统一大写无横线授权码
matched, err := password.Check(client.CodeHash, cleanedPlain)
if err != nil {
return nil, err
}
if matched {
grant := &ProtocolGrant{engine: s.engine, userID: userID, protocol: protocol, client: true, id: client.ID, version: client.CodeVersion, hash: client.CodeHash}
now := s.now()
affected, err := s.engine.ID(client.ID).
Where("revoked_at IS NULL AND code_version = ? AND code_hash = ?", client.CodeVersion, client.CodeHash).
Cols("last_used_at", "updated_at").Update(&models.ClientCredential{LastUsedAt: &now})
if err != nil || affected == 1 {
if err != nil {
return nil, err
}
return grant, nil
}
valid, err := s.engine.ID(client.ID).
Where("revoked_at IS NULL AND code_version = ? AND code_hash = ?", client.CodeVersion, client.CodeHash).
Exist(&models.ClientCredential{})
if err != nil || !valid {
return nil, err
}
return grant, nil
}
}
}
// 2. 回退校验旧版按协议划分的授权码(支持 cleanedPlain 或原樣 rawPlain)
protocol, err = NormalizeProtocol(protocol)
if err != nil {
return nil, password.CheckDummy(rawPlain)
}
var item models.ProtocolCredential
has, err := s.engine.Where("user_id = ? AND protocol = ? AND enabled = ? AND revoked_at IS NULL", userID, protocol, true).Get(&item)
if err != nil || !has || item.CodeHash == "" {
return nil, password.CheckDummy(rawPlain)
}
// 兼容旧哈希直接比对原始输入,或者比对清洗后的输入
trimmedPlain := strings.TrimSpace(rawPlain)
matched, err := password.Check(item.CodeHash, trimmedPlain)
if err != nil {
return nil, err
}
if !matched && trimmedPlain != cleanedPlain {
matched, err = password.Check(item.CodeHash, cleanedPlain)
}
if err != nil || !matched {
return nil, err
}
grant := &ProtocolGrant{engine: s.engine, userID: userID, protocol: protocol, id: item.ID, version: item.CodeVersion, hash: item.CodeHash}
now := s.now()
affected, err := s.engine.ID(item.ID).
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Cols("last_used_at", "updated_at").Update(&models.ProtocolCredential{LastUsedAt: &now})
if err == nil && affected == 1 {
return grant, nil
}
if err != nil {
return nil, err
}
valid, err := s.engine.ID(item.ID).
Where("enabled = ? AND revoked_at IS NULL AND code_version = ? AND code_hash = ?", true, item.CodeVersion, item.CodeHash).
Exist(&models.ProtocolCredential{})
if err != nil || !valid {
return nil, err
}
return grant, nil
}