mirror of
https://github.com/wassname/talk.git
synced 2026-07-28 11:27:05 +08:00
[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:
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./api";
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
+47
-12
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user