[next] Auth Popup v2 (#2101)

* feat: Implement new Sign In view

* feat: Move forgot + resetPassword to new design

* feat: Implement sign up with new design

* fix: narrow gutter

* test: add unit tests

* test: integration tests

* feat: support show / hide password

* feat: support oauth2 flow

* feat: add views for user completion

* feat: implement oauth2 sign up

* test: fix snapshots

* fix: lint

* fix: get more complete mutation response

* fix: removed array of OIDC integrations

* fix: renamed resolver function

* fix: adapt oidc client implementation

* fix: targetFilter should be stream on signup

* fix: removed unneeded message

* fix: moved password into local profile

* fix: made username optional, removed valid null value

* fix: linting

* fix: respect targetFilter

* feat: support user registration mutations

- Added `setUsername`
- Added `setEmail`
- Added `setPassword`
- Added `permit` to `@auth`
- Added `email` to `User`

* fix: fixed issue with query

* feat: added user password update

* feat: complete sign in mutation

* fix: adapt some rebasing gitches

* test: improve tests

* test: unittest for setting auth token

* fix: failing tests

* test: move most tests from enzyme to react-test-renderer

* fix: remove schema warnings in tests

* test: improve window mock

* test: test different social login configurations

* test: test social logins for sign up

* fix: use htmlFor instead of for

* test: more feature tests

* feat: always go through account completion

* test: feature test account completion

* feat: addtional account completion test

* Update start.ts

* chore: refactor auth token retrieval logic
This commit is contained in:
Kiwi
2018-12-20 22:32:04 +01:00
committed by GitHub
parent 326a10dc5d
commit 065cb4b03a
331 changed files with 12476 additions and 7163 deletions
+48 -43
View File
@@ -1,55 +1,20 @@
import { Omit } from "talk-common/types";
import {
GQLAuth,
GQLAuthDisplayNameConfiguration,
GQLCharCount,
GQLEmail,
GQLExternalIntegrations,
GQLFacebookAuthIntegration,
GQLGoogleAuthIntegration,
GQLKarma,
GQLLocalAuthIntegration,
GQLMODERATION_MODE,
GQLOIDCAuthIntegration,
GQLReactionConfiguration,
GQLSSOAuthIntegration,
GQLWordList,
} from "talk-server/graph/tenant/schema/__generated__/types";
// export interface EmailDomainRuleCondition {
// /**
// * emailDomain is the domain name component of the email addresses that should
// * match for this condition.
// */
// emailDomain: string;
// /**
// * emailVerifiedRequired stipulates that this rule only applies when the user
// * account has been marked as having their email address already verified.
// */
// emailVerifiedRequired: boolean;
// }
// /**
// * RoleRule describes the role assignment for when a user logs into Talk, how
// * they can have their account automatically upgraded to a specific role when
// * the domain for their email matches the one provided.
// */
// export interface RoleRule extends Partial<EmailDomainRuleCondition> {
// /**
// * role is the specific GQLUSER_ROLE that should be assigned to the newly
// * created user depending on their email address.
// */
// role: GQLUSER_ROLE;
// }
// export interface AuthRules {
// /**
// * roles allow the configuration of automatic role assignment based on the
// * user's email address.
// */
// roles?: RoleRule[];
// /**
// * restrictTo when populated, will restrict which users can login using this
// * integration. If a user successfully logs in using the OIDCStrategy, but
// * does not match the following rules, the user will not be created.
// */
// restrictTo?: EmailDomainRuleCondition[];
// }
export interface ModerationSettings {
moderation: GQLMODERATION_MODE;
requireEmailConfirmation: boolean;
@@ -67,6 +32,46 @@ export interface ModerationSettings {
charCount: GQLCharCount;
}
export type LocalAuthIntegration = GQLLocalAuthIntegration;
export type SSOAuthIntegration = GQLSSOAuthIntegration;
export type OIDCAuthIntegration = Omit<
GQLOIDCAuthIntegration,
"callbackURL" | "redirectURL"
>;
export type GoogleAuthIntegration = Omit<
GQLGoogleAuthIntegration,
"callbackURL" | "redirectURL"
>;
export type FacebookAuthIntegration = Omit<
GQLFacebookAuthIntegration,
"callbackURL" | "redirectURL"
>;
export interface AuthIntegrations {
local: LocalAuthIntegration;
sso: SSOAuthIntegration;
oidc: OIDCAuthIntegration;
google: GoogleAuthIntegration;
facebook: FacebookAuthIntegration;
}
export interface Auth {
/**
* integrations are the set of configurations for the variations of
* authentication solutions.
*/
integrations: AuthIntegrations;
/**
* displayName contains configuration related to the use of Display Names
* across AuthIntegrations.
*/
displayName: GQLAuthDisplayNameConfiguration;
}
export interface Settings extends ModerationSettings {
customCssUrl?: string;
@@ -96,7 +101,7 @@ export interface Settings extends ModerationSettings {
/**
* Set of configured authentication integrations.
*/
auth: GQLAuth;
auth: Auth;
/**
* Various integrations with external services.
+9 -166
View File
@@ -5,10 +5,7 @@ import uuid from "uuid";
import { DeepPartial, Omit, Sub } from "talk-common/types";
import { dotize, DotizeOptions } from "talk-common/utils/dotize";
import {
GQLMODERATION_MODE,
GQLOIDCAuthIntegration,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types";
import { Settings } from "talk-server/models/settings";
function collection(db: Db) {
@@ -114,11 +111,17 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
key: generateSSOKey(),
keyGeneratedAt: new Date(),
},
oidc: [],
oidc: {
enabled: false,
allowRegistration: false,
targetFilter: {
admin: true,
stream: true,
},
},
google: {
enabled: false,
allowRegistration: false,
callbackURL: "" as any, // FIXME: this should not be required
targetFilter: {
admin: true,
stream: true,
@@ -127,7 +130,6 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
facebook: {
enabled: false,
allowRegistration: false,
callbackURL: "", // FIXME: this should not be required
targetFilter: {
admin: true,
stream: true,
@@ -282,162 +284,3 @@ export async function regenerateTenantSSOKey(db: Db, id: string) {
return result.value || null;
}
export type CreateTenantOIDCAuthIntegrationInput = Omit<
GQLOIDCAuthIntegration,
"id" | "callbackURL"
>;
export interface CreateTenantOIDCAuthIntegrationResultObject {
tenant?: Tenant;
integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">;
wasCreated: boolean;
}
export async function createTenantOIDCAuthIntegration(
mongo: Db,
id: string,
input: CreateTenantOIDCAuthIntegrationInput
): Promise<CreateTenantOIDCAuthIntegrationResultObject> {
// Add the ID to the integration.
const integration = {
id: uuid.v4(),
...input,
};
const result = await collection(mongo).findOneAndUpdate(
{ id },
// Serialize the deep update into the Tenant.
{
$push: { "auth.integrations.oidc": integration },
},
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
if (!result.value) {
return {
wasCreated: false,
};
}
const wasCreated =
result.value.auth.integrations.oidc.findIndex(
({ id: integrationID }) => integrationID === integration.id
) !== -1;
return {
tenant: result.value,
integration,
wasCreated,
};
}
export type UpdateTenantOIDCAuthIntegrationInput = Partial<
Omit<GQLOIDCAuthIntegration, "id">
>;
export interface UpdateTenantOIDCAuthIntegrationResultObject {
tenant?: Tenant;
integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">;
wasUpdated: boolean;
}
export async function updateTenantOIDCAuthIntegration(
mongo: Db,
id: string,
oidcID: string,
input: UpdateTenantOIDCAuthIntegrationInput
): Promise<UpdateTenantOIDCAuthIntegrationResultObject> {
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$set: dotizeDropNull({
"auth.integrations.oidc.$[oidc]": input,
}),
},
{
// Add an ArrayFilter to only update one of the OpenID Connect
// integrations.
arrayFilters: [{ "oidc.id": oidcID }],
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
return {
wasUpdated: false,
};
}
const integration = result.value.auth.integrations.oidc.find(
({ id: integrationID }) => integrationID === oidcID
);
const wasUpdated = Boolean(integration);
return {
tenant: result.value,
integration,
wasUpdated,
};
}
export interface RemoveTenantOIDCAuthIntegrationResultObject {
tenant?: Tenant;
integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">;
wasRemoved: boolean;
}
/**
* removeTenantOIDCAuthIntegration will delete the specific OpenID Connect Auth
* Integration on the Tenant.
*
* @param mongo MongoDB Database handle
* @param id the id of the Tenant
* @param oidcID the id of the OpenID Connect Auth Integration we're deleting
*/
export async function removeTenantOIDCAuthIntegration(
mongo: Db,
id: string,
oidcID: string
): Promise<RemoveTenantOIDCAuthIntegrationResultObject> {
const result = await collection(mongo).findOneAndUpdate(
{ id },
{
$pull: { "auth.integrations.oidc": { id: oidcID } },
},
{
// True to return the document before we modified it. This gives us the
// opportunity to return the original document and asertain if the
// integration was/could be removed.
returnOriginal: true,
}
);
if (!result.value) {
return { wasRemoved: false };
}
// Find the integration that we wanted to delete.
const integration = result.value.auth.integrations.oidc.find(
({ id: integrationID }) => integrationID === oidcID
);
if (!integration) {
// The integration was not in the original document, so we could not have
// possibly deleted it!
return { wasRemoved: false };
}
// The integration was found, we should pull that integration out of the
// resulting Tenant.
result.value.auth.integrations.oidc.filter(
({ id: integrationID }) => integrationID !== integration.id
);
return {
tenant: result.value,
integration,
wasRemoved: true,
};
}
+330 -16
View File
@@ -17,6 +17,7 @@ function collection(db: Db) {
export interface LocalProfile {
type: "local";
id: string;
password: string;
}
export interface OIDCProfile {
@@ -74,9 +75,9 @@ export interface UserStatus {
export interface User extends TenantResource {
readonly id: string;
username: string | null;
username?: string;
lowercaseUsername?: string;
displayName?: string;
password?: string;
avatar?: string;
email?: string;
emailVerified?: boolean;
@@ -88,9 +89,19 @@ export interface User extends TenantResource {
createdAt: Date;
}
function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
export type UpsertUserInput = Omit<
User,
"id" | "tenantID" | "tokens" | "status" | "ignoredUserIDs" | "createdAt"
| "id"
| "tenantID"
| "tokens"
| "status"
| "ignoredUserIDs"
| "createdAt"
| "lowercaseUsername"
>;
export async function upsertUser(
@@ -129,20 +140,39 @@ export async function upsertUser(
createdAt: now,
};
let hashedPassword;
if (input.password) {
// Hash the user's password with bcrypt.
hashedPassword = await bcrypt.hash(input.password, 10);
// Mutate the profiles to ensure we mask handle any secrets.
const profiles: Profile[] = [];
for (let profile of input.profiles) {
switch (profile.type) {
case "local":
// FIXME: (wyattjoh) add password validation here.
// Hash the user's password with bcrypt.
const password = await hashPassword(profile.password);
profile = {
...profile,
password,
};
break;
}
// Save a copy.
profiles.push(profile);
}
// Add in the lowercase username if it was sent.
if (input.username) {
defaults.lowercaseUsername = input.username.toLowerCase();
// FIXME: (wyattjoh) add username checking regex here.
}
// FIXME: (wyattjoh) add email validation here.
// Merge the defaults and the input together.
const user: Readonly<User> = {
...defaults,
...input,
// Specified last in the merge call, it will override any existing password
// entry if it is defined.
password: hashedPassword,
profiles,
};
// Create a query that will utilize a findOneAndUpdate to facilitate an upsert
@@ -216,7 +246,7 @@ export async function retrieveManyUsers(
export async function retrieveUserWithProfile(
db: Db,
tenantID: string,
profile: Profile
profile: Partial<Profile>
) {
return collection(db).findOne({
tenantID,
@@ -235,16 +265,300 @@ export async function updateUserRole(
const result = await collection(db).findOneAndUpdate(
{ id, tenantID },
{ $set: { role } },
{ returnOriginal: false }
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
return result.value || null;
}
export async function verifyUserPassword(user: User, password: string) {
if (user.password) {
return bcrypt.compare(password, user.password);
const profile: LocalProfile | undefined = user.profiles.find(
({ type }) => type === "local"
) as LocalProfile | undefined;
if (!profile) {
throw new Error("no local profile exists for this user");
}
return false;
return bcrypt.compare(password, profile.password);
}
export async function updateUserPassword(
mongo: Db,
tenantID: string,
id: string,
password: string
) {
// FIXME: (wyattjoh) add password validation here.
// 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.
"profiles.type": "local",
},
{
$set: {
"profiles.$[profiles].password": hashedPassword,
},
},
{
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) {
// TODO: (wyattjoh) return better error
throw new Error("user not found");
}
if (
!user.profiles.some(
profile => profile.type === "local" && profile.id === user.email
)
) {
// TODO: (wyattjoh) return better error
throw new Error("user does not have a local profile");
}
// TODO: (wyattjoh) return better error
throw new Error("unexpected error occurred");
}
return result.value || null;
}
/**
* setUserUsername will set the username of the User if the username hasn't
* already been used before.
*
* @param mongo the database handle
* @param tenantID the ID to the Tenant
* @param id the ID of the User where we are setting the username on
* @param username the username that we want to set
*/
export async function setUserUsername(
mongo: Db,
tenantID: string,
id: string,
username: string
) {
// Lowercase the username.
const lowercaseUsername = username.toLowerCase();
// FIXME: (wyattjoh) add username checking regex here.
// Search to see if this username has been used before.
let user = await collection(mongo).findOne({
tenantID,
lowercaseUsername,
});
if (user) {
// TODO: (wyattjoh) return better error
throw new Error("duplicate username found");
}
// The username wasn't found, so add it to the user.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
username: null,
},
{
$set: {
username,
lowercaseUsername,
"status.username.status": GQLUSER_USERNAME_STATUS.SET,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
if (!user) {
// TODO: (wyattjoh) return better error
throw new Error("user not found");
}
if (user.username) {
// TODO: (wyattjoh) return better error
throw new Error("user already has username");
}
// TODO: (wyattjoh) return better error
throw new Error("unexpected error occurred");
}
return result.value;
}
/**
* setUserEmail will set the email address of the User if they don't already
* have one associated with them, and it hasn't been used before.
*
* @param mongo the database handle
* @param tenantID the ID to the Tenant
* @param id the ID of the User where we are setting the email address on
* @param emailAddress the email address we want to set
*/
export async function setUserEmail(
mongo: Db,
tenantID: string,
id: string,
emailAddress: string
) {
// Lowercase the email address.
const email = emailAddress.toLowerCase();
// FIXME: (wyattjoh) add email validation here.
// Search to see if this email has been used before.
let user = await collection(mongo).findOne({
tenantID,
email,
});
if (user) {
// TODO: (wyattjoh) return better error
throw new Error("duplicate email found");
}
// The email wasn't found, so try to update the User.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
email: null,
},
{
$set: {
email,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
if (!user) {
// TODO: (wyattjoh) return better error
throw new Error("user not found");
}
if (user.email) {
// TODO: (wyattjoh) return better error
throw new Error("user already has email");
}
// TODO: (wyattjoh) return better error
throw new Error("unexpected error occurred");
}
return result.value;
}
/**
* setUserLocalProfile will set the local profile for a User if they don't
* already have one associated with them and the profile doesn't exist on any
* other User already.
*
* @param mongo the database handle
* @param tenantID the ID to the Tenant
* @param id the ID of the User where we are setting the local profile on
* @param emailAddress the email address we want to set
* @param password the password we want to set
*/
export async function setUserLocalProfile(
mongo: Db,
tenantID: string,
id: string,
emailAddress: string,
password: string
) {
// Lowercase the email address.
const email = emailAddress.toLowerCase();
// FIXME: (wyattjoh) add email validation here.
// FIXME: (wyattjoh) add password validation here.
// Try to see if this local profile already exists on a User.
let user = await retrieveUserWithProfile(mongo, tenantID, {
type: "local",
id: email,
});
if (user) {
// TODO: (wyattjoh) return better error
throw new Error("duplicate profile found");
}
// Hash the password.
const hashedPassword = await hashPassword(password);
// Create the profile that we'll use.
const profile: LocalProfile = {
type: "local",
id: email,
password: hashedPassword,
};
// The profile wasn't found, so add it to the User.
const result = await collection(mongo).findOneAndUpdate(
{
tenantID,
id,
// This ensures that the document we're updating does not contain a local
// profile.
"profiles.type": { $ne: "local" },
},
{
$push: {
profiles: profile,
},
},
{
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!result.value) {
// Try to get the current user to discover what happened.
user = await retrieveUser(mongo, tenantID, id);
if (!user) {
// TODO: (wyattjoh) return better error
throw new Error("user not found");
}
if (user.profiles.some(({ type }) => type === "local")) {
// TODO: (wyattjoh) return better error
throw new Error("user already has local profile");
}
// TODO: (wyattjoh) return better error
throw new Error("unexpected error occurred");
}
return result.value;
}