Merge pull request #5 from Rykuno/feature/project-restructure

Simplification/restructure
This commit is contained in:
Donny Blaine 2024-08-31 12:56:22 -05:00 committed by GitHub
commit a286d60ef5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 3173 additions and 2064 deletions

View file

@ -9,13 +9,48 @@ services:
ports: ports:
- '5432:5432' - '5432:5432'
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/data
redis: redis:
image: redis:latest image: redis:latest
ports: ports:
- '6379:6379' - '6379:6379'
volumes: volumes:
- redis_data:/data - redis_data:/data
minio:
image: docker.io/bitnami/minio
ports:
- '9000:9000'
- '9001:9001'
networks:
- minionetwork
volumes:
- 'minio_data:/data'
environment:
- MINIO_ROOT_USER=user
- MINIO_ROOT_PASSWORD=password
- MINIO_DEFAULT_BUCKETS=dev
mailpit:
image: axllent/mailpit
container_name: mailpit
restart: unless-stopped
volumes:
- mailpit_data:/data
ports:
- 8025:8025
- 1025:1025
environment:
MP_MAX_MESSAGES: 5000
MP_DATABASE: /data/mailpit.db
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
mailpit_data:
minio_data:
driver: local
networks:
minionetwork:
driver: bridge

View file

@ -1,8 +1,8 @@
import type { Config } from 'drizzle-kit'; import type { Config } from 'drizzle-kit';
export default { export default {
out: './src/lib/server/api/infrastructure/database/migrations', out: './src/lib/server/api/databases/migrations',
schema: './src/lib/server/api/infrastructure/database/tables/*.table.ts', schema: './src/lib/server/api/databases/tables/*.table.ts',
breakpoints: false, breakpoints: false,
strict: true, strict: true,
dialect: 'postgresql', dialect: 'postgresql',

View file

@ -20,76 +20,77 @@
"test:unit": "vitest" "test:unit": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.624.0",
"@hono/zod-validator": "^0.2.2", "@hono/zod-validator": "^0.2.2",
"@lucia-auth/adapter-drizzle": "^1.0.7", "@lucia-auth/adapter-drizzle": "^1.1.0",
"@node-rs/argon2": "^1.8.3",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.46.0",
"@sveltejs/adapter-auto": "^3.2.2", "@sveltejs/adapter-auto": "^3.2.2",
"@sveltejs/adapter-node": "^5.2.0", "@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.5.17", "@sveltejs/kit": "^2.5.20",
"@sveltejs/vite-plugin-svelte": "^3.1.1", "@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"@types/eslint": "^8.56.0", "@types/eslint": "^9.6.0",
"@types/node": "^20.14.8", "@types/node": "^22.1.0",
"@types/nodemailer": "^6.4.15", "@types/pluralize": "^0.0.33",
"@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^7.13.1", "@typescript-eslint/parser": "^8.0.1",
"arctic": "^1.9.1", "arctic": "^1.9.2",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.20",
"bullmq": "^5.8.3", "bullmq": "^5.12.1",
"dayjs": "^1.11.11", "chalk": "^5.3.0",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"drizzle-kit": "^0.21.4", "drizzle-kit": "^0.23.2",
"drizzle-orm": "^0.30.10", "drizzle-orm": "^0.32.2",
"eslint": "^8.56.0", "ejs": "^3.1.10",
"eslint": "^9.8.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.40.0", "eslint-plugin-svelte": "^2.43.0",
"handlebars": "^4.7.8", "glob": "^11.0.0",
"hono": "^4.4.7", "hono": "^4.5.4",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"lucia": "^3.2.0", "lucia": "^3.2.0",
"lucide-svelte": "^0.396.0", "lucide-svelte": "^0.424.0",
"nodemailer": "^6.9.14", "oslo": "^1.2.1",
"oslo": "^1.2.0",
"pg": "^8.12.0", "pg": "^8.12.0",
"postcss": "^8.4.38", "postcss": "^8.4.41",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"prettier": "^3.3.2", "prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.5", "prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.4", "prettier-plugin-tailwindcss": "^0.6.5",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"svelte": "5.0.0-next.164", "svelte": "5.0.0-next.164",
"svelte-check": "^3.8.1", "svelte-check": "^3.8.5",
"svelte-eslint-parser": "^0.36.0", "svelte-dnd-action": "^0.9.49",
"sveltekit-superforms": "^2.15.1", "svelte-eslint-parser": "^0.41.0",
"tailwindcss": "^3.4.4", "sveltekit-search-params": "^3.0.0",
"sveltekit-superforms": "^2.16.1",
"tailwindcss": "^3.4.7",
"tslib": "^2.6.3", "tslib": "^2.6.3",
"tsx": "^4.15.7", "tsx": "^4.16.5",
"tsyringe": "^4.8.0", "tsyringe": "^4.8.0",
"typescript": "^5.5.2", "typescript": "^5.5.4",
"vite": "^5.3.1", "vite": "^5.3.5",
"vite-plugin-full-reload": "^1.1.0", "vitest": "^2.0.5",
"vite-plugin-restart": "^0.4.0",
"vitest": "^1.6.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@internationalized/date": "^3.5.4", "@internationalized/date": "^3.5.5",
"@node-rs/argon2": "^1.8.3", "bits-ui": "^0.21.13",
"bits-ui": "^0.21.10",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk-sv": "^0.0.17", "cmdk-sv": "^0.0.18",
"embla-carousel-svelte": "^8.1.5", "embla-carousel-svelte": "^8.1.8",
"formsnap": "^1.0.1", "formsnap": "^1.0.1",
"hono-rate-limiter": "^0.3.0", "hono-rate-limiter": "^0.4.0",
"mode-watcher": "^0.3.1", "mode-watcher": "^0.4.1",
"paneforge": "^0.0.5", "paneforge": "^0.0.5",
"rate-limit-redis": "^4.2.0", "rate-limit-redis": "^4.2.0",
"resend": "^3.3.0", "resend": "^3.5.0",
"svelte-sonner": "^0.3.24", "svelte-sonner": "^0.3.27",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.4.0",
"tailwind-variants": "^0.2.1", "tailwind-variants": "^0.2.1",
"vaul-svelte": "^0.3.1" "vaul-svelte": "^0.3.2"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@ services:
envVars: envVars:
- key: DATABASE_URL - key: DATABASE_URL
fromDatabase: fromDatabase:
name: tarostack name: tofustack
property: connectionString property: connectionString
- key: REDIS_URL - key: REDIS_URL
fromService: fromService:
@ -29,5 +29,5 @@ services:
databases: databases:
- name: db - name: db
databaseName: tarostack databaseName: tofustack
ipAllowList: [] ipAllowList: []

View file

@ -0,0 +1,11 @@
import { Hono } from "hono";
import type { HonoTypes } from "../types/hono.type";
import type { BlankSchema, Env, Schema } from "hono/types";
export abstract class Controler {
protected readonly controller: Hono<HonoTypes, BlankSchema, '/'>;
constructor() {
this.controller = new Hono();
}
abstract routes(): Hono<HonoTypes, BlankSchema, '/'>;
}

View file

@ -1,3 +0,0 @@
import * as envs from '$env/static/private';
export const config = { ...envs, isProduction: process.env.NODE_ENV === 'production' };

View file

@ -0,0 +1,7 @@
import { Hono } from 'hono';
import type { BlankSchema } from 'hono/types';
import type { HonoTypes } from '../types/hono.type';
// export interface Controller {
// routes()
// }

View file

@ -0,0 +1,5 @@
import type { DatabaseProvider } from "../../providers/database.provider";
export interface Repository {
trxHost(trx: DatabaseProvider): any;
}

View file

@ -2,13 +2,13 @@ import type { Promisify, RateLimitInfo } from 'hono-rate-limiter';
import type { Session, User } from 'lucia'; import type { Session, User } from 'lucia';
export type HonoTypes = { export type HonoTypes = {
Variables: { Variables: {
session: Session | null; session: Session | null;
user: User | null; user: User | null;
rateLimit: RateLimitInfo; rateLimit: RateLimitInfo;
rateLimitStore: { rateLimitStore: {
getKey?: (key: string) => Promisify<RateLimitInfo | undefined>; getKey?: (key: string) => Promisify<RateLimitInfo | undefined>;
resetKey: (key: string) => Promisify<void>; resetKey: (key: string) => Promisify<void>;
}; };
}; };
}; };

View file

@ -0,0 +1,14 @@
import { HTTPException } from "hono/http-exception";
export const takeFirst = <T>(values: T[]): T | null => {
if (values.length === 0) return null;
return values[0]!;
};
export const takeFirstOrThrow = <T>(values: T[]): T => {
if (values.length === 0)
throw new HTTPException(404, {
message: 'Resource not found'
});
return values[0]!;
};

View file

@ -0,0 +1,29 @@
import { timestamp } from 'drizzle-orm/pg-core';
import { customType } from 'drizzle-orm/pg-core';
export const citext = customType<{ data: string }>({
dataType() {
return 'citext';
}
});
export const cuid2 = customType<{ data: string }>({
dataType() {
return 'text';
}
});
export const timestamps = {
createdAt: timestamp('created_at', {
mode: 'date',
withTimezone: true
})
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', {
mode: 'date',
withTimezone: true
})
.notNull()
.defaultNow()
};

