Greatly simplify authentication system
This commit is contained in:
parent
40181a973b
commit
a79e2292d7
@ -15,7 +15,7 @@ export default class AuthComponent extends ApplicationComponent<void> {
|
||||
public async init(router: Router): Promise<void> {
|
||||
router.use(async (req, res, next) => {
|
||||
req.authGuard = this.authGuard;
|
||||
res.locals.user = await req.authGuard.getUserForSession(req.session!);
|
||||
res.locals.user = await (await req.authGuard.getProofForSession(req.session!))?.getResource();
|
||||
next();
|
||||
});
|
||||
}
|
||||
@ -30,13 +30,13 @@ export const REQUIRE_REQUEST_AUTH_MIDDLEWARE = async (req: Request, res: Respons
|
||||
return;
|
||||
}
|
||||
|
||||
req.models.user = await req.authGuard.getUserForRequest(req);
|
||||
req.models.user = await (await req.authGuard.getProofForRequest(req))?.getResource();
|
||||
next();
|
||||
};
|
||||
|
||||
export const REQUIRE_AUTH_MIDDLEWARE = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
if (await req.authGuard.isAuthenticatedViaRequest(req)) {
|
||||
req.models.user = await req.authGuard.getUserForRequest(req);
|
||||
req.models.user = await (await req.authGuard.getProofForRequest(req))?.getResource();
|
||||
next();
|
||||
} else {
|
||||
if (!await req.authGuard.isAuthenticated(req.session!)) {
|
||||
@ -47,7 +47,7 @@ export const REQUIRE_AUTH_MIDDLEWARE = async (req: Request, res: Response, next:
|
||||
return;
|
||||
}
|
||||
|
||||
req.models.user = await req.authGuard.getUserForSession(req.session!);
|
||||
req.models.user = await (await req.authGuard.getProofForSession(req.session!))?.getResource();
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
@ -26,7 +26,8 @@ export default abstract class AuthController extends Controller {
|
||||
protected abstract async getCheckAuth(req: Request, res: Response, next: NextFunction): Promise<void>;
|
||||
|
||||
protected async postLogout(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
await req.authGuard.logout(req.session!);
|
||||
const proof = await req.authGuard.getProofForSession(req.session!);
|
||||
await proof?.revoke();
|
||||
req.flash('success', 'Successfully logged out.');
|
||||
res.redirect(req.query.redirect_uri?.toString() || '/');
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import AuthProof from "./AuthProof";
|
||||
import MysqlConnectionManager from "../db/MysqlConnectionManager";
|
||||
import User from "./models/User";
|
||||
import UserEmail from "./models/UserEmail";
|
||||
import {Connection} from "mysql";
|
||||
import {Request} from "express";
|
||||
import {PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE} from "../Mails";
|
||||
@ -9,42 +8,34 @@ import Mail from "../Mail";
|
||||
import Controller from "../Controller";
|
||||
import config from "config";
|
||||
|
||||
export default abstract class AuthGuard<P extends AuthProof> {
|
||||
export default abstract class AuthGuard<P extends AuthProof<User>> {
|
||||
public abstract async getProofForSession(session: Express.Session): Promise<P | null>;
|
||||
|
||||
public async getProofForRequest(req: Request): Promise<P | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getUserForSession(session: Express.Session): Promise<User | null> {
|
||||
if (!await this.isAuthenticated(session)) return null;
|
||||
return await User.getById<User>(session.auth_id);
|
||||
}
|
||||
public async authenticateOrRegister(
|
||||
session: Express.Session,
|
||||
proof: P,
|
||||
onLogin?: (user: User) => Promise<void>,
|
||||
onRegister?: (connection: Connection, user: User) => Promise<RegisterCallback[]>
|
||||
): Promise<void> {
|
||||
if (!await proof.isValid()) throw new InvalidAuthProofError();
|
||||
if (!await proof.isAuthorized()) throw new UnauthorizedAuthProofError();
|
||||
|
||||
public async authenticateOrRegister(session: Express.Session, proof: P, registerCallback?: (connection: Connection, userID: number) => Promise<(() => Promise<void>)[]>): Promise<void> {
|
||||
if (!await proof.isAuthorized()) {
|
||||
throw new AuthError('Invalid argument: cannot authenticate with an unauthorized proof.');
|
||||
}
|
||||
let user = await proof.getResource();
|
||||
|
||||
let user = await proof.getUser();
|
||||
if (!user) { // Register
|
||||
const callbacks: (() => Promise<void>)[] = [];
|
||||
// Register if user doesn't exist
|
||||
if (!user) {
|
||||
const callbacks: RegisterCallback[] = [];
|
||||
|
||||
await MysqlConnectionManager.wrapTransaction(async connection => {
|
||||
const email = await proof.getEmail();
|
||||
user = new User({
|
||||
name: email.split('@')[0],
|
||||
});
|
||||
user = new User({});
|
||||
await user.save(connection, c => callbacks.push(c));
|
||||
|
||||
const userEmail = new UserEmail({
|
||||
user_id: user.id,
|
||||
email: email,
|
||||
main: true,
|
||||
});
|
||||
await userEmail.save(connection, c => callbacks.push(c));
|
||||
if (registerCallback) {
|
||||
(await registerCallback(connection, user.id!)).forEach(c => callbacks.push(c));
|
||||
if (onRegister) {
|
||||
(await onRegister(connection, user)).forEach(c => callbacks.push(c));
|
||||
}
|
||||
});
|
||||
|
||||
@ -62,36 +53,25 @@ export default abstract class AuthGuard<P extends AuthProof> {
|
||||
} else {
|
||||
throw new Error('Unable to register user.');
|
||||
}
|
||||
} else if (registerCallback) {
|
||||
throw new UserAlreadyExistsAuthError(await proof.getEmail());
|
||||
}
|
||||
|
||||
// Don't login if user isn't approved
|
||||
if (!user.isApproved()) {
|
||||
throw new PendingApprovalAuthError();
|
||||
}
|
||||
|
||||
// Login
|
||||
session.auth_id = user.id;
|
||||
if (onLogin) await onLogin(user);
|
||||
}
|
||||
|
||||
public async isAuthenticated(session: Express.Session): Promise<boolean> {
|
||||
return await this.checkCurrentSessionProofValidity(session);
|
||||
}
|
||||
|
||||
public async logout(session: Express.Session): Promise<void> {
|
||||
const proof = await this.getProofForSession(session);
|
||||
if (proof) {
|
||||
await proof.revoke(session);
|
||||
}
|
||||
session.auth_id = undefined;
|
||||
}
|
||||
|
||||
private async checkCurrentSessionProofValidity(session: Express.Session): Promise<boolean> {
|
||||
if (typeof session.auth_id !== 'number') return false;
|
||||
|
||||
const proof = await this.getProofForSession(session);
|
||||
|
||||
if (!proof || !await proof.isValid() || !await proof.isAuthorized() || !await proof.isOwnedBy(session.auth_id)) {
|
||||
await this.logout(session);
|
||||
if (!proof || !await proof.isValid() || !await proof.isAuthorized()) {
|
||||
await proof?.revoke();
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -100,16 +80,7 @@ export default abstract class AuthGuard<P extends AuthProof> {
|
||||
|
||||
public async isAuthenticatedViaRequest(req: Request): Promise<boolean> {
|
||||
const proof = await this.getProofForRequest(req);
|
||||
if (proof && await proof.isValid() && await proof.isAuthorized()) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserForRequest(req: Request): Promise<User | null> {
|
||||
const proof = await this.getProofForRequest(req);
|
||||
return proof ? await proof.getUser() : null;
|
||||
return Boolean(proof && await proof.isValid() && await proof.isAuthorized());
|
||||
}
|
||||
|
||||
}
|
||||
@ -120,12 +91,18 @@ export class AuthError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class UserAlreadyExistsAuthError extends AuthError {
|
||||
public readonly email: string;
|
||||
export class AuthProofError extends AuthError {
|
||||
}
|
||||
|
||||
constructor(userEmail: string) {
|
||||
super(`User with email ${userEmail} already exists.`);
|
||||
this.email = userEmail;
|
||||
export class InvalidAuthProofError extends AuthProofError {
|
||||
constructor() {
|
||||
super('Invalid auth proof.');
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedAuthProofError extends AuthProofError {
|
||||
constructor() {
|
||||
super('Unauthorized auth proof.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,3 +111,5 @@ export class PendingApprovalAuthError extends AuthError {
|
||||
super(`User is not approved.`);
|
||||
}
|
||||
}
|
||||
|
||||
export type RegisterCallback = () => Promise<void>;
|
||||
|
@ -1,15 +1,40 @@
|
||||
import User from "./models/User";
|
||||
|
||||
export default interface AuthProof {
|
||||
/**
|
||||
* This class is most commonly used for authentication. It can be more generically used to represent a verification
|
||||
* state of whether a given resource is owned by a session.
|
||||
*
|
||||
* Any auth system should consider this auth proof valid if and only if both {@code isValid()} and {@code isAuthorized()}
|
||||
* both return {@code true}.
|
||||
*
|
||||
* @type <R> The resource type this AuthProof authorizes.
|
||||
*/
|
||||
export default interface AuthProof<R> {
|
||||
/**
|
||||
* Is this auth proof valid in time (and context)?
|
||||
*
|
||||
* For example, it can return true for an initial short validity time period then false, and increase that time
|
||||
* period if {@code isAuthorized()} returns true.
|
||||
*/
|
||||
isValid(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Was this proof authorized?
|
||||
*
|
||||
* Return true once the session is proven to own the associated resource.
|
||||
*/
|
||||
isAuthorized(): Promise<boolean>;
|
||||
|
||||
isOwnedBy(userId: number): Promise<boolean>;
|
||||
/**
|
||||
* Retrieve the resource this auth proof is supposed to authorize.
|
||||
* If this resource doesn't exist yet, return {@code null}.
|
||||
*/
|
||||
getResource(): Promise<R | null>;
|
||||
|
||||
getUser(): Promise<User | null>;
|
||||
|
||||
getEmail(): Promise<string>;
|
||||
|
||||
revoke(session: Express.Session): Promise<void>;
|
||||
/**
|
||||
* Manually revokes this authentication proof. Once this method is called, all of the following must be true:
|
||||
* - {@code isAuthorized} returns {@code false}
|
||||
* - There is no way to re-authorize this proof (i.e. {@code isAuthorized} can never return {@code true} again)
|
||||
*
|
||||
* Additionally, this method should delete any stored data that could lead to restoration of this AuthProof instance.
|
||||
*/
|
||||
revoke(): Promise<void>;
|
||||
}
|
@ -5,7 +5,7 @@ import {BadRequestError} from "../../HttpError";
|
||||
import UserEmail from "../models/UserEmail";
|
||||
import MagicLinkController from "./MagicLinkController";
|
||||
import {MailTemplate} from "../../Mail";
|
||||
import {AuthError, PendingApprovalAuthError} from "../AuthGuard";
|
||||
import {AuthError, PendingApprovalAuthError, RegisterCallback} from "../AuthGuard";
|
||||
import geoip from "geoip-lite";
|
||||
import AuthController from "../AuthController";
|
||||
import NunjucksComponent from "../../components/NunjucksComponent";
|
||||
@ -19,7 +19,19 @@ export default abstract class MagicLinkAuthController extends AuthController {
|
||||
|
||||
// Auth
|
||||
try {
|
||||
await req.authGuard.authenticateOrRegister(req.session!, magicLink);
|
||||
await req.authGuard.authenticateOrRegister(req.session!, magicLink, undefined, async (connection, user) => {
|
||||
const callbacks: RegisterCallback[] = [];
|
||||
|
||||
const userEmail = new UserEmail({
|
||||
user_id: user.id,
|
||||
email: magicLink.getEmail(),
|
||||
});
|
||||
await userEmail.save(connection, c => callbacks.push(c));
|
||||
user.main_email_id = userEmail.id;
|
||||
await user.save(connection, c => callbacks.push(c));
|
||||
|
||||
return callbacks;
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof PendingApprovalAuthError) {
|
||||
res.format({
|
||||
|
@ -39,16 +39,16 @@ export default abstract class MagicLinkController extends Controller {
|
||||
this.magicLinkWebsocketPath = magicLinkWebsocketListener.path();
|
||||
}
|
||||
|
||||
getRoutesPrefix(): string {
|
||||
public getRoutesPrefix(): string {
|
||||
return '/magic';
|
||||
}
|
||||
|
||||
routes(): void {
|
||||
public routes(): void {
|
||||
this.get('/lobby', this.getLobby, 'magic_link_lobby');
|
||||
this.get('/link', this.getMagicLink, 'magic_link');
|
||||
}
|
||||
|
||||
private async getLobby(req: Request, res: Response): Promise<void> {
|
||||
protected async getLobby(req: Request, res: Response): Promise<void> {
|
||||
const link = await MagicLink.bySessionID(req.sessionID!);
|
||||
if (!link) {
|
||||
throw new NotFoundHttpError('magic link', req.url);
|
||||
@ -66,14 +66,14 @@ export default abstract class MagicLinkController extends Controller {
|
||||
}
|
||||
|
||||
res.render('magic_link_lobby', {
|
||||
email: await link.getEmail(),
|
||||
type: await link.getActionType(),
|
||||
email: link.getEmail(),
|
||||
type: link.getActionType(),
|
||||
validUntil: link.getExpirationDate().getTime(),
|
||||
websocketUrl: config.get<string>('public_websocket_url') + this.magicLinkWebsocketPath,
|
||||
});
|
||||
}
|
||||
|
||||
private async getMagicLink(req: Request, res: Response): Promise<void> {
|
||||
protected async getMagicLink(req: Request, res: Response): Promise<void> {
|
||||
const id = parseInt(<string>req.query.id);
|
||||
const token = <string>req.query.token;
|
||||
if (!id || !token) throw new BadRequestError('Need parameters id, token.', 'Please try again.', req.originalUrl);
|
||||
|
15
src/auth/migrations/DropNameFromUsers.ts
Normal file
15
src/auth/migrations/DropNameFromUsers.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import Migration from "../../db/Migration";
|
||||
import {Connection} from "mysql";
|
||||
|
||||
export default class DropNameFromUsers extends Migration {
|
||||
public async install(connection: Connection): Promise<void> {
|
||||
await this.query('ALTER TABLE users DROP COLUMN name', connection);
|
||||
}
|
||||
|
||||
public async rollback(connection: Connection): Promise<void> {
|
||||
await this.query('ALTER TABLE users ADD COLUMN name VARCHAR(64)', connection);
|
||||
}
|
||||
|
||||
public registerModels(): void {
|
||||
}
|
||||
}
|
@ -4,10 +4,10 @@ import {Connection} from "mysql";
|
||||
export default class FixUserMainEmailRelation extends Migration {
|
||||
public async install(connection: Connection): Promise<void> {
|
||||
await this.query(`ALTER TABLE users
|
||||
ADD COLUMN main_user_email_id INT,
|
||||
ADD FOREIGN KEY main_user_email_fk (main_user_email_id) REFERENCES user_emails (id)`, connection);
|
||||
ADD COLUMN main_email_id INT,
|
||||
ADD FOREIGN KEY main_user_email_fk (main_email_id) REFERENCES user_emails (id)`, connection);
|
||||
await this.query(`UPDATE users u LEFT JOIN user_emails ue ON u.id = ue.user_id
|
||||
SET u.main_user_email_id=ue.id
|
||||
SET u.main_email_id=ue.id
|
||||
WHERE ue.main = true`, connection);
|
||||
await this.query(`ALTER TABLE user_emails
|
||||
DROP COLUMN main`, connection);
|
||||
@ -16,11 +16,11 @@ export default class FixUserMainEmailRelation extends Migration {
|
||||
public async rollback(connection: Connection): Promise<void> {
|
||||
await this.query(`ALTER TABLE user_emails
|
||||
ADD COLUMN main BOOLEAN DEFAULT false`, connection);
|
||||
await this.query(`UPDATE user_emails ue LEFT JOIN users u ON ue.id = u.main_user_email_id
|
||||
await this.query(`UPDATE user_emails ue LEFT JOIN users u ON ue.id = u.main_email_id
|
||||
SET ue.main = true`, connection)
|
||||
await this.query(`ALTER TABLE users
|
||||
DROP FOREIGN KEY main_user_email_fk,
|
||||
DROP COLUMN main_user_email_id`, connection);
|
||||
DROP COLUMN main_email_id`, connection);
|
||||
}
|
||||
|
||||
public registerModels(): void {
|
||||
|
@ -7,7 +7,7 @@ import argon2 from "argon2";
|
||||
import {WhereTest} from "../../db/ModelQuery";
|
||||
import UserEmail from "./UserEmail";
|
||||
|
||||
export default class MagicLink extends Model implements AuthProof {
|
||||
export default class MagicLink extends Model implements AuthProof<User> {
|
||||
public static async bySessionID(sessionID: string, actionType?: string | string[]): Promise<MagicLink | null> {
|
||||
let query = this.select().where('session_id', sessionID);
|
||||
if (actionType !== undefined) {
|
||||
@ -31,15 +31,12 @@ export default class MagicLink extends Model implements AuthProof {
|
||||
private action_type?: string = undefined;
|
||||
private original_url?: string = undefined;
|
||||
private generated_at?: Date = undefined;
|
||||
private authorized?: boolean = undefined;
|
||||
private authorized: boolean = false;
|
||||
|
||||
constructor(data: any) {
|
||||
super(data);
|
||||
if (this.action_type === undefined) throw new Error('Action type must always be defined.');
|
||||
if (this.original_url === undefined) throw new Error('Origin url must always be defined.');
|
||||
if (this.authorized === undefined) {
|
||||
this.authorized = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected init(): void {
|
||||
@ -51,13 +48,9 @@ export default class MagicLink extends Model implements AuthProof {
|
||||
this.setValidation('authorized').defined();
|
||||
}
|
||||
|
||||
public async isOwnedBy(userId: number): Promise<boolean> {
|
||||
const user = await this.getUser();
|
||||
return user !== null && user.id === userId;
|
||||
}
|
||||
|
||||
public async getUser(): Promise<User | null> {
|
||||
public async getResource(): Promise<User | null> {
|
||||
const email = await UserEmail.select()
|
||||
.with('user')
|
||||
.where('email', await this.getEmail())
|
||||
.first();
|
||||
return email ? email.user.get() : null;
|
||||
@ -68,15 +61,18 @@ export default class MagicLink extends Model implements AuthProof {
|
||||
}
|
||||
|
||||
public async isValid(): Promise<boolean> {
|
||||
if (await this.isAuthorized()) return true;
|
||||
|
||||
return new Date().getTime() < this.getExpirationDate().getTime();
|
||||
return await this.isAuthorized() ||
|
||||
new Date().getTime() < this.getExpirationDate().getTime();
|
||||
}
|
||||
|
||||
public async isAuthorized(): Promise<boolean> {
|
||||
return this.authorized!;
|
||||
}
|
||||
|
||||
public authorize() {
|
||||
this.authorized = true;
|
||||
}
|
||||
|
||||
public async generateToken(email: string): Promise<string> {
|
||||
const rawToken = crypto.randomBytes(48).toString('base64'); // Raw token length = 64
|
||||
this.email = email;
|
||||
@ -84,7 +80,8 @@ export default class MagicLink extends Model implements AuthProof {
|
||||
this.token = await argon2.hash(rawToken, {
|
||||
timeCost: 10,
|
||||
memoryCost: 4096,
|
||||
parallelism: 4
|
||||
parallelism: 4,
|
||||
hashLength: 32,
|
||||
});
|
||||
return rawToken;
|
||||
}
|
||||
@ -104,7 +101,7 @@ export default class MagicLink extends Model implements AuthProof {
|
||||
return this.session_id!;
|
||||
}
|
||||
|
||||
public async getEmail(): Promise<string> {
|
||||
public getEmail(): string {
|
||||
return this.email!;
|
||||
}
|
||||
|
||||
@ -121,8 +118,4 @@ export default class MagicLink extends Model implements AuthProof {
|
||||
|
||||
return new Date(this.generated_at.getTime() + MagicLink.validityPeriod());
|
||||
}
|
||||
|
||||
public authorize() {
|
||||
this.authorized = true;
|
||||
}
|
||||
}
|
||||
|
@ -13,8 +13,7 @@ export default class User extends Model {
|
||||
}
|
||||
|
||||
public readonly id?: number = undefined;
|
||||
public name?: string = undefined;
|
||||
public main_user_email_id?: number = undefined;
|
||||
public main_email_id?: number = undefined;
|
||||
public is_admin: boolean = false;
|
||||
public created_at?: Date = undefined;
|
||||
public updated_at?: Date = undefined;
|
||||
@ -24,7 +23,7 @@ export default class User extends Model {
|
||||
foreignKey: 'user_id'
|
||||
});
|
||||
|
||||
public readonly mainEmail = this.emails.cloneReduceToOne().constraint(q => q.where('id', this.main_user_email_id));
|
||||
public readonly mainEmail = this.emails.cloneReduceToOne().constraint(q => q.where('id', this.main_email_id));
|
||||
|
||||
public constructor(data: any) {
|
||||
super(data);
|
||||
@ -32,7 +31,7 @@ export default class User extends Model {
|
||||
|
||||
protected init(): void {
|
||||
this.setValidation('name').acceptUndefined().between(3, 64);
|
||||
this.setValidation('main_user_email_id').acceptUndefined().exists(UserEmail, 'id');
|
||||
this.setValidation('main_email_id').acceptUndefined().exists(UserEmail, 'id');
|
||||
if (User.isApprovalMode()) {
|
||||
this.setValidation('approved').defined();
|
||||
}
|
||||
|
@ -19,8 +19,8 @@ export default abstract class ModelComponent<T extends Model> {
|
||||
}
|
||||
}
|
||||
|
||||
protected setValidation<T>(propertyName: keyof this): Validator<T> {
|
||||
const validator = new Validator<T>();
|
||||
protected setValidation<V>(propertyName: keyof this): Validator<V> {
|
||||
const validator = new Validator<V>();
|
||||
this._validators[propertyName as string] = validator;
|
||||
return validator;
|
||||
}
|
||||
|
@ -2,10 +2,14 @@ import CreateMigrationsTable from "../src/migrations/CreateMigrationsTable";
|
||||
import CreateLogsTable from "../src/migrations/CreateLogsTable";
|
||||
import CreateUsersAndUserEmailsTable from "../src/auth/migrations/CreateUsersAndUserEmailsTable";
|
||||
import FixUserMainEmailRelation from "../src/auth/migrations/FixUserMainEmailRelation";
|
||||
import DropNameFromUsers from "../src/auth/migrations/DropNameFromUsers";
|
||||
import CreateMagicLinksTable from "../src/auth/migrations/CreateMagicLinksTable";
|
||||
|
||||
export const MIGRATIONS = [
|
||||
CreateMigrationsTable,
|
||||
CreateLogsTable,
|
||||
CreateUsersAndUserEmailsTable,
|
||||
FixUserMainEmailRelation,
|
||||
DropNameFromUsers,
|
||||
CreateMagicLinksTable,
|
||||
];
|
Loading…
Reference in New Issue
Block a user