diff --git a/server/auth/actions.ts b/server/auth/actions.ts index fc561551..b3ce628b 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -50,6 +50,8 @@ export enum ActionsEnum { // removeUserAction = "removeUserAction", // removeUserSite = "removeUserSite", getOrgUser = "getOrgUser", + setResourceAuthMethods = "setResourceAuthMethods", + getResourceAuthMethods = "getResourceAuthMethods", } export async function checkUserActionPermission( diff --git a/server/auth/resource.ts b/server/auth/resource.ts index 0d87b105..66bfb7b3 100644 --- a/server/auth/resource.ts +++ b/server/auth/resource.ts @@ -1,60 +1,64 @@ import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; -import { - resourceSessions, - ResourceSession, - User, - users, -} from "@server/db/schema"; +import { resourceSessions, ResourceSession } from "@server/db/schema"; import db from "@server/db"; import { eq, and } from "drizzle-orm"; export const SESSION_COOKIE_NAME = "resource_session"; export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30; -export type ResourceAuthMethod = "password" | "pincode"; +export async function createResourceSession(opts: { + token: string; + resourceId: number; + passwordId?: number; + pincodeId?: number; +}): Promise { + if (!opts.passwordId && !opts.pincodeId) { + throw new Error( + "At least one of passwordId or pincodeId must be provided" + ); + } -export async function createResourceSession( - token: string, - userId: string, - resourceId: number, - method: ResourceAuthMethod -): Promise { const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(token)) + sha256(new TextEncoder().encode(opts.token)) ); + const session: ResourceSession = { sessionId: sessionId, - userId, expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), - resourceId, - method, + resourceId: opts.resourceId, + passwordId: opts.passwordId || null, + pincodeId: opts.pincodeId || null, }; + await db.insert(resourceSessions).values(session); + return session; } export async function validateResourceSessionToken( - token: string + token: string, + resourceId: number ): Promise { const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const result = await db - .select({ user: users, resourceSession: resourceSessions }) + .select() .from(resourceSessions) - .innerJoin(users, eq(resourceSessions.userId, users.userId)) - .where(eq(resourceSessions.sessionId, sessionId)); + .where( + and( + eq(resourceSessions.sessionId, sessionId), + eq(resourceSessions.resourceId, resourceId) + ) + ); + if (result.length < 1) { - return { session: null, user: null }; - } - const { user, resourceSession } = result[0]; - if (Date.now() >= resourceSession.expiresAt) { - await db - .delete(resourceSessions) - .where(eq(resourceSessions.sessionId, resourceSession.sessionId)); - return { session: null, user: null }; + return { resourceSession: null }; } + + const resourceSession = result[0]; + if (Date.now() >= resourceSession.expiresAt - SESSION_COOKIE_EXPIRES / 2) { resourceSession.expiresAt = new Date( Date.now() + SESSION_COOKIE_EXPIRES @@ -66,7 +70,8 @@ export async function validateResourceSessionToken( }) .where(eq(resourceSessions.sessionId, resourceSession.sessionId)); } - return { session: resourceSession, user }; + + return { resourceSession }; } export async function invalidateResourceSession( @@ -78,26 +83,38 @@ export async function invalidateResourceSession( } export async function invalidateAllSessions( - userId: string, - method?: ResourceAuthMethod + resourceId: number, + method?: { + passwordId?: number; + pincodeId?: number; + } ): Promise { - if (!method) { - await db - .delete(resourceSessions) - .where(eq(resourceSessions.userId, userId)); - } else { + if (method?.passwordId) { await db .delete(resourceSessions) .where( and( - eq(resourceSessions.userId, userId), - eq(resourceSessions.method, method) + eq(resourceSessions.resourceId, resourceId), + eq(resourceSessions.passwordId, method.passwordId) ) ); + } else if (method?.pincodeId) { + await db + .delete(resourceSessions) + .where( + and( + eq(resourceSessions.resourceId, resourceId), + eq(resourceSessions.pincodeId, method.pincodeId) + ) + ); + } else { + await db + .delete(resourceSessions) + .where(eq(resourceSessions.resourceId, resourceId)); } } -export function serializeSessionCookie( +export function serializeResourceSessionCookie( token: string, fqdn: string, secure: boolean @@ -109,7 +126,7 @@ export function serializeSessionCookie( } } -export function createBlankSessionTokenCookie( +export function createBlankResourceSessionTokenCookie( fqdn: string, secure: boolean ): string { @@ -120,6 +137,6 @@ export function createBlankSessionTokenCookie( } } -export type ResourceSessionValidationResult = - | { session: ResourceSession; user: User } - | { session: null; user: null }; +export type ResourceSessionValidationResult = { + resourceSession: ResourceSession | null; +}; diff --git a/server/db/schema.ts b/server/db/schema.ts index 93168d1c..d9fa02a6 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -41,7 +41,11 @@ export const resources = sqliteTable("resources", { subdomain: text("subdomain").notNull(), fullDomain: text("fullDomain").notNull().unique(), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), - appSSOEnabled: integer("appSSOEnabled", { mode: "boolean" }) + blockAccess: integer("blockAccess", { mode: "boolean" }) + .notNull() + .default(false), + sso: integer("sso", { mode: "boolean" }).notNull().default(false), + twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" }) .notNull() .default(false), }); @@ -253,7 +257,7 @@ export const userInvites = sqliteTable("userInvites", { }); export const resourcePincode = sqliteTable("resourcePincode", { - resourcePincodeId: integer("resourcePincodeId").primaryKey({ + pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true, }), resourceId: integer("resourceId") @@ -264,7 +268,7 @@ export const resourcePincode = sqliteTable("resourcePincode", { }); export const resourcePassword = sqliteTable("resourcePassword", { - resourcePasswordId: integer("resourcePasswordId").primaryKey({ + passwordId: integer("passwordId").primaryKey({ autoIncrement: true, }), resourceId: integer("resourceId") @@ -278,11 +282,19 @@ export const resourceSessions = sqliteTable("resourceSessions", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), - userId: text("userId") - .notNull() - .references(() => users.userId, { onDelete: "cascade" }), expiresAt: integer("expiresAt").notNull(), - method: text("method").notNull(), + passwordId: integer("passwordId").references( + () => resourcePassword.passwordId, + { + onDelete: "cascade", + } + ), + pincodeId: integer("pincodeId").references( + () => resourcePincode.pincodeId, + { + onDelete: "cascade", + } + ), }); export type Org = InferSelectModel; @@ -311,3 +323,5 @@ export type Limit = InferSelectModel; export type UserInvite = InferSelectModel; export type UserOrg = InferSelectModel; export type ResourceSession = InferSelectModel; +export type ResourcePincode = InferSelectModel; +export type ResourcePassword = InferSelectModel; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 93b58ccc..8b4aeb1e 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -10,13 +10,15 @@ import { resourcePassword, resourcePincode, resources, + userOrgs, } from "@server/db/schema"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import config from "@server/config"; import { validateResourceSessionToken } from "@server/auth/resource"; +import { Resource, roleResources, userResources } from "@server/db/schema"; const verifyResourceSessionSchema = z.object({ - cookies: z.object({ + sessions: z.object({ session: z.string().nullable(), resource_session: z.string().nullable(), }), @@ -42,7 +44,7 @@ export async function verifyResourceSession( res: Response, next: NextFunction ): Promise { - const parsedBody = verifyResourceSessionSchema.safeParse(req.query); + const parsedBody = verifyResourceSessionSchema.safeParse(req.body); if (!parsedBody.success) { return next( @@ -54,7 +56,7 @@ export async function verifyResourceSession( } try { - const { cookies, host, originalRequestURL } = parsedBody.data; + const { sessions, host, originalRequestURL } = parsedBody.data; const [result] = await db .select() @@ -74,53 +76,60 @@ export async function verifyResourceSession( const pincode = result?.resourcePincode; const password = result?.resourcePassword; - // resource doesn't exist for some reason if (!resource) { - return notAllowed(res); // no resource to redirect to + return notAllowed(res); } - // no auth is configured; auth check is disabled - if (!resource.appSSOEnabled && !pincode && !password) { + const { sso, blockAccess } = resource; + + if (blockAccess) { + return notAllowed(res); + } + + if (!resource.sso && !pincode && !password) { return allowed(res); } const redirectUrl = `${config.app.base_url}/auth/resource/${resource.resourceId}/login?redirect=${originalRequestURL}`; - // we need to check all session to find at least one valid session - // if we find one, we allow access - // if we don't find any, we deny access and redirect to the login page - - // we found a session token, and app sso is enabled, so we need to check if it's a valid session - if (cookies.session && resource.appSSOEnabled) { - const { user, session } = await validateSessionToken( - cookies.session + if (sso && sessions.session) { + const { session, user } = await validateSessionToken( + sessions.session ); - if (user && session) { - return allowed(res); - } - } - - // we found a resource session token, and either pincode or password is enabled for the resource - // so we need to check if it's a valid session - if (cookies.resource_session && (pincode || password)) { - const { session, user } = await validateResourceSessionToken( - cookies.resource_session - ); - if (session && user) { - if (pincode && session.method === "pincode") { - return allowed(res); - } + const isAllowed = await isUserAllowedToAccessResource( + user.userId, + resource + ); - if (password && session.method === "password") { + if (isAllowed) { + return allowed(res); + } + } + } + + if (password && sessions.resource_session) { + const { resourceSession } = await validateResourceSessionToken( + sessions.resource_session, + resource.resourceId + ); + if (resourceSession) { + if ( + pincode && + resourceSession.pincodeId === pincode.pincodeId + ) { + return allowed(res); + } + + if ( + password && + resourceSession.passwordId === password.passwordId + ) { return allowed(res); } } } - // a valid session was not found for an enabled auth method so we deny access - // the user is redirected to the login page - // the login page with render which auth methods are enabled and show the user the correct login form return notAllowed(res, redirectUrl); } catch (e) { return next( @@ -151,3 +160,52 @@ function allowed(res: Response) { status: HttpCode.OK, }); } + +async function isUserAllowedToAccessResource( + userId: string, + resource: Resource +) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, resource.orgId)) + ) + .limit(1); + + if (userOrgRole.length === 0) { + return false; + } + + const roleResourceAccess = await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resource.resourceId), + eq(roleResources.roleId, userOrgRole[0].roleId) + ) + ) + .limit(1); + + if (roleResourceAccess.length > 0) { + return true; + } + + const userResourceAccess = await db + .select() + .from(userResources) + .where( + and( + eq(userResources.userId, userId), + eq(userResources.resourceId, resource.resourceId) + ) + ) + .limit(1); + + if (userResourceAccess.length > 0) { + return true; + } + + return false; +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 62c5ac0b..cb3ac680 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -275,6 +275,18 @@ authenticated.post( resource.setResourceUsers ); +authenticated.post( + `/resource/:resourceId/password`, + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.setResourceAuthMethods), + resource.setResourcePassword +); + +unauthenticated.post( + "/resource/:resourceId/auth/password", + resource.authWithPassword +); + // authenticated.get( // "/role/:roleId/resources", // verifyRoleAccess, diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 64cb91bc..42aa7d47 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -24,6 +24,6 @@ gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth); const badgerRouter = Router(); internalRouter.use("/badger", badgerRouter); -badgerRouter.get("/verify-session", badger.verifyResourceSession); +badgerRouter.post("/verify-session", badger.verifyResourceSession); export default internalRouter; diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts new file mode 100644 index 00000000..b450a0ba --- /dev/null +++ b/server/routers/resource/authWithPassword.ts @@ -0,0 +1,153 @@ +import { verify } from "@node-rs/argon2"; +import { generateSessionToken } from "@server/auth"; +import db from "@server/db"; +import { resourcePassword, resources } 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"; +import { + createResourceSession, + serializeResourceSessionCookie, +} from "@server/auth/resource"; + +export const authWithPasswordBodySchema = z.object({ + password: z.string(), + email: z.string().email().optional(), + code: z.string().optional(), +}); + +export const authWithPasswordParamsSchema = z.object({ + resourceId: z.string().transform(Number).pipe(z.number().int().positive()), +}); + +export type AuthWithPasswordResponse = { + codeRequested?: boolean; +}; + +export async function authWithPassword( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = authWithPasswordBodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const parsedParams = authWithPasswordParamsSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { email, password, code } = parsedBody.data; + + try { + const [result] = await db + .select() + .from(resources) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + const resource = result?.resources; + const definedPassword = result?.resourcePassword; + + if (!resource) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") + ); + } + + if (!definedPassword) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + createHttpError( + HttpCode.BAD_REQUEST, + "Resource has no password protection" + ) + ) + ); + } + + const validPassword = await verify( + definedPassword.passwordHash, + password, + { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + } + ); + if (!validPassword) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password") + ); + } + + if (resource.twoFactorEnabled) { + if (!code) { + return response(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor authentication required", + status: HttpCode.ACCEPTED, + }); + } + + // TODO: Implement email OTP for resource 2fa + } + + const token = generateSessionToken(); + await createResourceSession({ + resourceId, + token, + passwordId: definedPassword.passwordId, + }); + const secureCookie = resource.ssl; + const cookie = serializeResourceSessionCookie( + token, + resource.fullDomain, + secureCookie + ); + + res.appendHeader("Set-Cookie", cookie); + + return response(res, { + data: null, + success: true, + error: false, + message: "Authenticated with resource successfully", + status: HttpCode.OK, + }); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to authenticate with resource" + ) + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index a3ad0963..47d066b6 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -6,4 +6,6 @@ export * from "./listResources"; export * from "./listResourceRoles"; export * from "./setResourceUsers"; export * from "./setResourceRoles"; -export * from "./listResourceUsers" +export * from "./listResourceUsers"; +export * from "./setResourcePassword"; +export * from "./authWithPassword"; diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts new file mode 100644 index 00000000..afd7973b --- /dev/null +++ b/server/routers/resource/setResourcePassword.ts @@ -0,0 +1,84 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourcePassword, resources } from "@server/db/schema"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; +import { hash } from "@node-rs/argon2"; +import { response } from "@server/utils"; + +const setResourceAuthMethodsParamsSchema = z.object({ + resourceId: z.string().transform(Number).pipe(z.number().int().positive()), +}); + +const setResourceAuthMethodsBodySchema = z + .object({ + password: z.string().nullable(), + }) + .strict(); + +export async function setResourcePassword( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = setResourceAuthMethodsParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = setResourceAuthMethodsBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { password } = parsedBody.data; + + await db.transaction(async (trx) => { + await trx + .delete(resourcePassword) + .where(eq(resourcePassword.resourceId, resourceId)); + + if (password) { + const passwordHash = await hash(password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + await trx + .insert(resourcePassword) + .values({ resourceId, passwordHash }); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Resource password set successfully", + status: HttpCode.CREATED, + }); + } catch (error) { + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index cf909ce1..06e98fbb 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -19,6 +19,8 @@ const updateResourceBodySchema = z name: z.string().min(1).max(255).optional(), subdomain: subdomainSchema.optional(), ssl: z.boolean().optional(), + sso: z.boolean().optional(), + blockAccess: z.boolean().optional(), // siteId: z.number(), }) .strict()