292 lines
9.3 KiB
Go
292 lines
9.3 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/credential"
|
|
"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"`
|
|
CanReveal bool `json:"can_reveal"`
|
|
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.CanReveal = item.CodeCiphertext != "" && 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
|
|
update := s.engine.ID(item.ID).Cols("enabled", "updated_at")
|
|
if !enabled {
|
|
// 即使关闭后立即开启且旧连接尚未发命令,也不能恢复旧会话的授权。
|
|
update = update.Incr("code_version", 1)
|
|
}
|
|
if _, err := update.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
|
|
}
|
|
ciphertext := ""
|
|
if s.vaultRoot != "" {
|
|
ciphertext, err = credential.Encrypt(s.vaultRoot, plain)
|
|
if err != nil {
|
|
return "", fmt.Errorf("protect protocol authorization code: %w", 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.CodeCiphertext = ciphertext
|
|
item.CodeVersion = version
|
|
item.RevokedAt = nil
|
|
item.LastUsedAt = nil
|
|
if _, err := s.engine.ID(item.ID).Cols("code_hash", "code_ciphertext", "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", "code_hash", "code_ciphertext", "updated_at").
|
|
Update(&models.ProtocolCredential{RevokedAt: &now, CodeHash: "", CodeCiphertext: ""}); 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 {
|
|
matched, _ := s.CheckProtocolCode(userID, protocol, plain)
|
|
return matched
|
|
}
|
|
|
|
// CheckProtocolCode 保留计算过载错误,供需要区分重试与认证失败的调用方使用。
|
|
func (s *Service) CheckProtocolCode(userID int, protocol string, plain string) (bool, error) {
|
|
if s == nil || s.engine == nil || userID <= 0 || plain == "" || len([]byte(plain)) > 256 {
|
|
return false, password.CheckDummy(plain)
|
|
}
|
|
protocol, err := NormalizeProtocol(protocol)
|
|
if err != nil {
|
|
return false, password.CheckDummy(plain)
|
|
}
|
|
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(plain)
|
|
}
|
|
matched, err := password.Check(item.CodeHash, strings.TrimSpace(plain))
|
|
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 {
|
|
return false, err
|
|
}
|
|
if affected == 1 {
|
|
return true, nil
|
|
}
|
|
// 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 valid, err
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// RevealProtocolCodes 解密当前用户所有有效协议授权码,仅在安全身份验证后调用。
|
|
func (s *Service) RevealProtocolCodes(userID int) (map[string]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[string]string), nil
|
|
}
|
|
var items []models.ProtocolCredential
|
|
if err := s.engine.Where("user_id = ? AND enabled = ? AND revoked_at IS NULL", userID, true).Find(&items); err != nil {
|
|
return nil, fmt.Errorf("load protocol credentials for reveal: %w", err)
|
|
}
|
|
result := make(map[string]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.Protocol] = plain
|
|
}
|
|
return result, nil
|
|
}
|