Shoraky commited on
Commit
d579c21
·
verified ·
1 Parent(s): b7f7c6d

Add password recovery API

Browse files
Files changed (3) hide show
  1. README.md +14 -0
  2. src/auth.ts +35 -0
  3. src/server.ts +64 -1
README.md CHANGED
@@ -76,6 +76,17 @@ Brevo is configured as Supabase Auth's SMTP provider; the Express API does not s
76
 
77
  Set **Site URL** to the frontend origin. Allow the frontend callback URLs listed above. The Pages proxy forwards confirmation to the backend, which verifies the one-time token and redirects to `/login?verified=1`; the user then signs in with their email and password. Existing email links using the Space origin should be replaced by newly issued emails.
78
 
 
 
 
 
 
 
 
 
 
 
 
79
  Brevo credentials stay in Supabase SMTP settings and are not added to the browser or committed to `.env`.
80
 
81
  ## Routes
@@ -85,6 +96,9 @@ Brevo credentials stay in Supabase SMTP settings and are not added to the browse
85
  - `GET /api/auth/google?redirect=/labs` — starts Google OAuth.
86
  - `GET /api/auth/callback` — exchanges the Supabase code and sets HttpOnly cookies.
87
  - `GET /api/auth/confirm?token_hash=...` — verifies the signup email and redirects to the login page with a confirmation message.
 
 
 
88
  - `GET /api/auth/me` — verifies the session and refreshes it when necessary.
89
  - `POST /api/auth/logout` — clears the session cookies.
90
  - `GET /api/auth/profile` and `PATCH /api/auth/profile` — read and update the signed-in user's profile.
 
76
 
77
  Set **Site URL** to the frontend origin. Allow the frontend callback URLs listed above. The Pages proxy forwards confirmation to the backend, which verifies the one-time token and redirects to `/login?verified=1`; the user then signs in with their email and password. Existing email links using the Space origin should be replaced by newly issued emails.
78
 
79
+ 6. In **Authentication → Email Templates → Reset Password**, use a token-hash link to the public reset page. The page asks the user to confirm before exchanging the one-time token, so email security scanners do not consume it:
80
+
81
+ ```html
82
+ <h2>Reset your Sporalize password</h2>
83
+ <p>We received a request to reset the password for your Sporalize account.</p>
84
+ <p><a href="{{ .SiteURL }}/reset-password?token_hash={{ .TokenHash }}&amp;type=recovery">Reset password</a></p>
85
+ <p>If you did not request this, you can ignore this email.</p>
86
+ ```
87
+
88
+ Keep the Supabase **Site URL** set to the frontend origin (`https://sporalize.pages.dev` in production). Add `https://sporalize.pages.dev/reset-password` and `http://localhost:8080/reset-password` to the allowed redirect URLs for production and local development.
89
+
90
  Brevo credentials stay in Supabase SMTP settings and are not added to the browser or committed to `.env`.
91
 
92
  ## Routes
 
96
  - `GET /api/auth/google?redirect=/labs` — starts Google OAuth.
97
  - `GET /api/auth/callback` — exchanges the Supabase code and sets HttpOnly cookies.
98
  - `GET /api/auth/confirm?token_hash=...` — verifies the signup email and redirects to the login page with a confirmation message.
99
+ - `POST /api/auth/password/reset` — requests a password-reset email without revealing whether the email is registered.
100
+ - `POST /api/auth/password/recovery/verify` — verifies the one-time recovery token and starts a short-lived recovery session.
101
+ - `POST /api/auth/password/recovery` — changes the password only during that verified recovery session.
102
  - `GET /api/auth/me` — verifies the session and refreshes it when necessary.
103
  - `POST /api/auth/logout` — clears the session cookies.
104
  - `GET /api/auth/profile` and `PATCH /api/auth/profile` — read and update the signed-in user's profile.
src/auth.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import type { Request, Response } from "express";
2
  import type { Session, User } from "@supabase/supabase-js";
3
  import { env, isProduction } from "./config.js";
@@ -5,8 +6,42 @@ import { supabaseAuth } from "./supabase.js";
5
 
6
  const accessCookie = "sp_access";
7
  const refreshCookie = "sp_refresh";
 
 
8
  const sameSite = "lax" as const;
