Skip to content

Commit

Permalink
Merge pull request #20 from MaddyGuthridge/maddy-private-data-dir
Browse files Browse the repository at this point in the history
Move private data to separate volume and modify firstrun workflow
  • Loading branch information
MaddyGuthridge authored Oct 2, 2024
2 parents 474c466 + 8c6abdf commit 3c35b0e
Show file tree
Hide file tree
Showing 70 changed files with 844 additions and 518 deletions.
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
HOST=localhost # the hostname to use
PORT=5096 # the port number to use
DATA_REPO_PATH="./data" # the path to the data repository
AUTH_SECRET="CHANGE ME" # the secret key to validate tokens
HOST=localhost # the hostname to use
PORT=5096 # the port number to use
DATA_REPO_PATH="./data" # the path to the data volume
PRIVATE_DATA_PATH="./private_data" # the path to the private data volume
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

# Data directory
/data/
# Private data directory
/private_data/

# Server logs
*.log
Expand Down
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"cSpell.words": [
"Asciinema",
"firstrun",
"Minifolio",
"superstruct"
]
}
31 changes: 31 additions & 0 deletions docs/Files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# File locations in Minifolio

## Data directory

Determined using environment variable `DATA_REPO_PATH`.

Main portfolio data. Should be backed up using a `git` repo.

### `config.json`

Main site configuration.

## Private data directory

Determined using environment variable `PRIVATE_DATA_PATH`.

Contains private data, including credentials and authentication secrets.

### `config.local.json`

Contains the local configuration of the server, including credentials and token
info.

### `id_ed25519`, `id_ed25519.pub`

SSH key used by the server. These are used to perform git operations over SSH.

### `auth.secret`

Contains the authentication secret used by the server. This is used to validate
JWTs.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "minifolio",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"license": "GPL-3.0-only",
"scripts": {
Expand Down
6 changes: 3 additions & 3 deletions src/endpoints/admin/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default function auth(token: string | undefined) {
'POST',
'/api/admin/auth/logout',
token,
)) as Promise<{ token: string }>;
));
};

/**
Expand Down Expand Up @@ -66,12 +66,12 @@ export default function auth(token: string | undefined) {
* @param token The auth token
* @param password The password to the admin account
*/
const disable = async (password: string) => {
const disable = async (username: string, password: string) => {
return json(apiFetch(
'POST',
'/api/admin/auth/disable',
token,
{ password }
{ username, password }
)) as Promise<Record<string, never>>;
};

Expand Down
11 changes: 6 additions & 5 deletions src/endpoints/admin/firstrun.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/** Git repository endpoints */
import type { FirstRunCredentials } from '$lib/server/auth';
import { apiFetch, json } from '../fetch';

/**
Expand All @@ -9,13 +8,15 @@ import { apiFetch, json } from '../fetch';
* @param branch The branch to check-out
*/
export default async function (
repoUrl: string | null,
branch: string | null,
username: string,
password: string,
repoUrl?: string | undefined,
branch?: string | undefined,
) {
return json(apiFetch(
'POST',
'/api/admin/firstrun',
undefined,
{ repoUrl, branch },
)) as Promise<{ credentials: FirstRunCredentials, firstTime: boolean }>;
{ username, password, repoUrl, branch },
)) as Promise<{ token: string, firstTime: boolean }>;
}
2 changes: 1 addition & 1 deletion src/endpoints/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export async function json(response: Promise<Response>): Promise<object> {
}

if ([400, 401, 403].includes(res.status)) {
// All 400 and 403 errors have an error message
// All 400, 401 and 403 errors have an error message
const message = (json as { message: string }).message;
throw new ApiError(res.status, message);
}
Expand Down
14 changes: 14 additions & 0 deletions src/lib/server/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Minifolio Auth
*
* Code for performing authorization in Minifolio.
*
* If you discover a security vulnerability, please disclose it responsibly.
*/
export { validateCredentials } from './passwords';
export {
generateToken,
validateTokenFromRequest,
isRequestAuthorized,
redirectOnInvalidToken,
} from './tokens';
65 changes: 65 additions & 0 deletions src/lib/server/auth/passwords.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@

