[next] Email (#2261)

* feat: suspending, banning, now propogation

* feat: added email rendering + localization support

* fix: fix related to lib

* refactor: moved juicer to queue task

* refactor: cleanup of job processor

* refactor: improved error messaging around failed email

* feat: initial forgot passwor impl

* fix: fixed rebase errors

* feat: send back Content-Language header with requests

* feat: added ban email

* feat: implemented forgotten password API

* fix: linting

* feat: support more emails

* fix: promise patches

* feat: initial confirm email API

* feat: added rate limiting

* feat: added URL support

* feat: added email docs

* fix: updated docs

* chore: documentation review

* fix: fixed build bug

* feat: implement forgot password in auth popup

* test: add tests + fixes

* chore: rename StatelessComponent to FunctionComponent

* fix: types and test fixes

* chore: upgrade deps

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

* chore: reorder imports

* chore: remove obsolete !

* feat: implement accounts bundle

* refactor: review suggestion

* fix: rebase upgrade error

* fix: rebase bug

* feat: reset password link support

* test: add tests for account password reset page

* fix: remove redirect uri

* fix: revert local state changes
This commit is contained in:
Wyatt Johnson
2019-05-09 22:54:56 +02:00
committed by Kiwi
parent 945bd7f2b0
commit df57b4eb17
487 changed files with 6794 additions and 2431 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ import {
prefixSchemeIfRequired,
} from "talk-server/app/url";
import { Tenant } from "talk-server/models/tenant";
import { isURLPermitted } from "talk-server/services/stories";
import { isURLPermitted } from "talk-server/services/tenant/url";
import { Request, RequestHandler } from "talk-server/types/express";
/**
+3
View File
@@ -0,0 +1,3 @@
import express from "express";
export const jsonMiddleware = express.json({});
@@ -17,7 +17,6 @@ import {
extractJWTFromRequest,
JWTSigningConfig,
revokeJWT,
SigningTokenOptions,
signTokenString,
} from "talk-server/services/jwt";
import { Request } from "talk-server/types/express";
@@ -109,18 +108,11 @@ export async function handleSuccessfulLogin(
next: NextFunction
) {
try {
// Talk is guaranteed at this point.
const { tenant } = req.talk!;
const options: SigningTokenOptions = {};
if (tenant) {
// Attach the tenant's id to the issued token as a `iss` claim.
options.issuer = tenant.id;
}
// Tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
// Grab the token.
const token = await signTokenString(signingConfig, user, options);
const token = await signTokenString(signingConfig, user, tenant);
// Set the cache control headers.
res.header("Cache-Control", "private, no-cache, no-store, must-revalidate");
@@ -152,18 +144,11 @@ export async function handleOAuth2Callback(
}
try {
// Talk is guaranteed at this point.
const { tenant } = req.talk!;
const options: SigningTokenOptions = {};
if (tenant) {
// Attach the tenant's id to the issued token as a `iss` claim.
options.issuer = tenant.id;
}
// Tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
// Grab the token.
const token = await signTokenString(signingConfig, user, options);
const token = await signTokenString(signingConfig, user, tenant);
// Send back the details!
res.redirect(path + `#accessToken=${token}`);
@@ -190,8 +175,12 @@ export const wrapOAuth2Authn = (
authenticator.authenticate(
name,
{ ...options, session: false },
(err: Error | null, user: User | null) => {
handleOAuth2Callback(err, user, signingConfig, req, res);
async (err: Error | null, user: User | null) => {
try {
await handleOAuth2Callback(err, user, signingConfig, req, res);
} catch (err) {
return next(err);
}
}
)(req, res, next);
@@ -213,7 +202,7 @@ export const wrapAuthn = (
authenticator.authenticate(
name,
{ ...options, session: false },
(err: Error | null, user: User | null) => {
async (err: Error | null, user: User | null) => {
if (err) {
return next(err);
}
@@ -221,8 +210,12 @@ export const wrapAuthn = (
return next(new AuthenticationError("user not on request"));
}
// Pass the login off to be signed.
handleSuccessfulLogin(user, signingConfig, req, res, next);
try {
// Pass the login off to be signed.
await handleSuccessfulLogin(user, signingConfig, req, res, next);
} catch (err) {
return next(err);
}
}
)(req, res, next);
@@ -53,7 +53,7 @@ export default class FacebookStrategy extends OAuth2Strategy<
if (!user) {
if (!integration.allowRegistration) {
// Registration is disabled, so we can't create the user user here.
return;
return null;
}
// FIXME: implement rules.
@@ -52,7 +52,7 @@ export default class GoogleStrategy extends OAuth2Strategy<
if (!user) {
if (!integration.allowRegistration) {
// Registration is disabled, so we can't create the user user here.
return;
return null;
}
// FIXME: implement rules.
@@ -1,7 +1,9 @@
import { Db } from "mongodb";
import { Strategy as LocalStrategy } from "passport-local";
import { Redis } from "ioredis";
import { VerifyCallback } from "talk-server/app/middleware/passport";
import { RequestLimiter } from "talk-server/app/request/limiter";
import { InvalidCredentialsError } from "talk-server/errors";
import {
retrieveUserWithProfile,
@@ -9,14 +11,19 @@ import {
} from "talk-server/models/user";
import { Request } from "talk-server/types/express";
const verifyFactory = (mongo: Db) => async (
const verifyFactory = (
mongo: Db,
ipLimiter: RequestLimiter,
emailLimiter: RequestLimiter
) => async (
req: Request,
email: string,
password: string,
done: VerifyCallback
) => {
try {
// TODO: rate limit based on the IP address and user agent.
await ipLimiter.test(req, req.ip);
await emailLimiter.test(req, email);
// The tenant is guaranteed at this point.
const tenant = req.talk!.tenant!;
@@ -45,9 +52,26 @@ const verifyFactory = (mongo: Db) => async (
export interface LocalStrategyOptions {
mongo: Db;
redis: Redis;
}
export function createLocalStrategy({ mongo }: LocalStrategyOptions) {
export function createLocalStrategy({
mongo,
redis: client,
}: LocalStrategyOptions) {
const ipLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 10,
prefix: "ip",
});
const emailLimiter = new RequestLimiter({
client,
ttl: "10m",
max: 10,
prefix: "email",
});
return new LocalStrategy(
{
usernameField: "email",
@@ -55,6 +79,6 @@ export function createLocalStrategy({ mongo }: LocalStrategyOptions) {
session: false,
passReqToCallback: true,
},
verifyFactory(mongo)
verifyFactory(mongo, ipLimiter, emailLimiter)
);
}
@@ -4,6 +4,7 @@ import { Strategy } from "passport-strategy";
import { Profile } from "passport";
import { VerifyCallback } from "passport-oauth2";
import { Config } from "talk-server/config";
import { IntegrationDisabled } from "talk-server/errors";
import { AuthIntegrations } from "talk-server/models/settings";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
@@ -28,6 +29,7 @@ export default abstract class OAuth2Strategy<
T extends OAuth2Integration,
U extends Strategy
> extends Strategy {
public abstract name: string;
protected config: Config;
protected mongo: Db;
protected cache: TenantCacheAdapter<U>;
@@ -59,7 +61,7 @@ export default abstract class OAuth2Strategy<
integration: Required<T>,
profile: Profile,
now: Date
): Promise<User | undefined>;
): Promise<User | null | undefined>;
protected verifyCallback = async (
req: Request,
@@ -83,6 +85,9 @@ export default abstract class OAuth2Strategy<
profile,
now
);
if (!user) {
return done(null);
}
return done(null, user);
} catch (err) {
@@ -100,8 +105,7 @@ export default abstract class OAuth2Strategy<
// Check to see if the integration is enabled.
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
throw new IntegrationDisabled(this.name);
}
if (!integration.clientID) {
@@ -1,12 +1,14 @@
import Joi from "joi";
import jwt from "jsonwebtoken";
import jwks, { JwksClient } from "jwks-rsa";
import { isNil } from "lodash";
import { Db } from "mongodb";
import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2";
import { Strategy } from "passport-strategy";
import { validate } from "talk-server/app/request/body";
import { reconstructURL } from "talk-server/app/url";
import { IntegrationDisabled, TokenInvalidError } from "talk-server/errors";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import logger from "talk-server/logger";
import { OIDCAuthIntegration } from "talk-server/models/settings";
@@ -16,6 +18,7 @@ import {
retrieveUserWithProfile,
User,
} from "talk-server/models/user";
import { AsymmetricSigningAlgorithm } from "talk-server/services/jwt";
import TenantCache from "talk-server/services/tenant/cache";
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
import { insert } from "talk-server/services/users";
@@ -43,21 +46,38 @@ export interface OIDCIDToken {
preferred_username?: string;
}
export const OIDCIDTokenSchema = Joi.object()
.keys({
sub: Joi.string().required(),
iss: Joi.string().required(),
aud: Joi.string().required(),
email: Joi.string().required(),
email_verified: Joi.boolean().default(false),
picture: Joi.string().default(undefined),
name: Joi.string().default(undefined),
nickname: Joi.string().default(undefined),
preferred_username: Joi.string().default(undefined),
})
.optionalKeys([
"picture",
"email_verified",
"name",
"nickname",
"preferred_username",
]);
export interface StrategyItem {
strategy: OAuth2Strategy;
jwksClient?: JwksClient;
}
export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken {
if (
(token as OIDCIDToken).iss &&
(token as OIDCIDToken).sub &&
(token as OIDCIDToken).aud
) {
return true;
}
return false;
const { error } = Joi.validate(token, OIDCIDTokenSchema, {
// OIDC ID tokens may contain many other fields we haven't seen.. We Just
// need to check to see that it contains at least the fields we need.
allowUnknown: true,
});
return isNil(error);
}
/**
@@ -92,8 +112,7 @@ export function getEnabledIntegration(
integration: OIDCAuthIntegration
): Required<OIDCAuthIntegration> {
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
throw new IntegrationDisabled("oidc");
}
if (
@@ -105,34 +124,13 @@ export function getEnabledIntegration(
!integration.jwksURI ||
!integration.issuer
) {
// TODO: return a better error.
throw new Error("integration not configured");
throw new IntegrationDisabled("oidc");
}
// TODO: (wyattjoh) for some reason, type guards above to not allow coercion to this required type.
return integration as Required<OIDCAuthIntegration>;
}
export const OIDCIDTokenSchema = Joi.object()
.keys({
sub: Joi.string(),
iss: Joi.string(),
aud: Joi.string(),
email: Joi.string(),
email_verified: Joi.boolean().default(false),
picture: Joi.string().default(undefined),
name: Joi.string().default(undefined),
nickname: Joi.string().default(undefined),
preferred_username: Joi.string().default(undefined),
})
.optionalKeys([
"picture",
"email_verified",
"name",
"nickname",
"preferred_username",
]);
export async function findOrCreateOIDCUser(
mongo: Db,
tenant: Tenant,
@@ -200,25 +198,41 @@ export function findOrCreateOIDCUserWithToken(
tenant: Tenant,
client: JwksClient,
integration: OIDCAuthIntegration,
token: string,
tokenString: string,
now: Date
) {
return new Promise<Readonly<User> | null>((resolve, reject) => {
logger.trace({ tenantID: tenant.id }, "verifying oidc id_token");
jwt.verify(
token,
tokenString,
signingKeyFactory(client),
{
issuer: integration.issuer,
// FIXME: (wyattjoh) support additional algorithms.
// Currently we're limited by the key retrieval factory to only support
// RS256. Tracking available:
//
// https://github.com/auth0/node-jwks-rsa/issues/40
// https://github.com/auth0/node-jwks-rsa/issues/50
algorithms: [AsymmetricSigningAlgorithm.RS256],
clockTimestamp: Math.floor(now.getTime() / 1000),
},
async (err, decoded) => {
async (err, token) => {
logger.trace(
{ tenantID: tenant.id },
"finished verifying oidc id_token"
);
if (err) {
// TODO: wrap error?
return reject(err);
return reject(
new TokenInvalidError(tokenString, "token validation error", err)
);
}
// Validate the token.
if (typeof token === "string" || !isOIDCToken(token)) {
return reject(
new TokenInvalidError(tokenString, "token is not an OIDCToken")
);
}
try {
@@ -226,7 +240,7 @@ export function findOrCreateOIDCUserWithToken(
mongo,
tenant,
integration,
decoded as OIDCIDToken,
token,
now
);
return resolve(user);
@@ -0,0 +1,27 @@
import jwt from "jsonwebtoken";
import {
JWTSigningConfig,
signTokenString,
SymmetricSigningAlgorithm,
} from "talk-server/services/jwt";
import { isJWTToken } from "./jwt";
// Create signing config.
const config: JWTSigningConfig = {
algorithm: SymmetricSigningAlgorithm.HS256,
secret: "secret",
};
it("validates a jwt token", async () => {
const user = { id: "user-id" };
const tenant = { id: "tenant-id" };
// Create the signed token string.
const tokenString = await signTokenString(config, user, tenant);
// Verify that the token conforms to the JWT token schema.
const token = jwt.decode(tokenString) as object;
expect(isJWTToken(token)).toBeTruthy();
});
@@ -1,39 +1,22 @@
import { Redis } from "ioredis";
import jwt from "jsonwebtoken";
import Joi from "joi";
import { isNil } from "lodash";
import { Db } from "mongodb";
import now from "performance-now";
import logger from "talk-server/logger";
import { Tenant } from "talk-server/models/tenant";
import { retrieveUser } from "talk-server/models/user";
import { checkJWTRevoked, JWTSigningConfig } from "talk-server/services/jwt";
import {
checkJWTRevoked,
JWTSigningConfig,
StandardClaims,
verifyJWT,
} from "talk-server/services/jwt";
import { Verifier } from "../jwt";
export interface JWTToken {
/**
* jti is the Token identifier. With normal login tokens, this is a randomly
* generated uuid, which is added to a revoke list when the User "logs out".
* For Personal Access Tokens, this is the Token identifier.
*/
jti: string;
/**
* sub is the ID of the User that this Token is associated with.
*/
sub: string;
/**
* iss is the ID of the Tenant that this Token is associated with.
*/
iss: string;
/**
* exp is the optional expiry for the tokens. Personal Access Token's do not
* have an expiry associated with them, hence why it's optional.
*/
exp?: number;
export interface JWTToken
extends Required<Pick<StandardClaims, "jti" | "sub" | "iss" | "iat">>,
Pick<StandardClaims, "exp"> {
/**
* pat, when true, indicates that this Token is a Personal Access Token, and
* it's `jti` claim should be treated as the Token ID. These tokens cannot be
@@ -42,30 +25,18 @@ export interface JWTToken {
pat?: boolean;
}
export const JWTTokenSchema = Joi.object().keys({
jti: Joi.string().required(),
sub: Joi.string().required(),
iat: Joi.number().required(),
iss: Joi.string().required(),
exp: Joi.number(),
pat: Joi.boolean(),
});
export function isJWTToken(token: JWTToken | object): token is JWTToken {
if (
typeof (token as JWTToken).jti !== "string" ||
typeof (token as JWTToken).sub !== "string" ||
typeof (token as JWTToken).iss !== "string"
) {
return false;
}
if (
typeof (token as JWTToken).exp !== "undefined" &&
typeof (token as JWTToken).exp !== "number"
) {
return false;
}
if (
typeof (token as JWTToken).pat !== "undefined" &&
typeof (token as JWTToken).pat !== "boolean"
) {
return false;
}
return true;
const { error } = Joi.validate(token, JWTTokenSchema);
return isNil(error);
}
export interface JWTVerifierOptions {
@@ -89,19 +60,14 @@ export class JWTVerifier implements Verifier<JWTToken> {
return isJWTToken(token) && token.iss === tenant.id;
}
public async verify(tokenString: string, token: JWTToken, tenant: Tenant) {
const startTime = now();
public async verify(
tokenString: string,
token: JWTToken,
tenant: Tenant,
now: Date
) {
// Verify that the token is valid. This will throw an error if it isn't.
jwt.verify(tokenString, this.signingConfig.secret, {
issuer: tenant.id,
algorithms: [this.signingConfig.algorithm],
});
// Compute the end time.
const responseTime = Math.round(now() - startTime);
logger.trace({ responseTime }, "jwt verification complete");
verifyJWT(tokenString, this.signingConfig, now, { issuer: tenant.id });
// Check to see if this is a Personal Access Token, these tokens cannot be
// revoked.
@@ -32,6 +32,7 @@ describe("SSOUserProfileSchema", () => {
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows an empty avatar", () => {
@@ -42,6 +43,7 @@ describe("SSOUserProfileSchema", () => {
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows a valid payload", () => {
@@ -54,6 +56,7 @@ describe("SSOUserProfileSchema", () => {
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows an empty avatar", () => {
@@ -65,6 +68,7 @@ describe("SSOUserProfileSchema", () => {
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
it("allows an empty displayName", () => {
@@ -76,5 +80,6 @@ describe("SSOUserProfileSchema", () => {
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
expect(isSSOToken({ user: profile })).toEqual(true);
});
});
@@ -1,8 +1,9 @@
import Joi from "joi";
import jwt from "jsonwebtoken";
import { isNil } from "lodash";
import { Db } from "mongodb";
import { validate } from "talk-server/app/request/body";
import { IntegrationDisabled } from "talk-server/errors";
import {
GQLSSOAuthIntegration,
GQLUSER_ROLE,
@@ -11,6 +12,7 @@ import { Tenant } from "talk-server/models/tenant";
import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user";
import { insert } from "talk-server/services/users";
import { SymmetricSigningAlgorithm, verifyJWT } from "talk-server/services/jwt";
import { Verifier } from "../jwt";
export interface SSOStrategyOptions {
@@ -30,14 +32,18 @@ export interface SSOToken {
export const SSOUserProfileSchema = Joi.object()
.keys({
id: Joi.string(),
email: Joi.string(),
username: Joi.string(),
id: Joi.string().required(),
email: Joi.string().required(),
username: Joi.string().required(),
avatar: Joi.string().default(undefined),
displayName: Joi.string().default(undefined),
})
.optionalKeys(["avatar", "displayName"]);
export const SSOTokenSchema = Joi.object().keys({
user: SSOUserProfileSchema.required(),
});
export async function findOrCreateSSOUser(
mongo: Db,
tenant: Tenant,
@@ -91,26 +97,9 @@ export async function findOrCreateSSOUser(
return user;
}
/**
* isSSOUserProfile will check if the given profile is a SSOUserProfile.
*
* @param profile the profile to check for the type
*/
export function isSSOUserProfile(
profile: SSOUserProfile | object
): profile is SSOUserProfile {
return (
typeof (profile as SSOUserProfile).id !== "undefined" &&
typeof (profile as SSOUserProfile).email !== "undefined" &&
typeof (profile as SSOUserProfile).username !== "undefined"
);
}
export function isSSOToken(token: SSOToken | object): token is SSOToken {
return (
typeof (token as SSOToken).user === "object" &&
isSSOUserProfile((token as SSOToken).user)
);
const { error } = Joi.validate(token, SSOTokenSchema);
return isNil(error);
}
export interface SSOVerifierOptions {
@@ -132,24 +121,28 @@ export class SSOVerifier implements Verifier<SSOToken> {
tokenString: string,
token: SSOToken,
tenant: Tenant,
now = new Date()
now: Date
) {
const integration = tenant.auth.integrations.sso;
if (!integration.enabled) {
// TODO: (wyattjoh) return a better error.
throw new Error("integration not enabled");
throw new IntegrationDisabled("sso");
}
if (!integration.key) {
throw new Error("integration key does not exist");
}
// Verify that the token is valid. This will throw an error if it isn't.
jwt.verify(tokenString, integration.key, {
// Force the use of the HS256 algorithm. We can explore switching this
// out in the future..
algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm.
});
verifyJWT(
tokenString,
{
// Force the use of the HS256 algorithm. We can explore switching this
// out in the future..
// TODO: (wyattjoh) investigate replacing algorithm.
algorithm: SymmetricSigningAlgorithm.HS256,
secret: integration.key,
},
now
);
return findOrCreateSSOUser(this.mongo, tenant, integration, token, now);
}
+5 -1
View File
@@ -1,6 +1,7 @@
import uuid from "uuid/v1";
import { TenantNotFoundError } from "talk-server/errors";
import logger from "talk-server/logger";
import TenantCache from "talk-server/services/tenant/cache";
import { RequestHandler } from "talk-server/types/express";
@@ -25,7 +26,7 @@ export const tenantMiddleware = ({
const now = new Date();
// Set Talk on the request.
req.talk = { id, now };
req.talk = { id, now, logger: logger.child({ traceID: id }) };
}
// Set the Talk Tenant Cache on the request.
@@ -49,6 +50,9 @@ export const tenantMiddleware = ({
// Attach the tenant to the request.
req.talk.tenant = tenant;
// Attach the tenant's language to the request.
res.setHeader("Content-Language", tenant.locale);
// Attach the tenant to the view locals.
res.locals.tenant = tenant;