Spaces:
Running
Running
Add password recovery API
Browse files- README.md +14 -0
- src/auth.ts +35 -0
- 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 }}&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); }
|