mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-11 04:12:26 +00:00
complete web device auth flow
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||
import { boolean } from "yargs";
|
||||
|
||||
export const domains = sqliteTable("domains", {
|
||||
domainId: text("domainId").primaryKey(),
|
||||
@@ -25,11 +24,10 @@ export const dnsRecords = sqliteTable("dnsRecords", {
|
||||
|
||||
recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT"
|
||||
baseDomain: text("baseDomain"),
|
||||
value: text("value").notNull(),
|
||||
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
|
||||
value: text("value").notNull(),
|
||||
verified: integer("verified", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
|
||||
export const orgs = sqliteTable("orgs", {
|
||||
orgId: text("orgId").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
@@ -142,9 +140,10 @@ export const resources = sqliteTable("resources", {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
proxyProtocol: integer("proxyProtocol", { mode: "boolean" }).notNull().default(false),
|
||||
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1)
|
||||
|
||||
});
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
@@ -802,6 +801,19 @@ export const requestAuditLog = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", {
|
||||
codeId: integer("codeId").primaryKey({ autoIncrement: true }),
|
||||
code: text("code").notNull().unique(),
|
||||
ip: text("ip"),
|
||||
city: text("city"),
|
||||
deviceName: text("deviceName"),
|
||||
applicationName: text("applicationName").notNull(),
|
||||
expiresAt: integer("expiresAt").notNull(),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
|
||||
userId: text("userId").references(() => users.userId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -859,3 +871,4 @@ export type LicenseKey = InferSelectModel<typeof licenseKey>;
|
||||
export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { NextFunction, Response } from "express";
|
||||
import ErrorResponse from "@server/types/ErrorResponse";
|
||||
import { db } from "@server/db";
|
||||
import { users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||
|
||||
@@ -18,19 +13,8 @@ export const verifySessionMiddleware = async (
|
||||
return next(unauthorized());
|
||||
}
|
||||
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.userId, user.userId));
|
||||
|
||||
if (!existingUser || !existingUser[0]) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "User does not exist")
|
||||
);
|
||||
}
|
||||
|
||||
req.user = existingUser[0];
|
||||
req.user = user;
|
||||
req.session = session;
|
||||
|
||||
next();
|
||||
return next();
|
||||
};
|
||||
|
||||
@@ -45,6 +45,12 @@ export class PrivateConfig {
|
||||
|
||||
this.rawPrivateConfig = parsedPrivateConfig;
|
||||
|
||||
if (this.rawPrivateConfig.branding?.hide_auth_layout_footer) {
|
||||
process.env.HIDE_AUTH_LAYOUT_FOOTER = JSON.stringify(
|
||||
this.rawPrivateConfig.branding?.hide_auth_layout_footer
|
||||
);
|
||||
}
|
||||
|
||||
if (this.rawPrivateConfig.branding?.colors) {
|
||||
process.env.BRANDING_COLORS = JSON.stringify(
|
||||
this.rawPrivateConfig.branding?.colors
|
||||
|
||||
@@ -124,6 +124,7 @@ export const privateConfigSchema = z.object({
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
hide_auth_layout_footer: z.boolean().optional().default(false),
|
||||
login_page: z
|
||||
.object({
|
||||
subtitle_text: z.string().optional(),
|
||||
|
||||
@@ -13,4 +13,7 @@ export * from "./initialSetupComplete";
|
||||
export * from "./validateSetupToken";
|
||||
export * from "./changePassword";
|
||||
export * from "./checkResourceSession";
|
||||
export * from "./securityKey";
|
||||
export * from "./securityKey";
|
||||
export * from "./startDeviceWebAuth";
|
||||
export * from "./verifyDeviceWebAuth";
|
||||
export * from "./pollDeviceWebAuth";
|
||||
166
server/routers/auth/pollDeviceWebAuth.ts
Normal file
166
server/routers/auth/pollDeviceWebAuth.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
import { response } from "@server/lib/response";
|
||||
import { db, deviceWebAuthCodes } from "@server/db";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
import {
|
||||
createSession,
|
||||
generateSessionToken
|
||||
} from "@server/auth/sessions/app";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
code: z.string().min(1, "Code is required")
|
||||
});
|
||||
|
||||
export type PollDeviceWebAuthParams = z.infer<typeof paramsSchema>;
|
||||
|
||||
export type PollDeviceWebAuthResponse = {
|
||||
verified: boolean;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
// Helper function to extract IP from request (same as in startDeviceWebAuth)
|
||||
function extractIpFromRequest(req: Request): string | undefined {
|
||||
const ip = req.ip || req.socket.remoteAddress;
|
||||
if (!ip) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle IPv6 format [::1] or IPv4 format
|
||||
if (ip.startsWith("[") && ip.includes("]")) {
|
||||
const ipv6Match = ip.match(/\[(.*?)\]/);
|
||||
if (ipv6Match) {
|
||||
return ipv6Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle IPv4 with port (split at last colon)
|
||||
const lastColonIndex = ip.lastIndexOf(":");
|
||||
if (lastColonIndex !== -1) {
|
||||
return ip.substring(0, lastColonIndex);
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
export async function pollDeviceWebAuth(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { code } = parsedParams.data;
|
||||
const now = Date.now();
|
||||
const requestIp = extractIpFromRequest(req);
|
||||
|
||||
// Find the code in the database
|
||||
const [deviceCode] = await db
|
||||
.select()
|
||||
.from(deviceWebAuthCodes)
|
||||
.where(eq(deviceWebAuthCodes.code, code))
|
||||
.limit(1);
|
||||
|
||||
if (!deviceCode) {
|
||||
return response<PollDeviceWebAuthResponse>(res, {
|
||||
data: {
|
||||
verified: false
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Code not found",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
// Check if code is expired
|
||||
if (deviceCode.expiresAt <= now) {
|
||||
return response<PollDeviceWebAuthResponse>(res, {
|
||||
data: {
|
||||
verified: false
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Code expired",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
// Check if code is verified
|
||||
if (!deviceCode.verified) {
|
||||
return response<PollDeviceWebAuthResponse>(res, {
|
||||
data: {
|
||||
verified: false
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Code not yet verified",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
// Check if IP matches
|
||||
if (!requestIp || !deviceCode.ip || requestIp !== deviceCode.ip) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"IP address does not match"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if userId is set (should be set when verified)
|
||||
if (!deviceCode.userId) {
|
||||
logger.error("Device code is verified but userId is missing", { codeId: deviceCode.codeId });
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Invalid code state"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Generate session token
|
||||
const token = generateSessionToken();
|
||||
await createSession(token, deviceCode.userId);
|
||||
|
||||
// Delete the code after successful exchange for a token
|
||||
await db
|
||||
.delete(deviceWebAuthCodes)
|
||||
.where(eq(deviceWebAuthCodes.codeId, deviceCode.codeId));
|
||||
|
||||
return response<PollDeviceWebAuthResponse>(res, {
|
||||
data: {
|
||||
verified: true,
|
||||
token
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Code verified and session created",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to poll device code"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import moment from "moment";
|
||||
|
||||
export const bodySchema = z.object({
|
||||
const bodySchema = z.object({
|
||||
email: z.string().toLowerCase().email(),
|
||||
password: passwordSchema,
|
||||
setupToken: z.string().min(1, "Setup token is required")
|
||||
|
||||
141
server/routers/auth/startDeviceWebAuth.ts
Normal file
141
server/routers/auth/startDeviceWebAuth.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
import { response } from "@server/lib/response";
|
||||
import { db, deviceWebAuthCodes } from "@server/db";
|
||||
import { alphabet, generateRandomString } from "oslo/crypto";
|
||||
import { createDate } from "oslo";
|
||||
import { TimeSpan } from "oslo";
|
||||
import { maxmindLookup } from "@server/db/maxmind";
|
||||
|
||||
const bodySchema = z.object({
|
||||
deviceName: z.string().optional(),
|
||||
applicationName: z.string().min(1, "Application name is required")
|
||||
}).strict();
|
||||
|
||||
export type StartDeviceWebAuthBody = z.infer<typeof bodySchema>;
|
||||
|
||||
export type StartDeviceWebAuthResponse = {
|
||||
code: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
// Helper function to generate device code in format A1AJ-N5JD
|
||||
function generateDeviceCode(): string {
|
||||
const part1 = generateRandomString(4, alphabet("A-Z", "0-9"));
|
||||
const part2 = generateRandomString(4, alphabet("A-Z", "0-9"));
|
||||
return `${part1}-${part2}`;
|
||||
}
|
||||
|
||||
// Helper function to extract IP from request
|
||||
function extractIpFromRequest(req: Request): string | undefined {
|
||||
const ip = req.ip || req.socket.remoteAddress;
|
||||
if (!ip) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle IPv6 format [::1] or IPv4 format
|
||||
if (ip.startsWith("[") && ip.includes("]")) {
|
||||
const ipv6Match = ip.match(/\[(.*?)\]/);
|
||||
if (ipv6Match) {
|
||||
return ipv6Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Handle IPv4 with port (split at last colon)
|
||||
const lastColonIndex = ip.lastIndexOf(":");
|
||||
if (lastColonIndex !== -1) {
|
||||
return ip.substring(0, lastColonIndex);
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
// Helper function to get city from IP (if available)
|
||||
async function getCityFromIp(ip: string): Promise<string | undefined> {
|
||||
try {
|
||||
if (!maxmindLookup) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = maxmindLookup.get(ip);
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// MaxMind CountryResponse doesn't include city by default
|
||||
// If city data is available, it would be in result.city?.names?.en
|
||||
// But since we're using CountryResponse type, we'll just return undefined
|
||||
// The user said "don't do this if not easy", so we'll skip city for now
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
logger.debug("Failed to get city from IP", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDeviceWebAuth(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { deviceName, applicationName } = parsedBody.data;
|
||||
|
||||
// Generate device code
|
||||
const code = generateDeviceCode();
|
||||
|
||||
// Extract IP from request
|
||||
const ip = extractIpFromRequest(req);
|
||||
|
||||
// Get city (optional, may return undefined)
|
||||
const city = ip ? await getCityFromIp(ip) : undefined;
|
||||
|
||||
// Set expiration to 5 minutes from now
|
||||
const expiresAt = createDate(new TimeSpan(5, "m")).getTime();
|
||||
|
||||
// Insert into database
|
||||
await db.insert(deviceWebAuthCodes).values({
|
||||
code,
|
||||
ip: ip || null,
|
||||
city: city || null,
|
||||
deviceName: deviceName || null,
|
||||
applicationName,
|
||||
expiresAt,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
return response<StartDeviceWebAuthResponse>(res, {
|
||||
data: {
|
||||
code,
|
||||
expiresAt
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Device web auth code generated",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to start device web auth"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
126
server/routers/auth/verifyDeviceWebAuth.ts
Normal file
126
server/routers/auth/verifyDeviceWebAuth.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import logger from "@server/logger";
|
||||
import { response } from "@server/lib/response";
|
||||
import { db, deviceWebAuthCodes } from "@server/db";
|
||||
import { eq, and, gt } from "drizzle-orm";
|
||||
|
||||
const bodySchema = z.object({
|
||||
code: z.string().min(1, "Code is required"),
|
||||
verify: z.boolean().optional().default(false) // If false, just check and return metadata
|
||||
}).strict();
|
||||
|
||||
export type VerifyDeviceWebAuthBody = z.infer<typeof bodySchema>;
|
||||
|
||||
export type VerifyDeviceWebAuthResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
metadata?: {
|
||||
ip: string | null;
|
||||
city: string | null;
|
||||
deviceName: string | null;
|
||||
applicationName: string;
|
||||
createdAt: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function verifyDeviceWebAuth(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { code, verify } = parsedBody.data;
|
||||
const now = Date.now();
|
||||
|
||||
logger.debug("Verifying device web auth code:", { code });
|
||||
|
||||
// Find the code in the database that is not expired and not already verified
|
||||
const [deviceCode] = await db
|
||||
.select()
|
||||
.from(deviceWebAuthCodes)
|
||||
.where(
|
||||
and(
|
||||
eq(deviceWebAuthCodes.code, code),
|
||||
gt(deviceWebAuthCodes.expiresAt, now),
|
||||
eq(deviceWebAuthCodes.verified, false)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
logger.debug("Device code lookup result:", deviceCode);
|
||||
|
||||
if (!deviceCode) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid, expired, or already verified code"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If verify is false, just return metadata without verifying
|
||||
if (!verify) {
|
||||
return response<VerifyDeviceWebAuthResponse>(res, {
|
||||
data: {
|
||||
success: true,
|
||||
message: "Code is valid",
|
||||
metadata: {
|
||||
ip: deviceCode.ip,
|
||||
city: deviceCode.city,
|
||||
deviceName: deviceCode.deviceName,
|
||||
applicationName: deviceCode.applicationName,
|
||||
createdAt: deviceCode.createdAt
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Code validation successful",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
// Update the code to mark it as verified and store the user who verified it
|
||||
await db
|
||||
.update(deviceWebAuthCodes)
|
||||
.set({
|
||||
verified: true,
|
||||
userId: req.user!.userId
|
||||
})
|
||||
.where(eq(deviceWebAuthCodes.codeId, deviceCode.codeId));
|
||||
|
||||
return response<VerifyDeviceWebAuthResponse>(res, {
|
||||
data: {
|
||||
success: true,
|
||||
message: "Device code verified successfully"
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Device code verified successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to verify device code"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1237,4 +1237,52 @@ authRouter.delete(
|
||||
store: createStore()
|
||||
}),
|
||||
auth.deleteSecurityKey
|
||||
);
|
||||
);
|
||||
|
||||
authRouter.post(
|
||||
"/device-web-auth/start",
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 30, // Allow 30 device auth code requests per 15 minutes per IP
|
||||
keyGenerator: (req) =>
|
||||
`deviceWebAuthStart:${ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only request a device auth code ${30} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
},
|
||||
store: createStore()
|
||||
}),
|
||||
auth.startDeviceWebAuth
|
||||
);
|
||||
|
||||
authRouter.get(
|
||||
"/device-web-auth/poll/:code",
|
||||
rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 60, // Allow 60 polling requests per minute per IP (poll every second)
|
||||
keyGenerator: (req) =>
|
||||
`deviceWebAuthPoll:${ipKeyGenerator(req.ip || "")}:${req.params.code}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only poll a device auth code ${60} times per minute. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
},
|
||||
store: createStore()
|
||||
}),
|
||||
auth.pollDeviceWebAuth
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/device-web-auth/verify",
|
||||
rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 50, // Allow 50 verification attempts per 15 minutes per user
|
||||
keyGenerator: (req) =>
|
||||
`deviceWebAuthVerify:${req.user?.userId || ipKeyGenerator(req.ip || "")}`,
|
||||
handler: (req, res, next) => {
|
||||
const message = `You can only verify a device auth code ${50} times every ${15} minutes. Please try again later.`;
|
||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||
},
|
||||
store: createStore()
|
||||
}),
|
||||
auth.verifyDeviceWebAuth
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user