diff --git a/backend/controllers/system_notification.go b/backend/controllers/system_notification.go index 09a3056..dcd4154 100644 --- a/backend/controllers/system_notification.go +++ b/backend/controllers/system_notification.go @@ -13,6 +13,7 @@ import ( "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/accountgroup" + "git.pcux.cn/xuans/Smail/services/auth" "git.pcux.cn/xuans/Smail/services/logstore" "git.pcux.cn/xuans/Smail/services/notification" "git.pcux.cn/xuans/Smail/utils/context" @@ -101,6 +102,8 @@ func GetNotificationStatus(ctx *context.Context, w http.ResponseWriter, req *htt response.NewSuccessResponse(map[string]any{ "user_id": user.ID, "account": user.Account, "enabled": item.Enabled == 1, "configured": notification.Configured(item, *user), "version": item.CodeVersion, + "can_reveal": notification.Configured(item, *user) && item.CodeCiphertext != "" && + access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID), "account_available": user.Disabled == 0 && user.MustChangePassword == 0, "can_manage": access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID), "minute_limit": item.MinuteLimit, "day_limit": item.DayLimit, @@ -191,7 +194,7 @@ func RotateNotificationCredential(ctx *context.Context, w http.ResponseWriter, r if !ok { return } - code, err := notification.Rotate(db.Instance, *user, input.Version, accountWriteGuard(ctx, *user)) + code, err := notification.Rotate(db.Instance, *user, input.Version, config.ROOT_PATH, accountWriteGuard(ctx, *user)) recordNotificationManagement(ctx, user.ID, "重置系统通知凭据", err, nil) if err != nil { writeNotificationError(w, err) @@ -201,6 +204,40 @@ func RotateNotificationCredential(ctx *context.Context, w http.ResponseWriter, r response.NewSuccessResponse(map[string]any{"code": code, "user_id": user.ID}).FPrint(w) } +// RevealNotificationCredential 每次查看都验证操作人密码,日志不得包含密码或凭据。 +func RevealNotificationCredential(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + w.Header().Set("Cache-Control", "no-store") + var input struct { + UserID int `json:"user_id"` + Version int `json:"version"` + Password string `json:"password"` + } + if decodeJSONRequest(w, req, &input) != nil || input.Version < 1 || input.Password == "" { + writeSecurityError(w, response.ParamsError, "请填写当前登录密码并选择有效账号") + return + } + user, ok := loadNotificationUser(ctx, w, input.UserID, true) + if !ok { + return + } + _, err := auth.AuthenticateUserByID(ctx.UserID, input.Password) + input.Password = "" + if err != nil { + recordNotificationManagement(ctx, user.ID, "查看系统通知凭据", err, nil) + if !writePasswordBusy(w, err) { + writeSecurityError(w, response.ParamsError, "密码验证失败") + } + return + } + code, err := notification.Reveal(db.Instance, *user, input.Version, config.ROOT_PATH, accountWriteGuard(ctx, *user)) + recordNotificationManagement(ctx, user.ID, "查看系统通知凭据", err, nil) + if err != nil { + writeNotificationError(w, err) + return + } + response.NewSuccessResponse(map[string]any{"code": code, "user_id": user.ID}).FPrint(w) +} + func recordNotificationManagement(ctx *context.Context, userID int, action string, err error, changes map[string]any) { result := "success" if err != nil { @@ -217,6 +254,8 @@ func writeNotificationError(w http.ResponseWriter, err error) { return } switch { + case errors.Is(err, notification.ErrNoCiphertext): + writeSecurityError(w, response.ParamsError, "旧凭据未保留加密密文,请重新生成后再查看") case errors.Is(err, notification.ErrConflict): writeSecurityError(w, response.ParamsError, "授权状态已变化,请刷新后重试") case errors.Is(err, notification.ErrDenied): diff --git a/backend/db/migration/notification_grant.go b/backend/db/migration/notification_grant.go index 5c48885..35ccf7f 100644 --- a/backend/db/migration/notification_grant.go +++ b/backend/db/migration/notification_grant.go @@ -1,5 +1,11 @@ package migration +import "context" + +func applyNotificationCiphertextSchema(ctx context.Context, exec executor, dialect Dialect) error { + return ensureColumn(ctx, exec, dialect, "notification_grant", "code_ciphertext", "VARCHAR(512) NULL") +} + func notificationGrantStatements() map[Dialect][]string { // 不依赖自增、布尔或日期方言;计数窗口统一使用 UTC 字符串。 statement := `CREATE TABLE IF NOT EXISTS notification_grant ( diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go index 58e5874..7fc5b67 100644 --- a/backend/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -41,6 +41,7 @@ func DefaultMigrations() []Migration { }), DefineForDialects(25, "create_notification_grants", notificationGrantStatements()), DefineForDialects(26, "create_account_groups", accountGroupStatements()), + DefineCustom(27, "expand_notification_ciphertext", "expand_notification_ciphertext_v1", applyNotificationCiphertextSchema), } } diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index ab0c6a9..d97373c 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -110,6 +110,7 @@ func registerNotificationRoutes(mux *http.ServeMux) { registerV1MethodRoute(mux, "/api/v1/system/notification/accounts", "/api/system/notification/accounts", http.MethodGet, 0, controllers.ListNotificationAccounts) registerV1MethodRoute(mux, "/api/v1/system/notification/authorization", "/api/system/notification/authorization", http.MethodPost, 4<<10, controllers.SetNotificationAuthorization) registerV1MethodRoute(mux, "/api/v1/system/notification/credential", "/api/system/notification/credential", http.MethodPost, 4<<10, controllers.RotateNotificationCredential) + registerV1MethodRoute(mux, "/api/v1/system/notification/credential/reveal", "/api/system/notification/credential/reveal", http.MethodPost, 4<<10, controllers.RevealNotificationCredential) } func registerV1SenderFiltersRoute(mux *http.ServeMux) { diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index 12ea4d4..d2869cc 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -15,6 +15,7 @@ import ( // loggedOperations 注册显式的静态状态变更路由,仅记录状态变更操作,不将列表查询误认为操作,也不复制请求或响应载荷。 var loggedOperations = map[string]string{ + "/api/system/notification/credential/reveal": "查看系统通知发信凭据", "/api/system/notification/authorization": "修改系统通知发信权限", "/api/system/notification/credential": "生成系统通知发信凭据", "/api/email/draft/save": "保存邮件草稿", diff --git a/backend/models/notification_grant.go b/backend/models/notification_grant.go index 768de44..1636013 100644 --- a/backend/models/notification_grant.go +++ b/backend/models/notification_grant.go @@ -1,11 +1,12 @@ package models -// NotificationGrant 与业务数据共用数据库,只保存通知发信授权及不可逆凭据摘要。 +// NotificationGrant 与业务数据共用数据库;摘要用于认证,密文仅供身份验证后查看。 type NotificationGrant struct { UserID int `xorm:"user_id pk" json:"user_id"` Enabled int `xorm:"enabled" json:"-"` CodeVersion int `xorm:"code_version" json:"version"` CodeHash string `xorm:"code_hash" json:"-"` + CodeCiphertext string `xorm:"code_ciphertext varchar(512) null" json:"-"` SessionVersion int `xorm:"session_version" json:"-"` SecurityVersion int `xorm:"security_version" json:"-"` MinuteLimit int `xorm:"minute_limit" json:"minute_limit"` diff --git a/backend/services/access/catalog.go b/backend/services/access/catalog.go index 535867e..1b6cf91 100644 --- a/backend/services/access/catalog.go +++ b/backend/services/access/catalog.go @@ -9,6 +9,8 @@ type Endpoint struct { } var managementEndpoints = []Endpoint{ + {Method: "POST", Path: "/api/system/notification/credential/reveal", Capability: CapabilityNotificationManage}, + {Method: "POST", Path: "/api/v1/system/notification/credential/reveal", Capability: CapabilityNotificationManage}, {Method: "POST", Path: "/api/system/notification/authorization", Capability: CapabilityNotificationManage}, {Method: "POST", Path: "/api/system/notification/credential", Capability: CapabilityNotificationManage}, {Method: "POST", Path: "/api/v1/user/groups/save", Capability: CapabilityAccountGroupManage}, diff --git a/backend/services/notification/service.go b/backend/services/notification/service.go index d797dae..e3439be 100644 --- a/backend/services/notification/service.go +++ b/backend/services/notification/service.go @@ -7,11 +7,13 @@ import ( "encoding/base64" "encoding/hex" "errors" + "strconv" "strings" "time" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/accountgroup" + "git.pcux.cn/xuans/Smail/utils/credential" "xorm.io/xorm" ) @@ -29,6 +31,7 @@ var ( ErrQuota = errors.New("notification quota exceeded") ErrUnavailable = errors.New("notification authorization unavailable") ErrLimits = errors.New("invalid notification limits") + ErrNoCiphertext = errors.New("notification credential must be regenerated before reveal") ) func IsUsername(username string) bool { @@ -88,6 +91,7 @@ func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabl item.Enabled = 1 } else { item.CodeHash = "" + item.CodeCiphertext = "" } item.MinuteLimit, item.DayLimit = minuteLimit, dayLimit item.CodeVersion++ @@ -98,7 +102,7 @@ func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabl return tx.Commit() } affected, err := tx.ID(user.ID).Where("code_version = ?", expected). - Cols("enabled", "code_hash", "code_version", "minute_limit", "day_limit").Update(&item) + Cols("enabled", "code_hash", "code_ciphertext", "code_version", "minute_limit", "day_limit").Update(&item) if err != nil { return err } @@ -108,9 +112,9 @@ func SetAuthorization(engine *xorm.Engine, user models.User, expected int, enabl return tx.Commit() } -// Rotate 仅返回一次明文。随机凭据具有 256 位熵,数据库只保存 SHA-256 摘要。 -func Rotate(engine *xorm.Engine, user models.User, expected int, guards ...accountgroup.WriteGuard) (string, error) { - if engine == nil || user.ID <= 0 { +// Rotate 生成独立随机凭据;加密绑定账号,关闭授权时同时清除摘要与密文。 +func Rotate(engine *xorm.Engine, user models.User, expected int, root string, guards ...accountgroup.WriteGuard) (string, error) { + if engine == nil || user.ID <= 0 || strings.TrimSpace(root) == "" { return "", ErrUnavailable } tx := engine.NewSession() @@ -138,11 +142,16 @@ func Rotate(engine *xorm.Engine, user models.User, expected int, guards ...accou return "", err } code := "sn_" + base64.RawURLEncoding.EncodeToString(entropy[:]) + ciphertext, err := credential.Encrypt(root, strconv.Itoa(user.ID)+":"+code) + if err != nil { + return "", ErrUnavailable + } item.CodeHash = digest(code) + item.CodeCiphertext = ciphertext item.CodeVersion++ item.SessionVersion, item.SecurityVersion = user.SessionVersion, user.SecurityVersion affected, err := tx.ID(user.ID).Where("code_version = ? AND enabled = 1", expected). - Cols("code_hash", "code_version", "session_version", "security_version").Update(&item) + Cols("code_hash", "code_ciphertext", "code_version", "session_version", "security_version").Update(&item) if err != nil { return "", err } @@ -155,6 +164,49 @@ func Rotate(engine *xorm.Engine, user models.User, expected int, guards ...accou return code, nil } +// Reveal 在事务内复核管理范围、账号安全版本和凭据版本,绝不重置用量或凭据。 +func Reveal(engine *xorm.Engine, user models.User, expected int, root string, guards ...accountgroup.WriteGuard) (string, error) { + if engine == nil || user.ID <= 0 || strings.TrimSpace(root) == "" { + return "", ErrUnavailable + } + tx := engine.NewSession() + defer tx.Close() + if err := tx.Begin(); err != nil { + return "", err + } + defer tx.Rollback() + if err := accountgroup.CheckWrite(tx, guards...); err != nil { + return "", err + } + var item models.NotificationGrant + found, err := tx.ID(user.ID).Get(&item) + if err != nil { + return "", err + } + if !found || !Configured(item, user) { + return "", ErrDenied + } + if item.CodeVersion != expected { + return "", ErrConflict + } + if item.CodeCiphertext == "" { + return "", ErrNoCiphertext + } + plain, err := credential.Decrypt(root, item.CodeCiphertext) + if err != nil { + return "", ErrUnavailable + } + account, code, ok := strings.Cut(plain, ":") + if !ok || account != strconv.Itoa(user.ID) || len(code) != 46 || !strings.HasPrefix(code, "sn_") || + subtle.ConstantTimeCompare([]byte(item.CodeHash), []byte(digest(code))) != 1 { + return "", ErrUnavailable + } + if err := tx.Commit(); err != nil { + return "", err + } + return code, nil +} + type Grant struct { engine *xorm.Engine userID int diff --git a/frontend/src/views/components/SystemNotification/SystemNotification.css b/frontend/src/views/components/SystemNotification/SystemNotification.css index 2af7f37..4cc7975 100644 --- a/frontend/src/views/components/SystemNotification/SystemNotification.css +++ b/frontend/src/views/components/SystemNotification/SystemNotification.css @@ -59,3 +59,118 @@ .notification-secret { margin-top: 16px; } + +.notification-compact { + gap: 10px; + padding: 0; +} + +.notification-compact .notification-header { + justify-content: flex-end; +} + +.notification-compact .notification-panel { + padding: 0; + border: 0; +} + +.notification-compact .notification-heading { + gap: 8px; + margin-bottom: 14px; +} + +.notification-compact h3 { + margin: 0; + overflow-wrap: anywhere; +} + +.notification-compact .notification-columns { + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + gap: 12px 22px; + align-items: start; +} + +.notification-compact .notification-columns > :deep(.el-form) { + grid-column: 1; + grid-row: 1; +} + +.notification-compact .notification-columns > :deep(.el-descriptions) { + grid-column: 1; + grid-row: 2; + min-width: 0; +} + +.notification-compact .notification-columns > :deep(.el-descriptions ~ .el-descriptions) { + grid-column: 2; + grid-row: 1 / span 2; +} + +.notification-compact .notification-hint { + grid-column: 1; + grid-row: 3; + margin: 0; + font-size: 12px; + line-height: 1.5; +} + +.notification-compact .notification-columns > :deep(.el-descriptions ~ .el-descriptions + .notification-hint) { + grid-column: 2; +} + +.notification-compact .notification-columns > :deep(.el-alert) { + grid-column: 1 / -1; +} + +.notification-compact .notification-limits { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.notification-compact :deep(.el-form-item) { + min-width: 0; + margin-bottom: 12px; +} + +.notification-compact :deep(.el-input-number) { + width: 100%; +} + +.notification-compact .notification-actions { + gap: 8px; + margin-bottom: 0; +} + +.notification-compact .notification-actions :deep(.el-button + .el-button) { + margin-left: 0; +} + +.notification-compact :deep(.el-descriptions__header) { + margin-bottom: 8px; +} + +.notification-compact :deep(.el-descriptions__table) { + table-layout: fixed; +} + +.notification-compact :deep(.el-descriptions__label) { + width: 118px; +} + +.notification-compact :deep(.el-descriptions__cell) { + overflow-wrap: anywhere; +} + +@media (max-width: 720px) { + .notification-compact .notification-columns { + display: flex; + flex-direction: column; + } + + .notification-compact .notification-columns > * { + width: 100%; + box-sizing: border-box; + } +} diff --git a/frontend/src/views/components/UserManagement/UserManagement.css b/frontend/src/views/components/UserManagement/UserManagement.css index 8ded6c5..ee69ea8 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.css +++ b/frontend/src/views/components/UserManagement/UserManagement.css @@ -331,6 +331,26 @@ grid-column: 1 / -1; } +:global(.notification-dialog) { + display: flex; + flex-direction: column; + max-height: 90vh; + max-height: 90dvh; + margin-bottom: 5vh; + box-sizing: border-box; +} + +:global(.notification-dialog > .el-dialog__header) { + flex: 0 0 auto; +} + +:global(.notification-dialog > .el-dialog__body) { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + .account-form > :deep(.el-form-item) { min-width: 0; }