[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",
+67 -5
View File
@@ -224,11 +224,31 @@ export class CommentBodyExceedsMaxLengthError extends TalkError {
}
}
export class URLInvalidError extends TalkError {
constructor({
url,
...properties
}: {
url: string;
tenantDomains: string[];
tenantDomain?: string;
}) {
super({
code: ERROR_CODES.URL_NOT_PERMITTED,
context: { pvt: properties, pub: { url } },
});
}
}
export class StoryURLInvalidError extends TalkError {
constructor(properties: { storyURL: string; tenantDomains: string[] }) {
constructor(properties: {
storyURL: string;
tenantDomains: string[];
tenantDomain?: string;
}) {
super({
code: ERROR_CODES.STORY_URL_NOT_PERMITTED,
context: { pub: properties },
context: { pvt: properties },
});
}
}
@@ -336,10 +356,11 @@ export class TokenNotFoundError extends TalkError {
}
export class TokenInvalidError extends TalkError {
constructor(token: string, reason: string) {
constructor(token: string, reason: string, cause?: Error) {
super({
code: ERROR_CODES.TOKEN_INVALID,
context: { pub: { token }, pvt: { reason } },
cause,
context: { pvt: { token, reason } },
status: 401,
});
}
@@ -366,7 +387,7 @@ export class UserForbiddenError extends TalkError {
export class UserNotFoundError extends TalkError {
constructor(userID: string) {
super({ code: ERROR_CODES.USER_NOT_FOUND, context: { pvt: { userID } } });
super({ code: ERROR_CODES.USER_NOT_FOUND, context: { pub: { userID } } });
}
}
@@ -394,6 +415,15 @@ export class TenantNotFoundError extends TalkError {
}
}
export class IntegrationDisabled extends TalkError {
constructor(integrationName: string) {
super({
code: ERROR_CODES.INTEGRATION_DISABLED,
context: { pvt: { integrationName } },
});
}
}
export class InternalError extends TalkError {
constructor(cause: Error, reason: string) {
super({
@@ -505,3 +535,35 @@ export class UserSuspended extends TalkError {
});
}
}
export class PasswordResetTokenExpired extends TalkError {
constructor(reason: string, cause?: Error) {
super({
code: ERROR_CODES.PASSWORD_RESET_TOKEN_EXPIRED,
cause,
status: 400,
context: { pvt: { reason } },
});
}
}
export class ConfirmEmailTokenExpired extends TalkError {
constructor(reason: string, cause?: Error) {
super({
code: ERROR_CODES.EMAIL_CONFIRM_TOKEN_EXPIRED,
cause,
status: 400,
context: { pvt: { reason } },
});
}
}
export class RateLimitExceeded extends TalkError {
constructor(resource: string, max: number, tries: number) {
super({
code: ERROR_CODES.RATE_LIMIT_EXCEEDED,
status: 429,
context: { pvt: { resource, max, tries } },
});
}
}
+5
View File
@@ -20,6 +20,7 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
STORY_CLOSED: "error-storyClosed",
STORY_NOT_FOUND: "error-storyNotFound",
STORY_URL_NOT_PERMITTED: "error-storyURLNotPermitted",
URL_NOT_PERMITTED: "error-urlNotPermitted",
TENANT_INSTALLED_ALREADY: "error-tenantInstalledAlready",
TENANT_NOT_FOUND: "error-tenantNotFound",
TOKEN_INVALID: "error-tokenInvalid",
@@ -39,4 +40,8 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
USER_ALREADY_BANNED: "error-userAlreadyBanned",
USER_BANNED: "error-userBanned",
USER_SUSPENDED: "error-userSuspended",
INTEGRATION_DISABLED: "error-integrationDisabled",
PASSWORD_RESET_TOKEN_EXPIRED: "error-passwordResetTokenExpired",
EMAIL_CONFIRM_TOKEN_EXPIRED: "error-emailConfirmTokenExpired",
RATE_LIMIT_EXCEEDED: "error-rateLimitExceeded",
};
+6 -5
View File
@@ -1,9 +1,8 @@
import bunyan from "bunyan";
import uuid from "uuid";
import { LanguageCode } from "talk-common/helpers/i18n/locales";
import { Config } from "talk-server/config";
import logger from "talk-server/logger";
import logger, { Logger } from "talk-server/logger";
import { User } from "talk-server/models/user";
import { I18n } from "talk-server/services/i18n";
import { Request } from "talk-server/types/express";
@@ -13,6 +12,7 @@ export interface CommonContextOptions {
now?: Date;
user?: User;
req?: Request;
logger?: Logger;
lang?: LanguageCode;
config: Config;
i18n: I18n;
@@ -26,11 +26,12 @@ export default class CommonContext {
public readonly i18n: I18n;
public readonly lang: LanguageCode;
public readonly now: Date;
public readonly logger: ReturnType<typeof bunyan.createLogger>;
public readonly logger: Logger;
constructor({
id = uuid.v1(),
now = new Date(),
logger: log = logger,
user,
req,
config,
@@ -38,9 +39,9 @@ export default class CommonContext {
lang = i18n.getDefaultLang(),
}: CommonContextOptions) {
this.id = id;
this.logger = logger.child({
this.logger = log.child({
context: "graph",
contextID: this.id,
contextID: id,
});
this.now = now;
this.user = user;
+17 -3
View File
@@ -52,7 +52,7 @@ export const Users = (ctx: TenantContext) => ({
),
setEmail: async (input: GQLSetEmailInput): Promise<Readonly<User> | null> =>
mapFieldsetToErrorCodes(
setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email),
setEmail(ctx.mongo, ctx.mailerQueue, ctx.tenant, ctx.user!, input.email),
{
"input.email": [
ERROR_CODES.EMAIL_ALREADY_SET,
@@ -69,7 +69,13 @@ export const Users = (ctx: TenantContext) => ({
updatePassword: async (
input: GQLUpdatePasswordInput
): Promise<Readonly<User> | null> =>
updatePassword(ctx.mongo, ctx.tenant, ctx.user!, input.password),
updatePassword(
ctx.mongo,
ctx.mailerQueue,
ctx.tenant,
ctx.user!,
input.password
),
createToken: async (input: GQLCreateTokenInput) =>
createToken(
ctx.mongo,
@@ -91,10 +97,18 @@ export const Users = (ctx: TenantContext) => ({
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
updateRole(ctx.mongo, ctx.tenant, ctx.user!, input.userID, input.role),
ban: async (input: GQLBanUserInput) =>
ban(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
ban(
ctx.mongo,
ctx.mailerQueue,
ctx.tenant,
ctx.user!,
input.userID,
ctx.now
),
suspend: async (input: GQLSuspendUserInput) =>
suspend(
ctx.mongo,
ctx.mailerQueue,
ctx.tenant,
ctx.user!,
input.userID,
@@ -536,6 +536,9 @@ type OIDCAuthIntegration {
##########################
type GoogleAuthIntegration {
"""
enabled, when true, will enable Google as an authentication integration.
"""
enabled: Boolean!
"""
@@ -551,7 +554,14 @@ type GoogleAuthIntegration {
"""
targetFilter: AuthenticationTargetFilter!
"""
clientID is the Client Identifier as provided by the Google API Console.
"""
clientID: String @auth(roles: [ADMIN])
"""
clientSecret is the Client Secret as provided by the Google API Console.
"""
clientSecret: String @auth(roles: [ADMIN])
"""
@@ -574,6 +584,9 @@ type GoogleAuthIntegration {
##########################
type FacebookAuthIntegration {
"""
enabled, when true, will enable Facebook as an authentication integration.
"""
enabled: Boolean!
"""
@@ -589,7 +602,16 @@ type FacebookAuthIntegration {
"""
targetFilter: AuthenticationTargetFilter!
"""
clientID is the Client Identifier as provided by the Facebook Developer
Console.
"""
clientID: String @auth(roles: [ADMIN])
"""
clientSecret is the Client Secret as provided by the Facebook Developer
Console.
"""
clientSecret: String @auth(roles: [ADMIN])
"""
@@ -1343,7 +1365,7 @@ type User {
)
"""
comments are the comments of the User.
comments are the comments written by the User.
"""
comments(
first: Int = 10
@@ -1352,8 +1374,8 @@ type User {
): CommentsConnection! @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
"""
commentModerationActionHistory returns a CommentModerationActionConnection that this User has
created.
commentModerationActionHistory returns a CommentModerationActionConnection
that this User has created.
"""
commentModerationActionHistory(
first: Int = 10
@@ -1373,7 +1395,7 @@ type User {
"""
createdAt is the time that the User was created at.
"""
createdAt: Time! @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
createdAt: Time!
}
"""
@@ -2377,6 +2399,9 @@ input SettingsSSOAuthIntegrationInput {
}
input SettingsGoogleAuthIntegrationInput {
"""
enabled, when true, will enable Google as an authentication integration.
"""
enabled: Boolean
"""
@@ -2392,11 +2417,21 @@ input SettingsGoogleAuthIntegrationInput {
"""
targetFilter: SettingsAuthenticationTargetFilterInput
"""
clientID is the Client Identifier as provided by the Google API Console.
"""
clientID: String
"""
clientSecret is the Client Secret as provided by the Google API Console.
"""
clientSecret: String
}
input SettingsFacebookAuthIntegrationInput {
"""
enabled, when true, will enable Facebook as an authentication integration.
"""
enabled: Boolean
"""
@@ -2412,7 +2447,16 @@ input SettingsFacebookAuthIntegrationInput {
"""
targetFilter: SettingsAuthenticationTargetFilterInput
"""
clientID is the Client Identifier as provided by the Facebook Developer
Console.
"""
clientID: String
"""
clientSecret is the Client Secret as provided by the Facebook Developer
Console.
"""
clientSecret: String
}
+47
View File
@@ -0,0 +1,47 @@
import { LocalProfile, SSOProfile } from "talk-server/models/user";
import { getLocalProfile, hasLocalProfile } from "./users";
const localProfile: LocalProfile = {
type: "local",
id: "hans@email.com",
password: "secret",
};
const ssoProfile: SSOProfile = {
type: "sso",
id: "sso-id",
};
it("will get the local profile", () => {
expect(getLocalProfile({ profiles: [localProfile] })).toEqual(localProfile);
});
it("will return undefined when it can't find the local profile", () => {
expect(getLocalProfile({ profiles: [ssoProfile] })).toBeUndefined();
});
it("will return true when the profile exists", () => {
expect(hasLocalProfile({ profiles: [localProfile] })).toBeTruthy();
});
it("will return true when the profile exists with the right email", () => {
expect(
hasLocalProfile({ profiles: [localProfile] }, localProfile.id)
).toBeTruthy();
});
it("will return false when the profile exists with the wrong email", () => {
expect(
hasLocalProfile({ profiles: [localProfile] }, "max@email.com")
).toBeFalsy();
});
it("will return false when the profile does not exist", () => {
expect(hasLocalProfile({ profiles: [ssoProfile] })).toBeFalsy();
});
it("will return false when the profile does not exist with an email specified", () => {
expect(
hasLocalProfile({ profiles: [ssoProfile] }, "max@email.com")
).toBeFalsy();
});
+38
View File
@@ -0,0 +1,38 @@
import { LocalProfile, User } from "talk-server/models/user";
/**
* getLocalProfile will get the LocalProfile from the User if it exists.
*
* @param user the User to pull the LocalProfile out of
*/
export function getLocalProfile(
user: Pick<User, "profiles">
): LocalProfile | undefined {
return user.profiles.find(({ type }) => type === "local") as
| LocalProfile
| undefined;
}
/**
* hasLocalProfile will return true if the User has a LocalProfile, optionally
* checking the email on it as well.
*
* @param user the User to pull the LocalProfile out of
* @param withEmail when specified, will ensure that the LocalProfile has the
* specific email provided
*/
export function hasLocalProfile(
user: Pick<User, "profiles">,
withEmail?: string
): boolean {
const profile = getLocalProfile(user);
if (!profile) {
return false;
}
if (withEmail && profile.id !== withEmail) {
return false;
}
return true;
}
+15 -9
View File
@@ -62,6 +62,9 @@ class Server {
// processing when true, indicates that `process()` was already called.
private processing: boolean = false;
// i18n is the server reference to the i18n framework.
private i18n: I18n;
constructor(options: ServerOptions) {
this.parentApp = express();
@@ -73,6 +76,13 @@ class Server {
// Load the graph schemas.
this.schema = getTenantSchema();
// Get the default locale. This is asserted here because the LanguageCode
// is verified via Convict, but not typed, so this resolves that.
const defaultLocale = this.config.get("default_locale") as LanguageCode;
// Setup the translation framework.
this.i18n = new I18n(defaultLocale);
}
/**
@@ -86,6 +96,9 @@ class Server {
}
this.connected = true;
// Load the translations.
await this.i18n.load();
// Setup MongoDB.
this.mongo = await createMongoDB(config);
@@ -107,6 +120,7 @@ class Server {
config: this.config,
mongo: this.mongo,
tenantCache: this.tenantCache,
i18n: this.i18n,
});
}
@@ -154,14 +168,6 @@ class Server {
// Create the signing config.
const signingConfig = createJWTSigningConfig(this.config);
// Get the default locale. This is asserted here because the LanguageCode
// is verified via Convict, but not typed, so this resolves that.
const defaultLocale = this.config.get("default_locale") as LanguageCode;
// Create and load the translations.
const i18n = new I18n(defaultLocale);
await i18n.load();
// Create the Talk App, branching off from the parent app.
const app: Express = await createApp({
parent,
@@ -171,7 +177,7 @@ class Server {
tenantCache: this.tenantCache,
config: this.config,
schema: this.schema,
i18n,
i18n: this.i18n,
mailerQueue: this.tasks.mailer,
scraperQueue: this.tasks.scraper,
});
+47
View File
@@ -0,0 +1,47 @@
email-notification-footer =
Sent by <a data-l10n-name="organizationLink">{ $organizationName }</a>
email-notification-template-forgotPassword =
Hello { $username },<br/><br/>
We received a request to reset your password on <a data-l10n-name="organizationName">{ $organizationName }</a>.<br/><br/>
Please follow this link reset your password: <a data-l10n-name="resetYourPassword">Click here to reset your password</a><br/><br/>
<i>If you did not request this, you can ignore this email.</i><br/>
email-subject-forgotPassword = Password Reset Request
email-notification-template-ban =
Hello { $username },<br/><br/>
Someone with access to your account has violated our community guidelines.
As a result, your account has been banned. You will no longer be able to
comment, react or report comments. if you think this has been done in error,
please contact our community team at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-ban = Your account has been banned
email-notification-template-passwordChange =
Hello { $username },<br/><br/>
The password on your account has been changed.<br/><br/>
If you did not request this change,
please contact our community team at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-passwordChange = Your password has been changed
email-notification-template-suspend =
Hello { $username },<br/><br/>
In accordance with { $organizationName }'s community guidelines, your
account has been temporarily suspended. During the suspension, you will be
unable to comment, flag or engage with fellow commenters. Please rejoin the
conversation { $until }.<br/><br/>
If you think this has been done in error, please contact our community team
at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-subject-suspend = Your account has been suspended
email-notification-template-confirmEmail =
Hello { $username },<br/><br/>
To confirm your email address for use with your commenting account at { $organizationName },
please follow this link: <a data-l10n-name="confirmYourEmail">Click here to confirm your email</a><br/><br/>
If you did not recently create a commenting account with
{ $organizationName }, you can safely ignore this email.
email-subject-confirmEmail = Confirm Email
+6 -1
View File
@@ -5,11 +5,12 @@ error-commentBodyExceedsMaxLength =
Comment body exceeds maximum length of {$max} characters.
error-storyURLNotPermitted =
The specified story URL does not exist in the permitted domains list.
error-urlNotPermitted = The specified URL ({$url}) is not permitted.
error-duplicateStoryURL = The specified story URL already exists.
error-tenantNotFound = Tenant hostname ({$hostname}) not found.
error-userNotFound = User ({$userID}) not found.
error-notFound = Unrecognized request URL ({$method} {$path}).
error-tokenInvalid = Invalid API Token provided: {$token}
error-tokenInvalid = Invalid API Token provided.
error-tokenNotFound = Specified token does not exist.
error-emailAlreadySet = Email address has already been set.
@@ -46,3 +47,7 @@ error-userAlreadySuspended = The user already has an active suspension until {$u
error-userAlreadyBanned = The user is already banned.
error-userBanned = Your account is currently banned.
error-userSuspended = Your account is currently suspended until {$until}.
error-integrationDisabled = Specified integration is disabled.
error-passwordResetTokenExpired = Password reset link expired.
error-emailConfirmTokenExpired = Email confirmation link expired.
error-rateLimitExceeded = Rate limit exceeded.
+2
View File
@@ -6,6 +6,8 @@ import config from "talk-server/config";
import serializers from "./serializers";
import { getStreams } from "./streams";
export type Logger = ReturnType<typeof bunyan.createLogger>;
const logger = bunyan.createLogger({
name: "talk",
+247 -10
View File
@@ -5,10 +5,12 @@ import uuid from "uuid";
import { DeepPartial, Omit, Sub } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import {
ConfirmEmailTokenExpired,
DuplicateEmailError,
DuplicateUserError,
LocalProfileAlreadySetError,
LocalProfileNotSetError,
PasswordResetTokenExpired,
TokenNotFoundError,
UserAlreadyBannedError,
UserAlreadySuspendedError,
@@ -21,6 +23,7 @@ import {
GQLTimeRange,
GQLUSER_ROLE,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { getLocalProfile, hasLocalProfile } from "talk-server/helpers/users";
import logger from "talk-server/logger";
import {
createConnectionOrderVariants,
@@ -42,6 +45,14 @@ export interface LocalProfile {
type: "local";
id: string;
password: string;
/**
* resetID is used during a password reset process to prevent replay attacks.
* When a password reset email is sent, a resetID is associated with the
* account and the token. When a given reset token is used, it is cleared from
* the user, preventing the same reset URL from being used multiple times.
*/
resetID?: string;
}
export interface OIDCProfile {
@@ -217,6 +228,12 @@ export interface User extends TenantResource {
*/
email?: string;
/**
* emailVerificationID is used to store state regarding the verification state
* of an email address to prevent replay attacks.
*/
emailVerificationID?: string;
/**
* emailVerified when true indicates that the given email address has been verified.
*/
@@ -459,10 +476,11 @@ export async function updateUserRole(
return result.value;
}
export async function verifyUserPassword(user: User, password: string) {
const profile: LocalProfile | undefined = user.profiles.find(
({ type }) => type === "local"
) as LocalProfile | undefined;
export async function verifyUserPassword(
user: Pick<User, "profiles">,
password: string
) {
const profile = getLocalProfile(user);
if (!profile) {
throw new LocalProfileNotSetError();
}
@@ -506,11 +524,7 @@ export async function updateUserPassword(
throw new UserNotFoundError(id);
}
if (
!user.profiles.some(
profile => profile.type === "local" && profile.id === user.email
)
) {
if (!hasLocalProfile(user)) {
throw new LocalProfileNotSetError();
}
@@ -850,7 +864,9 @@ export async function setUserLocalProfile(
throw new UserNotFoundError(id);
}
if (user.profiles.some(({ type }) => type === "local")) {
// Check to see if the user has any local profile (not just a local profile
// with a specific email).
if (hasLocalProfile(user)) {
throw new LocalProfileAlreadySetError();
}
@@ -1318,3 +1334,224 @@ export function consolidateUserStatus(
ban: consolidateUserBanStatus(status.ban),
};
}
/**
* createOrRetrieveUserPasswordResetID will create/retrieve a password reset ID
* on the User.
*
* @param mongo MongoDB instance to interact with
* @param tenantID Tenant ID that the User exists on
* @param id ID of the User that we are creating a reset ID with
*/
export async function createOrRetrieveUserPasswordResetID(
mongo: Db,
tenantID: string,
id: string
): Promise<string> {
// Create the ID.
const resetID = uuid.v4();
// Associate the resetID with the user.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
// This ensures that the document we're updating already has a local
// profile associated with them and also doesn't have a resetID.
profiles: {
$elemMatch: {
type: "local",
resetID: null,
},
},
},
{
$set: {
"profiles.$[profiles].resetID": resetID,
},
},
{
arrayFilters: [{ "profiles.type": "local" }],
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
const localProfile = getLocalProfile(user);
if (!localProfile) {
throw new LocalProfileNotSetError();
}
if (localProfile.resetID) {
return localProfile.resetID;
}
throw new Error("an unexpected error occurred");
}
return resetID;
}
export async function createOrRetrieveUserEmailVerificationID(
mongo: Db,
tenantID: string,
id: string
): Promise<string> {
// Create the ID.
const emailVerificationID = uuid.v4();
// Associate the resetID with the user.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
// This ensures that we don't set a emailVerificationID when there is one
// already.
emailVerificationID: null,
$or: [{ emailVerified: false }, { emailVerified: null }],
},
{
$set: {
emailVerificationID,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
if (user.emailVerified) {
throw new Error("email address has already been verified");
}
if (user.emailVerificationID) {
return user.emailVerificationID;
}
throw new Error("an unexpected error occurred");
}
return emailVerificationID;
}
export async function resetUserPassword(
mongo: Db,
tenantID: string,
id: string,
password: string,
resetID: string
) {
// Hash the password.
const hashedPassword = await hashPassword(password);
// Update the user with the new password.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
// This ensures that the document we're updating already has a local
// profile associated with them and also matches the resetID specified.
profiles: {
$elemMatch: {
type: "local",
resetID,
},
},
},
{
$set: {
"profiles.$[profiles].password": hashedPassword,
},
$unset: {
"profiles.$[profiles].resetID": "",
},
},
{
arrayFilters: [{ "profiles.type": "local", "profiles.resetID": resetID }],
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
const profile = getLocalProfile(user);
if (!profile) {
throw new LocalProfileNotSetError();
}
if (profile.resetID !== resetID) {
throw new PasswordResetTokenExpired("reset id mismatch");
}
throw new Error("an unexpected error occurred");
}
return result.value || null;
}
export async function confirmUserEmail(
mongo: Db,
tenantID: string,
id: string,
email: string,
emailVerificationID: string
) {
// Update the user with a confirmed email address.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
email,
emailVerificationID,
},
{
$set: {
emailVerified: true,
},
$unset: {
emailVerificationID: "",
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
const user = await retrieveUser(mongo, tenantID, id);
if (!user) {
throw new UserNotFoundError(id);
}
if (user.email !== email) {
throw new ConfirmEmailTokenExpired("email mismatch");
}
if (user.emailVerificationID !== emailVerificationID) {
throw new ConfirmEmailTokenExpired("email verification id mismatch");
}
throw new Error("an unexpected error occurred");
}
return result.value || null;
}
+45 -13
View File
@@ -6,6 +6,7 @@ import logger from "talk-server/logger";
export interface TaskOptions<T, U = any> {
jobName: string;
jobProcessor: (job: Job<T>) => Promise<U>;
jobOptions?: Queue.JobOptions;
queue: Queue.QueueOptions;
}
@@ -14,10 +15,39 @@ export default class Task<T, U = any> {
private queue: QueueType<T>;
private log: Logger;
constructor(options: TaskOptions<T, U>) {
this.queue = new Queue(options.jobName, options.queue);
this.options = options;
this.log = logger.child({ jobName: options.jobName });
constructor({
jobName,
jobProcessor,
jobOptions = {},
queue,
}: TaskOptions<T, U>) {
this.log = logger.child({ jobName });
this.queue = new Queue(jobName, queue);
this.options = {
jobName,
jobProcessor,
jobOptions: {
// We always remove the job when it's complete, no need to fill up Redis
// with completed entries if we don't need to.
removeOnComplete: true,
// By default, configure jobs to use an exponential backoff
// strategy starting at a 10 second delay.
backoff: {
type: "exponential",
delay: 10000,
},
// Be default, try all jobs at least 5 times.
attempts: 5,
// Add the custom job options if they exist.
...jobOptions,
},
queue,
};
// TODO: (wyattjoh) attach event handlers to the queue for metrics via: https://github.com/OptimalBits/bull/blob/develop/REFERENCE.md#events
}
/**
@@ -27,11 +57,8 @@ export default class Task<T, U = any> {
* @param data the data for the job to add.
*/
public async add(data: T): Promise<Queue.Job<T> | undefined> {
const job = await this.queue.add(data, {
// We always remove the job when it's complete, no need to fill up Redis
// with completed entries if we don't need to.
removeOnComplete: true,
});
// Create the job.
const job = await this.queue.add(data, this.options.jobOptions);
this.log.trace({ jobID: job.id }, "added job to queue");
return job;
@@ -47,10 +74,15 @@ export default class Task<T, U = any> {
log.trace("processing job from queue");
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
log.trace("processing completed");
return promise;
try {
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
log.trace("processing completed");
return promise;
} catch (err) {
log.error({ err }, "job failed to process");
throw err;
}
});
this.log.trace("registered processor for job type");
+2
View File
@@ -7,6 +7,7 @@ import {
createScraperTask,
ScraperQueue,
} from "talk-server/queue/tasks/scraper";
import { I18n } from "talk-server/services/i18n";
import { createRedisClient } from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
@@ -44,6 +45,7 @@ export interface QueueOptions {
mongo: Db;
config: Config;
tenantCache: TenantCache;
i18n: I18n;
}
export interface TaskQueue {
@@ -0,0 +1,14 @@
/*
* Refer to https://www.campaignmonitor.com/css/lists/list-style/ for supported
* features.
*/
.content {
padding: 10px;
background-color: white;
}
table {
padding: 10px;
background-color: whitesmoke;
}
+79 -21
View File
@@ -1,33 +1,84 @@
import nunjucks from "nunjucks";
import { camelCase } from "lodash";
import nunjucks, { Environment, ILoader } from "nunjucks";
import path from "path";
import { Config } from "talk-server/config";
import TenantCache from "talk-server/services/tenant/cache";
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
/**
* templateDirectory is the directory containing the email templates.
*/
import { Tenant } from "talk-server/models/tenant";
import { Template } from "./templates";
// templateDirectory is the directory containing the email templates.
const templateDirectory = path.join(__dirname, "templates");
export interface GenerateHTMLOptions {
/**
* name is the name of the template to render.
*/
name: string;
context: any;
export interface MailerContentOptions {
config: Config;
tenantCache: TenantCache;
}
/**
* render will render the nunjucks template using the provided environment.
*
* @param env the nunjucks rendering environment
* @param template the template to render
*/
function render(env: Environment, { name, context }: Template) {
return new Promise<string>((resolve, reject) =>
env.render(
name + ".html",
{ context, name: camelCase(name) },
(err, html) => {
if (err) {
return reject(err);
}
return resolve(html);
}
)
);
}
export default class MailerContent {
private env: nunjucks.Environment;
private cache: TenantCacheAdapter<nunjucks.Environment>;
private config: Config;
private loaders: ILoader[];
constructor(config: Config) {
// Configure the nunjucks environment.
this.env = new nunjucks.Environment(
new nunjucks.FileSystemLoader(templateDirectory),
{
constructor({ config, tenantCache }: MailerContentOptions) {
this.config = config;
// Configure the environment cache.
this.cache = new TenantCacheAdapter(tenantCache);
// Configure the loaders for the templates that apply to all clients.
this.loaders = [
// Load the templates from the filesystem.
new nunjucks.FileSystemLoader(templateDirectory, {
// When we aren't in production mode, reload the templates.
watch: config.get("env") !== "production",
}
);
watch: this.config.get("env") !== "production",
}),
];
}
/**
* getEnvironment will get the environment from the cache if it exists, or
* create it, and add it to the cache otherwise.
*
* @param tenant the Tenant to generate the environment for
*/
private getEnvironment(tenant: Pick<Tenant, "id">): nunjucks.Environment {
// Get the nunjucks environment to use for generating the email HTML.
let env = this.cache.get(tenant.id);
if (!env) {
// TODO: (wyattjoh) add the custom loader per tenant here to support customizing templates.
// Configure the nunjucks environment.
env = new nunjucks.Environment(this.loaders);
// Set the environment in the cache.
this.cache.set(tenant.id, env);
}
return env;
}
/**
@@ -37,7 +88,14 @@ export default class MailerContent {
* @param options configuration for generating HTML based on the email
* template.
*/
public generateHTML(options: GenerateHTMLOptions): string {
return this.env.render(options.name + ".html", options.context);
public async generateHTML(
tenant: Tenant,
template: Template
): Promise<string> {
// Get the environment to render with.
const env = this.getEnvironment(tenant);
// Render the nunjucks template.
return render(env, template);
}
}
+30 -139
View File
@@ -1,139 +1,24 @@
import Queue, { Job } from "bull";
import htmlToText from "html-to-text";
import Joi from "joi";
import { Db } from "mongodb";
import { createTransport } from "nodemailer";
import Queue from "bull";
import now from "performance-now";
import { Config } from "talk-server/config";
import logger from "talk-server/logger";
import Task from "talk-server/queue/Task";
import MailerContent from "talk-server/queue/tasks/mailer/content";
import TenantCache from "talk-server/services/tenant/cache";
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
const JOB_NAME = "mailer";
export interface MailProcessorOptions {
config: Config;
mongo: Db;
tenantCache: TenantCache;
}
export interface MailerData {
message: {
to: string;
subject: string;
html: string;
};
tenantID: string;
}
const MailerDataSchema = Joi.object().keys({
message: Joi.object().keys({
to: Joi.string(),
subject: Joi.string(),
html: Joi.string(),
}),
tenantID: Joi.string(),
});
const createJobProcessor = (options: MailProcessorOptions) => {
const { tenantCache } = options;
// Create the cache adapter that will handle invalidating the email transport
// when the tenant experiences a change.
const cache = new TenantCacheAdapter<ReturnType<typeof createTransport>>(
tenantCache
);
return async (job: Job<MailerData>) => {
const { value, error: err } = Joi.validate(job.data, MailerDataSchema, {
stripUnknown: true,
presence: "required",
abortEarly: false,
});
if (err) {
logger.error(
{
jobID: job.id,
jobName: JOB_NAME,
err,
},
"job data did not match expected schema"
);
return;
}
// Pull the data out of the validated model.
const { message, tenantID } = value;
const log = logger.child({
jobID: job.id,
jobName: JOB_NAME,
tenantID,
});
// Get the referenced tenant so we know who to send it from.
const tenant = await tenantCache.retrieveByID(tenantID);
if (!tenant) {
log.error("referenced tenant was not found");
return;
}
if (!tenant.email.enabled) {
log.error("not sending email, it was disabled");
return;
}
if (!tenant.email.smtpURI) {
log.error("email was enabled but the smtpURI configuration was missing");
return;
}
if (!tenant.email.fromAddress) {
// TODO: possibly have fallback email address?
log.error(
"email was enabled but the fromAddress configuration was missing"
);
return;
}
let transport = cache.get(tenantID);
if (!transport) {
// Create the transport based on the smtp uri.
transport = createTransport(tenant.email.smtpURI);
// Set the transport back into the cache.
cache.set(tenantID, transport);
log.debug("transport was not cached");
} else {
log.debug("transport was cached");
}
log.debug("starting to send the email");
// Send the mail message.
await transport.sendMail({
...message,
// Generate the text content of the message from the HTML.
text: htmlToText.fromString(message.html),
from: tenant.email.fromAddress,
});
log.debug("sent the email");
};
};
import {
createJobProcessor,
JOB_NAME,
MailerData,
MailProcessorOptions,
} from "./processor";
import { Template } from "./templates";
export interface MailerInput {
message: {
to: string;
subject: string;
};
template: {
name: string;
context: object;
};
template: Template;
tenantID: string;
}
@@ -148,13 +33,11 @@ export class MailerQueue {
jobProcessor: createJobProcessor(options),
queue,
});
this.content = new MailerContent(options.config);
this.content = new MailerContent(options);
this.tenantCache = options.tenantCache;
}
public async add({ template, ...rest }: MailerInput) {
const { tenantID } = rest;
public async add({ template, tenantID, message: { to } }: MailerInput) {
const log = logger.child({
jobName: JOB_NAME,
tenantID,
@@ -175,20 +58,28 @@ export class MailerQueue {
return;
}
// Generate the HTML for the email template.
const html = this.content.generateHTML({
...template,
context: {
...template.context,
tenant,
},
});
const startTemplateGenerationTime = now();
let html: string;
try {
// Generate the HTML for the email template.
html = await this.content.generateHTML(tenant, template);
} catch (err) {
log.error({ err }, "could not generate the html");
// TODO: (wyattjoh) maybe throw an error here?
return;
}
// Compute the end time.
const responseTime = Math.round(now() - startTemplateGenerationTime);
log.trace({ responseTime }, "finished template generation");
// Return the job that'll add the email to the queue to be processed later.
return this.task.add({
...rest,
tenantID,
templateName: template.name,
message: {
...rest.message,
to,
html,
},
});
@@ -0,0 +1,274 @@
import { Job } from "bull";
import { DOMLocalization } from "fluent-dom/compat";
import { FluentBundle } from "fluent/compat";
import { minify } from "html-minifier";
import htmlToText from "html-to-text";
import Joi from "joi";
import { JSDOM } from "jsdom";
import { juiceResources } from "juice";
import { camelCase } from "lodash";
import { Db } from "mongodb";
import { createTransport } from "nodemailer";
import now from "performance-now";
import { LanguageCode } from "talk-common/helpers/i18n/locales";
import { Config } from "talk-server/config";
import { InternalError } from "talk-server/errors";
import logger from "talk-server/logger";
import { I18n, translate } from "talk-server/services/i18n";
import TenantCache from "talk-server/services/tenant/cache";
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
export const JOB_NAME = "mailer";
export interface MailProcessorOptions {
config: Config;
mongo: Db;
tenantCache: TenantCache;
i18n: I18n;
}
export interface MailerData {
templateName: string;
message: {
to: string;
html: string;
};
tenantID: string;
}
const MailerDataSchema = Joi.object().keys({
templateName: Joi.string(),
message: Joi.object().keys({
to: Joi.string(),
html: Joi.string(),
}),
tenantID: Joi.string(),
});
interface Message {
from: string;
to: string;
html: string;
text: string;
subject: string;
}
/**
* juiceHTML will juice the HTML to inline the CSS and minify it.
*
* @param input html string to juice
*/
export function juiceHTML(input: string) {
return new Promise<string>((resolve, reject) => {
juiceResources(
input,
{ webResources: { relativeTo: __dirname } },
(err, html) => {
if (err) {
return reject(err);
}
return resolve(
minify(html, {
removeComments: true,
collapseWhitespace: true,
})
);
}
);
});
}
function generateBundleIterator(bundle: FluentBundle) {
return function* generate(resourceIDs: string[]) {
yield bundle;
};
}
function createMessageTranslator(i18n: I18n) {
/**
* translateMessage will translate the message to the specified locale as well
* a juice the contents.
*
* @param templateName the name of the template to base the translations off of
* @param locale the locale to translate the email content into
* @param fromAddress the address that is sending the email (from the Tenant)
* @param data data used to send the message
*/
return async (
templateName: string,
locale: LanguageCode,
fromAddress: string,
data: MailerData
): Promise<Message> => {
// Setup the localization bundles.
const bundle = i18n.getBundle(locale);
const loc = new DOMLocalization([], generateBundleIterator(bundle));
// Translate the HTML fragments.
let dom: JSDOM;
try {
// Parse the rendered template.
dom = new JSDOM(data.message.html, {});
} catch (err) {
logger.error({ err }, "could not parse the HTML for i18n");
throw err;
}
// Translate the bundle.
await loc.translateFragment(dom.window.document);
// TODO: (wyattjoh) strip the i18n attributes from the source.
// Grab the rendered HTML from the dom, and juice them.
if (!dom.window.document.documentElement) {
throw new Error("dom did not have a document element");
}
const translatedHTML = dom.window.document.documentElement.outerHTML;
// Juice the HTML to inline resources.
const html = await juiceHTML(translatedHTML);
// Get the translated subject.
const subject = translate(
bundle,
templateName,
`email-subject-${camelCase(templateName)}`
);
// Generate the text content of the message from the HTML.
const text = htmlToText.fromString(html);
// Prepare the message payload.
return {
from: fromAddress,
to: data.message.to,
html,
text,
subject,
};
};
}
export const createJobProcessor = (options: MailProcessorOptions) => {
const { tenantCache, i18n } = options;
// Create the cache adapter that will handle invalidating the email transport
// when the tenant experiences a change.
const cache = new TenantCacheAdapter<ReturnType<typeof createTransport>>(
tenantCache
);
// Create the message translator function.
const translateMessage = createMessageTranslator(i18n);
return async (job: Job<MailerData>) => {
const { value: data, error: err } = Joi.validate(
job.data,
MailerDataSchema,
{
stripUnknown: true,
presence: "required",
abortEarly: false,
}
);
if (err) {
logger.error(
{
jobID: job.id,
jobName: JOB_NAME,
err,
},
"job data did not match expected schema"
);
return;
}
// Pull the data out of the validated model.
const { tenantID } = data;
const log = logger.child({
jobID: job.id,
jobName: JOB_NAME,
tenantID,
});
// Get the referenced tenant so we know who to send it from.
const tenant = await tenantCache.retrieveByID(tenantID);
if (!tenant) {
log.error("referenced tenant was not found");
return;
}
const { enabled, smtpURI, fromAddress } = tenant.email;
if (!enabled) {
log.error("not sending email, it was disabled");
return;
}
if (!smtpURI) {
log.error("email was enabled but the smtpURI configuration was missing");
return;
}
if (!fromAddress) {
log.error(
"email was enabled but the fromAddress configuration was missing"
);
return;
}
const startTemplateGenerationTime = now();
// Get the message to send.
let message: Message;
try {
message = await translateMessage(
data.templateName,
tenant.locale,
fromAddress,
data
);
} catch (err) {
throw new InternalError(err, "could not translate the message");
}
// Compute the end time.
const responseTime = Math.round(now() - startTemplateGenerationTime);
log.trace({ responseTime }, "finished mail translation");
let transport = cache.get(tenantID);
if (!transport) {
try {
// Create the transport based on the smtp uri.
transport = createTransport(smtpURI);
} catch (err) {
throw new InternalError(err, "could not create email transport");
}
// Set the transport back into the cache.
cache.set(tenantID, transport);
log.debug("transport was not cached");
} else {
log.debug("transport was cached");
}
log.debug("starting to send the email");
const startMessageSendTime = now();
try {
// Send the mail message.
await transport.sendMail(message);
} catch (err) {
throw new InternalError(err, "could not send email");
}
// Compute the end time.
const messageSendResponseTime = Math.round(now() - startMessageSendTime);
log.debug({ responseTime: messageSendResponseTime }, "sent the email");
};
};
@@ -0,0 +1,9 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
Someone with access to your account has violated our community guidelines.
As a result, your account has been banned. You will no longer be able to
comment, react or report comments. if you think this has been done in error,
please contact our community team at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
@@ -0,0 +1,9 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
To confirm your email address for use with your commenting account at {{ context.organizationName }},
please follow this link: <a data-l10n-name="confirmYourEmail" href="{{ context.confirmURL }}">Click here to confirm your email</a><br/><br/>
If you did not recently create a commenting account with
{{ context.organizationName }}, you can safely ignore this email.
{% endblock %}
@@ -1,14 +1,8 @@
<!doctype html>
<html lang="en">
<head></head>
<body>
<h1>Forgot Password</h1>
<p>We received a request to reset your password on <a href="{{ tenant.organizationURL }}">{{ tenant.organizationName }}</a>.</p>
<p>Please follow the link below to reset your password:</p>
<p><a href="{{ resetURL }}">Click here to reset your password</a></p>
<p><i>If you did not request this change, you can ignore this email.</i></p>
<footer>
<p>Sent by <a href="{{ tenant.organizationURL }}">{{ tenant.organizationName }}</a></p>
</footer>
</body>
</html>
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
We received a request to reset your password on <a data-l10n-name="organizationName" href="{{ context.organizationURL }}"></a>.<br/><br/>
Please follow this link reset your password: <a data-l10n-name="resetYourPassword" href="{{ context.resetURL }}">Click here to reset your password</a><br/><br/>
<i>If you did not request this, you can ignore this email.</i><br/>
{% endblock %}
@@ -0,0 +1,63 @@
interface Template<T extends string, U extends {}> {
name: T;
context: U;
}
type UserNotificationContext<T extends string, U extends {}> = Template<
T,
U & {
organizationURL: string;
organizationName: string;
}
>;
export type ForgotPasswordTemplate = UserNotificationContext<
"forgot-password",
{
username: string;
resetURL: string;
}
>;
export type BanTemplate = UserNotificationContext<
"ban",
{
username: string;
organizationContactEmail: string;
}
>;
export type SuspendTemplate = UserNotificationContext<
"suspend",
{
username: string;
until: string;
organizationContactEmail: string;
}
>;
export type PasswordChangeTemplate = UserNotificationContext<
"password-change",
{
username: string;
organizationContactEmail: string;
}
>;
export type ConfirmEmailTemplate = UserNotificationContext<
"confirm-email",
{
username: string;
confirmURL: string;
organizationContactEmail: string;
}
>;
type Templates =
| ForgotPasswordTemplate
| BanTemplate
| SuspendTemplate
| PasswordChangeTemplate
| ConfirmEmailTemplate;
export { Templates as Template };
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
{# Meta tags #}
<meta charset="utf-8" />
{% block meta %}{% endblock %}
{# CSS #}
<link href="assets/main.css" rel="stylesheet">
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
@@ -0,0 +1,16 @@
{% extends "layouts/base.html" %}
{% block body %}
<table>
<tr>
<td class="content" data-l10n-id="email-notification-template-{{ name }}" data-l10n-args="{{ context | dump }}">
{% block content %}{% endblock %}
</td>
</tr>
<tr>
<td align="center" data-l10n-id="email-notification-footer" data-l10n-args="{{ context | dump }}">
Sent by <a data-l10n-name="organizationLink" href="{{ context.organizationURL }}">{{ context.organizationName }}</a>
</td>
</tr>
</table>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
The password on your account has been changed.<br/><br/>
If you did not request this change,
please contact please contact our community team at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Hello {{ context.username }},<br/><br/>
In accordance with {{ context.organizationName }}'s community guidelines, your
account has been temporarily suspended. During the suspension, you will be
unable to comment, flag or engage with fellow commenters. Please rejoin the
conversation {{ context.until }}.<br/><br/>
If you think this has been done in error, please contact our community team
at <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
@@ -1,31 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createJWTSigningConfig parses a RSA certificate 1`] = `
"-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV
EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+
MHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U
C9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2
nQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV
1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS
2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw
fdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD
KrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB
GdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7
0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ
kPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi
55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN
oLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v
lbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6
9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK
8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY
SvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1
rFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr
xqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb
za9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7
1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0
pYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f
RaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt
ySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=
-----END RSA PRIVATE KEY-----"
`;
@@ -33,6 +33,14 @@ describe("extractJWTFromRequest", () => {
expect(extractJWTFromRequest((req as any) as Request)).toEqual("token");
});
it("does not extract the token from query string when it's disabled", () => {
const req = {
url: "https://talk.coralproject.net/api?accessToken=token",
};
expect(extractJWTFromRequest((req as any) as Request, true)).toEqual(null);
});
});
describe("createJWTSigningConfig", () => {
+176 -11
View File
@@ -1,13 +1,130 @@
import { Redis } from "ioredis";
import jwt, { SignOptions } from "jsonwebtoken";
import { Bearer } from "permit";
import Joi from "joi";
import jwt, { SignOptions, VerifyOptions } from "jsonwebtoken";
import { Bearer, BearerOptions } from "permit";
import uuid from "uuid/v4";
import { Omit } from "talk-common/types";
import { Config } from "talk-server/config";
import { AuthenticationError } from "talk-server/errors";
import { AuthenticationError, TokenInvalidError } from "talk-server/errors";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
/**
* The following Claim Names are registered in the IANA "JSON Web Token
* Claims" registry established by Section 10.1. None of the claims
* defined below are intended to be mandatory to use or implement in all
* cases, but rather they provide a starting point for a set of useful,
* interoperable claims. Applications using JWTs should define which
* specific claims they use and when they are required or optional. All
* the names are short because a core goal of JWTs is for the
* representation to be compact.
*
* https://tools.ietf.org/html/rfc7519#section-4.1
*/
export interface StandardClaims {
/**
* The "jti" (JWT ID) claim provides a unique identifier for the JWT. The
* identifier value MUST be assigned in a manner that ensures that there is a
* negligible probability that the same value will be accidentally assigned to
* a different data object; if the application uses multiple issuers,
* collisions MUST be prevented among values produced by different issuers as
* well. The "jti" claim can be used to prevent the JWT from being replayed.
* The "jti" value is a case- sensitive string. Use of this claim is
* OPTIONAL.
*
* https://tools.ietf.org/html/rfc7519#section-4.1.7
*/
jti?: string;
/**
* The "aud" (audience) claim identifies the recipients that the JWT is
* intended for. Each principal intended to process the JWT MUST
* identify itself with a value in the audience claim. If the principal
* processing the claim does not identify itself with a value in the
* "aud" claim when this claim is present, then the JWT MUST be
* rejected. In the general case, the "aud" value is an array of case-
* sensitive strings, each containing a StringOrURI value. In the
* special case when the JWT has one audience, the "aud" value MAY be a
* single case-sensitive string containing a StringOrURI value. The
* interpretation of audience values is generally application specific.
* Use of this claim is OPTIONAL.
*
* https://tools.ietf.org/html/rfc7519#section-4.1.3
*/
aud?: string;
/**
* The "sub" (subject) claim identifies the principal that is the
* subject of the JWT. The claims in a JWT are normally statements
* about the subject. The subject value MUST either be scoped to be
* locally unique in the context of the issuer or be globally unique.
* The processing of this claim is generally application specific. The
* "sub" value is a case-sensitive string containing a StringOrURI
* value. Use of this claim is OPTIONAL.
*
* https://tools.ietf.org/html/rfc7519#section-4.1.2
*/
sub?: string;
/**
* The "iss" (issuer) claim identifies the principal that issued the
* JWT. The processing of this claim is generally application specific.
* The "iss" value is a case-sensitive string containing a StringOrURI
* value. Use of this claim is OPTIONAL.
*
* https://tools.ietf.org/html/rfc7519#section-4.1.2
*/
iss?: string;
/**
* The "exp" (expiration time) claim identifies the expiration time on
* or after which the JWT MUST NOT be accepted for processing. The
* processing of the "exp" claim requires that the current date/time
* MUST be before the expiration date/time listed in the "exp" claim.
* Implementers MAY provide for some small leeway, usually no more than
* a few minutes, to account for clock skew. Its value MUST be a number
* containing a NumericDate value. Use of this claim is OPTIONAL.
*
* https://tools.ietf.org/html/rfc7519#section-4.1.4
*/
exp?: number;
/**
* The "nbf" (not before) claim identifies the time before which the JWT
* MUST NOT be accepted for processing. The processing of the "nbf"
* claim requires that the current date/time MUST be after or equal to
* the not-before date/time listed in the "nbf" claim. Implementers MAY
* provide for some small leeway, usually no more than a few minutes, to
* account for clock skew. Its value MUST be a number containing a
* NumericDate value. Use of this claim is OPTIONAL.
*
* https://tools.ietf.org/html/rfc7519#section-4.1.5
*/
nbf?: number;
/**
* The "iat" (issued at) claim identifies the time at which the JWT was
* issued. This claim can be used to determine the age of the JWT. Its
* value MUST be a number containing a NumericDate value. Use of this
* claim is OPTIONAL.
*
* https://tools.ietf.org/html/rfc7519#section-4.1.6
*/
iat?: number;
}
export const StandardClaimsSchema = Joi.object().keys({
jti: Joi.string(),
aud: Joi.string(),
sub: Joi.string(),
iss: Joi.string(),
exp: Joi.number(),
nbf: Joi.number(),
iat: Joi.number(),
});
export enum AsymmetricSigningAlgorithm {
RS256 = "RS256",
RS384 = "RS384",
@@ -28,7 +145,7 @@ export type JWTSigningAlgorithm =
| SymmetricSigningAlgorithm;
export interface JWTSigningConfig {
secret: Buffer;
secret: Buffer | string;
algorithm: JWTSigningAlgorithm;
}
@@ -48,7 +165,7 @@ export function createSymmetricSigningConfig(
secret: string
): JWTSigningConfig {
return {
secret: Buffer.from(secret, "utf8"),
secret,
algorithm,
};
}
@@ -89,14 +206,16 @@ export type SigningTokenOptions = Pick<
export const signTokenString = async (
{ algorithm, secret }: JWTSigningConfig,
user: User,
options: SigningTokenOptions
user: Pick<User, "id">,
tenant: Pick<Tenant, "id">,
options: SigningTokenOptions = {}
) =>
jwt.sign({}, secret, {
jwtid: uuid(),
// TODO: (wyattjoh) evaluate allowing configuration?
expiresIn: "1 day",
...options,
issuer: tenant.id,
subject: user.id,
algorithm,
});
@@ -112,11 +231,32 @@ export const signPATString = async (
algorithm,
});
export function extractJWTFromRequest(req: Request) {
const permit = new Bearer({
export async function signString<T extends {}>(
{ algorithm, secret }: JWTSigningConfig,
payload: T,
options: Omit<SignOptions, "algorithm"> = {}
) {
return jwt.sign(payload, secret, { ...options, algorithm });
}
/**
*
* @param req the request to extract the JWT from
* @param excludeQuery when true, does not pull from the query params
*/
export function extractJWTFromRequest(
req: Request,
excludeQuery: boolean = false
) {
const options: BearerOptions = {
basic: "password",
query: "accessToken",
});
};
if (!excludeQuery) {
options.query = "accessToken";
}
const permit = new Bearer(options);
return permit.check(req) || null;
}
@@ -140,3 +280,28 @@ export async function checkJWTRevoked(redis: Redis, jti: string) {
throw new AuthenticationError("JWT was revoked");
}
}
export function verifyJWT(
tokenString: string,
{ algorithm, secret }: JWTSigningConfig,
now: Date,
options: Omit<VerifyOptions, "algorithms" | "clockTimestamp"> = {}
) {
try {
return jwt.verify(tokenString, secret, {
...options,
algorithms: [algorithm],
clockTimestamp: Math.floor(now.getTime() / 1000),
}) as object;
} catch (err) {
throw new TokenInvalidError(tokenString, "token validation error", err);
}
}
export function decodeJWT(tokenString: string) {
try {
return jwt.decode(tokenString, {}) as StandardClaims;
} catch (err) {
throw new TokenInvalidError(tokenString, "token validation error", err);
}
}
-50
View File
@@ -1,50 +0,0 @@
import sinon from "sinon";
import { Config } from "talk-server/config";
import { Request } from "talk-server/types/express";
import { createJWTSigningConfig, extractJWTFromRequest } from ".";
describe("extractJWTFromRequest", () => {
it("extracts the token from header", () => {
const req = {
headers: {
authorization: "Bearer token",
},
url: "",
};
expect(extractJWTFromRequest((req as any) as Request)).toEqual("token");
delete req.headers.authorization;
expect(extractJWTFromRequest((req as any) as Request)).toEqual(null);
});
it("extracts the token from query string", () => {
const req = {
url: "",
};
expect(extractJWTFromRequest((req as any) as Request)).toEqual(null);
req.url = "https://talk.coralproject.net/api?accessToken=token";
expect(extractJWTFromRequest((req as any) as Request)).toEqual("token");
});
});
describe("createJWTSigningConfig", () => {
it("parses a RSA certificate", () => {
const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`;
const config = {
get: sinon.stub(),
};
config.get.withArgs("signing_secret").returns(input);
config.get.withArgs("signing_algorithm").returns("RS256");
const signingConfig = createJWTSigningConfig((config as any) as Config);
expect(signingConfig.algorithm).toEqual("RS256");
expect(signingConfig.secret.toString()).toMatchSnapshot();
});
});
+1 -41
View File
@@ -2,12 +2,6 @@ import { zip } from "lodash";
import { DateTime } from "luxon";
import { Db } from "mongodb";
import {
doesRequireSchemePrefixing,
getOrigin,
isURLSecure,
prefixSchemeIfRequired,
} from "talk-server/app/url";
import { StoryURLInvalidError } from "talk-server/errors";
import logger from "talk-server/logger";
import {
@@ -46,6 +40,7 @@ import { ScraperQueue } from "talk-server/queue/tasks/scraper";
import { scrape } from "talk-server/services/stories/scraper";
import { AugmentedRedis } from "../redis";
import { isURLPermitted } from "../tenant/url";
export type FindOrCreateStory = FindOrCreateStoryInput;
@@ -87,41 +82,6 @@ export async function findOrCreate(
return story;
}
/**
* isURLInsideAllowedDomains will validate if the given origin is allowed given
* the Tenant's domain configuration.
*/
export function isURLPermitted(
tenant: Pick<Tenant, "domains">,
targetURL: string
) {
// If there aren't any domains, then we reject it, because no url we have can
// satisfy those requirements.
if (tenant.domains.length === 0) {
return false;
}
// If the scheme can not be inferred, then we can't determine the
// admissability of the url.
if (doesRequireSchemePrefixing(targetURL)) {
return false;
}
// Determine the scheme of the targetOrigin. We know that the targetURL does
// not need prefixing, so it can only be true/false here.
const originSecure = isURLSecure(targetURL) as boolean;
// Extract the origin from the URL.
const targetOrigin = getOrigin(targetURL);
// Loop over all the Tenant domains provided. Prefix the domain of each if it
// is required with the target url scheme. Return if at least one match is
// found within the Tenant domains.
return tenant.domains
.map(domain => getOrigin(prefixSchemeIfRequired(originSecure, domain)))
.some(origin => origin === targetOrigin);
}
export async function remove(
mongo: Db,
tenant: Tenant,
@@ -1,4 +1,19 @@
import { isURLPermitted } from "talk-server/services/stories";
import { Tenant } from "talk-server/models/tenant";
import { isURLPermitted } from "talk-server/services/tenant/url";
type PartialTenant = Pick<Tenant, "domains" | "domain">;
function createTenant(input: Partial<PartialTenant> = {}): PartialTenant {
if (!input.domain) {
input.domain = "";
}
if (!input.domains) {
input.domains = [];
}
return input as PartialTenant;
}
it("denies when the tenant has no specified domains", () => {
const tenant = { domains: [] };
@@ -7,23 +22,23 @@ it("denies when the tenant has no specified domains", () => {
});
it("denies when tenant has a domain but not a valid url", () => {
const tenant = { domains: ["https://coralproject.net"] };
const tenant = createTenant({ domains: ["https://coralproject.net"] });
expect(isURLPermitted(tenant, "")).toEqual(false);
});
it("denies when there are multiple tenants domains and not a valid url", () => {
const tenant = {
const tenant = createTenant({
domains: ["https://coralproject.net", "https://news.coralproject.net"],
};
});
expect(isURLPermitted(tenant, "")).toEqual(false);
});
it("denies when there are multiple tenants domains and a invalid url", () => {
const tenant = {
const tenant = createTenant({
domains: ["https://coralproject.net", "https://news.coralproject.net"],
};
});
expect(
isURLPermitted(tenant, "https://blog.coralproject.net/a/page")
@@ -31,9 +46,9 @@ it("denies when there are multiple tenants domains and a invalid url", () => {
});
it("allows when there are multiple tenants domains and a valid url", () => {
const tenant = {
const tenant = createTenant({
domains: ["https://coralproject.net", "https://news.coralproject.net"],
};
});
expect(
isURLPermitted(tenant, "https://news.coralproject.net/a/page")
@@ -41,9 +56,9 @@ it("allows when there are multiple tenants domains and a valid url", () => {
});
it("allows when there are multiple prefix domains and a valid url", () => {
const tenant = {
const tenant = createTenant({
domains: ["coralproject.net", "news.coralproject.net"],
};
});
expect(isURLPermitted(tenant, "http://news.coralproject.net/a/page")).toEqual(
true
@@ -51,11 +66,31 @@ it("allows when there are multiple prefix domains and a valid url", () => {
});
it("allows when there are some prefix domains and a valid url", () => {
const tenant = {
const tenant = createTenant({
domains: ["http://coralproject.net", "news.coralproject.net"],
};
});
expect(
isURLPermitted(tenant, "https://news.coralproject.net/a/page")
).toEqual(true);
});
it("allows and validates with the tenant domain", () => {
const tenant = createTenant({
domain: "coralproject.net",
});
expect(
isURLPermitted(tenant, "https://coralproject.net/admin/login", true)
).toEqual(true);
});
it("denies and validates with the tenant domain", () => {
const tenant = createTenant({
domain: "talk.coralproject.net",
});
expect(
isURLPermitted(tenant, "https://coralproject.net/admin/login", true)
).toEqual(false);
});
+60
View File
@@ -0,0 +1,60 @@
import {
doesRequireSchemePrefixing,
getOrigin,
isURLSecure,
prefixSchemeIfRequired,
} from "talk-server/app/url";
import { Tenant } from "talk-server/models/tenant";
export function isURLPermitted(
tenant: Pick<Tenant, "domains">,
targetURL: string,
includeTenantDomain?: false
): boolean;
export function isURLPermitted(
tenant: Pick<Tenant, "domains" | "domain">,
targetURL: string,
includeTenantDomain: true
): boolean;
/**
* isURLInsideAllowedDomains will validate if the given origin is allowed given
* the Tenant's domain configuration.
*/
export function isURLPermitted(
tenant: Pick<Tenant, "domains" | "domain">,
targetURL: string,
includeTenantDomain: boolean = false
) {
// If there aren't any domains, then we reject it, because no url we have can
// satisfy those requirements.
if (tenant.domains.length === 0 && !includeTenantDomain) {
return false;
}
// If the scheme can not be inferred, then we can't determine the
// admissability of the url.
if (doesRequireSchemePrefixing(targetURL)) {
return false;
}
// Determine the scheme of the targetOrigin. We know that the targetURL does
// not need prefixing, so it can only be true/false here.
const originSecure = isURLSecure(targetURL) as boolean;
// Extract the origin from the URL.
const targetOrigin = getOrigin(targetURL);
// Create the list of domains to check against.
const domains = includeTenantDomain
? [tenant.domain, ...tenant.domains]
: tenant.domains;
// Loop over all the Tenant domains provided. Prefix the domain of each if it
// is required with the target url scheme. Return if at least one match is
// found within the Tenant domains.
return domains
.map(domain => getOrigin(prefixSchemeIfRequired(originSecure, domain)))
.some(origin => origin === targetOrigin);
}
@@ -0,0 +1,33 @@
import uuid from "uuid/v1";
import { ConfirmToken, isConfirmToken } from "./confirm";
function createToken(partial: Partial<ConfirmToken> | any = {}): ConfirmToken {
const now = Math.floor(Date.now() / 1000);
const token: ConfirmToken = {
sub: uuid(),
jti: uuid(),
iss: uuid(),
exp: now + 60 * 60 * 24,
nbf: now,
iat: now,
aud: "confirm",
evid: uuid(),
email: "email@address.com",
};
return {
...token,
...partial,
};
}
describe("isConfirmToken", () => {
it("validates a valid confirm token", () => {
expect(isConfirmToken(createToken())).toBeTruthy();
});
it("rejects an invalid aud value in a token", () => {
expect(isConfirmToken(createToken({ aud: "not-confirm" }))).toBeFalsy();
});
});
@@ -0,0 +1,243 @@
import Joi from "joi";
import { isNull } from "lodash";
import { DateTime } from "luxon";
import { Db } from "mongodb";
import { constructTenantURL } from "talk-server/app/url";
import { Config } from "talk-server/config";
import {
ConfirmEmailTokenExpired,
TokenInvalidError,
UserNotFoundError,
} from "talk-server/errors";
import { Tenant } from "talk-server/models/tenant";
import {
confirmUserEmail,
createOrRetrieveUserEmailVerificationID,
retrieveUser,
User,
} from "talk-server/models/user";
import { MailerQueue } from "talk-server/queue/tasks/mailer";
import {
JWTSigningConfig,
signString,
StandardClaims,
StandardClaimsSchema,
verifyJWT,
} from "talk-server/services/jwt";
import uuid = require("uuid");
export interface ConfirmToken extends Required<StandardClaims> {
// aud specifies `confirm` as the audience to indicate that this is a confirm
// token.
aud: "confirm";
/**
* email is the email address being confirmed.
*/
email: string;
/**
* evid is the email verification ID token.
*/
evid: string;
}
const ConfirmTokenSchema = StandardClaimsSchema.keys({
aud: Joi.string().only("confirm"),
email: Joi.string().email(),
evid: Joi.string(),
});
export function validateConfirmToken(
token: ConfirmToken | object
): Error | null {
const { error } = Joi.validate(token, ConfirmTokenSchema, {
presence: "required",
});
return error || null;
}
export function isConfirmToken(
token: ConfirmToken | object
): token is ConfirmToken {
return isNull(validateConfirmToken(token));
}
export async function generateConfirmURL(
mongo: Db,
tenant: Tenant,
config: Config,
signingConfig: JWTSigningConfig,
user: Required<Pick<User, "id" | "email">>,
now: Date
) {
// Pull some stuff out of the user.
const { id, email } = user;
// Change the JS Date to a DateTime for ease of use.
const nowDate = DateTime.fromJSDate(now);
const nowSeconds = Math.round(nowDate.toSeconds());
// The expiry of this token is linked as 1 week after issuance.
const expiresAt = Math.round(nowDate.plus({ weeks: 1 }).toSeconds());
// Create the email verification id.
const evid = await createOrRetrieveUserEmailVerificationID(
mongo,
tenant.id,
id
);
// Generate a token with this new reset ID.
const confirmToken: ConfirmToken = {
jti: uuid.v4(),
iss: tenant.id,
sub: id,
exp: expiresAt,
iat: nowSeconds,
nbf: nowSeconds,
aud: "confirm",
email,
evid,
};
// Sign it with the signing config.
const token = await signString(signingConfig, confirmToken);
// Generate the confirmation url.
return constructTenantURL(
config,
tenant,
// TODO: (kiwi) verify that url is correct.
`/account/email/confirm#confirmToken=${token}`
);
}
export async function verifyConfirmTokenString(
mongo: Db,
tenant: Tenant,
signingConfig: JWTSigningConfig,
tokenString: string,
now: Date
) {
const token = verifyJWT(tokenString, signingConfig, now, {
// Verify that the token is for this Tenant.
issuer: tenant.id,
// Verify that this is a confirm token based on the audience.
audience: "confirm",
});
// Validate that this is indeed a reset token.
if (!isConfirmToken(token)) {
// TODO: (wyattjoh) look into a way of pulling the error into this one
throw new TokenInvalidError(
tokenString,
"does not conform to the confirm token schema"
);
}
// Unpack some of the token.
const { sub: userID, email, evid: emailVerificationID } = token;
// TODO: (wyattjoh) verify that the token has not been revoked.
// Check to see if this confirm token has already verified this email.
const user = await retrieveUser(mongo, tenant.id, userID);
if (!user) {
throw new UserNotFoundError(userID);
}
// Check to see if the email address being verified matches the one that's
// been provided.
if (user.email !== email) {
throw new ConfirmEmailTokenExpired("email mismatch");
}
// Check to see that the email verification ID matches.
if (user.emailVerificationID !== emailVerificationID) {
throw new ConfirmEmailTokenExpired("email verification id mismatch");
}
// Check to see if the user has a verified email address already.
if (user.emailVerified) {
throw new ConfirmEmailTokenExpired("email address already verified");
}
// Now that we've verified that the token is valid, and has been checked to
// match the current email address, we're good to go!
return token;
}
export async function confirmEmail(
mongo: Db,
tenant: Tenant,
signingConfig: JWTSigningConfig,
tokenString: string,
now: Date
) {
// Verify that the confirm token is valid and unpack it.
const {
sub: userID,
email,
evid: emailVerificationID,
} = await verifyConfirmTokenString(
mongo,
tenant,
signingConfig,
tokenString,
now
);
// Perform the confirm operation.
const user = await confirmUserEmail(
mongo,
tenant.id,
userID,
email,
emailVerificationID
);
// TODO: (wyattjoh) revoke the JTI
return user;
}
export async function sendConfirmationEmail(
mongo: Db,
mailer: MailerQueue,
tenant: Tenant,
config: Config,
signingConfig: JWTSigningConfig,
user: Required<Pick<User, "id" | "username" | "email">>,
now: Date
) {
// Generate the confirmation url.
const confirmURL = await generateConfirmURL(
mongo,
tenant,
config,
signingConfig,
user,
now
);
// Send the email confirmation.
await mailer.add({
tenantID: tenant.id,
message: {
to: user.email,
},
template: {
name: "confirm-email",
context: {
// TODO: (wyattjoh) possibly reevaluate the use of a required username.
username: user.username,
confirmURL,
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
organizationContactEmail: tenant.organization.contactEmail,
},
},
});
}
@@ -0,0 +1,6 @@
export {
generateResetURL,
resetPassword,
verifyResetTokenString,
} from "./reset";
export { sendConfirmationEmail } from "./confirm";
@@ -0,0 +1,32 @@
import uuid from "uuid/v1";
import { isResetToken, ResetToken } from "./reset";
function createToken(partial: Partial<ResetToken> | any = {}): ResetToken {
const now = Math.floor(Date.now() / 1000);
const token: ResetToken = {
sub: uuid(),
jti: uuid(),
iss: uuid(),
exp: now + 60 * 60 * 24,
nbf: now,
iat: now,
aud: "reset",
rid: uuid(),
};
return {
...token,
...partial,
};
}
describe("isResetToken", () => {
it("validates a valid reset token", () => {
expect(isResetToken(createToken())).toBeTruthy();
});
it("rejects an invalid aud value in a token", () => {
expect(isResetToken(createToken({ aud: "not-reset" }))).toBeFalsy();
});
});
@@ -0,0 +1,195 @@
import Joi from "joi";
import { isNil } from "lodash";
import { DateTime } from "luxon";
import { Db } from "mongodb";
import uuid from "uuid";
import {
LocalProfileNotSetError,
PasswordResetTokenExpired,
TokenInvalidError,
UserNotFoundError,
} from "talk-server/errors";
import { getLocalProfile } from "talk-server/helpers/users";
import { Tenant } from "talk-server/models/tenant";
import {
createOrRetrieveUserPasswordResetID,
resetUserPassword,
retrieveUser,
User,
} from "talk-server/models/user";
import {
JWTSigningConfig,
signString,
StandardClaims,
StandardClaimsSchema,
verifyJWT,
} from "talk-server/services/jwt";
import { constructTenantURL } from "talk-server/app/url";
import { Config } from "talk-server/config";
import { validatePassword } from "../helpers";
export interface ResetToken extends Required<StandardClaims> {
// aud specifies `reset` as the audience to indicate that this is a reset
// token.
aud: "reset";
/**
* rid is the reset ID that is used to prevent replay attacks with the same
* reset token.
*/
rid: string;
}
const ResetTokenSchema = StandardClaimsSchema.keys({
aud: Joi.string().only("reset"),
rid: Joi.string(),
});
export function isResetToken(token: ResetToken | object): token is ResetToken {
const { error } = Joi.validate(token, ResetTokenSchema, {
presence: "required",
});
return isNil(error);
}
/**
* generateResetURL will generate a reset URL that will send the user to the redirect
* after they have reset their password.
*
* @param mongo MongoDB instance to interact with
* @param tenant Tenant that the user exists on
* @param signingConfig signing configuration that will be used to sign the token
* @param user User to create the password reset URL for
* @param now the current time
*/
export async function generateResetURL(
mongo: Db,
tenant: Tenant,
config: Config,
signingConfig: JWTSigningConfig,
user: User,
now: Date = new Date()
) {
// Generate a reset ID to associate with the user account.
const resetID = await createOrRetrieveUserPasswordResetID(
mongo,
tenant.id,
user.id
);
// Change the JS Date to a DateTime for ease of use.
const nowDate = DateTime.fromJSDate(now);
const nowSeconds = Math.round(nowDate.toSeconds());
// The expiry of this token is linked as 1 day after issuance.
const expiresAt = Math.round(nowDate.plus({ days: 1 }).toSeconds());
// Generate a token with this new reset ID.
const resetToken: ResetToken = {
jti: uuid.v4(),
iss: tenant.id,
sub: user.id,
exp: expiresAt,
rid: resetID,
iat: nowSeconds,
nbf: nowSeconds,
aud: "reset",
};
// Sign it with the signing config.
const token = await signString(signingConfig, resetToken);
// Generate and return the reset URL.
return constructTenantURL(
config,
tenant,
// TODO: (kiwi) verify that url is correct.
`/account/password/reset#resetToken=${token}`
);
}
export async function verifyResetTokenString(
mongo: Db,
tenant: Tenant,
signingConfig: JWTSigningConfig,
tokenString: string,
now: Date
): Promise<ResetToken> {
const token = verifyJWT(tokenString, signingConfig, now, {
// Verify that the token is for this Tenant.
issuer: tenant.id,
// Verify that this is a reset token based on the audience.
audience: "reset",
});
// Validate that this is indeed a reset token.
if (!isResetToken(token)) {
throw new TokenInvalidError(
tokenString,
"does not conform to the reset token schema"
);
}
// Unpack some of the token.
const { sub: userID, rid: resetID } = token;
// TODO: (wyattjoh) verify that the token has not been revoked.
// Check to see if this reset id is a valid match against the user.
const user = await retrieveUser(mongo, tenant.id, userID);
if (!user) {
throw new UserNotFoundError(userID);
}
// Get the local profile that might contain the reset token.
const localProfile = getLocalProfile(user);
if (!localProfile) {
throw new LocalProfileNotSetError();
}
// Verify that the reset id matches the current user.
if (localProfile.resetID !== resetID) {
throw new PasswordResetTokenExpired("reset id mismatch");
}
// Now that we've verified that the token is valid and it has not expired,
// checked that the reset id matches the current one set on the user, we can
// be confident that the passed reset token is valid.
return token;
}
export async function resetPassword(
mongo: Db,
tenant: Tenant,
signingConfig: JWTSigningConfig,
tokenString: string,
password: string,
now: Date
) {
// Validate that the password meets the validation requirements.
validatePassword(password);
// Verify that the password reset token is valid and unpack it.
const { sub: userID, rid: resetID } = await verifyResetTokenString(
mongo,
tenant,
signingConfig,
tokenString,
now
);
// Perform the password reset operation.
const user = await resetUserPassword(
mongo,
tenant.id,
userID,
password,
resetID
);
// TODO: (wyattjoh) revoke the JTI
return user;
}
+75
View File
@@ -0,0 +1,75 @@
import {
EMAIL_REGEX,
PASSWORD_MIN_LENGTH,
USERNAME_MAX_LENGTH,
USERNAME_MIN_LENGTH,
USERNAME_REGEX,
} from "talk-common/helpers/validate";
import {
EmailExceedsMaxLengthError,
EmailInvalidFormatError,
PasswordTooShortError,
UsernameContainsInvalidCharactersError,
UsernameExceedsMaxLengthError,
UsernameTooShortError,
} from "talk-server/errors";
/**
* validateUsername will validate that the username is valid. Current
* implementation uses a RegExp statically, future versions will expose this as
* configuration.
*
* @param username the username to be tested
*/
export function validateUsername(username: string) {
// TODO: replace these static regex/length with database options in the Tenant eventually
if (!USERNAME_REGEX.test(username)) {
throw new UsernameContainsInvalidCharactersError();
}
if (username.length > USERNAME_MAX_LENGTH) {
throw new UsernameExceedsMaxLengthError(
username.length,
USERNAME_MAX_LENGTH
);
}
if (username.length < USERNAME_MIN_LENGTH) {
throw new UsernameTooShortError(username.length, USERNAME_MIN_LENGTH);
}
}
/**
* validatePassword will validate that the password is valid. Current
* implementation uses a length statically, future versions will expose this as
* configuration.
*
* @param password the password to be tested
*/
export function validatePassword(password: string) {
// TODO: replace these static length with database options in the Tenant eventually
if (password.length < PASSWORD_MIN_LENGTH) {
throw new PasswordTooShortError(password.length, PASSWORD_MIN_LENGTH);
}
}
const EMAIL_MAX_LENGTH = 100;
/**
* validateEmail will validate that the email is valid. Current implementation
* uses a length statically, future versions will expose this as configuration.
*
* @param email the email to be tested
*/
export function validateEmail(email: string) {
if (!EMAIL_REGEX.test(email)) {
throw new EmailInvalidFormatError();
}
// TODO: replace these static length with database options in the Tenant eventually
if (email.length > EMAIL_MAX_LENGTH) {
throw new EmailExceedsMaxLengthError(email.length, EMAIL_MAX_LENGTH);
}
}
+123 -90
View File
@@ -1,31 +1,19 @@
import { DateTime } from "luxon";
import { Db } from "mongodb";
import {
EMAIL_REGEX,
PASSWORD_MIN_LENGTH,
USERNAME_MAX_LENGTH,
USERNAME_MIN_LENGTH,
USERNAME_REGEX,
} from "talk-common/helpers/validate";
import {
EmailAlreadySetError,
EmailExceedsMaxLengthError,
EmailInvalidFormatError,
EmailNotSetError,
LocalProfileAlreadySetError,
LocalProfileNotSetError,
PasswordTooShortError,
TokenNotFoundError,
UserAlreadyBannedError,
UserAlreadySuspendedError,
UsernameAlreadySetError,
UsernameContainsInvalidCharactersError,
UsernameExceedsMaxLengthError,
UsernameTooShortError,
UserNotFoundError,
} from "talk-server/errors";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { getLocalProfile, hasLocalProfile } from "talk-server/helpers/users";
import { Tenant } from "talk-server/models/tenant";
import {
banUser,
@@ -35,7 +23,6 @@ import {
deactivateUserToken,
insertUser,
InsertUserInput,
LocalProfile,
removeActiveUserSuspensions,
removeUserBan,
retrieveUser,
@@ -50,67 +37,10 @@ import {
updateUserUsername,
User,
} from "talk-server/models/user";
import { MailerQueue } from "talk-server/queue/tasks/mailer";
import { JWTSigningConfig, signPATString } from "talk-server/services/jwt";
import { JWTSigningConfig, signPATString } from "../jwt";
/**
* validateUsername will validate that the username is valid. Current
* implementation uses a RegExp statically, future versions will expose this as
* configuration.
*
* @param username the username to be tested
*/
function validateUsername(username: string) {
// TODO: replace these static regex/length with database options in the Tenant eventually
if (!USERNAME_REGEX.test(username)) {
throw new UsernameContainsInvalidCharactersError();
}
if (username.length > USERNAME_MAX_LENGTH) {
throw new UsernameExceedsMaxLengthError(
username.length,
USERNAME_MAX_LENGTH
);
}
if (username.length < USERNAME_MIN_LENGTH) {
throw new UsernameTooShortError(username.length, USERNAME_MIN_LENGTH);
}
}
/**
* validatePassword will validate that the password is valid. Current
* implementation uses a length statically, future versions will expose this as
* configuration.
*
* @param password the password to be tested
*/
function validatePassword(password: string) {
// TODO: replace these static length with database options in the Tenant eventually
if (password.length < PASSWORD_MIN_LENGTH) {
throw new PasswordTooShortError(password.length, PASSWORD_MIN_LENGTH);
}
}
const EMAIL_MAX_LENGTH = 100;
/**
* validateEmail will validate that the email is valid. Current implementation
* uses a length statically, future versions will expose this as configuration.
*
* @param email the email to be tested
*/
function validateEmail(email: string) {
if (!EMAIL_REGEX.test(email)) {
throw new EmailInvalidFormatError();
}
// TODO: replace these static length with database options in the Tenant eventually
if (email.length > EMAIL_MAX_LENGTH) {
throw new EmailExceedsMaxLengthError(email.length, EMAIL_MAX_LENGTH);
}
}
import { validateEmail, validatePassword, validateUsername } from "./helpers";
export type InsertUser = InsertUserInput;
@@ -135,9 +65,7 @@ export async function insert(
validateEmail(input.email);
}
const localProfile: LocalProfile | undefined = input.profiles.find(
({ type }) => type === "local"
) as LocalProfile | undefined;
const localProfile = getLocalProfile(input);
if (localProfile) {
validateEmail(localProfile.id);
validatePassword(localProfile.password);
@@ -149,6 +77,17 @@ export async function insert(
const user = await insertUser(mongo, tenant.id, input, now);
// // TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email.
// if (localProfile && user.email) {
// if (mailer) {
// // // Send the email confirmation email.
// // await sendConfirmationEmail(mongo, mailer, tenant, user, user.email);
// } else {
// // FIXME: (wyattjoh) extract the local profile based inserts into another function.
// throw new Error("local profile was provided, but the mailer was not");
// }
// }
return user;
}
@@ -188,6 +127,7 @@ export async function setUsername(
*/
export async function setEmail(
mongo: Db,
mailer: MailerQueue,
tenant: Tenant,
user: User,
email: string
@@ -200,7 +140,13 @@ export async function setEmail(
validateEmail(email);
return setUserEmail(mongo, tenant.id, user.id, email);
const updatedUser = await setUserEmail(mongo, tenant.id, user.id, email);
// // FIXME: (wyattjoh) evaluate the tenant to determine if we should send the verification email.
// // Send the email confirmation email.
// await sendConfirmationEmail(mailer, tenant, updatedUser, email);
return updatedUser;
}
/**
@@ -228,7 +174,7 @@ export async function setPassword(
// We also don't allow this method to be used by users that already have a
// local profile.
if (user.profiles.some(({ type }) => type === "local")) {
if (hasLocalProfile(user)) {
throw new LocalProfileAlreadySetError();
}
@@ -250,6 +196,7 @@ export async function setPassword(
*/
export async function updatePassword(
mongo: Db,
mailer: MailerQueue,
tenant: Tenant,
user: User,
password: string
@@ -261,15 +208,42 @@ export async function updatePassword(
// We also don't allow this method to be used by users that don't have a local
// profile already.
if (
!user.profiles.some(({ id, type }) => type === "local" && id === user.email)
) {
if (!hasLocalProfile(user, user.email)) {
throw new LocalProfileNotSetError();
}
validatePassword(password);
return updateUserPassword(mongo, tenant.id, user.id, password);
const updatedUser = await updateUserPassword(
mongo,
tenant.id,
user.id,
password
);
// If the user has an email address associated with their account, send them
// a ban notification email.
if (updatedUser.email) {
// Send the ban user email.
await mailer.add({
tenantID: tenant.id,
message: {
to: updatedUser.email,
},
template: {
name: "password-change",
context: {
// TODO: (wyattjoh) possibly reevaluate the use of a required username.
username: updatedUser.username!,
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
organizationContactEmail: tenant.organization.contactEmail,
},
},
});
}
return user;
}
/**
@@ -374,7 +348,9 @@ export async function updateRole(
}
/**
* updateEmail will update the given User's email address.
* updateEmail will update the given User's email address. This should not
* trigger and email notifications as it's designed to be used by administrators
* to update a user's email address.
*
* @param mongo mongo database to interact with
* @param tenant Tenant where the User will be interacted with
@@ -421,8 +397,9 @@ export async function updateAvatar(
*/
export async function ban(
mongo: Db,
mailer: MailerQueue,
tenant: Tenant,
user: User,
banner: User,
userID: string,
now = new Date()
) {
@@ -439,7 +416,32 @@ export async function ban(
throw new UserAlreadyBannedError();
}
return banUser(mongo, tenant.id, userID, user.id, now);
// Ban the user.
const user = await banUser(mongo, tenant.id, userID, banner.id, now);
// If the user has an email address associated with their account, send them
// a ban notification email.
if (user.email) {
// Send the ban user email.
await mailer.add({
tenantID: tenant.id,
message: {
to: user.email,
},
template: {
name: "ban",
context: {
// TODO: (wyattjoh) possibly reevaluate the use of a required username.
username: user.username!,
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
organizationContactEmail: tenant.organization.contactEmail,
},
},
});
}
return user;
}
/**
@@ -454,6 +456,7 @@ export async function ban(
*/
export async function suspend(
mongo: Db,
mailer: MailerQueue,
tenant: Tenant,
user: User,
userID: string,
@@ -461,9 +464,7 @@ export async function suspend(
now = new Date()
) {
// Convert the timeout to the until time.
const finish = DateTime.fromJSDate(now)
.plus({ seconds: timeout })
.toJSDate();
const finishDateTime = DateTime.fromJSDate(now).plus({ seconds: timeout });
// Get the user being suspended to check to see if the user already has an
// existing suspension.
@@ -481,7 +482,39 @@ export async function suspend(
throw new UserAlreadySuspendedError(suspended.until);
}
return suspendUser(mongo, tenant.id, userID, user.id, finish, now);
const updatedUser = await suspendUser(
mongo,
tenant.id,
userID,
user.id,
finishDateTime.toJSDate(),
now
);
// If the user has an email address associated with their account, send them
// a suspend notification email.
if (updatedUser.email) {
// Send the suspend user email.
await mailer.add({
tenantID: tenant.id,
message: {
to: updatedUser.email,
},
template: {
name: "suspend",
context: {
// TODO: (wyattjoh) possibly reevaluate the use of a required username.
username: updatedUser.username!,
until: finishDateTime.toRFC2822(),
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
organizationContactEmail: tenant.organization.contactEmail,
},
},
});
}
return updatedUser;
}
export async function removeSuspension(
+2
View File
@@ -1,5 +1,6 @@
import { NextFunction, Request as ExpressRequest, Response } from "express";
import { Logger } from "talk-server/logger";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
@@ -11,6 +12,7 @@ export interface TalkRequest {
tenant: TenantCache;
};
tenant?: Tenant;
logger: Logger;
}
export interface Request extends ExpressRequest {