9
  const cookieOptions = { httpOnly: true, secure: isProduction, sameSite, path: "/" };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  export function setSessionCookies(res: Response, session: Session) {
12
  res.cookie(accessCookie, session.access_token, { ...cookieOptions, maxAge: Math.max(session.expires_in * 1000, 60_000) });
 
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
  import type { Request, Response } from "express";
3
  import type { Session, User } from "@supabase/supabase-js";
4
  import { env, isProduction } from "./config.js";
 
6
 
7
  const accessCookie = "sp_access";
8
  const refreshCookie = "sp_refresh";
9
+ const passwordRecoveryCookie = "sp_password_recovery";
10
+ const passwordRecoveryWindowMs = 15 * 60 * 1000;
11
  const sameSite = "lax" as const;
12
  const cookieOptions = { httpOnly: true, secure: isProduction, sameSite, path: "/" };
13
+ const passwordRecoveryCookieOptions = {
14
+ httpOnly: true, secure: isProduction, sameSite, path: "/api/auth/password/recovery",
15
+ };
16
+
17
+ function passwordRecoverySignature(userId: string, expiresAt: number) {
18
+ return createHmac("sha256", env.SUPABASE_SERVICE_ROLE_KEY)
19
+ .update(`sporalize-password-recovery:${userId}:${expiresAt}`)
20
+ .digest("base64url");
21
+ }
22
+
23
+ export function setPasswordRecoveryCookie(res: Response, userId: string) {
24
+ const expiresAt = Date.now() + passwordRecoveryWindowMs;
25
+ res.cookie(passwordRecoveryCookie, `${expiresAt}.${passwordRecoverySignature(userId, expiresAt)}`, {
26
+ ...passwordRecoveryCookieOptions, maxAge: passwordRecoveryWindowMs,
27
+ });
28
+ }
29
+
30
+ export function hasPasswordRecoveryCookie(req: Request, userId: string) {
31
+ const value = req.cookies?.[passwordRecoveryCookie];
32
+ if (typeof value !== "string") return false;
33
+ const [expiresText, suppliedSignature, extra] = value.split(".");
34
+ const expiresAt = Number(expiresText);
35
+ if (extra !== undefined || !Number.isSafeInteger(expiresAt) || expiresAt <= Date.now() ||
36
+ expiresAt > Date.now() + passwordRecoveryWindowMs + 5_000 || !suppliedSignature) return false;
37
+ const expected = Buffer.from(passwordRecoverySignature(userId, expiresAt));
38
+ const supplied = Buffer.from(suppliedSignature);
39
+ return expected.length === supplied.length && timingSafeEqual(expected, supplied);
40
+ }
41
+
42
+ export function clearPasswordRecoveryCookie(res: Response) {
43
+ res.clearCookie(passwordRecoveryCookie, passwordRecoveryCookieOptions);
44
+ }
45
 
46
  export function setSessionCookies(res: Response, session: Session) {
47
  res.cookie(accessCookie, session.access_token, { ...cookieOptions, maxAge: Math.max(session.expires_in * 1000, 60_000) });
src/server.ts CHANGED
@@ -9,7 +9,7 @@ import { pipeline } from "node:stream/promises";
9
  import { z } from "zod";
10
  import { authPublicOrigin, env } from "./config.js";
11
  import { analysisRouter } from "./analysis.js";
12
- import { authenticatedUser, clearSessionCookies, safeRedirect, setSessionCookies } from "./auth.js";
13
  import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
14
 
15
  const app = express();
@@ -46,10 +46,15 @@ app.use((req, res, next) => {
46
  });
47
 
48
  const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 25, standardHeaders: "draft-8", legacyHeaders: false });
 
 
49
  const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 3000, standardHeaders: "draft-8", legacyHeaders: false });
50
  const analysisUploadLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 4, standardHeaders: "draft-8", legacyHeaders: false });
51
  const signupSchema = z.object({ fname: z.string().trim().min(1).max(100), lname: z.string().trim().min(1).max(100), email: z.string().trim().toLowerCase().email().max(255), password: z.string().min(8).max(128), academy: z.string().trim().max(150).optional() });
52
  const loginSchema = z.object({ email: z.string().trim().toLowerCase().email(), password: z.string().min(1).max(128) });
 
 
 
53
  const profileRoles = ["football_player", "coach", "club_manager", "academy_manager", "scout", "performance_analyst", "sports_director", "other"] as const;
