开放用户本人查看通知凭据前保存实现检查点
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-26 22:10:51 +08:00
parent 08ed8c3636
commit 05b01bbc83
10 changed files with 245 additions and 7 deletions

View File

@ -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):

View File

@ -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 (

View File

@ -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),
}
}

View File

@ -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) {

View File

@ -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": "保存邮件草稿",

View File

@ -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"`

View File

@ -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},

View File

@ -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

View File

@ -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;
}
}

View File

@ -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;
}