mirror of
https://github.com/wassname/talk.git
synced 2026-07-22 13:00:29 +08:00
[CORL-845] Account Linking (#2818)
* feat: added new linking backend * feat: added duplicateEmail to hash * fix: stored the duplicate email on the user * feat: initial implmentation of account linking in auth * test: fix unit tests * fix+test: translations and tests added * chore+test: rename view to LINK_ACCOUNT + more tests * feat+test: account linking admin + more tests * feat: Handle incomplete accounts * chore: add some comments * feat: expose duplicateEmail through graphql and impl for stream * feat: admin to use duplicateEmail from graphql * fix: no need to validate password for account linking * fix: dont validate password * fix: no need to render error message when account was incomplete * chore: log to console when encountering incomplete account * chore: adjust comment * chore: simplify + add comments * chore: wording * chore: comments Co-authored-by: Vinh <vinh@vinh.tech> Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
co-authored by
Vinh
Kim Gardner
parent
7497e33046
commit
dcb2a10e72
@@ -4,6 +4,7 @@ import { AppOptions } from "coral-server/app";
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { RequestLimiter } from "coral-server/app/request/limiter";
|
||||
import { IntegrationDisabled } from "coral-server/errors";
|
||||
import { hasEnabledAuthIntegration } from "coral-server/models/tenant";
|
||||
import { retrieveUserWithProfile } from "coral-server/models/user";
|
||||
import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt";
|
||||
import {
|
||||
@@ -62,7 +63,7 @@ export const forgotHandler = ({
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Check to ensure that the local integration has been enabled.
|
||||
if (!tenant.auth.integrations.local.enabled) {
|
||||
if (!hasEnabledAuthIntegration(tenant, "local")) {
|
||||
throw new IntegrationDisabled("local");
|
||||
}
|
||||
|
||||
@@ -178,7 +179,7 @@ export const forgotResetHandler = ({
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Check to ensure that the local integration has been enabled.
|
||||
if (!tenant.auth.integrations.local.enabled) {
|
||||
if (!hasEnabledAuthIntegration(tenant, "local")) {
|
||||
throw new IntegrationDisabled("local");
|
||||
}
|
||||
|
||||
@@ -254,7 +255,7 @@ export const forgotCheckHandler = ({
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Check to ensure that the local integration has been enabled.
|
||||
if (!tenant.auth.integrations.local.enabled) {
|
||||
if (!hasEnabledAuthIntegration(tenant, "local")) {
|
||||
throw new IntegrationDisabled("local");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export * from "./forgot";
|
||||
export * from "./signup";
|
||||
export * from "./link";
|
||||
|
||||
export type LogoutOptions = Pick<AppOptions, "redis">;
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import Joi from "joi";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { RequestLimiter } from "coral-server/app/request/limiter";
|
||||
import { linkUsersAvailable } from "coral-server/models/tenant";
|
||||
import { signTokenString } from "coral-server/services/jwt";
|
||||
import { link } from "coral-server/services/users";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export interface LinkBody {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const LinkBodySchema = Joi.object().keys({
|
||||
email: Joi.string()
|
||||
.trim()
|
||||
.lowercase()
|
||||
.email(),
|
||||
password: Joi.string(),
|
||||
});
|
||||
|
||||
export type LinkOptions = Pick<
|
||||
AppOptions,
|
||||
"mongo" | "signingConfig" | "mailerQueue" | "redis" | "config"
|
||||
>;
|
||||
|
||||
export const linkHandler = ({
|
||||
redis,
|
||||
mongo,
|
||||
signingConfig,
|
||||
config,
|
||||
}: LinkOptions): RequestHandler => {
|
||||
const ipLimiter = new RequestLimiter({
|
||||
redis,
|
||||
ttl: "10m",
|
||||
max: 10,
|
||||
prefix: "ip",
|
||||
config,
|
||||
});
|
||||
|
||||
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 coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Check to ensure that the local integration has been enabled.
|
||||
if (!linkUsersAvailable(tenant)) {
|
||||
throw new Error("cannot link users, not available");
|
||||
}
|
||||
|
||||
// Get the fields from the body. Validate will throw an error if the body
|
||||
// does not conform to the specification.
|
||||
const { email, password }: LinkBody = validate(LinkBodySchema, req.body);
|
||||
|
||||
// Start the account linking process. We are assured the user at this
|
||||
// point because of the middleware inserted before which rejects any
|
||||
// unauthenticated requests.
|
||||
const user = await link(mongo, tenant, req.user!, { email, password });
|
||||
|
||||
// Account linking is complete! Return the new access token for the
|
||||
// request.
|
||||
const token = await signTokenString(
|
||||
signingConfig,
|
||||
user,
|
||||
tenant,
|
||||
{},
|
||||
coral.now
|
||||
);
|
||||
|
||||
return res.json({ token });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -6,12 +6,14 @@ import { handleSuccessfulLogin } from "coral-server/app/middleware/passport";
|
||||
import { validate } from "coral-server/app/request/body";
|
||||
import { RequestLimiter } from "coral-server/app/request/limiter";
|
||||
import { IntegrationDisabled } from "coral-server/errors";
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
|
||||
import { hasEnabledAuthIntegration } from "coral-server/models/tenant";
|
||||
import { LocalProfile, User } from "coral-server/models/user";
|
||||
import { create } from "coral-server/services/users";
|
||||
import { sendConfirmationEmail } from "coral-server/services/users/auth";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export interface SignupBody {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -57,7 +59,7 @@ export const signupHandler = ({
|
||||
const now = req.coral!.now;
|
||||
|
||||
// Check to ensure that the local integration has been enabled.
|
||||
if (!tenant.auth.integrations.local.enabled) {
|
||||
if (!hasEnabledAuthIntegration(tenant, "local")) {
|
||||
throw new IntegrationDisabled("local");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { UserForbiddenError } from "coral-server/errors";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export const loggedInMiddleware: RequestHandler = (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next(
|
||||
new UserForbiddenError(
|
||||
"user is not logged in",
|
||||
req.originalUrl,
|
||||
req.method
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -2,8 +2,10 @@ import { CookieOptions, NextFunction, RequestHandler, Response } from "express";
|
||||
import { Redis } from "ioredis";
|
||||
import Joi from "joi";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { DateTime } from "luxon";
|
||||
import passport, { Authenticator } from "passport";
|
||||
|
||||
import { stringifyQuery } from "coral-common/utils";
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import FacebookStrategy from "coral-server/app/middleware/passport/strategies/facebook";
|
||||
import GoogleStrategy from "coral-server/app/middleware/passport/strategies/google";
|
||||
@@ -21,7 +23,6 @@ import {
|
||||
signTokenString,
|
||||
} from "coral-server/services/jwt";
|
||||
import { Request } from "coral-server/types/express";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
export type VerifyCallback = (
|
||||
err?: Error | null,
|
||||
@@ -126,9 +127,7 @@ export async function handleSuccessfulLogin(
|
||||
signingConfig,
|
||||
user,
|
||||
tenant,
|
||||
{
|
||||
expiresIn: tenant.auth.sessionDuration,
|
||||
},
|
||||
{},
|
||||
coral.now
|
||||
);
|
||||
|
||||
@@ -159,6 +158,14 @@ const generateCookieOptions = (
|
||||
expires: expiresIn,
|
||||
});
|
||||
|
||||
function redirectWithHash(
|
||||
res: Response,
|
||||
path: string,
|
||||
hash: Record<string, any>
|
||||
) {
|
||||
res.redirect(`${path}${stringifyQuery(hash, "#")}`);
|
||||
}
|
||||
|
||||
export async function handleOAuth2Callback(
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
@@ -173,36 +180,37 @@ export async function handleOAuth2Callback(
|
||||
err = new Error("user not on request");
|
||||
}
|
||||
|
||||
return res.redirect(path + `#error=${encodeURIComponent(err.message)}`);
|
||||
return redirectWithHash(res, path, { error: err.message });
|
||||
}
|
||||
|
||||
try {
|
||||
// Tenant is guaranteed at this point.
|
||||
const tenant = req.coral!.tenant!;
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Compute the expiry date.
|
||||
const expiresIn = DateTime.fromJSDate(req.coral!.now).plus({
|
||||
const expiresIn = DateTime.fromJSDate(coral.now).plus({
|
||||
seconds: tenant.auth.sessionDuration,
|
||||
});
|
||||
|
||||
// Grab the token.
|
||||
const token = await signTokenString(
|
||||
const accessToken = await signTokenString(
|
||||
signingConfig,
|
||||
user,
|
||||
tenant,
|
||||
{ expiresIn: tenant.auth.sessionDuration },
|
||||
req.coral!.now
|
||||
{},
|
||||
coral.now
|
||||
);
|
||||
res.cookie(
|
||||
COOKIE_NAME,
|
||||
token,
|
||||
accessToken,
|
||||
generateCookieOptions(req, expiresIn.toJSDate())
|
||||
);
|
||||
|
||||
// Send back the details!
|
||||
res.redirect(path + `#accessToken=${token}`);
|
||||
return redirectWithHash(res, path, { accessToken });
|
||||
} catch (e) {
|
||||
res.redirect(path + `#error=${encodeURIComponent(e.message)}`);
|
||||
return redirectWithHash(res, path, { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,47 +50,45 @@ export default class FacebookStrategy extends OAuth2Strategy<
|
||||
id,
|
||||
};
|
||||
|
||||
let user = await retrieveUserWithProfile(this.mongo, tenant.id, profile);
|
||||
if (!user) {
|
||||
if (!integration.allowRegistration) {
|
||||
// Registration is disabled, so we can't create the user user here.
|
||||
return null;
|
||||
}
|
||||
|
||||
// FIXME: implement rules.
|
||||
|
||||
// Try to get the avatar.
|
||||
let avatar: string | undefined;
|
||||
if (photos && photos.length > 0) {
|
||||
avatar = photos[0].value;
|
||||
}
|
||||
|
||||
// Try to get the email address.
|
||||
let email: string | undefined;
|
||||
let emailVerified: boolean | undefined;
|
||||
if (emails && emails.length > 0) {
|
||||
email = emails[0].value;
|
||||
emailVerified = false;
|
||||
}
|
||||
|
||||
user = await findOrCreate(
|
||||
this.mongo,
|
||||
tenant,
|
||||
{
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
emailVerified,
|
||||
avatar,
|
||||
profile,
|
||||
},
|
||||
{},
|
||||
now
|
||||
);
|
||||
const user = await retrieveUserWithProfile(this.mongo, tenant.id, profile);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// TODO: maybe update user details?
|
||||
if (!integration.allowRegistration) {
|
||||
// Registration is disabled, so we can't create the user user here.
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
// FIXME: implement rules.
|
||||
|
||||
// Try to get the avatar.
|
||||
let avatar: string | undefined;
|
||||
if (photos && photos.length > 0) {
|
||||
avatar = photos[0].value;
|
||||
}
|
||||
|
||||
// Try to get the email address.
|
||||
let email: string | undefined;
|
||||
let emailVerified: boolean | undefined;
|
||||
if (emails && emails.length > 0) {
|
||||
email = emails[0].value;
|
||||
emailVerified = false;
|
||||
}
|
||||
|
||||
return findOrCreate(
|
||||
this.mongo,
|
||||
tenant,
|
||||
{
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
emailVerified,
|
||||
avatar,
|
||||
profile,
|
||||
},
|
||||
{},
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
protected createStrategy(
|
||||
|
||||
@@ -49,47 +49,43 @@ export default class GoogleStrategy extends OAuth2Strategy<
|
||||
id,
|
||||
};
|
||||
|
||||
let user = await retrieveUserWithProfile(this.mongo, tenant.id, profile);
|
||||
if (!user) {
|
||||
if (!integration.allowRegistration) {
|
||||
// Registration is disabled, so we can't create the user user here.
|
||||
return null;
|
||||
}
|
||||
|
||||
// FIXME: implement rules.
|
||||
|
||||
// Try to get the avatar.
|
||||
let avatar: string | undefined;
|
||||
if (photos && photos.length > 0) {
|
||||
avatar = photos[0].value;
|
||||
}
|
||||
|
||||
// Try to get the email address.
|
||||
let email: string | undefined;
|
||||
let emailVerified: boolean | undefined;
|
||||
if (emails && emails.length > 0) {
|
||||
email = emails[0].value;
|
||||
emailVerified = false;
|
||||
}
|
||||
|
||||
user = await findOrCreate(
|
||||
this.mongo,
|
||||
tenant,
|
||||
{
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
emailVerified,
|
||||
avatar,
|
||||
profile,
|
||||
},
|
||||
{},
|
||||
now
|
||||
);
|
||||
const user = await retrieveUserWithProfile(this.mongo, tenant.id, profile);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// TODO: maybe update user details?
|
||||
if (!integration.allowRegistration) {
|
||||
// Registration is disabled, so we can't create the user user here.
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
// Try to get the avatar.
|
||||
let avatar: string | undefined;
|
||||
if (photos && photos.length > 0) {
|
||||
avatar = photos[0].value;
|
||||
}
|
||||
|
||||
// Try to get the email address.
|
||||
let email: string | undefined;
|
||||
let emailVerified: boolean | undefined;
|
||||
if (emails && emails.length > 0) {
|
||||
email = emails[0].value;
|
||||
emailVerified = false;
|
||||
}
|
||||
|
||||
return findOrCreate(
|
||||
this.mongo,
|
||||
tenant,
|
||||
{
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
email,
|
||||
emailVerified,
|
||||
avatar,
|
||||
profile,
|
||||
},
|
||||
{},
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
protected createStrategy(
|
||||
|
||||
@@ -72,18 +72,18 @@ export default abstract class OAuth2Strategy<
|
||||
) => {
|
||||
try {
|
||||
// Coral is defined at this point.
|
||||
const tenant = req.coral!.tenant!;
|
||||
const now = req.coral!.now;
|
||||
const coral = req.coral!;
|
||||
const tenant = coral.tenant!;
|
||||
|
||||
// Get the integration.
|
||||
const integration = this.getIntegration(tenant.auth.integrations);
|
||||
|
||||
// Get the user.
|
||||
// Find or create the user.
|
||||
const user = await this.findOrCreateUser(
|
||||
tenant,
|
||||
integration as Required<T>,
|
||||
profile,
|
||||
now
|
||||
coral.now
|
||||
);
|
||||
if (!user) {
|
||||
return done(null);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
import {
|
||||
JWTSigningConfig,
|
||||
signTokenString,
|
||||
SymmetricSigningAlgorithm,
|
||||
} from "coral-server/services/jwt";
|
||||
import { isJWTToken } from "./jwt";
|
||||
|
||||
import { isJWTToken, JWTToken } from "./jwt";
|
||||
|
||||
// Create signing config.
|
||||
const config: JWTSigningConfig = {
|
||||
@@ -15,13 +17,22 @@ const config: JWTSigningConfig = {
|
||||
|
||||
it("validates a jwt token", async () => {
|
||||
const user = { id: "user-id" };
|
||||
const tenant = { id: "tenant-id" };
|
||||
const tenant = { id: "tenant-id", auth: { sessionDuration: 100 } };
|
||||
|
||||
// Get the time.
|
||||
const now = new Date();
|
||||
now.setMilliseconds(0);
|
||||
|
||||
// Create the signed token string.
|
||||
const tokenString = await signTokenString(config, user, tenant);
|
||||
const tokenString = await signTokenString(config, user, tenant, {}, now);
|
||||
|
||||
// Verify that the token conforms to the JWT token schema.
|
||||
const token = jwt.decode(tokenString) as object;
|
||||
|
||||
expect(isJWTToken(token)).toBeTruthy();
|
||||
expect((token as JWTToken).exp).toBe(
|
||||
DateTime.fromJSDate(now)
|
||||
.plus({ seconds: 100 })
|
||||
.toSeconds()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
forgotCheckHandler,
|
||||
forgotHandler,
|
||||
forgotResetHandler,
|
||||
linkHandler,
|
||||
logoutHandler,
|
||||
signupHandler,
|
||||
} from "coral-server/app/handlers";
|
||||
import { noCacheMiddleware } from "coral-server/app/middleware/cacheHeaders";
|
||||
import { jsonMiddleware } from "coral-server/app/middleware/json";
|
||||
import { loggedInMiddleware } from "coral-server/app/middleware/loggedIn";
|
||||
import {
|
||||
authenticate,
|
||||
wrapAuthn,
|
||||
@@ -48,6 +50,15 @@ export function createNewAuthRouter(
|
||||
router.put("/local/forgot", jsonMiddleware, forgotResetHandler(app));
|
||||
router.post("/local/forgot", jsonMiddleware, forgotHandler(app));
|
||||
|
||||
// Mount the link handler.
|
||||
router.post(
|
||||
"/link",
|
||||
authenticate(passport),
|
||||
loggedInMiddleware,
|
||||
jsonMiddleware,
|
||||
linkHandler(app)
|
||||
);
|
||||
|
||||
// Mount the logout handler.
|
||||
router.delete("/", authenticate(passport), logoutHandler(app));
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
## Table of Contents
|
||||
|
||||
- [events](#events)
|
||||
- [Adding new events](#adding-new-events)
|
||||
- [Adding new event listeners](#adding-new-event-listeners)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
# events
|
||||
|
||||
This is the events package for Coral.
|
||||
|
||||
@@ -1944,6 +1944,13 @@ type User {
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
)
|
||||
|
||||
"""
|
||||
duplicateEmail is set on users that have a matching email with another user in
|
||||
the database. Only relevant during the account completion process.
|
||||
"""
|
||||
duplicateEmail: String
|
||||
@auth(userIDField: "id", permit: [MISSING_NAME, MISSING_EMAIL])
|
||||
|
||||
"""
|
||||
emailVerified when true indicates that the given email address has been
|
||||
verified.
|
||||
@@ -1972,7 +1979,7 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
)
|
||||
|
||||
"""
|
||||
|
||||
@@ -4,6 +4,7 @@ import crypto from "crypto";
|
||||
import { translate } from "coral-server/services/i18n";
|
||||
|
||||
import {
|
||||
GQLAuthIntegrations,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLReactionConfiguration,
|
||||
GQLStaffConfiguration,
|
||||
@@ -68,6 +69,21 @@ export function hasFeatureFlag(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasEnabledAuthIntegration(
|
||||
tenant: Pick<Tenant, "auth">,
|
||||
integration: keyof GQLAuthIntegrations
|
||||
) {
|
||||
return tenant.auth.integrations[integration].enabled;
|
||||
}
|
||||
|
||||
export function linkUsersAvailable(tenant: Pick<Tenant, "auth">) {
|
||||
return (
|
||||
hasEnabledAuthIntegration(tenant, "local") &&
|
||||
(hasEnabledAuthIntegration(tenant, "facebook") ||
|
||||
hasEnabledAuthIntegration(tenant, "google"))
|
||||
);
|
||||
}
|
||||
|
||||
export function getWebhookEndpoint(
|
||||
tenant: Pick<Tenant, "webhooks">,
|
||||
endpointID: string
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { isEqual } from "lodash";
|
||||
|
||||
import { SSOUserProfile } from "coral-server/app/middleware/passport/strategies/verifiers/sso";
|
||||
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { SSOUserProfile } from "coral-server/app/middleware/passport/strategies/verifiers/sso";
|
||||
import { STAFF_ROLES } from "./constants";
|
||||
import { LocalProfile, SSOProfile, User } from "./user";
|
||||
import { LocalProfile, Profile, SSOProfile, User } from "./user";
|
||||
|
||||
export function roleIsStaff(role: GQLUSER_ROLE) {
|
||||
if (STAFF_ROLES.includes(role)) {
|
||||
@@ -18,14 +19,19 @@ export function hasStaffRole(user: Pick<User, "role">) {
|
||||
return roleIsStaff(user.role);
|
||||
}
|
||||
|
||||
export function getSSOProfile(user: Pick<User, "profiles">) {
|
||||
export function getUserProfile(
|
||||
user: Pick<User, "profiles">,
|
||||
type: Profile["type"]
|
||||
) {
|
||||
if (!user.profiles) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
return user.profiles.find(profile => profile.type === "sso") as
|
||||
| SSOProfile
|
||||
| undefined;
|
||||
return user.profiles.find(p => p.type === type) || null;
|
||||
}
|
||||
|
||||
export function getSSOProfile(user: Pick<User, "profiles">) {
|
||||
return getUserProfile(user, "sso") as SSOProfile | null;
|
||||
}
|
||||
|
||||
export function needsSSOUpdate(
|
||||
@@ -50,14 +56,7 @@ export function getLocalProfile(
|
||||
user: Pick<User, "profiles">,
|
||||
withEmail?: string
|
||||
): LocalProfile | undefined {
|
||||
if (!user.profiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = user.profiles.find(({ type }) => type === "local") as
|
||||
| LocalProfile
|
||||
| undefined;
|
||||
|
||||
const profile = getUserProfile(user, "local") as LocalProfile | null;
|
||||
if (!profile) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ConfirmEmailTokenExpired,
|
||||
DuplicateEmailError,
|
||||
DuplicateUserError,
|
||||
EmailAlreadySetError,
|
||||
LocalProfileAlreadySetError,
|
||||
LocalProfileNotSetError,
|
||||
PasswordResetTokenExpired,
|
||||
@@ -408,6 +409,13 @@ export interface User extends TenantResource {
|
||||
*/
|
||||
emailVerified?: boolean;
|
||||
|
||||
/**
|
||||
* duplicateEmail is used to store the email address that was associated with
|
||||
* the user account on creation that at the time was previously associated
|
||||
* with another local account.
|
||||
*/
|
||||
duplicateEmail?: string;
|
||||
|
||||
/**
|
||||
* profiles is the array of profiles assigned to the user. When a user deletes
|
||||
* their account, this is unset.
|
||||
@@ -492,6 +500,7 @@ export interface FindOrCreateUserInput {
|
||||
badges?: string[];
|
||||
ssoURL?: string;
|
||||
emailVerified?: boolean;
|
||||
duplicateEmail?: string;
|
||||
role: GQLUSER_ROLE;
|
||||
profile: Profile;
|
||||
}
|
||||
@@ -1059,6 +1068,9 @@ export async function setUserEmail(
|
||||
$set: {
|
||||
email,
|
||||
},
|
||||
$unset: {
|
||||
duplicateEmail: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
@@ -1074,7 +1086,7 @@ export async function setUserEmail(
|
||||
}
|
||||
|
||||
if (user.email) {
|
||||
throw new UsernameAlreadySetError();
|
||||
throw new EmailAlreadySetError();
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occurred");
|
||||
@@ -2472,6 +2484,50 @@ export async function deleteModeratorNote(
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function linkUsers(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
sourceUserID: string,
|
||||
destinationUserID: string
|
||||
) {
|
||||
// Delete the old user from the database.
|
||||
const source = await collection(mongo).findOneAndDelete({
|
||||
id: sourceUserID,
|
||||
tenantID,
|
||||
});
|
||||
if (!source.value) {
|
||||
throw new UserNotFoundError(sourceUserID);
|
||||
}
|
||||
|
||||
// If the source user doesn't have any profiles, we have nothing to do. We
|
||||
// should abort.
|
||||
if (!source.value.profiles) {
|
||||
throw new Error(
|
||||
"cannot link a user with no profiles, failed source authentication precondition"
|
||||
);
|
||||
}
|
||||
|
||||
// Add the new profile to the destination user.
|
||||
const dest = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id: destinationUserID,
|
||||
tenantID,
|
||||
},
|
||||
{
|
||||
$push: {
|
||||
profiles: {
|
||||
$each: source.value.profiles,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!dest.value) {
|
||||
throw new UserNotFoundError(destinationUserID);
|
||||
}
|
||||
|
||||
return dest.value;
|
||||
}
|
||||
|
||||
export const updateUserCommentCounts = (
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
JWTRevokedError,
|
||||
TokenInvalidError,
|
||||
} from "coral-server/errors";
|
||||
import { Auth } from "coral-server/models/settings";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { User } from "coral-server/models/user";
|
||||
import { Request } from "coral-server/types/express";
|
||||
@@ -251,7 +252,9 @@ export type SigningTokenOptions = Pick<
|
||||
export const signTokenString = async (
|
||||
{ algorithm, secret }: JWTSigningConfig,
|
||||
user: Pick<User, "id">,
|
||||
tenant: Pick<Tenant, "id">,
|
||||
tenant: Pick<Tenant, "id"> & {
|
||||
auth: Pick<Auth, "sessionDuration">;
|
||||
},
|
||||
options: SigningTokenOptions = {},
|
||||
now = new Date()
|
||||
) =>
|
||||
@@ -262,7 +265,7 @@ export const signTokenString = async (
|
||||
secret,
|
||||
{
|
||||
jwtid: uuid(),
|
||||
expiresIn: DEFAULT_SESSION_DURATION,
|
||||
expiresIn: tenant.auth.sessionDuration || DEFAULT_SESSION_DURATION,
|
||||
...options,
|
||||
issuer: tenant.id,
|
||||
subject: user.id,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { intersection } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
DuplicateUserError,
|
||||
EmailAlreadySetError,
|
||||
EmailNotSetError,
|
||||
InvalidCredentialsError,
|
||||
LocalProfileAlreadySetError,
|
||||
LocalProfileNotSetError,
|
||||
PasswordIncorrect,
|
||||
@@ -27,7 +29,7 @@ import {
|
||||
} from "coral-server/errors";
|
||||
import logger from "coral-server/logger";
|
||||
import { Comment, retrieveComment } from "coral-server/models/comment";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import { linkUsersAvailable, Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
banUser,
|
||||
clearDeletionDate,
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
findOrCreateUser,
|
||||
FindOrCreateUserInput,
|
||||
ignoreUser,
|
||||
linkUsers,
|
||||
NotificationSettingsInput,
|
||||
premodUser,
|
||||
removeActiveUserSuspensions,
|
||||
@@ -126,19 +129,43 @@ export async function findOrCreate(
|
||||
// Validate the input.
|
||||
validateFindOrCreateUserInput(input, options);
|
||||
|
||||
const { user, wasUpserted } = await findOrCreateUser(
|
||||
mongo,
|
||||
tenant.id,
|
||||
input,
|
||||
now
|
||||
);
|
||||
let user: Readonly<User>;
|
||||
let wasUpserted: boolean;
|
||||
|
||||
try {
|
||||
const result = await findOrCreateUser(mongo, tenant.id, input, now);
|
||||
user = result.user;
|
||||
wasUpserted = result.wasUpserted;
|
||||
} catch (err) {
|
||||
// If this is an error related to a duplicate email, we might be in a
|
||||
// position where the user can link their accounts. This can only occur if
|
||||
// the tenant has both local and another social profile enabled.
|
||||
if (err instanceof DuplicateEmailError && linkUsersAvailable(tenant)) {
|
||||
// Pull the email address out of the input, and re-try creating the user
|
||||
// given that.
|
||||
const { email, emailVerified, ...rest } = input;
|
||||
|
||||
// Create the user again this time, but associate the duplicate email to
|
||||
// the user account.
|
||||
const result = await findOrCreateUser(
|
||||
mongo,
|
||||
tenant.id,
|
||||
{ ...rest, duplicateEmail: email },
|
||||
now
|
||||
);
|
||||
|
||||
user = result.user;
|
||||
wasUpserted = result.wasUpserted;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (wasUpserted) {
|
||||
// TODO: (wyattjoh) emit that a user was created
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) evaluate the tenant to determine if we should send the verification email.
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -1262,3 +1289,76 @@ export async function retrieveUserLastComment(
|
||||
|
||||
return retrieveComment(mongo, tenant.id, id);
|
||||
}
|
||||
|
||||
export interface LinkUser {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export async function link(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
source: User,
|
||||
{ email, password }: LinkUser
|
||||
) {
|
||||
if (!linkUsersAvailable(tenant)) {
|
||||
throw new Error("cannot link users, not available");
|
||||
}
|
||||
|
||||
// TODO: validate the source user
|
||||
|
||||
// Refuse to link a user that already has an email address.
|
||||
if (source.email || hasLocalProfile(source)) {
|
||||
throw new Error("user already has an email linked to the source account");
|
||||
}
|
||||
|
||||
// Validate the input. If the values do not pass validation, it can't possibly
|
||||
// be correct.
|
||||
validateEmail(email);
|
||||
|
||||
// Validate if the credentials are correct.
|
||||
const destination = await retrieveUserWithEmail(mongo, tenant.id, email);
|
||||
if (!destination) {
|
||||
throw new InvalidCredentialsError(
|
||||
"can't find user linked with email address"
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that the source and destination user aren't the same.
|
||||
if (destination.id === source.id) {
|
||||
throw new Error("cannot link the same accounts together");
|
||||
}
|
||||
|
||||
// Ensure that the destination profile has a local profile.
|
||||
if (!hasLocalProfile(destination, email)) {
|
||||
throw new Error("destination user does not have a local profile");
|
||||
}
|
||||
|
||||
// Ensure there is no clash between the source and destination user profiles.
|
||||
const profiles = {
|
||||
destination: (destination.profiles || []).map(p => p.type),
|
||||
source: (source.profiles || []).map(p => p.type),
|
||||
};
|
||||
|
||||
// Check for any intersecting profiles.
|
||||
const intersecting = intersection(profiles.destination, profiles.source);
|
||||
if (intersecting.length > 0) {
|
||||
throw new Error(
|
||||
`user linking found intersecting profiles: ${intersecting}`
|
||||
);
|
||||
}
|
||||
|
||||
// Verify if the password provided is correct for this account.
|
||||
const verified = await verifyUserPassword(destination, password, email);
|
||||
if (!verified) {
|
||||
throw new InvalidCredentialsError("can't verify password for linking");
|
||||
}
|
||||
|
||||
// Perform the account linking step to delete the source user and copy over
|
||||
// the account profiles.
|
||||
const linked = await linkUsers(mongo, tenant.id, source.id, destination.id);
|
||||
|
||||
// TODO: send an email to the linked user
|
||||
|
||||
return linked;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user