import { hash } from 'crypto';
import { getLocalConfig } from '../data/localConfig';
import { error } from '@sveltejs/kit';

/**
* How long to wait (in ms) before notifying the user that their login attempt
* was rejected.
*/
const FAIL_DURATION = 100;

/**
* Promise that resolves in a random amount of time, used to get some timing
* invariance.
*/
const sleepRandom = () => new Promise<void>((r) => setTimeout(r, Math.random() * FAIL_DURATION));

/**
* Throw a 401 after a random (small) amount of time, so that timing attacks
* cannot be used reliably.
*/
async function fail(timer: Promise<void>, code: number) {
await timer;
return error(code, 'The username or password is incorrect');
}

/** Hash a password with the given salt, returning the result */
export function hashAndSalt(salt: string, password: string): string {
// TODO: Thoroughly check this against the OWASP guidelines -- it might
// not match the requirements.
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
return hash('SHA256', salt + password);
}

/**
* Validate the user's credentials.
*
* If validation is successful, the user's userId is returned.
*
* If the credentials fail to validate, a random amount of time is waited
* before sending the response, in order to prevent timing attacks.
*/
export async function validateCredentials(
username: string,
password: string,
code: number = 403,
): Promise<string> {
const local = await getLocalConfig();

const failTimer = sleepRandom();

// Find a user with a matching username
const userId = Object.keys(local.auth).find(id => local.auth[id].username === username);

if (!userId) {
return fail(failTimer, code);
}

const hashResult = hashAndSalt(local.auth[userId].password.salt, password);

if (hashResult !== local.auth[userId].password.hash) {
return fail(failTimer, code);
}
return userId;
}
38 changes: 38 additions & 0 deletions src/lib/server/auth/secret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Code for managing the authentication secret.
*/
import fs from 'fs/promises';
import { getPrivateDataDir } from '../data/dataDir';
import { nanoid } from 'nanoid';

/** Returns the path to the auth secret file */
export function getAuthSecretPath(): string {
return `${getPrivateDataDir()}/auth.secret`;
}

/** Cache for token secret */
let authSecret: string | undefined;

/** Returns the secret value used to validate JWTs */
export async function getAuthSecret(): Promise<string> {
if (authSecret) {
return authSecret;
}
return fs.readFile(getAuthSecretPath(), { encoding: 'utf-8' });
}

/** Generate and store a new auth secret, returning its value */
export async function generateAuthSecret(): Promise<string> {
await fs.mkdir(getPrivateDataDir()).catch(() => { });
const secret = nanoid();
authSecret = secret;
await fs.writeFile(getAuthSecretPath(), secret, { encoding: 'utf-8' });
return secret;
}

/**
* Invalidate the in-memory copy of the auth secret.
*/
export function invalidateAuthSecret() {
authSecret = undefined;
}
58 changes: 58 additions & 0 deletions src/lib/server/auth/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Code managing the setup of the auth system of Minifolio.
*/
import { setLocalConfig, type ConfigLocalJson } from '../data/localConfig';
import { version } from '$app/environment';
import { hashAndSalt } from './passwords';
import { nanoid } from 'nanoid';
import { unixTime } from '$lib/util';
import { generateToken } from './tokens';
import type { Cookies } from '@sveltejs/kit';
import { generateAuthSecret } from './secret';

/**
* Set up auth information.
*
* This is responsible for:
*
* 1. Setting up the auth secret
* 2. Creating the first account
* 3. Storing the auth info info the local config.
*/
export async function authSetup(
username: string,
password: string,
cookies?: Cookies,
): Promise<string> {
// 1. Set up the auth secret
await generateAuthSecret();

// 2. Create the user
const userId = nanoid();

// Generate a salt for the password
// Using nanoid for secure generation
const salt = nanoid();
const passwordHash = hashAndSalt(salt, password);

// Set up auth config
const config: ConfigLocalJson = {
auth: {
[userId]: {
username,
password: {
hash: passwordHash,
salt: salt,
},
sessions: {
notBefore: unixTime(),
revokedSessions: {},
}
},
},
version,
};
await setLocalConfig(config);

return generateToken(userId, cookies);
}
Loading

0 comments on commit 3c35b0e

Please sign in to comment.