started integrating auth with lucia

This commit is contained in:
Milo Schwartz 2024-10-01 20:48:03 -04:00
parent a33a8d7367
commit fc5dca136f
No known key found for this signature in database
20 changed files with 1341 additions and 61 deletions

50
server/auth/index.ts Normal file
View file

@ -0,0 +1,50 @@
import { Lucia, TimeSpan } from "lucia";
import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle";
import db from "@server/db";
import { sessions, users } from "@server/db/schema";
import environment from "@server/environment";
const adapter = new DrizzleSQLiteAdapter(db, sessions, users);
export const lucia = new Lucia(adapter, {
getUserAttributes: (attributes) => {
return {
username: attributes.username,
};
},
// getSessionAttributes: (attributes) => {
// return {
// country: attributes.country,
// };
// },
sessionCookie: {
name: "session",
expires: false, // session cookies have very long lifespan (2 years)
attributes: {
secure: environment.ENVIRONMENT === "prod",
sameSite: "strict",
// domain: "example.com"
},
},
sessionExpiresIn: new TimeSpan(2, "w"),
});
export default lucia;
// IMPORTANT!
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
DatabaseSessionAttributes: DatabaseSessionAttributes;
}
}
interface DatabaseUserAttributes {
username: string;
passwordHash: string;
}
interface DatabaseSessionAttributes {
// country: string;
}

78
server/auth/login.ts Normal file
View file

@ -0,0 +1,78 @@
import { verify } from "@node-rs/argon2";
import lucia from "@server/auth";
import db from "@server/db";
import { users } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/utils/response";
import { eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
export const loginBodySchema = z.object({
email: z.string().email(),
password: z.string(),
});
export async function login(req: Request, res: Response, next: NextFunction) {
const parsedBody = loginBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString(),
),
);
}
const { email, password } = parsedBody.data;
const existingUserRes = await db
.select()
.from(users)
.where(eq(users.email, email));
if (!existingUserRes || !existingUserRes.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email address does not exist",
),
);
}
const existingUser = existingUserRes[0];
const validPassword = await verify(existingUser.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
if (!validPassword) {
await new Promise((resolve) => setTimeout(resolve, 500)); // delay to prevent brute force attacks
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The password you entered is incorrect",
),
);
}
const session = await lucia.createSession(existingUser.id, {});
res.appendHeader(
"Set-Cookie",
lucia.createSessionCookie(session.id).serialize(),
);
return res.status(HttpCode.OK).send(
response<null>({
data: null,
success: true,
error: false,
message: "Logged in successfully",
status: HttpCode.OK,
}),
);
}

93
server/auth/signup.ts Normal file
View file

@ -0,0 +1,93 @@
import { NextFunction, Request, Response } from "express";
import db from "@server/db";
import { hash } from "@node-rs/argon2";
import HttpCode from "@server/types/HttpCode";
import { z } from "zod";
import { generateId } from "lucia";
import { users } from "@server/db/schema";
import lucia from "@server/auth";
import { fromError } from "zod-validation-error";
import createHttpError from "http-errors";
import response from "@server/utils/response";
import { SqliteError } from "better-sqlite3";
export const signupBodySchema = z.object({
email: z.string().email(),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(31, { message: "Password must be at most 31 characters long" })
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
message: `Your password must meet the following conditions:
- At least one uppercase English letter.
- At least one lowercase English letter.
- At least one digit.
- At least one special character.`,
}),
});
export type SignUpBody = z.infer<typeof signupBodySchema>;
export async function signup(req: Request, res: Response, next: NextFunction) {
const parsedBody = signupBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString(),
),
);
}
const { email, password } = parsedBody.data;
const passwordHash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
const userId = generateId(15);
try {
await db.insert(users).values({
id: userId,
email: email,
passwordHash,
});
const session = await lucia.createSession(userId, {});
res.appendHeader(
"Set-Cookie",
lucia.createSessionCookie(session.id).serialize(),
);
return res.status(HttpCode.OK).send(
response<null>({
data: null,
success: true,
error: false,
message: "User created successfully",
status: HttpCode.OK,
}),
);
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email address already exists",
),
);
} else {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create user",
),
);
}
}
}

View file

