Skip to content

Commit 27d7916

Browse files
committed
Implement login, user info and logout API and add to user test.
1 parent d7d48be commit 27d7916

5 files changed

Lines changed: 198 additions & 13 deletions

File tree

src/server/db.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,29 @@ import type { PGInterface } from './types.ts';
33

44
export interface User {
55
id: number;
6-
}
6+
email: string | null;
7+
username: string;
8+
hashed_password: string | null;
9+
alias: string;
10+
verification_email: string | null;
11+
created_ip_address: string;
12+
created_at: Date;
13+
active_at: Date;
14+
};
715
export interface VerificationEmail {
816
email: string;
917
verification_code: string;
1018
created_at: Date;
11-
}
19+
};
1220
export interface Session {
13-
session_id: string;
21+
id: number;
22+
session_key?: string;
1423
url?: string;
1524
user_id: number;
1625
created_at: Date;
1726
active_ip_address: string;
1827
updated_at: Date;
19-
}
28+
};
2029

2130
export async function cleanupDatabase(client: PGInterface) {
2231
const sqlTables = [
@@ -65,9 +74,9 @@ export async function initializeDatabase(client: PGInterface) {
6574
`CREATE TABLE users (
6675
id SERIAL PRIMARY KEY,
6776
email VARCHAR(255) NULL,
68-
username VARCHAR(100) UNIQUE NOT NULL DEFAULT CONCAT('user-', currval('users_id_seq')::TEXT),
77+
username VARCHAR(50) UNIQUE NOT NULL DEFAULT CONCAT('user-', currval('users_id_seq')::TEXT),
6978
hashed_password TEXT NULL,
70-
alias VARCHAR(100) NOT NULL,
79+
alias VARCHAR(50) NOT NULL,
7180
verification_email VARCHAR(255) NULL,
7281
created_ip_address INET NOT NULL,
7382
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
@@ -84,14 +93,16 @@ export async function initializeDatabase(client: PGInterface) {
8493

8594
// Sessions tracks associated users for each session cookie.
8695
`CREATE TABLE sessions (
87-
session_id TEXT PRIMARY KEY,
96+
id SERIAL PRIMARY KEY,
97+
session_key TEXT UNIQUE NOT NULL,
8898
url TEXT NULL,
8999
user_id INTEGER,
90100
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
91101
active_ip_address INET NOT NULL,
92102
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
93103
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
94104
);`,
105+
`CREATE UNIQUE INDEX unique_session_key ON sessions (session_key);`,
95106
`CREATE INDEX idx_sessions_updated ON sessions (updated_at);`,
96107
`CREATE INDEX idx_sessions_user_id ON sessions (user_id);`,
97108

src/server/server.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,28 @@ export class Server {
199199
res.writeHead(result.resultCode, { ...headers, 'Content-Type': 'application/json' });
200200
res.end(JSON.stringify({ message: result.message }));
201201
res.end();
202+
} if (req.url == '/api/login' && req.method == 'POST') {
203+
const form = formidable({});
204+
let data : {username: string; password: string;} = {
205+
username: '',
206+
password: '',
207+
}
208+
try {
209+
const fields = (await form.parse(req))[0];
210+
data = {username: fields.username[0], password: fields.password[0]};
211+
} catch (err) {
212+
console.error(err);
213+
res.writeHead(err.httpCode || 400, { ...headers, 'Content-Type': 'text/plain' });
214+
res.end(String(err));
215+
return;
216+
}
217+
const result = await this.#authHandler.loginUser(requestIp(req), data);
218+
if (result.sessionId) {
219+
headers['Set-Cookie'] = `sessionid=${result.sessionId}; HttpOnly; Secure; Path=/; SameSite=Strict`;
220+
}
221+
res.writeHead(result.resultCode, { ...headers, 'Content-Type': 'application/json' });
222+
res.end(JSON.stringify({ message: result.message }));
223+
res.end();
202224
} else if (req.url.startsWith('/api/')) {
203225
// These remaining API endpoints require that the user is logged in.
204226
const rawCookie = req.headers.cookie || "";
@@ -238,6 +260,34 @@ export class Server {
238260
result: success ? 'ok' : 'failed'
239261
}));
240262
return;
263+
} else if (req.url == '/api/userinfo') {
264+
const userInfo = await this.#authHandler.getUserInfo(session);
265+
res.writeHead(200, { ...headers,
266+
'Content-Type': 'application/json',
267+
});
268+
res.end(JSON.stringify(userInfo));
269+
return;
270+
} else if (req.url == '/api/logout' && req.method == 'POST') {
271+
// Check if a session id was provided to log out instead of the current session.
272+
const form = formidable({});
273+
let logoutSessionId: number = session.id;
274+
try {
275+
const fields = (await form.parse(req))[0];
276+
if (fields.sessionid) {
277+
logoutSessionId = parseInt(fields.sessionid[0]);
278+
}
279+
} catch (err) {
280+
console.error(err);
281+
res.writeHead(err.httpCode || 400, { ...headers, 'Content-Type': 'text/plain' });
282+
res.end(String(err));
283+
return;
284+
}
285+
await this.#authHandler.logoutSession(session, logoutSessionId);
286+
res.writeHead(200, { ...headers,
287+
'Content-Type': 'application/json',
288+
});
289+
res.end(JSON.stringify({ result: 'ok' }));
290+
return;
241291
}
242292
res.writeHead(404, headers);
243293
res.end();

src/server/user.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export type RegistrationData = {
1111
alias: string;
1212
email: string | null;
1313
};
14+
export type LoginData = {
15+
username: string;
16+
password: string;
17+
}
1418
export type RegistrationResult = {
1519
resultCode: number;
1620
message: string;
@@ -29,6 +33,11 @@ export type AuthenticationHandlerConfig = {
2933
limits: LimitsConfig;
3034
}
3135

36+
export type UserInfo = {
37+
user: User;
38+
sessions: Session[];
39+
}
40+
3241
const SALT_ROUNDS = 12;
3342

3443
function generateSessionId() {
@@ -81,7 +90,7 @@ export class AuthenticationHandler {
8190
const userId = user.rows[0].id;
8291
const sessionId = generateSessionId();
8392
let session = await this.#config.db.query<Session>(
84-
`INSERT INTO sessions (session_id, user_id, created_at, active_ip_address, updated_at)
93+
`INSERT INTO sessions (session_key, user_id, created_at, active_ip_address, updated_at)
8594
VALUES ($1, $2, now(), $3, now());`,
8695
[
8796
sessionId,
@@ -169,15 +178,14 @@ Enjoy!`;
169178
return user.affectedRows > 0;
170179
}
171180

172-
173181
async getSession(address: string, sessionId?: string): Promise<Session | null> {
174182
if (!sessionId) {
175183
return null;
176184
}
177185
const session = await this.#config.db.query<Session>(
178186
`UPDATE sessions
179187
SET updated_at = now(), active_ip_address = $2
180-
WHERE session_id = $1 AND updated_at >= now() - INTERVAL '7 days'
188+
WHERE session_key = $1 AND updated_at >= now() - INTERVAL '7 days'
181189
RETURNING *;`,
182190
[sessionId, address]
183191
);
@@ -186,4 +194,74 @@ Enjoy!`;
186194
}
187195
return session.rows[0];
188196
}
197+
198+
async loginUser(address: string, data: LoginData): Promise<RegistrationResult> {
199+
const { username, password } = data;
200+
const userResult = await this.#config.db.query<User>(
201+
`SELECT * FROM users WHERE username = $1;`,
202+
[username]
203+
);
204+
if (userResult.rows.length == 0 || !userResult.rows[0].hashed_password ||
205+
!await bcrypt.compare(password, userResult.rows[0].hashed_password)) {
206+
return {
207+
resultCode: 401,
208+
message: 'Invalid username or password',
209+
};
210+
}
211+
const user = userResult.rows[0];
212+
const sessionId = generateSessionId();
213+
let session = await this.#config.db.query<Session>(
214+
`INSERT INTO sessions (session_key, user_id, created_at, active_ip_address, updated_at)
215+
VALUES ($1, $2, now(), $3, now());`,
216+
[
217+
sessionId,
218+
user.id,
219+
address,
220+
]
221+
);
222+
if (session.affectedRows == 0) {
223+
return {
224+
resultCode: 500,
225+
message: 'Failed to initialize session',
226+
};
227+
}
228+
return {
229+
resultCode: 200,
230+
message: 'Login successful.',
231+
sessionId: sessionId
232+
};
233+
}
234+
235+
async logoutSession(session: Session, id: number): Promise<void> {
236+
await this.#config.db.query(
237+
`DELETE FROM sessions WHERE id = $1 AND user_id = $2;`,
238+
[id, session.user_id]
239+
);
240+
}
241+
242+
async getUserInfo(session: Session): Promise<UserInfo> {
243+
const userData = await this.#config.db.query<User>(
244+
`SELECT id, email, username, alias, verification_email, created_ip_address, created_at, active_at
245+
FROM users WHERE id = $1;`,
246+
[session.user_id]
247+
);
248+
if (userData.rows.length == 0) {
249+
throw new Error('User not found');
250+
}
251+
252+
// Don't include session_key for security reasons.
253+
const sessions = await this.#config.db.query<Session>(
254+
`SELECT id, url, user_id, created_at, active_ip_address, updated_at FROM sessions
255+
WHERE user_id = $1
256+
ORDER BY updated_at DESC;`,
257+
[session.user_id]
258+
);
259+
260+
return {
261+
user: userData.rows[0],
262+
sessions: sessions.rows,
263+
};
264+
}
265+
266+
189267
};

