Skip to content

Commit

Permalink
Implement logging out
Browse files Browse the repository at this point in the history
  • Loading branch information
MaddyGuthridge committed Aug 2, 2024
1 parent 2fec5ea commit bcc110a
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 31 deletions.
66 changes: 48 additions & 18 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { nanoid } from 'nanoid';
import { validate, number, string, type } from 'superstruct';
import { validate, number, string, type, type Infer } from 'superstruct';
import jwt, { type Algorithm as JwtAlgorithm } from 'jsonwebtoken';
import { unixTime } from '$lib/util';
import { hash } from 'crypto';
import { generate as generateWords } from 'random-words';
import { setLocalConfig, type ConfigLocalJson } from './data/localConfig';
import { getLocalConfig, setLocalConfig, type ConfigLocalJson } from './data/localConfig';
import consts from '$lib/consts';

/** Maximum lifetime of a session */
Expand Down Expand Up @@ -50,28 +50,58 @@ export function generateToken(): string {
}

/** Decode the given token, and return the session if it passes validation */
export function validateToken(token: string): string | undefined {
export async function validateToken(token: string): Promise<Infer<typeof JwtPayload>> {
// If the token starts with 'Bearer ', strip that out
if (token.startsWith('Bearer ')) {
token = token.replace('Bearer ', '');
}
// Disallow token validation if auth is disabled
const config = await getLocalConfig();
if (!config.auth) {
throw Error('Authentication is disabled');
}
// Otherwise attempt to validate the token
let payload: unknown;
try {
const payload = jwt.verify(token, getTokenSecret(), { algorithms: [algorithm] });
const [err, data] = validate(payload, JwtPayload);
if (err) {
// Token failed validation
console.log(err);
return undefined;
}
// Ensure that the session isn't in our revoked list
// And also that it wasn't issued before our notBefore time
return data.sessionId;
payload = jwt.verify(token, getTokenSecret(), { algorithms: [algorithm] });
} catch (e) {
// Print the error
console.log(e);
return undefined;
// Token failed to validate
if (e instanceof Error) {
throw Error(`Token failed to validate: ${e.message}`);
} else {
// Should always be an error
throw Error('Token failed to validate');
}
}
const [err, data] = validate(payload, JwtPayload);
if (err) {
// Token data format is incorrect
throw Error('Token data is in incorrect format');
}
// Ensure that the session isn't in our revoked list
if (data.sessionId in config.auth.sessions.revokedSessions) {
throw Error('This session has been revoked');
}

// And also that it wasn't issued before our notBefore time
if (data.iat < config.auth.sessions.notBefore) {
throw Error('This session was created too long ago');
}
return data;
}

/** Revoke the session of the given token */
export function revokeToken(token: string) {
// TODO
export async function revokeSession(token: string): Promise<void> {
const config = await getLocalConfig();
if (!config.auth) {
// Can't invalidate tokens if there is not auth
throw Error('Authentication is disabled');
}
const sessionData = await validateToken(token);
// Add to the revoked sessions
config.auth.sessions.revokedSessions[sessionData.sessionId] = sessionData.exp;
await setLocalConfig(config);
return;
}

/** Credentials provided after first run */
Expand Down
5 changes: 5 additions & 0 deletions src/routes/api/admin/auth/login/+server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generateToken, hashAndSalt } from '$lib/server/auth.js';
import { dataDirIsInit } from '$lib/server/data/dataDir';
import { getLocalConfig } from '$lib/server/data/localConfig.js';
import { error, json } from '@sveltejs/kit';

Expand All @@ -21,6 +22,10 @@ async function fail(timer: Promise<void>) {


export async function POST({ request, cookies }) {
if (!await dataDirIsInit()) {
return error(400, 'Server is not initialized');
}

const local = await getLocalConfig();

if (!local.auth) {
Expand Down
23 changes: 23 additions & 0 deletions src/routes/api/admin/auth/logout/+server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { revokeSession } from '$lib/server/auth.js';
import { dataDirIsInit } from '$lib/server/data/dataDir.js';
import { error, json } from '@sveltejs/kit';


export async function POST({ request, cookies }) {
const token = request.headers.get('Authorization');
if (!token) {
return error(401, 'Authorization token is required');
}

if (!await dataDirIsInit()) {
return error(400, 'Server is not initialized');
}

try {
await revokeSession(token)
} catch (e) {
return error(401, `${e}`);
}

return json({}, { status: 200 });
}
24 changes: 11 additions & 13 deletions tests/api/admin/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,30 @@
import { apiFetch } from '../fetch';

/**
* Set up the authentication system for the site.
* Log in as an administrator for the site
*
* @param username The username to use for the admin account
* @param password The password to use for the admin account
* @param username The username of the admin account
* @param password The password of the admin account
*/
export const setup = async (username: string, password: string) => {
export const login = async (username: string, password: string) => {
return apiFetch(
'POST',
'/api/admin/auth/setup',
'/api/admin/auth/login',
undefined,
{ username, password }
) as Promise<{ token: string }>;
};

/**
* Log in as an administrator for the site
* Log out, invalidating the token
*
* @param username The username of the admin account
* @param password The password of the admin account
* @param token The token to invalidate
*/
export const login = async (username: string, password: string) => {
export const logout = async (token: string) => {
return apiFetch(
'POST',
'/api/admin/auth/login',
undefined,
{ username, password }
'/api/admin/auth/logout',
token,
) as Promise<{ token: string }>;
};

Expand All @@ -48,8 +46,8 @@ export const change = async (token: string, oldPassword: string, newPassword: st
};

const auth = {
setup,
login,
logout,
change,
};

Expand Down
6 changes: 6 additions & 0 deletions tests/backend/admin/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ beforeEach(async () => {
credentials = await setup();
});

it("Gives an error when the server isn't setup", async () => {
await api.debug.clear();
expect(api.admin.auth.login(credentials.username, credentials.password))
.rejects.toMatchObject({ code: 400 });
})

it('Returns a token when correct credentials are provided', async () => {
expect(api.admin.auth.login(credentials.username, credentials.password))
.resolves.toStrictEqual({ token: expect.any(String) });
Expand Down
29 changes: 29 additions & 0 deletions tests/backend/admin/auth/logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/** Test cases for POST /api/admin/auth/logout */

import api from "$api";
import { expect, it } from "vitest";
import { setup } from "../../helpers";

it("Gives an error if the server isn't setup", async () => {
const { token } = await setup();
await api.debug.clear();
await expect(api.admin.auth.logout(token)).rejects.toMatchObject({ code: 400 });
});

it('Gives an error for invalid tokens', async () => {
const { token } = await setup();
await expect(api.admin.auth.logout(token + 'a')).rejects.toMatchObject({ code: 401 });
});

it('Gives an error for empty tokens', async () => {
await setup();
await expect(api.admin.auth.logout('')).rejects.toMatchObject({ code: 401 });
});

it('Invalidates tokens', async () => {
const { token } = await setup();
console.log(token);
await expect(api.admin.auth.logout(token)).resolves.toStrictEqual({});
// Now that we're logged out, logging out again should fail
await expect(api.admin.auth.logout(token)).rejects.toMatchObject({ code: 401 });
});

0 comments on commit bcc110a

Please sign in to comment.