完善授权分页与恢复安全校验
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-06 07:22:00 +08:00
parent d93337852d
commit dbcfc5c461
6 changed files with 36 additions and 16 deletions

View File

@ -7,6 +7,11 @@ import (
// authorizeCapability 统一把 HTTP 请求上下文映射到能力授权器。
func authorizeCapability(ctx *context.Context, capability access.Capability, targetRole access.Role, ownResource bool) error {
return authorizeCapabilityWithNewRole(ctx, capability, targetRole, "", ownResource)
}
// authorizeCapabilityWithNewRole 在角色变更时同时把目标新角色传给统一授权器。
func authorizeCapabilityWithNewRole(ctx *context.Context, capability access.Capability, targetRole, newRole access.Role, ownResource bool) error {
if ctx == nil {
return &access.Denial{Code: access.CodeAccessDenied}
}
@ -18,6 +23,7 @@ func authorizeCapability(ctx *context.Context, capability access.Capability, tar
RecentAuth: ctx.RecentAuth,
RecoveryCodeConfigured: ctx.RecoveryCodeConfigured,
TargetRecoveryConfigured: ctx.TargetRecoveryConfigured,
NewRole: newRole,
PluginInstanceAuthorized: ctx.PluginInstanceAuthorized,
AuditScopeAuthorized: ctx.AuditScopeAuthorized,
DeploymentChannel: ctx.DeploymentChannel,

View File

@ -583,7 +583,7 @@ func recoveryBrowserContext(req *http.Request, w http.ResponseWriter, create boo
value := hex.EncodeToString(raw[:])
secure := req.TLS != nil || !config.DevelopmentMode
http.SetCookie(w, &http.Cookie{
Name: recoveryContextCookieName, Value: value, Path: "/api/security/recovery",
Name: recoveryContextCookieName, Value: value, Path: "/api/",
HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: 15 * 60,
})
return value, true
@ -595,7 +595,7 @@ func clearRecoveryBrowserContext(w http.ResponseWriter, req *http.Request) {
}
secure := req != nil && (req.TLS != nil || !config.DevelopmentMode)
http.SetCookie(w, &http.Cookie{
Name: recoveryContextCookieName, Value: "", Path: "/api/security/recovery",
Name: recoveryContextCookieName, Value: "", Path: "/api/",
HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: -1,
})
}

View File

@ -73,7 +73,7 @@ func TestRecoveryBrowserContextCookieProperties(t *testing.T) {
t.Fatal("recovery context was not created")
}
cookie := recorder.Result().Cookies()[0]
if cookie.Name != recoveryContextCookieName || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/api/security/recovery" || cookie.Secure {
if cookie.Name != recoveryContextCookieName || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/api/" || cookie.Secure {
t.Fatalf("unexpected recovery context cookie: %+v", cookie)
}
request.AddCookie(cookie)

View File

@ -147,15 +147,15 @@ func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
domains := array.Difference(mailDomains.Accepted, []string{mailDomains.Primary})
domains = append([]string{mailDomains.Primary}, domains...)
result := map[string]any{
"account": ctx.UserAccount,
"name": ctx.UserName,
"role": ctx.EffectiveRole(),
"capabilities": access.Capabilities(ctx.EffectiveRole()),
"is_admin": ctx.IsAdmin,
"must_change_password": ctx.MustChangePassword,
"account": ctx.UserAccount,
"name": ctx.UserName,
"role": ctx.EffectiveRole(),
"capabilities": access.Capabilities(ctx.EffectiveRole()),
"is_admin": ctx.IsAdmin,
"must_change_password": ctx.MustChangePassword,
"showSecuritySetupPrompt": ctx.SecuritySetupAcknowledged == 0,
"webAuthnEnabled": webAuthnRuntime() != nil,
"domains": domains,
"webAuthnEnabled": webAuthnRuntime() != nil,
"domains": domains,
}
if ctx.EffectiveRole() != access.RoleSuperAdmin {
result["recoveryCodesConfigured"] = ctx.RecoveryCodeConfigured
@ -232,7 +232,7 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
}
ctx.TargetRecoveryConfigured = configured
}
if err := authorizeCapability(ctx, access.CapabilityRoleManage, targetRole, false); err != nil {
if err := authorizeCapabilityWithNewRole(ctx, access.CapabilityRoleManage, targetRole, newRole, false); err != nil {
response.NewErrorResponse(response.NoAccessPrivileges, "角色变更需要近期认证和恢复凭据", "").FPrint(w)
return
}

View File

@ -27,11 +27,14 @@ func getList(ctx *context.Context, tagInfo dto.SearchTag, keyword string, pop3Li
totalSQL, totalParams := genSQL(ctx, true, tagInfo, keyword, pop3List, offset, limit)
if _, err := db.Instance.SQL(totalSQL, totalParams...).Get(&total); err != nil {
var countResult struct {
Total int64 `xorm:"total"`
}
if _, err := db.Instance.SQL(totalSQL, totalParams...).Get(&countResult); err != nil {
return nil, 0, fmt.Errorf("query email count: %w", err)
}
return emailList, total, nil
return emailList, countResult.Total, nil
}
func genSQL(ctx *context.Context, count bool, tagInfo dto.SearchTag, keyword string, pop3List bool, offset, limit int) (string, []any) {
@ -39,7 +42,7 @@ func genSQL(ctx *context.Context, count bool, tagInfo dto.SearchTag, keyword str
sql := "select "
if count {
sql += `count(1) from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ? `
sql += `count(1) AS total from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ? `
} else if pop3List {
sql += `e.id,e.size from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ? `
} else {
@ -94,10 +97,14 @@ func genSQL(ctx *context.Context, count bool, tagInfo dto.SearchTag, keyword str
sqlParams = append(sqlParams, "%"+keyword+"%", "%"+keyword+"%")
}
if limit == 0 {
if !count && limit == 0 {
limit = 10
}
if count {
return sql, sqlParams
}
sql += " order by e.id desc"
if limit < 10000 {

View File

@ -326,6 +326,13 @@ func (s *Service) CompleteWithContext(ticketID, newPassword, browserContext stri
return ErrRecoveryDenied
}
}
if item.Method == "webauthn" && item.CredentialID > 0 {
var credential models.WebAuthnCredential
found, lookupErr := s.engine.Where("id = ? AND user_id = ? AND revoked_at IS NULL", item.CredentialID, item.UserID).Get(&credential)
if lookupErr != nil || !found {
return ErrRecoveryDenied
}
}
version := user.SessionVersion + 1
if version <= 0 {
version = 1