View file

@ -0,0 +1,3 @@
import * as envs from '$env/static/private';
export const env = { ...envs, isProduction: process.env.NODE_ENV === 'production' };

View file

@ -1,53 +1,25 @@
import { Hono } from 'hono'; import { Hono, type Schema } from 'hono';
import { setCookie } from 'hono/cookie'; import { setCookie } from 'hono/cookie';
import type { HonoTypes } from '../types';
import { inject, injectable } from 'tsyringe'; import { inject, injectable } from 'tsyringe';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { IamService } from '../services/iam.service'; import { IamService } from '../services/iam.service';
import { LuciaProvider } from '../providers/lucia.provider'; import { LuciaProvider } from '../providers/lucia.provider';
import { requireAuth } from '../middleware/auth.middleware';
import { limiter } from '../middleware/rate-limiter.middlware';
import { signInEmailDto } from '../../../dtos/signin-email.dto'; import { signInEmailDto } from '../../../dtos/signin-email.dto';
import { updateEmailDto } from '../../../dtos/update-email.dto'; import { updateEmailDto } from '../../../dtos/update-email.dto';
import { verifyEmailDto } from '../../../dtos/verify-email.dto'; import { verifyEmailDto } from '../../../dtos/verify-email.dto';
import { registerEmailDto } from '../../../dtos/register-email.dto'; import { registerEmailDto } from '../../../dtos/register-email.dto';
import type { Controller } from '../interfaces/controller.interface'; import { limiter } from '../middlewares/rate-limiter.middlware';
import { EmailVerificationsService } from '../services/email-verifications.service'; import { requireAuth } from '../middlewares/auth.middleware';
import { LoginRequestsService } from '../services/login-requests.service'; import { Controler } from '../common/classes/controller.class';
/* -------------------------------------------------------------------------- */
/* Controller */
/* -------------------------------------------------------------------------- */
/* -------------------------------------------------------------------------- */
/* ---------------------------------- About --------------------------------- */
/*
Controllers are responsible for handling incoming requests and returning responses
to a client.
*/
/* ---------------------------------- Notes --------------------------------- */
/*
A controller should generally only handle routing and authorization through
middleware.
Any business logic should be delegated to a service. This keeps the controller
clean and easy to read.
*/
/* -------------------------------- Important ------------------------------- */
/*
Remember to register your controller in the api/index.ts file.
*/
/* -------------------------------------------------------------------------- */
@injectable() @injectable()
export class IamController implements Controller { export class IamController extends Controler {
controller = new Hono<HonoTypes>();
constructor( constructor(
@inject(IamService) private iamService: IamService, @inject(IamService) private iamService: IamService,
@inject(LoginRequestsService) private loginRequestsService: LoginRequestsService, @inject(LuciaProvider) private lucia: LuciaProvider,
@inject(EmailVerificationsService) private emailVerificationsService: EmailVerificationsService, ) {
@inject(LuciaProvider) private lucia: LuciaProvider super();
) { } }
routes() { routes() {
return this.controller return this.controller
@ -57,12 +29,12 @@ export class IamController implements Controller {
}) })
.post('/login/request', zValidator('json', registerEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => { .post('/login/request', zValidator('json', registerEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const { email } = c.req.valid('json'); const { email } = c.req.valid('json');
await this.loginRequestsService.create({ email }); await this.iamService.createLoginRequest({ email });
return c.json({ message: 'Verification email sent' }); return c.json({ message: 'Verification email sent' });
}) })
.post('/login/verify', zValidator('json', signInEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => { .post('/login/verify', zValidator('json', signInEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const { email, token } = c.req.valid('json'); const { email, token } = c.req.valid('json');
const session = await this.loginRequestsService.verify({ email, token }); const session = await this.iamService.verifyLoginRequest({ email, token });
const sessionCookie = this.lucia.createSessionCookie(session.id); const sessionCookie = this.lucia.createSessionCookie(session.id);
setCookie(c, sessionCookie.name, sessionCookie.value, { setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path, path: sessionCookie.attributes.path,
@ -92,14 +64,14 @@ export class IamController implements Controller {
}) })
.patch('/email', requireAuth, zValidator('json', updateEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => { .patch('/email', requireAuth, zValidator('json', updateEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const json = c.req.valid('json'); const json = c.req.valid('json');
await this.emailVerificationsService.dispatchEmailVerificationRequest(c.var.user.id, json.email); await this.iamService.dispatchEmailVerificationRequest(c.var.user.id, json.email);
return c.json({ message: 'Verification email sent' }); return c.json({ message: 'Verification email sent' });
}) })
// this could also be named to use custom methods, aka /email:verify // this could also be named to use custom methods, aka /email#verify
// https://cloud.google.com/apis/design/custom_methods // https://cloud.google.com/apis/design/custom_methods
.post('/email/verification', requireAuth, zValidator('json', verifyEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => { .post('/email/verification', requireAuth, zValidator('json', verifyEmailDto), limiter({ limit: 10, minutes: 60 }), async (c) => {
const json = c.req.valid('json'); const json = c.req.valid('json');
await this.emailVerificationsService.processEmailVerificationRequest(c.var.user.id, json.token); await this.iamService.processEmailVerificationRequest(c.var.user.id, json.token);
return c.json({ message: 'Verified and updated' }); return c.json({ message: 'Verified and updated' });
}); });
} }

View file

@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS "email_verifications" (
"updated_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "email_verifications_user_id_unique" UNIQUE("user_id") CONSTRAINT "email_verifications_user_id_unique" UNIQUE("user_id")
); );
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "login_requests" ( CREATE TABLE IF NOT EXISTS "login_requests" (
"id" text PRIMARY KEY NOT NULL, "id" text PRIMARY KEY NOT NULL,
"hashed_token" text NOT NULL, "hashed_token" text NOT NULL,
@ -20,13 +20,13 @@ CREATE TABLE IF NOT EXISTS "login_requests" (
"updated_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "login_requests_email_unique" UNIQUE("email") CONSTRAINT "login_requests_email_unique" UNIQUE("email")
); );
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "sessions" ( CREATE TABLE IF NOT EXISTS "sessions" (
"id" text PRIMARY KEY NOT NULL, "id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL, "user_id" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL "expires_at" timestamp with time zone NOT NULL
); );
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" ( CREATE TABLE IF NOT EXISTS "users" (
"id" text PRIMARY KEY NOT NULL, "id" text PRIMARY KEY NOT NULL,
"avatar" text, "avatar" text,
@ -36,13 +36,13 @@ CREATE TABLE IF NOT EXISTS "users" (
"updated_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email") CONSTRAINT "users_email_unique" UNIQUE("email")
); );
--> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "email_verifications" ADD CONSTRAINT "email_verifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; ALTER TABLE "email_verifications" ADD CONSTRAINT "email_verifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;
--> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION EXCEPTION

View file

@ -1,7 +1,7 @@
{ {
"id": "2fdb0575-b4b3-4ebb-9ca0-73a655a7fbe7", "id": "7c8066fe-53c3-4fbb-b700-34ae02d25480",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "6", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"tables": { "tables": {
"public.email_verifications": { "public.email_verifications": {
@ -238,6 +238,7 @@
}, },
"enums": {}, "enums": {},
"schemas": {}, "schemas": {},
"sequences": {},
"_meta": { "_meta": {
"columns": {}, "columns": {},
"schemas": {}, "schemas": {},

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1722983304054,
"tag": "0000_clear_paper_doll",
"breakpoints": true
}
]
}

View file

@ -1,8 +1,8 @@
import { createId } from '@paralleldrive/cuid2'; import { createId } from '@paralleldrive/cuid2';
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'; import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { timestamps } from '../utils';
import { relations } from 'drizzle-orm'; import { relations } from 'drizzle-orm';
import { usersTable } from './users.table'; import { usersTable } from './users.table';
import { timestamps } from '../../common/utils/table.utils';
export const emailVerificationsTable = pgTable('email_verifications', { export const emailVerificationsTable = pgTable('email_verifications', {
id: text('id') id: text('id')

View file

@ -0,0 +1,12 @@
import { bigint, pgTable, text } from "drizzle-orm/pg-core";
import { cuid2, timestamps } from "../../common/utils/table.utils";
import { createId } from "@paralleldrive/cuid2";
export const filesTable = pgTable('files', {
id: cuid2('id').primaryKey().$defaultFn(() => createId()),
key: text('key').notNull(),
size: bigint('size', { mode: 'bigint' }).notNull(),
contentType: text('content_type').notNull(),
...timestamps
});

View file

@ -1,7 +1,7 @@
import { timestamps } from '../utils';
import { relations } from 'drizzle-orm'; import { relations } from 'drizzle-orm';
import { createId } from '@paralleldrive/cuid2'; import { createId } from '@paralleldrive/cuid2';
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'; import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { timestamps } from '../../common/utils/table.utils';
export const loginRequestsTable = pgTable('login_requests', { export const loginRequestsTable = pgTable('login_requests', {
id: text('id') id: text('id')

View file

@ -1,4 +1,4 @@
import { cuid2 } from '../utils'; import { cuid2 } from '../../common/utils/table.utils';
import { usersTable } from './users.table'; import { usersTable } from './users.table';
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'; import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';

View file

@ -1,15 +1,17 @@
import { relations } from 'drizzle-orm'; import { relations } from 'drizzle-orm';;
import { citext, timestamps } from '../utils';
import { createId } from '@paralleldrive/cuid2'; import { createId } from '@paralleldrive/cuid2';
import { sessionsTable } from './sessions.table'; import { sessionsTable } from './sessions.table';
import { boolean, pgTable, text } from 'drizzle-orm/pg-core'; import { boolean, pgTable, text } from 'drizzle-orm/pg-core';
import { emailVerificationsTable } from './email-verifications.table'; import { emailVerificationsTable } from './email-verifications.table';
import { citext, cuid2, timestamps } from '../../common/utils/table.utils';
import { filesTable } from './files.table';
export const usersTable = pgTable('users', { export const usersTable = pgTable('users', {
id: text('id') id: text('id')
.primaryKey() .primaryKey()
.$defaultFn(() => createId()), .$defaultFn(() => createId()),
avatar: text('avatar'), avatarId: cuid2('avatar').references(() => filesTable.id),
email: citext('email').notNull().unique(), email: citext('email').notNull().unique(),
verified: boolean('verified').notNull().default(false), verified: boolean('verified').notNull().default(false),
...timestamps ...timestamps
@ -17,6 +19,10 @@ export const usersTable = pgTable('users', {
export const usersRelations = relations(usersTable, ({ many, one }) => ({ export const usersRelations = relations(usersTable, ({ many, one }) => ({
sessions: many(sessionsTable), sessions: many(sessionsTable),
avatar: one(filesTable, {
fields: [usersTable.avatarId],
references: [filesTable.id]
}),
emailVerifications: one(emailVerificationsTable, { emailVerifications: one(emailVerificationsTable, {
fields: [usersTable.id], fields: [usersTable.id],
references: [emailVerificationsTable.userId] references: [emailVerificationsTable.userId]

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
export const registerEmailDto = z.object({
email: z.string().email()
});
export type RegisterEmailDto = z.infer<typeof registerEmailDto>;

View file

@ -0,0 +1,8 @@
import { z } from 'zod';
export const signInEmailDto = z.object({
email: z.string().email(),
token: z.string()
});
export type SignInEmailDto = z.infer<typeof signInEmailDto>;

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
export const updateEmailDto = z.object({
email: z.string().email()
});
export type UpdateEmailDto = z.infer<typeof updateEmailDto>;

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
export const verifyEmailDto = z.object({
token: z.string()
});
export type VerifyEmailDto = z.infer<typeof verifyEmailDto>;

View file

@ -1,4 +1,4 @@
import type { Email } from "../interfaces/email.interface"; import type { Email } from "../common/inferfaces/email.interface"
export class EmailChangeNoticeEmail implements Email { export class EmailChangeNoticeEmail implements Email {
constructor() { } constructor() { }

View file

@ -1,4 +1,4 @@
import type { Email } from "../interfaces/email.interface"; import type { Email } from "../common/inferfaces/email.interface"
export class LoginVerificationEmail implements Email { export class LoginVerificationEmail implements Email {
constructor(private readonly token: string) { } constructor(private readonly token: string) { }

View file

@ -1,4 +1,4 @@
import type { Email } from '../interfaces/email.interface'; import type { Email } from "../common/inferfaces/email.interface";
export class WelcomeEmail implements Email { export class WelcomeEmail implements Email {
constructor() { } constructor() { }

View file

@ -1,45 +1,41 @@
import 'reflect-metadata'; import 'reflect-metadata';
import './providers';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { hc } from 'hono/client'; import { hc } from 'hono/client';
import { container } from 'tsyringe'; import { container } from 'tsyringe';
import { validateAuthSession, verifyOrigin } from './middleware/auth.middleware';
import { IamController } from './controllers/iam.controller'; import { IamController } from './controllers/iam.controller';
import { config } from './common/config'; import { env } from './configs/envs.config';
import { validateAuthSession, verifyOrigin } from './middlewares/auth.middleware';
import { AuthCleanupJobs } from './jobs/auth-cleanup.job';
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Client Request */ /* App */
/* ------------------------------------ ▲ ----------------------------------- */
/* ------------------------------------ | ----------------------------------- */
/* ------------------------------------ ▼ ----------------------------------- */
/* Controller */
/* ---------------------------- (Request Routing) --------------------------- */
/* ------------------------------------ ▲ ----------------------------------- */
/* ------------------------------------ | ----------------------------------- */
/* ------------------------------------ ▼ ----------------------------------- */
/* Service */
/* ---------------------------- (Business logic) ---------------------------- */
/* ------------------------------------ ▲ ----------------------------------- */
/* ------------------------------------ | ----------------------------------- */
/* ------------------------------------ ▼ ----------------------------------- */
/* Repository */
/* ----------------------------- (Data storage) ----------------------------- */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
export const app = new Hono().basePath('/api');
/* ----------------------------------- Api ---------------------------------- */ /* -------------------------------------------------------------------------- */
const app = new Hono().basePath('/api'); /* Global Middlewares */
/* -------------------------------------------------------------------------- */
/* --------------------------- Global Middlewares --------------------------- */
app.use(verifyOrigin).use(validateAuthSession); app.use(verifyOrigin).use(validateAuthSession);
/* --------------------------------- Routes --------------------------------- */ /* -------------------------------------------------------------------------- */
/* Routes */
/* -------------------------------------------------------------------------- */
const routes = app const routes = app
.route('/iam', container.resolve(IamController).routes()) .route('/iam', container.resolve(IamController).routes())
/* -------------------------------------------------------------------------- */
/* Cron Jobs */
/* -------------------------------------------------------------------------- */
container.resolve(AuthCleanupJobs).deleteStaleEmailVerificationRequests();
container.resolve(AuthCleanupJobs).deleteStaleLoginRequests();
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Exports */ /* Exports */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
export const rpc = hc<typeof routes>(config.ORIGIN); const rpc = hc<typeof routes>(env.ORIGIN);
export type ApiClient = typeof rpc; export type ApiClient = typeof rpc;
export type ApiRoutes = typeof routes; export type ApiRoutes = typeof routes;
export { app };

View file

@ -1,43 +0,0 @@
import { Lucia } from 'lucia';
import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle';
import { Discord } from 'arctic';
import { sessionsTable, usersTable } from '../database/tables';
import { db } from '../database';
import { config } from '../../common/config';
const adapter = new DrizzlePostgreSQLAdapter(db, sessionsTable, usersTable);
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
// set to `true` when using HTTPS
secure: config.isProduction
}
},
getUserAttributes: (attributes) => {
return {
// attributes has the type of DatabaseUserAttributes
...attributes
};
}
});
export const discord = new Discord(
config.DISCORD_CLIENT_ID!,
config.DISCORD_CLIENT_SECRET!,
`${config.ORIGIN}/api/iam/discord/callback`
);
interface DatabaseUserAttributes {
id: string;
email: string;
avatar: string | null;
verified: boolean;
}
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}

View file

@ -1,7 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { config } from '../../common/config';
import * as schema from './tables';
export const client = postgres(config.DATABASE_URL!, { max: 1 });
export const db = drizzle(client, { schema });

View file

@ -1,13 +0,0 @@
{
"version": "6",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1719512747861,
"tag": "0000_nostalgic_skrulls",
"breakpoints": false
}
]
}

View file

@ -1,43 +0,0 @@
import { HTTPException } from 'hono/http-exception';
import { timestamp } from 'drizzle-orm/pg-core';
import { customType } from 'drizzle-orm/pg-core';
export const citext = customType<{ data: string }>({
dataType() {
return 'citext';
}
});
export const cuid2 = customType<{ data: string }>({
dataType() {
return 'text';
}
});
export const takeFirst = <T>(values: T[]): T | null => {
if (values.length === 0) return null;
return values[0]!;
};
export const takeFirstOrThrow = <T>(values: T[]): T => {
if (values.length === 0)
throw new HTTPException(404, {
message: 'Resource not found'
});
return values[0]!;
};
export const timestamps = {
createdAt: timestamp('created_at', {
mode: 'date',
withTimezone: true
})
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', {
mode: 'date',
withTimezone: true
})
.notNull()
.defaultNow()
};

View file

@ -1,18 +0,0 @@
<html lang='en'>
<head>
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Email Change Request</title>
</head>
<body>
<p class='title'>Email address change notice </p>
<p>
An update to your email address has been requested. If this is unexpected or you did not perform this action, please login and secure your account.</p>
</body>
<style>
.title { font-size: 24px; font-weight: 700; } .token-text { font-size: 24px; font-weight: 700; margin-top: 8px; }
.token-title { font-size: 18px; font-weight: 700; margin-bottom: 0px; }
.center { display: flex; justify-content: center; align-items: center; flex-direction: column;}
.token-subtext { font-size: 12px; margin-top: 0px; }
</style>
</html>

View file

@ -1,25 +0,0 @@
<html lang='en'>
<head>
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Message</title>
</head>
<body>
<p class='title'>Verify your email address</p>
<p>
Thanks for using example.com. We want to make sure it's really you. Please enter the following
verification code when prompted. If you don't have an exmaple.com an account, you can ignore
this message.</p>
<div class='center'>
<p class="token-title">Verification Code</p>
<p class='token-text'>{{token}}</p>
<p class='token-subtext'>(This code is valid for 15 minutes)</p>
</div>
</body>
<style>
.title { font-size: 24px; font-weight: 700; } .token-text { font-size: 24px; font-weight: 700; margin-top: 8px; }
.token-title { font-size: 18px; font-weight: 700; margin-bottom: 0px; }
.center { display: flex; justify-content: center; align-items: center; flex-direction: column;}
.token-subtext { font-size: 12px; margin-top: 0px; }
</style>
</html>

View file

@ -1,20 +0,0 @@
<html lang='en'>
<head>
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Message</title>
</head>
<body>
<p class='title'>Welcome to Example</p>
<p>
Thanks for using example.com. We want to make sure it's really you. Please enter the following
verification code when prompted. If you don't have an exmaple.com an account, you can ignore
this message.</p>
</body>
<style>
.title { font-size: 24px; font-weight: 700; } .token-text { font-size: 24px; font-weight: 700; margin-top: 8px; }
.token-title { font-size: 18px; font-weight: 700; margin-bottom: 0px; }
.center { display: flex; justify-content: center; align-items: center; flex-direction: column;}
.token-subtext { font-size: 12px; margin-top: 0px; }
</style>
</html>

View file

@ -1,8 +0,0 @@
import { Hono } from 'hono';
import type { HonoTypes } from '../types';
import type { BlankSchema } from 'hono/types';
export interface Controller {
controller: Hono<HonoTypes, BlankSchema, '/'>;
routes(): any;
}

View file

@ -1,5 +0,0 @@
import type { DatabaseProvider } from '../providers';
export interface Repository {
trxHost(trx: DatabaseProvider): any;
}

View file

@ -0,0 +1,44 @@
import { inject, injectable } from "tsyringe";
import { JobsService } from "../services/jobs.service";
@injectable()
export class AuthCleanupJobs {
private queue;
constructor(
@inject(JobsService) private jobsService: JobsService,
) {
/* ------------------------------ Create Queue ------------------------------ */
this.queue = this.jobsService.createQueue('test')
/* ---------------------------- Register Workers ---------------------------- */
this.worker();
}
async deleteStaleEmailVerificationRequests() {
this.queue.add('delete_stale_email_verifiactions', null, {
repeat: {
pattern: '0 0 * * 0' // Runs once a week at midnight on Sunday
}
})
}
async deleteStaleLoginRequests() {
this.queue.add('delete_stale_login_requests', null, {
repeat: {
pattern: '0 0 * * 0' // Runs once a week at midnight on Sunday
}
})
}
private async worker() {
return this.jobsService.createWorker(this.queue.name, async (job) => {
if (job.name === "delete_stale_email_verifiactions") {
// delete stale email verifications
}
if (job.name === "delete_stale_login_requests") {
// delete stale email verifications
}
})
}
}

View file

@ -1,10 +1,10 @@
import type { MiddlewareHandler } from 'hono'; import type { MiddlewareHandler } from 'hono';
import { createMiddleware } from 'hono/factory'; import { createMiddleware } from 'hono/factory';
import type { HonoTypes } from '../types';
import { lucia } from '../infrastructure/auth/lucia';
import { verifyRequestOrigin } from 'lucia'; import { verifyRequestOrigin } from 'lucia';
import type { Session, User } from 'lucia'; import type { Session, User } from 'lucia';
import { Unauthorized } from '../common/errors'; import { Unauthorized } from '../common/exceptions';
import type { HonoTypes } from '../common/types/hono.type';
import { lucia } from '../packages/lucia';
export const verifyOrigin: MiddlewareHandler<HonoTypes> = createMiddleware(async (c, next) => { export const verifyOrigin: MiddlewareHandler<HonoTypes> = createMiddleware(async (c, next) => {
if (c.req.method === "GET") { if (c.req.method === "GET") {

View file

@ -1,10 +1,10 @@
import { rateLimiter } from "hono-rate-limiter"; import { rateLimiter } from "hono-rate-limiter";
import { RedisStore } from 'rate-limit-redis' import { RedisStore } from 'rate-limit-redis'
import RedisClient from 'ioredis' import RedisClient from 'ioredis'
import type { HonoTypes } from "../types"; import { env } from "../configs/envs.config";
import { config } from "../common/config"; import type { HonoTypes } from "../common/types/hono.type";
const client = new RedisClient(config.REDIS_URL) const client = new RedisClient(env.REDIS_URL)
export function limiter({ limit, minutes, key = "" }: { export function limiter({ limit, minutes, key = "" }: {
limit: number; limit: number;

View file

@ -1,11 +0,0 @@
import { Scrypt } from "oslo/password";
export async function hash(value: string) {
const scrypt = new Scrypt()
return scrypt.hash(value);
}
export function verify(hashedValue: string, value: string) {
return new Scrypt().verify(hashedValue, value);
}

View file

@ -0,0 +1,7 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '../databases/tables';
import { env } from '../configs/envs.config';
const client = postgres(env.DATABASE_URL!, { max: 1 });
export const db = drizzle(client, { schema });

View file

@ -0,0 +1,22 @@
import { Lucia } from 'lucia';
import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle';
import { sessionsTable, usersTable } from '../databases/tables';
import { env } from '../configs/envs.config';
import { db } from './drizzle';
const adapter = new DrizzlePostgreSQLAdapter(db, sessionsTable, usersTable);
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
// set to `true` when using HTTPS
secure: env.isProduction
}
},
getUserAttributes: (attributes) => {
return {
// attributes has the type of DatabaseUserAttributes
...attributes
};
}
});

View file

@ -0,0 +1,12 @@
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '$env/dynamic/private';
export const s3Client = new S3Client({
region: 'auto',
endpoint: env.STORAGE_API_URL,
credentials: {
accessKeyId: env.STORAGE_API_ACCESS_KEY,
secretAccessKey: env.STORAGE_API_SECRET_KEY
},
forcePathStyle: true
})

View file

@ -1,11 +1,6 @@
import { container } from 'tsyringe'; import { container } from 'tsyringe';
import { db } from '../infrastructure/database'; import { db } from '../packages/drizzle';
// Symbol
export const DatabaseProvider = Symbol('DATABASE_TOKEN'); export const DatabaseProvider = Symbol('DATABASE_TOKEN');
// Type
export type DatabaseProvider = typeof db; export type DatabaseProvider = typeof db;
// Register
container.register<DatabaseProvider>(DatabaseProvider, { useValue: db }); container.register<DatabaseProvider>(DatabaseProvider, { useValue: db });

View file

@ -1,3 +0,0 @@
export * from './database.provider';
export * from './lucia.provider';
export * from './redis.provider';

View file

@ -1,11 +1,6 @@
import { container } from 'tsyringe'; import { container } from 'tsyringe';
import { lucia } from '../infrastructure/auth/lucia'; import { lucia } from '../packages/lucia';
// Symbol
export const LuciaProvider = Symbol('LUCIA_PROVIDER'); export const LuciaProvider = Symbol('LUCIA_PROVIDER');
// Type
export type LuciaProvider = typeof lucia; export type LuciaProvider = typeof lucia;
// Register
container.register<LuciaProvider>(LuciaProvider, { useValue: lucia }); container.register<LuciaProvider>(LuciaProvider, { useValue: lucia });

View file

@ -1,14 +1,11 @@
import { container } from 'tsyringe'; import { container } from 'tsyringe';
import { env } from '../configs/envs.config';
import RedisClient from 'ioredis' import RedisClient from 'ioredis'
import { config } from '../common/config';
// Symbol
export const RedisProvider = Symbol('REDIS_TOKEN'); export const RedisProvider = Symbol('REDIS_TOKEN');
// Type
export type RedisProvider = RedisClient; export type RedisProvider = RedisClient;
// Register
container.register<RedisProvider>(RedisProvider, { container.register<RedisProvider>(RedisProvider, {
useValue: new RedisClient(config.REDIS_URL) useValue: new RedisClient(env.REDIS_URL, {
maxRetriesPerRequest: null
})
}); });

View file

@ -0,0 +1,9 @@
import { container } from 'tsyringe';
import { S3Client } from '@aws-sdk/client-s3';
import { s3Client } from '../packages/s3';
export const S3ClientProvider = Symbol('STORAGE_TOKEN');
export type S3ClientProvider = S3Client;
container.register<S3ClientProvider>(S3ClientProvider, {
useValue: s3Client
});

View file

View file

@ -1,9 +1,10 @@
import { inject, injectable } from "tsyringe"; import { inject, injectable } from "tsyringe";
import { DatabaseProvider } from "../providers";
import { and, eq, gte, lte, type InferInsertModel } from "drizzle-orm"; import { and, eq, gte, lte, type InferInsertModel } from "drizzle-orm";
import type { Repository } from "../interfaces/repository.interface"; import { emailVerificationsTable } from "../databases/tables";
import { takeFirst, takeFirstOrThrow } from "../infrastructure/database/utils"; import type { Repository } from "../common/inferfaces/repository.interface";
import { emailVerificationsTable } from "../infrastructure/database/tables/email-verifications.table"; import { DatabaseProvider } from "../providers/database.provider";
import { takeFirst, takeFirstOrThrow } from "../common/utils/repository.utils";
export type CreateEmailVerification = Pick<InferInsertModel<typeof emailVerificationsTable>, 'requestedEmail' | 'hashedToken' | 'userId' | 'expiresAt'>; export type CreateEmailVerification = Pick<InferInsertModel<typeof emailVerificationsTable>, 'requestedEmail' | 'hashedToken' | 'userId' | 'expiresAt'>;

View file

@ -0,0 +1,41 @@
import { inject } from "tsyringe";
import { StorageService } from "../services/storage.service";
import { DatabaseProvider } from "../providers/database.provider";
import { eq } from "drizzle-orm";
import { filesTable } from "../databases/tables/files.table";
import { takeFirst, takeFirstOrThrow } from "../common/utils/repository.utils";
export class FilesRepository {
constructor(
@inject(StorageService) private readonly storageService: StorageService,
@inject(DatabaseProvider) private readonly db: DatabaseProvider) { }
async create(file: File, db = this.db) {
const asset = await this.storageService.upload(file);
return db.insert(filesTable).values({ key: asset.key, contentType: asset.type, size: BigInt(asset.size) }).returning().then(takeFirst)
}
async findOneById(id: string, db = this.db) {
return db.select().from(filesTable).where(eq(filesTable.id, id)).then(takeFirst)
}
async findOneByIdOrThrow(id: string, db = this.db) {
return db.select().from(filesTable).where(eq(filesTable.id, id)).then(takeFirstOrThrow)
}
async update(id: string, file: File, db = this.db) {
// upload new file
const newAsset = await this.storageService.upload(file);
await db.update(filesTable).set({ key: newAsset.key, contentType: newAsset.type, size: BigInt(newAsset.size) }).where(eq(filesTable.id, id))
// remove old file
const oldAsset = await this.findOneByIdOrThrow(id)
await this.storageService.delete(oldAsset.key)
}
async delete(id: string, db = this.db) {
const asset = await this.findOneByIdOrThrow(id)
await this.storageService.delete(asset.key)
await db.delete(filesTable).where(eq(filesTable.id, id))
}
}

View file

@ -1,9 +1,10 @@
import { inject, injectable } from "tsyringe"; import { inject, injectable } from "tsyringe";
import { DatabaseProvider } from "../providers";
import type { Repository } from "../interfaces/repository.interface";
import { and, eq, gte, type InferInsertModel } from "drizzle-orm"; import { and, eq, gte, type InferInsertModel } from "drizzle-orm";
import { takeFirst, takeFirstOrThrow } from "../infrastructure/database/utils"; import { loginRequestsTable } from "../databases/tables";
import { loginRequestsTable } from "../infrastructure/database/tables/login-requests.table"; import type { Repository } from "../common/inferfaces/repository.interface";
import { DatabaseProvider } from "../providers/database.provider";
import { takeFirst, takeFirstOrThrow } from "../common/utils/repository.utils";
export type CreateLoginRequest = Pick<InferInsertModel<typeof loginRequestsTable>, 'email' | 'expiresAt' | 'hashedToken'>; export type CreateLoginRequest = Pick<InferInsertModel<typeof loginRequestsTable>, 'email' | 'expiresAt' | 'hashedToken'>;

View file

@ -1,25 +1,9 @@
import { inject, injectable } from 'tsyringe'; import { inject, injectable } from 'tsyringe';
import type { Repository } from '../interfaces/repository.interface'; import { usersTable } from '../databases/tables';
import { DatabaseProvider } from '../providers';
import { eq, type InferInsertModel } from 'drizzle-orm'; import { eq, type InferInsertModel } from 'drizzle-orm';
import { usersTable } from '../infrastructure/database/tables/users.table'; import { DatabaseProvider } from '../providers/database.provider';
import { takeFirstOrThrow } from '../infrastructure/database/utils'; import { takeFirstOrThrow } from '../common/utils/repository.utils';
import type { Repository } from '../common/inferfaces/repository.interface';
/* -------------------------------------------------------------------------- */
/* Repository */
/* -------------------------------------------------------------------------- */
/* ---------------------------------- About --------------------------------- */
/*
Repositories are the layer that interacts with the database. They are responsible for retrieving and
storing data. They should not contain any business logic, only database queries.
*/
/* ---------------------------------- Notes --------------------------------- */
/*
Repositories should only contain methods for CRUD operations and any other database interactions.
Any complex logic should be delegated to a service. If a repository method requires a transaction,
it should be passed in as an argument or the class should have a method to set the transaction.
In our case the method 'trxHost' is used to set the transaction context.
*/
export type CreateUser = InferInsertModel<typeof usersTable>; export type CreateUser = InferInsertModel<typeof usersTable>;
export type UpdateUser = Partial<CreateUser>; export type UpdateUser = Partial<CreateUser>;

View file

@ -1,67 +0,0 @@
import { inject, injectable } from 'tsyringe';
import { BadRequest } from '../common/errors';
import { DatabaseProvider } from '../providers';
import { MailerService } from './mailer.service';
import { TokensService } from './tokens.service';
import { UsersRepository } from '../repositories/users.repository';
import { EmailVerificationsRepository } from '../repositories/email-verifications.repository';
@injectable()
export class EmailVerificationsService {
constructor(
@inject(DatabaseProvider) private readonly db: DatabaseProvider,
@inject(TokensService) private readonly tokensService: TokensService,
@inject(MailerService) private readonly mailerService: MailerService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository,
@inject(EmailVerificationsRepository) private readonly emailVerificationsRepository: EmailVerificationsRepository,
) { }
// These steps follow the process outlined in OWASP's "Changing A User's Email Address" guide.
// https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#changing-a-users-registered-email-address
async dispatchEmailVerificationRequest(userId: string, requestedEmail: string) {
// generate a token and expiry
const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm')
const user = await this.usersRepository.findOneByIdOrThrow(userId)
// create a new email verification record
await this.emailVerificationsRepository.create({ requestedEmail, userId, hashedToken, expiresAt: expiry })
// A confirmation-required email message to the proposed new address, instructing the user to
// confirm the change and providing a link for unexpected situations
this.mailerService.sendEmailVerificationToken({
to: requestedEmail,
props: {
token
}
})
// A notification-only email message to the current address, alerting the user to the impending change and
// providing a link for an unexpected situation.
this.mailerService.sendEmailChangeNotification({
to: user.email,
props: null
})
}
async processEmailVerificationRequest(userId: string, token: string) {
const validRecord = await this.findAndBurnEmailVerificationToken(userId, token)
if (!validRecord) throw BadRequest('Invalid token');
await this.usersRepository.update(userId, { email: validRecord.requestedEmail, verified: true });
}
private async findAndBurnEmailVerificationToken(userId: string, token: string) {
return this.db.transaction(async (trx) => {
// find a valid record
const emailVerificationRecord = await this.emailVerificationsRepository.trxHost(trx).findValidRecord(userId);
if (!emailVerificationRecord) return null;
// check if the token is valid
const isValidRecord = await this.tokensService.verifyHashedToken(emailVerificationRecord.hashedToken, token);
if (!isValidRecord) return null
// burn the token if it is valid
await this.emailVerificationsRepository.trxHost(trx).deleteById(emailVerificationRecord.id)
return emailVerificationRecord
})
}
}

View file

@ -1,36 +1,45 @@
import { scrypt } from "node:crypto";
import { decodeHex, encodeHex } from "oslo/encoding";
import { constantTimeEqual } from "oslo/crypto";
import { injectable } from "tsyringe"; import { injectable } from "tsyringe";
import { Scrypt } from "oslo/password";
/* ---------------------------------- Note ---------------------------------- */
/*
Reference: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
I use Scrpt as the hashing algorithm due to its higher compatability
with vite's build system and it uses less memory than Argon2id.
You can use Argon2id or any other hashing algorithm you prefer.
*/
/* -------------------------------------------------------------------------- */
/*
With Argon2id, you get the following error at times when vite optimizes its dependencies at times,
Error: Build failed with 2 errors:
node_modules/.pnpm/@node-rs+argon2@1.7.0/node_modules/@node-rs/argon2/index.js:159:36: ERROR: No loader is configured for ".node" files: node_module
*/
/* -------------------------------------------------------------------------- */
// If you don't use a hasher from oslo, which are preconfigured with recommended parameters from OWASP,
// ensure that you configure them properly.
@injectable() @injectable()
export class HashingService { export class HashingService {
private readonly hasher = new Scrypt(); N;
// private readonly hasher = new Argon2id(); // argon2id hasher r;
p;
dkLen;
async hash(data: string) { constructor() {
return this.hasher.hash(data); this.N = 16384;
this.r = 16;
this.p = 1;
this.dkLen = 64;
}
async hash(password: string) {
const salt = encodeHex(crypto.getRandomValues(new Uint8Array(16)));
const key = await this.generateKey(password, salt);
return `${salt}:${encodeHex(key)}`;
} }
async verify(hash: string, data: string) { async verify(hash: string, password: string) {
return this.hasher.verify(hash, data) const [salt, key] = hash.split(":");
const targetKey = await this.generateKey(password, salt);
return constantTimeEqual(targetKey, decodeHex(key));
}
async generateKey(password: string, salt: string): Promise<Buffer> {
return await new Promise((resolve, reject) => {
scrypt(password.normalize("NFKC"), salt, this.dkLen, {
N: this.N,
p: this.p,
r: this.r,
maxmem: 128 * this.N * this.r * 2
}, (err, buff) => {
if (err)
return reject(err);
return resolve(buff);
});
});
} }
} }

View file

@ -1,31 +1,128 @@
import { inject, injectable } from 'tsyringe'; import { inject, injectable } from 'tsyringe';
import { MailerService } from './mailer.service';
import { TokensService } from './tokens.service';
import { LuciaProvider } from '../providers/lucia.provider'; import { LuciaProvider } from '../providers/lucia.provider';
import { UsersRepository } from '../repositories/users.repository';
/* -------------------------------------------------------------------------- */ import type { SignInEmailDto } from '../dtos/signin-email.dto';
/* Service */ import type { RegisterEmailDto } from '../dtos/register-email.dto';
/* -------------------------------------------------------------------------- */ import { LoginRequestsRepository } from '../repositories/login-requests.repository';
/* -------------------------------------------------------------------------- */ import { LoginVerificationEmail } from '../emails/login-verification.email';
/* ---------------------------------- About --------------------------------- */ import { DatabaseProvider } from '../providers/database.provider';
/* import { BadRequest } from '../common/exceptions';
Services are responsible for handling business logic and data manipulation. import { WelcomeEmail } from '../emails/welcome.email';
They genreally call on repositories or other services to complete a use-case. import { EmailVerificationsRepository } from '../repositories/email-verifications.repository';
*/ import { EmailChangeNoticeEmail } from '../emails/email-change-notice.email';
/* ---------------------------------- Notes --------------------------------- */
/*
Services should be kept as clean and simple as possible.
Create private functions to handle complex logic and keep the public methods as
simple as possible. This makes the service easier to read, test and understand.
*/
/* -------------------------------------------------------------------------- */
@injectable() @injectable()
export class IamService { export class IamService {
constructor( constructor(
@inject(LuciaProvider) private readonly lucia: LuciaProvider, @inject(LuciaProvider) private readonly lucia: LuciaProvider,
@inject(DatabaseProvider) private readonly db: DatabaseProvider,
@inject(TokensService) private readonly tokensService: TokensService,
@inject(MailerService) private readonly mailerService: MailerService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository,
@inject(LoginRequestsRepository) private readonly loginRequestsRepository: LoginRequestsRepository,
@inject(EmailVerificationsRepository) private readonly emailVerificationsRepository: EmailVerificationsRepository,
) { } ) { }
async createLoginRequest(data: RegisterEmailDto) {
// generate a token, expiry date, and hash
const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm');
// save the login request to the database - ensuring we save the hashedToken
await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry });
// send the login request email
await this.mailerService.send({ email: new LoginVerificationEmail(token), to: data.email });
}
async verifyLoginRequest(data: SignInEmailDto) {
const validLoginRequest = await this.getValidLoginRequest(data.email, data.token);
if (!validLoginRequest) throw BadRequest('Invalid token');
let existingUser = await this.usersRepository.findOneByEmail(data.email);
if (!existingUser) {
const newUser = await this.handleNewUserRegistration(data.email);
return this.lucia.createSession(newUser.id, {});
}
return this.lucia.createSession(existingUser.id, {});
}
// These steps follow the process outlined in OWASP's "Changing A User's Email Address" guide.
// https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#changing-a-users-registered-email-address
async dispatchEmailVerificationRequest(userId: string, requestedEmail: string) {
// generate a token and expiry
const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm')
const user = await this.usersRepository.findOneByIdOrThrow(userId)
// create a new email verification record
await this.emailVerificationsRepository.create({ requestedEmail, userId, hashedToken, expiresAt: expiry })
// A confirmation-required email message to the proposed new address, instructing the user to
// confirm the change and providing a link for unexpected situations
this.mailerService.send({
to: requestedEmail,
email: new LoginVerificationEmail(token)
})
// A notification-only email message to the current address, alerting the user to the impending change and
// providing a link for an unexpected situation.
this.mailerService.send({
to: user.email,
email: new EmailChangeNoticeEmail()
})
}
async processEmailVerificationRequest(userId: string, token: string) {
const validRecord = await this.findAndBurnEmailVerificationToken(userId, token)
if (!validRecord) throw BadRequest('Invalid token');
await this.usersRepository.update(userId, { email: validRecord.requestedEmail, verified: true });
}
async logout(sessionId: string) { async logout(sessionId: string) {
return this.lucia.invalidateSession(sessionId); return this.lucia.invalidateSession(sessionId);
} }
// Create a new user and send a welcome email - or other onboarding process
private async handleNewUserRegistration(email: string) {
const newUser = await this.usersRepository.create({ email, verified: true })
await this.mailerService.send({ email: new WelcomeEmail(), to: newUser.email });
// TODO: add whatever onboarding process or extra data you need here
return newUser
}
// Fetch a valid request from the database, verify the token and burn the request if it is valid
private async getValidLoginRequest(email: string, token: string) {
return await this.db.transaction(async (trx) => {
// fetch the login request
const loginRequest = await this.loginRequestsRepository.trxHost(trx).findOneByEmail(email)
if (!loginRequest) return null;
// check if the token is valid
const isValidRequest = await this.tokensService.verifyHashedToken(loginRequest.hashedToken, token);
if (!isValidRequest) return null
// if the token is valid, burn the request
await this.loginRequestsRepository.trxHost(trx).deleteById(loginRequest.id);
return loginRequest
})
}
private async findAndBurnEmailVerificationToken(userId: string, token: string) {
return this.db.transaction(async (trx) => {
// find a valid record
const emailVerificationRecord = await this.emailVerificationsRepository.trxHost(trx).findValidRecord(userId);
if (!emailVerificationRecord) return null;
// check if the token is valid
const isValidRecord = await this.tokensService.verifyHashedToken(emailVerificationRecord.hashedToken, token);
if (!isValidRecord) return null
// burn the token if it is valid
await this.emailVerificationsRepository.trxHost(trx).deleteById(emailVerificationRecord.id)
return emailVerificationRecord
})
}
} }

View file

@ -0,0 +1,17 @@
import { inject, injectable } from "tsyringe";
import { Queue, Worker, type Processor } from 'bullmq';
import { RedisProvider } from "../providers/redis.provider";
@injectable()
export class JobsService {
constructor(@inject(RedisProvider) private readonly redis: RedisProvider) {
}
createQueue(name: string) {
return new Queue(name, { connection: this.redis })
}
createWorker(name: string, prcoessor: Processor) {
return new Worker(name, prcoessor, { connection: this.redis })
}
}

View file

@ -1,73 +0,0 @@
import { inject, injectable } from 'tsyringe';
import { BadRequest } from '../common/errors';
import { DatabaseProvider } from '../providers';
import { MailerService } from './mailer.service';
import { TokensService } from './tokens.service';
import { LuciaProvider } from '../providers/lucia.provider';
import { UsersRepository } from '../repositories/users.repository';
import type { SignInEmailDto } from '../../../dtos/signin-email.dto';
import type { RegisterEmailDto } from '../../../dtos/register-email.dto';
import { LoginRequestsRepository } from '../repositories/login-requests.repository';
@injectable()
export class LoginRequestsService {
constructor(
@inject(LuciaProvider) private readonly lucia: LuciaProvider,
@inject(DatabaseProvider) private readonly db: DatabaseProvider,
@inject(TokensService) private readonly tokensService: TokensService,
@inject(MailerService) private readonly mailerService: MailerService,
@inject(UsersRepository) private readonly usersRepository: UsersRepository,
@inject(LoginRequestsRepository) private readonly loginRequestsRepository: LoginRequestsRepository,
) { }
async create(data: RegisterEmailDto) {
// generate a token, expiry date, and hash
const { token, expiry, hashedToken } = await this.tokensService.generateTokenWithExpiryAndHash(15, 'm');
// save the login request to the database - ensuring we save the hashedToken
await this.loginRequestsRepository.create({ email: data.email, hashedToken, expiresAt: expiry });
// send the login request email
await this.mailerService.sendLoginRequest({
to: data.email,
props: { token: token }
});
}
async verify(data: SignInEmailDto) {
const validLoginRequest = await this.fetchValidRequest(data.email, data.token);
if (!validLoginRequest) throw BadRequest('Invalid token');
let existingUser = await this.usersRepository.findOneByEmail(data.email);
if (!existingUser) {
const newUser = await this.handleNewUserRegistration(data.email);
return this.lucia.createSession(newUser.id, {});
}
return this.lucia.createSession(existingUser.id, {});
}
// Create a new user and send a welcome email - or other onboarding process
private async handleNewUserRegistration(email: string) {
const newUser = await this.usersRepository.create({ email, verified: true, avatar: null })
this.mailerService.sendWelcome({ to: email, props: null });
// TODO: add whatever onboarding process or extra data you need here
return newUser
}
// Fetch a valid request from the database, verify the token and burn the request if it is valid
private async fetchValidRequest(email: string, token: string) {
return await this.db.transaction(async (trx) => {
// fetch the login request
const loginRequest = await this.loginRequestsRepository.trxHost(trx).findOneByEmail(email)
if (!loginRequest) return null;
// check if the token is valid
const isValidRequest = await this.tokensService.verifyHashedToken(loginRequest.hashedToken, token);
if (!isValidRequest) return null
// if the token is valid, burn the request
await this.loginRequestsRepository.trxHost(trx).deleteById(loginRequest.id);
return loginRequest
})
}
}

View file

@ -1,8 +1,6 @@
import nodemailer from 'nodemailer';
import { injectable } from 'tsyringe'; import { injectable } from 'tsyringe';
import type { Email } from '../interfaces/email.interface'; import { env } from '../configs/envs.config';
import { config } from '../common/config'; import type { Email } from '../common/inferfaces/email.interface';
type SendProps = { type SendProps = {
to: string | string[]; to: string | string[];
@ -11,34 +9,35 @@ type SendProps = {
@injectable() @injectable()
export class MailerService { export class MailerService {
async send(data: SendProps) {
const mailer = env.isProduction ? this.sendProd : this.sendDev;
await mailer(data);
}
private async sendDev({ to, email }: SendProps) { private async sendDev({ to, email }: SendProps) {
const message = await nodemailer.createTransport({ const options = {
host: 'smtp.ethereal.email', method: 'POST',
port: 587, headers: {
secure: false, // Use `true` for port 465, `false` for all other ports 'Content-Type': 'application/json'
auth: { },
user: 'adella.hoppe@ethereal.email', body: JSON.stringify({
pass: 'dshNQZYhATsdJ3ENke' Attachments: [],
} From: { Email: "noreply@tofustack.com", Name: "TofuStack" },
}).sendMail({ HTML: email.html(),
from: '"Example" <example@ethereal.email>', Subject: email.subject(),
bcc: to, Text: email.html(),
subject: email.subject(), To: Array.isArray(to) ? to.map(to => ({ Email: to, Name: to })) : [{ Email: to, Name: to }],
text: email.html(), })
html: email.html() };
});
console.log(nodemailer.getTestMessageUrl(message)); const response = await fetch('http://localhost:8025/api/v1/send', options)
const data = await response.json()
console.log(`http://localhost:8025/view/${data.ID}`)
} }
private async sendProd({ to, email }: SendProps) { private async sendProd({ to, email }: SendProps) {
// CONFIGURE MAILER // CONFIGURE MAILER
} }
async send({ to, email }: SendProps) {
if (config.isProduction) {
await this.sendProd({ to, email });
} else {
await this.sendDev({ to, email });
}
}
} }

View file

@ -1,19 +0,0 @@
import { injectable } from "tsyringe";
import RedisClient from 'ioredis'
import { config } from "../common/config";
import { Queue, Worker, type Processor } from 'bullmq';
@injectable()
export class QueuesServices {
connection = new RedisClient(config.REDIS_URL);
constructor() { }
createQueue(name: string) {
return new Queue(name, { connection: this.connection })
}
createWorker(name: string, prcoessor: Processor) {
return new Worker(name, prcoessor, { connection: this.connection })
}
}

View file

@ -0,0 +1,33 @@
import { PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { createId } from '@paralleldrive/cuid2';
import { inject, injectable } from 'tsyringe';
import { env } from '../configs/envs.config';
import { S3ClientProvider } from '../providers/s3.provider';
@injectable()
export class StorageService {
constructor(@inject(S3ClientProvider) private readonly s3Client: S3ClientProvider) { }
async upload(file: File) {
const key = createId();
const uploadCommand = new PutObjectCommand({
Bucket: env.STORAGE_BUCKET_NAME,
ACL: 'public-read',
Key: key,
ContentType: file.type,
Body: new Uint8Array(await file.arrayBuffer())
});
const response = await this.s3Client.send(uploadCommand);
return { ...response, size: file.size, name: file.name, type: file.type, key };
}
delete(key: string) {
const deleteCommand = new DeleteObjectCommand({
Bucket: env.STORAGE_BUCKET_NAME,
Key: key
});
return this.s3Client.send(deleteCommand);
}
}

View file

@ -4,10 +4,11 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { TokensService } from '../services/tokens.service'; import { TokensService } from '../services/tokens.service';
import { MailerService } from '../services/mailer.service'; import { MailerService } from '../services/mailer.service';
import { UsersRepository } from '../repositories/users.repository'; import { UsersRepository } from '../repositories/users.repository';
import { DatabaseProvider, LuciaProvider } from '../providers';
import { LoginRequestsRepository } from '../repositories/login-requests.repository'; import { LoginRequestsRepository } from '../repositories/login-requests.repository';
import { PgDatabase } from 'drizzle-orm/pg-core'; import { PgDatabase } from 'drizzle-orm/pg-core';
import { container } from 'tsyringe'; import { container } from 'tsyringe';
import { LuciaProvider } from '../providers/lucia.provider';
import { DatabaseProvider } from '../providers/database.provider';
describe('LoginRequestService', () => { describe('LoginRequestService', () => {
let service: LoginRequestsService; let service: LoginRequestsService;
@ -29,7 +30,6 @@ describe('LoginRequestService', () => {
.resolve(LoginRequestsService); .resolve(LoginRequestsService);
}); });
afterAll(() => { afterAll(() => {
vi.resetAllMocks() vi.resetAllMocks()
}) })
@ -50,9 +50,9 @@ describe('LoginRequestService', () => {
updatedAt: new Date() updatedAt: new Date()
} satisfies Awaited<ReturnType<typeof loginRequestsRepository.create>>) } satisfies Awaited<ReturnType<typeof loginRequestsRepository.create>>)
mailerService.sendLoginRequest = vi.fn().mockResolvedValue(null) mailerService.send = vi.fn().mockResolvedValue(null)
const spy_mailerService_sendLoginRequest = vi.spyOn(mailerService, 'sendLoginRequest') const spy_mailerService_sendLoginRequest = vi.spyOn(mailerService, 'send')
const spy_tokensService_generateTokenWithExpiryAndHash = vi.spyOn(tokensService, 'generateTokenWithExpiryAndHash') const spy_tokensService_generateTokenWithExpiryAndHash = vi.spyOn(tokensService, 'generateTokenWithExpiryAndHash')
const spy_loginRequestsRepository_create = vi.spyOn(loginRequestsRepository, 'create') const spy_loginRequestsRepository_create = vi.spyOn(loginRequestsRepository, 'create')

View file

@ -1,3 +0,0 @@
{
"status": "failed"
}