54
  const profileSchema = z.object({
55
  fname: z.string().trim().min(1).max(100),
@@ -402,12 +407,66 @@ app.post("/api/auth/signup", authLimiter, async (req, res, next) => {
402
  if (data.user?.identities?.length) await syncProfile(data.user, { ...input, passwordHash });
403
  if (data.user && data.session) {
404
  setSessionCookies(res, data.session);
 
405
  return res.status(201).json({ user: userPayload(data.user), requiresEmailVerification: false });
406
  }
407
  return res.status(201).json({ user: data.user ? userPayload(data.user) : null, requiresEmailVerification: true });
408
  } catch (error) { next(error); }
409
  });
410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  app.post("/api/auth/login", authLimiter, async (req, res, next) => {
412
  try {
413
  const input = loginSchema.parse(req.body);
@@ -415,6 +474,7 @@ app.post("/api/auth/login", authLimiter, async (req, res, next) => {
415
  if (error || !data.user || !data.session) return res.status(401).json({ error: "Invalid email or password." });
416
  await syncProfile(data.user);
417
  setSessionCookies(res, data.session);
 
418
  const subscription = await activeSubscriptionFor(data.user.id);
419
  return res.json({ user: userPayload(data.user), access: { active: Boolean(subscription), subscription } });
420
  } catch (error) { next(error); }
@@ -439,6 +499,7 @@ app.get("/api/auth/callback", async (req, res, next) => {
439
  if (error || !data.user || !data.session) return res.redirect(`${env.FRONTEND_URL}/login?error=oauth_callback_failed`);
440
  await syncProfile(data.user);
441
  setSessionCookies(res, data.session);
 
442
  return res.redirect(safeRedirect(req.query.next));
443
  } catch (error) { next(error); }
444
  });
@@ -461,6 +522,7 @@ app.get("/api/auth/confirm", async (req, res, next) => {
461
  app.post("/api/auth/logout", async (req, res, next) => {
462
  try {
463
  clearSessionCookies(res);
 
464
  return res.status(204).send();
465
  } catch (error) { next(error); }
466
  });
@@ -545,6 +607,7 @@ app.delete("/api/auth/account", async (req, res, next) => {
545
  if (authError) return res.status(500).json({ error: "Unable to delete your account. Please try again." });
546
  const { error: profileError } = await supabaseAdmin.from("users").delete().eq("user_id", current.user.id);
547
  clearSessionCookies(res);
 
548
  if (profileError) throw new Error(`Account deleted but profile cleanup failed: ${profileError.message}`);
549
  return res.status(204).send();
550
  } catch (error) { next(error); }
 
9
  import { z } from "zod";
10
  import { authPublicOrigin, env } from "./config.js";
11
  import { analysisRouter } from "./analysis.js";
12
+ import { authenticatedUser, clearPasswordRecoveryCookie, clearSessionCookies, hasPasswordRecoveryCookie, safeRedirect, setPasswordRecoveryCookie, setSessionCookies } from "./auth.js";
13
  import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
14
 
15
  const app = express();
 
46
  });
47
 
48
  const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 25, standardHeaders: "draft-8", legacyHeaders: false });
49
+ const passwordResetRequestLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 5, standardHeaders: "draft-8", legacyHeaders: false });
50
+ const passwordRecoveryLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 15, standardHeaders: "draft-8", legacyHeaders: false });
51
  const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 3000, standardHeaders: "draft-8", legacyHeaders: false });
52
  const analysisUploadLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 4, standardHeaders: "draft-8", legacyHeaders: false });
53
  const signupSchema = z.object({ fname: z.string().trim().min(1).max(100), lname: z.string().trim().min(1).max(100), email: z.string().trim().toLowerCase().email().max(255), password: z.string().min(8).max(128), academy: z.string().trim().max(150).optional() });
54
  const loginSchema = z.object({ email: z.string().trim().toLowerCase().email(), password: z.string().min(1).max(128) });
55
+ const passwordResetRequestSchema = z.object({ email: z.string().trim().toLowerCase().email().max(255) });
56
+ const passwordRecoveryVerifySchema = z.object({ tokenHash: z.string().trim().min(1).max(512) });
57
+ const passwordRecoverySchema = z.object({ newPassword: z.string().min(8).max(128) }).strict();
58
  const profileRoles = ["football_player", "coach", "club_manager", "academy_manager", "scout", "performance_analyst", "sports_director", "other"] as const;
59
  const profileSchema = z.object({
60
  fname: z.string().trim().min(1).max(100),
 
407
  if (data.user?.identities?.length) await syncProfile(data.user, { ...input, passwordHash });
408
  if (data.user && data.session) {
409
  setSessionCookies(res, data.session);
410
+ clearPasswordRecoveryCookie(res);
411
  return res.status(201).json({ user: userPayload(data.user), requiresEmailVerification: false });
412
  }
413
  return res.status(201).json({ user: data.user ? userPayload(data.user) : null, requiresEmailVerification: true });
414
  } catch (error) { next(error); }
415
  });
416
 
