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:
Rokojori 2026-07-13 12:33:39 +02:00
parent d7eeaace01
commit 07aad6c69e
5 changed files with 199 additions and 58 deletions

View File

@ -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>

View File

@ -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 ) );
} }
}; };

View File

@ -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' ) ) );

View File

@ -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 ) );

View File

@ -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;