mirror of
https://github.com/wassname/talk.git
synced 2026-08-11 05:54:07 +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",
|
||||
|
||||
Reference in New Issue
Block a user