支持邮件客户端协议授权码持久加密存储与安全解密查看

This commit is contained in:
smail 2026-09-12 14:01:38 +08:00
parent 09fa1d3133
commit 279653847e
10 changed files with 157 additions and 7 deletions

View File

@ -16,6 +16,7 @@ import (
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/auth"
"git.pcux.cn/xuans/Smail/services/credentials"
"git.pcux.cn/xuans/Smail/services/externalemail"
"git.pcux.cn/xuans/Smail/services/recovery"
@ -431,6 +432,31 @@ func RevokeProtocolCredential(ctx *context.Context, w http.ResponseWriter, req *
protocol, _ := credentials.NormalizeProtocol(input.Protocol)
response.NewSuccessResponse(map[string]any{"protocol": protocol, "revoked": true}).FPrint(w)
}
// RevealProtocolCredentials 在安全身份验证成功后解密并返回当前用户所有有效协议授权码。
func RevealProtocolCredentials(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
service, ok := requireProtocolCredentialContext(ctx, w)
if !ok {
return
}
var input struct {
Password string `json:"password"`
}
if err := decodeJSONRequest(w, req, &input); err != nil || strings.TrimSpace(input.Password) == "" {
writeSecurityError(w, response.ParamsError, "请输入当前账号密码进行安全验证")
return
}
if _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password); err != nil {
writeSecurityError(w, response.ParamsError, "密码验证失败")
return
}
codes, err := service.RevealProtocolCodes(ctx.UserID)
if err != nil {
writeSecurityError(w, response.ServerError, "解密授权码失败")
return
}
response.NewSuccessResponse(codes).FPrint(w)
}
func RequestVerification(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
runtime := currentSecurityRuntime()
if ctx == nil || ctx.UserID <= 0 {

View File

@ -0,0 +1,19 @@
package migration
import (
"context"
"fmt"
)
func applyProtocolCredentialCiphertextSchema(ctx context.Context, exec executor, dialect Dialect) error {
definitions := map[Dialect]string{
DialectSQLite: "TEXT NULL",
DialectMySQL: "VARCHAR(512) NULL",
DialectPostgres: "VARCHAR(512) NULL",
}
definition, ok := definitions[dialect]
if !ok {
return fmt.Errorf("unsupported migration dialect %q", dialect)
}
return ensureColumn(ctx, exec, dialect, "protocol_credential", "code_ciphertext", definition)
}

View File

@ -29,6 +29,7 @@ func DefaultMigrations() []Migration {
DefineForDialects(17, "create_managed_logs", logStatements()),
DefineCustom(18, "expand_operation_log_actor_role", "expand_operation_log_actor_role_v1", applyOperationLogActorRoleSchema),
DefineForDialects(19, "create_user_contacts_and_trusted_senders", userContactStatements()),
DefineCustom(20, "expand_protocol_credential_ciphertext", "expand_protocol_credential_ciphertext_v1", applyProtocolCredentialCiphertextSchema),
}
}

View File

@ -284,6 +284,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
17: "1dad66acb5e7fecf6c39f4a78526b994748d90045220f446a2e80c1b5fd881e7",
18: "17622df3406657f199d78a3a1aa1f3a8693baf85aa7707910cc6d6cb44c13ee4",
19: "f24b94ca9e4c25b7973f6c15fcaddccc7f02bcf7886c0a4995d2f7b79f4a3a16",
20: "e0bc01f4c2df742bc23c519c2a399cc1c3f341565dbed014509a8c63d68bf725",
}
migrations := DefaultMigrations()
if len(migrations) != len(expected) {

View File

@ -38,6 +38,7 @@ func registerV1Routes(mux *http.ServeMux) {
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/enabled", "/api/security/credentials/protocols/enabled", http.MethodPut, 16<<10, controllers.SetProtocolCredentialEnabled)
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/generate", "/api/security/credentials/protocols/generate", http.MethodPost, 16<<10, controllers.GenerateProtocolCredential)
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/revoke", "/api/security/credentials/protocols/revoke", http.MethodPost, 16<<10, controllers.RevokeProtocolCredential)
registerV1MethodRoute(mux, "/api/v1/security/credentials/protocols/reveal", "/api/security/credentials/protocols/reveal", http.MethodPost, 16<<10, controllers.RevealProtocolCredentials)
registerV1MethodRoute(mux, "/api/v1/security/external-email/list", "/api/security/external-email/list", http.MethodGet, 0, controllers.ListExternalEmails)
registerV1MethodRoute(mux, "/api/v1/security/external-email/bind", "/api/security/external-email/bind", http.MethodPost, 16<<10, controllers.BindExternalEmail)
registerV1MethodRoute(mux, "/api/v1/security/external-email/verify", "/api/security/external-email/verify", http.MethodPost, 16<<10, controllers.VerifyExternalEmail)

View File

@ -10,6 +10,7 @@ type ProtocolCredential struct {
Enabled bool `xorm:"enabled notnull default(0)" json:"enabled"`
CodeHash string `xorm:"code_hash varchar(255) notnull" json:"-"`
CodeVersion int `xorm:"code_version int notnull default(1)" json:"-"`
CodeCiphertext string `xorm:"code_ciphertext varchar(512) null" json:"-"`
CreatedAt time.Time `xorm:"created_at datetime created notnull" json:"created_at"`
UpdatedAt time.Time `xorm:"updated_at datetime updated notnull" json:"updated_at"`
LastUsedAt *time.Time `xorm:"last_used_at datetime null" json:"last_used_at,omitempty"`

View File

@ -318,6 +318,7 @@ func startVerificationRuntime() error {
runtime.Stop()
return fmt.Errorf("start credential service: %w", err)
}
credential.SetVaultRoot(config.ROOT_PATH)
vault, err := externalemail.NewVault(config.ROOT_PATH)
if err != nil {
runtime.Stop()

View File

@ -10,6 +10,7 @@ import (
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/password"
"git.pcux.cn/xuans/Smail/utils/credential"
)
const (
@ -132,6 +133,13 @@ func (s *Service) GenerateProtocolCode(userID int, protocol string) (string, err
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)
@ -151,10 +159,11 @@ func (s *Service) GenerateProtocolCode(userID int, protocol string) (string, err
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_version", "revoked_at", "last_used_at", "updated_at").Update(&item); err != 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
@ -176,9 +185,9 @@ func (s *Service) RevokeProtocolCode(userID int, protocol string) error {
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)
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
}
@ -237,3 +246,34 @@ func newProtocolCode() (string, error) {
}
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
}
if s.vaultRoot == "" {
return make(map[string]string), nil
}
s.mu.Lock()
defer s.mu.Unlock()
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(s.vaultRoot, item.CodeCiphertext)
if err != nil {
continue
}
result[item.Protocol] = plain
}
return result, nil
}

View File

@ -33,9 +33,28 @@ const (
// Service 统一管理密保问题和离线恢复码。
type Service struct {
engine *xorm.Engine
mu sync.Mutex
now func() time.Time
engine *xorm.Engine
mu sync.Mutex
now func() time.Time
vaultRoot string
}
func (s *Service) SetVaultRoot(root string) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.vaultRoot = root
}
func (s *Service) VaultRoot() string {
if s == nil {
return ""
}
s.mu.Lock()
defer s.mu.Unlock()
return s.vaultRoot
}
func NewService(engine *xorm.Engine) (*Service, error) {

View File

@ -174,3 +174,44 @@ func TestProtocolCredentialLifecycle(t *testing.T) {
t.Fatal("revoked protocol code was accepted")
}
}
func TestProtocolCredentialRevealAndRevoke(t *testing.T) {
service := newFixture(t)
root := t.TempDir()
service.SetVaultRoot(root)
if err := service.SetProtocolEnabled(1, ProtocolIMAP, true); err != nil {
t.Fatal(err)
}
if err := service.SetProtocolEnabled(1, ProtocolSMTP, true); err != nil {
t.Fatal(err)
}
imapCode, err := service.GenerateProtocolCode(1, ProtocolIMAP)
if err != nil {
t.Fatal(err)
}
smtpCode, err := service.GenerateProtocolCode(1, ProtocolSMTP)
if err != nil {
t.Fatal(err)
}
revealed, err := service.RevealProtocolCodes(1)
if err != nil {
t.Fatal(err)
}
if revealed[ProtocolIMAP] != imapCode || revealed[ProtocolSMTP] != smtpCode {
t.Fatalf("expected revealed codes to match, got %+v", revealed)
}
if err := service.RevokeProtocolCode(1, ProtocolIMAP); err != nil {
t.Fatal(err)
}
revealedAfter, err := service.RevealProtocolCodes(1)
if err != nil {
t.Fatal(err)
}
if _, has := revealedAfter[ProtocolIMAP]; has {
t.Fatal("imap code should be revoked and not revealed")
}
if revealedAfter[ProtocolSMTP] != smtpCode {
t.Fatal("smtp code should still be revealed")
}
}