- Locks no longer fail open: an unreachable backend throws LockUnavailableError and withLock skips the run (or opts into running unlocked, as template seeding does). The lnbits payment poller keeps polling through an outage, made safe by only sending confirmation emails when a row actually transitioned. - Rate limiter INCR+PEXPIRE now runs as one Lua script, self-healing counters stranded without a TTL. - Per-email login lockout moves to a Redis-backed store shared across replicas (in-memory fallback preserved), case-insensitive on email. - Graceful shutdown on SIGTERM/SIGINT: stop periodic jobs, close the server, force-close lingering SSE sockets, close Redis. - Active PING probe backs the health flag; /health reports last ping. SSE payment streams also poll the DB as a pub/sub-gap fallback. - Scale compose gains requirepass, maxmemory 256mb with noeviction, and an authenticated healthcheck; .env.example documents passwords, TLS (rediss://), and DB-index selection. - First tests in the repo: vitest + ioredis-mock covering the lock, rate limiter, and login lockout stores (27 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
685 lines
21 KiB
TypeScript
685 lines
21 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { zValidator } from '@hono/zod-validator';
|
|
import { z } from 'zod';
|
|
import { db, dbGet, users, magicLinkTokens, User } from '../db/index.js';
|
|
import { eq } from 'drizzle-orm';
|
|
import {
|
|
hashPassword,
|
|
verifyPassword,
|
|
createToken,
|
|
createRefreshToken,
|
|
isFirstUser,
|
|
getAuthUser,
|
|
validatePassword,
|
|
createMagicLinkToken,
|
|
verifyMagicLinkToken,
|
|
invalidateAllUserSessions,
|
|
bumpTokenVersion,
|
|
requireAuth,
|
|
getUserPasswordHash,
|
|
} from '../lib/auth.js';
|
|
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
|
import { sendEmail } from '../lib/email.js';
|
|
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
|
import { getLoginLockout } from '../lib/stores/loginLockout.js';
|
|
|
|
// Per-IP rate limit for sensitive auth endpoints (registration, login, and all
|
|
// email-dispatching flows) to curb credential stuffing and email flooding.
|
|
const authRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth' });
|
|
|
|
// User type that includes all fields (some added in schema updates)
|
|
type AuthUser = User & {
|
|
isClaimed: boolean;
|
|
googleId: string | null;
|
|
rucNumber: string | null;
|
|
accountStatus: string;
|
|
};
|
|
|
|
const auth = new Hono();
|
|
|
|
const registerSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(10, 'Password must be at least 10 characters'),
|
|
name: z.string().min(2),
|
|
phone: z.string().optional(),
|
|
languagePreference: z.enum(['en', 'es']).optional(),
|
|
});
|
|
|
|
const loginSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string(),
|
|
});
|
|
|
|
const magicLinkRequestSchema = z.object({
|
|
email: z.string().email(),
|
|
});
|
|
|
|
const magicLinkVerifySchema = z.object({
|
|
token: z.string(),
|
|
});
|
|
|
|
const passwordResetRequestSchema = z.object({
|
|
email: z.string().email(),
|
|
});
|
|
|
|
const passwordResetSchema = z.object({
|
|
token: z.string(),
|
|
password: z.string().min(10, 'Password must be at least 10 characters'),
|
|
});
|
|
|
|
const claimAccountSchema = z.object({
|
|
token: z.string(),
|
|
password: z.string().min(10, 'Password must be at least 10 characters'),
|
|
});
|
|
|
|
const changePasswordSchema = z.object({
|
|
currentPassword: z.string(),
|
|
newPassword: z.string().min(10, 'Password must be at least 10 characters'),
|
|
});
|
|
|
|
const googleAuthSchema = z.object({
|
|
credential: z.string(), // Google ID token
|
|
});
|
|
|
|
// Register
|
|
auth.post('/register', authRateLimit, zValidator('json', registerSchema), async (c) => {
|
|
const data = c.req.valid('json');
|
|
|
|
// Validate password strength
|
|
const passwordValidation = validatePassword(data.password);
|
|
if (!passwordValidation.valid) {
|
|
return c.json({ error: passwordValidation.error }, 400);
|
|
}
|
|
|
|
// Check if email exists
|
|
const existing = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
|
);
|
|
if (existing) {
|
|
// If user exists but is unclaimed, allow claiming
|
|
if (!existing.isClaimed || existing.accountStatus === 'unclaimed') {
|
|
return c.json({
|
|
error: 'Email already registered',
|
|
canClaim: true,
|
|
message: 'This email has an unclaimed account. Please check your email for the claim link or request a new one.'
|
|
}, 400);
|
|
}
|
|
return c.json({ error: 'Email already registered' }, 400);
|
|
}
|
|
|
|
// Check if first user (becomes admin)
|
|
const firstUser = await isFirstUser();
|
|
|
|
const hashedPassword = await hashPassword(data.password);
|
|
const now = getNow();
|
|
const id = generateId();
|
|
|
|
const newUser = {
|
|
id,
|
|
email: data.email,
|
|
password: hashedPassword,
|
|
name: data.name,
|
|
phone: data.phone || null,
|
|
role: firstUser ? 'admin' : 'user',
|
|
languagePreference: data.languagePreference || null,
|
|
isClaimed: toDbBool(true),
|
|
googleId: null,
|
|
rucNumber: null,
|
|
accountStatus: 'active',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
await (db as any).insert(users).values(newUser);
|
|
|
|
const token = await createToken(id, data.email, newUser.role, 0);
|
|
const refreshToken = await createRefreshToken(id);
|
|
|
|
return c.json({
|
|
user: {
|
|
id,
|
|
email: data.email,
|
|
name: data.name,
|
|
role: newUser.role,
|
|
isClaimed: true,
|
|
},
|
|
token,
|
|
refreshToken,
|
|
message: firstUser ? 'Admin account created successfully' : 'Account created successfully',
|
|
}, 201);
|
|
});
|
|
|
|
// Login with email/password
|
|
auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => {
|
|
const data = c.req.valid('json');
|
|
|
|
// Per-email lockout (shared across instances when Redis is configured).
|
|
const lockout = await getLoginLockout().isLocked(data.email);
|
|
if (lockout.locked) {
|
|
return c.json({
|
|
error: 'Too many login attempts. Please try again later.',
|
|
retryAfter: lockout.retryAfter
|
|
}, 429);
|
|
}
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
|
);
|
|
if (!user) {
|
|
await getLoginLockout().recordFailure(data.email);
|
|
return c.json({ error: 'Invalid credentials' }, 401);
|
|
}
|
|
|
|
// Check if account is suspended
|
|
if (user.accountStatus === 'suspended') {
|
|
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
|
}
|
|
|
|
// Check if user has a password set
|
|
if (!user.password) {
|
|
return c.json({
|
|
error: 'No password set for this account',
|
|
needsClaim: !user.isClaimed,
|
|
message: user.isClaimed
|
|
? 'Please use Google login or request a password reset.'
|
|
: 'Please claim your account first.'
|
|
}, 400);
|
|
}
|
|
|
|
const validPassword = await verifyPassword(data.password, user.password);
|
|
if (!validPassword) {
|
|
await getLoginLockout().recordFailure(data.email);
|
|
return c.json({ error: 'Invalid credentials' }, 401);
|
|
}
|
|
|
|
// Clear failed attempts on successful login
|
|
await getLoginLockout().clear(data.email);
|
|
|
|
// Transparently upgrade legacy bcrypt hashes to argon2 now that we have the
|
|
// plaintext and have verified it. Best-effort: a failure here must not block
|
|
// the login.
|
|
if (!String(user.password).startsWith('$argon2')) {
|
|
try {
|
|
const upgradedHash = await hashPassword(data.password);
|
|
await (db as any)
|
|
.update(users)
|
|
.set({ password: upgradedHash })
|
|
.where(eq((users as any).id, user.id));
|
|
} catch (err: any) {
|
|
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
|
|
}
|
|
}
|
|
|
|
const token = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
|
|
const refreshToken = await createRefreshToken(user.id);
|
|
|
|
return c.json({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
isClaimed: user.isClaimed,
|
|
phone: user.phone,
|
|
rucNumber: user.rucNumber,
|
|
languagePreference: user.languagePreference,
|
|
},
|
|
token,
|
|
refreshToken,
|
|
});
|
|
});
|
|
|
|
// Request magic link login
|
|
auth.post('/magic-link/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
|
|
const { email } = c.req.valid('json');
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, email))
|
|
);
|
|
|
|
if (!user) {
|
|
// Don't reveal if email exists
|
|
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
|
}
|
|
|
|
if (user.accountStatus === 'suspended') {
|
|
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
|
}
|
|
|
|
// Create magic link token (expires in 10 minutes)
|
|
const token = await createMagicLinkToken(user.id, 'login', 10);
|
|
const magicLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/magic-link?token=${token}`;
|
|
|
|
// Send email
|
|
try {
|
|
await sendEmail({
|
|
to: email,
|
|
subject: 'Your Spanglish Login Link',
|
|
html: `
|
|
<h2>Login to Spanglish</h2>
|
|
<p>Click the link below to log in. This link expires in 10 minutes.</p>
|
|
<p><a href="${magicLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Log In</a></p>
|
|
<p>Or copy this link: ${magicLink}</p>
|
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
|
`,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to send magic link email:', error);
|
|
}
|
|
|
|
return c.json({ message: 'If an account exists with this email, a login link has been sent.' });
|
|
});
|
|
|
|
// Verify magic link and login
|
|
auth.post('/magic-link/verify', authRateLimit, zValidator('json', magicLinkVerifySchema), async (c) => {
|
|
const { token } = c.req.valid('json');
|
|
|
|
const verification = await verifyMagicLinkToken(token, 'login');
|
|
|
|
if (!verification.valid) {
|
|
return c.json({ error: verification.error }, 400);
|
|
}
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
|
|
);
|
|
|
|
if (!user || user.accountStatus === 'suspended') {
|
|
return c.json({ error: 'Invalid token' }, 400);
|
|
}
|
|
|
|
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
|
|
const refreshToken = await createRefreshToken(user.id);
|
|
|
|
return c.json({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
isClaimed: user.isClaimed,
|
|
phone: user.phone,
|
|
rucNumber: user.rucNumber,
|
|
languagePreference: user.languagePreference,
|
|
},
|
|
token: authToken,
|
|
refreshToken,
|
|
});
|
|
});
|
|
|
|
// Request password reset
|
|
auth.post('/password-reset/request', authRateLimit, zValidator('json', passwordResetRequestSchema), async (c) => {
|
|
const { email } = c.req.valid('json');
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, email))
|
|
);
|
|
|
|
if (!user) {
|
|
// Don't reveal if email exists
|
|
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
|
}
|
|
|
|
if (user.accountStatus === 'suspended') {
|
|
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
|
}
|
|
|
|
// Create reset token (expires in 30 minutes)
|
|
const token = await createMagicLinkToken(user.id, 'reset_password', 30);
|
|
const resetLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/reset-password?token=${token}`;
|
|
|
|
// Send email
|
|
try {
|
|
await sendEmail({
|
|
to: email,
|
|
subject: 'Reset Your Spanglish Password',
|
|
html: `
|
|
<h2>Reset Your Password</h2>
|
|
<p>Click the link below to reset your password. This link expires in 30 minutes.</p>
|
|
<p><a href="${resetLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Reset Password</a></p>
|
|
<p>Or copy this link: ${resetLink}</p>
|
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
|
`,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to send password reset email:', error);
|
|
}
|
|
|
|
return c.json({ message: 'If an account exists with this email, a password reset link has been sent.' });
|
|
});
|
|
|
|
// Reset password
|
|
auth.post('/password-reset/confirm', authRateLimit, zValidator('json', passwordResetSchema), async (c) => {
|
|
const { token, password } = c.req.valid('json');
|
|
|
|
// Validate password strength
|
|
const passwordValidation = validatePassword(password);
|
|
if (!passwordValidation.valid) {
|
|
return c.json({ error: passwordValidation.error }, 400);
|
|
}
|
|
|
|
const verification = await verifyMagicLinkToken(token, 'reset_password');
|
|
|
|
if (!verification.valid) {
|
|
return c.json({ error: verification.error }, 400);
|
|
}
|
|
|
|
const hashedPassword = await hashPassword(password);
|
|
const now = getNow();
|
|
|
|
await (db as any)
|
|
.update(users)
|
|
.set({
|
|
password: hashedPassword,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((users as any).id, verification.userId));
|
|
|
|
// Invalidate all existing sessions/JWTs for security
|
|
await invalidateAllUserSessions(verification.userId!);
|
|
await bumpTokenVersion(verification.userId!);
|
|
|
|
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
|
|
});
|
|
|
|
// Claim unclaimed account
|
|
auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
|
|
const { email } = c.req.valid('json');
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, email))
|
|
);
|
|
|
|
if (!user) {
|
|
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
|
|
}
|
|
|
|
if (user.isClaimed && user.accountStatus !== 'unclaimed') {
|
|
return c.json({ error: 'Account is already claimed' }, 400);
|
|
}
|
|
|
|
// Create claim token (expires in 1 hour)
|
|
const token = await createMagicLinkToken(user.id, 'claim_account', 60);
|
|
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
|
|
|
|
// Send email
|
|
try {
|
|
await sendEmail({
|
|
to: email,
|
|
subject: 'Claim Your Spanglish Account',
|
|
html: `
|
|
<h2>Claim Your Account</h2>
|
|
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
|
|
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
|
|
<p>Or copy this link: ${claimLink}</p>
|
|
<p>This link expires in 1 hour.</p>
|
|
`,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to send claim account email:', error);
|
|
}
|
|
|
|
return c.json({ message: 'If an unclaimed account exists with this email, a claim link has been sent.' });
|
|
});
|
|
|
|
// Complete account claim
|
|
auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccountSchema), async (c) => {
|
|
const { token, password } = c.req.valid('json');
|
|
|
|
const verification = await verifyMagicLinkToken(token, 'claim_account');
|
|
|
|
if (!verification.valid) {
|
|
return c.json({ error: verification.error }, 400);
|
|
}
|
|
|
|
const passwordValidation = validatePassword(password);
|
|
if (!passwordValidation.valid) {
|
|
return c.json({ error: passwordValidation.error }, 400);
|
|
}
|
|
|
|
const now = getNow();
|
|
// Only set a password here. Linking a Google account requires a verified Google
|
|
// ID token via /google; we never trust a client-supplied googleId.
|
|
const updates: Record<string, any> = {
|
|
isClaimed: toDbBool(true),
|
|
accountStatus: 'active',
|
|
password: await hashPassword(password),
|
|
updatedAt: now,
|
|
};
|
|
|
|
await (db as any)
|
|
.update(users)
|
|
.set(updates)
|
|
.where(eq((users as any).id, verification.userId));
|
|
|
|
const user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
|
|
);
|
|
|
|
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
|
|
const refreshToken = await createRefreshToken(user.id);
|
|
|
|
return c.json({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
isClaimed: user.isClaimed,
|
|
phone: user.phone,
|
|
rucNumber: user.rucNumber,
|
|
languagePreference: user.languagePreference,
|
|
},
|
|
token: authToken,
|
|
refreshToken,
|
|
message: 'Account claimed successfully!',
|
|
});
|
|
});
|
|
|
|
// Google OAuth login/register
|
|
auth.post('/google', authRateLimit, zValidator('json', googleAuthSchema), async (c) => {
|
|
const { credential } = c.req.valid('json');
|
|
|
|
try {
|
|
// Verify the Google ID token. Google's tokeninfo endpoint validates the
|
|
// signature and expiry server-side; we additionally enforce the audience so a
|
|
// token minted for a different OAuth client cannot be replayed against us.
|
|
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
|
|
|
|
if (!response.ok) {
|
|
return c.json({ error: 'Invalid Google token' }, 400);
|
|
}
|
|
|
|
const googleData = await response.json() as {
|
|
sub: string;
|
|
email: string;
|
|
name: string;
|
|
email_verified: string;
|
|
aud?: string;
|
|
exp?: string;
|
|
};
|
|
|
|
// email_verified can be returned as boolean true or string "true"
|
|
if (String(googleData.email_verified) !== 'true') {
|
|
return c.json({ error: 'Google email not verified' }, 400);
|
|
}
|
|
|
|
// Enforce audience when a client ID is configured (closes token-confusion attacks)
|
|
const expectedAud = process.env.GOOGLE_CLIENT_ID;
|
|
if (expectedAud) {
|
|
if (googleData.aud !== expectedAud) {
|
|
return c.json({ error: 'Invalid Google token audience' }, 400);
|
|
}
|
|
} else {
|
|
console.warn('[auth] GOOGLE_CLIENT_ID is not set; skipping audience verification for Google login.');
|
|
}
|
|
|
|
// Reject expired tokens (defense-in-depth; tokeninfo also rejects them)
|
|
if (googleData.exp && Number(googleData.exp) * 1000 < Date.now()) {
|
|
return c.json({ error: 'Google token expired' }, 400);
|
|
}
|
|
|
|
const { sub: googleId, email, name } = googleData;
|
|
|
|
// Check if user exists by email or google_id
|
|
let user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).email, email))
|
|
);
|
|
|
|
if (!user) {
|
|
// Check by google_id
|
|
user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).googleId, googleId))
|
|
);
|
|
}
|
|
|
|
const now = getNow();
|
|
|
|
if (user) {
|
|
// User exists - link Google account if not already linked
|
|
if (user.accountStatus === 'suspended') {
|
|
return c.json({ error: 'Account is suspended. Please contact support.' }, 403);
|
|
}
|
|
|
|
if (!user.googleId) {
|
|
await (db as any)
|
|
.update(users)
|
|
.set({
|
|
googleId,
|
|
isClaimed: toDbBool(true),
|
|
accountStatus: 'active',
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((users as any).id, user.id));
|
|
}
|
|
|
|
// Refresh user data
|
|
user = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).id, user.id))
|
|
);
|
|
} else {
|
|
// Create new user
|
|
const firstUser = await isFirstUser();
|
|
const id = generateId();
|
|
|
|
const newUser = {
|
|
id,
|
|
email,
|
|
password: null,
|
|
name,
|
|
phone: null,
|
|
role: firstUser ? 'admin' : 'user',
|
|
languagePreference: null,
|
|
isClaimed: toDbBool(true),
|
|
googleId,
|
|
rucNumber: null,
|
|
accountStatus: 'active',
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
await (db as any).insert(users).values(newUser);
|
|
user = newUser;
|
|
}
|
|
|
|
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
|
|
const refreshToken = await createRefreshToken(user.id);
|
|
|
|
return c.json({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
isClaimed: user.isClaimed,
|
|
phone: user.phone,
|
|
rucNumber: user.rucNumber,
|
|
languagePreference: user.languagePreference,
|
|
},
|
|
token: authToken,
|
|
refreshToken,
|
|
});
|
|
} catch (error) {
|
|
console.error('Google auth error:', error);
|
|
return c.json({ error: 'Failed to authenticate with Google' }, 500);
|
|
}
|
|
});
|
|
|
|
// Get current user
|
|
auth.get('/me', async (c) => {
|
|
const user = await getAuthUser(c);
|
|
|
|
if (!user) {
|
|
return c.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
return c.json({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
phone: user.phone,
|
|
isClaimed: user.isClaimed,
|
|
rucNumber: user.rucNumber,
|
|
languagePreference: user.languagePreference,
|
|
accountStatus: user.accountStatus,
|
|
createdAt: user.createdAt,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Change password (authenticated users)
|
|
auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSchema), async (c) => {
|
|
const user = (c as any).get('user') as AuthUser;
|
|
const { currentPassword, newPassword } = c.req.valid('json');
|
|
|
|
// Validate new password
|
|
const passwordValidation = validatePassword(newPassword);
|
|
if (!passwordValidation.valid) {
|
|
return c.json({ error: passwordValidation.error }, 400);
|
|
}
|
|
|
|
// Verify current password if user has one
|
|
const existingHash = await getUserPasswordHash(user.id);
|
|
if (existingHash) {
|
|
const validPassword = await verifyPassword(currentPassword, existingHash);
|
|
if (!validPassword) {
|
|
return c.json({ error: 'Current password is incorrect' }, 400);
|
|
}
|
|
}
|
|
|
|
const hashedPassword = await hashPassword(newPassword);
|
|
const now = getNow();
|
|
|
|
await (db as any)
|
|
.update(users)
|
|
.set({
|
|
password: hashedPassword,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq((users as any).id, user.id));
|
|
|
|
// Invalidate all previously issued JWTs so a stolen old token can't outlive the change,
|
|
// then hand the current client a fresh token so it stays logged in on this device.
|
|
await bumpTokenVersion(user.id);
|
|
const refreshedUser = await dbGet<any>(
|
|
(db as any).select().from(users).where(eq((users as any).id, user.id))
|
|
);
|
|
const newToken = await createToken(user.id, user.email, user.role, refreshedUser?.tokenVersion ?? 0);
|
|
|
|
return c.json({ message: 'Password changed successfully', token: newToken });
|
|
});
|
|
|
|
// Logout - invalidate all previously issued JWTs for this user (logout everywhere)
|
|
auth.post('/logout', async (c) => {
|
|
const user = await getAuthUser(c);
|
|
if (user) {
|
|
await invalidateAllUserSessions(user.id);
|
|
await bumpTokenVersion(user.id);
|
|
}
|
|
return c.json({ message: 'Logged out successfully' });
|
|
});
|
|
|
|
export default auth;
|