diff --git a/package.json b/package.json index a4d4113..e63f3b9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcb62df..d583d1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/src/db/models/ChallengeCode.ts b/src/db/models/ChallengeCode.ts new file mode 100644 index 0000000..23e17de --- /dev/null +++ b/src/db/models/ChallengeCode.ts @@ -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(), + }; +} diff --git a/src/db/models/Session.ts b/src/db/models/Session.ts index 137f778..ec04498 100644 --- a/src/db/models/Session.ts +++ b/src/db/models/Session.ts @@ -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' }) diff --git a/src/db/models/User.ts b/src/db/models/User.ts index adad648..309388a 100644 --- a/src/db/models/User.ts +++ b/src/db/models/User.ts @@ -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: { diff --git a/src/modules/jobs/list/challengeCode.ts b/src/modules/jobs/list/challengeCode.ts new file mode 100644 index 0000000..fd3d7a2 --- /dev/null +++ b/src/modules/jobs/list/challengeCode.ts @@ -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(); +}); diff --git a/src/routes/auth/manage.ts b/src/routes/auth/manage.ts index a4c3760..6e60976 100644 --- a/src/routes/auth/manage.ts +++ b/src/routes/auth/manage.ts @@ -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), diff --git a/src/routes/users/edit.ts b/src/routes/users/edit.ts index d3eb093..b2ac327 100644 --- a/src/routes/users/edit.ts +++ b/src/routes/users/edit.ts @@ -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);