@ -8,32 +8,29 @@ export const orgs = sqliteTable("orgs", {
domain: text("domain").notNull(),
});
// Users table
export const users = sqliteTable("users", {
userId: integer("userId").primaryKey({ autoIncrement: true }),
orgId: integer("orgId").references(() => orgs.orgId, { onDelete: "cascade" }),
name: text("name").notNull(),
email: text("email").notNull(),
groups: text("groups"),
});
// Sites table
export const sites = sqliteTable("sites", {
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
orgId: integer("orgId").references(() => orgs.orgId, { onDelete: "cascade" }),
exitNode: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }),
orgId: integer("orgId").references(() => orgs.orgId, {
onDelete: "cascade",
}),
exitNode: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null",
}),
name: text("name").notNull(),
subdomain: text("subdomain"),
pubKey: text("pubKey"),
subnet: text("subnet"),
megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut")
megabytesOut: integer("bytesOut"),
});
// Resources table
export const resources = sqliteTable("resources", {
resourceId: text("resourceId", { length: 2048 }).primaryKey(),
siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade",
}),
name: text("name").notNull(),
subdomain: text("subdomain"),
});
@ -41,7 +38,9 @@ export const resources = sqliteTable("resources", {
// Targets table
export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: text("resourceId").references(() => resources.resourceId, { onDelete: "cascade" }),
resourceId: text("resourceId").references(() => resources.resourceId, {
onDelete: "cascade",
}),
ip: text("ip").notNull(),
method: text("method").notNull(),
port: integer("port").notNull(),
@ -61,10 +60,28 @@ export const exitNodes = sqliteTable("exitNodes", {
// Routes table
export const routes = sqliteTable("routes", {
routeId: integer("routeId").primaryKey({ autoIncrement: true }),
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { onDelete: "cascade" }),
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
onDelete: "cascade",
}),
subnet: text("subnet").notNull(),
});
// Users table
export const users = sqliteTable("user", {
id: text("id").primaryKey(), // has to be id not userId for lucia
email: text("email").notNull().unique(),
passwordHash: text("passwordHash").notNull(),
});
// Sessions table
export const sessions = sqliteTable("session", {
id: text("id").primaryKey(), // has to be id not sessionId for lucia
userId: text("userId")
.notNull()
.references(() => users.id),
expiresAt: integer("expiresAt").notNull(),
});
// Define the model types for type inference
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
@ -73,3 +90,4 @@ export type Resource = InferSelectModel<typeof resources>;
export type ExitNode = InferSelectModel<typeof exitNodes>;
export type Route = InferSelectModel<typeof routes>;
export type Target = InferSelectModel<typeof targets>;
export type Session = InferSelectModel<typeof sessions>;

View file

@ -7,6 +7,8 @@ import helmet from "helmet";
import cors from "cors";
import internal from "@server/routers/internal";
import external from "@server/routers/external";
import notFoundMiddleware from "./middlewares/notFound";
import { errorHandlerMiddleware } from "./middlewares/formatError";
const dev = environment.ENVIRONMENT !== "prod";
const app = next({ dev });
@ -34,6 +36,9 @@ app.prepare().then(() => {
logger.info(`Main server is running on http://localhost:${mainPort}`);
});
mainServer.use(notFoundMiddleware);
mainServer.use(errorHandlerMiddleware);
// Internal server
const internalServer = express();
internalServer.use(helmet());

View file

@ -0,0 +1,23 @@
import { ErrorRequestHandler, NextFunction, Response } from "express";
import ErrorResponse from "@server/types/ErrorResponse";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
import environment from "@server/environment";
export const errorHandlerMiddleware: ErrorRequestHandler = (
error,
req,
res: Response<ErrorResponse>,
next: NextFunction,
) => {
logger.error(error);
const statusCode = error.statusCode || HttpCode.INTERNAL_SERVER_ERROR;
res?.status(statusCode).send({
data: null,
success: false,
error: true,
message: error.message || "Internal Server Error",
status: statusCode,
stack: environment.ENVIRONMENT === "prod" ? null : error.stack,
});
};

View file

@ -0,0 +1,14 @@
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export function notFoundMiddleware(
req: Request,
res: Response,
next: NextFunction,
) {
const message = `The requests url is not found - ${req.originalUrl}`;
return next(createHttpError(HttpCode.NOT_FOUND, message));
}
export default notFoundMiddleware;

View file

@ -27,6 +27,13 @@ CREATE TABLE `routes` (
FOREIGN KEY (`exitNodeId`) REFERENCES `exitNodes`(`exitNodeId`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`expiresAt` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `sites` (
`siteId` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`orgId` integer,
@ -52,11 +59,10 @@ CREATE TABLE `targets` (
FOREIGN KEY (`resourceId`) REFERENCES `resources`(`resourceId`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users` (
`userId` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`orgId` integer,
`name` text NOT NULL,
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`groups` text,
FOREIGN KEY (`orgId`) REFERENCES `orgs`(`orgId`) ON UPDATE no action ON DELETE cascade
`passwordHash` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);

View file

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "db8ede7f-7ece-463c-be9c-da36b2b20db6",
"id": "fb7ff7a8-20e6-4602-b096-2f2284ad751e",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"exitNodes": {
@ -173,6 +173,50 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"sites": {
"name": "sites",
"columns": {
@ -345,27 +389,13 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"users": {
"name": "users",
"user": {
"name": "user",
"columns": {
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"orgId": {
"name": "orgId",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
@ -376,30 +406,24 @@
"notNull": true,
"autoincrement": false
},
"groups": {
"name": "groups",
"passwordHash": {
"name": "passwordHash",
"type": "text",
"primaryKey": false,
"notNull": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"users_orgId_orgs_orgId_fk": {
"name": "users_orgId_orgs_orgId_fk",
"tableFrom": "users",
"tableTo": "orgs",
"columnsFrom": [
"orgId"
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"columnsTo": [
"orgId"
],
"onDelete": "cascade",
"onUpdate": "no action"
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1727582003591,
"tag": "0000_ancient_blob",
"when": 1727750917675,
"tag": "0000_faithful_katie_power",
"breakpoints": true
}
]

View file

@ -1,6 +1,7 @@
import { Router } from "express";
import gerbil from "./gerbil/gerbil";
import pangolin from "./pangolin/pangolin";
import global from "./global/global";
const unauth = Router();
@ -11,4 +12,6 @@ unauth.get("/", (_, res) => {
unauth.use("/newt", gerbil);
unauth.use("/pangolin", pangolin);
unauth.use("/", global)
export default unauth;

View file

@ -1,5 +1,7 @@
import { Router } from "express";
import { createSite } from "./createSite";
import { signup } from "@server/auth/signup";
import { login } from "@server/auth/login";
const global = Router();
@ -9,4 +11,8 @@ global.get("/", (_, res) => {
global.get("/createSite", createSite);
// auth
global.post("/signup", signup);
global.post("/login", login);
export default global;

View file

@ -4,16 +4,19 @@ import * as schema from "@server/db/schema";
import { DynamicTraefikConfig } from "./configSchema";
import { and, like, eq } from "drizzle-orm";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
export async function traefikConfigProvider(_: Request, res: Response) {
try {
const targets = await getAllTargets();
const traefikConfig = buildTraefikConfig(targets);
// logger.debug("Built traefik config");
res.status(200).send(traefikConfig);
res.status(HttpCode.OK).json(traefikConfig);
} catch (e) {
logger.error(`Failed to build traefik config: ${e}`);
res.status(500).send({ message: "Failed to build traefik config" });
res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
error: "Failed to build traefik config",
});
}
}

View file

@ -0,0 +1,7 @@
import MessageResponse from "./MessageResponse";
export interface ErrorResponse extends MessageResponse<null> {
stack?: string;
}
export default ErrorResponse;

65
server/types/HttpCode.ts Normal file
View file

@ -0,0 +1,65 @@
export enum HttpCode {
CONTINUE = 100,
SWITCHING_PROTOCOLS = 101,
PROCESSING = 102,
EARLY_HINTS = 103,
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NON_AUTHORITATIVE_INFORMATION = 203,
NO_CONTENT = 204,
RESET_CONTENT = 205,
PARTIAL_CONTENT = 206,
MULTI_STATUS = 207,
ALREADY_REPORTED = 208,
IM_USED = 226,
MULTIPLE_CHOICES = 300,
MOVED_PERMANENTLY = 301,
FOUND = 302,
SEE_OTHER = 303,
NOT_MODIFIED = 304,
TEMPORARY_REDIRECT = 307,
PERMANENT_REDIRECT = 308,
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
PAYMENT_REQUIRED = 402,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
NOT_ACCEPTABLE = 406,
PROXY_AUTHENTICATION_REQUIRED = 407,
REQUEST_TIMEOUT = 408,
CONFLICT = 409,
GONE = 410,
LENGTH_REQUIRED = 411,
PRECONDITION_FAILED = 412,
CONTENT_TOO_LARGE = 413,
URI_TOO_LONG = 414,
UNSUPPORTED_MEDIA_TYPE = 415,
RANGE_NOT_SATISFIABLE = 416,
EXPECTATION_FAILED = 417,
IM_A_TEAPOT = 418,
MISDIRECTED_REQUEST = 421,
UNPROCESSABLE_CONTENT = 422,
LOCKED = 423,
FAILED_DEPENDENCY = 424,
TOO_EARLY = 425,
UPGRADE_REQUIRED = 426,
PRECONDITION_REQUIRED = 428,
TOO_MANY_REQUESTS = 429,
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
UNAVAILABLE_FOR_LEGAL_REASONS = 451,
INTERNAL_SERVER_ERROR = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
HTTP_VERSION_NOT_SUPPORTED = 505,
VARIANT_ALSO_NEGOTIATES = 506,
INSUFFICIENT_STORAGE = 507,
LOOP_DETECTED = 508,
NOT_EXTENDED = 510,
NETWORK_AUTHENTICATION_REQUIRED = 511,
}
export default HttpCode;

View file

@ -0,0 +1,9 @@
export interface ResponseT<T> {
data: T | null;
success: boolean;
error: boolean;
message: string;
status: number;
}
export default ResponseT;

9
server/types/Response.ts Normal file
View file

@ -0,0 +1,9 @@
export interface ResponseT<T> {
data: T | null;
success: boolean;
error: boolean;
message: string;
status: number;
}
export default ResponseT;

19
server/utils/response.ts Normal file
View file

@ -0,0 +1,19 @@
import { ResponseT } from "@server/types/Response";
export const response = <T>({
data,
success,
error,
message,
status,
}: ResponseT<T>) => {
return {
data,
success,
error,
message,
status,
};
};
export default response;