Add auth system with invite-only registration and admin roles
JWT-based auth (hono/jwt + bcrypt), anonymous-first flow preserved. Registration requires invite code when REQUIRE_INVITE=true. Admin user seeded on startup (admin/admin, forced password reset). Login accepts email or username. Admin invitations management page in sidebar. Regular users get invite-a-friend button when USER_INVITATIONS > 0. Frontend gate screen blocks game access for unregistered users with invite code entry, registration, login, and password reset flows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,11 @@ import { pgTable, uuid, text, timestamp, jsonb, integer, boolean, index } from '
|
||||
export const users = pgTable('users', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
anonToken: uuid('anon_token').defaultRandom().notNull().unique(),
|
||||
username: text('username').unique(),
|
||||
email: text('email').unique(),
|
||||
passwordHash: text('password_hash'),
|
||||
role: text('role').notNull().default('user'),
|
||||
mustResetPassword: boolean('must_reset_password').notNull().default(false),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
lastSeenAt: timestamp('last_seen_at').defaultNow().notNull(),
|
||||
});
|
||||
@@ -44,3 +47,12 @@ export const achievements = pgTable('achievements', {
|
||||
}, (table) => [
|
||||
index('achievements_user_id_idx').on(table.userId),
|
||||
]);
|
||||
|
||||
export const invitations = pgTable('invitations', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
code: text('code').notNull().unique(),
|
||||
createdBy: uuid('created_by').notNull().references(() => users.id),
|
||||
usedBy: uuid('used_by').references(() => users.id),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at'),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from './index';
|
||||
import { users } from './schema';
|
||||
|
||||
export async function seedAdmin() {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, 'admin'))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
console.log('Admin user already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash('admin', 10);
|
||||
await db.insert(users).values({
|
||||
username: 'admin',
|
||||
passwordHash,
|
||||
role: 'admin',
|
||||
mustResetPassword: true,
|
||||
});
|
||||
|
||||
console.log('Admin user seeded (admin/admin — password reset required)');
|
||||
}
|
||||
@@ -5,6 +5,13 @@ import { serve } from '@hono/node-server';
|
||||
import { auth } from './routes/auth';
|
||||
import { savesRouter } from './routes/saves';
|
||||
import { leaderboardRouter } from './routes/leaderboard';
|
||||
import { invitesRouter } from './routes/invites';
|
||||
import { seedAdmin } from './db/seed';
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
console.error('FATAL: JWT_SECRET environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -19,12 +26,20 @@ app.use('*', cors({
|
||||
|
||||
app.get('/health', (c) => c.json({ status: 'ok', version: '0.1.0' }));
|
||||
|
||||
app.get('/api/config', (c) => c.json({
|
||||
requireInvite: process.env.REQUIRE_INVITE !== 'false',
|
||||
userInvitations: parseInt(process.env.USER_INVITATIONS || '0', 10),
|
||||
}));
|
||||
|
||||
app.route('/api/auth', auth);
|
||||
app.route('/api/saves', savesRouter);
|
||||
app.route('/api/leaderboard', leaderboardRouter);
|
||||
app.route('/api/invites', invitesRouter);
|
||||
|
||||
const port = Number(process.env.PORT) || 3001;
|
||||
|
||||
console.log(`AI Tycoon API server starting on port ${port}...`);
|
||||
|
||||
await seedAdmin();
|
||||
|
||||
serve({ fetch: app.fetch, port });
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { sign, verify } from 'hono/jwt';
|
||||
|
||||
const JWT_EXPIRY_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
export function getJwtSecret(): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) throw new Error('JWT_SECRET env var is required');
|
||||
return secret;
|
||||
}
|
||||
|
||||
export async function createToken(
|
||||
userId: string,
|
||||
email: string | null,
|
||||
role: string,
|
||||
username: string | null,
|
||||
mustResetPassword: boolean,
|
||||
): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return sign(
|
||||
{ sub: userId, email, role, username, mustResetPassword, iat: now, exp: now + JWT_EXPIRY_SECONDS },
|
||||
getJwtSecret(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string): Promise<{
|
||||
sub: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
username: string | null;
|
||||
mustResetPassword: boolean;
|
||||
}> {
|
||||
const payload = await verify(token, getJwtSecret(), 'HS256');
|
||||
return {
|
||||
sub: payload.sub as string,
|
||||
email: (payload.email as string) ?? null,
|
||||
role: (payload.role as string) ?? 'user',
|
||||
username: (payload.username as string) ?? null,
|
||||
mustResetPassword: (payload.mustResetPassword as boolean) ?? false,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createMiddleware } from 'hono/factory';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users } from '../db/schema';
|
||||
import { verifyToken } from '../lib/jwt';
|
||||
import type { AppEnv } from '../types';
|
||||
|
||||
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
||||
@@ -14,10 +15,12 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
try {
|
||||
const payload = await verifyToken(token);
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.anonToken, token))
|
||||
.where(eq(users.id, payload.sub))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
@@ -30,9 +33,24 @@ export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
c.set('userId', user.id);
|
||||
c.set('user', user as AppEnv['Variables']['user']);
|
||||
c.set('user', {
|
||||
id: user.id,
|
||||
anonToken: user.anonToken,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
mustResetPassword: user.mustResetPassword,
|
||||
});
|
||||
await next();
|
||||
} catch {
|
||||
return c.json({ error: 'Authentication failed' }, 500);
|
||||
return c.json({ error: 'Invalid or expired token' }, 401);
|
||||
}
|
||||
});
|
||||
|
||||
export const requireAdmin = createMiddleware<AppEnv>(async (c, next) => {
|
||||
const user = c.get('user');
|
||||
if (user.role !== 'admin') {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
+135
-34
@@ -1,7 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, or } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '../db';
|
||||
import { users } from '../db/schema';
|
||||
import { createToken } from '../lib/jwt';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import type { AppEnv } from '../types';
|
||||
|
||||
const auth = new Hono<AppEnv>();
|
||||
@@ -12,20 +15,51 @@ auth.post('/anonymous', async (c) => {
|
||||
.values({})
|
||||
.returning();
|
||||
|
||||
return c.json({
|
||||
userId: user.id,
|
||||
token: user.anonToken,
|
||||
});
|
||||
const token = await createToken(user.id, null, 'user', null, false);
|
||||
return c.json({ userId: user.id, token });
|
||||
});
|
||||
|
||||
auth.post('/link-email', async (c) => {
|
||||
const userId = c.get('userId') as string;
|
||||
if (!userId) return c.json({ error: 'Not authenticated' }, 401);
|
||||
auth.post('/register', authMiddleware, async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const { email, password, inviteCode } = await c.req.json<{
|
||||
email: string;
|
||||
password: string;
|
||||
inviteCode: string;
|
||||
}>();
|
||||
|
||||
const { email, password } = await c.req.json<{ email: string; password: string }>();
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return c.json({ error: 'Valid email required' }, 400);
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
||||
}
|
||||
|
||||
if (!email || !password) {
|
||||
return c.json({ error: 'Email and password required' }, 400);
|
||||
if (process.env.REQUIRE_INVITE !== 'false') {
|
||||
if (!inviteCode) {
|
||||
return c.json({ error: 'Invite code required' }, 400);
|
||||
}
|
||||
|
||||
const { invitations } = await import('../db/schema');
|
||||
const { isNull, and, sql } = await import('drizzle-orm');
|
||||
|
||||
const [consumed] = await db
|
||||
.update(invitations)
|
||||
.set({ usedBy: userId })
|
||||
.where(
|
||||
and(
|
||||
eq(invitations.code, inviteCode),
|
||||
isNull(invitations.usedBy),
|
||||
or(
|
||||
isNull(invitations.expiresAt),
|
||||
sql`${invitations.expiresAt} > NOW()`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!consumed) {
|
||||
return c.json({ error: 'Invalid or used invite code' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
@@ -38,45 +72,112 @@ auth.post('/link-email', async (c) => {
|
||||
return c.json({ error: 'Email already in use' }, 409);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(password);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
await db
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({ email, passwordHash: hashHex })
|
||||
.where(eq(users.id, userId));
|
||||
.set({ email, passwordHash })
|
||||
.where(eq(users.id, userId))
|
||||
.returning();
|
||||
|
||||
return c.json({ success: true });
|
||||
const token = await createToken(updated.id, updated.email, updated.role, updated.username, false);
|
||||
return c.json({ userId: updated.id, token });
|
||||
});
|
||||
|
||||
auth.post('/login', async (c) => {
|
||||
const { email, password } = await c.req.json<{ email: string; password: string }>();
|
||||
const { login, password } = await c.req.json<{ login: string; password: string }>();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(password);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
if (!login || !password) {
|
||||
return c.json({ error: 'Login and password required' }, 400);
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.where(or(eq(users.email, login), eq(users.username, login)))
|
||||
.limit(1);
|
||||
|
||||
if (!user || user.passwordHash !== hashHex) {
|
||||
if (!user || !user.passwordHash) {
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
userId: user.id,
|
||||
token: user.anonToken,
|
||||
});
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return c.json({ error: 'Invalid credentials' }, 401);
|
||||
}
|
||||
|
||||
const token = await createToken(user.id, user.email, user.role, user.username, user.mustResetPassword);
|
||||
return c.json({ userId: user.id, token });
|
||||
});
|
||||
|
||||
auth.post('/change-password', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
const { currentPassword, newPassword } = await c.req.json<{
|
||||
currentPassword?: string;
|
||||
newPassword: string;
|
||||
}>();
|
||||
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return c.json({ error: 'New password must be at least 8 characters' }, 400);
|
||||
}
|
||||
|
||||
if (!user.mustResetPassword) {
|
||||
if (!currentPassword) {
|
||||
return c.json({ error: 'Current password required' }, 400);
|
||||
}
|
||||
const [dbUser] = await db
|
||||
.select({ passwordHash: users.passwordHash })
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.limit(1);
|
||||
|
||||
if (!dbUser?.passwordHash) {
|
||||
return c.json({ error: 'No password set' }, 400);
|
||||
}
|
||||
const valid = await bcrypt.compare(currentPassword, dbUser.passwordHash);
|
||||
if (!valid) {
|
||||
return c.json({ error: 'Current password is incorrect' }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash, mustResetPassword: false })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
const token = await createToken(user.id, user.email, user.role, user.username, false);
|
||||
return c.json({ success: true, token });
|
||||
});
|
||||
|
||||
auth.post('/change-username', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
if (user.role !== 'admin') {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const { username } = await c.req.json<{ username: string }>();
|
||||
if (!username || username.length < 2) {
|
||||
return c.json({ error: 'Username must be at least 2 characters' }, 400);
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0 && existing[0].id !== user.id) {
|
||||
return c.json({ error: 'Username already taken' }, 409);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ username })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
const token = await createToken(user.id, user.email, user.role, username, user.mustResetPassword);
|
||||
return c.json({ success: true, token });
|
||||
});
|
||||
|
||||
export { auth };
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Hono } from 'hono';
|
||||
import { eq, and, isNull, or, sql, count, desc } from 'drizzle-orm';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '../db';
|
||||
import { invitations, users } from '../db/schema';
|
||||
import { authMiddleware, requireAdmin } from '../middleware/auth';
|
||||
import type { AppEnv } from '../types';
|
||||
|
||||
const invitesRouter = new Hono<AppEnv>();
|
||||
|
||||
function generateCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||||
const bytes = crypto.randomBytes(8);
|
||||
let code = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += chars[bytes[i] % chars.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
invitesRouter.post('/', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
|
||||
if (!user.email && user.role !== 'admin') {
|
||||
return c.json({ error: 'Must be registered to create invites' }, 403);
|
||||
}
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
const limit = parseInt(process.env.USER_INVITATIONS || '0', 10);
|
||||
if (limit <= 0) {
|
||||
return c.json({ error: 'You are not allowed to create invites' }, 403);
|
||||
}
|
||||
|
||||
const [{ value: created }] = await db
|
||||
.select({ value: count() })
|
||||
.from(invitations)
|
||||
.where(eq(invitations.createdBy, user.id));
|
||||
|
||||
if (created >= limit) {
|
||||
return c.json({ error: 'Invite limit reached' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const code = generateCode();
|
||||
try {
|
||||
await db.insert(invitations).values({ code, createdBy: user.id });
|
||||
return c.json({ code });
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : '';
|
||||
if (message.includes('unique') || message.includes('duplicate')) continue;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ error: 'Failed to generate unique code' }, 500);
|
||||
});
|
||||
|
||||
invitesRouter.get('/validate/:code', async (c) => {
|
||||
const code = c.req.param('code');
|
||||
|
||||
const [invite] = await db
|
||||
.select()
|
||||
.from(invitations)
|
||||
.where(
|
||||
and(
|
||||
eq(invitations.code, code),
|
||||
isNull(invitations.usedBy),
|
||||
or(
|
||||
isNull(invitations.expiresAt),
|
||||
sql`${invitations.expiresAt} > NOW()`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return c.json({ valid: !!invite });
|
||||
});
|
||||
|
||||
invitesRouter.get('/remaining', authMiddleware, async (c) => {
|
||||
const user = c.get('user');
|
||||
|
||||
if (user.role === 'admin') {
|
||||
return c.json({ remaining: -1 });
|
||||
}
|
||||
|
||||
const limit = parseInt(process.env.USER_INVITATIONS || '0', 10);
|
||||
if (limit <= 0) {
|
||||
return c.json({ remaining: 0 });
|
||||
}
|
||||
|
||||
const [{ value: created }] = await db
|
||||
.select({ value: count() })
|
||||
.from(invitations)
|
||||
.where(eq(invitations.createdBy, user.id));
|
||||
|
||||
return c.json({ remaining: Math.max(0, limit - created) });
|
||||
});
|
||||
|
||||
invitesRouter.get('/', authMiddleware, requireAdmin, async (c) => {
|
||||
const allInvites = await db
|
||||
.select({
|
||||
id: invitations.id,
|
||||
code: invitations.code,
|
||||
createdBy: invitations.createdBy,
|
||||
usedBy: invitations.usedBy,
|
||||
createdAt: invitations.createdAt,
|
||||
expiresAt: invitations.expiresAt,
|
||||
})
|
||||
.from(invitations)
|
||||
.orderBy(desc(invitations.createdAt));
|
||||
|
||||
const userIds = new Set<string>();
|
||||
for (const inv of allInvites) {
|
||||
userIds.add(inv.createdBy);
|
||||
if (inv.usedBy) userIds.add(inv.usedBy);
|
||||
}
|
||||
|
||||
const userMap = new Map<string, { username: string | null; email: string | null }>();
|
||||
if (userIds.size > 0) {
|
||||
const userList = await db
|
||||
.select({ id: users.id, username: users.username, email: users.email })
|
||||
.from(users)
|
||||
.where(or(...[...userIds].map(id => eq(users.id, id))));
|
||||
|
||||
for (const u of userList) {
|
||||
userMap.set(u.id, { username: u.username, email: u.email });
|
||||
}
|
||||
}
|
||||
|
||||
const enriched = allInvites.map(inv => ({
|
||||
id: inv.id,
|
||||
code: inv.code,
|
||||
createdBy: userMap.get(inv.createdBy) ?? { username: null, email: null },
|
||||
usedBy: inv.usedBy ? (userMap.get(inv.usedBy) ?? { username: null, email: null }) : null,
|
||||
createdAt: inv.createdAt,
|
||||
expiresAt: inv.expiresAt,
|
||||
used: !!inv.usedBy,
|
||||
}));
|
||||
|
||||
return c.json({ invitations: enriched });
|
||||
});
|
||||
|
||||
export { invitesRouter };
|
||||
@@ -4,7 +4,10 @@ export type AppEnv = {
|
||||
user: {
|
||||
id: string;
|
||||
anonToken: string;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
role: string;
|
||||
mustResetPassword: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user