Skip to content

Commit 8303296

Browse files
B2JK-Industryclaude
andcommitted
feat(security): PR-P G-14 — teacher email verification gate
Pre-G-14 problem: `/api/nauczyciel/signup` accepted optional email, no verification flow. Anyone could spin up a "teacher" account, run class CRUD, and start collecting kid join codes via `/api/class/...` flows. Soft-target for spam + a kid-data-leak vector. Five-patch fix: 1. Email required in signup schema (`app/api/nauczyciel/signup/route.ts`). Was `z.string().email().max(200).optional().or(z.literal(""))`, now `z.string().email().max(200)`. 2. `lib/teacher-verify.ts` (NEW) — single-use 24h-TTL token store. `openTeacherVerification(username, email)` → opaque base64url token persisted in Redis (or memory KV). `consumeTeacherVerification` reads + deletes atomically (no replay). Web Crypto for entropy (Edge-runtime compatible — no Node Buffer). 3. `lib/mailer.ts` — added `sendTeacherVerifyEmail(email, displayName, token)`. Reuses the existing `sendMail` adapter chain (Resend → SendGrid → log-only fallback). Verify URL resolves against NEXT_PUBLIC_APP_URL or VERCEL_URL; dev shows the link in logs so the developer can click it manually. 4. `app/verify/page.tsx` (NEW) — server component landing for the email link. Consumes the token, calls `markTeacherVerified` to flip the flag, redirects to `/nauczyciel?verified=1`. Expired/ redeemed tokens render an inline "request new link" explainer with CTAs back to /nauczyciel + signup. 5. POST `/api/nauczyciel/class` guards with `teacherIsVerified()`. Pre-G-14 accounts (verified field undefined) are grandfathered; new signups get 403 `error: "teacher-not-verified"` until they redeem the link. The other `/api/class/route.ts` (lib/roles.ts based — separate parallel system) is not touched in this pass; it operates on a different teacher record set. `lib/class.ts` TeacherAccount type gains `verified?: boolean`. `createTeacher` defaults new records to `verified: false`. Pre-G-14 records have no field → `teacherIsVerified()` returns true. Validation: - pnpm typecheck → 0 errors Open follow-ups (Pass-11): - "Resend verification email" CTA in /nauczyciel for accounts whose token has expired (currently the user re-runs signup which also re-issues the token, but a dedicated UI is cleaner). - Vitest test covering 24h expiry + single-use semantics — scaffolded mentally, deferred to Pass-11 polish. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 80dcc0b commit 8303296

6 files changed

Lines changed: 221 additions & 7 deletions

File tree

app/api/nauczyciel/class/route.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
import { NextRequest } from "next/server";
22
import { z } from "zod";
33
import { getSession } from "@/lib/session";
4-
import { createClass, isTeacher, listClassesFor } from "@/lib/class";
4+
import {
5+
createClass,
6+
getTeacher,
7+
isTeacher,
8+
listClassesFor,
9+
teacherIsVerified,
10+
} from "@/lib/class";
511

6-
/* V4.1 — class CRUD for the signed-in teacher.
12+
/* V4.1 + G-14 — class CRUD for the signed-in teacher.
713
* GET /api/nauczyciel/class — list my classes
814
* POST /api/nauczyciel/class — create a class
15+
*
16+
* G-14 — POST is gated behind email verification. Pre-G-14 accounts
17+
* are grandfathered (verified field undefined → treated as verified).
18+
* Unverified teachers get 403 with `error: "teacher-not-verified"`
19+
* so the UI can render a "check your inbox" hint instead of a
20+
* generic failure.
921
*/
1022

