This commit is contained in:
parent
08ed8c3636
commit
05b01bbc83
@ -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):
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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": "保存邮件草稿",
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user