417
+ app.post("/api/auth/password/reset", passwordResetRequestLimiter, async (req, res, next) => {
418
+ try {
419
+ const input = passwordResetRequestSchema.parse(req.body);
420
+ const { error } = await supabaseAuth.auth.resetPasswordForEmail(input.email);
421
+ if (error) {
422
+ console.error("Supabase password reset request failed", { status: error.status, code: error.code });
423
+ return res.status(503).json({ error: "We couldn't send the reset email right now. Please try again later." });
424
+ }
425
+ // Keep the response identical whether or not the email belongs to an account.
426
+ return res.json({ ok: true });
427
+ } catch (error) { next(error); }
428
+ });
429
+
430
+ app.post("/api/auth/password/recovery/verify", passwordRecoveryLimiter, async (req, res, next) => {
431
+ try {
432
+ const input = passwordRecoveryVerifySchema.parse(req.body);
433
+ const { data, error } = await supabaseAuth.auth.verifyOtp({ token_hash: input.tokenHash, type: "recovery" });
434
+ if (error || !data.user || !data.session) {
435
+ return res.status(400).json({ error: "This reset link is invalid or has expired. Request a new one." });
436
+ }
437
+ setSessionCookies(res, data.session);
438
+ setPasswordRecoveryCookie(res, data.user.id);
439
+ res.set("Cache-Control", "no-store");
440
+ return res.json({ ok: true });
441
+ } catch (error) { next(error); }
442
+ });
443
+
444
+ app.post("/api/auth/password/recovery", passwordRecoveryLimiter, async (req, res, next) => {
445
+ try {
446
+ const current = await authenticatedUser(req, res);
447
+ if (!current) return res.status(401).json({ error: "Your reset link has expired. Request a new one." });
448
+ if (!hasPasswordRecoveryCookie(req, current.user.id)) {
449
+ return res.status(403).json({ error: "Verify a password reset link before changing your password." });
450
+ }
451
+ const input = passwordRecoverySchema.parse(req.body);
452
+ const { error } = await supabaseAdmin.auth.admin.updateUserById(current.user.id, {
453
+ password: input.newPassword,
454
+ app_metadata: { ...current.user.app_metadata, has_password: true },
455
+ });
456
+ if (error) return res.status(400).json({ error: "Unable to update your password. Please try again." });
457
+
458
+ const passwordHash = await hashPassword(input.newPassword);
459
+ const { error: profileError } = await supabaseAdmin.from("users")
460
+ .update({ password_hash: passwordHash }).eq("user_id", current.user.id);
461
+ if (profileError) throw new Error(`Password updated but profile hash sync failed: ${profileError.message}`);
462
+
463
+ clearSessionCookies(res);
464
+ clearPasswordRecoveryCookie(res);
465
+ res.set("Cache-Control", "no-store");
466
+ return res.json({ ok: true });
467
+ } catch (error) { next(error); }
468
+ });
469
+
470
  app.post("/api/auth/login", authLimiter, async (req, res, next) => {
471
  try {
472
  const input = loginSchema.parse(req.body);
 
474
  if (error || !data.user || !data.session) return res.status(401).json({ error: "Invalid email or password." });
475
  await syncProfile(data.user);
476
  setSessionCookies(res, data.session);
477
+ clearPasswordRecoveryCookie(res);
478
  const subscription = await activeSubscriptionFor(data.user.id);
479
  return res.json({ user: userPayload(data.user), access: { active: Boolean(subscription), subscription } });
480
  } catch (error) { next(error); }
 
499
  if (error || !data.user || !data.session) return res.redirect(`${env.FRONTEND_URL}/login?error=oauth_callback_failed`);
500
  await syncProfile(data.user);
501
  setSessionCookies(res, data.session);
502
+ clearPasswordRecoveryCookie(res);
503
  return res.redirect(safeRedirect(req.query.next));
504
  } catch (error) { next(error); }
505
  });
 
522
  app.post("/api/auth/logout", async (req, res, next) => {
523
  try {
524
  clearSessionCookies(res);
525
+ clearPasswordRecoveryCookie(res);
526
  return res.status(204).send();
527
  } catch (error) { next(error); }
528
  });
 
607
  if (authError) return res.status(500).json({ error: "Unable to delete your account. Please try again." });
608
  const { error: profileError } = await supabaseAdmin.from("users").delete().eq("user_id", current.user.id);
609
  clearSessionCookies(res);
610
+ clearPasswordRecoveryCookie(res);
611
  if (profileError) throw new Error(`Account deleted but profile cleanup failed: ${profileError.message}`);
612
  return res.status(204).send();
613
  } catch (error) { next(error); }