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>
</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) -->
<div class="card" id="admin-card" hidden>
<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();
</script>
</body>

View File

@ -138,5 +138,10 @@ export const resetTokens =
delete( token: string ): void
{
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();
app.set( 'trust proxy', 1 );
app.use( express.json() );
app.use( cookieParser() );
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
{
count: number;
windowStart: number;
}
const store = new Map<string, Entry>();
function makeRateLimiter( maxAttempts: number, windowMs: number )
{
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 <= 10 ) return 15_000;
@ -19,18 +38,47 @@ function delayMs( count: number ): number
export function checkForgotPasswordRate( ip: string ): { blocked: boolean; delay: number }
{
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++;
store.set( ip, entry );
forgotStore.set( ip, entry );
if ( entry.count > MAX_ATTEMPTS ) return { blocked: true, delay: 0 };
return { blocked: false, delay: delayMs( entry.count ) };
if ( entry.count > FORGOT_MAX_ATTEMPTS ) return { blocked: true, delay: 0 };
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>
{
return new Promise( resolve => setTimeout( resolve, ms ) );

View File

@ -4,14 +4,15 @@ import jwt from 'jsonwebtoken';
import { users, refreshTokens, resetTokens } from '../db';
import { requireAuth } from '../middleware/requireAuth';
import { EmailService } from '../email/EmailService';
import { checkForgotPasswordRate, sleep } from '../rateLimiter';
import { checkForgotPasswordRate, checkLoginRate, checkRegisterRate, sleep } from '../rateLimiter';
import { isSuperAdmin } from '../roles';
const router = Router();
const ACCESS_TOKEN_TTL = '1h';
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com';
const RESET_BASE_URL = process.env.RESET_BASE_URL ?? 'https://account.rokojori.com';
const ACCESS_TOKEN_TTL = '1h';
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.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
{
@ -27,22 +28,50 @@ function issueAccessToken( userId: string ): string
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,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 1000,
sameSite: 'lax',
sameSite: 'lax' as const,
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
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 };
if ( !email || !password )
{
@ -54,7 +83,6 @@ router.post( '/register', async ( req, res ) =>
const passwordHash = await bcrypt.hash( password, 10 );
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();
if ( superadminEmail && email.toLowerCase() === superadminEmail )
{
@ -63,10 +91,16 @@ router.post( '/register', async ( req, res ) =>
users.update( user.id, { roles: [ 'superadmin', 'user' ] } );
}
const accessToken = issueAccessToken( user.id );
const refresh = refreshTokens.create( user.id );
setTokenCookie( res, accessToken );
res.json( { accessToken, refreshToken: refresh.token } );
const tokens = issueTokenPair( res, user.id );
// Welcome email — non-blocking
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
{
@ -77,6 +111,11 @@ router.post( '/register', async ( req, res ) =>
// POST /api/auth/login
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 user = email ? users.findByEmail( email ) : undefined;
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' } );
return;
}
const accessToken = issueAccessToken( user.id );
const refresh = refreshTokens.create( user.id );
setTokenCookie( res, accessToken );
res.json( { accessToken, refreshToken: refresh.token } );
res.json( issueTokenPair( res, user.id ) );
} );
// POST /api/auth/logout
router.post( '/logout', ( req, res ) =>
{
const { refreshToken } = req.body as { refreshToken?: string };
if ( refreshToken ) refreshTokens.delete( refreshToken );
res.clearCookie( 'accessToken', { domain: COOKIE_DOMAIN, path: '/' } );
const tokenFromBody = ( req.body as { refreshToken?: string } ).refreshToken;
const tokenFromCookie = req.cookies?.refreshToken as string | undefined;
const token = tokenFromBody ?? tokenFromCookie;
if ( token ) refreshTokens.delete( token );
clearAuthCookies( res );
res.json( { ok: true } );
} );
// POST /api/auth/refresh
// POST /api/auth/refresh — non-browser clients (token in body)
router.post( '/refresh', ( req, res ) =>
{
const { refreshToken } = req.body as { refreshToken?: string };
if ( !refreshToken )
{
res.status( 400 ).json( { error: 'Refresh token required' } );
return;
}
if ( !refreshToken ) { res.status( 400 ).json( { error: 'Refresh token required' } ); return; }
const record = refreshTokens.find( refreshToken );
if ( !record || new Date( record.expiresAt ) < new Date() )
{
@ -115,14 +150,36 @@ router.post( '/refresh', ( req, res ) =>
return;
}
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 ) =>
{
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() )
{
res.status( 401 ).json( { error: 'User not found' } );
clearAuthCookies( res );
res.redirect( loginFallback );
return;
}
const accessToken = issueAccessToken( user.id );
setTokenCookie( res, accessToken );
res.json( { accessToken } );
const user = users.findById( record.userId );
if ( !user ) { clearAuthCookies( res ); res.redirect( loginFallback ); return; }
refreshTokens.delete( token );
issueTokenPair( res, user.id );
res.redirect( redirectTo ?? '/profile.html' );
} );
// 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 { 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 );
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 } );
} );
@ -162,11 +212,8 @@ router.post( '/forgot-password', async ( req, res ) =>
router.post( '/reset-password', async ( req, res ) =>
{
const { token, password } = req.body as { token?: string; password?: string };
if ( !token || !password )
{
res.status( 400 ).json( { error: 'Token and password required' } );
return;
}
if ( !token || !password ) { res.status( 400 ).json( { error: 'Token and password required' } ); return; }
const record = resetTokens.find( token );
if ( !record || new Date( record.expiresAt ) < new Date() )
{
@ -179,7 +226,7 @@ router.post( '/reset-password', async ( req, res ) =>
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 ) =>
{
const token = req.query.token as string | undefined;
@ -219,7 +266,7 @@ router.patch( '/me/settings', requireAuth, ( req, res ) =>
res.json( { settings } );
} );
// POST /api/auth/me/password — authenticated password change
// POST /api/auth/me/password
router.post( '/me/password', requireAuth, async ( req, res ) =>
{
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 } );
} );
// 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;