auth: refresh token cookie, rate limiting, delete account, welcome email
- trust proxy for correct IP behind nginx - refresh token now set as httpOnly cookie alongside access token - GET /api/auth/refresh-session for browser-based token refresh - DELETE /api/auth/me with full cleanup of tokens and cookies - rate limiting on login (10/15min) and register (5/hr) - welcome email on registration (non-blocking) - delete account UI on profile page with two-step confirm Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d7eeaace01
commit
07aad6c69e
|
|
@ -76,6 +76,20 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete account -->
|
||||||
|
<div class="card" style="border-color:#3f1515">
|
||||||
|
<h2 style="color:#f87171">Delete account</h2>
|
||||||
|
<p style="font-size:0.88rem;color:#888;margin-bottom:1rem">This permanently deletes your account and all associated data. It cannot be undone.</p>
|
||||||
|
<div id="delete-confirm" hidden>
|
||||||
|
<p style="font-size:0.88rem;color:#f87171;margin-bottom:0.75rem">Are you sure? This cannot be undone.</p>
|
||||||
|
<div style="display:flex;gap:0.75rem">
|
||||||
|
<button class="danger" id="delete-confirm-btn">Yes, delete my account</button>
|
||||||
|
<button class="secondary" id="delete-cancel-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="danger" id="delete-btn">Delete account</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Admin: user list (admin + superadmin) -->
|
<!-- Admin: user list (admin + superadmin) -->
|
||||||
<div class="card" id="admin-card" hidden>
|
<div class="card" id="admin-card" hidden>
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
|
|
@ -215,6 +229,21 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('delete-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('delete-btn').hidden = true;
|
||||||
|
document.getElementById('delete-confirm').hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('delete-cancel-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('delete-confirm').hidden = true;
|
||||||
|
document.getElementById('delete-btn').hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('delete-confirm-btn').addEventListener('click', async () => {
|
||||||
|
const res = await fetch('/api/auth/me', { method: 'DELETE' });
|
||||||
|
if (res.ok) location.href = '/login.html';
|
||||||
|
});
|
||||||
|
|
||||||
loadProfile();
|
loadProfile();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -138,5 +138,10 @@ export const resetTokens =
|
||||||
delete( token: string ): void
|
delete( token: string ): void
|
||||||
{
|
{
|
||||||
write( 'resetTokens', read<ResetToken>( 'resetTokens' ).filter( t => t.token !== token ) );
|
write( 'resetTokens', read<ResetToken>( 'resetTokens' ).filter( t => t.token !== token ) );
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteForUser( userId: string ): void
|
||||||
|
{
|
||||||
|
write( 'resetTokens', read<ResetToken>( 'resetTokens' ).filter( t => t.userId !== userId ) );
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import adminRouter from './routes/admin';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
app.set( 'trust proxy', 1 );
|
||||||
app.use( express.json() );
|
app.use( express.json() );
|
||||||
app.use( cookieParser() );
|
app.use( cookieParser() );
|
||||||
app.use( express.static( path.join( __dirname, '..', '..', 'build', 'app' ) ) );
|
app.use( express.static( path.join( __dirname, '..', '..', 'build', 'app' ) ) );
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,34 @@
|
||||||
const WINDOW_MS = 20 * 60 * 1000; // 20 minutes
|
|
||||||
const MAX_ATTEMPTS = 20;
|
|
||||||
|
|
||||||
interface Entry
|
interface Entry
|
||||||
{
|
{
|
||||||
count: number;
|
count: number;
|
||||||
windowStart: number;
|
windowStart: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeRateLimiter( maxAttempts: number, windowMs: number )
|
||||||
|
{
|
||||||
const store = new Map<string, Entry>();
|
const store = new Map<string, Entry>();
|
||||||
|
|
||||||
function delayMs( count: number ): number
|
return function check( ip: string ): { blocked: boolean }
|
||||||
|
{
|
||||||
|
const now = Date.now();
|
||||||
|
let entry = store.get( ip );
|
||||||
|
|
||||||
|
if ( !entry || now - entry.windowStart > windowMs )
|
||||||
|
entry = { count: 0, windowStart: now };
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
store.set( ip, entry );
|
||||||
|
|
||||||
|
return { blocked: entry.count > maxAttempts };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgot-password — strict, with escalating delay to prevent email spam
|
||||||
|
const FORGOT_WINDOW_MS = 20 * 60 * 1000;
|
||||||
|
const FORGOT_MAX_ATTEMPTS = 20;
|
||||||
|
const forgotStore = new Map<string, Entry>();
|
||||||
|
|
||||||
|
function forgotDelayMs( count: number ): number
|
||||||
{
|
{
|
||||||
if ( count <= 5 ) return 5_000;
|
if ( count <= 5 ) return 5_000;
|
||||||
if ( count <= 10 ) return 15_000;
|
if ( count <= 10 ) return 15_000;
|
||||||
|
|
@ -19,18 +38,47 @@ function delayMs( count: number ): number
|
||||||
export function checkForgotPasswordRate( ip: string ): { blocked: boolean; delay: number }
|
export function checkForgotPasswordRate( ip: string ): { blocked: boolean; delay: number }
|
||||||
{
|
{
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let entry = store.get( ip );
|
let entry = forgotStore.get( ip );
|
||||||
|
|
||||||
if ( !entry || now - entry.windowStart > WINDOW_MS )
|
if ( !entry || now - entry.windowStart > FORGOT_WINDOW_MS )
|
||||||
entry = { count: 0, windowStart: now };
|
entry = { count: 0, windowStart: now };
|
||||||
|
|
||||||
entry.count++;
|
entry.count++;
|
||||||
store.set( ip, entry );
|
forgotStore.set( ip, entry );
|
||||||
|
|
||||||
if ( entry.count > MAX_ATTEMPTS ) return { blocked: true, delay: 0 };
|
if ( entry.count > FORGOT_MAX_ATTEMPTS ) return { blocked: true, delay: 0 };
|
||||||
return { blocked: false, delay: delayMs( entry.count ) };
|
return { blocked: false, delay: forgotDelayMs( entry.count ) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// login — 10 attempts per 15 min, small delay after 5
|
||||||
|
const loginStore = new Map<string, Entry>();
|
||||||
|
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const LOGIN_MAX_ATTEMPTS = 10;
|
||||||
|
|
||||||
|
function loginDelayMs( count: number ): number
|
||||||
|
{
|
||||||
|
if ( count <= 5 ) return 0;
|
||||||
|
return 3_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkLoginRate( ip: string ): { blocked: boolean; delay: number }
|
||||||
|
{
|
||||||
|
const now = Date.now();
|
||||||
|
let entry = loginStore.get( ip );
|
||||||
|
|
||||||
|
if ( !entry || now - entry.windowStart > LOGIN_WINDOW_MS )
|
||||||
|
entry = { count: 0, windowStart: now };
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
loginStore.set( ip, entry );
|
||||||
|
|
||||||
|
if ( entry.count > LOGIN_MAX_ATTEMPTS ) return { blocked: true, delay: 0 };
|
||||||
|
return { blocked: false, delay: loginDelayMs( entry.count ) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// register — 5 attempts per hour, no delay
|
||||||
|
export const checkRegisterRate = makeRateLimiter( 5, 60 * 60 * 1000 );
|
||||||
|
|
||||||
export function sleep( ms: number ): Promise<void>
|
export function sleep( ms: number ): Promise<void>
|
||||||
{
|
{
|
||||||
return new Promise( resolve => setTimeout( resolve, ms ) );
|
return new Promise( resolve => setTimeout( resolve, ms ) );
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import jwt from 'jsonwebtoken';
|
||||||
import { users, refreshTokens, resetTokens } from '../db';
|
import { users, refreshTokens, resetTokens } from '../db';
|
||||||
import { requireAuth } from '../middleware/requireAuth';
|
import { requireAuth } from '../middleware/requireAuth';
|
||||||
import { EmailService } from '../email/EmailService';
|
import { EmailService } from '../email/EmailService';
|
||||||
import { checkForgotPasswordRate, sleep } from '../rateLimiter';
|
import { checkForgotPasswordRate, checkLoginRate, checkRegisterRate, sleep } from '../rateLimiter';
|
||||||
import { isSuperAdmin } from '../roles';
|
import { isSuperAdmin } from '../roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -12,6 +12,7 @@ const router = Router();
|
||||||
const ACCESS_TOKEN_TTL = '1h';
|
const ACCESS_TOKEN_TTL = '1h';
|
||||||
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com';
|
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com';
|
||||||
const RESET_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com';
|
const RESET_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com';
|
||||||
|
const ACCOUNT_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com';
|
||||||
|
|
||||||
function issueAccessToken( userId: string ): string
|
function issueAccessToken( userId: string ): string
|
||||||
{
|
{
|
||||||
|
|
@ -27,22 +28,50 @@ function issueAccessToken( userId: string ): string
|
||||||
return jwt.sign( payload, process.env.JWT_SECRET ?? '', { expiresIn: ACCESS_TOKEN_TTL } );
|
return jwt.sign( payload, process.env.JWT_SECRET ?? '', { expiresIn: ACCESS_TOKEN_TTL } );
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTokenCookie( res: Response, accessToken: string ): void
|
function cookieOptions( maxAge: number )
|
||||||
{
|
|
||||||
res.cookie( 'accessToken', accessToken,
|
|
||||||
{
|
{
|
||||||
|
return {
|
||||||
domain: COOKIE_DOMAIN,
|
domain: COOKIE_DOMAIN,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
maxAge: 60 * 60 * 1000,
|
sameSite: 'lax' as const,
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/'
|
path: '/'
|
||||||
} );
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTokenCookie( res: Response, accessToken: string ): void
|
||||||
|
{
|
||||||
|
res.cookie( 'accessToken', accessToken, { ...cookieOptions( 60 * 60 * 1000 ), maxAge: 60 * 60 * 1000 } );
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRefreshTokenCookie( res: Response, token: string ): void
|
||||||
|
{
|
||||||
|
res.cookie( 'refreshToken', token, { ...cookieOptions( 30 * 24 * 60 * 60 * 1000 ), maxAge: 30 * 24 * 60 * 60 * 1000 } );
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuthCookies( res: Response ): void
|
||||||
|
{
|
||||||
|
const base = { domain: COOKIE_DOMAIN, path: '/' };
|
||||||
|
res.clearCookie( 'accessToken', base );
|
||||||
|
res.clearCookie( 'refreshToken', base );
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueTokenPair( res: Response, userId: string ): { accessToken: string; refreshToken: string }
|
||||||
|
{
|
||||||
|
const accessToken = issueAccessToken( userId );
|
||||||
|
const refreshRecord = refreshTokens.create( userId );
|
||||||
|
setTokenCookie( res, accessToken );
|
||||||
|
setRefreshTokenCookie( res, refreshRecord.token );
|
||||||
|
return { accessToken, refreshToken: refreshRecord.token };
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/auth/register
|
// POST /api/auth/register
|
||||||
router.post( '/register', async ( req, res ) =>
|
router.post( '/register', async ( req, res ) =>
|
||||||
{
|
{
|
||||||
|
const ip = req.ip ?? 'unknown';
|
||||||
|
const { blocked } = checkRegisterRate( ip );
|
||||||
|
if ( blocked ) { res.status( 429 ).json( { error: 'Too many registrations. Try again later.' } ); return; }
|
||||||
|
|
||||||
const { email, password } = req.body as { email?: string; password?: string };
|
const { email, password } = req.body as { email?: string; password?: string };
|
||||||
if ( !email || !password )
|
if ( !email || !password )
|
||||||
{
|
{
|
||||||
|
|
@ -54,7 +83,6 @@ router.post( '/register', async ( req, res ) =>
|
||||||
const passwordHash = await bcrypt.hash( password, 10 );
|
const passwordHash = await bcrypt.hash( password, 10 );
|
||||||
const user = users.create( email, passwordHash );
|
const user = users.create( email, passwordHash );
|
||||||
|
|
||||||
// Bootstrap: if this email matches INITIAL_SUPERADMIN_EMAIL and no superadmin exists yet
|
|
||||||
const superadminEmail = process.env.INITIAL_SUPERADMIN_EMAIL?.toLowerCase();
|
const superadminEmail = process.env.INITIAL_SUPERADMIN_EMAIL?.toLowerCase();
|
||||||
if ( superadminEmail && email.toLowerCase() === superadminEmail )
|
if ( superadminEmail && email.toLowerCase() === superadminEmail )
|
||||||
{
|
{
|
||||||
|
|
@ -63,10 +91,16 @@ router.post( '/register', async ( req, res ) =>
|
||||||
users.update( user.id, { roles: [ 'superadmin', 'user' ] } );
|
users.update( user.id, { roles: [ 'superadmin', 'user' ] } );
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = issueAccessToken( user.id );
|
const tokens = issueTokenPair( res, user.id );
|
||||||
const refresh = refreshTokens.create( user.id );
|
|
||||||
setTokenCookie( res, accessToken );
|
// Welcome email — non-blocking
|
||||||
res.json( { accessToken, refreshToken: refresh.token } );
|
EmailService.sendEmail(
|
||||||
|
email,
|
||||||
|
'Welcome to rokojori',
|
||||||
|
`Hi,\n\nYour account has been created at ${ACCOUNT_BASE_URL}.\n\nIf you did not create this account or want to delete it, visit:\n${ACCOUNT_BASE_URL}/profile.html\n\nYou can delete your account there at any time.`
|
||||||
|
).catch( () => {} );
|
||||||
|
|
||||||
|
res.json( tokens );
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
@ -77,6 +111,11 @@ router.post( '/register', async ( req, res ) =>
|
||||||
// POST /api/auth/login
|
// POST /api/auth/login
|
||||||
router.post( '/login', async ( req, res ) =>
|
router.post( '/login', async ( req, res ) =>
|
||||||
{
|
{
|
||||||
|
const ip = req.ip ?? 'unknown';
|
||||||
|
const { blocked, delay } = checkLoginRate( ip );
|
||||||
|
if ( blocked ) { res.status( 429 ).json( { error: 'Too many attempts. Try again later.' } ); return; }
|
||||||
|
if ( delay ) await sleep( delay );
|
||||||
|
|
||||||
const { email, password } = req.body as { email?: string; password?: string };
|
const { email, password } = req.body as { email?: string; password?: string };
|
||||||
const user = email ? users.findByEmail( email ) : undefined;
|
const user = email ? users.findByEmail( email ) : undefined;
|
||||||
if ( !user || !password || !( await bcrypt.compare( password, user.passwordHash ) ) )
|
if ( !user || !password || !( await bcrypt.compare( password, user.passwordHash ) ) )
|
||||||
|
|
@ -84,30 +123,26 @@ router.post( '/login', async ( req, res ) =>
|
||||||
res.status( 401 ).json( { error: 'Invalid credentials' } );
|
res.status( 401 ).json( { error: 'Invalid credentials' } );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const accessToken = issueAccessToken( user.id );
|
res.json( issueTokenPair( res, user.id ) );
|
||||||
const refresh = refreshTokens.create( user.id );
|
|
||||||
setTokenCookie( res, accessToken );
|
|
||||||
res.json( { accessToken, refreshToken: refresh.token } );
|
|
||||||
} );
|
} );
|
||||||
|
|
||||||
// POST /api/auth/logout
|
// POST /api/auth/logout
|
||||||
router.post( '/logout', ( req, res ) =>
|
router.post( '/logout', ( req, res ) =>
|
||||||
{
|
{
|
||||||
const { refreshToken } = req.body as { refreshToken?: string };
|
const tokenFromBody = ( req.body as { refreshToken?: string } ).refreshToken;
|
||||||
if ( refreshToken ) refreshTokens.delete( refreshToken );
|
const tokenFromCookie = req.cookies?.refreshToken as string | undefined;
|
||||||
res.clearCookie( 'accessToken', { domain: COOKIE_DOMAIN, path: '/' } );
|
const token = tokenFromBody ?? tokenFromCookie;
|
||||||
|
if ( token ) refreshTokens.delete( token );
|
||||||
|
clearAuthCookies( res );
|
||||||
res.json( { ok: true } );
|
res.json( { ok: true } );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
// POST /api/auth/refresh
|
// POST /api/auth/refresh — non-browser clients (token in body)
|
||||||
router.post( '/refresh', ( req, res ) =>
|
router.post( '/refresh', ( req, res ) =>
|
||||||
{
|
{
|
||||||
const { refreshToken } = req.body as { refreshToken?: string };
|
const { refreshToken } = req.body as { refreshToken?: string };
|
||||||
if ( !refreshToken )
|
if ( !refreshToken ) { res.status( 400 ).json( { error: 'Refresh token required' } ); return; }
|
||||||
{
|
|
||||||
res.status( 400 ).json( { error: 'Refresh token required' } );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const record = refreshTokens.find( refreshToken );
|
const record = refreshTokens.find( refreshToken );
|
||||||
if ( !record || new Date( record.expiresAt ) < new Date() )
|
if ( !record || new Date( record.expiresAt ) < new Date() )
|
||||||
{
|
{
|
||||||
|
|
@ -115,14 +150,36 @@ router.post( '/refresh', ( req, res ) =>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const user = users.findById( record.userId );
|
const user = users.findById( record.userId );
|
||||||
if ( !user )
|
if ( !user ) { res.status( 401 ).json( { error: 'User not found' } ); return; }
|
||||||
|
|
||||||
|
refreshTokens.delete( refreshToken );
|
||||||
|
res.json( issueTokenPair( res, user.id ) );
|
||||||
|
} );
|
||||||
|
|
||||||
|
// GET /api/auth/refresh-session?redirect=... — browser clients (token from cookie)
|
||||||
|
router.get( '/refresh-session', ( req, res ) =>
|
||||||
{
|
{
|
||||||
res.status( 401 ).json( { error: 'User not found' } );
|
const redirectTo = req.query.redirect as string | undefined;
|
||||||
|
const loginFallback = redirectTo
|
||||||
|
? `/login.html?redirect=${encodeURIComponent( redirectTo )}`
|
||||||
|
: '/login.html';
|
||||||
|
|
||||||
|
const token = req.cookies?.refreshToken as string | undefined;
|
||||||
|
if ( !token ) { res.redirect( loginFallback ); return; }
|
||||||
|
|
||||||
|
const record = refreshTokens.find( token );
|
||||||
|
if ( !record || new Date( record.expiresAt ) < new Date() )
|
||||||
|
{
|
||||||
|
clearAuthCookies( res );
|
||||||
|
res.redirect( loginFallback );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const accessToken = issueAccessToken( user.id );
|
const user = users.findById( record.userId );
|
||||||
setTokenCookie( res, accessToken );
|
if ( !user ) { clearAuthCookies( res ); res.redirect( loginFallback ); return; }
|
||||||
res.json( { accessToken } );
|
|
||||||
|
refreshTokens.delete( token );
|
||||||
|
issueTokenPair( res, user.id );
|
||||||
|
res.redirect( redirectTo ?? '/profile.html' );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
// POST /api/auth/forgot-password — rate-limited with escalating delay
|
// POST /api/auth/forgot-password — rate-limited with escalating delay
|
||||||
|
|
@ -130,13 +187,7 @@ router.post( '/forgot-password', async ( req, res ) =>
|
||||||
{
|
{
|
||||||
const ip = req.ip ?? 'unknown';
|
const ip = req.ip ?? 'unknown';
|
||||||
const { blocked, delay } = checkForgotPasswordRate( ip );
|
const { blocked, delay } = checkForgotPasswordRate( ip );
|
||||||
|
if ( blocked ) { res.status( 429 ).json( { error: 'Too many attempts. Try again later.' } ); return; }
|
||||||
if ( blocked )
|
|
||||||
{
|
|
||||||
res.status( 429 ).json( { error: 'Too many attempts. Try again later.' } );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep( delay );
|
await sleep( delay );
|
||||||
|
|
||||||
const { email } = req.body as { email?: string };
|
const { email } = req.body as { email?: string };
|
||||||
|
|
@ -154,7 +205,6 @@ router.post( '/forgot-password', async ( req, res ) =>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always respond OK — never reveal whether the email exists
|
|
||||||
res.json( { ok: true } );
|
res.json( { ok: true } );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
|
@ -162,11 +212,8 @@ router.post( '/forgot-password', async ( req, res ) =>
|
||||||
router.post( '/reset-password', async ( req, res ) =>
|
router.post( '/reset-password', async ( req, res ) =>
|
||||||
{
|
{
|
||||||
const { token, password } = req.body as { token?: string; password?: string };
|
const { token, password } = req.body as { token?: string; password?: string };
|
||||||
if ( !token || !password )
|
if ( !token || !password ) { res.status( 400 ).json( { error: 'Token and password required' } ); return; }
|
||||||
{
|
|
||||||
res.status( 400 ).json( { error: 'Token and password required' } );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const record = resetTokens.find( token );
|
const record = resetTokens.find( token );
|
||||||
if ( !record || new Date( record.expiresAt ) < new Date() )
|
if ( !record || new Date( record.expiresAt ) < new Date() )
|
||||||
{
|
{
|
||||||
|
|
@ -179,7 +226,7 @@ router.post( '/reset-password', async ( req, res ) =>
|
||||||
res.json( { ok: true } );
|
res.json( { ok: true } );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
// GET /api/auth/reset-token-email — returns email for a valid reset token (used by reset-password page)
|
// GET /api/auth/reset-token-email
|
||||||
router.get( '/reset-token-email', ( req, res ) =>
|
router.get( '/reset-token-email', ( req, res ) =>
|
||||||
{
|
{
|
||||||
const token = req.query.token as string | undefined;
|
const token = req.query.token as string | undefined;
|
||||||
|
|
@ -219,7 +266,7 @@ router.patch( '/me/settings', requireAuth, ( req, res ) =>
|
||||||
res.json( { settings } );
|
res.json( { settings } );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
// POST /api/auth/me/password — authenticated password change
|
// POST /api/auth/me/password
|
||||||
router.post( '/me/password', requireAuth, async ( req, res ) =>
|
router.post( '/me/password', requireAuth, async ( req, res ) =>
|
||||||
{
|
{
|
||||||
const { currentPassword, newPassword } = req.body as { currentPassword?: string; newPassword?: string };
|
const { currentPassword, newPassword } = req.body as { currentPassword?: string; newPassword?: string };
|
||||||
|
|
@ -240,4 +287,15 @@ router.post( '/me/password', requireAuth, async ( req, res ) =>
|
||||||
res.json( { ok: true } );
|
res.json( { ok: true } );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
// DELETE /api/auth/me
|
||||||
|
router.delete( '/me', requireAuth, ( req, res ) =>
|
||||||
|
{
|
||||||
|
const userId = req.auth!.userId;
|
||||||
|
refreshTokens.deleteForUser( userId );
|
||||||
|
resetTokens.deleteForUser( userId );
|
||||||
|
users.delete( userId );
|
||||||
|
clearAuthCookies( res );
|
||||||
|
res.json( { ok: true } );
|
||||||
|
} );
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue