Approval mode: revoke unapproved users auth proofs

Also add tests for auth approval mode
This commit is contained in:
Alice Gaudon 2021-04-22 15:38:24 +02:00
parent 85e23b7f42
commit cfc632ba1a
11 changed files with 296 additions and 102 deletions

View File

@ -8,7 +8,11 @@
}, },
session: { session: {
cookie: { cookie: {
maxAge: 1000, // 1s // 1s
maxAge: 1000,
}, },
}, },
auth: {
approval_mode: true,
},
} }

View File

@ -29,9 +29,10 @@ import Controller from "./Controller";
import AccountController from "./auth/AccountController"; import AccountController from "./auth/AccountController";
import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagicLinksSessionNotUniqueMigration"; import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagicLinksSessionNotUniqueMigration";
import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration"; import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration";
import packageJson = require('./package.json');
import PreviousUrlComponent from "./components/PreviousUrlComponent"; import PreviousUrlComponent from "./components/PreviousUrlComponent";
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration"; import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration";
import BackendController from "./helpers/BackendController";
import packageJson = require('./package.json');
export const MIGRATIONS = [ export const MIGRATIONS = [
CreateMigrationsTable, CreateMigrationsTable,
@ -105,6 +106,7 @@ export default class TestApp extends Application {
this.use(new MailController()); this.use(new MailController());
this.use(new AuthController()); this.use(new AuthController());
this.use(new AccountController()); this.use(new AccountController());
this.use(new BackendController());
this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener))); this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener)));

View File

@ -119,6 +119,11 @@ export default class AuthGuard {
let user = await proof.getResource(); let user = await proof.getResource();
// Revoke proof early if user is not approved
if (user && !user.isApproved() || !user && User.isApprovalMode()) {
await proof.revoke();
}
// Register if user doesn't exist // Register if user doesn't exist
if (!user) { if (!user) {
const callbacks: RegisterCallback[] = []; const callbacks: RegisterCallback[] = [];
@ -139,7 +144,7 @@ export default class AuthGuard {
await callback(); await callback();
} }
if (!user.isApproved()) { if (User.isApprovalMode()) {
await new Mail(this.app.as(NunjucksComponent).getEnvironment(), PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, { await new Mail(this.app.as(NunjucksComponent).getEnvironment(), PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
username: user.asOptional(UserNameComponent)?.getName() || username: user.asOptional(UserNameComponent)?.getName() ||
(await user.mainEmail.get())?.getOrFail('email') || (await user.mainEmail.get())?.getOrFail('email') ||

View File

@ -87,18 +87,8 @@ export default class MagicLinkController<A extends Application> extends Controll
}); });
} catch (e) { } catch (e) {
if (e instanceof PendingApprovalAuthError) { if (e instanceof PendingApprovalAuthError) {
res.format({
json: () => {
res.json({
'status': 'warning',
'message': `Your account is pending review. You'll receive an email once you're approved.`,
});
},
html: () => {
req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`); req.flash('warning', `Your account is pending review. You'll receive an email once you're approved.`);
res.redirect('/'); res.redirect(Controller.route('auth'));
},
});
return null; return null;
} else { } else {
throw e; throw e;

View File

@ -8,6 +8,9 @@ import UserApprovedComponent from "./UserApprovedComponent";
import UserNameComponent from "./UserNameComponent"; import UserNameComponent from "./UserNameComponent";
export default class User extends Model { export default class User extends Model {
/**
* If true, new users are unapproved by default.
*/
public static isApprovalMode(): boolean { public static isApprovalMode(): boolean {
return config.get<boolean>('auth.approval_mode') && return config.get<boolean>('auth.approval_mode') &&
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTableMigration); MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTableMigration);

View File

@ -26,6 +26,7 @@ export default class PasswordAuthProof implements AuthProof<User> {
private forRegistration: boolean = false; private forRegistration: boolean = false;
private userId: number | null; private userId: number | null;
private userPassword: UserPasswordComponent | null = null; private userPassword: UserPasswordComponent | null = null;
private revoked: boolean = false;
private constructor(session: Session & Partial<SessionData>) { private constructor(session: Session & Partial<SessionData>) {
this.session = session; this.session = session;
@ -54,7 +55,14 @@ export default class PasswordAuthProof implements AuthProof<User> {
} }
public async revoke(): Promise<void> { public async revoke(): Promise<void> {
this.revoked = true;
this.session.authPasswordProof = undefined; this.session.authPasswordProof = undefined;
await new Promise<void>((resolve, reject) => {
this.session.save(err => {
if (err) reject(err);
else resolve();
});
});
} }
private async getUserPassword(): Promise<UserPasswordComponent | null> { private async getUserPassword(): Promise<UserPasswordComponent | null> {
@ -74,6 +82,7 @@ export default class PasswordAuthProof implements AuthProof<User> {
} }
private save() { private save() {
if (!this.revoked) {
this.session.authPasswordProof = { this.session.authPasswordProof = {
authorized: this.authorized, authorized: this.authorized,
forRegistration: this.forRegistration, forRegistration: this.forRegistration,
@ -81,6 +90,7 @@ export default class PasswordAuthProof implements AuthProof<User> {
}; };
} }
} }
}
export type PasswordAuthProofSessionData = { export type PasswordAuthProofSessionData = {
authorized: boolean, authorized: boolean,

View File

@ -1,46 +1,23 @@
import TestApp from "../src/TestApp";
import useApp from "./_app"; import useApp from "./_app";
import Controller from "../src/Controller";
import supertest from "supertest"; import supertest from "supertest";
import CsrfProtectionComponent from "../src/components/CsrfProtectionComponent";
import User from "../src/auth/models/User"; import User from "../src/auth/models/User";
import UserNameComponent from "../src/auth/models/UserNameComponent"; import UserNameComponent from "../src/auth/models/UserNameComponent";
import UserPasswordComponent from "../src/auth/password/UserPasswordComponent"; import UserPasswordComponent from "../src/auth/password/UserPasswordComponent";
import {popEmail} from "./_mail_server"; import {popEmail} from "./_mail_server";
import AuthComponent from "../src/auth/AuthComponent"; import {authAppProvider, followMagicLinkFromMail, testLogout} from "./_authentication_common";
import {followMagicLinkFromMail, testLogout} from "./_authentication_common";
import UserEmail from "../src/auth/models/UserEmail"; import UserEmail from "../src/auth/models/UserEmail";
import * as querystring from "querystring"; import * as querystring from "querystring";
let app: TestApp; const app = useApp(authAppProvider());
useApp(async (addr, port) => {
return app = new class extends TestApp {
protected async init(): Promise<void> {
this.use(new class extends Controller {
public routes(): void {
this.get('/', (req, res) => {
res.render('home');
}, 'home');
this.get('/csrf', (req, res) => {
res.send(CsrfProtectionComponent.getCsrfToken(req.getSession()));
}, 'csrf');
this.get('/is-auth', async (req, res) => {
const proofs = await this.getApp().as(AuthComponent).getAuthGuard().getProofs(req);
if (proofs.length > 0) res.sendStatus(200);
else res.sendStatus(401);
}, 'is-auth');
}
}());
await super.init();
}
}(addr, port, true);
});
let agent: supertest.SuperTest<supertest.Test>; let agent: supertest.SuperTest<supertest.Test>;
beforeAll(() => { beforeAll(() => {
agent = supertest(app.getExpressApp()); agent = supertest(app().getExpressApp());
});
test('Approval Mode', () => {
expect(User.isApprovalMode()).toStrictEqual(false);
}); });
describe('Register with username and password (password)', () => { describe('Register with username and password (password)', () => {
@ -74,6 +51,11 @@ describe('Register with username and password (password)', () => {
expect(user).toBeDefined(); expect(user).toBeDefined();
expect(user?.as(UserNameComponent).getName()).toStrictEqual('entrapta'); expect(user?.as(UserNameComponent).getName()).toStrictEqual('entrapta');
await expect(user?.as(UserPasswordComponent).verifyPassword('darla_is_cute')).resolves.toStrictEqual(true); await expect(user?.as(UserPasswordComponent).verifyPassword('darla_is_cute')).resolves.toStrictEqual(true);
// Proof must not be revoked
await agent.get('/has-any-password-auth-proof')
.set('Cookie', cookies)
.expect(200);
}); });
test('Can\'t register when logged in', async () => { test('Can\'t register when logged in', async () => {
@ -172,6 +154,11 @@ describe('Register with email (magic_link)', () => {
await followMagicLinkFromMail(agent, cookies); await followMagicLinkFromMail(agent, cookies);
// Proof must not be revoked
await agent.get('/has-any-magic-link')
.set('Cookie', cookies)
.expect(200);
await testLogout(agent, cookies, csrf); await testLogout(agent, cookies, csrf);
// Verify saved user // Verify saved user

View File

@ -0,0 +1,160 @@
import useApp from "./_app";
import supertest from "supertest";
import {authAppProvider, followMagicLinkFromMail} from "./_authentication_common";
import User from "../src/auth/models/User";
import querystring from "querystring";
import UserApprovedComponent from "../src/auth/models/UserApprovedComponent";
import {popEmail} from "./_mail_server";
const app = useApp(authAppProvider(true, true));
let agent: supertest.SuperTest<supertest.Test>;
beforeAll(() => {
agent = supertest(app().getExpressApp());
});
test('Approval Mode', () => {
expect(User.isApprovalMode()).toStrictEqual(true);
});
describe('Register with username and password (password)', () => {
let cookies: string[];
let csrf: string;
test('General case', async () => {
const res = await agent.get('/csrf').expect(200);
cookies = res.get('Set-Cookie');
csrf = res.text;
// Register user
await agent.post('/auth/register')
.set('Cookie', cookies)
.send({
csrf: csrf,
auth_method: 'password',
identifier: 'entrapta2',
password: 'darla_is_cute',
password_confirmation: 'darla_is_cute',
terms: 'on',
})
.expect(302)
.expect('Location', '/auth/');
// Verify saved user
const user = await User.select()
.where('name', 'entrapta2')
.first();
expect(user).toBeDefined();
expect(user?.isApproved()).toBeFalsy();
expect(user?.as(UserApprovedComponent).approved).toBeFalsy();
// Proof must be revoked
await agent.get('/has-any-password-auth-proof')
.set('Cookie', cookies)
.expect(404);
await popEmail();
});
});
describe('Register with email (magic_link)', () => {
test('General case', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const csrf = res.text;
await agent.post('/auth/register?' + querystring.stringify({redirect_uri: '/redirect-uri'}))
.set('Cookie', cookies)
.send({
csrf: csrf,
auth_method: 'magic_link',
identifier: 'glimmer2@example.org',
name: 'glimmer2',
})
.expect(302)
.expect('Location', '/magic/lobby?redirect_uri=%2Fredirect-uri');
await followMagicLinkFromMail(agent, cookies, '/auth/');
// Verify saved user
const user = await User.select()
.with('mainEmail')
.where('name', 'glimmer2')
.first();
expect(user).toBeDefined();
const email = user?.mainEmail.getOrFail();
expect(email).toBeDefined();
expect(user?.isApproved()).toBeFalsy();
expect(user?.as(UserApprovedComponent).approved).toBeFalsy();
// Proof must be revoked
await agent.get('/has-any-magic-link')
.set('Cookie', cookies)
.expect(404);
await popEmail();
});
});
describe('Authenticate with username and password (password)', () => {
test('Force auth_method', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const csrf = res.text;
// Not authenticated
await agent.get('/is-auth').set('Cookie', cookies).expect(401);
// Authenticate
await agent.post('/auth/login?' + querystring.stringify({redirect_uri: '/redirect-uri'}))
.set('Cookie', cookies)
.send({
csrf: csrf,
identifier: 'entrapta2',
password: 'darla_is_cute',
auth_method: 'password',
})
.expect(302)
.expect('Location', '/auth/');
// Proof must be revoked
await agent.get('/has-any-password-auth-proof')
.set('Cookie', cookies)
.expect(404);
});
});
describe('Authenticate with email (magic_link)', () => {
test('Force auth_method', async () => {
const res = await agent.get('/csrf').expect(200);
const cookies = res.get('Set-Cookie');
const csrf = res.text;
// Not authenticated
await agent.get('/is-auth').set('Cookie', cookies).expect(401);
// Authenticate
await agent.post('/auth/login?' + querystring.stringify({redirect_uri: '/redirect-uri'}))
.set('Cookie', cookies)
.send({
csrf: csrf,
identifier: 'glimmer2@example.org',
auth_method: 'magic_link',
})
.expect(302)
.expect('Location', '/magic/lobby?redirect_uri=%2Fredirect-uri');
await followMagicLinkFromMail(agent, cookies, '/auth/');
// Proof must be revoked
await agent.get('/has-any-magic-link')
.set('Cookie', cookies)
.expect(404);
});
});

View File

@ -1,49 +1,21 @@
import TestApp from "../src/TestApp";
import useApp from "./_app"; import useApp from "./_app";
import Controller from "../src/Controller";
import supertest from "supertest"; import supertest from "supertest";
import CsrfProtectionComponent from "../src/components/CsrfProtectionComponent";
import UserPasswordComponent from "../src/auth/password/UserPasswordComponent"; import UserPasswordComponent from "../src/auth/password/UserPasswordComponent";
import {popEmail} from "./_mail_server"; import {popEmail} from "./_mail_server";
import AuthComponent from "../src/auth/AuthComponent"; import {authAppProvider, followMagicLinkFromMail, testLogout} from "./_authentication_common";
import Migration, {MigrationType} from "../src/db/Migration";
import AddNameToUsersMigration from "../src/auth/migrations/AddNameToUsersMigration";
import {followMagicLinkFromMail, testLogout} from "./_authentication_common";
import UserEmail from "../src/auth/models/UserEmail"; import UserEmail from "../src/auth/models/UserEmail";
import User from "../src/auth/models/User";
let app: TestApp; const app = useApp(authAppProvider(false));
useApp(async (addr, port) => {
return app = new class extends TestApp {
protected async init(): Promise<void> {
this.use(new class extends Controller {
public routes(): void {
this.get('/', (req, res) => {
res.render('home');
}, 'home');
this.get('/csrf', (req, res) => {
res.send(CsrfProtectionComponent.getCsrfToken(req.getSession()));
}, 'csrf');
this.get('/is-auth', async (req, res) => {
const proofs = await this.getApp().as(AuthComponent).getAuthGuard().getProofs(req);
if (proofs.length > 0) res.sendStatus(200);
else res.sendStatus(401);
}, 'is-auth');
}
}());
await super.init();
}
protected getMigrations(): MigrationType<Migration>[] {
return super.getMigrations().filter(m => m !== AddNameToUsersMigration);
}
}(addr, port, true);
});
let agent: supertest.SuperTest<supertest.Test>; let agent: supertest.SuperTest<supertest.Test>;
beforeAll(() => { beforeAll(() => {
agent = supertest(app.getExpressApp()); agent = supertest(app().getExpressApp());
});
test('Approval Mode', () => {
expect(User.isApprovalMode()).toStrictEqual(false);
}); });
describe('Register with username and password (password)', () => { describe('Register with username and password (password)', () => {

View File

@ -1,12 +1,11 @@
import Application from "../src/Application";
import {setupMailServer, teardownMailServer} from "./_mail_server"; import {setupMailServer, teardownMailServer} from "./_mail_server";
import TestApp from "../src/TestApp"; import TestApp from "../src/TestApp";
import MysqlConnectionManager from "../src/db/MysqlConnectionManager"; import MysqlConnectionManager from "../src/db/MysqlConnectionManager";
import config from "config"; import config from "config";
export default function useApp(appSupplier?: AppSupplier): void { export default function useApp<T extends TestApp>(appSupplier: AppSupplier<T>): () => T {
let app: Application; let app: T;
beforeAll(async (done) => { beforeAll(async (done) => {
await MysqlConnectionManager.prepare(); await MysqlConnectionManager.prepare();
@ -14,7 +13,7 @@ export default function useApp(appSupplier?: AppSupplier): void {
await MysqlConnectionManager.endPool(); await MysqlConnectionManager.endPool();
await setupMailServer(); await setupMailServer();
app = appSupplier ? await appSupplier('127.0.0.1', 8966) : new TestApp('127.0.0.1', 8966, true); app = await appSupplier('127.0.0.1', 8966);
await app.start(); await app.start();
done(); done();
@ -38,6 +37,8 @@ export default function useApp(appSupplier?: AppSupplier): void {
if (errors.length > 0) throw errors; if (errors.length > 0) throw errors;
done(); done();
}); });
return () => app;
} }
export type AppSupplier = (addr: string, port: number) => Promise<TestApp>; export type AppSupplier<T extends TestApp> = (addr: string, port: number) => Promise<T>;

View File

@ -1,5 +1,15 @@
import {popEmail} from "./_mail_server"; import {popEmail} from "./_mail_server";
import supertest from "supertest"; import supertest from "supertest";
import {AppSupplier} from "./_app";
import TestApp from "../src/TestApp";
import Controller from "../src/Controller";
import CsrfProtectionComponent from "../src/components/CsrfProtectionComponent";
import AuthComponent from "../src/auth/AuthComponent";
import Migration, {MigrationType} from "../src/db/Migration";
import AddNameToUsersMigration from "../src/auth/migrations/AddNameToUsersMigration";
import AddApprovedFieldToUsersTableMigration from "../src/auth/migrations/AddApprovedFieldToUsersTableMigration";
import PasswordAuthProof from "../src/auth/password/PasswordAuthProof";
import MagicLink from "../src/auth/models/MagicLink";
export async function followMagicLinkFromMail( export async function followMagicLinkFromMail(
agent: supertest.SuperTest<supertest.Test>, agent: supertest.SuperTest<supertest.Test>,
@ -37,3 +47,53 @@ export async function testLogout(
// Not authenticated // Not authenticated
await agent.get('/is-auth').set('Cookie', cookies).expect(401); await agent.get('/is-auth').set('Cookie', cookies).expect(401);
} }
export function authAppProvider(withUsername: boolean = true, approvalMode: boolean = false): AppSupplier<TestApp> {
return async (addr, port) => {
return new class extends TestApp {
protected async init(): Promise<void> {
this.use(new class extends Controller {
public routes(): void {
this.get('/', (req, res) => {
res.render('home');
}, 'home');
this.get('/csrf', (req, res) => {
res.send(CsrfProtectionComponent.getCsrfToken(req.getSession()));
}, 'csrf');
this.get('/is-auth', async (req, res) => {
const proofs = await this.getApp().as(AuthComponent).getAuthGuard().getProofs(req);
if (proofs.length > 0) res.sendStatus(200);
else res.sendStatus(401);
}, 'is-auth');
this.get('/has-any-password-auth-proof', async (req, res) => {
const proof = await PasswordAuthProof.getProofForSession(req.getSession());
if (proof) res.sendStatus(200);
else res.sendStatus(404);
}, 'is-auth');
this.get('/has-any-magic-link', async (req, res) => {
const proofs = await MagicLink.select()
.where('session_id', req.getSession().id)
.get();
if (proofs.length > 0) res.sendStatus(200);
else res.sendStatus(404);
}, 'is-auth');
}
}());
await super.init();
}
protected getMigrations(): MigrationType<Migration>[] {
let migrations = withUsername ?
super.getMigrations() :
super.getMigrations().filter(m => m !== AddNameToUsersMigration);
migrations = approvalMode ?
[...migrations, AddApprovedFieldToUsersTableMigration] :
migrations;
return migrations;
}
}(addr, port, true);
};
}