-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathauth.ts
More file actions
130 lines (119 loc) · 5.28 KB
/
Copy pathauth.ts
File metadata and controls
130 lines (119 loc) · 5.28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import { prisma } from '@/lib/prisma';
import { getJwtSecret } from '@/lib/jwt';
import { checkAccessGate, isExemptPath } from '@/lib/accessControl';
import { getRecoveryEmailPolicy, isAllowedWhileRecoveryIncomplete } from '@/lib/recoveryEmailPolicy';
export type AuthUser = {
id: string;
email: string;
role: string;
firstName?: string;
lastName?: string;
};
type Handler = (req: NextRequest, user: AuthUser, context?: any) => Promise<NextResponse>;
export function withAuth(handler: Handler) {
return async (req: NextRequest, context?: any): Promise<NextResponse> => {
const authHeader = req.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
}
const token = authHeader.split(' ')[1];
// Resolved outside the try/catch below so that a missing JWT_SECRET fails
// as a server error instead of being reported to every user as an expired
// token — a misconfigured deploy should be obvious, not look like mass logout.
const secret = getJwtSecret();
let decoded: AuthUser;
try {
decoded = jwt.verify(token, secret) as AuthUser;
} catch {
return NextResponse.json(
{ message: 'Invalid or expired token — please log in again' },
{ status: 401 }
);
}
// Re-read the account from the DB on every request instead of trusting
// the token's baked-in role. A JWT lives 30 days, so without this a
// deactivated user (or one whose role was just switched) keeps their old
// access until the token happens to expire.
const dbUser = await prisma.user.findUnique({
where: { id: decoded.id },
select: {
id: true, email: true, role: true, firstName: true, lastName: true,
isActive: true, deletedAt: true, passwordChangedAt: true,
personalEmail: true, personalEmailVerifiedAt: true,
},
});
if (!dbUser || !dbUser.isActive || dbUser.deletedAt) {
return NextResponse.json({ message: 'Your account is no longer active — please contact your admin' }, { status: 401 });
}
// Revocation for a stateless token. Tokens live 30 days, so without this a
// password reset left any stolen session fully alive — at exactly the
// moment the owner believed they had taken the account back. `iat` is
// stamped by jsonwebtoken at signing time and is in seconds; anything
// issued before the last password change is no longer trusted.
// `Math.floor` on the boundary keeps a token minted in the same second as
// the change from being rejected by sub-second rounding alone.
const issuedAtMs = (decoded as any).iat ? (decoded as any).iat * 1000 : 0;
if (dbUser.passwordChangedAt && issuedAtMs < Math.floor(dbUser.passwordChangedAt.getTime() / 1000) * 1000) {
return NextResponse.json(
{ message: 'Your password was changed — please log in again' },
{ status: 401 }
);
}
const user: AuthUser = {
id: dbUser.id,
email: dbUser.email,
role: dbUser.role,
firstName: dbUser.firstName,
lastName: dbUser.lastName,
};
// Recovery-email requirement. Enforced server-side for the same reason
// the after-hours gate below is: a client-only check is bypassed by
// calling the API directly. Gated behind a configured date so existing
// users are never locked out of a system they were already using.
const recoveryPolicy = getRecoveryEmailPolicy();
if (recoveryPolicy.enforced && !isAllowedWhileRecoveryIncomplete(req.nextUrl.pathname)) {
if (!dbUser.personalEmail || !dbUser.personalEmailVerifiedAt) {
return NextResponse.json(
{
message: 'Add and verify a recovery email in your profile to continue.',
code: 'RECOVERY_EMAIL_REQUIRED',
},
{ status: 403 }
);
}
}
// After-hours access gate — this used to only be checked client-side
// (the dashboard polling /api/access-status), so a restricted user could
// keep calling every API directly. Enforce it here for every route that
// isn't explicitly exempt (login, status/request endpoints, logout, etc).
if (!isExemptPath(req.nextUrl.pathname)) {
const gate = await checkAccessGate(user.role, user.id);
if (gate.blocked) {
return NextResponse.json(
{ message: 'CRM access is restricted right now. Request access from your admin.' },
{ status: 403 }
);
}
}
try {
return await handler(req, user, context);
} catch (err: any) {
const status: number = err.status || 500;
const message: string = err.message || 'Internal server error';
if (status >= 500) console.error('[API ERROR]', err?.message, err?.code, err?.meta);
return NextResponse.json({ message }, { status });
}
};
}
export function requireRoles(allowedRoles: string[]) {
return function (handler: Handler): Handler {
return async (req: NextRequest, user: AuthUser, context?: any): Promise<NextResponse> => {
if (!allowedRoles.includes(user.role)) {
return NextResponse.json({ message: 'Access denied' }, { status: 403 });
}
return handler(req, user, context);
};
};
}