From 279653847e9e0bad7f8cc89bd583f118d53e6571 Mon Sep 17 00:00:00 2001 From: smail Date: Sat, 12 Sep 2026 14:01:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=82=AE=E4=BB=B6=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E5=8D=8F=E8=AE=AE=E6=8E=88=E6=9D=83=E7=A0=81?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8A=A0=E5=AF=86=E5=AD=98=E5=82=A8=E4=B8=8E?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E8=A7=A3=E5=AF=86=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/security.go | 26 +++++++++++ backend/db/migration/protocol_ciphertext.go | 19 ++++++++ backend/db/migration/registry.go | 1 + backend/db/migration/registry_test.go | 1 + backend/listen/http_server/api_v1_compat.go | 1 + backend/models/protocol_credential.go | 1 + backend/res_init/init.go | 1 + backend/services/credentials/protocol.go | 48 ++++++++++++++++++-- backend/services/credentials/service.go | 25 ++++++++-- backend/services/credentials/service_test.go | 41 +++++++++++++++++ 10 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 backend/db/migration/protocol_ciphertext.go diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 93af8da..6fae27d 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -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 { diff --git a/backend/db/migration/protocol_ciphertext.go b/backend/db/migration/protocol_ciphertext.go new file mode 100644 index 0000000..1823744 --- /dev/null +++ b/backend/db/migration/protocol_ciphertext.go @@ -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) +} diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go index 3287471..23ce153 100644 --- a/backend/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -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), } } diff --git a/backend/db/migration/registry_test.go b/backend/db/migration/registry_test.go index 1f5d84f..09b3d14 100644 --- a/backend/db/migration/registry_test.go +++ b/backend/db/migration/registry_test.go @@ -284,6 +284,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) { 17: "1dad66acb5e7fecf6c39f4a78526b994748d90045220f446a2e80c1b5fd881e7", 18: "17622df3406657f199d78a3a1aa1f3a8693baf85aa7707910cc6d6cb44c13ee4", 19: "f24b94ca9e4c25b7973f6c15fcaddccc7f02bcf7886c0a4995d2f7b79f4a3a16", + 20: "e0bc01f4c2df742bc23c519c2a399cc1c3f341565dbed014509a8c63d68bf725", } migrations := DefaultMigrations() if len(migrations) != len(expected) { diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index f4970b6..11764ff 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -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) diff --git a/backend/models/protocol_credential.go b/backend/models/protocol_credential.go index fdbe246..f95dd57 100644 --- a/backend/models/protocol_credential.go +++ b/backend/models/protocol_credential.go @@ -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"` diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 1953ba1..c062352 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -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() diff --git a/backend/services/credentials/protocol.go b/backend/services/credentials/protocol.go index 02029e0..26baf4d 100644 --- a/backend/services/credentials/protocol.go +++ b/backend/services/credentials/protocol.go @@ -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 +} diff --git a/backend/services/credentials/service.go b/backend/services/credentials/service.go index 67dfb5c..e014894 100644 --- a/backend/services/credentials/service.go +++ b/backend/services/credentials/service.go @@ -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) { diff --git a/backend/services/credentials/service_test.go b/backend/services/credentials/service_test.go index 1dce6bc..ff3de6a 100644 --- a/backend/services/credentials/service_test.go +++ b/backend/services/credentials/service_test.go @@ -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") + } +}