fix(auth): enforce live role + active status from DB on every request
JwtStrategy read `role` straight from the JWT payload, so a role change or deactivation stayed ineffective until the 1h access token expired: after a promotion the menus showed (via /users/me) but admin endpoints returned 403 because RolesGuard still saw the old token role; after a demotion the old admin kept API access. Load the user from the DB in validate() and use the current role; reject inactive users. Frontend: poll /users/me in the dashboard layout (+ on window focus) so the sidebar reflects role changes without a hard reload, and redirect away from pages the new role can no longer reach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { UsersService } from '../../users/users.service';
|
||||||
|
|
||||||
interface JwtPayload {
|
interface JwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
@@ -13,7 +14,10 @@ interface JwtPayload {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(configService: ConfigService) {
|
constructor(
|
||||||
|
configService: ConfigService,
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
@@ -22,10 +26,20 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: JwtPayload) {
|
async validate(payload: JwtPayload) {
|
||||||
|
// Resolve the role (and active status) from the database on every request
|
||||||
|
// rather than trusting the token. A token's `role` claim is frozen at login,
|
||||||
|
// so a role change or deactivation would otherwise stay ineffective until the
|
||||||
|
// token expires. `payload.sub` is the impersonated user on impersonation
|
||||||
|
// tokens, so this keeps "login as user" working with the target's live role.
|
||||||
|
const user = await this.usersService.findById(payload.sub);
|
||||||
|
if (!user || !user.isActive) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: payload.sub,
|
id: user.id,
|
||||||
email: payload.email,
|
email: user.email ?? user.phone,
|
||||||
role: payload.role,
|
role: user.role,
|
||||||
// Non-null only while an admin is impersonating this user.
|
// Non-null only while an admin is impersonating this user.
|
||||||
impersonatedBy: payload.act?.sub ?? null,
|
impersonatedBy: payload.act?.sub ?? null,
|
||||||
impersonatorRole: payload.act?.role ?? null,
|
impersonatorRole: payload.act?.role ?? null,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useLocalizedRouter, usePathname } from '@/i18n/navigation';
|
|||||||
import { useT } from '@/i18n/I18nProvider';
|
import { useT } from '@/i18n/I18nProvider';
|
||||||
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
|
||||||
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
||||||
|
import type { User } from '@/types';
|
||||||
import { DeploymentProgressManager } from '@/components/deployment-progress-manager';
|
import { DeploymentProgressManager } from '@/components/deployment-progress-manager';
|
||||||
import { ImpersonationBanner } from '@/components/impersonation-banner';
|
import { ImpersonationBanner } from '@/components/impersonation-banner';
|
||||||
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||||
@@ -72,9 +73,28 @@ const salesNavItems: NavItem[] = [
|
|||||||
{ href: '/dashboard/staff/tickets', labelKey: 'salesTickets', icon: <Briefcase className="w-4 h-4" /> },
|
{ href: '/dashboard/staff/tickets', labelKey: 'salesTickets', icon: <Briefcase className="w-4 h-4" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** The nav items a given role is allowed to see — the source of truth for both the sidebar and access control. */
|
||||||
|
function navItemsForRole(role: User['role'] | undefined): NavItem[] {
|
||||||
|
const items = [...userNavItems];
|
||||||
|
if (role === 'admin') items.push(...adminNavItems);
|
||||||
|
if (role === 'technical') items.push(...technicalNavItems);
|
||||||
|
if (role === 'sales') items.push(...salesNavItems);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `pathname` (locale-stripped) is reachable for the role, matching the sidebar's active-link logic. */
|
||||||
|
function isPathAllowed(pathname: string, role: User['role'] | undefined): boolean {
|
||||||
|
// Admin is a superset and may reach any dashboard page (incl. ones not in its
|
||||||
|
// sidebar, e.g. staff views), so never redirect an admin.
|
||||||
|
if (role === 'admin') return true;
|
||||||
|
return navItemsForRole(role).some(
|
||||||
|
(item) => pathname === item.href || pathname.startsWith(item.href + '/'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { user, isAuthenticated, isLoading, logout } = useAuthStore();
|
const { user, isAuthenticated, isLoading, logout, setUser } = useAuthStore();
|
||||||
const router = useLocalizedRouter();
|
const router = useLocalizedRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
@@ -86,6 +106,34 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, router]);
|
}, [isLoading, isAuthenticated, router]);
|
||||||
|
|
||||||
|
// Re-validate the session so a role change (or deactivation) made by an admin
|
||||||
|
// takes effect without a hard reload. Polls in the background and refetches
|
||||||
|
// when the tab regains focus; a deactivated account 401s and gets logged out
|
||||||
|
// by the axios interceptor.
|
||||||
|
const { data: freshUser } = useQuery<User>({
|
||||||
|
queryKey: ['current-user'],
|
||||||
|
queryFn: () => api.get('/users/me').then((r) => r.data),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push the latest role into the store only when it actually changes, so the
|
||||||
|
// sidebar and role-gated sections re-render against current permissions.
|
||||||
|
useEffect(() => {
|
||||||
|
if (freshUser && freshUser.role !== user?.role) {
|
||||||
|
setUser(freshUser);
|
||||||
|
}
|
||||||
|
}, [freshUser, user?.role, setUser]);
|
||||||
|
|
||||||
|
// If the current role can no longer reach this page (e.g. an admin demoted to
|
||||||
|
// a regular user while sitting on an admin page), bounce to the dashboard home.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && isAuthenticated && user && !isPathAllowed(pathname, user.role)) {
|
||||||
|
router.replace('/dashboard');
|
||||||
|
}
|
||||||
|
}, [pathname, user, isLoading, isAuthenticated, router]);
|
||||||
|
|
||||||
// Close sidebar on route change (mobile)
|
// Close sidebar on route change (mobile)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user