Auth Polish
This commit is contained in:
parent
d1445b7c77
commit
08859255df
|
|
@ -94,15 +94,17 @@ export const refreshTokens =
|
|||
find: ( token: string ): RefreshToken | undefined =>
|
||||
read<RefreshToken>( 'refreshTokens' ).find( t => t.token === token ),
|
||||
|
||||
create( userId: string ): RefreshToken
|
||||
create( userId: string, ttlMs: number = 30 * 24 * 60 * 60 * 1000 ): RefreshToken
|
||||
{
|
||||
const token: RefreshToken =
|
||||
{
|
||||
token: randomUUID(),
|
||||
userId,
|
||||
expiresAt: new Date( Date.now() + 30 * 24 * 60 * 60 * 1000 ).toISOString()
|
||||
expiresAt: new Date( Date.now() + ttlMs ).toISOString()
|
||||
};
|
||||
|
||||
write( 'refreshTokens', [ ...read<RefreshToken>( 'refreshTokens' ), token ] );
|
||||
|
||||
return token;
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
export class LogColors
|
||||
{
|
||||
static readonly reset = "\x1b[0m";
|
||||
static readonly bright = "\x1b[1m";
|
||||
static readonly dim = "\x1b[2m";
|
||||
static readonly underscore = "\x1b[4m";
|
||||
static readonly blink = "\x1b[5m";
|
||||
static readonly reverse = "\x1b[7m";
|
||||
static readonly hidden = "\x1b[8m";
|
||||
|
||||
static readonly black_Foreground = "\x1b[30m";
|
||||
static readonly red_Foreground = "\x1b[31m";
|
||||
static readonly green_Foreground = "\x1b[32m";
|
||||
static readonly yellow_Foreground = "\x1b[33m";
|
||||
static readonly blue_Foreground = "\x1b[34m";
|
||||
static readonly magenta_Foreground = "\x1b[35m";
|
||||
static readonly cyan_Foreground = "\x1b[36m";
|
||||
static readonly white_Foreground = "\x1b[37m";
|
||||
static readonly gray_Foreground = "\x1b[90m";
|
||||
|
||||
static readonly black_Background = "\x1b[40m";
|
||||
static readonly red_Background = "\x1b[41m";
|
||||
static readonly green_Background = "\x1b[42m";
|
||||
static readonly yellow_Background = "\x1b[43m";
|
||||
static readonly blue_Background = "\x1b[44m";
|
||||
static readonly magenta_Background = "\x1b[45m";
|
||||
static readonly cyan_Background = "\x1b[46m";
|
||||
static readonly white_Background = "\x1b[47m";
|
||||
static readonly gray_Background = "\x1b[100m";
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { LogColors } from "./LogColors";
|
||||
|
||||
export class RJLog
|
||||
{
|
||||
static readonly errorColor = LogColors.red_Background;
|
||||
static readonly errorMessageColor = LogColors.red_Foreground;
|
||||
static readonly warnColor = LogColors.yellow_Background;
|
||||
static readonly logColor = LogColors.gray_Background
|
||||
static readonly resetColor = LogColors.reset;
|
||||
|
||||
static readonly matcherWithFunction = /^\s+at\s(.+)\s\(.+?:(\d+:\d+)\)/;
|
||||
static readonly matcherFile = /\(.+?\\(\w+)\.js:(\d+:\d+)\)/;
|
||||
static readonly matcherAnonymous = /^\s+at\s(.+)\s\((.+)\)/;
|
||||
|
||||
static readonly logAlwaysLineInfo = true;
|
||||
|
||||
|
||||
static _parseLineResult( line:string ):RegExpExecArray|null
|
||||
{
|
||||
let result = RJLog.matcherWithFunction.exec( line ) ||
|
||||
RJLog.matcherFile.exec( line ) ||
|
||||
RJLog.matcherAnonymous.exec( line );
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static _parseLine( line:string ):string
|
||||
{
|
||||
let result = RJLog._parseLineResult( line );
|
||||
|
||||
if ( result )
|
||||
{
|
||||
return " " + result[ 1 ] + "(" + result[ 2 ] + ") ";
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
static logError( e:Error )
|
||||
{
|
||||
console.log( "\n" + RJLog._formatErrorMessage( ( e as any ) .stack ) );
|
||||
}
|
||||
|
||||
static splitLines( text:string )
|
||||
{
|
||||
return text.split( /(?:\r\n)|\n|\r/g );
|
||||
}
|
||||
|
||||
static _formatErrorMessage( stackTrace:string, color:string = RJLog.errorMessageColor )
|
||||
{
|
||||
let lines = RJLog.splitLines( stackTrace );
|
||||
let output:string[] = [ color ];
|
||||
|
||||
lines.forEach(
|
||||
( line, index ) =>
|
||||
{
|
||||
let lineInfo = RJLog._parseLine( line );
|
||||
|
||||
output.push( lineInfo );
|
||||
|
||||
if ( index !== lines.length - 1 )
|
||||
{
|
||||
output.push( "\n" );
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
output.push( RJLog.resetColor );
|
||||
|
||||
return output.join( "" );
|
||||
}
|
||||
|
||||
static getLineInfo( color:string = RJLog.logColor, stackTrace?:string, lineIndex:number = 3 )
|
||||
{
|
||||
stackTrace = stackTrace || ( new Error().stack + "" );
|
||||
|
||||
let lines = RJLog.splitLines( stackTrace );
|
||||
|
||||
let result:RegExpExecArray|null = null;
|
||||
|
||||
while ( ! result && lineIndex < lines.length )
|
||||
{
|
||||
let line = lines[ lineIndex ];
|
||||
|
||||
result = RJLog._parseLineResult( line ) ;
|
||||
|
||||
lineIndex ++;
|
||||
}
|
||||
|
||||
|
||||
if ( ! result )
|
||||
{
|
||||
console.log( stackTrace );
|
||||
return color + " <Unknown> " + RJLog.resetColor ;
|
||||
}
|
||||
|
||||
return color + " " + result[ 1 ] + "(" + result[ 2 ] + ") " + RJLog.resetColor;
|
||||
}
|
||||
|
||||
|
||||
static error( ...params:any[] )
|
||||
{
|
||||
if ( RJLog.logAlwaysLineInfo || typeof process === "object" )
|
||||
{
|
||||
let lineInfo = RJLog.getLineInfo( RJLog.errorColor );
|
||||
console.log( "\n" + lineInfo );
|
||||
}
|
||||
|
||||
console.error.apply( console, params );
|
||||
|
||||
}
|
||||
|
||||
static warn( ...params:any[] )
|
||||
{
|
||||
if ( RJLog.logAlwaysLineInfo || typeof process === "object" )
|
||||
{
|
||||
let lineInfo = RJLog.getLineInfo( RJLog.warnColor );
|
||||
console.log( "\n" + lineInfo );
|
||||
}
|
||||
|
||||
console.warn.apply( console, params );
|
||||
}
|
||||
|
||||
|
||||
static log( ...params:any[] )
|
||||
{
|
||||
if ( RJLog.logAlwaysLineInfo || typeof process === "object" )
|
||||
{
|
||||
let lineInfo = RJLog.getLineInfo();
|
||||
console.log( "\n" + lineInfo );
|
||||
}
|
||||
|
||||
console.log.apply( console, params );
|
||||
}
|
||||
}
|
||||
|
|
@ -6,17 +6,33 @@ import { requireAuth } from '../middleware/requireAuth';
|
|||
import { EmailService } from '../email/EmailService';
|
||||
import { checkForgotPasswordRate, checkLoginRate, checkRegisterRate, sleep } from '../rateLimiter';
|
||||
import { isSuperAdmin } from '../roles';
|
||||
import { RJLog } from '../debug/RJLog';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const ACCESS_TOKEN_TTL = ( process.env.ACCESS_TOKEN_TTL ?? '1h' ) as any;
|
||||
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN ?? '.rokojori.com';
|
||||
function parseTtlMs( ttl: string ): number
|
||||
{
|
||||
const n = parseInt( ttl, 10 );
|
||||
if ( ttl.endsWith( 'ms' ) ) return n;
|
||||
if ( ttl.endsWith( 's' ) ) return n * 1_000;
|
||||
if ( ttl.endsWith( 'm' ) ) return n * 60_000;
|
||||
if ( ttl.endsWith( 'h' ) ) return n * 3_600_000;
|
||||
if ( ttl.endsWith( 'd' ) ) return n * 86_400_000;
|
||||
if ( ttl.endsWith( 'w' ) ) return n * 604_800_000;
|
||||
return 3_600_000;
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_TTL = ( process.env.ACCESS_TOKEN_TTL ?? '1h' ) as any;
|
||||
const ACCESS_TOKEN_TTL_MS = parseTtlMs( process.env.ACCESS_TOKEN_TTL ?? '1h' );
|
||||
const REFRESH_TOKEN_TTL_MS = parseTtlMs( process.env.REFRESH_TOKEN_TTL ?? '30d' );
|
||||
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
|
||||
{
|
||||
const user = users.findById( userId )!;
|
||||
|
||||
const payload =
|
||||
{
|
||||
userId: user.id,
|
||||
|
|
@ -25,6 +41,7 @@ function issueAccessToken( userId: string ): string
|
|||
products: user.products.map( p => p.id ),
|
||||
settings: user.settings
|
||||
};
|
||||
|
||||
return jwt.sign( payload, process.env.JWT_SECRET ?? '', { expiresIn: ACCESS_TOKEN_TTL } );
|
||||
}
|
||||
|
||||
|
|
@ -41,12 +58,12 @@ function cookieOptions( maxAge: number )
|
|||
|
||||
function setTokenCookie( res: Response, accessToken: string ): void
|
||||
{
|
||||
res.cookie( 'accessToken', accessToken, { ...cookieOptions( 60 * 60 * 1000 ), maxAge: 60 * 60 * 1000 } );
|
||||
res.cookie( 'accessToken', accessToken, { ...cookieOptions( ACCESS_TOKEN_TTL_MS ), maxAge: ACCESS_TOKEN_TTL_MS } );
|
||||
}
|
||||
|
||||
function setRefreshTokenCookie( res: Response, token: string ): void
|
||||
{
|
||||
res.cookie( 'refreshToken', token, { ...cookieOptions( 30 * 24 * 60 * 60 * 1000 ), maxAge: 30 * 24 * 60 * 60 * 1000 } );
|
||||
res.cookie( 'refreshToken', token, { ...cookieOptions( REFRESH_TOKEN_TTL_MS ), maxAge: REFRESH_TOKEN_TTL_MS } );
|
||||
}
|
||||
|
||||
function clearAuthCookies( res: Response ): void
|
||||
|
|
@ -59,274 +76,437 @@ function clearAuthCookies( res: Response ): void
|
|||
function issueTokenPair( res: Response, userId: string ): { accessToken: string; refreshToken: string }
|
||||
{
|
||||
const accessToken = issueAccessToken( userId );
|
||||
const refreshRecord = refreshTokens.create( userId );
|
||||
const refreshRecord = refreshTokens.create( userId, REFRESH_TOKEN_TTL_MS );
|
||||
|
||||
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; }
|
||||
// POST /api/auth/register - Create new user
|
||||
router.post( '/register',
|
||||
|
||||
const { email, password } = req.body as { email?: string; password?: string };
|
||||
if ( !email || !password )
|
||||
async ( req, res ) =>
|
||||
{
|
||||
res.status( 400 ).json( { error: 'Email and password required' } );
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
const passwordHash = await bcrypt.hash( password, 10 );
|
||||
const user = users.create( email, passwordHash );
|
||||
const ip = req.ip ?? 'unknown';
|
||||
const { blocked } = checkRegisterRate( ip );
|
||||
if ( blocked ) { res.status( 429 ).json( { error: 'Too many registrations. Try again later.' } ); return; }
|
||||
|
||||
const superadminEmail = process.env.INITIAL_SUPERADMIN_EMAIL?.toLowerCase();
|
||||
if ( superadminEmail && email.toLowerCase() === superadminEmail )
|
||||
const { email, password } = req.body as { email?: string; password?: string };
|
||||
|
||||
if ( ! email || ! password )
|
||||
{
|
||||
const alreadyHasSuperAdmin = users.all().some( u => u.id !== user.id && isSuperAdmin( u.roles ) );
|
||||
if ( !alreadyHasSuperAdmin )
|
||||
users.update( user.id, { roles: [ 'superadmin', 'user' ] } );
|
||||
res.status( 400 ).json( { error: 'Email and password required' } );
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = issueTokenPair( res, user.id );
|
||||
try
|
||||
{
|
||||
const passwordHash = await bcrypt.hash( password, 10 );
|
||||
const user = users.create( email, passwordHash );
|
||||
|
||||
// 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( () => {} );
|
||||
const superadminEmail = process.env.INITIAL_SUPERADMIN_EMAIL?.toLowerCase();
|
||||
|
||||
res.json( tokens );
|
||||
}
|
||||
catch
|
||||
if ( superadminEmail && email.toLowerCase() === superadminEmail )
|
||||
{
|
||||
const alreadyHasSuperAdmin = users.all().some( u => u.id !== user.id && isSuperAdmin( u.roles ) );
|
||||
if ( !alreadyHasSuperAdmin )
|
||||
{
|
||||
users.update( user.id, { roles: [ 'superadmin', 'user' ] } );
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
res.status( 409 ).json( { error: 'Email already registered' } );
|
||||
}
|
||||
}
|
||||
|
||||
);
|
||||
|
||||
// POST /api/auth/login - Login user
|
||||
router.post( '/login',
|
||||
|
||||
async ( req, res ) =>
|
||||
{
|
||||
res.status( 409 ).json( { error: 'Email already registered' } );
|
||||
}
|
||||
} );
|
||||
const ip = req.ip ?? 'unknown';
|
||||
|
||||
// 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 { blocked, delay } = checkLoginRate( ip );
|
||||
|
||||
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 ) ) )
|
||||
{
|
||||
res.status( 401 ).json( { error: 'Invalid credentials' } );
|
||||
return;
|
||||
}
|
||||
res.json( issueTokenPair( res, user.id ) );
|
||||
} );
|
||||
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 ) ) )
|
||||
{
|
||||
res.status( 401 ).json( { error: 'Invalid credentials' } );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let tokenData = issueTokenPair( res, user.id );
|
||||
|
||||
RJLog.log( "Logging in:", user.id, tokenData );
|
||||
|
||||
res.json( tokenData );
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/auth/logout
|
||||
router.post( '/logout', ( req, res ) =>
|
||||
{
|
||||
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 } );
|
||||
} );
|
||||
router.post( '/logout',
|
||||
|
||||
( req, res ) =>
|
||||
{
|
||||
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 } );
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/auth/logout?redirect=... — browser clients (link/redirect-based logout)
|
||||
router.get( '/logout', ( req, res ) =>
|
||||
{
|
||||
const redirectTo = req.query.redirect as string | undefined;
|
||||
const token = req.cookies?.refreshToken as string | undefined;
|
||||
if ( token ) refreshTokens.delete( token );
|
||||
clearAuthCookies( res );
|
||||
res.redirect( redirectTo ?? '/login.html' );
|
||||
} );
|
||||
router.get( '/logout',
|
||||
|
||||
( req, res ) =>
|
||||
{
|
||||
const redirectTo = req.query.redirect as string | undefined;
|
||||
const token = req.cookies?.refreshToken as string | undefined;
|
||||
if ( token ) refreshTokens.delete( token );
|
||||
clearAuthCookies( res );
|
||||
res.redirect( redirectTo ?? '/login.html' );
|
||||
}
|
||||
);
|
||||
|
||||
// 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; }
|
||||
router.post( '/refresh',
|
||||
|
||||
const record = refreshTokens.find( refreshToken );
|
||||
if ( !record || new Date( record.expiresAt ) < new Date() )
|
||||
( req, res ) =>
|
||||
{
|
||||
res.status( 401 ).json( { error: 'Invalid or expired refresh token' } );
|
||||
return;
|
||||
}
|
||||
const user = users.findById( record.userId );
|
||||
if ( !user ) { res.status( 401 ).json( { error: 'User not found' } ); return; }
|
||||
const { refreshToken } = req.body as { refreshToken?: string };
|
||||
|
||||
refreshTokens.delete( refreshToken );
|
||||
res.json( issueTokenPair( res, user.id ) );
|
||||
} );
|
||||
if ( ! refreshToken )
|
||||
{
|
||||
RJLog.log( "Refresh token required, but got", req.body );
|
||||
|
||||
// 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';
|
||||
res.status( 400 ).json( { error: 'Refresh token required' } );
|
||||
return;
|
||||
}
|
||||
|
||||
const token = req.cookies?.refreshToken as string | undefined;
|
||||
if ( !token ) { res.redirect( loginFallback ); return; }
|
||||
const record = refreshTokens.find( refreshToken );
|
||||
|
||||
const record = refreshTokens.find( token );
|
||||
if ( !record || new Date( record.expiresAt ) < new Date() )
|
||||
{
|
||||
clearAuthCookies( res );
|
||||
res.redirect( loginFallback );
|
||||
return;
|
||||
}
|
||||
const user = users.findById( record.userId );
|
||||
if ( !user ) { clearAuthCookies( res ); res.redirect( loginFallback ); return; }
|
||||
if ( ! record || new Date( record.expiresAt ) < new Date() )
|
||||
{
|
||||
RJLog.log( "Invalid or expired refresh token", refreshToken );
|
||||
|
||||
refreshTokens.delete( token );
|
||||
issueTokenPair( res, user.id );
|
||||
res.redirect( redirectTo ?? '/profile.html' );
|
||||
} );
|
||||
res.status( 401 ).json( { error: 'Invalid or expired refresh token' } );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const user = users.findById( record.userId );
|
||||
|
||||
if ( ! user )
|
||||
{
|
||||
RJLog.log( "User not found", refreshToken, record );
|
||||
res.status( 401 ).json( { error: 'User not found' } );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
refreshTokens.delete( refreshToken );
|
||||
|
||||
let tokenData = issueTokenPair( res, user.id );
|
||||
|
||||
RJLog.log( "Refreshing token", user.id, tokenData );
|
||||
|
||||
res.json( tokenData );
|
||||
}
|
||||
|
||||
);
|
||||
|
||||
// DEPRECATED
|
||||
// // 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() )
|
||||
// {
|
||||
// clearAuthCookies( res );
|
||||
// res.redirect( loginFallback );
|
||||
// return;
|
||||
// }
|
||||
// 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
|
||||
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; }
|
||||
await sleep( delay );
|
||||
|
||||
const { email } = req.body as { email?: string };
|
||||
if ( !email ) { res.status( 400 ).json( { error: 'Email required' } ); return; }
|
||||
|
||||
const user = users.findByEmail( email );
|
||||
if ( user )
|
||||
router.post( '/forgot-password',
|
||||
|
||||
async ( req, res ) =>
|
||||
{
|
||||
const token = resetTokens.create( user.id );
|
||||
const link = `${RESET_BASE_URL}/reset-password.html?token=${token.token}`;
|
||||
await EmailService.sendEmail(
|
||||
email,
|
||||
'Reset your password — rokojori',
|
||||
`Hi,\n\nClick the link below to reset your password. It expires in 1 hour.\n\n${link}\n\nIf you did not request this, you can safely ignore this email.`
|
||||
);
|
||||
}
|
||||
const ip = req.ip ?? 'unknown';
|
||||
const { blocked, delay } = checkForgotPasswordRate( ip );
|
||||
|
||||
res.json( { ok: true } );
|
||||
} );
|
||||
if ( blocked )
|
||||
{
|
||||
res.status( 429 ).json( { error: 'Too many attempts. Try again later.' } );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep( delay );
|
||||
|
||||
const { email } = req.body as { email?: string };
|
||||
if ( !email )
|
||||
{
|
||||
res.status( 400 ).json( { error: 'Email required' } );
|
||||
return;
|
||||
}
|
||||
|
||||
const user = users.findByEmail( email );
|
||||
|
||||
if ( user )
|
||||
{
|
||||
const token = resetTokens.create( user.id );
|
||||
const link = `${RESET_BASE_URL}/reset-password.html?token=${token.token}`;
|
||||
await EmailService.sendEmail(
|
||||
email,
|
||||
'Reset your password — rokojori',
|
||||
`Hi,\n\nClick the link below to reset your password. It expires in 1 hour.\n\n${link}\n\nIf you did not request this, you can safely ignore this email.`
|
||||
);
|
||||
}
|
||||
|
||||
res.json( { ok: true } );
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/auth/reset-password
|
||||
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; }
|
||||
router.post( '/reset-password',
|
||||
|
||||
const record = resetTokens.find( token );
|
||||
if ( !record || new Date( record.expiresAt ) < new Date() )
|
||||
async ( req, res ) =>
|
||||
{
|
||||
res.status( 400 ).json( { error: 'Invalid or expired token' } );
|
||||
return;
|
||||
}
|
||||
const passwordHash = await bcrypt.hash( password, 10 );
|
||||
users.update( record.userId, { passwordHash } );
|
||||
resetTokens.delete( token );
|
||||
res.json( { ok: true } );
|
||||
} );
|
||||
const { token, password } = req.body as { token?: string; password?: string };
|
||||
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() )
|
||||
{
|
||||
res.status( 400 ).json( { error: 'Invalid or expired token' } );
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash( password, 10 );
|
||||
|
||||
users.update( record.userId, { passwordHash } );
|
||||
resetTokens.delete( token );
|
||||
res.json( { ok: true } );
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/auth/reset-token-email
|
||||
router.get( '/reset-token-email', ( req, res ) =>
|
||||
{
|
||||
const token = req.query.token as string | undefined;
|
||||
const record = token ? resetTokens.find( token ) : undefined;
|
||||
if ( !record || new Date( record.expiresAt ) < new Date() )
|
||||
router.get( '/reset-token-email',
|
||||
|
||||
( req, res ) =>
|
||||
{
|
||||
res.status( 400 ).json( { error: 'Invalid or expired token' } );
|
||||
return;
|
||||
}
|
||||
const user = users.findById( record.userId );
|
||||
if ( !user ) { res.status( 400 ).json( { error: 'User not found' } ); return; }
|
||||
res.json( { email: user.email } );
|
||||
} );
|
||||
const token = req.query.token as string | undefined;
|
||||
const record = token ? resetTokens.find( token ) : undefined;
|
||||
if ( !record || new Date( record.expiresAt ) < new Date() )
|
||||
{
|
||||
res.status( 400 ).json( { error: 'Invalid or expired token' } );
|
||||
return;
|
||||
}
|
||||
const user = users.findById( record.userId );
|
||||
if ( !user ) { res.status( 400 ).json( { error: 'User not found' } ); return; }
|
||||
res.json( { email: user.email } );
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/auth/lookup-email — server-to-server; requires SERVICE_SECRET
|
||||
router.post( '/lookup-email', ( req, res ) =>
|
||||
{
|
||||
const secret = process.env.SERVICE_SECRET;
|
||||
const auth = req.headers.authorization;
|
||||
router.post( '/lookup-email',
|
||||
|
||||
if ( !secret || auth !== `Bearer ${secret}` )
|
||||
( req, res ) =>
|
||||
{
|
||||
res.status( 401 ).json( { error: 'Unauthorized' } );
|
||||
return;
|
||||
}
|
||||
const secret = process.env.SERVICE_SECRET;
|
||||
const auth = req.headers.authorization;
|
||||
|
||||
const { email } = req.body as { email?: string };
|
||||
if ( !email ) { res.status( 400 ).json( { error: 'Email required' } ); return; }
|
||||
if ( ! secret || auth !== `Bearer ${secret}` )
|
||||
{
|
||||
res.status( 401 ).json( { error: 'Unauthorized' } );
|
||||
|
||||
const user = users.findByEmail( email );
|
||||
if ( !user ) { res.status( 404 ).json( { error: 'No account found for that email' } ); return; }
|
||||
return;
|
||||
}
|
||||
|
||||
res.json( { id: user.id, email: user.email } );
|
||||
} );
|
||||
const { email } = req.body as { email?: string };
|
||||
|
||||
if ( ! email )
|
||||
{
|
||||
res.status( 400 ).json( { error: 'Email required' } );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const user = users.findByEmail( email );
|
||||
|
||||
if ( ! user )
|
||||
{
|
||||
res.status( 404 ).json( { error: 'No account found for that email' } );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
res.json( { id: user.id, email: user.email } );
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get( '/me', requireAuth, ( req, res ) =>
|
||||
{
|
||||
const user = users.findById( req.auth!.userId );
|
||||
if ( !user ) { res.status( 404 ).json( { error: 'User not found' } ); return; }
|
||||
res.json(
|
||||
router.get( '/me', requireAuth,
|
||||
|
||||
( req, res ) =>
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
roles: user.roles,
|
||||
products: user.products,
|
||||
settings: user.settings
|
||||
} );
|
||||
} );
|
||||
const user = users.findById( req.auth!.userId );
|
||||
|
||||
if ( !user )
|
||||
{
|
||||
res.status( 404 ).json( { error: 'User not found' } );
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
roles: user.roles,
|
||||
products: user.products,
|
||||
settings: user.settings
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /api/auth/me/settings
|
||||
router.patch( '/me/settings', requireAuth, ( req, res ) =>
|
||||
{
|
||||
const user = users.findById( req.auth!.userId );
|
||||
if ( !user ) { res.status( 404 ).json( { error: 'User not found' } ); return; }
|
||||
const settings = { ...user.settings, ...( req.body as Record<string, unknown> ) };
|
||||
users.update( req.auth!.userId, { settings } );
|
||||
res.json( { settings } );
|
||||
} );
|
||||
router.patch( '/me/settings', requireAuth,
|
||||
|
||||
( req, res ) =>
|
||||
{
|
||||
const user = users.findById( req.auth!.userId );
|
||||
if ( ! user )
|
||||
{
|
||||
res.status( 404 ).json( { error: 'User not found' } ); return;
|
||||
}
|
||||
|
||||
const settings = { ...user.settings, ...( req.body as Record<string, unknown> ) };
|
||||
|
||||
users.update( req.auth!.userId, { settings } );
|
||||
|
||||
res.json( { settings } );
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/auth/me/password
|
||||
router.post( '/me/password', requireAuth, async ( req, res ) =>
|
||||
{
|
||||
const { currentPassword, newPassword } = req.body as { currentPassword?: string; newPassword?: string };
|
||||
if ( !currentPassword || !newPassword )
|
||||
router.post( '/me/password',
|
||||
|
||||
requireAuth, async ( req, res ) =>
|
||||
{
|
||||
res.status( 400 ).json( { error: 'Current and new password required' } );
|
||||
return;
|
||||
}
|
||||
const user = users.findById( req.auth!.userId );
|
||||
if ( !user ) { res.status( 404 ).json( { error: 'User not found' } ); return; }
|
||||
if ( !( await bcrypt.compare( currentPassword, user.passwordHash ) ) )
|
||||
{
|
||||
res.status( 401 ).json( { error: 'Current password is incorrect' } );
|
||||
return;
|
||||
}
|
||||
const passwordHash = await bcrypt.hash( newPassword, 10 );
|
||||
users.update( user.id, { passwordHash } );
|
||||
res.json( { ok: true } );
|
||||
} );
|
||||
const { currentPassword, newPassword } = req.body as { currentPassword?: string; newPassword?: string };
|
||||
|
||||
if ( ! currentPassword || ! newPassword )
|
||||
{
|
||||
res.status( 400 ).json( { error: 'Current and new password required' } );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const user = users.findById( req.auth!.userId );
|
||||
if ( !user )
|
||||
{
|
||||
res.status( 404 ).json( { error: 'User not found' } );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! ( await bcrypt.compare( currentPassword, user.passwordHash ) ) )
|
||||
{
|
||||
res.status( 401 ).json( { error: 'Current password is incorrect' } );
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash( newPassword, 10 );
|
||||
|
||||
users.update( user.id, { passwordHash } );
|
||||
|
||||
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 } );
|
||||
} );
|
||||
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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue