Update Registration to new auth method

This commit is contained in:
William Oldham
2023-11-03 17:43:34 +00:00
parent 88bf4eb31b
commit c4f6e7a87f
8 changed files with 160 additions and 21 deletions

View File

@@ -19,6 +19,7 @@
"devDependencies": { "devDependencies": {
"@types/jsonwebtoken": "^9.0.4", "@types/jsonwebtoken": "^9.0.4",
"@types/node": "^20.5.3", "@types/node": "^20.5.3",
"@types/node-forge": "^1.3.8",
"@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1", "@typescript-eslint/parser": "^6.4.1",
"eslint": "^8.47.0", "eslint": "^8.47.0",
@@ -41,7 +42,9 @@
"fastify-metrics": "^10.3.2", "fastify-metrics": "^10.3.2",
"fastify-type-provider-zod": "^1.1.9", "fastify-type-provider-zod": "^1.1.9",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"nanoid": "^3.3.6",
"neat-config": "^2.0.0", "neat-config": "^2.0.0",
"node-forge": "^1.3.1",
"prom-client": "^15.0.0", "prom-client": "^15.0.0",
"type-fest": "^4.2.0", "type-fest": "^4.2.0",
"winston": "^3.10.0", "winston": "^3.10.0",

26
pnpm-lock.yaml generated
View File

@@ -29,9 +29,15 @@ dependencies:
jsonwebtoken: jsonwebtoken:
specifier: ^9.0.2 specifier: ^9.0.2
version: 9.0.2 version: 9.0.2
nanoid:
specifier: ^3.3.6
version: 3.3.6
neat-config: neat-config:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
node-forge:
specifier: ^1.3.1
version: 1.3.1
prom-client: prom-client:
specifier: ^15.0.0 specifier: ^15.0.0
version: 15.0.0 version: 15.0.0
@@ -55,6 +61,9 @@ devDependencies:
'@types/node': '@types/node':
specifier: ^20.5.3 specifier: ^20.5.3
version: 20.8.9 version: 20.8.9
'@types/node-forge':
specifier: ^1.3.8
version: 1.3.8
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^6.4.1 specifier: ^6.4.1
version: 6.9.0(@typescript-eslint/parser@6.9.0)(eslint@8.52.0)(typescript@5.2.2) version: 6.9.0(@typescript-eslint/parser@6.9.0)(eslint@8.52.0)(typescript@5.2.2)
@@ -447,6 +456,12 @@ packages:
resolution: {integrity: sha512-/BJF3NT0pRMuxrenr42emRUF67sXwcZCd+S1ksG/Fcf9O7C3kKCY4uJSbKBE4KDUIYr3WMsvfmWD8hRjXExBJQ==} resolution: {integrity: sha512-/BJF3NT0pRMuxrenr42emRUF67sXwcZCd+S1ksG/Fcf9O7C3kKCY4uJSbKBE4KDUIYr3WMsvfmWD8hRjXExBJQ==}
dev: false dev: false
/@types/node-forge@1.3.8:
resolution: {integrity: sha512-vGXshY9vim9CJjrpcS5raqSjEfKlJcWy2HNdgUasR66fAnVEYarrf1ULV4nfvpC1nZq/moA9qyqBcu83x+Jlrg==}
dependencies:
'@types/node': 20.8.9
dev: true
/@types/node@20.8.9: /@types/node@20.8.9:
resolution: {integrity: sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==} resolution: {integrity: sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==}
dependencies: dependencies:
@@ -1930,6 +1945,12 @@ packages:
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
dev: true dev: true
/nanoid@3.3.6:
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
dev: false
/natural-compare@1.4.0: /natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
dev: true dev: true
@@ -1941,6 +1962,11 @@ packages:
zod: 3.22.4 zod: 3.22.4
dev: false dev: false
/node-forge@1.3.1:
resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
engines: {node: '>= 6.13.0'}
dev: false
/nodemon@3.0.1: /nodemon@3.0.1:
resolution: {integrity: sha512-g9AZ7HmkhQkqXkRc20w+ZfQ73cHLbE8hnPbtaFbFtCumZsjyMhKk9LajQ07U5Ux28lvFjZ5X7HvWR1xzU8jHVw==} resolution: {integrity: sha512-g9AZ7HmkhQkqXkRc20w+ZfQ73cHLbE8hnPbtaFbFtCumZsjyMhKk9LajQ07U5Ux28lvFjZ5X7HvWR1xzU8jHVw==}
engines: {node: '>=10'} engines: {node: '>=10'}

View File

@@ -0,0 +1,43 @@
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { randomUUID } from 'crypto';
// 30 seconds
const CHALLENGE_EXPIRY_MS = 3000000 * 1000;
@Entity({ tableName: 'challenge_codes' })
export class ChallengeCode {
@PrimaryKey({ name: 'code', type: 'uuid' })
code: string = randomUUID();
@Property({ name: 'stage', type: 'text' })
stage!: 'registration' | 'login';
@Property({ name: 'auth_type' })
authType!: 'mnemonic';
@Property({ type: 'date' })
createdAt: Date = new Date();
@Property({ type: 'date' })
expiresAt: Date = new Date(Date.now() + CHALLENGE_EXPIRY_MS);
}
export interface ChallengeCodeDTO {
code: string;
stage: string;
authType: string;
createdAt: string;
expiresAt: string;
}
export function formatChallengeCode(
challenge: ChallengeCode,
): ChallengeCodeDTO {
return {
code: challenge.code,
stage: challenge.stage,
authType: challenge.authType,
createdAt: challenge.createdAt.toISOString(),
expiresAt: challenge.expiresAt.toISOString(),
};
}

View File

@@ -6,7 +6,7 @@ export class Session {
@PrimaryKey({ name: 'id', type: 'uuid' }) @PrimaryKey({ name: 'id', type: 'uuid' })
id: string = randomUUID(); id: string = randomUUID();
@Property({ name: 'user', type: 'uuid' }) @Property({ name: 'user', type: 'text' })
user!: string; user!: string;
@Property({ type: 'date' }) @Property({ type: 'date' })

View File

@@ -1,5 +1,5 @@
import { Entity, PrimaryKey, Property, types } from '@mikro-orm/core'; import { Entity, Index, PrimaryKey, Property, types } from '@mikro-orm/core';
import { randomUUID } from 'crypto'; import { nanoid } from 'nanoid';
export type UserProfile = { export type UserProfile = {
colorA: string; colorA: string;
@@ -9,8 +9,12 @@ export type UserProfile = {
@Entity({ tableName: 'users' }) @Entity({ tableName: 'users' })
export class User { export class User {
@PrimaryKey({ name: 'id', type: 'uuid' }) @PrimaryKey({ name: 'id', type: 'text' })
id: string = randomUUID(); id: string = nanoid(12);
@Property({ name: 'public_key', type: 'text' })
@Index()
publicKey!: string;
@Property({ name: 'namespace' }) @Property({ name: 'namespace' })
namespace!: string; namespace!: string;
@@ -18,9 +22,6 @@ export class User {
@Property({ type: 'date' }) @Property({ type: 'date' })
createdAt: Date = new Date(); createdAt: Date = new Date();
@Property({ type: 'text' })
name!: string;
@Property({ name: 'permissions', type: types.array }) @Property({ name: 'permissions', type: types.array })
roles: string[] = []; roles: string[] = [];
@@ -34,7 +35,7 @@ export class User {
export interface UserDTO { export interface UserDTO {
id: string; id: string;
namespace: string; namespace: string;
name: string; publicKey: string;
roles: string[]; roles: string[];
createdAt: string; createdAt: string;
profile: { profile: {
@@ -48,7 +49,7 @@ export function formatUser(user: User): UserDTO {
return { return {
id: user.id, id: user.id,
namespace: user.namespace, namespace: user.namespace,
name: user.name, publicKey: user.publicKey,
roles: user.roles, roles: user.roles,
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
profile: { profile: {

View File

@@ -0,0 +1,15 @@
import { ChallengeCode } from '@/db/models/ChallengeCode';
import { job } from '@/modules/jobs/job';
// every day at 12:00:00
export const sessionExpiryJob = job('0 12 * * *', async ({ em }) => {
await em
.createQueryBuilder(ChallengeCode)
.delete()
.where({
expiresAt: {
$lt: new Date(),
},
})
.execute();
});

View File

@@ -1,3 +1,4 @@
import { ChallengeCode, formatChallengeCode } from '@/db/models/ChallengeCode';
import { formatSession } from '@/db/models/Session'; import { formatSession } from '@/db/models/Session';
import { User, formatUser } from '@/db/models/User'; import { User, formatUser } from '@/db/models/User';
import { getMetrics } from '@/modules/metrics'; import { getMetrics } from '@/modules/metrics';
@@ -6,40 +7,91 @@ import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router'; import { makeRouter } from '@/services/router';
import { makeSession, makeSessionToken } from '@/services/session'; import { makeSession, makeSessionToken } from '@/services/session';
import { z } from 'zod'; import { z } from 'zod';
import { nanoid } from 'nanoid';
import forge from 'node-forge';
import { StatusError } from '@/services/error';
import { t } from '@mikro-orm/core';
const registerSchema = z.object({ const startSchema = z.object({
captchaToken: z.string().optional(),
});
const completeSchema = z.object({
publicKey: z.string(),
challenge: z.object({
code: z.string(),
signature: z.string(),
}),
namespace: z.string().min(1), namespace: z.string().min(1),
name: z.string().max(500).min(1),
device: z.string().max(500).min(1), device: z.string().max(500).min(1),
profile: z.object({ profile: z.object({
colorA: z.string(), colorA: z.string(),
colorB: z.string(), colorB: z.string(),
icon: z.string(), icon: z.string(),
}), }),
captchaToken: z.string().optional(),
}); });
export const manageAuthRouter = makeRouter((app) => { export const manageAuthRouter = makeRouter((app) => {
app.post( app.post(
'/auth/register', '/auth/register/start',
{ schema: { body: registerSchema } }, { schema: { body: startSchema } },
handle(async ({ em, body, req }) => { handle(async ({ em, body }) => {
await assertCaptcha(body.captchaToken); await assertCaptcha(body.captchaToken);
const challenge = new ChallengeCode();
challenge.authType = 'mnemonic';
challenge.stage = 'registration';
await em.persistAndFlush(challenge);
return {
challenge: challenge.code,
};
}),
);
app.post(
'/auth/register/complete',
{ schema: { body: completeSchema } },
handle(async ({ em, body, req }) => {
const now = Date.now();
const challenge = await em.findOne(ChallengeCode, {
code: body.challenge.code,
});
if (!challenge) throw new StatusError('Challenge Code Invalid', 401);
if (challenge.expiresAt.getTime() <= now)
throw new StatusError('Challenge Code Expired', 401);
const verifiedChallenge = forge.pki.ed25519.verify({
publicKey: new forge.util.ByteStringBuffer(
Buffer.from(body.publicKey, 'base64url'),
),
encoding: 'utf8',
signature: new forge.util.ByteStringBuffer(
Buffer.from(body.challenge.signature, 'base64url'),
),
message: body.challenge.code,
});
if (!verifiedChallenge)
throw new StatusError('Challenge Code Signature Invalid', 401);
em.remove(challenge);
const user = new User(); const user = new User();
user.namespace = body.namespace; user.namespace = body.namespace;
user.name = body.name; user.publicKey = body.publicKey;
user.profile = body.profile; user.profile = body.profile;
const session = makeSession( const session = makeSession(
user.id, user.id,
body.device, body.device,
req.headers['user-agent'], req.headers['user-agent'],
); );
await em.persistAndFlush([user, session]); await em.persistAndFlush([user, session]);
getMetrics().user.inc({ namespace: body.namespace }, 1); getMetrics().user.inc({ namespace: body.namespace }, 1);
return { return {
user: formatUser(user), user: formatUser(user),
session: formatSession(session), session: formatSession(session),

View File

@@ -33,7 +33,6 @@ export const userEditRouter = makeRouter((app) => {
if (auth.user.id !== user.id) if (auth.user.id !== user.id)
throw new StatusError('Cannot modify user other than yourself', 403); throw new StatusError('Cannot modify user other than yourself', 403);
if (body.name) user.name = body.name;
if (body.profile) user.profile = body.profile; if (body.profile) user.profile = body.profile;
await em.persistAndFlush(user); await em.persistAndFlush(user);