diff --git a/package.json b/package.json index e817ac04..8918efb2 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "db:studio": "drizzle-kit studio", "build": "mkdir -p dist && next build && node scripts/esbuild.mjs -e server/index.ts -o dist/server.mjs", "start": "NODE_ENV=development ENVIRONMENT=prod node dist/server.mjs", - "email": "email dev --dir server/emails/templates --port 3002" + "email": "email dev --dir server/emails/templates --port 3005" }, "dependencies": { "@hookform/resolvers": "3.9.0", diff --git a/server/auth/resource.ts b/server/auth/resource.ts index b13b54d9..558f4c25 100644 --- a/server/auth/resource.ts +++ b/server/auth/resource.ts @@ -16,15 +16,17 @@ export async function createResourceSession(opts: { resourceId: number; passwordId?: number; pincodeId?: number; + whitelistId: number; + usedOtp?: boolean; }): Promise { if (!opts.passwordId && !opts.pincodeId) { throw new Error( - "At least one of passwordId or pincodeId must be provided", + "At least one of passwordId or pincodeId must be provided" ); } const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(opts.token)), + sha256(new TextEncoder().encode(opts.token)) ); const session: ResourceSession = { @@ -33,6 +35,8 @@ export async function createResourceSession(opts: { resourceId: opts.resourceId, passwordId: opts.passwordId || null, pincodeId: opts.pincodeId || null, + whitelistId: opts.whitelistId, + usedOtp: opts.usedOtp || false }; await db.insert(resourceSessions).values(session); @@ -42,10 +46,10 @@ export async function createResourceSession(opts: { export async function validateResourceSessionToken( token: string, - resourceId: number, + resourceId: number ): Promise { const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(token)), + sha256(new TextEncoder().encode(token)) ); const result = await db .select() @@ -53,8 +57,8 @@ export async function validateResourceSessionToken( .where( and( eq(resourceSessions.sessionId, sessionId), - eq(resourceSessions.resourceId, resourceId), - ), + eq(resourceSessions.resourceId, resourceId) + ) ); if (result.length < 1) { @@ -65,12 +69,12 @@ export async function validateResourceSessionToken( if (Date.now() >= resourceSession.expiresAt - SESSION_COOKIE_EXPIRES / 2) { resourceSession.expiresAt = new Date( - Date.now() + SESSION_COOKIE_EXPIRES, + Date.now() + SESSION_COOKIE_EXPIRES ).getTime(); await db .update(resourceSessions) .set({ - expiresAt: resourceSession.expiresAt, + expiresAt: resourceSession.expiresAt }) .where(eq(resourceSessions.sessionId, resourceSession.sessionId)); } @@ -79,7 +83,7 @@ export async function validateResourceSessionToken( } export async function invalidateResourceSession( - sessionId: string, + sessionId: string ): Promise { await db .delete(resourceSessions) @@ -91,7 +95,8 @@ export async function invalidateAllSessions( method?: { passwordId?: number; pincodeId?: number; - }, + whitelistId?: number; + } ): Promise { if (method?.passwordId) { await db @@ -99,19 +104,34 @@ export async function invalidateAllSessions( .where( and( eq(resourceSessions.resourceId, resourceId), - eq(resourceSessions.passwordId, method.passwordId), - ), + eq(resourceSessions.passwordId, method.passwordId) + ) ); - } else if (method?.pincodeId) { + } + + if (method?.pincodeId) { await db .delete(resourceSessions) .where( and( eq(resourceSessions.resourceId, resourceId), - eq(resourceSessions.pincodeId, method.pincodeId), - ), + eq(resourceSessions.pincodeId, method.pincodeId) + ) ); - } else { + } + + if (method?.whitelistId) { + await db + .delete(resourceSessions) + .where( + and( + eq(resourceSessions.resourceId, resourceId), + eq(resourceSessions.whitelistId, method.whitelistId) + ) + ); + + } + if (!method?.passwordId && !method?.pincodeId && !method?.whitelistId) { await db .delete(resourceSessions) .where(eq(resourceSessions.resourceId, resourceId)); @@ -121,7 +141,7 @@ export async function invalidateAllSessions( export function serializeResourceSessionCookie( cookieName: string, token: string, - fqdn: string, + fqdn: string ): string { if (SECURE_COOKIES) { return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; @@ -132,7 +152,7 @@ export function serializeResourceSessionCookie( export function createBlankResourceSessionTokenCookie( cookieName: string, - fqdn: string, + fqdn: string ): string { if (SECURE_COOKIES) { return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; diff --git a/server/auth/resourceOtp.ts b/server/auth/resourceOtp.ts new file mode 100644 index 00000000..523b4011 --- /dev/null +++ b/server/auth/resourceOtp.ts @@ -0,0 +1,102 @@ +import db from "@server/db"; +import { resourceOtp } from "@server/db/schema"; +import { and, eq } from "drizzle-orm"; +import { createDate, isWithinExpirationDate, TimeSpan } from "oslo"; +import { alphabet, generateRandomString, sha256 } from "oslo/crypto"; +import { encodeHex } from "oslo/encoding"; +import { sendEmail } from "@server/emails"; +import ResourceOTPCode from "@server/emails/templates/ResourceOTPCode"; +import config from "@server/config"; +import { hash, verify } from "@node-rs/argon2"; + +export async function sendResourceOtpEmail( + email: string, + resourceId: number, + resourceName: string, + orgName: string +): Promise { + const otp = await generateResourceOtpCode(resourceId, email); + + await sendEmail( + ResourceOTPCode({ + email, + resourceName, + orgName, + otp + }), + { + to: email, + from: config.email?.no_reply, + subject: `Your one-time code to access ${resourceName}` + } + ); +} + +export async function generateResourceOtpCode( + resourceId: number, + email: string +): Promise { + await db + .delete(resourceOtp) + .where( + and( + eq(resourceOtp.email, email), + eq(resourceOtp.resourceId, resourceId) + ) + ); + + const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); + + const otpHash = await hash(otp, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + + await db.insert(resourceOtp).values({ + resourceId, + email, + otpHash, + expiresAt: createDate(new TimeSpan(15, "m")).getTime() + }); + + return otp; +} + +export async function isValidOtp( + email: string, + resourceId: number, + otp: string +): Promise { + const record = await db + .select() + .from(resourceOtp) + .where( + and( + eq(resourceOtp.email, email), + eq(resourceOtp.resourceId, resourceId) + ) + ) + .limit(1); + + if (record.length === 0) { + return false; + } + + const validCode = await verify(record[0].otpHash, otp, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1 + }); + if (!validCode) { + return false; + } + + if (!isWithinExpirationDate(new Date(record[0].expiresAt))) { + return false; + } + + return true; +} diff --git a/server/db/schema.ts b/server/db/schema.ts index 26ea4b0c..5cf8b215 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -1,41 +1,41 @@ -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; import { InferSelectModel } from "drizzle-orm"; +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), name: text("name").notNull(), - domain: text("domain").notNull(), + domain: text("domain").notNull() }); export const sites = sqliteTable("sites", { siteId: integer("siteId").primaryKey({ autoIncrement: true }), orgId: text("orgId") .references(() => orgs.orgId, { - onDelete: "cascade", + onDelete: "cascade" }) .notNull(), niceId: text("niceId").notNull(), exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { - onDelete: "set null", + onDelete: "set null" }), name: text("name").notNull(), pubKey: text("pubKey"), subnet: text("subnet").notNull(), megabytesIn: integer("bytesIn"), megabytesOut: integer("bytesOut"), - type: text("type").notNull(), // "newt" or "wireguard" + type: text("type").notNull() // "newt" or "wireguard" }); export const resources = sqliteTable("resources", { resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), siteId: integer("siteId") .references(() => sites.siteId, { - onDelete: "cascade", + onDelete: "cascade" }) .notNull(), orgId: text("orgId") .references(() => orgs.orgId, { - onDelete: "cascade", + onDelete: "cascade" }) .notNull(), name: text("name").notNull(), @@ -46,16 +46,16 @@ export const resources = sqliteTable("resources", { .notNull() .default(false), sso: integer("sso", { mode: "boolean" }).notNull().default(true), - twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" }) + otpEnabled: integer("otpEnabled", { mode: "boolean" }) .notNull() - .default(false), + .default(false) }); export const targets = sqliteTable("targets", { targetId: integer("targetId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId") .references(() => resources.resourceId, { - onDelete: "cascade", + onDelete: "cascade" }) .notNull(), ip: text("ip").notNull(), @@ -63,7 +63,7 @@ export const targets = sqliteTable("targets", { port: integer("port").notNull(), internalPort: integer("internalPort"), protocol: text("protocol"), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) }); export const exitNodes = sqliteTable("exitNodes", { @@ -73,7 +73,7 @@ export const exitNodes = sqliteTable("exitNodes", { endpoint: text("endpoint").notNull(), // this is how to reach gerbil externally - gets put into the wireguard config publicKey: text("pubicKey").notNull(), listenPort: integer("listenPort").notNull(), - reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control + reachableAt: text("reachableAt") // this is the internal address of the gerbil http server for command control }); export const users = sqliteTable("user", { @@ -87,14 +87,14 @@ export const users = sqliteTable("user", { emailVerified: integer("emailVerified", { mode: "boolean" }) .notNull() .default(false), - dateCreated: text("dateCreated").notNull(), + dateCreated: text("dateCreated").notNull() }); export const newts = sqliteTable("newt", { newtId: text("id").primaryKey(), secretHash: text("secretHash").notNull(), dateCreated: text("dateCreated").notNull(), - siteId: integer("siteId").references(() => sites.siteId), + siteId: integer("siteId").references(() => sites.siteId) }); export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { @@ -102,7 +102,7 @@ export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), - codeHash: text("codeHash").notNull(), + codeHash: text("codeHash").notNull() }); export const sessions = sqliteTable("session", { @@ -110,7 +110,7 @@ export const sessions = sqliteTable("session", { userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), - expiresAt: integer("expiresAt").notNull(), + expiresAt: integer("expiresAt").notNull() }); export const newtSessions = sqliteTable("newtSession", { @@ -118,7 +118,7 @@ export const newtSessions = sqliteTable("newtSession", { newtId: text("newtId") .notNull() .references(() => newts.newtId, { onDelete: "cascade" }), - expiresAt: integer("expiresAt").notNull(), + expiresAt: integer("expiresAt").notNull() }); export const userOrgs = sqliteTable("userOrgs", { @@ -131,7 +131,7 @@ export const userOrgs = sqliteTable("userOrgs", { roleId: integer("roleId") .notNull() .references(() => roles.roleId), - isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), + isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false) }); export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { @@ -141,7 +141,7 @@ export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { .references(() => users.userId, { onDelete: "cascade" }), email: text("email").notNull(), code: text("code").notNull(), - expiresAt: integer("expiresAt").notNull(), + expiresAt: integer("expiresAt").notNull() }); export const passwordResetTokens = sqliteTable("passwordResetTokens", { @@ -150,25 +150,25 @@ export const passwordResetTokens = sqliteTable("passwordResetTokens", { .notNull() .references(() => users.userId, { onDelete: "cascade" }), tokenHash: text("tokenHash").notNull(), - expiresAt: integer("expiresAt").notNull(), + expiresAt: integer("expiresAt").notNull() }); export const actions = sqliteTable("actions", { actionId: text("actionId").primaryKey(), name: text("name"), - description: text("description"), + description: text("description") }); export const roles = sqliteTable("roles", { roleId: integer("roleId").primaryKey({ autoIncrement: true }), orgId: text("orgId") .references(() => orgs.orgId, { - onDelete: "cascade", + onDelete: "cascade" }) .notNull(), isAdmin: integer("isAdmin", { mode: "boolean" }), name: text("name").notNull(), - description: text("description"), + description: text("description") }); export const roleActions = sqliteTable("roleActions", { @@ -180,7 +180,7 @@ export const roleActions = sqliteTable("roleActions", { .references(() => actions.actionId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), + .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const userActions = sqliteTable("userActions", { @@ -192,7 +192,7 @@ export const userActions = sqliteTable("userActions", { .references(() => actions.actionId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), + .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const roleSites = sqliteTable("roleSites", { @@ -201,7 +201,7 @@ export const roleSites = sqliteTable("roleSites", { .references(() => roles.roleId, { onDelete: "cascade" }), siteId: integer("siteId") .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), + .references(() => sites.siteId, { onDelete: "cascade" }) }); export const userSites = sqliteTable("userSites", { @@ -210,7 +210,7 @@ export const userSites = sqliteTable("userSites", { .references(() => users.userId, { onDelete: "cascade" }), siteId: integer("siteId") .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), + .references(() => sites.siteId, { onDelete: "cascade" }) }); export const roleResources = sqliteTable("roleResources", { @@ -219,7 +219,7 @@ export const roleResources = sqliteTable("roleResources", { .references(() => roles.roleId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), + .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const userResources = sqliteTable("userResources", { @@ -228,19 +228,19 @@ export const userResources = sqliteTable("userResources", { .references(() => users.userId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), + .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const limitsTable = sqliteTable("limits", { limitId: integer("limitId").primaryKey({ autoIncrement: true }), orgId: text("orgId") .references(() => orgs.orgId, { - onDelete: "cascade", + onDelete: "cascade" }) .notNull(), name: text("name").notNull(), value: integer("value").notNull(), - description: text("description"), + description: text("description") }); export const userInvites = sqliteTable("userInvites", { @@ -253,28 +253,28 @@ export const userInvites = sqliteTable("userInvites", { tokenHash: text("token").notNull(), roleId: integer("roleId") .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }), + .references(() => roles.roleId, { onDelete: "cascade" }) }); export const resourcePincode = sqliteTable("resourcePincode", { pincodeId: integer("pincodeId").primaryKey({ - autoIncrement: true, + autoIncrement: true }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), pincodeHash: text("pincodeHash").notNull(), - digitLength: integer("digitLength").notNull(), + digitLength: integer("digitLength").notNull() }); export const resourcePassword = sqliteTable("resourcePassword", { passwordId: integer("passwordId").primaryKey({ - autoIncrement: true, + autoIncrement: true }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), - passwordHash: text("passwordHash").notNull(), + passwordHash: text("passwordHash").notNull() }); export const resourceSessions = sqliteTable("resourceSessions", { @@ -282,31 +282,49 @@ export const resourceSessions = sqliteTable("resourceSessions", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + usedOtp: integer("usedOtp", { mode: "boolean" }).notNull().default(false), expiresAt: integer("expiresAt").notNull(), passwordId: integer("passwordId").references( () => resourcePassword.passwordId, { - onDelete: "cascade", - }, + onDelete: "cascade" + } ), pincodeId: integer("pincodeId").references( () => resourcePincode.pincodeId, { - onDelete: "cascade", - }, + onDelete: "cascade" + } ), + whitelistId: integer("whitelistId").references( + () => resourceWhitelistedEmail.whitelistId, + { + onDelete: "cascade" + } + ) }); +export const resourceWhitelistedEmail = sqliteTable( + "resourceWhitelistedEmail", + { + whitelistId: integer("id").primaryKey({ autoIncrement: true }), + email: text("email").primaryKey(), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }) + } +); + export const resourceOtp = sqliteTable("resourceOtp", { otpId: integer("otpId").primaryKey({ - autoIncrement: true, + autoIncrement: true }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), email: text("email").notNull(), otpHash: text("otpHash").notNull(), - expiresAt: integer("expiresAt").notNull(), + expiresAt: integer("expiresAt").notNull() }); export type Org = InferSelectModel; diff --git a/server/emails/templates/ResourceOTPCode.tsx b/server/emails/templates/ResourceOTPCode.tsx new file mode 100644 index 00000000..20356160 --- /dev/null +++ b/server/emails/templates/ResourceOTPCode.tsx @@ -0,0 +1,71 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, + Tailwind +} from "@react-email/components"; +import * as React from "react"; + +interface ResourceOTPCodeProps { + email?: string; + resourceName: string; + orgName: string; + otp: string; +} + +export const ResourceOTPCode = ({ + email, + resourceName, + orgName: organizationName, + otp +}: ResourceOTPCodeProps) => { + const previewText = `Your one-time password for ${resourceName} is ready!`; + + return ( + + + {previewText} + + + + + Your One-Time Password + + + Hi {email || "there"}, + + + You’ve requested a one-time password (OTP) to + authenticate with the resource{" "} + {resourceName} in{" "} + {organizationName}. Use the OTP + below to complete your authentication: + +
+ + {otp} + +
+
+ +
+ + ); +}; + +export default ResourceOTPCode; diff --git a/server/emails/templates/SendInviteLink.tsx b/server/emails/templates/SendInviteLink.tsx index fabcffbd..9612f5d9 100644 --- a/server/emails/templates/SendInviteLink.tsx +++ b/server/emails/templates/SendInviteLink.tsx @@ -71,11 +71,6 @@ export const SendInviteLink = ({ Accept invitation to {orgName} - - Best regards, -
- Fossorial -
diff --git a/server/emails/templates/VerifyEmailCode.tsx b/server/emails/templates/VerifyEmailCode.tsx index fc8978ed..4a1d07ce 100644 --- a/server/emails/templates/VerifyEmailCode.tsx +++ b/server/emails/templates/VerifyEmailCode.tsx @@ -63,11 +63,6 @@ export const VerifyEmail = ({ If you didn’t request this, you can safely ignore this email. - - Best regards, -
- Fossorial -
diff --git a/server/routers/auth/sendEmailVerificationCode.ts b/server/routers/auth/sendEmailVerificationCode.ts index 334eb23a..9d9dc08d 100644 --- a/server/routers/auth/sendEmailVerificationCode.ts +++ b/server/routers/auth/sendEmailVerificationCode.ts @@ -9,7 +9,7 @@ import { VerifyEmail } from "@server/emails/templates/VerifyEmailCode"; export async function sendEmailVerificationCode( email: string, - userId: string, + userId: string ): Promise { const code = await generateEmailVerificationCode(userId, email); @@ -17,19 +17,19 @@ export async function sendEmailVerificationCode( VerifyEmail({ username: email, verificationCode: code, - verifyLink: `${config.app.base_url}/auth/verify-email`, + verifyLink: `${config.app.base_url}/auth/verify-email` }), { to: email, from: config.email?.no_reply, - subject: "Verify your email address", - }, + subject: "Verify your email address" + } ); } async function generateEmailVerificationCode( userId: string, - email: string, + email: string ): Promise { await db .delete(emailVerificationCodes) @@ -41,7 +41,7 @@ async function generateEmailVerificationCode( userId, email, code, - expiresAt: createDate(new TimeSpan(15, "m")).getTime(), + expiresAt: createDate(new TimeSpan(15, "m")).getTime() }); return code; diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 46fe6df3..032c0faf 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -11,7 +11,7 @@ import { resourcePincode, resources, User, - userOrgs, + userOrgs } from "@server/db/schema"; import { and, eq } from "drizzle-orm"; import config from "@server/config"; @@ -26,7 +26,7 @@ const verifyResourceSessionSchema = z.object({ host: z.string(), path: z.string(), method: z.string(), - tls: z.boolean(), + tls: z.boolean() }); export type VerifyResourceSessionSchema = z.infer< @@ -41,7 +41,7 @@ export type VerifyUserResponse = { export async function verifyResourceSession( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { logger.debug("Badger sent", req.body); // remove when done testing @@ -51,8 +51,8 @@ export async function verifyResourceSession( return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString(), - ), + fromError(parsedBody.error).toString() + ) ); } @@ -64,11 +64,11 @@ export async function verifyResourceSession( .from(resources) .leftJoin( resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId), + eq(resourcePincode.resourceId, resources.resourceId) ) .leftJoin( resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId), + eq(resourcePassword.resourceId, resources.resourceId) ) .where(eq(resources.fullDomain, host)) .limit(1); @@ -103,17 +103,17 @@ export async function verifyResourceSession( const sessionToken = sessions[config.server.session_cookie_name]; // check for unified login - if (sso && sessionToken) { + if (sso && sessionToken && !resource.otpEnabled) { const { session, user } = await validateSessionToken(sessionToken); if (session && user) { const isAllowed = await isUserAllowedToAccessResource( user, - resource, + resource ); if (isAllowed) { logger.debug( - "Resource allowed because user session is valid", + "Resource allowed because user session is valid" ); return allowed(res); } @@ -125,19 +125,56 @@ export async function verifyResourceSession( `${config.server.resource_session_cookie_name}_${resource.resourceId}` ]; + if ( + sso && + sessionToken && + resourceSessionToken && + resource.otpEnabled + ) { + const { session, user } = await validateSessionToken(sessionToken); + const { resourceSession } = await validateResourceSessionToken( + resourceSessionToken, + resource.resourceId + ); + + if (session && user && resourceSession) { + if (!resourceSession.usedOtp) { + logger.debug("Resource not allowed because OTP not used"); + return notAllowed(res, redirectUrl); + } + + const isAllowed = await isUserAllowedToAccessResource( + user, + resource + ); + + if (isAllowed) { + logger.debug( + "Resource allowed because user and resource session is valid" + ); + return allowed(res); + } + } + } + if ((pincode || password) && resourceSessionToken) { const { resourceSession } = await validateResourceSessionToken( resourceSessionToken, - resource.resourceId, + resource.resourceId ); if (resourceSession) { + if (resource.otpEnabled && !resourceSession.usedOtp) { + logger.debug("Resource not allowed because OTP not used"); + return notAllowed(res, redirectUrl); + } + if ( pincode && resourceSession.pincodeId === pincode.pincodeId ) { logger.debug( - "Resource allowed because pincode session is valid", + "Resource allowed because pincode session is valid" ); return allowed(res); } @@ -147,7 +184,7 @@ export async function verifyResourceSession( resourceSession.passwordId === password.passwordId ) { logger.debug( - "Resource allowed because password session is valid", + "Resource allowed because password session is valid" ); return allowed(res); } @@ -161,8 +198,8 @@ export async function verifyResourceSession( return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to verify session", - ), + "Failed to verify session" + ) ); } } @@ -173,7 +210,7 @@ function notAllowed(res: Response, redirectUrl?: string) { success: true, error: false, message: "Access denied", - status: HttpCode.OK, + status: HttpCode.OK }; logger.debug(JSON.stringify(data)); return response(res, data); @@ -185,7 +222,7 @@ function allowed(res: Response) { success: true, error: false, message: "Access allowed", - status: HttpCode.OK, + status: HttpCode.OK }; logger.debug(JSON.stringify(data)); return response(res, data); @@ -193,7 +230,7 @@ function allowed(res: Response) { async function isUserAllowedToAccessResource( user: User, - resource: Resource, + resource: Resource ): Promise { if (config.flags?.require_email_verification && !user.emailVerified) { return false; @@ -205,8 +242,8 @@ async function isUserAllowedToAccessResource( .where( and( eq(userOrgs.userId, user.userId), - eq(userOrgs.orgId, resource.orgId), - ), + eq(userOrgs.orgId, resource.orgId) + ) ) .limit(1); @@ -220,8 +257,8 @@ async function isUserAllowedToAccessResource( .where( and( eq(roleResources.resourceId, resource.resourceId), - eq(roleResources.roleId, userOrgRole[0].roleId), - ), + eq(roleResources.roleId, userOrgRole[0].roleId) + ) ) .limit(1); @@ -235,8 +272,8 @@ async function isUserAllowedToAccessResource( .where( and( eq(userResources.userId, user.userId), - eq(userResources.resourceId, resource.resourceId), - ), + eq(userResources.resourceId, resource.resourceId) + ) ) .limit(1); diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index 4e1ae35e..29f73fa1 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -1,40 +1,42 @@ import { verify } from "@node-rs/argon2"; import { generateSessionToken } from "@server/auth"; import db from "@server/db"; -import { resourcePassword, resources } from "@server/db/schema"; +import { orgs, resourceOtp, resourcePassword, resources } from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; import response from "@server/utils/response"; -import { eq } from "drizzle-orm"; +import { and, 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, + serializeResourceSessionCookie } from "@server/auth/resource"; import logger from "@server/logger"; import config from "@server/config"; +import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; export const authWithPasswordBodySchema = z.object({ password: z.string(), email: z.string().email().optional(), - code: z.string().optional(), + otp: z.string().optional() }); export const authWithPasswordParamsSchema = z.object({ - resourceId: z.string().transform(Number).pipe(z.number().int().positive()), + resourceId: z.string().transform(Number).pipe(z.number().int().positive()) }); export type AuthWithPasswordResponse = { - codeRequested?: boolean; + otpRequested?: boolean; + otpSent?: boolean; session?: string; }; export async function authWithPassword( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { const parsedBody = authWithPasswordBodySchema.safeParse(req.body); @@ -42,8 +44,8 @@ export async function authWithPassword( return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString(), - ), + fromError(parsedBody.error).toString() + ) ); } @@ -53,13 +55,13 @@ export async function authWithPassword( return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString(), - ), + fromError(parsedParams.error).toString() + ) ); } const { resourceId } = parsedParams.data; - const { email, password, code } = parsedBody.data; + const { email, password, otp } = parsedBody.data; try { const [result] = await db @@ -67,20 +69,25 @@ export async function authWithPassword( .from(resources) .leftJoin( resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId), + eq(resourcePassword.resourceId, resources.resourceId) ) + .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) .where(eq(resources.resourceId, resourceId)) .limit(1); const resource = result?.resources; + const org = result?.orgs; const definedPassword = result?.resourcePassword; + if (!org) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") + ); + } + if (!resource) { return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Resource does not exist", - ), + createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") ); } @@ -90,9 +97,9 @@ export async function authWithPassword( HttpCode.UNAUTHORIZED, createHttpError( HttpCode.BAD_REQUEST, - "Resource has no password protection", - ), - ), + "Resource has no password protection" + ) + ) ); } @@ -103,27 +110,69 @@ export async function authWithPassword( memoryCost: 19456, timeCost: 2, outputLen: 32, - parallelism: 1, - }, + parallelism: 1 + } ); if (!validPassword) { return next( - createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password"), + createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password") ); } - if (resource.twoFactorEnabled) { - if (!code) { + if (resource.otpEnabled) { + if (otp && email) { + const isValidCode = await isValidOtp( + email, + resource.resourceId, + otp + ); + if (!isValidCode) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP") + ); + } + + await db + .delete(resourceOtp) + .where( + and( + eq(resourceOtp.email, email), + eq(resourceOtp.resourceId, resource.resourceId) + ) + ); + } else if (email) { + try { + await sendResourceOtpEmail( + email, + resource.resourceId, + resource.name, + org.name + ); + return response(res, { + data: { otpSent: true }, + success: true, + error: false, + message: "Sent one-time otp to email address", + status: HttpCode.ACCEPTED + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to send one-time otp. Make sure the email address is correct and try again." + ) + ); + } + } else { return response(res, { - data: { codeRequested: true }, + data: { otpRequested: true }, success: true, error: false, - message: "Two-factor authentication required", - status: HttpCode.ACCEPTED, + message: "One-time otp required to complete authentication", + status: HttpCode.ACCEPTED }); } - - // TODO: Implement email OTP for resource 2fa } const token = generateSessionToken(); @@ -131,32 +180,32 @@ export async function authWithPassword( resourceId, token, passwordId: definedPassword.passwordId, + usedOtp: otp !== undefined, + email }); const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`; const cookie = serializeResourceSessionCookie( cookieName, token, - resource.fullDomain, + resource.fullDomain ); res.appendHeader("Set-Cookie", cookie); - logger.debug(cookie); // remove after testing - return response(res, { data: { - session: token, + session: token }, success: true, error: false, message: "Authenticated with resource successfully", - status: HttpCode.OK, + status: HttpCode.OK }); } catch (e) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to authenticate with resource", - ), + "Failed to authenticate with resource" + ) ); } } diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index 8d69ede1..e6c43a8b 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -1,40 +1,48 @@ import { verify } from "@node-rs/argon2"; import { generateSessionToken } from "@server/auth"; import db from "@server/db"; -import { resourcePincode, resources } from "@server/db/schema"; +import { + orgs, + resourceOtp, + resourcePincode, + resources +} from "@server/db/schema"; import HttpCode from "@server/types/HttpCode"; import response from "@server/utils/response"; -import { eq } from "drizzle-orm"; +import { and, 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, + serializeResourceSessionCookie } from "@server/auth/resource"; import logger from "@server/logger"; import config from "@server/config"; +import { AuthWithPasswordResponse } from "./authWithPassword"; +import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; export const authWithPincodeBodySchema = z.object({ pincode: z.string(), email: z.string().email().optional(), - code: z.string().optional(), + otp: z.string().optional() }); export const authWithPincodeParamsSchema = z.object({ - resourceId: z.string().transform(Number).pipe(z.number().int().positive()), + resourceId: z.string().transform(Number).pipe(z.number().int().positive()) }); export type AuthWithPincodeResponse = { - codeRequested?: boolean; + otpRequested?: boolean; + otpSent?: boolean; session?: string; }; export async function authWithPincode( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { const parsedBody = authWithPincodeBodySchema.safeParse(req.body); @@ -42,8 +50,8 @@ export async function authWithPincode( return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString(), - ), + fromError(parsedBody.error).toString() + ) ); } @@ -53,13 +61,13 @@ export async function authWithPincode( return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString(), - ), + fromError(parsedParams.error).toString() + ) ); } const { resourceId } = parsedParams.data; - const { email, pincode, code } = parsedBody.data; + const { email, pincode, otp } = parsedBody.data; try { const [result] = await db @@ -67,20 +75,28 @@ export async function authWithPincode( .from(resources) .leftJoin( resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId), + eq(resourcePincode.resourceId, resources.resourceId) ) + .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) .where(eq(resources.resourceId, resourceId)) .limit(1); const resource = result?.resources; + const org = result?.orgs; const definedPincode = result?.resourcePincode; + if (!org) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Org does not exist" + ) + ); + } + if (!resource) { return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Resource does not exist", - ), + createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") ); } @@ -90,9 +106,9 @@ export async function authWithPincode( HttpCode.UNAUTHORIZED, createHttpError( HttpCode.BAD_REQUEST, - "Resource has no pincode protection", - ), - ), + "Resource has no pincode protection" + ) + ) ); } @@ -100,26 +116,68 @@ export async function authWithPincode( memoryCost: 19456, timeCost: 2, outputLen: 32, - parallelism: 1, + parallelism: 1 }); if (!validPincode) { return next( - createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN code"), + createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN") ); } - if (resource.twoFactorEnabled) { - if (!code) { - return response(res, { - data: { codeRequested: true }, + if (resource.otpEnabled) { + if (otp && email) { + const isValidCode = await isValidOtp( + email, + resource.resourceId, + otp + ); + if (!isValidCode) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP") + ); + } + + await db + .delete(resourceOtp) + .where( + and( + eq(resourceOtp.email, email), + eq(resourceOtp.resourceId, resource.resourceId) + ) + ); + } else if (email) { + try { + await sendResourceOtpEmail( + email, + resource.resourceId, + resource.name, + org.name + ); + return response(res, { + data: { otpSent: true }, + success: true, + error: false, + message: "Sent one-time otp to email address", + status: HttpCode.ACCEPTED + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to send one-time otp. Make sure the email address is correct and try again." + ) + ); + } + } else { + return response(res, { + data: { otpRequested: true }, success: true, error: false, - message: "Two-factor authentication required", - status: HttpCode.ACCEPTED, + message: "One-time otp required to complete authentication", + status: HttpCode.ACCEPTED }); } - - // TODO: Implement email OTP for resource 2fa } const token = generateSessionToken(); @@ -127,32 +185,33 @@ export async function authWithPincode( resourceId, token, pincodeId: definedPincode.pincodeId, + usedOtp: otp !== undefined, + email }); const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`; const cookie = serializeResourceSessionCookie( cookieName, token, - resource.fullDomain, + resource.fullDomain ); res.appendHeader("Set-Cookie", cookie); - logger.debug(cookie); // remove after testing - return response(res, { data: { - session: token, + session: token }, success: true, error: false, message: "Authenticated with resource successfully", - status: HttpCode.OK, + status: HttpCode.OK }); } catch (e) { + logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to authenticate with resource", - ), + "Failed to authenticate with resource" + ) ); } } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 138deaff..0eaa9472 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -10,7 +10,7 @@ import { formatAxiosError } from "@app/lib/utils"; import { GetResourceAuthInfoResponse, ListResourceRolesResponse, - ListResourceUsersResponse, + ListResourceUsersResponse } from "@server/routers/resource"; import { Button } from "@app/components/ui/button"; import { set, z } from "zod"; @@ -24,7 +24,7 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@app/components/ui/form"; import { TagInput } from "emblor"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; @@ -42,15 +42,15 @@ const UsersRolesFormSchema = z.object({ roles: z.array( z.object({ id: z.string(), - text: z.string(), - }), + text: z.string() + }) ), users: z.array( z.object({ id: z.string(), - text: z.string(), - }), - ), + text: z.string() + }) + ) }); export default function ResourceAuthenticationPage() { @@ -64,10 +64,10 @@ export default function ResourceAuthenticationPage() { const [pageLoading, setPageLoading] = useState(true); const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( - [], + [] ); const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( - [], + [] ); const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null @@ -90,7 +90,7 @@ export default function ResourceAuthenticationPage() { const usersRolesForm = useForm>({ resolver: zodResolver(UsersRolesFormSchema), - defaultValues: { roles: [], users: [] }, + defaultValues: { roles: [], users: [] } }); useEffect(() => { @@ -100,29 +100,29 @@ export default function ResourceAuthenticationPage() { rolesResponse, resourceRolesResponse, usersResponse, - resourceUsersResponse, + resourceUsersResponse ] = await Promise.all([ api.get>( - `/org/${org?.org.orgId}/roles`, + `/org/${org?.org.orgId}/roles` ), api.get>( - `/resource/${resource.resourceId}/roles`, + `/resource/${resource.resourceId}/roles` ), api.get>( - `/org/${org?.org.orgId}/users`, + `/org/${org?.org.orgId}/users` ), api.get>( - `/resource/${resource.resourceId}/users`, - ), + `/resource/${resource.resourceId}/users` + ) ]); setAllRoles( rolesResponse.data.data.roles .map((role) => ({ id: role.roleId.toString(), - text: role.name, + text: role.name })) - .filter((role) => role.text !== "Admin"), + .filter((role) => role.text !== "Admin") ); usersRolesForm.setValue( @@ -130,24 +130,24 @@ export default function ResourceAuthenticationPage() { resourceRolesResponse.data.data.roles .map((i) => ({ id: i.roleId.toString(), - text: i.name, + text: i.name })) - .filter((role) => role.text !== "Admin"), + .filter((role) => role.text !== "Admin") ); setAllUsers( usersResponse.data.data.users.map((user) => ({ id: user.id.toString(), - text: user.email, - })), + text: user.email + })) ); usersRolesForm.setValue( "users", resourceUsersResponse.data.data.users.map((i) => ({ id: i.userId.toString(), - text: i.email, - })), + text: i.email + })) ); setPageLoading(false); @@ -158,8 +158,8 @@ export default function ResourceAuthenticationPage() { title: "Failed to fetch data", description: formatAxiosError( e, - "An error occurred while fetching the data", - ), + "An error occurred while fetching the data" + ) }); } }; @@ -168,36 +168,36 @@ export default function ResourceAuthenticationPage() { }, []); async function onSubmitUsersRoles( - data: z.infer, + data: z.infer ) { try { setLoadingSaveUsersRoles(true); const jobs = [ api.post(`/resource/${resource.resourceId}/roles`, { - roleIds: data.roles.map((i) => parseInt(i.id)), + roleIds: data.roles.map((i) => parseInt(i.id)) }), api.post(`/resource/${resource.resourceId}/users`, { - userIds: data.users.map((i) => i.id), + userIds: data.users.map((i) => i.id) }), api.post(`/resource/${resource.resourceId}`, { - sso: ssoEnabled, - }), + sso: ssoEnabled + }) ]; await Promise.all(jobs); updateResource({ - sso: ssoEnabled, + sso: ssoEnabled }); updateAuthInfo({ - sso: ssoEnabled, + sso: ssoEnabled }); toast({ title: "Saved successfully", - description: "Authentication settings have been saved", + description: "Authentication settings have been saved" }); } catch (e) { console.error(e); @@ -206,8 +206,8 @@ export default function ResourceAuthenticationPage() { title: "Failed to set roles", description: formatAxiosError( e, - "An error occurred while setting the roles", - ), + "An error occurred while setting the roles" + ) }); } finally { setLoadingSaveUsersRoles(false); @@ -218,17 +218,17 @@ export default function ResourceAuthenticationPage() { setLoadingRemoveResourcePassword(true); api.post(`/resource/${resource.resourceId}/password`, { - password: null, + password: null }) .then(() => { toast({ title: "Resource password removed", description: - "The resource password has been removed successfully", + "The resource password has been removed successfully" }); updateAuthInfo({ - password: false, + password: false }); }) .catch((e) => { @@ -237,8 +237,8 @@ export default function ResourceAuthenticationPage() { title: "Error removing resource password", description: formatAxiosError( e, - "An error occurred while removing the resource password", - ), + "An error occurred while removing the resource password" + ) }); }) .finally(() => setLoadingRemoveResourcePassword(false)); @@ -248,17 +248,17 @@ export default function ResourceAuthenticationPage() { setLoadingRemoveResourcePincode(true); api.post(`/resource/${resource.resourceId}/pincode`, { - pincode: null, + pincode: null }) .then(() => { toast({ title: "Resource pincode removed", description: - "The resource password has been removed successfully", + "The resource password has been removed successfully" }); updateAuthInfo({ - pincode: false, + pincode: false }); }) .catch((e) => { @@ -267,8 +267,8 @@ export default function ResourceAuthenticationPage() { title: "Error removing resource pincode", description: formatAxiosError( e, - "An error occurred while removing the resource pincode", - ), + "An error occurred while removing the resource pincode" + ) }); }) .finally(() => setLoadingRemoveResourcePincode(false)); @@ -288,7 +288,7 @@ export default function ResourceAuthenticationPage() { onSetPassword={() => { setIsSetPasswordOpen(false); updateAuthInfo({ - password: true, + password: true }); }} /> @@ -302,7 +302,7 @@ export default function ResourceAuthenticationPage() { onSetPincode={() => { setIsSetPincodeOpen(false); updateAuthInfo({ - pincode: true, + pincode: true }); }} /> @@ -336,7 +336,7 @@ export default function ResourceAuthenticationPage() {
@@ -365,8 +365,8 @@ export default function ResourceAuthenticationPage() { "roles", newRoles as [ Tag, - ...Tag[], - ], + ...Tag[] + ] ); }} enableAutocomplete={true} @@ -378,11 +378,11 @@ export default function ResourceAuthenticationPage() { sortTags={true} styleClasses={{ tag: { - body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full", + body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" }, input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", inlineTagsContainer: - "bg-transparent", + "bg-transparent" }} /> @@ -420,8 +420,8 @@ export default function ResourceAuthenticationPage() { "users", newUsers as [ Tag, - ...Tag[], - ], + ...Tag[] + ] ); }} enableAutocomplete={true} @@ -433,11 +433,11 @@ export default function ResourceAuthenticationPage() { sortTags={true} styleClasses={{ tag: { - body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full", + body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full" }, input: "border-none bg-transparent text-inherit placeholder:text-inherit shadow-none", inlineTagsContainer: - "bg-transparent", + "bg-transparent" }} /> @@ -468,7 +468,7 @@ export default function ResourceAuthenticationPage() {
diff --git a/src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx index e2199db1..63877c99 100644 --- a/src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx @@ -10,7 +10,7 @@ import { CardDescription, CardFooter, CardHeader, - CardTitle, + CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; @@ -18,16 +18,26 @@ import { Input } from "@/components/ui/input"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@/components/ui/form"; -import { LockIcon, Binary, Key, User } from "lucide-react"; +import { + LockIcon, + Binary, + Key, + User, + Send, + ArrowLeft, + ArrowRight, + Lock +} from "lucide-react"; import { InputOTP, InputOTPGroup, - InputOTPSlot, + InputOTPSlot } from "@app/components/ui/input-otp"; import { useRouter } from "next/navigation"; import { Alert, AlertDescription } from "@app/components/ui/alert"; @@ -39,18 +49,45 @@ import { redirect } from "next/dist/server/api-utils"; import ResourceAccessDenied from "./ResourceAccessDenied"; import { createApiClient } from "@app/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useToast } from "@app/hooks/useToast"; + +const pin = z + .string() + .length(6, { message: "PIN must be exactly 6 digits" }) + .regex(/^\d+$/, { message: "PIN must only contain numbers" }); const pinSchema = z.object({ - pin: z - .string() - .length(6, { message: "PIN must be exactly 6 digits" }) - .regex(/^\d+$/, { message: "PIN must only contain numbers" }), + pin +}); + +const pinRequestOtpSchema = z.object({ + pin, + email: z.string().email() +}); + +const pinOtpSchema = z.object({ + pin, + email: z.string().email(), + otp: z.string() +}); + +const password = z.string().min(1, { + message: "Password must be at least 1 character long" }); const passwordSchema = z.object({ - password: z - .string() - .min(1, { message: "Password must be at least 1 character long" }), + password +}); + +const passwordRequestOtpSchema = z.object({ + password, + email: z.string().email() +}); + +const passwordOtpSchema = z.object({ + password, + email: z.string().email(), + otp: z.string() }); type ResourceAuthPortalProps = { @@ -68,6 +105,7 @@ type ResourceAuthPortalProps = { export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const router = useRouter(); + const { toast } = useToast(); const getNumMethods = () => { let colLength = 0; @@ -84,6 +122,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const [accessDenied, setAccessDenied] = useState(false); const [loadingLogin, setLoadingLogin] = useState(false); + const [otpState, setOtpState] = useState< + "idle" | "otp_requested" | "otp_sent" + >("idle"); + const api = createApiClient(useEnvContext()); function getDefaultSelectedMethod() { @@ -104,25 +146,77 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const pinForm = useForm>({ resolver: zodResolver(pinSchema), + defaultValues: { + pin: "" + } + }); + + const pinRequestOtpForm = useForm>({ + resolver: zodResolver(pinRequestOtpSchema), defaultValues: { pin: "", - }, + email: "" + } + }); + + const pinOtpForm = useForm>({ + resolver: zodResolver(pinOtpSchema), + defaultValues: { + pin: "", + email: "", + otp: "" + } }); const passwordForm = useForm>({ resolver: zodResolver(passwordSchema), defaultValues: { - password: "", - }, + password: "" + } }); - const onPinSubmit = (values: z.infer) => { + const passwordRequestOtpForm = useForm< + z.infer + >({ + resolver: zodResolver(passwordRequestOtpSchema), + defaultValues: { + password: "", + email: "" + } + }); + + const passwordOtpForm = useForm>({ + resolver: zodResolver(passwordOtpSchema), + defaultValues: { + password: "", + email: "", + otp: "" + } + }); + + const onPinSubmit = (values: any) => { setLoadingLogin(true); api.post>( `/auth/resource/${props.resource.id}/pincode`, - { pincode: values.pin }, + { pincode: values.pin, email: values.email, otp: values.otp } ) .then((res) => { + setPincodeError(null); + if (res.data.data.otpRequested) { + setOtpState("otp_requested"); + pinRequestOtpForm.setValue("pin", values.pin); + return; + } else if (res.data.data.otpSent) { + pinOtpForm.setValue("email", values.email); + pinOtpForm.setValue("pin", values.pin); + toast({ + title: "OTP Sent", + description: `OTP sent to ${values.email}` + }); + setOtpState("otp_sent"); + return; + } + const session = res.data.data.session; if (session) { window.location.href = props.redirect; @@ -131,21 +225,59 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { .catch((e) => { console.error(e); setPincodeError( - formatAxiosError(e, "Failed to authenticate with pincode"), + formatAxiosError(e, "Failed to authenticate with pincode") ); }) .then(() => setLoadingLogin(false)); }; - const onPasswordSubmit = (values: z.infer) => { + const resetPasswordForms = () => { + passwordForm.reset(); + passwordRequestOtpForm.reset(); + passwordOtpForm.reset(); + setOtpState("idle"); + setPasswordError(null); + }; + + const resetPinForms = () => { + pinForm.reset(); + pinRequestOtpForm.reset(); + pinOtpForm.reset(); + setOtpState("idle"); + setPincodeError(null); + } + + const onPasswordSubmit = (values: any) => { setLoadingLogin(true); + api.post>( `/auth/resource/${props.resource.id}/password`, { password: values.password, - }, + email: values.email, + otp: values.otp + } ) .then((res) => { + setPasswordError(null); + if (res.data.data.otpRequested) { + setOtpState("otp_requested"); + passwordRequestOtpForm.setValue( + "password", + values.password + ); + return; + } else if (res.data.data.otpSent) { + passwordOtpForm.setValue("email", values.email); + passwordOtpForm.setValue("password", values.password); + toast({ + title: "OTP Sent", + description: `OTP sent to ${values.email}` + }); + setOtpState("otp_sent"); + return; + } + const session = res.data.data.session; if (session) { window.location.href = props.redirect; @@ -154,7 +286,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { .catch((e) => { console.error(e); setPasswordError( - formatAxiosError(e, "Failed to authenticate with password"), + formatAxiosError(e, "Failed to authenticate with password") ); }) .finally(() => setLoadingLogin(false)); @@ -233,86 +365,237 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { value="pin" className={`${numMethods <= 1 ? "mt-0" : ""}`} > - - - ( - - - 6-digit PIN Code - - -
- - - - - - - - - - -
-
- -
+ {otpState === "idle" && ( + + - {pincodeError && ( - - - {pincodeError} - - - )} - - - + ( + + + 6-digit PIN + Code + + +
+ + + + + + + + + + +
+
+ +
+ )} + /> + {pincodeError && ( + + + {pincodeError} + + + )} + + + + )} + + {otpState === "otp_requested" && ( +
+ + ( + + + Email + + + + + + A one-time + code will be + sent to this + email. + + + + )} + /> + + {pincodeError && ( + + + {pincodeError} + + + )} + + + + + + + )} + + {otpState === "otp_sent" && ( +
+ + ( + + + One-Time + Password + (OTP) + + + + + + + )} + /> + + {pincodeError && ( + + + {pincodeError} + + + )} + + + + + + + + + )} )} {props.methods.password && ( @@ -320,52 +603,202 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { value="password" className={`${numMethods <= 1 ? "mt-0" : ""}`} > -
- - ( - - - Password - - - - - - + {otpState === "idle" && ( + + - {passwordError && ( - - - {passwordError} - - - )} - - - + ( + + + Password + + + + + + + )} + /> + + {passwordError && ( + + + {passwordError} + + + )} + + + + + )} + + {otpState === "otp_requested" && ( +
+ + ( + + + Email + + + + + + A one-time + code will be + sent to this + email. + + + + )} + /> + + {passwordError && ( + + + {passwordError} + + + )} + + + + + + + )} + + {otpState === "otp_sent" && ( +
+ + ( + + + One-Time + Password + (OTP) + + + + + + + )} + /> + + {passwordError && ( + + + {passwordError} + + + )} + + + + + + + + + )} )} {props.methods.sso && ( diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index b7a04c2f..9da6f15f 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -23,11 +23,12 @@ import { } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { LoginResponse } from "@server/routers/auth"; -import { api } from "@app/api"; import { useRouter } from "next/navigation"; import { AxiosResponse } from "axios"; import { formatAxiosError } from "@app/lib/utils"; import { LockIcon } from "lucide-react"; +import { createApiClient } from "@app/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type LoginFormProps = { redirect?: string; @@ -44,6 +45,8 @@ const formSchema = z.object({ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -58,6 +61,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { async function onSubmit(values: z.infer) { const { email, password } = values; + setLoading(true); const res = await api