Smail/backend/services/credentials/protocol.go
smail 20a6eea3b3
Some checks are pending
Security Checks / security (push) Waiting to run
补齐邮件协议隔离回归并优化客户端设置
2026-09-06 22:28:20 +08:00

240 lines
7.4 KiB
Go

package credentials
import (
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/password"
)
const (
ProtocolIMAP = "imap"
ProtocolPOP3 = "pop3"
ProtocolSMTP = "smtp"
)
var (
ErrInvalidProtocol = errors.New("invalid mail protocol")
ErrProtocolDisabled = errors.New("mail protocol is disabled")
ErrProtocolCodeMissing = errors.New("protocol authorization code is missing")
)
var supportedProtocols = []string{ProtocolIMAP, ProtocolPOP3, ProtocolSMTP}
// ProtocolCredentialStatus 返回协议开关和授权码状态,不包含授权码哈希。
type ProtocolCredentialStatus struct {
Protocol string `json:"protocol"`
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}
func NormalizeProtocol(value string) (string, error) {
protocol := strings.ToLower(strings.TrimSpace(value))
switch protocol {
case ProtocolIMAP, ProtocolPOP3, ProtocolSMTP:
return protocol, nil
default:
return "", ErrInvalidProtocol
}
}
func SupportedProtocols() []string {
return append([]string(nil), supportedProtocols...)
}
// ListProtocolCredentials 返回当前用户三个邮件协议的完整状态。
func (s *Service) ListProtocolCredentials(userID int) ([]ProtocolCredentialStatus, error) {
if s == nil || s.engine == nil {
return nil, ErrUnavailable
}
if userID <= 0 {
return nil, ErrInvalidUser
}
var items []models.ProtocolCredential
if err := s.engine.Where("user_id = ?", userID).Find(&items); err != nil {
return nil, fmt.Errorf("load protocol credentials: %w", err)
}
byProtocol := make(map[string]models.ProtocolCredential, len(items))
for _, item := range items {
byProtocol[item.Protocol] = item
}
result := make([]ProtocolCredentialStatus, 0, len(supportedProtocols))
for _, protocol := range supportedProtocols {
status := ProtocolCredentialStatus{Protocol: protocol}
if item, ok := byProtocol[protocol]; ok {
createdAt := item.CreatedAt
updatedAt := item.UpdatedAt
status.Enabled = item.Enabled
status.Configured = item.CodeHash != "" && item.RevokedAt == nil
status.CreatedAt = &createdAt
status.UpdatedAt = &updatedAt
status.LastUsedAt = item.LastUsedAt
}
result = append(result, status)
}
return result, nil
}
// SetProtocolEnabled 设置用户级协议开关。关闭后授权码会保留,但认证立即失效。
func (s *Service) SetProtocolEnabled(userID int, protocol string, enabled bool) error {
if s == nil || s.engine == nil {
return ErrUnavailable
}
if userID <= 0 {
return ErrInvalidUser
}
protocol, err := NormalizeProtocol(protocol)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
var item models.ProtocolCredential
has, err := s.engine.Where("user_id = ? AND protocol = ?", userID, protocol).Get(&item)
if err != nil {
return fmt.Errorf("load protocol credential: %w", err)
}
if !has {
item = models.ProtocolCredential{UserID: userID, Protocol: protocol, Enabled: enabled, CodeVersion: 1}
if _, err := s.engine.Insert(&item); err != nil {
return fmt.Errorf("create protocol credential: %w", err)
}
return nil
}
item.Enabled = enabled
if _, err := s.engine.ID(item.ID).Cols("enabled", "updated_at").Update(&item); err != nil {
return fmt.Errorf("update protocol credential: %w", err)
}
return nil
}
// GenerateProtocolCode 生成只展示一次的协议授权码,并撤销同协议旧授权码。
func (s *Service) GenerateProtocolCode(userID int, protocol string) (string, error) {
if s == nil || s.engine == nil {
return "", ErrUnavailable
}
if userID <= 0 {
return "", ErrInvalidUser
}
protocol, err := NormalizeProtocol(protocol)
if err != nil {
return "", err
}
plain, err := newProtocolCode()
if err != nil {
return "", err
}
hash, err := password.Hash(plain)
if err != nil {
return "", fmt.Errorf("hash protocol authorization code: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
var item models.ProtocolCredential
has, err := s.engine.Where("user_id = ? AND protocol = ?", userID, protocol).Get(&item)
if err != nil {
return "", fmt.Errorf("load protocol credential: %w", err)
}
if !has || !item.Enabled {
return "", ErrProtocolDisabled
}
version := item.CodeVersion + 1
if version <= 1 {
version = 1
}
item.CodeHash = hash
item.CodeVersion = version
item.RevokedAt = nil
item.LastUsedAt = nil
if _, err := s.engine.ID(item.ID).Cols("code_hash", "code_version", "revoked_at", "last_used_at", "updated_at").Update(&item); err != nil {
return "", fmt.Errorf("update protocol authorization code: %w", err)
}
return plain, nil
}
// RevokeProtocolCode 撤销指定协议的授权码,协议开关状态保持不变。
func (s *Service) RevokeProtocolCode(userID int, protocol string) error {
if s == nil || s.engine == nil {
return ErrUnavailable
}
if userID <= 0 {
return ErrInvalidUser
}
protocol, err := NormalizeProtocol(protocol)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
if _, err := s.engine.Where("user_id = ? AND protocol = ? AND revoked_at IS NULL", userID, protocol).
Cols("revoked_at", "updated_at").
Update(&models.ProtocolCredential{RevokedAt: &now}); err != nil {
return fmt.Errorf("revoke protocol authorization code: %w", err)
}
return nil
}
// VerifyProtocolCode 校验协议开关和授权码,不接受网页登录密码。
func (s *Service) VerifyProtocolCode(userID int, protocol string, plain string) bool {
if s == nil || s.engine == nil || userID <= 0 || plain == "" || len([]byte(plain)) > 256 {
password.VerifyDummy(plain)
return false
}
protocol, err := NormalizeProtocol(protocol)
if err != nil {
password.VerifyDummy(plain)
return false
}
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 == "" {
password.VerifyDummy(plain)
return false
}
if !password.Verify(item.CodeHash, strings.TrimSpace(plain)) {
return false
}
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 {
return false
}
if affected == 1 {
return true
}
// MySQL 在时间字段未变化时可能报告零影响行,必须再次查询有效状态而不是误拒绝或直接放行。
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 err == nil && valid
}
func newProtocolCode() (string, error) {
buf := make([]byte, 15)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate protocol authorization code: %w", err)
}
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
groups := make([]string, 0, 6)
for len(encoded) > 4 {
groups = append(groups, encoded[:4])
encoded = encoded[4:]
}
if encoded != "" {
groups = append(groups, encoded)
}
return strings.Join(groups, "-"), nil
}