phase-10.5: market screener ui enhancements
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* AuthController — HTTP layer for authentication.
|
||||
*
|
||||
* POST /auth/register — create account (requires invite code generated at boot)
|
||||
* POST /auth/login — verify credentials, returns JWT
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { AuthService } from './AuthService.js';
|
||||
|
||||
interface RegisterBody {
|
||||
email: string;
|
||||
password: string;
|
||||
inviteCode: string;
|
||||
role?: 'trader' | 'viewer';
|
||||
}
|
||||
|
||||
interface LoginBody {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface ForgotBody {
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface ResetBody {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const registerSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password', 'inviteCode'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string', minLength: 8 },
|
||||
inviteCode: { type: 'string' },
|
||||
role: { type: 'string', enum: ['trader', 'viewer'] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const loginSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const forgotSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resetSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['token', 'password'],
|
||||
properties: {
|
||||
token: { type: 'string', minLength: 32 },
|
||||
password: { type: 'string', minLength: 8 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export class AuthController {
|
||||
readonly #inviteCode: string;
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
inviteCode: string,
|
||||
) {
|
||||
this.#inviteCode = inviteCode;
|
||||
}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.post('/auth/register', { schema: registerSchema }, this.#register.bind(this));
|
||||
app.post('/auth/login', { schema: loginSchema }, this.#login.bind(this));
|
||||
app.post('/auth/forgot-password', { schema: forgotSchema }, this.#forgot.bind(this));
|
||||
app.post('/auth/reset-password', { schema: resetSchema }, this.#reset.bind(this));
|
||||
}
|
||||
|
||||
async #register(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const { email, password, inviteCode, role } = req.body as RegisterBody;
|
||||
|
||||
if (inviteCode !== this.#inviteCode) {
|
||||
return reply.code(403).send({ error: 'Invalid invite code' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = this.authService.register(email, password, role ?? 'viewer');
|
||||
reply.code(201).send(result);
|
||||
} catch (err: unknown) {
|
||||
const e = err as { message: string; statusCode?: number };
|
||||
reply.code(e.statusCode ?? 500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
async #login(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const { email, password } = req.body as LoginBody;
|
||||
try {
|
||||
const result = this.authService.login(email, password);
|
||||
reply.send(result);
|
||||
} catch (err: unknown) {
|
||||
const e = err as { message: string; statusCode?: number };
|
||||
reply.code(e.statusCode ?? 500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
async #forgot(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const { email } = req.body as ForgotBody;
|
||||
const origin = process.env.CLIENT_ORIGIN ?? 'http://localhost:5173';
|
||||
try {
|
||||
this.authService.forgotPassword(email, origin);
|
||||
} catch (err) {
|
||||
// Log server-side but never expose details to client
|
||||
console.error('[forgot-password] error:', err);
|
||||
}
|
||||
// Always return 200 — never reveal whether the email exists or any error occurred
|
||||
reply.send({
|
||||
message: 'If that email is registered, a reset link has been printed to the server console.',
|
||||
});
|
||||
}
|
||||
|
||||
async #reset(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const { token, password } = req.body as ResetBody;
|
||||
try {
|
||||
this.authService.resetPassword(token, password);
|
||||
reply.send({ message: 'Password updated. You can now log in.' });
|
||||
} catch (err: unknown) {
|
||||
const e = err as { message: string; statusCode?: number };
|
||||
reply.code(e.statusCode ?? 500).send({ error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* AuthService — authentication logic.
|
||||
*
|
||||
* JWT: hand-rolled HMAC-SHA256 (no external lib) using Node's built-in crypto.
|
||||
* Passwords: scrypt KDF with random salt (Node crypto, OWASP-recommended).
|
||||
*/
|
||||
|
||||
import { createHmac, randomBytes, scryptSync, timingSafeEqual, randomUUID } from 'crypto';
|
||||
import type { UserStore } from './UserStore.js';
|
||||
import type { AuthResponse, Role, TokenPayload, User } from './auth.model.js';
|
||||
|
||||
// ── JWT helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function b64url(input: string | Buffer): string {
|
||||
const buf = typeof input === 'string' ? Buffer.from(input) : input;
|
||||
return buf.toString('base64url');
|
||||
}
|
||||
|
||||
function signJwt(payload: TokenPayload, secret: string, expiresInSec = 60 * 60 * 8): string {
|
||||
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const body = b64url(JSON.stringify({ ...payload, iat: now, exp: now + expiresInSec }));
|
||||
const sig = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest());
|
||||
return `${header}.${body}.${sig}`;
|
||||
}
|
||||
|
||||
export function verifyJwt(token: string, secret: string): TokenPayload {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) throw new Error('Invalid token format');
|
||||
const [header, body, sig] = parts;
|
||||
const expected = b64url(createHmac('sha256', secret).update(`${header}.${body}`).digest());
|
||||
if (sig !== expected) throw new Error('Invalid token signature');
|
||||
const payload: TokenPayload = JSON.parse(Buffer.from(body, 'base64url').toString());
|
||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('Token expired');
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ── Password helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 32 };
|
||||
|
||||
function hashPassword(plain: string): string {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, {
|
||||
N: SCRYPT_PARAMS.N,
|
||||
r: SCRYPT_PARAMS.r,
|
||||
p: SCRYPT_PARAMS.p,
|
||||
}).toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
function verifyPassword(plain: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (!salt || !hash) return false;
|
||||
const attempt = scryptSync(plain, salt, SCRYPT_PARAMS.keylen, {
|
||||
N: SCRYPT_PARAMS.N,
|
||||
r: SCRYPT_PARAMS.r,
|
||||
p: SCRYPT_PARAMS.p,
|
||||
});
|
||||
return timingSafeEqual(Buffer.from(hash, 'hex'), attempt);
|
||||
}
|
||||
|
||||
// ── AuthService ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class AuthService {
|
||||
readonly #store: UserStore;
|
||||
readonly #secret: string;
|
||||
|
||||
constructor(store: UserStore, secret: string) {
|
||||
this.#store = store;
|
||||
this.#secret = secret;
|
||||
}
|
||||
|
||||
register(email: string, password: string, role: Role = 'viewer'): AuthResponse {
|
||||
const existing = this.#store.findByEmail(email);
|
||||
if (existing) throw Object.assign(new Error('Email already registered'), { statusCode: 409 });
|
||||
|
||||
const passwordHash = hashPassword(password);
|
||||
const user = this.#store.create(email, passwordHash, role);
|
||||
const token = this.#issueToken(user);
|
||||
return { token, user };
|
||||
}
|
||||
|
||||
login(email: string, password: string): AuthResponse {
|
||||
const row = this.#store.findByEmail(email);
|
||||
if (!row) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
|
||||
|
||||
const valid = verifyPassword(password, row.password_hash);
|
||||
if (!valid) throw Object.assign(new Error('Invalid credentials'), { statusCode: 401 });
|
||||
|
||||
this.#store.touchLogin(row.id);
|
||||
|
||||
const user: User = {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
lastLogin: row.last_login,
|
||||
};
|
||||
const token = this.#issueToken(user);
|
||||
return { token, user };
|
||||
}
|
||||
|
||||
verify(token: string): TokenPayload {
|
||||
return verifyJwt(token, this.#secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a password reset token and print the reset link to the console.
|
||||
* Always returns success (no email enumeration).
|
||||
*/
|
||||
forgotPassword(email: string, appOrigin: string): void {
|
||||
this.#store.purgeExpiredTokens();
|
||||
const user = this.#store.findByEmail(email);
|
||||
if (!user) return; // silent — don't reveal whether email exists
|
||||
|
||||
const token = randomUUID().replace(/-/g, ''); // 32-char hex
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
|
||||
this.#store.createResetToken(user.id, token, expiresAt);
|
||||
|
||||
const link = `${appOrigin}/auth/reset-password?token=${token}`;
|
||||
console.log('\n🔐 Password reset requested for:', email);
|
||||
console.log(' Link (expires in 1 hour):');
|
||||
console.log(` ${link}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a reset token and update the user's password.
|
||||
*/
|
||||
resetPassword(token: string, newPassword: string): void {
|
||||
const row = this.#store.findResetToken(token);
|
||||
if (!row) throw Object.assign(new Error('Invalid or expired reset link'), { statusCode: 400 });
|
||||
if (row.used) throw Object.assign(new Error('Reset link already used'), { statusCode: 400 });
|
||||
if (new Date(row.expires_at) < new Date()) {
|
||||
throw Object.assign(new Error('Reset link has expired'), { statusCode: 400 });
|
||||
}
|
||||
|
||||
const passwordHash = hashPassword(newPassword);
|
||||
this.#store.updatePassword(row.user_id, passwordHash);
|
||||
this.#store.markTokenUsed(token);
|
||||
}
|
||||
|
||||
#issueToken(user: User): string {
|
||||
return signJwt({ sub: user.id, email: user.email, role: user.role }, this.#secret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* UserStore — persistence layer for the users table.
|
||||
* All queries go through DatabaseConnection for audit + safety.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { DatabaseConnection } from '../shared/db/DatabaseConnection.js';
|
||||
import { USER_QUERIES, RESET_TOKEN_QUERIES } from '../shared/db/queries.constant.js';
|
||||
import type { Role, User, UserRow } from './auth.model.js';
|
||||
|
||||
export class UserStore {
|
||||
constructor(private readonly db: DatabaseConnection) {}
|
||||
|
||||
findByEmail(email: string): UserRow | undefined {
|
||||
return this.db.rawGet<UserRow>(USER_QUERIES.SELECT_BY_EMAIL, [email]);
|
||||
}
|
||||
|
||||
findById(id: string): User | undefined {
|
||||
const row = this.db.rawGet<UserRow>(USER_QUERIES.SELECT_BY_ID, [id]);
|
||||
if (!row) return undefined;
|
||||
return this.#toUser(row);
|
||||
}
|
||||
|
||||
create(email: string, passwordHash: string, role: Role = 'viewer'): User {
|
||||
const id = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
this.db.rawRun(USER_QUERIES.INSERT, [id, email, passwordHash, role, createdAt]);
|
||||
return { id, email, role, createdAt, lastLogin: null };
|
||||
}
|
||||
|
||||
touchLogin(id: string): void {
|
||||
this.db.rawRun(USER_QUERIES.UPDATE_LAST_LOGIN, [new Date().toISOString(), id]);
|
||||
}
|
||||
|
||||
updatePassword(id: string, passwordHash: string): void {
|
||||
this.db.rawRun('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id]);
|
||||
}
|
||||
|
||||
// ── Password reset tokens ──────────────────────────────────────────────────
|
||||
|
||||
createResetToken(userId: string, token: string, expiresAt: string): void {
|
||||
this.db.rawRun(RESET_TOKEN_QUERIES.INSERT, [token, userId, expiresAt]);
|
||||
}
|
||||
|
||||
findResetToken(
|
||||
token: string,
|
||||
): { token: string; user_id: string; expires_at: string; used: number } | undefined {
|
||||
return this.db.rawGet(RESET_TOKEN_QUERIES.FIND, [token]);
|
||||
}
|
||||
|
||||
markTokenUsed(token: string): void {
|
||||
this.db.rawRun(RESET_TOKEN_QUERIES.MARK_USED, [token]);
|
||||
}
|
||||
|
||||
purgeExpiredTokens(): void {
|
||||
this.db.rawRun(RESET_TOKEN_QUERIES.PURGE, [new Date().toISOString()]);
|
||||
}
|
||||
|
||||
#toUser(row: UserRow): User {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
createdAt: row.created_at,
|
||||
lastLogin: row.last_login,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// ── Auth domain types ─────────────────────────────────────────────────────────
|
||||
|
||||
export type Role = 'trader' | 'viewer' | 'admin';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
createdAt: string;
|
||||
lastLogin: string | null;
|
||||
}
|
||||
|
||||
/** Full user row including password hash — only used internally by UserStore/AuthService. */
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
role: Role;
|
||||
created_at: string;
|
||||
last_login: string | null;
|
||||
}
|
||||
|
||||
/** Payload embedded in the JWT. */
|
||||
export interface TokenPayload {
|
||||
sub: string; // user id
|
||||
email: string;
|
||||
role: Role;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
/** Response body for successful login / register. */
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AuthController } from './AuthController.js';
|
||||
export { AuthService, verifyJwt } from './AuthService.js';
|
||||
export { UserStore } from './UserStore.js';
|
||||
export type { User, UserRow, Role, TokenPayload, AuthResponse } from './auth.model.js';
|
||||
Reference in New Issue
Block a user