支持邮件客户端协议授权码持久加密存储与安全解密查看
This commit is contained in:
parent
09fa1d3133
commit
279653847e
@ -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 {
|
||||
|
||||
19
backend/db/migration/protocol_ciphertext.go
Normal file
19
backend/db/migration/protocol_ciphertext.go
Normal 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)
|
||||
}
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -284,6 +284,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) {
|
||||
17: "1dad66acb5e7fecf6c39f4a78526b994748d90045220f446a2e80c1b5fd881e7",
|
||||
18: "17622df3406657f199d78a3a1aa1f3a8693baf85aa7707910cc6d6cb44c13ee4",
|
||||
19: "f24b94ca9e4c25b7973f6c15fcaddccc7f02bcf7886c0a4995d2f7b79f4a3a16",
|
||||
20: "e0bc01f4c2df742bc23c519c2a399cc1c3f341565dbed014509a8c63d68bf725",
|
||||
}
|
||||
migrations := DefaultMigrations()
|
||||
if len(migrations) != len(expected) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user