Skip to content

Commit

Permalink
Wizard for creating admin user when no users exist
Browse files Browse the repository at this point in the history
This replaces the error-prone and clumsy ADMIN_REGISTRATION_ALLOWED
system.
  • Loading branch information
PurkkaKoodari committed Jan 11, 2024
1 parent 82bc2c2 commit 11687dc
Show file tree
Hide file tree
Showing 24 changed files with 329 additions and 106 deletions.
4 changes: 0 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ ANONYMIZE_AFTER_DAYS=180
DELETION_GRACE_PERIOD_DAYS=14


# Whether or not new admin accounts can be added. REMEMBER TO DISABLE AFTER SETUP.
# This is disabled by default so that new users know of this variable.
ADMIN_REGISTRATION_ALLOWED=false

# Whether or not to trust X-Forwarded-For headers for remote IP. Set to true IF
# AND ONLY IF running behind a proxy that sets this header.
TRUST_PROXY=false
Expand Down
24 changes: 0 additions & 24 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,6 @@ B-tier App Service Plans have been tried and at least B1 doesn't seem to handle
- `DB_PASSWORD` = password of PostgreSQL user
- `DB_DATABASE` = name of PostgreSQL database
- `DB_SSL` = `true` (required with Azure's default config)
- `ADMIN_REGISTRATION_ALLOWED` = `true` for initial setup (see [_Creating the first admin user_](#creating-the-first-admin-user))
- `NEW_EDIT_TOKEN_SECRET` = secure random string (see [_Generating secrets_](#generating-secrets))
- `FEATHERS_AUTH_SECRET` = secure random string (see [_Generating secrets_](#generating-secrets))
- `MAIL_FROM` = "From" email for system messages
Expand All @@ -264,7 +263,6 @@ B-tier App Service Plans have been tried and at least B1 doesn't seem to handle
- `BRANDING_MAIL_FOOTER_TEXT` and `BRANDING_MAIL_FOOTER_LINK` (may be empty)
7. Access the app at `https://{your-app-name}.azurewebsites.net/`.
- If something is broken, check the *Log stream* page or read logs via `https://{your-app-name}.scm.azurewebsites.net/`.
8. On first run, follow the instructions in [_Creating the first admin user_](#creating-the-first-admin-user).
### Docker Compose
Expand All @@ -276,7 +274,6 @@ You can use Docker Compose to run both a database and production container local
3. **Optional:** Make [customizations](#customization) in other files if necessary.
4. Run `docker-compose -f docker-compose.prod.yml up` manually or e.g. via `systemd`.
5. Access the app at <http://localhost:8000>.
6. On first run, follow the instructions in [_Creating the first admin user_](#creating-the-first-admin-user).

### Docker (manual)

Expand All @@ -296,7 +293,6 @@ If you don't want to use Docker Compose, or already have a database, you can run
- Customized image, built in CI or uploaded by you: `ghcr.io/yourorg/ilmomasiina:latest` (example)
- Locally built image: `ilmomasiina` (what was after `-t` in `docker build`)
4. Access the app at <http://localhost:3000>.
5. On first run, follow the instructions in [_Creating the first admin user_](#creating-the-first-admin-user).
### Running without Docker
Expand All @@ -313,24 +309,6 @@ You can also set up a production deployment without Docker. **This method is not
node packages/ilmomasiina-backend/dist/bin/server.js
```
7. Access the app at <http://localhost:3000>.
8. On first run, follow the instructions in [_Creating the first admin user_](#creating-the-first-admin-user).
### Creating the first admin user
By default, only logged-in admin users can create new admin users using the `/admin` endpoint.
To create the first user on a new system, admin registration needs to be allowed.
Allow admin registration temporarily by adding the env variable `ADMIN_REGISTRATION_ALLOWED=true`.
Now, create a new user with POST request to `/api/users`. For example, using `curl`:
```
curl 'http://localhost:3000/api/users' \
-H 'Content-Type: application/json' \
--data '{ "email": "[email protected]", "password": "password123" }'
```
:warning: **Important**: After creating the first user, disallow admin user creation by
removing the env variable and restarting Ilmomasiina.
### Reverse proxy
Expand Down Expand Up @@ -417,7 +395,6 @@ Currently Prettier is not used in the project, so here is a recommended `.vscode
- Alternatively, you can use `pnpm run --filter=@tietokilta/ilmomasiina-frontend start`
(and similar for the backend).
6. Access the app at <http://localhost:3000>.
7. On first run, follow the instructions in [_Creating the first admin user_](#creating-the-first-admin-user).
### Docker Compose
Expand All @@ -429,7 +406,6 @@ pre-configured PostgreSQL server, so an external database server is not required
2. Go to the repository root and run `docker-compose up`. This builds the dev container and starts the frontend and
backend servers in parallel.
3. Access the app at <http://localhost:3000>.
4. On first run, follow the instructions in [_Creating the first admin user_](#creating-the-first-admin-user).
Due to how the dev Docker is set up, you will still need to rebuild the development image if you change the
dependencies, package.json or ESLint configs. You'll also need Node.js and pnpm installed locally to do that.
13 changes: 13 additions & 0 deletions packages/ilmomasiina-backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import removeDeletedData from './cron/removeDeletedData';
import enforceHTTPS from './enforceHTTPS';
import setupDatabase from './models';
import setupRoutes from './routes';
import { isInitialSetupDone } from './routes/admin/users/createInitialUser';

// Disable type coercion for request bodies - we don't need it, and it breaks stuff like anyOf
const bodyCompiler = new Ajv({
Expand Down Expand Up @@ -48,6 +49,9 @@ export default async function initApp(): Promise<FastifyInstance> {
httpPart === 'body' ? bodyCompiler.compile(schema) : defaultCompiler.compile(schema)
));

// Enable admin registration if no users are present
server.decorate('initialSetupDone', await isInitialSetupDone());

// Register fastify-sensible (https://github.com/fastify/fastify-sensible)
server.register(fastifySensible);

Expand Down Expand Up @@ -113,3 +117,12 @@ export default async function initApp(): Promise<FastifyInstance> {

return server;
}

declare module 'fastify' {
interface FastifyInstance {
/** If set to false, GET /api/events raises an error.
* This is "cached" in the application instance to avoid an unnecessary database query.
*/
initialSetupDone: boolean;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import bcrypt from 'bcrypt';
import { BadRequest } from 'http-errors';

export default class AdminPasswordAuth {
static validateNewPassword(password: string): void {
if (password.length < 10) {
throw new BadRequest('Password must be at least 10 characters long');
}
}

static createHash(password: string): string {
return bcrypt.hashSync(password, 10);
}
Expand Down
12 changes: 0 additions & 12 deletions packages/ilmomasiina-backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,6 @@ const config = {
/** Host for Mailgun API server. */
mailgunHost: envString('MAILGUN_HOST', 'api.eu.mailgun.net'),

/** Whether or not new admin accounts can be added. */
adminRegistrationAllowed: envBoolean('ADMIN_REGISTRATION_ALLOWED', false),

/** How long after an event's date to remove signup details. */
anonymizeAfterDays: envInteger('ANONYMIZE_AFTER_DAYS', 180),
/** How long items stay in the database after deletion, in order to allow restoring accidentally deleted items. */
Expand Down Expand Up @@ -162,13 +159,4 @@ if (!config.editSignupUrl.includes('{id}') || !config.editSignupUrl.includes('{e
throw new Error('EDIT_SIGNUP_URL must contain {id} and {editToken} if set.');
}

if (config.adminRegistrationAllowed) {
console.warn(
'----------------------------------------------------\n'
+ 'WARNING!\nAdmin registration is enabled, meaning anyone can register an administrator account.\n'
+ 'After creating your initial administrator account, make sure to set ADMIN_REGISTRATION_ALLOWED=false.\n'
+ '----------------------------------------------------',
);
}

export default config;
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { BadRequest, NotFound } from 'http-errors';
import { NotFound } from 'http-errors';

import { AuditEvent, ErrorCode, UserChangePasswordSchema } from '@tietokilta/ilmomasiina-models';
import AdminPasswordAuth from '../../../authentication/adminPasswordAuth';
Expand All @@ -16,9 +16,7 @@ export default async function changePassword(
request: FastifyRequest<{ Body: UserChangePasswordSchema }>,
reply: FastifyReply,
): Promise<void> {
if (request.body.newPassword.length < 10) {
throw new BadRequest('Password must be at least 10 characters long');
}
AdminPasswordAuth.validateNewPassword(request.body.newPassword);

await User.sequelize!.transaction(async (transaction) => {
// Try to fetch existing user
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/* eslint-disable max-classes-per-file */
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { Transaction } from 'sequelize';

import { AdminLoginResponse, ErrorCode, UserCreateSchema } from '@tietokilta/ilmomasiina-models';
import AdminAuthSession from '../../../authentication/adminAuthSession';
import AdminPasswordAuth from '../../../authentication/adminPasswordAuth';
import { User } from '../../../models/user';
import CustomError from '../../../util/customError';
import { createUser } from './inviteUser';

export class InitialSetupNeeded extends CustomError {
constructor(message: string) {
super(418, ErrorCode.INITIAL_SETUP_NEEDED, message);
}
}

export class InitialSetupAlreadyDone extends CustomError {
constructor(message: string) {
super(409, ErrorCode.INITIAL_SETUP_ALREADY_DONE, message);
}
}

export async function isInitialSetupDone(transaction?: Transaction) {
return await User.count({ transaction }) > 0;
}

/**
* Creates a new (admin) user and logs them in
*
* Supposed to be used only for initial user creation.
* For additional users, use {@link inviteUser} instead.
*/
export default function createInitialUser(session: AdminAuthSession) {
return async function handler(
this: FastifyInstance<any, any, any, any, any>,
request: FastifyRequest<{ Body: UserCreateSchema }>,
reply: FastifyReply,
): Promise<AdminLoginResponse> {
AdminPasswordAuth.validateNewPassword(request.body.password);

const user = await User.sequelize!.transaction(async (transaction) => {
if (await isInitialSetupDone(transaction)) {
throw new InitialSetupAlreadyDone('The initial admin user has already been created.');
}
return createUser(request.body, request.logEvent, transaction);
});

const accessToken = session.createSession({ user: user.id, email: user.email });

// Stop raising errors on requests to event list
this.initialSetupDone = true;

reply.status(201);
return { accessToken };
};
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { Conflict } from 'http-errors';
import { Transaction } from 'sequelize';

import type { UserCreateSchema, UserInviteSchema, UserSchema } from '@tietokilta/ilmomasiina-models';
import { AuditEvent } from '@tietokilta/ilmomasiina-models';
Expand All @@ -15,70 +16,58 @@ import generatePassword from './generatePassword';
* @param params user parameters
* @param auditLogger audit logger function from the originating request
*/
async function create(params: UserCreateSchema, auditLogger: AuditLogger): Promise<UserSchema> {
return User.sequelize!.transaction(async (transaction) => {
const existing = await User.findOne({
where: { email: params.email },
transaction,
});

if (existing) throw new Conflict('User with given email already exists');
export async function createUser(
params: UserCreateSchema,
auditLogger: AuditLogger,
transaction: Transaction,
): Promise<UserSchema> {
const existing = await User.findOne({
where: { email: params.email },
transaction,
});

// Create new user with hashed password
const user = await User.create(
{
...params,
password: AdminPasswordAuth.createHash(params.password),
},
{ transaction },
);
if (existing) throw new Conflict('User with given email already exists');

const res = {
id: user.id,
email: user.email,
};
// Create new user with hashed password
const user = await User.create(
{
...params,
password: AdminPasswordAuth.createHash(params.password),
},
{ transaction },
);

await auditLogger(AuditEvent.CREATE_USER, {
extra: res,
transaction,
});
const res = {
id: user.id,
email: user.email,
};

return res;
await auditLogger(AuditEvent.CREATE_USER, {
extra: res,
transaction,
});
}

/**
* Creates a new (admin) user
*
* Supposed to be used only for initial user creation.
* For additional users, use {@link inviteUser} instead.
*/
export async function createUser(
request: FastifyRequest<{ Body: UserCreateSchema }>,
reply: FastifyReply,
): Promise<UserSchema> {
const user = await create(request.body, request.logEvent);
reply.status(201);
return user;
return res;
}

/**
* Creates a new user and sends an invitation mail to their email
*/
export async function inviteUser(
export default async function inviteUser(
request: FastifyRequest<{ Body: UserInviteSchema }>,
reply: FastifyReply,
): Promise<UserSchema> {
// Generate secure password
const password = generatePassword();

const user = await create(
const user = await User.sequelize!.transaction(async (transaction) => createUser(
{
email: request.body.email,
password,
},
request.logEvent,
);
transaction,
));

// Send invitation mail
await EmailService.sendNewUserMail(user.email, null, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function adminLogin(session: AdminAuthSession) {
return async (
request: FastifyRequest<{ Body: AdminLoginBody }>,
reply: FastifyReply,
): Promise<AdminLoginResponse | void> => {
): Promise<AdminLoginResponse> => {
// Verify user
const user = await User.findOne({
where: { email: request.body.email },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { col, fn, Order } from 'sequelize';

import type { AdminEventListResponse, EventListQuery, UserEventListResponse } from '@tietokilta/ilmomasiina-models';
Expand All @@ -10,6 +10,7 @@ import { Event } from '../../models/event';
import { Quota } from '../../models/quota';
import { Signup } from '../../models/signup';
import { ascNullsFirst } from '../../models/util';
import { InitialSetupNeeded } from '../admin/users/createInitialUser';
import { stringifyDates } from '../utils';

function eventOrder(): Order {
Expand All @@ -23,9 +24,15 @@ function eventOrder(): Order {
}

export async function getEventsListForUser(
this: FastifyInstance<any, any, any, any, any>,
request: FastifyRequest<{ Querystring: EventListQuery }>,
reply: FastifyReply,
): Promise<UserEventListResponse> {
// When the application hasn't been set up for the first time, throw an error.
if (!this.initialSetupDone) {
throw new InitialSetupNeeded('Initial setup of Ilmomasiina is needed.');
}

const eventAttrs = eventListEventAttrs;
const filter = { ...request.query };

Expand Down
Loading

0 comments on commit 11687dc

Please sign in to comment.