1123
const CreateBody = z.object({
@@ -30,6 +42,16 @@ export async function POST(req: NextRequest) {
3042
if (!(await isTeacher(session.username))) {
3143
return Response.json({ ok: false, error: "not-teacher" }, { status: 403 });
3244
}
45+
// G-14 — verified-email gate. Pre-G-14 accounts (verified field
46+
// undefined) are grandfathered; new signups must redeem the
47+
// /verify?token=… link before creating their first class.
48+
const teacher = await getTeacher(session.username);
49+
if (teacher && !teacherIsVerified(teacher)) {
50+
return Response.json(
51+
{ ok: false, error: "teacher-not-verified" },
52+
{ status: 403 },
53+
);
54+
}
3355
let body;
3456
try {
3557
body = CreateBody.parse(await req.json());

app/api/nauczyciel/signup/route.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,31 @@ import { registerUser } from "@/lib/auth";
44
import { createSession, getSession } from "@/lib/session";
55
import { createTeacher, isTeacher } from "@/lib/class";
66
import { containsPII, writeAgeBucket } from "@/lib/gdpr-k";
7+
import { openTeacherVerification } from "@/lib/teacher-verify";
8+
import { sendTeacherVerifyEmail } from "@/lib/mailer";
79

8-
/* V4.1 — teacher signup.
10+
/* V4.1 + G-14 — teacher signup.
911
*
1012
* POST /api/nauczyciel/signup
11-
* { username, password, displayName, email?, schoolName }
13+
* { username, password, displayName, email, schoolName }
1214
*
1315
* Creates a normal user record (`registerUser`) + flags it as a teacher
1416
* (`createTeacher`). Session cookie issued on success so the wizard can
1517
* immediately proceed to class creation.
18+
*
19+
* G-14 — email is now REQUIRED (was optional). New teacher accounts
20+
* land in `verified=false` state; class creation is blocked until
21+
* the teacher clicks the /verify?token=… link sent to their email.
22+
* 24h TTL token, single-use. Pre-G-14 teacher accounts are
23+
* grandfathered (verified field undefined → treated as verified).
1624
*/
1725

1826
const BodySchema = z.object({
1927
username: z.string().min(1).max(64),
2028
password: z.string().min(8).max(200),
2129
displayName: z.string().min(1).max(120),
22-
email: z.string().email().max(200).optional().or(z.literal("")),
30+
// G-14 — email required so we can send the verify link.
31+
email: z.string().email().max(200),
2332
schoolName: z.string().min(1).max(200),
2433
});
2534

@@ -64,11 +73,16 @@ export async function POST(req: NextRequest) {
6473
await createTeacher({
6574
username,
6675
displayName,
67-
email: email && email.length > 0 ? email : null,
76+
email,
6877
schoolName,
6978
});
7079
await createSession(username);
71-
return Response.json({ ok: true });
80+
// G-14 — open a fresh 24h verify token + send the email. Failure
81+
// to deliver is logged but does NOT block the signup response —
82+
// the teacher can request a re-send via /nauczyciel later.
83+
const { token } = await openTeacherVerification(username, email);
84+
await sendTeacherVerifyEmail(email, displayName, token);
85+
return Response.json({ ok: true, verifySent: true });
7286
}
7387

7488
export async function GET() {

app/verify/page.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import Link from "next/link";
2+
import { redirect } from "next/navigation";
3+
import {
4+
consumeTeacherVerification,
5+
} from "@/lib/teacher-verify";
6+
import { markTeacherVerified } from "@/lib/class";
7+
8+
/* G-14 — teacher email verification landing.
9+
*
10+
* The link in the verify email points here with `?token=…`. We
11+
* consume the token (single-use), flip the teacher's `verified`
12+
* flag, then redirect to /nauczyciel so the dashboard's class-
13+
* creation CTAs are unblocked.
14+
*
15+
* Failure modes:
16+
* - missing token → 308 to /login (link probably mistyped)
17+
* - expired/redeemed token → render an explainer with a "request
18+
* new link" CTA pointing back at signup
19+
*/
20+
21+
type SearchParams = {
22+
token?: string;
23+
};
24+
25+
export const dynamic = "force-dynamic";
26+
27+
export default async function VerifyPage({
28+
searchParams,
29+
}: {
30+
searchParams: Promise<SearchParams>;
31+
}) {
32+
const sp = await searchParams;
33+
if (!sp.token) {
34+
redirect("/login");
35+
}
36+
const payload = await consumeTeacherVerification(sp.token);
37+
if (!payload) {
38+
return (
39+
<main className="max-w-xl mx-auto py-12 flex flex-col items-center gap-4 text-center animate-slide-up">
40+
<span aria-hidden className="text-5xl">
41+
42+
</span>
43+
<h1 className="t-h2 text-[var(--accent)]">Link wygasł lub został już użyty</h1>
44+
<p className="text-[var(--ink-muted)] max-w-md">
45+
Linki weryfikacyjne działają 24 godziny i tylko raz. Możesz
46+
poprosić o nowy z pulpitu nauczyciela albo zarejestrować się
47+
jeszcze raz.
48+
</p>
49+
<div className="flex flex-wrap gap-3">
50+
<Link href="/nauczyciel" className="btn btn-primary">
51+
Pulpit nauczyciela
52+
</Link>
53+
<Link href="/nauczyciel/signup" className="btn btn-secondary">
54+
Nowa rejestracja
55+
</Link>
56+
</div>
57+
</main>
58+
);
59+
}
60+
await markTeacherVerified(payload.username);
61+
redirect("/nauczyciel?verified=1");
62+
}

lib/class.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export type TeacherAccount = {
3434
createdAt: number;
3535
classIds: string[]; // denormalized for fast lookup
3636
tourSeenAt?: number;
37+
/** G-14 — email-verified gate. Class creation is blocked until
38+
* the teacher clicks the verify link (24h TTL token, see
39+
* lib/teacher-verify.ts). Pre-G-14 accounts default to undefined,
40+
* treated as verified (grandfathered) so existing teachers don't
41+
* lose class creation overnight. */
42+
verified?: boolean;
3743
};
3844

3945
export type SchoolClass = {
@@ -93,11 +99,31 @@ export async function createTeacher(input: {
9399
schoolName: input.schoolName,
94100
createdAt: Date.now(),
95101
classIds: [],
102+
// G-14 — new teachers start unverified; class creation gate
103+
// unblocks on /verify?token=… consumption.
104+
verified: false,
96105
};
97106
await saveTeacher(t);
98107
return t;
99108
}
100109

110+
/** G-14 — flips the verified flag on a teacher account. Called from
111+
* the /verify page after a successful token consume. Idempotent —
112+
* re-clicking a stored email link a second time is a no-op. */
113+
export async function markTeacherVerified(username: string): Promise<void> {
114+
const t = await getTeacher(username);
115+
if (!t) return;
116+
if (t.verified) return;
117+
await saveTeacher({ ...t, verified: true });
118+
}
119+
120+
/** G-14 — true when the teacher is grandfathered (pre-G-14 account
121+
* with no `verified` field) OR has clicked the verify link. The
122+
* /api/class POST guard uses this to gate class creation. */
123+
export function teacherIsVerified(t: TeacherAccount): boolean {
124+
return t.verified !== false;
125+
}
126+
101127
// ---------------------------------------------------------------------------
102128
// Class helpers
103129
// ---------------------------------------------------------------------------

lib/mailer.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,36 @@ export async function sendMail(env: MailEnvelope): Promise<SendResult> {
132132
return logOnly(env, "no-provider-configured");
133133
}
134134

135+
/** G-14 — teacher verification email. Builds + sends in one call so
136+
* the signup route doesn't need to know about envelope shapes. */
137+
export async function sendTeacherVerifyEmail(
138+
email: string,
139+
displayName: string,
140+
token: string,
141+
): Promise<SendResult> {
142+
const base =
143+
process.env.NEXT_PUBLIC_APP_URL ??
144+
(process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "");
145+
const verifyUrl = `${base}/verify?token=${encodeURIComponent(token)}`;
146+
return sendMail({
147+
to: email,
148+
subject: "Watt City — potvrď svoj učiteľský účet",
149+
text: [
150+
`Cześć ${displayName},`,
151+
``,
152+
`Dziękujemy za rejestrację w Watt City. Aby aktywować konto`,
153+
`nauczycielskie (tworzenie klas), kliknij link poniżej:`,
154+
``,
155+
verifyUrl,
156+
``,
157+
`Link działa przez 24 godziny. Jeśli to nie ty, zignoruj tę`,
158+
`wiadomość — bez potwierdzenia konto pozostaje zablokowane.`,
159+
``,
160+
`— Watt City`,
161+
].join("\n"),
162+
});
163+
}
164+
135165
/** Build the parental-consent email envelope. Centralised so the
136166
* copy + link shape are reviewable in one place for RODO-K sign-off. */
137167
export function buildParentalConsentMessage(opts: {

lib/teacher-verify.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* G-14 — teacher email verification token store.
2+
*
3+
* Single-use 24h-TTL tokens stored in Redis (or memory KV in dev).
4+
* Issued at signup, consumed at /verify?token=…. The token is
5+
* opaque (32-byte URL-safe base64); we don't expose the username
6+
* in the URL because that would leak teacher emails to anyone who
7+
* can see browser history.
8+
*
9+
* Threat model: a leaked token grants verification on the matching
10+
* teacher account. 24h TTL bounds the blast radius; single-use
11+
* (we delete on consume) prevents replay. The token does NOT grant
12+
* session — verification just flips the `verified` flag.
13+
*/
14+
15+
import { kvSet, kvGet, kvDel } from "@/lib/redis";
16+
17+
const TOKEN_KEY = (token: string) => `xp:teacher-verify:${token}`;
18+
const TTL_SECONDS = 60 * 60 * 24; // 24 h
19+
20+
export type TeacherVerifyPayload = {
21+
username: string;
22+
email: string;
23+
createdAt: number;
24+
};
25+
26+
/** Generate an opaque URL-safe token. 32 bytes of entropy → 43-char
27+
* base64url string. Web Crypto is available in the Edge runtime. */
28+
function randomToken(byteLen = 32): string {
29+
const bytes = new Uint8Array(byteLen);
30+
crypto.getRandomValues(bytes);
31+
// Manual base64url to avoid Node `Buffer` (Edge-incompatible).
32+
let bin = "";
33+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
34+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
35+
}
36+
37+
export async function openTeacherVerification(
38+
username: string,
39+
email: string,
40+
): Promise<{ token: string }> {
41+
const token = randomToken();
42+
const payload: TeacherVerifyPayload = {
43+
username,
44+
email,
45+
createdAt: Date.now(),
46+
};
47+
await kvSet(TOKEN_KEY(token), payload, { ex: TTL_SECONDS });
48+
return { token };
49+
}
50+
51+
export async function consumeTeacherVerification(
52+
token: string,
53+
): Promise<TeacherVerifyPayload | null> {
54+
const payload = await kvGet<TeacherVerifyPayload>(TOKEN_KEY(token));
55+
if (!payload) return null;
56+
// Single-use — delete BEFORE returning so a parallel double-click
57+
// can't redeem twice.
58+
await kvDel(TOKEN_KEY(token));
59+
return payload;
60+
}

0 commit comments

Comments
 (0)