diff --git a/backend/controllers/authorization.go b/backend/controllers/authorization.go index 29d4e9f..00fded8 100644 --- a/backend/controllers/authorization.go +++ b/backend/controllers/authorization.go @@ -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, diff --git a/backend/controllers/security.go b/backend/controllers/security.go index 6ad579a..0b48f38 100644 --- a/backend/controllers/security.go +++ b/backend/controllers/security.go @@ -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, }) } diff --git a/backend/controllers/security_reset_entry_test.go b/backend/controllers/security_reset_entry_test.go index 22c3486..a535765 100644 --- a/backend/controllers/security_reset_entry_test.go +++ b/backend/controllers/security_reset_entry_test.go @@ -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) diff --git a/backend/controllers/user.go b/backend/controllers/user.go index f0ea1c7..854da96 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -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 } diff --git a/backend/services/list/list.go b/backend/services/list/list.go index 29a7064..ff6662b 100644 --- a/backend/services/list/list.go +++ b/backend/services/list/list.go @@ -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 { diff --git a/backend/services/recovery/service.go b/backend/services/recovery/service.go index 287e95b..0083e5c 100644 --- a/backend/services/recovery/service.go +++ b/backend/services/recovery/service.go @@ -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