Merge pull request #829 from sheensantoscapadngan/feature/enable-users-to-select-multi-auth-methods

Feature: enable users to select multi auth methods (backward compatible)
This commit is contained in:
BlackMagiq
2023-08-13 17:15:39 +07:00
committed by GitHub
17 changed files with 418 additions and 355 deletions
@@ -1,5 +1,5 @@
import { Request, Response } from "express";
import { User } from "../../models";
import { AuthMethod, User } from "../../models";
import { checkEmailVerification, sendEmailVerification } from "../../helpers/signup";
import { createToken } from "../../helpers/auth";
import { BadRequestError } from "../../utils/errors";
@@ -81,7 +81,8 @@ export const verifyEmailSignup = async (req: Request, res: Response) => {
if (!user) {
user = await new User({
email
email,
authMethods: [AuthMethod.EMAIL]
}).save();
}
+16 -10
View File
@@ -4,7 +4,7 @@ import crypto from "crypto";
import bcrypt from "bcrypt";
import {
APIKeyData,
AuthProvider,
AuthMethod,
MembershipOrg,
TokenVersion,
User
@@ -113,21 +113,26 @@ export const updateName = async (req: Request, res: Response) => {
}
/**
* Update auth provider of the current user to [authProvider]
* Update auth method of the current user to [authMethods]
* @param req
* @param res
* @returns
*/
export const updateAuthProvider = async (req: Request, res: Response) => {
export const updateAuthMethods = async (req: Request, res: Response) => {
const {
authProvider
authMethods
} = req.body;
if (
req.user?.authProvider === AuthProvider.OKTA_SAML
|| req.user?.authProvider === AuthProvider.AZURE_SAML
|| req.user?.authProvider === AuthProvider.JUMPCLOUD_SAML
) {
const hasSamlEnabled = req.user.authMethods
.some(
(authMethod: AuthMethod) => [
AuthMethod.OKTA_SAML,
AuthMethod.AZURE_SAML,
AuthMethod.JUMPCLOUD_SAML
].includes(authMethod)
);
if (hasSamlEnabled) {
return res.status(400).send({
message: "Failed to update user authentication method because SAML SSO is enforced"
});
@@ -136,7 +141,7 @@ export const updateAuthProvider = async (req: Request, res: Response) => {
const user = await User.findByIdAndUpdate(
req.user._id.toString(),
{
authProvider
authMethods
},
{
new: true
@@ -148,6 +153,7 @@ export const updateAuthProvider = async (req: Request, res: Response) => {
});
}
/**
* Return organizations that the current user is part of.
* @param req
+173 -190
View File
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import { Request, Response } from "express";
import jwt from "jsonwebtoken";
import * as Sentry from "@sentry/node";
import * as bigintConversion from "bigint-conversion";
const jsrp = require("jsrp");
import { LoginSRPDetail, User } from "../../models";
@@ -21,13 +20,13 @@ import {
getJwtMfaLifetime,
getJwtMfaSecret,
} from "../../config";
import { AuthProvider } from "../../models/user";
import { AuthMethod } from "../../models/user";
declare module "jsonwebtoken" {
export interface ProviderAuthJwtPayload extends jwt.JwtPayload {
userId: string;
email: string;
authProvider: AuthProvider;
authProvider: AuthMethod;
isUserCompleted: boolean,
}
}
@@ -39,62 +38,54 @@ declare module "jsonwebtoken" {
* @returns
*/
export const login1 = async (req: Request, res: Response) => {
try {
const {
const {
email,
providerAuthToken,
clientPublicKey,
}: {
email: string;
clientPublicKey: string,
providerAuthToken?: string;
} = req.body;
const user = await User.findOne({
email,
}).select("+salt +verifier");
if (!user) throw new Error("Failed to find user");
if (!user.authMethods.includes(AuthMethod.EMAIL)) {
await validateProviderAuthToken({
email,
user,
providerAuthToken,
clientPublicKey,
}: {
email: string;
clientPublicKey: string,
providerAuthToken?: string;
} = req.body;
const user = await User.findOne({
email,
}).select("+salt +verifier");
if (!user) throw new Error("Failed to find user");
if (user.authProvider && user.authProvider !== AuthProvider.EMAIL) {
await validateProviderAuthToken({
email,
user,
providerAuthToken,
})
}
const server = new jsrp.server();
server.init(
{
salt: user.salt,
verifier: user.verifier,
},
async () => {
// generate server-side public key
const serverPublicKey = server.getPublicKey();
await LoginSRPDetail.findOneAndReplace({
email: email,
}, {
email,
userId: user.id,
clientPublicKey: clientPublicKey,
serverBInt: bigintConversion.bigintToBuf(server.bInt),
}, { upsert: true, returnNewDocument: false });
return res.status(200).send({
serverPublicKey,
salt: user.salt,
});
}
);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
return res.status(400).send({
message: "Failed to start authentication process",
});
}
const server = new jsrp.server();
server.init(
{
salt: user.salt,
verifier: user.verifier,
},
async () => {
// generate server-side public key
const serverPublicKey = server.getPublicKey();
await LoginSRPDetail.findOneAndReplace({
email: email,
}, {
email,
userId: user.id,
clientPublicKey: clientPublicKey,
serverBInt: bigintConversion.bigintToBuf(server.bInt),
}, { upsert: true, returnNewDocument: false });
return res.status(200).send({
serverPublicKey,
salt: user.salt,
});
}
);
};
/**
@@ -105,159 +96,151 @@ export const login1 = async (req: Request, res: Response) => {
* @returns
*/
export const login2 = async (req: Request, res: Response) => {
try {
if (!req.headers["user-agent"]) throw InternalServerError({ message: "User-Agent header is required" });
if (!req.headers["user-agent"]) throw InternalServerError({ message: "User-Agent header is required" });
const { email, clientProof, providerAuthToken } = req.body;
const { email, clientProof, providerAuthToken } = req.body;
const user = await User.findOne({
const user = await User.findOne({
email,
}).select("+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag +devices");
if (!user) throw new Error("Failed to find user");
if (!user.authMethods.includes(AuthMethod.EMAIL)) {
await validateProviderAuthToken({
email,
}).select("+salt +verifier +encryptionVersion +protectedKey +protectedKeyIV +protectedKeyTag +publicKey +encryptedPrivateKey +iv +tag +devices");
user,
providerAuthToken,
})
}
if (!user) throw new Error("Failed to find user");
const loginSRPDetail = await LoginSRPDetail.findOneAndDelete({ email: email })
if (user.authProvider && user.authProvider !== AuthProvider.EMAIL) {
await validateProviderAuthToken({
email,
user,
providerAuthToken,
})
}
if (!loginSRPDetail) {
return BadRequestError(Error("Failed to find login details for SRP"))
}
const loginSRPDetail = await LoginSRPDetail.findOneAndDelete({ email: email })
const server = new jsrp.server();
server.init(
{
salt: user.salt,
verifier: user.verifier,
b: loginSRPDetail.serverBInt,
},
async () => {
server.setClientPublicKey(loginSRPDetail.clientPublicKey);
if (!loginSRPDetail) {
return BadRequestError(Error("Failed to find login details for SRP"))
}
// compare server and client shared keys
if (server.checkClientProof(clientProof)) {
const server = new jsrp.server();
server.init(
{
salt: user.salt,
verifier: user.verifier,
b: loginSRPDetail.serverBInt,
},
async () => {
server.setClientPublicKey(loginSRPDetail.clientPublicKey);
if (user.isMfaEnabled) {
// case: user has MFA enabled
// compare server and client shared keys
if (server.checkClientProof(clientProof)) {
if (user.isMfaEnabled) {
// case: user has MFA enabled
// generate temporary MFA token
const token = createToken({
payload: {
userId: user._id.toString(),
},
expiresIn: await getJwtMfaLifetime(),
secret: await getJwtMfaSecret(),
});
const code = await TokenService.createToken({
type: TOKEN_EMAIL_MFA,
email,
});
// send MFA code [code] to [email]
await sendMail({
template: "emailMfa.handlebars",
subjectLine: "Infisical MFA code",
recipients: [user.email],
substitutions: {
code,
},
});
return res.status(200).send({
mfaEnabled: true,
token,
});
}
await checkUserDevice({
user,
ip: req.realIP,
userAgent: req.headers["user-agent"] ?? "",
// generate temporary MFA token
const token = createToken({
payload: {
userId: user._id.toString(),
},
expiresIn: await getJwtMfaLifetime(),
secret: await getJwtMfaSecret(),
});
// issue tokens
const tokens = await issueAuthTokens({
userId: user._id,
ip: req.realIP,
userAgent: req.headers["user-agent"] ?? "",
});
// store (refresh) token in httpOnly cookie
res.cookie("jid", tokens.refreshToken, {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: await getHttpsEnabled(),
const code = await TokenService.createToken({
type: TOKEN_EMAIL_MFA,
email,
});
// case: user does not have MFA enablgged
// return (access) token in response
interface ResponseData {
mfaEnabled: boolean;
encryptionVersion: any;
protectedKey?: string;
protectedKeyIV?: string;
protectedKeyTag?: string;
token: string;
publicKey?: string;
encryptedPrivateKey?: string;
iv?: string;
tag?: string;
}
const response: ResponseData = {
mfaEnabled: false,
encryptionVersion: user.encryptionVersion,
token: tokens.token,
publicKey: user.publicKey,
encryptedPrivateKey: user.encryptedPrivateKey,
iv: user.iv,
tag: user.tag,
}
if (
user?.protectedKey &&
user?.protectedKeyIV &&
user?.protectedKeyTag
) {
response.protectedKey = user.protectedKey;
response.protectedKeyIV = user.protectedKeyIV
response.protectedKeyTag = user.protectedKeyTag;
}
const loginAction = await EELogService.createAction({
name: ACTION_LOGIN,
userId: user._id,
// send MFA code [code] to [email]
await sendMail({
template: "emailMfa.handlebars",
subjectLine: "Infisical MFA code",
recipients: [user.email],
substitutions: {
code,
},
});
loginAction && await EELogService.createLog({
userId: user._id,
actions: [loginAction],
channel: getUserAgentType(req.headers["user-agent"]),
ipAddress: req.realIP,
return res.status(200).send({
mfaEnabled: true,
token,
});
return res.status(200).send(response);
}
return res.status(400).send({
message: "Failed to authenticate. Try again?",
await checkUserDevice({
user,
ip: req.realIP,
userAgent: req.headers["user-agent"] ?? "",
});
// issue tokens
const tokens = await issueAuthTokens({
userId: user._id,
ip: req.realIP,
userAgent: req.headers["user-agent"] ?? "",
});
// store (refresh) token in httpOnly cookie
res.cookie("jid", tokens.refreshToken, {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: await getHttpsEnabled(),
});
// case: user does not have MFA enablgged
// return (access) token in response
interface ResponseData {
mfaEnabled: boolean;
encryptionVersion: any;
protectedKey?: string;
protectedKeyIV?: string;
protectedKeyTag?: string;
token: string;
publicKey?: string;
encryptedPrivateKey?: string;
iv?: string;
tag?: string;
}
const response: ResponseData = {
mfaEnabled: false,
encryptionVersion: user.encryptionVersion,
token: tokens.token,
publicKey: user.publicKey,
encryptedPrivateKey: user.encryptedPrivateKey,
iv: user.iv,
tag: user.tag,
}
if (
user?.protectedKey &&
user?.protectedKeyIV &&
user?.protectedKeyTag
) {
response.protectedKey = user.protectedKey;
response.protectedKeyIV = user.protectedKeyIV
response.protectedKeyTag = user.protectedKeyTag;
}
const loginAction = await EELogService.createAction({
name: ACTION_LOGIN,
userId: user._id,
});
loginAction && await EELogService.createLog({
userId: user._id,
actions: [loginAction],
channel: getUserAgentType(req.headers["user-agent"]),
ipAddress: req.realIP,
});
return res.status(200).send(response);
}
);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
return res.status(400).send({
message: "Failed to authenticate. Try again?",
});
}
return res.status(400).send({
message: "Failed to authenticate. Try again?",
});
}
);
};
+12 -2
View File
@@ -12,7 +12,7 @@ import { standardRequest } from "../../config/request";
import { getHttpsEnabled, getJwtSignupSecret, getLoopsApiKey } from "../../config";
import { BadRequestError } from "../../utils/errors";
import { TelemetryService } from "../../services";
import { AuthProvider } from "../../models";
import { AuthMethod } from "../../models";
/**
* Complete setting up user by adding their personal and auth information as part of the
@@ -117,7 +117,17 @@ export const completeAccountSignup = async (req: Request, res: Response) => {
if (!user)
throw new Error("Failed to complete account for non-existent user"); // ensure user is non-null
if (user.authProvider !== AuthProvider.OKTA_SAML) {
const hasSamlEnabled = user.authMethods
.some(
(authMethod: AuthMethod) =>
[
AuthMethod.OKTA_SAML,
AuthMethod.AZURE_SAML,
AuthMethod.JUMPCLOUD_SAML
].includes(authMethod)
);
if (!hasSamlEnabled) { // TODO: modify this part
// initialize default organization and workspace
await initializeDefaultOrg({
organizationName,
@@ -3,6 +3,7 @@ import { Types } from "mongoose";
import { BotOrgService } from "../../../services";
import { SSOConfig } from "../../models";
import {
AuthMethod,
MembershipOrg,
User
} from "../../../models";
@@ -156,7 +157,7 @@ export const updateSSOConfig = async (req: Request, res: Response) => {
}
},
{
authProvider: ssoConfig.authProvider
authMethods: [ssoConfig.authProvider],
}
);
} else {
@@ -167,9 +168,7 @@ export const updateSSOConfig = async (req: Request, res: Response) => {
}
},
{
$unset: {
authProvider: 1
}
authMethods: [AuthMethod.EMAIL],
}
);
}
+2 -2
View File
@@ -408,9 +408,9 @@ export const validateProviderAuthToken = async ({
);
if (
decodedToken.authProvider !== user.authProvider ||
!user.authMethods.includes(decodedToken.authMethod) ||
decodedToken.email !== email
) {
throw new Error("Invalid authentication credentials.")
}
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ import ServiceAccountKey, { IServiceAccountKey } from "./serviceAccountKey"; //
import ServiceAccountOrganizationPermission, { IServiceAccountOrganizationPermission } from "./serviceAccountOrganizationPermission"; // new
import ServiceAccountWorkspacePermission, { IServiceAccountWorkspacePermission } from "./serviceAccountWorkspacePermission"; // new
import TokenData, { ITokenData } from "./tokenData";
import User, { AuthProvider, IUser } from "./user";
import User, { AuthMethod, IUser } from "./user";
import UserAction, { IUserAction } from "./userAction";
import Workspace, { IWorkspace } from "./workspace";
import ServiceTokenData, { IServiceTokenData } from "./serviceTokenData";
@@ -28,7 +28,7 @@ import LoginSRPDetail, { ILoginSRPDetail } from "./loginSRPDetail";
import TokenVersion, { ITokenVersion } from "./tokenVersion";
export {
AuthProvider,
AuthMethod,
BackupPrivateKey,
IBackupPrivateKey,
Bot,
+12 -7
View File
@@ -1,6 +1,6 @@
import { Document, Schema, Types, model } from "mongoose";
export enum AuthProvider {
export enum AuthMethod {
EMAIL = "email",
GOOGLE = "google",
GITHUB = "github",
@@ -11,8 +11,8 @@ export enum AuthProvider {
export interface IUser extends Document {
_id: Types.ObjectId;
authId?: string;
authProvider?: AuthProvider;
authProvider?: AuthMethod;
authMethods: AuthMethod[];
email: string;
firstName?: string;
lastName?: string;
@@ -36,12 +36,17 @@ export interface IUser extends Document {
const userSchema = new Schema<IUser>(
{
authId: {
authProvider: { // TODO field: deprecate
type: String,
enum: AuthMethod,
},
authProvider: {
type: String,
enum: AuthProvider,
authMethods: {
type: [{
type: String,
enum: AuthMethod,
}],
default: [AuthMethod.EMAIL],
required: true
},
email: {
type: String,
+13 -9
View File
@@ -8,7 +8,7 @@ import { body, param } from "express-validator";
import { usersController } from "../../controllers/v2";
import { AuthMode } from "../../variables";
import {
AuthProvider
AuthMethod
} from "../../models";
router.get(
@@ -40,18 +40,22 @@ router.patch(
usersController.updateName
);
router.patch(
"/me/auth-provider",
router.put(
"/me/auth-methods",
requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY],
}),
body("authProvider").exists().isString().isIn([
AuthProvider.EMAIL,
AuthProvider.GOOGLE,
AuthProvider.GITHUB
]),
body("authMethods").exists().isArray({
min: 1,
}).custom((authMethods: AuthMethod[]) => {
return authMethods.every(provider => [
AuthMethod.EMAIL,
AuthMethod.GOOGLE,
AuthMethod.GITHUB
].includes(provider))
}),
validateRequest,
usersController.updateAuthProvider
usersController.updateAuthMethods,
);
router.get(
+29 -21
View File
@@ -3,7 +3,7 @@ import passport from "passport";
import { Types } from "mongoose";
import { AuthData } from "../interfaces/middleware";
import {
AuthProvider,
AuthMethod,
MembershipOrg,
Organization,
ServiceAccount,
@@ -97,20 +97,19 @@ const initializePassport = async () => {
email
}).select("+publicKey");
if (user && user.authProvider !== AuthProvider.GOOGLE) {
done(InternalServerError());
}
if (!user) {
user = await new User({
email,
authProvider: AuthProvider.GOOGLE,
authId: profile.id,
authMethods: [AuthMethod.GOOGLE],
firstName: profile.name.givenName,
lastName: profile.name.familyName
}).save();
}
if (!user.authMethods.includes(AuthMethod.GOOGLE)) {
done(InternalServerError());
}
const isUserCompleted = !!user.publicKey;
const providerAuthToken = createToken({
payload: {
@@ -118,7 +117,7 @@ const initializePassport = async () => {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
authProvider: user.authProvider,
authMethod: AuthMethod.GOOGLE,
isUserCompleted,
...(req.query.state ? {
callbackPort: req.query.state as string
@@ -150,20 +149,19 @@ const initializePassport = async () => {
let user = await User.findOne({
email
}).select("+publicKey");
if (user && user.authProvider !== AuthProvider.GITHUB) {
done(InternalServerError());
}
if (!user) {
user = await new User({
email: email,
authProvider: AuthProvider.GITHUB,
authId: profile.id,
authMethods: [AuthMethod.GITHUB],
firstName: profile.displayName,
lastName: ""
}).save();
}
if (!user.authMethods.includes(AuthMethod.GITHUB)) {
done(InternalServerError());
}
const isUserCompleted = !!user.publicKey;
const providerAuthToken = createToken({
@@ -172,7 +170,7 @@ const initializePassport = async () => {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
authProvider: user.authProvider,
authMethod: AuthMethod.GITHUB,
isUserCompleted,
...(req.query.state ? {
callbackPort: req.query.state as string
@@ -218,7 +216,7 @@ const initializePassport = async () => {
audience: await getSiteURL()
});
if (ssoConfig.authProvider === AuthProvider.JUMPCLOUD_SAML) {
if (ssoConfig.authProvider.toString() === AuthMethod.JUMPCLOUD_SAML.toString()) {
samlConfig.wantAuthnResponseSigned = false;
}
@@ -243,11 +241,21 @@ const initializePassport = async () => {
}).select("+publicKey");
if (user) {
if (!user.authProvider || user.authProvider === AuthProvider.EMAIL || user.authProvider === AuthProvider.GOOGLE) {
// if user does not have SAML enabled then update
const hasSamlEnabled = user.authMethods
.some(
(authMethod: AuthMethod) => [
AuthMethod.OKTA_SAML,
AuthMethod.AZURE_SAML,
AuthMethod.JUMPCLOUD_SAML
].includes(authMethod)
);
if (!hasSamlEnabled) {
await User.findByIdAndUpdate(
user._id,
{
authProvider: req.ssoConfig.authProvider
authMethods: [req.ssoConfig.authProvider]
},
{
new: true
@@ -279,7 +287,7 @@ const initializePassport = async () => {
} else {
user = await new User({
email,
authProvider: req.ssoConfig.authProvider,
authMethods: [req.ssoConfig.authProvider],
firstName,
lastName
}).save();
@@ -301,7 +309,7 @@ const initializePassport = async () => {
firstName,
lastName,
organizationName: organization?.name,
authProvider: user.authProvider,
authMethod: req.ssoConfig.authProvider,
isUserCompleted,
...(req.body.RelayState ? {
callbackPort: req.body.RelayState as string
+39
View File
@@ -11,6 +11,7 @@ import {
TrustedIP
} from "../../ee/models";
import {
AuthMethod,
BackupPrivateKey,
Bot,
BotOrg,
@@ -21,6 +22,7 @@ import {
Secret,
SecretBlindIndexData,
ServiceTokenData,
User,
Workspace
} from "../../models";
import { generateKeyPair } from "../../utils/crypto";
@@ -631,3 +633,40 @@ export const backfillTrustedIps = async () => {
console.log("Backfill: Trusted IPs complete");
}
}
export const backfillUserAuthMethods = async () => {
await User.updateMany(
{
authProvider: {
$exists: false
},
authMethods: {
$exists: false
}
},
{
authMethods: [AuthMethod.EMAIL]
}
);
await User.updateMany(
{
authProvider: {
$exists: true
},
authMethods: {
$exists: false
}
},
[
{
$set: {
authMethods: ["$authProvider"]
}
},
{
$unset: ["authProvider", "authId"]
}
]
);
}
+3 -1
View File
@@ -16,7 +16,8 @@ import {
backfillSecretVersions,
backfillServiceToken,
backfillServiceTokenMultiScope,
backfillTrustedIps
backfillTrustedIps,
backfillUserAuthMethods
} from "./backfillData";
import {
reencryptBotOrgKeys,
@@ -79,6 +80,7 @@ export const setup = async () => {
await backfillIntegration();
await backfillServiceTokenMultiScope();
await backfillTrustedIps();
await backfillUserAuthMethods();
// re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY
// to base64 256-bit ROOT_ENCRYPTION_KEY