[next] Email (#2261)

* feat: suspending, banning, now propogation

* feat: added email rendering + localization support

* fix: fix related to lib

* refactor: moved juicer to queue task

* refactor: cleanup of job processor

* refactor: improved error messaging around failed email

* feat: initial forgot passwor impl

* fix: fixed rebase errors

* feat: send back Content-Language header with requests

* feat: added ban email

* feat: implemented forgotten password API

* fix: linting

* feat: support more emails

* fix: promise patches

* feat: initial confirm email API

* feat: added rate limiting

* feat: added URL support

* feat: added email docs

* fix: updated docs

* chore: documentation review

* fix: fixed build bug

* feat: implement forgot password in auth popup

* test: add tests + fixes

* chore: rename StatelessComponent to FunctionComponent

* fix: types and test fixes

* chore: upgrade deps

* fix: THANK YOU TESTS FOR SAVING MY A**

* chore: reorder imports

* chore: remove obsolete !

* feat: implement accounts bundle

* refactor: review suggestion

* fix: rebase upgrade error

* fix: rebase bug

* feat: reset password link support

* test: add tests for account password reset page

* fix: remove redirect uri

* fix: revert local state changes
This commit is contained in:
Wyatt Johnson
2019-05-09 22:54:56 +02:00
committed by Kiwi
parent 945bd7f2b0
commit df57b4eb17
487 changed files with 6794 additions and 2431 deletions
@@ -0,0 +1,247 @@
import Joi from "joi";
import { AppOptions } from "talk-server/app";
import { validate } from "talk-server/app/request/body";
import { RequestLimiter } from "talk-server/app/request/limiter";
import {
AuthenticationError,
UserForbiddenError,
UserNotFoundError,
} from "talk-server/errors";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { retrieveUser, User } from "talk-server/models/user";
import { decodeJWT, extractJWTFromRequest } from "talk-server/services/jwt";
import {
confirmEmail,
sendConfirmationEmail,
verifyConfirmTokenString,
} from "talk-server/services/users/auth/confirm";
import { RequestHandler } from "talk-server/types/express";
export type ConfirmRequestOptions = Pick<
AppOptions,
"mongo" | "mailerQueue" | "signingConfig" | "redis" | "config"
>;
export interface ConfirmRequestBody {
userID?: string;
}
export const ConfirmRequestBodySchema = Joi.object()
.keys({
userID: Joi.string().default(undefined),
})
.optionalKeys(["userID"]);
export const confirmRequestHandler = ({
redis: client,
config,
mongo,
mailerQueue,
signingConfig,
}: ConfirmRequestOptions): RequestHandler => {
const ipLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 10,
prefix: "ip",
});
const userIDLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 10,
prefix: "userID",
});
return async (req, res, next) => {
try {
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const talk = req.talk!;
const tenant = talk.tenant!;
// Grab the requesting user.
const requestingUser = req.user;
if (!requestingUser) {
throw new AuthenticationError("no user on request");
}
// Store the user's ID that should have a email confirmation email sent.
let targetUserID: string = requestingUser.id;
// Get the fields from the body. Validate will throw an error if the body
// does not conform to the specification.
const body: ConfirmRequestBody = validate(
ConfirmRequestBodySchema,
req.body
);
// Now check to see if they have specified a userID in the request.
if (body.userID) {
// If the user is an admin user, they can request a confirmation email for
// another user, so check their role.
if (requestingUser.role === GQLUSER_ROLE.ADMIN) {
if (body.userID) {
targetUserID = body.userID;
}
} else {
throw new UserForbiddenError(
"attempt to send a confirmation email as a non-admin user",
"/api/account/confirm",
"POST",
requestingUser.id
);
}
}
await userIDLimiter.test(req, targetUserID);
const log = talk.logger.child({
targetUserID,
requestingUserID: requestingUser.id,
tenantID: tenant.id,
});
// Lookup the user.
const targetUser = await retrieveUser(mongo, tenant.id, targetUserID);
if (!targetUser) {
throw new UserNotFoundError(targetUserID);
}
await sendConfirmationEmail(
mongo,
mailerQueue,
tenant,
config,
signingConfig,
// TODO: (wyattjoh) evaluate the use of required here.
targetUser as Required<User>,
talk.now
);
log.trace("sent confirm email with token");
return res.sendStatus(204);
} catch (err) {
return next(err);
}
};
};
export type ConfirmCheckOptions = Pick<
AppOptions,
"mongo" | "signingConfig" | "redis"
>;
export const confirmCheckHandler = ({
redis: client,
mongo,
signingConfig,
}: ConfirmCheckOptions): RequestHandler => {
const ipLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 10,
prefix: "ip",
});
const subLimiter = new RequestLimiter({
client,
ttl: "5m",
max: 10,
prefix: "sub",
});
return async (req, res, next) => {
try {
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const talk = req.talk!;
const tenant = talk.tenant!;
// TODO: evaluate verifying if the Tenant allows verifications to short circuit.
// Grab the token from the request.
const tokenString = extractJWTFromRequest(req, true);
if (!tokenString) {
return res.sendStatus(400);
}
// Decode the token so we can rate limit based on the user's ID.
const { sub } = decodeJWT(tokenString);
if (sub) {
await subLimiter.test(req, sub);
}
// Verify the token.
await verifyConfirmTokenString(
mongo,
tenant,
signingConfig,
tokenString,
talk.now
);
return res.sendStatus(204);
} catch (err) {
return next(err);
}
};
};
export type ConfirmOptions = Pick<
AppOptions,
"mongo" | "signingConfig" | "redis"
>;
export const confirmHandler = ({
redis: client,
mongo,
signingConfig,
}: ConfirmOptions): RequestHandler => {
const ipLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 10,
prefix: "ip",
});
const subLimiter = new RequestLimiter({
client,
ttl: "5m",
max: 10,
prefix: "sub",
});
return async (req, res, next) => {
try {
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const talk = req.talk!;
const tenant = talk.tenant!;
// Grab the token from the request.
const tokenString = extractJWTFromRequest(req, true);
if (!tokenString) {
return res.sendStatus(400);
}
// Decode the token so we can rate limit based on the user's ID.
const { sub } = decodeJWT(tokenString);
if (sub) {
await subLimiter.test(req, sub);
}
// Execute the reset.
await confirmEmail(mongo, tenant, signingConfig, tokenString, talk.now);
return res.sendStatus(204);
} catch (err) {
return next(err);
}
};
};
@@ -0,0 +1 @@
export * from "./confirm";
@@ -0,0 +1 @@
export * from "./local";
@@ -1,119 +0,0 @@
import { RequestHandler } from "express";
import Joi from "joi";
import { AppOptions } from "talk-server/app";
import {
handleLogout,
handleSuccessfulLogin,
} from "talk-server/app/middleware/passport";
import { validate } from "talk-server/app/request/body";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalProfile } from "talk-server/models/user";
import { insert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface SignupBody {
username: string;
password: string;
email: string;
}
export const SignupBodySchema = Joi.object().keys({
username: Joi.string().trim(),
password: Joi.string(),
email: Joi.string()
.trim()
.lowercase()
.email(),
});
export type SignupOptions = Pick<AppOptions, "mongo" | "signingConfig">;
export const signupHandler = ({
mongo,
signingConfig,
}: SignupOptions): RequestHandler => async (req: Request, res, next) => {
try {
// TODO: rate limit based on the IP address and user agent.
// Tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
const now = req.talk!.now;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
// TODO: replace with better error.
return next(new Error("integration is disabled"));
}
if (!tenant.auth.integrations.local.allowRegistration) {
// TODO: replace with better error.
return next(new Error("registration is disabled"));
}
// Get the fields from the body. Validate will throw an error if the body
// does not conform to the specification.
const { username, password, email }: SignupBody = validate(
SignupBodySchema,
req.body
);
// Configure with profile.
const profile: LocalProfile = {
id: email,
type: "local",
password,
};
// Create the new user.
const user = await insert(
mongo,
tenant,
{
email,
username,
profiles: [profile],
// New users signing up via local auth will have the commenter role to
// start with.
role: GQLUSER_ROLE.COMMENTER,
},
now
);
// Send off to the passport handler.
return handleSuccessfulLogin(user, signingConfig, req, res, next);
} catch (err) {
return next(err);
}
};
export type LogoutOptions = Pick<AppOptions, "redis">;
export const logoutHandler = ({
redis,
}: LogoutOptions): RequestHandler => async (req: Request, res, next) => {
try {
// TODO: rate limit based on the IP address and user agent.
// Tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
// TODO: replace with better error.
return next(new Error("integration is disabled"));
}
// Get the user on the request.
const user = req.user;
if (!user) {
// If a user is already logged out, then there's no need to do it again!
return res.sendStatus(204);
}
// Delegate to the logout handler.
return handleLogout(redis, req, res);
} catch (err) {
return next(err);
}
};
@@ -0,0 +1,276 @@
import Joi from "joi";
import { AppOptions } from "talk-server/app";
import { validate } from "talk-server/app/request/body";
import { RequestLimiter } from "talk-server/app/request/limiter";
import { IntegrationDisabled } from "talk-server/errors";
import { retrieveUserWithProfile } from "talk-server/models/user";
import { decodeJWT, extractJWTFromRequest } from "talk-server/services/jwt";
import {
generateResetURL,
resetPassword,
verifyResetTokenString,
} from "talk-server/services/users/auth";
import { validateEmail } from "talk-server/services/users/helpers";
import { RequestHandler } from "talk-server/types/express";
export interface ForgotBody {
email: string;
}
export const ForgotBodySchema = Joi.object().keys({
email: Joi.string()
.trim()
.lowercase()
.email(),
});
export type ForgotOptions = Pick<
AppOptions,
"mongo" | "signingConfig" | "mailerQueue" | "redis" | "config"
>;
export const forgotHandler = ({
config,
redis: client,
mongo,
signingConfig,
mailerQueue,
}: ForgotOptions): RequestHandler => {
const ipLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 10,
prefix: "ip",
});
const emailLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 1,
prefix: "email",
});
return async (req, res, next) => {
try {
// Limit based on the IP address.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const talk = req.talk!;
const tenant = talk.tenant!;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
throw new IntegrationDisabled("local");
}
// Get the fields from the body. Validate will throw an error if the body
// does not conform to the specification.
const { email }: ForgotBody = validate(ForgotBodySchema, req.body);
// Validate the email address. This will ensure that if we end up rate
// limiting based on it, it isn't too long.
validateEmail(email);
// Limit based on the email address.
await emailLimiter.test(req, email);
const log = talk.logger.child({
email,
tenantID: tenant.id,
});
// Lookup the user.
const user = await retrieveUserWithProfile(mongo, tenant.id, {
id: email,
type: "local",
});
if (!user) {
// No user, therefore we don't have to send anything!.
// TODO: (wyattjoh) delay the response to avoid timing attacks.
log.warn("attempted password forgot for user that wasn't found");
return res.sendStatus(204);
}
// Prepare the email content to send to the user.
const resetURL = await generateResetURL(
mongo,
tenant,
config,
signingConfig,
user,
req.talk!.now
);
// Add the email to the processing queue.
await mailerQueue.add({
template: {
name: "forgot-password",
context: {
resetURL,
// TODO: (wyattjoh) possibly reevaluate the use of a required username.
username: user.username!,
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
},
},
tenantID: tenant.id,
message: {
to: email,
},
});
log.trace("sent forgotten password email with token");
return res.sendStatus(204);
} catch (err) {
return next(err);
}
};
};
export interface ForgotResetBody {
password: string;
}
export const ForgotResetBodySchema = Joi.object().keys({
password: Joi.string(),
});
export type ForgotResetOptions = Pick<
AppOptions,
"mongo" | "signingConfig" | "mailerQueue" | "redis"
>;
export const forgotResetHandler = ({
redis: client,
mongo,
signingConfig,
}: ForgotResetOptions): RequestHandler => {
const ipLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 10,
prefix: "ip",
});
const subLimiter = new RequestLimiter({
client,
ttl: "5m",
max: 10,
prefix: "sub",
});
return async (req, res, next) => {
try {
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const talk = req.talk!;
const tenant = talk.tenant!;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
throw new IntegrationDisabled("local");
}
// Get the fields from the body. Validate will throw an error if the body
// does not conform to the specification.
const { password }: ForgotResetBody = validate(
ForgotResetBodySchema,
req.body
);
// Grab the token from the request.
const tokenString = extractJWTFromRequest(req, true);
if (!tokenString) {
return res.sendStatus(400);
}
// Decode the token so we can rate limit based on the user's ID.
const { sub } = decodeJWT(tokenString);
if (sub) {
await subLimiter.test(req, sub);
}
// Execute the reset.
await resetPassword(
mongo,
tenant,
signingConfig,
tokenString,
password,
talk.now
);
return res.sendStatus(204);
} catch (err) {
return next(err);
}
};
};
export type ForgotCheckOptions = Pick<
AppOptions,
"mongo" | "signingConfig" | "redis"
>;
export const forgotCheckHandler = ({
redis: client,
mongo,
signingConfig,
}: ForgotCheckOptions): RequestHandler => {
const ipLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 100,
prefix: "ip",
});
const subLimiter = new RequestLimiter({
client,
ttl: "5m",
max: 100,
prefix: "sub",
});
return async (req, res, next) => {
try {
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const talk = req.talk!;
const tenant = talk.tenant!;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
throw new IntegrationDisabled("local");
}
// Grab the token from the request.
const tokenString = extractJWTFromRequest(req, true);
if (!tokenString) {
return res.sendStatus(400);
}
// Decode the token so we can rate limit based on the user's ID.
const { sub } = decodeJWT(tokenString);
if (sub) {
await subLimiter.test(req, sub);
}
// Verify the token.
await verifyResetTokenString(
mongo,
tenant,
signingConfig,
tokenString,
talk.now
);
return res.sendStatus(204);
} catch (err) {
return next(err);
}
};
};
@@ -0,0 +1,35 @@
import { AppOptions } from "talk-server/app";
import { handleLogout } from "talk-server/app/middleware/passport";
import { IntegrationDisabled } from "talk-server/errors";
import { RequestHandler } from "talk-server/types/express";
export * from "./signup";
export * from "./forgot";
export type LogoutOptions = Pick<AppOptions, "redis">;
export const logoutHandler = ({
redis,
}: LogoutOptions): RequestHandler => async (req, res, next) => {
try {
// Tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
throw new IntegrationDisabled("local");
}
// Get the user on the request.
const user = req.user;
if (!user) {
// If a user is already logged out, then there's no need to do it again!
return res.sendStatus(204);
}
// Delegate to the logout handler.
return handleLogout(redis, req, res);
} catch (err) {
return next(err);
}
};
@@ -0,0 +1,113 @@
import Joi from "joi";
import { AppOptions } from "talk-server/app";
import { handleSuccessfulLogin } from "talk-server/app/middleware/passport";
import { validate } from "talk-server/app/request/body";
import { RequestLimiter } from "talk-server/app/request/limiter";
import { IntegrationDisabled } from "talk-server/errors";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalProfile, User } from "talk-server/models/user";
import { insert } from "talk-server/services/users";
import { sendConfirmationEmail } from "talk-server/services/users/auth";
import { RequestHandler } from "talk-server/types/express";
export interface SignupBody {
username: string;
password: string;
email: string;
}
export const SignupBodySchema = Joi.object().keys({
username: Joi.string().trim(),
password: Joi.string(),
email: Joi.string()
.trim()
.lowercase()
.email(),
});
export type SignupOptions = Pick<
AppOptions,
"mongo" | "signingConfig" | "mailerQueue" | "redis" | "config"
>;
export const signupHandler = ({
config,
redis: client,
mongo,
signingConfig,
mailerQueue,
}: SignupOptions): RequestHandler => {
const ipLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 10,
prefix: "ip",
});
return async (req, res, next) => {
try {
// Rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
// Tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
const now = req.talk!.now;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
throw new IntegrationDisabled("local");
}
if (!tenant.auth.integrations.local.allowRegistration) {
// TODO: replace with better error.
return next(new Error("registration is disabled"));
}
// Get the fields from the body. Validate will throw an error if the body
// does not conform to the specification.
const { username, password, email }: SignupBody = validate(
SignupBodySchema,
req.body
);
// Configure with profile.
const profile: LocalProfile = {
id: email,
type: "local",
password,
};
// Create the new user.
const user = await insert(
mongo,
tenant,
{
email,
username,
profiles: [profile],
// New users signing up via local auth will have the commenter role to
// start with.
role: GQLUSER_ROLE.COMMENTER,
},
now
);
// Send off the confirm email.
await sendConfirmationEmail(
mongo,
mailerQueue,
tenant,
config,
signingConfig,
user as Required<User>,
now
);
// Send off to the passport handler.
return handleSuccessfulLogin(user, signingConfig, req, res, next);
} catch (err) {
return next(err);
}
};
};
+2 -1
View File
@@ -30,7 +30,7 @@ export const graphQLHandler = ({
}
// Pull out some useful properties from Talk.
const { id, now, tenant, cache } = req.talk;
const { id, now, tenant, cache, logger } = req.talk;
if (!cache) {
throw new Error("cache was not set");
@@ -49,6 +49,7 @@ export const graphQLHandler = ({
req,
config,
tenant,
logger,
user: req.user,
tenantCache: cache.tenant,
}),
@@ -0,0 +1,5 @@
export * from "./account";
export * from "./auth";
export * from "./graphql";
export * from "./install";
export * from "./version";
+5 -8
View File
@@ -1,11 +1,9 @@
import { Redis } from "ioredis";
import Joi from "joi";
import { Db } from "mongodb";
import { LanguageCode, LOCALES } from "talk-common/helpers/i18n/locales";
import { Omit } from "talk-common/types";
import { AppOptions } from "talk-server/app";
import { validate } from "talk-server/app/request/body";
import { Config } from "talk-server/config";
import { TenantInstalledAlreadyError } from "talk-server/errors";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalProfile } from "talk-server/models/user";
@@ -53,11 +51,10 @@ const TenantInstallBodySchema = Joi.object().keys({
}),
});
export interface TenantInstallHandlerOptions {
redis: Redis;
mongo: Db;
config: Config;
}
export type TenantInstallHandlerOptions = Pick<
AppOptions,
"redis" | "mongo" | "config" | "mailerQueue"
>;
export const installHandler = ({
mongo,
+1
View File
@@ -0,0 +1 @@
export * from "./api";
+1 -1
View File
@@ -7,7 +7,7 @@ import {
prefixSchemeIfRequired,
} from "talk-server/app/url";
import { Tenant } from "talk-server/models/tenant";
import { isURLPermitted } from "talk-server/services/stories";
import { isURLPermitted } from "talk-server/services/tenant/url";
import { Request, RequestHandler } from "talk-server/types/express";
/**
+3
View File
@@ -0,0 +1,3 @@
import express from "express";
export const jsonMiddleware = express.json({});
@@ -17,7 +17,6 @@ import {
extractJWTFromRequest,
JWTSigningConfig,
revokeJWT,
SigningTokenOptions,
signTokenString,
} from "talk-server/services/jwt";
import { Request } from "talk-server/types/express";
@@ -109,18 +108,11 @@ export async function handleSuccessfulLogin(
next: NextFunction
) {
try {
// Talk is guaranteed at this point.
const { tenant } = req.talk!;
const options: SigningTokenOptions = {};
if (tenant) {
// Attach the tenant's id to the issued token as a `iss` claim.
options.issuer = tenant.id;
}
// Tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
// Grab the token.
const token = await signTokenString(signingConfig, user, options);
const token = await signTokenString(signingConfig, user, tenant);
// Set the cache control headers.
res.header("Cache-Control", "private, no-cache, no-store, must-revalidate");
@@ -152,18 +144,11 @@ export async function handleOAuth2Callback(
}
try {
// Talk is guaranteed at this point.
const { tenant } = req.talk!;
const options: SigningTokenOptions = {};
if (tenant) {
// Attach the tenant's id to the issued token as a `iss` claim.
options.issuer = tenant.id;
}
// Tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
// Grab the token.
const token = await signTokenString(signingConfig, user, options);
const token = await signTokenString(signingConfig, user, tenant);
// Send back the details!
res.redirect(path + `#accessToken=${token}`);
@@ -190,8 +175,12 @@ export const wrapOAuth2Authn = (
authenticator.authenticate(
name,
{ ...options, session: false },
(err: Error | null, user: User | null) => {
handleOAuth2Callback(err, user, signingConfig, req, res);
async (err: Error | null, user: User | null) => {
try {
await handleOAuth2Callback(err, user, signingConfig, req, res);
} catch (err) {
return next(err);
}
}
)(req, res, next);
@@ -213,7 +202,7 @@ export const wrapAuthn = (
authenticator.authenticate(
name,
{ ...options, session: false },
(err: Error | null, user: User | null) => {
async (err: Error | null, user: User | null) => {
if (err) {
return next(err);
}
@@ -221,8 +210,12 @@ export const wrapAuthn = (
return next(new AuthenticationError("user not on request"));
}
// Pass the login off to be signed.
handleSuccessfulLogin(user, signingConfig, req, res, next);
try {
// Pass the login off to be signed.
await handleSuccessfulLogin(user, signingConfig, req, res, next);
} catch (err) {
return next(err);
}
}
)(req, res, next);
@@ -53,7 +53,7 @@ export default class FacebookStrategy extends OAuth2Strategy<
if (!user) {
if (!integration.allowRegistration) {
// Registration is disabled, so we can't create the user user here.
return;
return null;
}
// FIXME: implement rules.
@@ -52,7 +52,7 @@ export default class GoogleStrategy extends OAuth2Strategy<
if (!user) {
if (!integration.allowRegistration) {
// Registration is disabled, so we can't create the user user here.
return;
return null;
}
// FIXME: implement rules.
@@ -1,7 +1,9 @@
import { Db } from "mongodb";
import { Strategy as LocalStrategy } from "passport-local";
import { Redis } from "ioredis";
import { VerifyCallback } from "talk-server/app/middleware/passport";
import { RequestLimiter } from "talk-server/app/request/limiter";
import { InvalidCredentialsError } from "talk-server/errors";
import {
retrieveUserWithProfile,
@@ -9,14 +11,19 @@ import {
} from "talk-server/models/user";
import { Request } from "talk-server/types/express";
const verifyFactory = (mongo: Db) => async (
const verifyFactory = (
mongo: Db,
ipLimiter: RequestLimiter,
emailLimiter: RequestLimiter
) => async (
req: Request,
email: string,
password: string,
done: VerifyCallback
) => {
try {
// TODO: rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
await emailLimiter.test(req, email);
// The tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
@@ -45,9 +52,26 @@ const verifyFactory = (mongo: Db) => async (
export interface LocalStrategyOptions {
mongo: Db;
redis: Redis;
}
export function createLocalStrategy({ mongo }: LocalStrategyOptions) {
export function createLocalStrategy({
mongo,
redis: client,
}: LocalStrategyOptions) {
const ipLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 10,
prefix: "ip",
});
const emailLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 10,
prefix: "email",
});
return new LocalStrategy(
{
usernameField: "email",
@@ -55,6 +79,6 @@ export function createLocalStrategy({ mongo }: LocalStrategyOptions) {
session: false,
passReqToCallback: true,
},
verifyFactory(mongo)
verifyFactory(mongo, ipLimiter, emailLimiter)
);
}
@@ -4,6 +4,7 @@ import { Strategy } from "passport-strategy";
import { Profile } from "passport";
import { VerifyCallback } from "passport-oauth2";
import { Config } from "talk-server/config";
import { IntegrationDisabled } from "talk-server/errors";
import { AuthIntegrations } from "talk-server/models/settings";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
@@ -28,6 +29,7 @@ export default abstract class OAuth2Strategy<
T extends OAuth2Integration,
U extends Strategy
> extends Strategy {
public abstract name: string;
protected config: Config;
protected mongo: Db;
protected cache: TenantCacheAdapter<U>;
@@ -59,7 +61,7 @@ export default abstract class OAuth2Strategy<
integration: Required<T>,
profile: Profile,
now: Date
): Promise<User | undefined>;
): Promise<User | null | undefined>;
protected verifyCallback = async (
req: Request,
@@ -83,6 +85,9 @@ export default abstract class OAuth2Strategy<
profile,
now
);
if (!user) {
return done(null);
}
return done(null, user);
} catch (err) {
@@ -100,8 +105,7 @@ export default abstract class OAuth2Strategy<
// Check to see if the integration is enabled.
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
throw new IntegrationDisabled(this.name);
}
if (!integration.clientID) {
@@ -1,12 +1,14 @@
import Joi from "joi";
import jwt from "jsonwebtoken";
import jwks, { JwksClient } from "jwks-rsa";
import { isNil } from "lodash";
import { Db } from "mongodb";
import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2";
import { Strategy } from "passport-strategy";
import { validate } from "talk-server/app/request/body";
import { reconstructURL } from "talk-server/app/url";
import { IntegrationDisabled, TokenInvalidError } from "talk-server/errors";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { OIDCAuthIntegration } from "talk-server/models/settings";
@@ -16,6 +18,7 @@ import {
retrieveUserWithProfile,
User,
} from "talk-server/models/user";
import { AsymmetricSigningAlgorithm } from "talk-server/services/jwt";
import TenantCache from "talk-server/services/tenant/cache";
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
import { insert } from "talk-server/services/users";
@@ -43,21 +46,38 @@ export interface OIDCIDToken {
preferred_username?: string;
}
export const OIDCIDTokenSchema = Joi.object()
.keys({
sub: Joi.string().required(),
iss: Joi.string().required(),
aud: Joi.string().required(),
email: Joi.string().required(),
email_verified: Joi.boolean().default(false),
picture: Joi.string().default(undefined),
name: Joi.string().default(undefined),
nickname: Joi.string().default(undefined),
preferred_username: Joi.string().default(undefined),
})
.optionalKeys([
"picture",
"email_verified",
"name",
"nickname",
"preferred_username",
]);
export interface StrategyItem {
strategy: OAuth2Strategy;
jwksClient?: JwksClient;
}
export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken {
if (
(token as OIDCIDToken).iss &&
(token as OIDCIDToken).sub &&
(token as OIDCIDToken).aud
) {
return true;
}
return false;
const { error } = Joi.validate(token, OIDCIDTokenSchema, {
// OIDC ID tokens may contain many other fields we haven't seen.. We Just
// need to check to see that it contains at least the fields we need.
allowUnknown: true,
});
return isNil(error);
}
/**
@@ -92,8 +112,7 @@ export function getEnabledIntegration(
integration: OIDCAuthIntegration
): Required<OIDCAuthIntegration> {
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
throw new IntegrationDisabled("oidc");
}
if (
@@ -105,34 +124,13 @@ export function getEnabledIntegration(
!integration.jwksURI ||
!integration.issuer
) {
// TODO: return a better error.
throw new Error("integration not configured");
throw new IntegrationDisabled("oidc");
}
// TODO: (wyattjoh) for some reason, type guards above to not allow coercion to this required type.
return integration as Required<OIDCAuthIntegration>;
}
export const OIDCIDTokenSchema = Joi.object()
.keys({
sub: Joi.string(),
iss: Joi.string(),
aud: Joi.string(),
email: Joi.string(),
email_verified: Joi.boolean().default(false),
picture: Joi.string().default(undefined),
name: Joi.string().default(undefined),
nickname: Joi.string().default(undefined),
preferred_username: Joi.string().default(undefined),
})
.optionalKeys([
"picture",
"email_verified",
"name",
"nickname",
"preferred_username",
]);
export async function findOrCreateOIDCUser(
mongo: Db,
tenant: Tenant,
@@ -200,25 +198,41 @@ export function findOrCreateOIDCUserWithToken(
tenant: Tenant,
client: JwksClient,
integration: OIDCAuthIntegration,
token: string,
tokenString: string,
now: Date
) {
return new Promise<Readonly<User> | null>((resolve, reject) => {
logger.trace({ tenantID: tenant.id }, "verifying oidc id_token");
jwt.verify(
token,
tokenString,
signingKeyFactory(client),
{
issuer: integration.issuer,
// FIXME: (wyattjoh) support additional algorithms.
// Currently we're limited by the key retrieval factory to only support
// RS256. Tracking available:
//
// https://github.com/auth0/node-jwks-rsa/issues/40
// https://github.com/auth0/node-jwks-rsa/issues/50
algorithms: [AsymmetricSigningAlgorithm.RS256],
clockTimestamp: Math.floor(now.getTime() / 1000),
},
async (err, decoded) => {
async (err, token) => {
logger.trace(
{ tenantID: tenant.id },
"finished verifying oidc id_token"
);
if (err) {
// TODO: wrap error?
return reject(err);
return reject(
new TokenInvalidError(tokenString, "token validation error", err)
);
}
// Validate the token.
if (typeof token === "string" || !isOIDCToken(token)) {
return reject(
new TokenInvalidError(tokenString, "token is not an OIDCToken")
);
}
try {
@@ -226,7 +240,7 @@ export function findOrCreateOIDCUserWithToken(
mongo,
tenant,
integration,
decoded as OIDCIDToken,
token,
now
);
return resolve(user);
@@ -0,0 +1,27 @@
import jwt from "jsonwebtoken";
import {
JWTSigningConfig,
signTokenString,
SymmetricSigningAlgorithm,
} from "talk-server/services/jwt";
import { isJWTToken } from "./jwt";
// Create signing config.
const config: JWTSigningConfig = {
algorithm: SymmetricSigningAlgorithm.HS256,
secret: "secret",
};
it("validates a jwt token", async () => {
const user = { id: "user-id" };
const tenant = { id: "tenant-id" };
// Create the signed token string.
const tokenString = await signTokenString(config, user, tenant);
// Verify that the token conforms to the JWT token schema.
const token = jwt.decode(tokenString) as object;
expect(isJWTToken(token)).toBeTruthy();
});
@@ -1,39 +1,22 @@
import { Redis } from "ioredis";
import jwt from "jsonwebtoken";
import Joi from "joi";
import { isNil } from "lodash";
import { Db } from "mongodb";
import now from "performance-now";
import logger from "talk-server/logger";
import { Tenant } from "talk-server/models/tenant";
import { retrieveUser } from "talk-server/models/user";
import { checkJWTRevoked, JWTSigningConfig } from "talk-server/services/jwt";
import {
checkJWTRevoked,
JWTSigningConfig,
StandardClaims,
verifyJWT,
} from "talk-server/services/jwt";
import { Verifier } from "../jwt";
export interface JWTToken {
/**
* jti is the Token identifier. With normal login tokens, this is a randomly
* generated uuid, which is added to a revoke list when the User "logs out".
* For Personal Access Tokens, this is the Token identifier.
*/
jti: string;
/**
* sub is the ID of the User that this Token is associated with.
*/
sub: string;
/**
* iss is the ID of the Tenant that this Token is associated with.
*/
iss: string;
/**
* exp is the optional expiry for the tokens. Personal Access Token's do not
* have an expiry associated with them, hence why it's optional.
*/
exp?: number;
export interface JWTToken
extends Required<Pick<StandardClaims, "jti" | "sub" | "iss" | "iat">>,
Pick<StandardClaims, "exp"> {
/**
* pat, when true, indicates that this Token is a Personal Access Token, and
* it's `jti` claim should be treated as the Token ID. These tokens cannot be
@@ -42,30 +25,18 @@ export interface JWTToken {
pat?: boolean;
}
export const JWTTokenSchema = Joi.object().keys({
jti: Joi.string().required(),
sub: Joi.string().required(),
iat: Joi.number().required(),
iss: Joi.string().required(),
exp: Joi.number(),
pat: Joi.boolean(),
});
export function isJWTToken(token: JWTToken | object): token is JWTToken {
if (
typeof (token as JWTToken).jti !== "string" ||
typeof (token as JWTToken).sub !== "string" ||
typeof (token as JWTToken).iss !== "string"
) {
return false;
}
if (
typeof (token as JWTToken).exp !== "undefined" &&
typeof (token as JWTToken).exp !== "number"
) {
return false;
}
if (
typeof (token as JWTToken).pat !== "undefined" &&
typeof (token as JWTToken).pat !== "boolean"
) {
return false;
}
return true;
const { error } = Joi.validate(token, JWTTokenSchema);
return isNil(error);
}
export interface JWTVerifierOptions {
@@ -89,19 +60,14 @@ export class JWTVerifier implements Verifier<JWTToken> {
return isJWTToken(token) && token.iss === tenant.id;
}
public async verify(tokenString: string, token: JWTToken, tenant: Tenant) {
const startTime = now();
public async verify(
tokenString: string,
token: JWTToken,
tenant: Tenant,
now: Date
) {
// Verify that the token is valid. This will throw an error if it isn't.
jwt.verify(tokenString, this.signingConfig.secret, {
issuer: tenant.id,
algorithms: [this.signingConfig.algorithm],
});
// Compute the end time.
const responseTime = Math.round(now() - startTime);
logger.trace({ responseTime }, "jwt verification complete");
verifyJWT(tokenString, this.signingConfig, now, { issuer: tenant.id });
// Check to see if this is a Personal Access Token, these tokens cannot be
// revoked.
@@ -32,6 +32,7 @@ describe("SSOUserProfileSchema", () => {
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows an empty avatar", () => {
@@ -42,6 +43,7 @@ describe("SSOUserProfileSchema", () => {
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows a valid payload", () => {
@@ -54,6 +56,7 @@ describe("SSOUserProfileSchema", () => {
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows an empty avatar", () => {
@@ -65,6 +68,7 @@ describe("SSOUserProfileSchema", () => {
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows an empty displayName", () => {
@@ -76,5 +80,6 @@ describe("SSOUserProfileSchema", () => {
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
});
@@ -1,8 +1,9 @@
import Joi from "joi";
import jwt from "jsonwebtoken";
import { isNil } from "lodash";
import { Db } from "mongodb";
import { validate } from "talk-server/app/request/body";
import { IntegrationDisabled } from "talk-server/errors";
import {
GQLSSOAuthIntegration,
GQLUSER_ROLE,
@@ -11,6 +12,7 @@ import { Tenant } from "talk-server/models/tenant";
import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user";
import { insert } from "talk-server/services/users";
import { SymmetricSigningAlgorithm, verifyJWT } from "talk-server/services/jwt";
import { Verifier } from "../jwt";
export interface SSOStrategyOptions {
@@ -30,14 +32,18 @@ export interface SSOToken {
export const SSOUserProfileSchema = Joi.object()
.keys({
id: Joi.string(),
email: Joi.string(),
username: Joi.string(),
id: Joi.string().required(),
email: Joi.string().required(),
username: Joi.string().required(),
avatar: Joi.string().default(undefined),
displayName: Joi.string().default(undefined),
})
.optionalKeys(["avatar", "displayName"]);
export const SSOTokenSchema = Joi.object().keys({
user: SSOUserProfileSchema.required(),
});
export async function findOrCreateSSOUser(
mongo: Db,
tenant: Tenant,
@@ -91,26 +97,9 @@ export async function findOrCreateSSOUser(
return user;
}
/**
* isSSOUserProfile will check if the given profile is a SSOUserProfile.
*
* @param profile the profile to check for the type
*/
export function isSSOUserProfile(
profile: SSOUserProfile | object
): profile is SSOUserProfile {
return (
typeof (profile as SSOUserProfile).id !== "undefined" &&
typeof (profile as SSOUserProfile).email !== "undefined" &&
typeof (profile as SSOUserProfile).username !== "undefined"
);
}
export function isSSOToken(token: SSOToken | object): token is SSOToken {
return (
typeof (token as SSOToken).user === "object" &&
isSSOUserProfile((token as SSOToken).user)
);
const { error } = Joi.validate(token, SSOTokenSchema);
return isNil(error);
}
export interface SSOVerifierOptions {
@@ -132,24 +121,28 @@ export class SSOVerifier implements Verifier<SSOToken> {
tokenString: string,
token: SSOToken,
tenant: Tenant,
now = new Date()
now: Date
) {
const integration = tenant.auth.integrations.sso;
if (!integration.enabled) {
// TODO: (wyattjoh) return a better error.
throw new Error("integration not enabled");
throw new IntegrationDisabled("sso");
}
if (!integration.key) {
throw new Error("integration key does not exist");
}
// Verify that the token is valid. This will throw an error if it isn't.
jwt.verify(tokenString, integration.key, {
// Force the use of the HS256 algorithm. We can explore switching this
// out in the future..
algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm.
});
verifyJWT(
tokenString,
{
// Force the use of the HS256 algorithm. We can explore switching this
// out in the future..
// TODO: (wyattjoh) investigate replacing algorithm.
algorithm: SymmetricSigningAlgorithm.HS256,
secret: integration.key,
},
now
);
return findOrCreateSSOUser(this.mongo, tenant, integration, token, now);
}
+5 -1
View File
@@ -1,6 +1,7 @@
import uuid from "uuid/v1";
import { TenantNotFoundError } from "talk-server/errors";
import logger from "talk-server/logger";
import TenantCache from "talk-server/services/tenant/cache";
import { RequestHandler } from "talk-server/types/express";
@@ -25,7 +26,7 @@ export const tenantMiddleware = ({
const now = new Date();
// Set Talk on the request.
req.talk = { id, now };
req.talk = { id, now, logger: logger.child({ traceID: id }) };
}
// Set the Talk Tenant Cache on the request.
@@ -49,6 +50,9 @@ export const tenantMiddleware = ({
// Attach the tenant to the request.
req.talk.tenant = tenant;
// Attach the tenant's language to the request.
res.setHeader("Content-Language", tenant.locale);
// Attach the tenant to the view locals.
res.locals.tenant = tenant;
+87
View File
@@ -0,0 +1,87 @@
// tslint:disable:max-classes-per-file
import { Redis } from "ioredis";
import ms from "ms";
import { Omit } from "talk-common/types";
import { RateLimitExceeded } from "talk-server/errors";
import { Request } from "talk-server/types/express";
export interface LimiterOptions {
client: Redis;
ttl: string;
max: number;
resource: string;
operation: string;
prefix: string;
}
export class Limiter {
private client: Redis;
private ttl: number;
private max: number;
private prefix: string;
private resource: string;
private operation: string;
constructor(options: LimiterOptions) {
this.client = options.client;
this.ttl = Math.floor(ms(options.ttl) / 1000);
this.max = options.max;
this.prefix = options.prefix;
this.resource = options.resource;
this.operation = options.operation;
}
private key(key: string, resource?: string, operation?: string): string {
return `limiter[${this.prefix}][${resource || this.resource}][${operation ||
this.operation}][${key}]`;
}
public async test(
value: string,
resource?: string,
operation?: string
): Promise<number> {
const key = this.key(value, resource, operation);
const [[, tries], [, expiry]] = await this.client
.multi()
.incr(key)
.expire(key, this.ttl)
.exec();
// if this is new or has no expiry
if (tries === 1 || expiry === -1) {
// then expire it after the timeout
this.client.expire(key, this.ttl);
}
if (tries > this.max) {
throw new RateLimitExceeded(key, this.max, tries);
}
return tries;
}
}
export type RequestLimiterOptions = Omit<
LimiterOptions,
"operation" | "resource"
>;
export class RequestLimiter {
private limiter: Limiter;
constructor(options: RequestLimiterOptions) {
this.limiter = new Limiter({
...options,
operation: "",
resource: "",
});
}
public async test(req: Request, value: string) {
return this.limiter.test(value, req.originalUrl, req.method);
}
}
+29
View File
@@ -0,0 +1,29 @@
import express from "express";
import { AppOptions } from "talk-server/app";
import {
confirmCheckHandler,
confirmHandler,
confirmRequestHandler,
} from "talk-server/app/handlers";
import { jsonMiddleware } from "talk-server/app/middleware/json";
import { authenticate } from "talk-server/app/middleware/passport";
import { RouterOptions } from "talk-server/app/router/types";
export function createNewAccountRouter(
app: AppOptions,
{ passport }: Pick<RouterOptions, "passport">
) {
const router = express.Router();
router.post(
"/confirm",
jsonMiddleware,
authenticate(passport),
confirmRequestHandler(app)
);
router.get("/confirm", confirmCheckHandler(app));
router.put("/confirm", confirmHandler(app));
return router;
}
+25 -17
View File
@@ -2,11 +2,16 @@ import express from "express";
import { AppOptions } from "talk-server/app";
import {
forgotCheckHandler,
forgotHandler,
forgotResetHandler,
logoutHandler,
signupHandler,
} from "talk-server/app/handlers/api/auth/local";
} from "talk-server/app/handlers";
import { noCacheMiddleware } from "talk-server/app/middleware/cacheHeaders";
import { jsonMiddleware } from "talk-server/app/middleware/json";
import {
authenticate,
wrapAuthn,
wrapOAuth2Authn,
} from "talk-server/app/middleware/passport";
@@ -14,39 +19,42 @@ import { RouterOptions } from "talk-server/app/router/types";
function wrapPath(
app: AppOptions,
options: RouterOptions,
{ passport }: Pick<RouterOptions, "passport">,
router: express.Router,
strategy: string,
path: string = `/${strategy}`
) {
const handler = wrapOAuth2Authn(
options.passport,
app.signingConfig,
strategy
);
const handler = wrapOAuth2Authn(passport, app.signingConfig, strategy);
router.get(path, noCacheMiddleware, handler);
router.get(path + "/callback", noCacheMiddleware, handler);
}
export function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
export function createNewAuthRouter(
app: AppOptions,
{ passport }: Pick<RouterOptions, "passport">
) {
const router = express.Router();
// Mount the logout handler.
router.delete("/", logoutHandler(app));
// Mount the Local Authentication handlers.
router.post(
"/local",
express.json(),
wrapAuthn(options.passport, app.signingConfig, "local")
jsonMiddleware,
wrapAuthn(passport, app.signingConfig, "local")
);
router.post("/local/signup", express.json(), signupHandler(app));
router.post("/local/signup", jsonMiddleware, signupHandler(app));
router.get("/local/forgot", forgotCheckHandler(app));
router.put("/local/forgot", jsonMiddleware, forgotResetHandler(app));
router.post("/local/forgot", jsonMiddleware, forgotHandler(app));
// Mount the logout handler.
router.delete("/", authenticate(passport), logoutHandler(app));
// Mount the external auth integrations with middleware/handle wrappers.
wrapPath(app, options, router, "facebook");
wrapPath(app, options, router, "google");
wrapPath(app, options, router, "oidc");
wrapPath(app, { passport }, router, "facebook");
wrapPath(app, { passport }, router, "google");
wrapPath(app, { passport }, router, "oidc");
return router;
}
+17 -11
View File
@@ -2,15 +2,19 @@ import express from "express";
import passport from "passport";
import { AppOptions } from "talk-server/app";
import { graphQLHandler } from "talk-server/app/handlers/api/graphql";
import { installHandler } from "talk-server/app/handlers/api/install";
import { versionHandler } from "talk-server/app/handlers/api/version";
import {
graphQLHandler,
installHandler,
versionHandler,
} from "talk-server/app/handlers";
import { JSONErrorHandler } from "talk-server/app/middleware/error";
import { jsonMiddleware } from "talk-server/app/middleware/json";
import { errorLogger } from "talk-server/app/middleware/logging";
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
import { authenticate } from "talk-server/app/middleware/passport";
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
import { createNewAccountRouter } from "./account";
import { createNewAuthRouter } from "./auth";
export interface RouterOptions {
@@ -31,7 +35,7 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
// Installation middleware.
router.use(
"/install",
express.json(),
jsonMiddleware,
tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }),
installHandler(app)
);
@@ -41,17 +45,19 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
router.use(tenantMiddleware({ cache: app.tenantCache }));
// Setup Passport middleware.
router.use(options.passport.initialize());
router.use(passport.initialize());
// Authenticate all requests made to this route. This will allow requests
// that are not authenticated pass through.
router.use(authenticate(options.passport));
// Setup auth routes.
// Create the auth router.
router.use("/auth", createNewAuthRouter(app, options));
router.use("/account", createNewAccountRouter(app, options));
// Configure the GraphQL route.
router.use("/graphql", express.json(), graphQLHandler(app));
router.use(
"/graphql",
authenticate(options.passport),
jsonMiddleware,
graphQLHandler(app)
);
// General API error handler.
router.use(notFoundMiddleware);
+11
View File
@@ -80,6 +80,17 @@ export function createRouter(app: AppOptions, options: RouterOptions) {
})
);
// Add the standalone targets.
router.use(
"/account",
// If we aren't already installed, redirect the user to the install page.
installedMiddleware(),
createClientTargetRouter({
staticURI,
cacheDuration: false,
entrypoint: entrypoints.get("account"),
})
);
// Add the standalone targets.
router.use(
"/admin",