Skip to content

Commit

Permalink
Merge pull request #123 from Tietokilta/feat/initial-admin-creation
Browse files Browse the repository at this point in the history
Initial admin creation wizard and some user management rework
  • Loading branch information
PurkkaKoodari committed Jan 18, 2024
2 parents eb2471a + 11687dc commit 7d68bbf
Show file tree
Hide file tree
Showing 36 changed files with 473 additions and 221 deletions.
5 changes: 1 addition & 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 Expand Up @@ -125,6 +121,7 @@ BRANDING_FOOTER_GDPR_TEXT=Tietosuoja
BRANDING_FOOTER_GDPR_LINK=http://example.com/privacy
BRANDING_FOOTER_HOME_TEXT=Example.com
BRANDING_FOOTER_HOME_LINK=http://example.com
BRANDING_LOGIN_PLACEHOLDER_EMAIL=[email protected]

# Email strings
BRANDING_MAIL_FOOTER_TEXT=Rakkaudella, Tietskarijengi & Athene
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ env:
branding_footer_gdpr_link: 'https://tietokilta.fi/kilta/hallinto/viralliset-asiat/rekisteriselosteet/'
branding_footer_home_text: 'Tietokilta.fi'
branding_footer_home_link: 'https://tietokilta.fi'
branding_login_placeholder_email: '[email protected]'

on:
push:
Expand Down Expand Up @@ -72,6 +73,7 @@ jobs:
BRANDING_FOOTER_GDPR_LINK=${{ env.branding_footer_gdpr_link }}
BRANDING_FOOTER_HOME_TEXT=${{ env.branding_footer_home_text }}
BRANDING_FOOTER_HOME_LINK=${{ env.branding_footer_home_link }}
BRANDING_LOGIN_PLACEHOLDER_EMAIL=${{ env.branding_login_placeholder_email }}
# This is disabled on forks since you'll most likely need to modify it anyway for your usage
deploy:
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ ARG BRANDING_FOOTER_GDPR_TEXT
ARG BRANDING_FOOTER_GDPR_LINK
ARG BRANDING_FOOTER_HOME_TEXT
ARG BRANDING_FOOTER_HOME_LINK
ARG BRANDING_LOGIN_PLACEHOLDER_EMAIL

# Copy source files
COPY .eslint* package.json pnpm-*.yaml /opt/ilmomasiina/
Expand Down
1 change: 1 addition & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ services:
- BRANDING_FOOTER_GDPR_LINK=https://tietokilta.fi/kilta/hallinto/viralliset-asiat/rekisteriselosteet/
- BRANDING_FOOTER_HOME_TEXT=Tietokilta.fi
- BRANDING_FOOTER_HOME_LINK=https://tietokilta.fi
- [email protected]
restart: always
depends_on:
- database
Expand Down
25 changes: 1 addition & 24 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ docker build \
--build-arg BRANDING_FOOTER_GDPR_LINK='https://example.com' \
--build-arg BRANDING_FOOTER_HOME_TEXT='Kotisivu' \
--build-arg BRANDING_FOOTER_HOME_LINK='https://example.com' \
--build-arg BRANDING_LOGIN_PLACEHOLDER_EMAIL='[email protected]' \
-t ilmomasiina .
```

Expand Down Expand Up @@ -254,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 @@ -263,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 @@ -275,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 @@ -295,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 @@ -312,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 @@ -416,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 @@ -428,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 @@ -118,9 +118,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 @@ -171,13 +168,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
Expand Up @@ -2,8 +2,15 @@ import { FastifyReply, FastifyRequest } from 'fastify';
import { NotFound } from 'http-errors';

import type { UserPathParams } from '@tietokilta/ilmomasiina-models';
import { AuditEvent } from '@tietokilta/ilmomasiina-models';
import { AuditEvent, ErrorCode } from '@tietokilta/ilmomasiina-models';
import { User } from '../../../models/user';
import CustomError from '../../../util/customError';

class CannotDeleteSelf extends CustomError {
constructor(message: string) {
super(403, ErrorCode.CANNOT_DELETE_SELF, message);
}
}

export default async function deleteUser(
request: FastifyRequest<{ Params: UserPathParams }>,
Expand All @@ -18,6 +25,8 @@ export default async function deleteUser(

if (!existing) {
throw new NotFound('User does not exist');
} else if (request.sessionData.user === existing.id) {
throw new CannotDeleteSelf('You can\'t delete your own user');
} else {
// Delete user
await existing.destroy({ transaction });
Expand Down
Loading

0 comments on commit 7d68bbf

Please sign in to comment.