mirror of
https://github.com/movie-web/backend.git
synced 2025-09-13 12:23:25 +00:00
Update Registration to new auth method
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.4",
|
||||
"@types/node": "^20.5.3",
|
||||
"@types/node-forge": "^1.3.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"eslint": "^8.47.0",
|
||||
@@ -41,7 +42,9 @@
|
||||
"fastify-metrics": "^10.3.2",
|
||||
"fastify-type-provider-zod": "^1.1.9",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nanoid": "^3.3.6",
|
||||
"neat-config": "^2.0.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"prom-client": "^15.0.0",
|
||||
"type-fest": "^4.2.0",
|
||||
"winston": "^3.10.0",
|
||||
|
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@@ -29,9 +29,15 @@ dependencies:
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
nanoid:
|
||||
specifier: ^3.3.6
|
||||
version: 3.3.6
|
||||
neat-config:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
node-forge:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
prom-client:
|
||||
specifier: ^15.0.0
|
||||
version: 15.0.0
|
||||
@@ -55,6 +61,9 @@ devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.5.3
|
||||
version: 20.8.9
|
||||
'@types/node-forge':
|
||||
specifier: ^1.3.8
|
||||
version: 1.3.8
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^6.4.1
|
||||
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==}
|
||||
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:
|
||||
resolution: {integrity: sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==}
|
||||
dependencies:
|
||||
@@ -1930,6 +1945,12 @@ packages:
|
||||
engines: {node: '>=12.0.0'}
|
||||
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:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
dev: true
|
||||
@@ -1941,6 +1962,11 @@ packages:
|
||||
zod: 3.22.4
|
||||
dev: false
|
||||
|
||||
/node-forge@1.3.1:
|
||||
resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
|
||||
engines: {node: '>= 6.13.0'}
|
||||
dev: false
|
||||
|
||||
/nodemon@3.0.1:
|
||||
resolution: {integrity: sha512-g9AZ7HmkhQkqXkRc20w+ZfQ73cHLbE8hnPbtaFbFtCumZsjyMhKk9LajQ07U5Ux28lvFjZ5X7HvWR1xzU8jHVw==}
|
||||
engines: {node: '>=10'}
|
||||
|
43
src/db/models/ChallengeCode.ts
Normal file
43
src/db/models/ChallengeCode.ts
Normal 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(),
|
||||
};
|
||||
}
|
@@ -6,7 +6,7 @@ export class Session {
|
||||
@PrimaryKey({ name: 'id', type: 'uuid' })
|
||||
id: string = randomUUID();
|
||||
|
||||
@Property({ name: 'user', type: 'uuid' })
|
||||
@Property({ name: 'user', type: 'text' })
|
||||
user!: string;
|
||||
|
||||
@Property({ type: 'date' })
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { Entity, PrimaryKey, Property, types } from '@mikro-orm/core';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Entity, Index, PrimaryKey, Property, types } from '@mikro-orm/core';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export type UserProfile = {
|
||||
colorA: string;
|
||||
@@ -9,8 +9,12 @@ export type UserProfile = {
|
||||
|
||||
@Entity({ tableName: 'users' })
|
||||
export class User {
|
||||
@PrimaryKey({ name: 'id', type: 'uuid' })
|
||||
id: string = randomUUID();
|
||||
@PrimaryKey({ name: 'id', type: 'text' })
|
||||
id: string = nanoid(12);
|
||||
|
||||
@Property({ name: 'public_key', type: 'text' })
|
||||
@Index()
|
||||
publicKey!: string;
|
||||
|
||||
@Property({ name: 'namespace' })
|
||||
namespace!: string;
|
||||
@@ -18,9 +22,6 @@ export class User {
|
||||
@Property({ type: 'date' })
|
||||
createdAt: Date = new Date();
|
||||
|
||||
@Property({ type: 'text' })
|
||||
name!: string;
|
||||
|
||||
@Property({ name: 'permissions', type: types.array })
|
||||
roles: string[] = [];
|
||||
|
||||
@@ -34,7 +35,7 @@ export class User {
|
||||
export interface UserDTO {
|
||||
id: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
publicKey: string;
|
||||
roles: string[];
|
||||
createdAt: string;
|
||||
profile: {
|
||||
@@ -48,7 +49,7 @@ export function formatUser(user: User): UserDTO {
|
||||
return {
|
||||
id: user.id,
|
||||
namespace: user.namespace,
|
||||
name: user.name,
|
||||
publicKey: user.publicKey,
|
||||
roles: user.roles,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
profile: {
|
||||
|
15
src/modules/jobs/list/challengeCode.ts
Normal file
15
src/modules/jobs/list/challengeCode.ts
Normal 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();
|
||||
});
|
@@ -1,3 +1,4 @@
|
||||
import { ChallengeCode, formatChallengeCode } from '@/db/models/ChallengeCode';
|
||||
import { formatSession } from '@/db/models/Session';
|
||||
import { User, formatUser } from '@/db/models/User';
|
||||
import { getMetrics } from '@/modules/metrics';
|
||||
@@ -6,40 +7,91 @@ import { handle } from '@/services/handler';
|
||||
import { makeRouter } from '@/services/router';
|
||||
import { makeSession, makeSessionToken } from '@/services/session';
|
||||
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),
|
||||
name: z.string().max(500).min(1),
|
||||
device: z.string().max(500).min(1),
|
||||
profile: z.object({
|
||||
colorA: z.string(),
|
||||
colorB: z.string(),
|
||||
icon: z.string(),
|
||||
}),
|
||||
captchaToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const manageAuthRouter = makeRouter((app) => {
|
||||
app.post(
|
||||
'/auth/register',
|
||||
{ schema: { body: registerSchema } },
|
||||
handle(async ({ em, body, req }) => {
|
||||
'/auth/register/start',
|
||||
{ schema: { body: startSchema } },
|
||||
handle(async ({ em, body }) => {
|
||||
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();
|
||||
user.namespace = body.namespace;
|
||||
user.name = body.name;
|
||||
user.publicKey = body.publicKey;
|
||||
user.profile = body.profile;
|
||||
|
||||
const session = makeSession(
|
||||
user.id,
|
||||
body.device,
|
||||
req.headers['user-agent'],
|
||||
);
|
||||
|
||||
await em.persistAndFlush([user, session]);
|
||||
getMetrics().user.inc({ namespace: body.namespace }, 1);
|
||||
|
||||
return {
|
||||
user: formatUser(user),
|
||||
session: formatSession(session),
|
||||
|
@@ -33,7 +33,6 @@ export const userEditRouter = makeRouter((app) => {
|
||||
if (auth.user.id !== user.id)
|
||||
throw new StatusError('Cannot modify user other than yourself', 403);
|
||||
|
||||
if (body.name) user.name = body.name;
|
||||
if (body.profile) user.profile = body.profile;
|
||||
|
||||
await em.persistAndFlush(user);
|
||||
|
Reference in New Issue
Block a user