test/mock/environment.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@ export class MockClient {
195195
#listeners: Map<number, MockServer> = new Map();
196196
#listeningWebRTCConnections: Map<string, RTCPeerConnectionInterface> = new Map();
197197
#cookies: {[key: string]: {[key: string]: string}} = {};
198+
199+
get ip(): string {
200+
return this.#options.ip;
201+
}
202+
198203
constructor(environment: MockEnvironment, options: Partial<ClientOptions>) {
199204
this.#environment = environment;
200205
this.#options = { ...this.#options, ...options };

test/server/user.test.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { describe, expect, test } from '@jest/globals';
22

33
import { MockEnvironment, MockClient } from '../mock/environment.ts';
44
import { clock, lobbyDb, createLobbyServer } from '../mock/lobby.ts';
5-
import type { RegistrationData } from '../../src/server/user.ts';
5+
import type { RegistrationData, UserInfo } from '../../src/server/user.ts';
66

7-
describe('lobby server', () => {
7+
describe('user management', () => {
88
const tryCreate = async (client: MockClient, address: string, fields: Partial<RegistrationData>) => {
99
const data: RegistrationData = {
1010
username: 'user',
@@ -26,7 +26,7 @@ describe('lobby server', () => {
2626
return response;
2727
}
2828

29-
test('Registers a new user and verifies email', async () => {
29+
test('Registers a new user, verifies email and tests login', async () => {
3030
await lobbyDb.initialized;
3131
const world = new MockEnvironment(clock);
3232
const { server, transport } = createLobbyServer(world);
@@ -55,6 +55,47 @@ describe('lobby server', () => {
5555
body: formData,
5656
});
5757
expect(response.status).toBe(200);
58+
59+
// Verify session
60+
response = await client.fetch(`${address}/api/userinfo`);
61+
expect(response.status).toBe(200);
62+
let userInfo = await response.json() as UserInfo;
63+
expect(userInfo.user.username).toBe('verify-test');
64+
expect(userInfo.user.email).toBe('test@test.com');
65+
expect(userInfo.sessions[0].active_ip_address).toBe(client.ip);
66+
67+
const client2 = world.createClient();
68+
formData = new FormData();
69+
formData.set('username', 'verify-test');
70+
formData.set('password', 'supersecret');
71+
response = await client2.fetch(`${address}/api/login`, {
72+
method: 'POST',
73+
body: formData,
74+
});
75+
expect(response.status).toBe(200);
76+
77+
response = await client2.fetch(`${address}/api/userinfo`);
78+
expect(response.status).toBe(200);
79+
userInfo = await response.json() as UserInfo;
80+
expect(userInfo.sessions.length).toBe(2);
81+
expect(userInfo.sessions[1].active_ip_address).toBe(client2.ip);
82+
83+
// Logout client2
84+
response = await client2.fetch(`${address}/api/logout`, {
85+
method: 'POST',
86+
});
87+
expect(response.status).toBe(200);
88+
89+
// Expect userinfo to fail now
90+
response = await client2.fetch(`${address}/api/userinfo`);
91+
expect(response.status).toBe(401);
92+
93+
// Ensure session was removed.
94+
response = await client.fetch(`${address}/api/userinfo`);
95+
expect(response.status).toBe(200);
96+
userInfo = await response.json() as UserInfo;
97+
expect(userInfo.sessions.length).toBe(1);
98+
5899
await server.close();
59100
});
60101

0 commit comments

Comments
 (0)