Smail/backend/services/credentials/client.go
smail b4f5f51c0e
Some checks are pending
Security Checks / security (push) Waiting to run
保存认证容量保护整改检查点待远程复审
2026-09-17 12:00:45 +08:00

369 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 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) {
if s == nil || s.engine == nil || userID <= 0 || rawPlain == "" || len([]byte(rawPlain)) > 256 {
return false, password.CheckDummy(rawPlain)
}
cleanedPlain := CleanAuthCode(rawPlain)
if cleanedPlain == "" {
return false, 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 false, err
}
if matched {
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 {
return err == nil, err
}
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{})
return valid, err
}
}
}
// 2. 回退校验旧版按协议划分的授权码(支持 cleanedPlain 或原樣 rawPlain)
protocol, err = NormalizeProtocol(protocol)
if err != nil {
return false, 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 false, password.CheckDummy(rawPlain)
}
// 兼容旧哈希直接比对原始输入,或者比对清洗后的输入
trimmedPlain := strings.TrimSpace(rawPlain)
matched, err := password.Check(item.CodeHash, trimmedPlain)
if err != nil {
return false, err
}
if !matched && trimmedPlain != cleanedPlain {
matched, err = password.Check(item.CodeHash, cleanedPlain)
}
if err != nil || !matched {
return false, err
}
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 true, nil
}
if err != nil {
return false, 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{})
return valid, err
}