@mars-stack/cli 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# Skill: Configure Two-Factor Authentication
|
|
2
|
+
|
|
3
|
+
Set up TOTP-based two-factor authentication (2FA) with backup codes in a MARS application.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add 2FA, two-factor authentication, TOTP, authenticator app support, or backup codes.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Auth system configured with session management
|
|
12
|
+
- User model exists in Prisma schema
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
MARS 2FA uses TOTP (Time-based One-Time Password) compatible with Google Authenticator, Authy, 1Password, and similar apps. The flow:
|
|
17
|
+
|
|
18
|
+
1. **Setup**: User enables 2FA → server generates a TOTP secret, displays QR code → user scans and enters a code to confirm
|
|
19
|
+
2. **Login**: After password verification, if 2FA is enabled → user enters TOTP code or backup code → session is created
|
|
20
|
+
3. **Backup codes**: Generated during setup, hashed and stored, each single-use
|
|
21
|
+
|
|
22
|
+
## Step 1: Install Dependencies
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
yarn add otpauth qrcode
|
|
26
|
+
yarn add -D @types/qrcode
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `otpauth` — TOTP/HOTP token generation and verification (lightweight, standards-compliant)
|
|
30
|
+
- `qrcode` — QR code generation for the setup URI
|
|
31
|
+
|
|
32
|
+
## Step 2: Prisma Schema
|
|
33
|
+
|
|
34
|
+
```prisma
|
|
35
|
+
// prisma/schema/auth.prisma — add to User model
|
|
36
|
+
model User {
|
|
37
|
+
// ... existing fields
|
|
38
|
+
twoFactorSecret String?
|
|
39
|
+
twoFactorEnabled Boolean @default(false)
|
|
40
|
+
backupCodes String? // JSON array of hashed codes
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run `yarn db:push` after changes.
|
|
45
|
+
|
|
46
|
+
## Step 3: TOTP Service
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
// src/features/auth/server/two-factor.ts
|
|
50
|
+
import 'server-only';
|
|
51
|
+
|
|
52
|
+
import { TOTP } from 'otpauth';
|
|
53
|
+
import { randomBytes, createHash } from 'crypto';
|
|
54
|
+
import { prisma } from '@/lib/prisma';
|
|
55
|
+
import { appConfig } from '@/config/app.config';
|
|
56
|
+
|
|
57
|
+
function hashCode(code: string): string {
|
|
58
|
+
return createHash('sha256').update(code).digest('hex');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function generateBackupCodes(count: number = 10): string[] {
|
|
62
|
+
return Array.from({ length: count }, () =>
|
|
63
|
+
randomBytes(4).toString('hex').toUpperCase().match(/.{4}/g)!.join('-'),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createTOTP(secret: string, email: string): TOTP {
|
|
68
|
+
return new TOTP({
|
|
69
|
+
issuer: appConfig.name,
|
|
70
|
+
label: email,
|
|
71
|
+
algorithm: 'SHA1',
|
|
72
|
+
digits: 6,
|
|
73
|
+
period: 30,
|
|
74
|
+
secret,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function initiate2FASetup(
|
|
79
|
+
userId: string,
|
|
80
|
+
email: string,
|
|
81
|
+
): Promise<{ secret: string; uri: string; qrDataUrl: string }> {
|
|
82
|
+
const { Secret } = await import('otpauth');
|
|
83
|
+
const QRCode = await import('qrcode');
|
|
84
|
+
|
|
85
|
+
const secret = new Secret({ size: 20 });
|
|
86
|
+
const totp = createTOTP(secret.base32, email);
|
|
87
|
+
const uri = totp.toString();
|
|
88
|
+
const qrDataUrl = await QRCode.toDataURL(uri);
|
|
89
|
+
|
|
90
|
+
// Store the secret but don't enable 2FA yet (requires confirmation)
|
|
91
|
+
await prisma.user.update({
|
|
92
|
+
where: { id: userId },
|
|
93
|
+
data: { twoFactorSecret: secret.base32 },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return { secret: secret.base32, uri, qrDataUrl };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function confirm2FASetup(
|
|
100
|
+
userId: string,
|
|
101
|
+
code: string,
|
|
102
|
+
): Promise<{ success: boolean; backupCodes?: string[]; error?: string }> {
|
|
103
|
+
const user = await prisma.user.findUnique({
|
|
104
|
+
where: { id: userId },
|
|
105
|
+
select: { twoFactorSecret: true, email: true },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (!user?.twoFactorSecret) {
|
|
109
|
+
return { success: false, error: '2FA setup not initiated' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const totp = createTOTP(user.twoFactorSecret, user.email);
|
|
113
|
+
const isValid = totp.validate({ token: code, window: 1 }) !== null;
|
|
114
|
+
|
|
115
|
+
if (!isValid) {
|
|
116
|
+
return { success: false, error: 'Invalid code. Please try again.' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const backupCodes = generateBackupCodes();
|
|
120
|
+
const hashedCodes = backupCodes.map(hashCode);
|
|
121
|
+
|
|
122
|
+
await prisma.user.update({
|
|
123
|
+
where: { id: userId },
|
|
124
|
+
data: {
|
|
125
|
+
twoFactorEnabled: true,
|
|
126
|
+
backupCodes: JSON.stringify(hashedCodes),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return { success: true, backupCodes };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function verify2FACode(
|
|
134
|
+
userId: string,
|
|
135
|
+
code: string,
|
|
136
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
137
|
+
const user = await prisma.user.findUnique({
|
|
138
|
+
where: { id: userId },
|
|
139
|
+
select: { twoFactorSecret: true, twoFactorEnabled: true, email: true, backupCodes: true },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!user?.twoFactorEnabled || !user.twoFactorSecret) {
|
|
143
|
+
return { success: false, error: '2FA is not enabled' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Try TOTP code first
|
|
147
|
+
const totp = createTOTP(user.twoFactorSecret, user.email);
|
|
148
|
+
const isValidTOTP = totp.validate({ token: code, window: 1 }) !== null;
|
|
149
|
+
|
|
150
|
+
if (isValidTOTP) {
|
|
151
|
+
return { success: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Try backup code
|
|
155
|
+
const hashedInput = hashCode(code);
|
|
156
|
+
const storedCodes: string[] = JSON.parse(user.backupCodes || '[]');
|
|
157
|
+
const codeIndex = storedCodes.findIndex((stored) => stored === hashedInput);
|
|
158
|
+
|
|
159
|
+
if (codeIndex !== -1) {
|
|
160
|
+
// Remove the used backup code
|
|
161
|
+
storedCodes.splice(codeIndex, 1);
|
|
162
|
+
await prisma.user.update({
|
|
163
|
+
where: { id: userId },
|
|
164
|
+
data: { backupCodes: JSON.stringify(storedCodes) },
|
|
165
|
+
});
|
|
166
|
+
return { success: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { success: false, error: 'Invalid code' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function disable2FA(userId: string): Promise<void> {
|
|
173
|
+
await prisma.user.update({
|
|
174
|
+
where: { id: userId },
|
|
175
|
+
data: {
|
|
176
|
+
twoFactorEnabled: false,
|
|
177
|
+
twoFactorSecret: null,
|
|
178
|
+
backupCodes: null,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function regenerateBackupCodes(userId: string): Promise<string[]> {
|
|
184
|
+
const backupCodes = generateBackupCodes();
|
|
185
|
+
const hashedCodes = backupCodes.map(hashCode);
|
|
186
|
+
|
|
187
|
+
await prisma.user.update({
|
|
188
|
+
where: { id: userId },
|
|
189
|
+
data: { backupCodes: JSON.stringify(hashedCodes) },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return backupCodes;
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Step 4: 2FA Setup API Routes
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
// src/app/api/protected/user/two-factor/setup/route.ts
|
|
200
|
+
import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
|
|
201
|
+
import { initiate2FASetup } from '@/features/auth/server/two-factor';
|
|
202
|
+
import { NextResponse } from 'next/server';
|
|
203
|
+
|
|
204
|
+
export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
205
|
+
try {
|
|
206
|
+
const result = await initiate2FASetup(request.session.userId, request.session.email);
|
|
207
|
+
return NextResponse.json({
|
|
208
|
+
qrDataUrl: result.qrDataUrl,
|
|
209
|
+
secret: result.secret,
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return handleApiError(error, { endpoint: '/api/protected/user/two-factor/setup' });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// src/app/api/protected/user/two-factor/confirm/route.ts
|
|
219
|
+
import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
|
|
220
|
+
import { confirm2FASetup } from '@/features/auth/server/two-factor';
|
|
221
|
+
import { NextResponse } from 'next/server';
|
|
222
|
+
import { z } from 'zod';
|
|
223
|
+
|
|
224
|
+
const confirmSchema = z.object({
|
|
225
|
+
code: z.string().length(6, 'Code must be 6 digits'),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
229
|
+
try {
|
|
230
|
+
const { code } = confirmSchema.parse(await request.json());
|
|
231
|
+
const result = await confirm2FASetup(request.session.userId, code);
|
|
232
|
+
|
|
233
|
+
if (!result.success) {
|
|
234
|
+
return NextResponse.json({ error: result.error }, { status: 400 });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return NextResponse.json({ backupCodes: result.backupCodes });
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return handleApiError(error, { endpoint: '/api/protected/user/two-factor/confirm' });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
// src/app/api/protected/user/two-factor/disable/route.ts
|
|
246
|
+
import { handleApiError, withAuthNoParams, type AuthenticatedRequest } from '@/lib/mars';
|
|
247
|
+
import { disable2FA } from '@/features/auth/server/two-factor';
|
|
248
|
+
import { NextResponse } from 'next/server';
|
|
249
|
+
|
|
250
|
+
export const POST = withAuthNoParams(async (request: AuthenticatedRequest) => {
|
|
251
|
+
try {
|
|
252
|
+
await disable2FA(request.session.userId);
|
|
253
|
+
return NextResponse.json({ message: '2FA has been disabled' });
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return handleApiError(error, { endpoint: '/api/protected/user/two-factor/disable' });
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Step 5: Modify Login Flow
|
|
261
|
+
|
|
262
|
+
Update the login route to check for 2FA after password verification:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// In src/app/api/auth/login/route.ts — after password verification
|
|
266
|
+
import { prisma } from '@/lib/prisma';
|
|
267
|
+
|
|
268
|
+
// After verifying password...
|
|
269
|
+
const user = await prisma.user.findUnique({
|
|
270
|
+
where: { email },
|
|
271
|
+
select: { id: true, twoFactorEnabled: true },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (user.twoFactorEnabled) {
|
|
275
|
+
// Return a pending-2FA response with a temporary token
|
|
276
|
+
const pendingToken = randomBytes(32).toString('hex');
|
|
277
|
+
// Store in short-lived cache or DB with 5-minute expiry
|
|
278
|
+
await storePending2FAToken(pendingToken, user.id);
|
|
279
|
+
|
|
280
|
+
return NextResponse.json({
|
|
281
|
+
requiresTwoFactor: true,
|
|
282
|
+
pendingToken,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// If no 2FA, create session as normal
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
// src/app/api/auth/two-factor/verify/route.ts
|
|
291
|
+
import { handleApiError } from '@/lib/mars';
|
|
292
|
+
import { verify2FACode } from '@/features/auth/server/two-factor';
|
|
293
|
+
import { createSession } from '@/features/auth/server/sessions';
|
|
294
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
295
|
+
import { z } from 'zod';
|
|
296
|
+
|
|
297
|
+
const verifySchema = z.object({
|
|
298
|
+
pendingToken: z.string().min(1),
|
|
299
|
+
code: z.string().min(1),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
export async function POST(request: NextRequest) {
|
|
303
|
+
try {
|
|
304
|
+
const { pendingToken, code } = verifySchema.parse(await request.json());
|
|
305
|
+
|
|
306
|
+
const userId = await validatePending2FAToken(pendingToken);
|
|
307
|
+
if (!userId) {
|
|
308
|
+
return NextResponse.json({ error: 'Invalid or expired session' }, { status: 401 });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const result = await verify2FACode(userId, code);
|
|
312
|
+
if (!result.success) {
|
|
313
|
+
return NextResponse.json({ error: result.error }, { status: 401 });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const response = NextResponse.json({ success: true });
|
|
317
|
+
await createSession(userId, response);
|
|
318
|
+
return response;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
return handleApiError(error, { endpoint: '/api/auth/two-factor/verify' });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Step 6: Settings UI — 2FA Management
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
// src/features/auth/components/TwoFactorSetup.tsx
|
|
329
|
+
'use client';
|
|
330
|
+
|
|
331
|
+
import { useState } from 'react';
|
|
332
|
+
|
|
333
|
+
type SetupStep = 'idle' | 'scanning' | 'confirming' | 'backup-codes' | 'complete';
|
|
334
|
+
|
|
335
|
+
export function TwoFactorSetup({ enabled }: { enabled: boolean }) {
|
|
336
|
+
const [step, setStep] = useState<SetupStep>('idle');
|
|
337
|
+
const [qrDataUrl, setQrDataUrl] = useState('');
|
|
338
|
+
const [secret, setSecret] = useState('');
|
|
339
|
+
const [code, setCode] = useState('');
|
|
340
|
+
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
341
|
+
const [error, setError] = useState('');
|
|
342
|
+
const [loading, setLoading] = useState(false);
|
|
343
|
+
|
|
344
|
+
async function handleStartSetup() {
|
|
345
|
+
setLoading(true);
|
|
346
|
+
setError('');
|
|
347
|
+
try {
|
|
348
|
+
const res = await fetch('/api/protected/user/two-factor/setup', { method: 'POST' });
|
|
349
|
+
const data = await res.json();
|
|
350
|
+
setQrDataUrl(data.qrDataUrl);
|
|
351
|
+
setSecret(data.secret);
|
|
352
|
+
setStep('scanning');
|
|
353
|
+
} catch {
|
|
354
|
+
setError('Failed to start setup');
|
|
355
|
+
} finally {
|
|
356
|
+
setLoading(false);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function handleConfirm() {
|
|
361
|
+
setLoading(true);
|
|
362
|
+
setError('');
|
|
363
|
+
try {
|
|
364
|
+
const res = await fetch('/api/protected/user/two-factor/confirm', {
|
|
365
|
+
method: 'POST',
|
|
366
|
+
headers: { 'Content-Type': 'application/json' },
|
|
367
|
+
body: JSON.stringify({ code }),
|
|
368
|
+
});
|
|
369
|
+
const data = await res.json();
|
|
370
|
+
|
|
371
|
+
if (!res.ok) {
|
|
372
|
+
setError(data.error || 'Invalid code');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
setBackupCodes(data.backupCodes);
|
|
377
|
+
setStep('backup-codes');
|
|
378
|
+
} catch {
|
|
379
|
+
setError('Verification failed');
|
|
380
|
+
} finally {
|
|
381
|
+
setLoading(false);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function handleDisable() {
|
|
386
|
+
setLoading(true);
|
|
387
|
+
try {
|
|
388
|
+
await fetch('/api/protected/user/two-factor/disable', { method: 'POST' });
|
|
389
|
+
window.location.reload();
|
|
390
|
+
} catch {
|
|
391
|
+
setError('Failed to disable 2FA');
|
|
392
|
+
} finally {
|
|
393
|
+
setLoading(false);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (enabled) {
|
|
398
|
+
return (
|
|
399
|
+
<div className="space-y-4">
|
|
400
|
+
<div className="flex items-center gap-2">
|
|
401
|
+
<span className="h-2 w-2 rounded-full bg-status-success" />
|
|
402
|
+
<span className="text-content-primary font-medium">Two-factor authentication is enabled</span>
|
|
403
|
+
</div>
|
|
404
|
+
<button
|
|
405
|
+
onClick={handleDisable}
|
|
406
|
+
disabled={loading}
|
|
407
|
+
className="rounded-md bg-status-error px-4 py-2 text-white hover:bg-status-error/90 disabled:opacity-50"
|
|
408
|
+
>
|
|
409
|
+
Disable 2FA
|
|
410
|
+
</button>
|
|
411
|
+
</div>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Render setup flow based on current step...
|
|
416
|
+
// (scanning → QR code display, confirming → code input, backup-codes → display codes)
|
|
417
|
+
return (
|
|
418
|
+
<div className="space-y-4">
|
|
419
|
+
{step === 'idle' && (
|
|
420
|
+
<button
|
|
421
|
+
onClick={handleStartSetup}
|
|
422
|
+
disabled={loading}
|
|
423
|
+
className="rounded-md bg-interactive-primary px-4 py-2 text-white hover:bg-interactive-primary-hover disabled:opacity-50"
|
|
424
|
+
>
|
|
425
|
+
Enable Two-Factor Authentication
|
|
426
|
+
</button>
|
|
427
|
+
)}
|
|
428
|
+
|
|
429
|
+
{step === 'scanning' && (
|
|
430
|
+
<div className="space-y-4">
|
|
431
|
+
<p className="text-content-secondary">Scan this QR code with your authenticator app:</p>
|
|
432
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
433
|
+
<img src={qrDataUrl} alt="2FA QR Code" className="mx-auto h-48 w-48" />
|
|
434
|
+
<details className="text-sm">
|
|
435
|
+
<summary className="cursor-pointer text-content-tertiary">Can't scan? Enter manually</summary>
|
|
436
|
+
<code className="mt-2 block break-all rounded bg-surface-secondary p-2 text-xs">{secret}</code>
|
|
437
|
+
</details>
|
|
438
|
+
<button
|
|
439
|
+
onClick={() => setStep('confirming')}
|
|
440
|
+
className="rounded-md bg-interactive-primary px-4 py-2 text-white hover:bg-interactive-primary-hover"
|
|
441
|
+
>
|
|
442
|
+
Next
|
|
443
|
+
</button>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
{step === 'confirming' && (
|
|
448
|
+
<div className="space-y-4">
|
|
449
|
+
<p className="text-content-secondary">Enter the 6-digit code from your authenticator app:</p>
|
|
450
|
+
<input
|
|
451
|
+
type="text"
|
|
452
|
+
value={code}
|
|
453
|
+
onChange={(e) => setCode(e.target.value)}
|
|
454
|
+
maxLength={6}
|
|
455
|
+
placeholder="000000"
|
|
456
|
+
className="block w-full rounded-md border border-border-primary bg-surface-primary px-3 py-2 text-center text-2xl tracking-widest text-content-primary focus:border-interactive-primary focus:outline-none focus:ring-1 focus:ring-interactive-primary"
|
|
457
|
+
/>
|
|
458
|
+
{error && <p className="text-sm text-status-error">{error}</p>}
|
|
459
|
+
<button
|
|
460
|
+
onClick={handleConfirm}
|
|
461
|
+
disabled={loading || code.length !== 6}
|
|
462
|
+
className="w-full rounded-md bg-interactive-primary px-4 py-2 text-white hover:bg-interactive-primary-hover disabled:opacity-50"
|
|
463
|
+
>
|
|
464
|
+
Verify & Enable
|
|
465
|
+
</button>
|
|
466
|
+
</div>
|
|
467
|
+
)}
|
|
468
|
+
|
|
469
|
+
{step === 'backup-codes' && (
|
|
470
|
+
<div className="space-y-4">
|
|
471
|
+
<h3 className="text-lg font-semibold text-content-primary">Save your backup codes</h3>
|
|
472
|
+
<p className="text-content-secondary">
|
|
473
|
+
Store these codes in a safe place. Each can be used once if you lose access to your authenticator.
|
|
474
|
+
</p>
|
|
475
|
+
<div className="grid grid-cols-2 gap-2 rounded-md bg-surface-secondary p-4">
|
|
476
|
+
{backupCodes.map((bc) => (
|
|
477
|
+
<code key={bc} className="font-mono text-sm text-content-primary">{bc}</code>
|
|
478
|
+
))}
|
|
479
|
+
</div>
|
|
480
|
+
<button
|
|
481
|
+
onClick={() => setStep('complete')}
|
|
482
|
+
className="rounded-md bg-interactive-primary px-4 py-2 text-white hover:bg-interactive-primary-hover"
|
|
483
|
+
>
|
|
484
|
+
I've saved my codes
|
|
485
|
+
</button>
|
|
486
|
+
</div>
|
|
487
|
+
)}
|
|
488
|
+
|
|
489
|
+
{step === 'complete' && (
|
|
490
|
+
<p className="text-status-success font-medium">Two-factor authentication is now enabled.</p>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
## Testing
|
|
498
|
+
|
|
499
|
+
1. Enable 2FA in settings — scan QR with an authenticator app (or use the manual key).
|
|
500
|
+
2. Enter the 6-digit code — verify backup codes are displayed.
|
|
501
|
+
3. Log out and log back in — verify 2FA prompt appears after password.
|
|
502
|
+
4. Enter a valid TOTP code — verify login succeeds.
|
|
503
|
+
5. Enter an invalid code — verify it's rejected.
|
|
504
|
+
6. Use a backup code — verify login succeeds and the code is consumed.
|
|
505
|
+
7. Try the same backup code again — verify it's rejected.
|
|
506
|
+
8. Disable 2FA — verify login no longer requires a code.
|
|
507
|
+
|
|
508
|
+
## Checklist
|
|
509
|
+
|
|
510
|
+
- [ ] `otpauth` and `qrcode` installed
|
|
511
|
+
- [ ] User model updated with `twoFactorSecret`, `twoFactorEnabled`, `backupCodes`
|
|
512
|
+
- [ ] TOTP service handles setup, confirm, verify, disable, and backup code regeneration
|
|
513
|
+
- [ ] Login flow branched to handle 2FA prompt
|
|
514
|
+
- [ ] 2FA verification route creates session after code validation
|
|
515
|
+
- [ ] Backup codes are hashed and single-use
|
|
516
|
+
- [ ] Settings UI allows enable/disable with QR code flow
|
|
517
|
+
- [ ] Pending-2FA tokens are short-lived (5 minutes)
|
|
518
|
+
- [ ] `db:push` run after schema changes
|