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 { Request, Response } from "express";
import { User } from "../../models"; import { AuthMethod, User } from "../../models";
import { checkEmailVerification, sendEmailVerification } from "../../helpers/signup"; import { checkEmailVerification, sendEmailVerification } from "../../helpers/signup";
import { createToken } from "../../helpers/auth"; import { createToken } from "../../helpers/auth";
import { BadRequestError } from "../../utils/errors"; import { BadRequestError } from "../../utils/errors";
@@ -81,7 +81,8 @@ export const verifyEmailSignup = async (req: Request, res: Response) => {
if (!user) { if (!user) {
user = await new User({ user = await new User({
email email,
authMethods: [AuthMethod.EMAIL]
}).save(); }).save();
} }
+16 -10
View File
@@ -4,7 +4,7 @@ import crypto from "crypto";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { import {
APIKeyData, APIKeyData,
AuthProvider, AuthMethod,
MembershipOrg, MembershipOrg,
TokenVersion, TokenVersion,
User 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 req
* @param res * @param res
* @returns * @returns
*/ */
export const updateAuthProvider = async (req: Request, res: Response) => { export const updateAuthMethods = async (req: Request, res: Response) => {
const { const {
authProvider authMethods
} = req.body; } = req.body;
if ( const hasSamlEnabled = req.user.authMethods
req.user?.authProvider === AuthProvider.OKTA_SAML .some(
|| req.user?.authProvider === AuthProvider.AZURE_SAML (authMethod: AuthMethod) => [
|| req.user?.authProvider === AuthProvider.JUMPCLOUD_SAML AuthMethod.OKTA_SAML,
) { AuthMethod.AZURE_SAML,
AuthMethod.JUMPCLOUD_SAML
].includes(authMethod)
);
if (hasSamlEnabled) {
return res.status(400).send({ return res.status(400).send({
message: "Failed to update user authentication method because SAML SSO is enforced" 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( const user = await User.findByIdAndUpdate(
req.user._id.toString(), req.user._id.toString(),
{ {
authProvider authMethods
}, },
{ {
new: true new: true
@@ -148,6 +153,7 @@ export const updateAuthProvider = async (req: Request, res: Response) => {
}); });
} }
/** /**
* Return organizations that the current user is part of. * Return organizations that the current user is part of.
* @param req * @param req
+173 -190
View File
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
import { Request, Response } from "express"; import { Request, Response } from "express";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import * as Sentry from "@sentry/node";
import * as bigintConversion from "bigint-conversion"; import * as bigintConversion from "bigint-conversion";
const jsrp = require("jsrp"); const jsrp = require("jsrp");
import { LoginSRPDetail, User } from "../../models"; import { LoginSRPDetail, User } from "../../models";
@@ -21,13 +20,13 @@ import {
getJwtMfaLifetime, getJwtMfaLifetime,
getJwtMfaSecret, getJwtMfaSecret,
} from "../../config"; } from "../../config";
import { AuthProvider } from "../../models/user"; import { AuthMethod } from "../../models/user";
declare module "jsonwebtoken" { declare module "jsonwebtoken" {
export interface ProviderAuthJwtPayload extends jwt.JwtPayload { export interface ProviderAuthJwtPayload extends jwt.JwtPayload {
userId: string; userId: string;
email: string; email: string;
authProvider: AuthProvider; authProvider: AuthMethod;
isUserCompleted: boolean, isUserCompleted: boolean,
} }
} }
@@ -39,62 +38,54 @@ declare module "jsonwebtoken" {
* @returns * @returns
*/ */
export const login1 = async (req: Request, res: Response) => { 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, email,
user,
providerAuthToken, 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 * @returns
*/ */
export const login2 = async (req: Request, res: Response) => { 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, 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) { if (!loginSRPDetail) {
await validateProviderAuthToken({ return BadRequestError(Error("Failed to find login details for SRP"))
email, }
user,
providerAuthToken,
})
}
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) { // compare server and client shared keys
return BadRequestError(Error("Failed to find login details for SRP")) if (server.checkClientProof(clientProof)) {
}
const server = new jsrp.server(); if (user.isMfaEnabled) {
server.init( // case: user has MFA enabled
{
salt: user.salt,
verifier: user.verifier,
b: loginSRPDetail.serverBInt,
},
async () => {
server.setClientPublicKey(loginSRPDetail.clientPublicKey);
// compare server and client shared keys // generate temporary MFA token
if (server.checkClientProof(clientProof)) { const token = createToken({
payload: {
if (user.isMfaEnabled) { userId: user._id.toString(),
// case: user has MFA enabled },
expiresIn: await getJwtMfaLifetime(),
// generate temporary MFA token secret: await getJwtMfaSecret(),
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"] ?? "",
}); });
// issue tokens const code = await TokenService.createToken({
const tokens = await issueAuthTokens({ type: TOKEN_EMAIL_MFA,
userId: user._id, email,
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 // send MFA code [code] to [email]
// return (access) token in response await sendMail({
template: "emailMfa.handlebars",
interface ResponseData { subjectLine: "Infisical MFA code",
mfaEnabled: boolean; recipients: [user.email],
encryptionVersion: any; substitutions: {
protectedKey?: string; code,
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({ return res.status(200).send({
userId: user._id, mfaEnabled: true,
actions: [loginAction], token,
channel: getUserAgentType(req.headers["user-agent"]),
ipAddress: req.realIP,
}); });
return res.status(200).send(response);
} }
return res.status(400).send({ await checkUserDevice({
message: "Failed to authenticate. Try again?", 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) { return res.status(400).send({
Sentry.setUser(null); message: "Failed to authenticate. Try again?",
Sentry.captureException(err); });
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 { getHttpsEnabled, getJwtSignupSecret, getLoopsApiKey } from "../../config";
import { BadRequestError } from "../../utils/errors"; import { BadRequestError } from "../../utils/errors";
import { TelemetryService } from "../../services"; 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 * 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) if (!user)
throw new Error("Failed to complete account for non-existent user"); // ensure user is non-null 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 // initialize default organization and workspace
await initializeDefaultOrg({ await initializeDefaultOrg({
organizationName, organizationName,
@@ -3,6 +3,7 @@ import { Types } from "mongoose";
import { BotOrgService } from "../../../services"; import { BotOrgService } from "../../../services";
import { SSOConfig } from "../../models"; import { SSOConfig } from "../../models";
import { import {
AuthMethod,
MembershipOrg, MembershipOrg,
User User
} from "../../../models"; } from "../../../models";
@@ -156,7 +157,7 @@ export const updateSSOConfig = async (req: Request, res: Response) => {
} }
}, },
{ {
authProvider: ssoConfig.authProvider authMethods: [ssoConfig.authProvider],
} }
); );
} else { } else {
@@ -167,9 +168,7 @@ export const updateSSOConfig = async (req: Request, res: Response) => {
} }
}, },
{ {
$unset: { authMethods: [AuthMethod.EMAIL],
authProvider: 1
}
} }
); );
} }
+2 -2
View File
@@ -408,9 +408,9 @@ export const validateProviderAuthToken = async ({
); );
if ( if (
decodedToken.authProvider !== user.authProvider || !user.authMethods.includes(decodedToken.authMethod) ||
decodedToken.email !== email decodedToken.email !== email
) { ) {
throw new Error("Invalid authentication credentials.") 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 ServiceAccountOrganizationPermission, { IServiceAccountOrganizationPermission } from "./serviceAccountOrganizationPermission"; // new
import ServiceAccountWorkspacePermission, { IServiceAccountWorkspacePermission } from "./serviceAccountWorkspacePermission"; // new import ServiceAccountWorkspacePermission, { IServiceAccountWorkspacePermission } from "./serviceAccountWorkspacePermission"; // new
import TokenData, { ITokenData } from "./tokenData"; import TokenData, { ITokenData } from "./tokenData";
import User, { AuthProvider, IUser } from "./user"; import User, { AuthMethod, IUser } from "./user";
import UserAction, { IUserAction } from "./userAction"; import UserAction, { IUserAction } from "./userAction";
import Workspace, { IWorkspace } from "./workspace"; import Workspace, { IWorkspace } from "./workspace";
import ServiceTokenData, { IServiceTokenData } from "./serviceTokenData"; import ServiceTokenData, { IServiceTokenData } from "./serviceTokenData";
@@ -28,7 +28,7 @@ import LoginSRPDetail, { ILoginSRPDetail } from "./loginSRPDetail";
import TokenVersion, { ITokenVersion } from "./tokenVersion"; import TokenVersion, { ITokenVersion } from "./tokenVersion";
export { export {
AuthProvider, AuthMethod,
BackupPrivateKey, BackupPrivateKey,
IBackupPrivateKey, IBackupPrivateKey,
Bot, Bot,
+12 -7
View File
@@ -1,6 +1,6 @@
import { Document, Schema, Types, model } from "mongoose"; import { Document, Schema, Types, model } from "mongoose";
export enum AuthProvider { export enum AuthMethod {
EMAIL = "email", EMAIL = "email",
GOOGLE = "google", GOOGLE = "google",
GITHUB = "github", GITHUB = "github",
@@ -11,8 +11,8 @@ export enum AuthProvider {
export interface IUser extends Document { export interface IUser extends Document {
_id: Types.ObjectId; _id: Types.ObjectId;
authId?: string; authProvider?: AuthMethod;
authProvider?: AuthProvider; authMethods: AuthMethod[];
email: string; email: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
@@ -36,12 +36,17 @@ export interface IUser extends Document {
const userSchema = new Schema<IUser>( const userSchema = new Schema<IUser>(
{ {
authId: { authProvider: { // TODO field: deprecate
type: String, type: String,
enum: AuthMethod,
}, },
authProvider: { authMethods: {
type: String, type: [{
enum: AuthProvider, type: String,
enum: AuthMethod,
}],
default: [AuthMethod.EMAIL],
required: true
}, },
email: { email: {
type: String, type: String,
+13 -9
View File
@@ -8,7 +8,7 @@ import { body, param } from "express-validator";
import { usersController } from "../../controllers/v2"; import { usersController } from "../../controllers/v2";
import { AuthMode } from "../../variables"; import { AuthMode } from "../../variables";
import { import {
AuthProvider AuthMethod
} from "../../models"; } from "../../models";
router.get( router.get(
@@ -40,18 +40,22 @@ router.patch(
usersController.updateName usersController.updateName
); );
router.patch( router.put(
"/me/auth-provider", "/me/auth-methods",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY],
}), }),
body("authProvider").exists().isString().isIn([ body("authMethods").exists().isArray({
AuthProvider.EMAIL, min: 1,
AuthProvider.GOOGLE, }).custom((authMethods: AuthMethod[]) => {
AuthProvider.GITHUB return authMethods.every(provider => [
]), AuthMethod.EMAIL,
AuthMethod.GOOGLE,
AuthMethod.GITHUB
].includes(provider))
}),
validateRequest, validateRequest,
usersController.updateAuthProvider usersController.updateAuthMethods,
); );
router.get( router.get(
+29 -21
View File
@@ -3,7 +3,7 @@ import passport from "passport";
import { Types } from "mongoose"; import { Types } from "mongoose";
import { AuthData } from "../interfaces/middleware"; import { AuthData } from "../interfaces/middleware";
import { import {
AuthProvider, AuthMethod,
MembershipOrg, MembershipOrg,
Organization, Organization,
ServiceAccount, ServiceAccount,
@@ -97,20 +97,19 @@ const initializePassport = async () => {
email email
}).select("+publicKey"); }).select("+publicKey");
if (user && user.authProvider !== AuthProvider.GOOGLE) {
done(InternalServerError());
}
if (!user) { if (!user) {
user = await new User({ user = await new User({
email, email,
authProvider: AuthProvider.GOOGLE, authMethods: [AuthMethod.GOOGLE],
authId: profile.id,
firstName: profile.name.givenName, firstName: profile.name.givenName,
lastName: profile.name.familyName lastName: profile.name.familyName
}).save(); }).save();
} }
if (!user.authMethods.includes(AuthMethod.GOOGLE)) {
done(InternalServerError());
}
const isUserCompleted = !!user.publicKey; const isUserCompleted = !!user.publicKey;
const providerAuthToken = createToken({ const providerAuthToken = createToken({
payload: { payload: {
@@ -118,7 +117,7 @@ const initializePassport = async () => {
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
authProvider: user.authProvider, authMethod: AuthMethod.GOOGLE,
isUserCompleted, isUserCompleted,
...(req.query.state ? { ...(req.query.state ? {
callbackPort: req.query.state as string callbackPort: req.query.state as string
@@ -150,20 +149,19 @@ const initializePassport = async () => {
let user = await User.findOne({ let user = await User.findOne({
email email
}).select("+publicKey"); }).select("+publicKey");
if (user && user.authProvider !== AuthProvider.GITHUB) {
done(InternalServerError());
}
if (!user) { if (!user) {
user = await new User({ user = await new User({
email: email, email: email,
authProvider: AuthProvider.GITHUB, authMethods: [AuthMethod.GITHUB],
authId: profile.id,
firstName: profile.displayName, firstName: profile.displayName,
lastName: "" lastName: ""
}).save(); }).save();
} }
if (!user.authMethods.includes(AuthMethod.GITHUB)) {
done(InternalServerError());
}
const isUserCompleted = !!user.publicKey; const isUserCompleted = !!user.publicKey;
const providerAuthToken = createToken({ const providerAuthToken = createToken({
@@ -172,7 +170,7 @@ const initializePassport = async () => {
email: user.email, email: user.email,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
authProvider: user.authProvider, authMethod: AuthMethod.GITHUB,
isUserCompleted, isUserCompleted,
...(req.query.state ? { ...(req.query.state ? {
callbackPort: req.query.state as string callbackPort: req.query.state as string
@@ -218,7 +216,7 @@ const initializePassport = async () => {
audience: await getSiteURL() audience: await getSiteURL()
}); });
if (ssoConfig.authProvider === AuthProvider.JUMPCLOUD_SAML) { if (ssoConfig.authProvider.toString() === AuthMethod.JUMPCLOUD_SAML.toString()) {
samlConfig.wantAuthnResponseSigned = false; samlConfig.wantAuthnResponseSigned = false;
} }
@@ -243,11 +241,21 @@ const initializePassport = async () => {
}).select("+publicKey"); }).select("+publicKey");
if (user) { 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( await User.findByIdAndUpdate(
user._id, user._id,
{ {
authProvider: req.ssoConfig.authProvider authMethods: [req.ssoConfig.authProvider]
}, },
{ {
new: true new: true
@@ -279,7 +287,7 @@ const initializePassport = async () => {
} else { } else {
user = await new User({ user = await new User({
email, email,
authProvider: req.ssoConfig.authProvider, authMethods: [req.ssoConfig.authProvider],
firstName, firstName,
lastName lastName
}).save(); }).save();
@@ -301,7 +309,7 @@ const initializePassport = async () => {
firstName, firstName,
lastName, lastName,
organizationName: organization?.name, organizationName: organization?.name,
authProvider: user.authProvider, authMethod: req.ssoConfig.authProvider,
isUserCompleted, isUserCompleted,
...(req.body.RelayState ? { ...(req.body.RelayState ? {
callbackPort: req.body.RelayState as string callbackPort: req.body.RelayState as string
+39
View File
@@ -11,6 +11,7 @@ import {
TrustedIP TrustedIP
} from "../../ee/models"; } from "../../ee/models";
import { import {
AuthMethod,
BackupPrivateKey, BackupPrivateKey,
Bot, Bot,
BotOrg, BotOrg,
@@ -21,6 +22,7 @@ import {
Secret, Secret,
SecretBlindIndexData, SecretBlindIndexData,
ServiceTokenData, ServiceTokenData,
User,
Workspace Workspace
} from "../../models"; } from "../../models";
import { generateKeyPair } from "../../utils/crypto"; import { generateKeyPair } from "../../utils/crypto";
@@ -631,3 +633,40 @@ export const backfillTrustedIps = async () => {
console.log("Backfill: Trusted IPs complete"); 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, backfillSecretVersions,
backfillServiceToken, backfillServiceToken,
backfillServiceTokenMultiScope, backfillServiceTokenMultiScope,
backfillTrustedIps backfillTrustedIps,
backfillUserAuthMethods
} from "./backfillData"; } from "./backfillData";
import { import {
reencryptBotOrgKeys, reencryptBotOrgKeys,
@@ -79,6 +80,7 @@ export const setup = async () => {
await backfillIntegration(); await backfillIntegration();
await backfillServiceTokenMultiScope(); await backfillServiceTokenMultiScope();
await backfillTrustedIps(); await backfillTrustedIps();
await backfillUserAuthMethods();
// re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY // re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY
// to base64 256-bit ROOT_ENCRYPTION_KEY // to base64 256-bit ROOT_ENCRYPTION_KEY
@@ -16,6 +16,7 @@ type Props = {
position?: "item-aligned" | "popper"; position?: "item-aligned" | "popper";
isDisabled?: boolean; isDisabled?: boolean;
icon?: IconProp; icon?: IconProp;
isMulti?: boolean;
}; };
export type SelectProps = Omit<SelectPrimitive.SelectProps, "disabled"> & Props; export type SelectProps = Omit<SelectPrimitive.SelectProps, "disabled"> & Props;
+2 -1
View File
@@ -17,4 +17,5 @@ export {
useRevokeMySessions, useRevokeMySessions,
useUpdateMfaEnabled, useUpdateMfaEnabled,
useUpdateOrgUserRole, useUpdateOrgUserRole,
useUpdateUserAuthProvider} from "./queries"; useUpdateUserAuthMethods
} from "./queries";
+8 -7
View File
@@ -13,14 +13,14 @@ import {
AddUserToWsDTO, AddUserToWsDTO,
AddUserToWsRes, AddUserToWsRes,
APIKeyData, APIKeyData,
AuthMethod,
CreateAPIKeyRes, CreateAPIKeyRes,
DeletOrgMembershipDTO, DeletOrgMembershipDTO,
OrgUser, OrgUser,
RenameUserDTO, RenameUserDTO,
TokenVersion, TokenVersion,
UpdateOrgUserRoleDTO, UpdateOrgUserRoleDTO,
User User} from "./types";
} from "./types";
const userKeys = { const userKeys = {
getUser: ["user"] as const, getUser: ["user"] as const,
@@ -61,17 +61,18 @@ export const useRenameUser = () => {
}); });
}; };
export const useUpdateUserAuthProvider = () => {
export const useUpdateUserAuthMethods = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async ({
authProvider authMethods
}: { }: {
authProvider: string; authMethods: AuthMethod[];
}) => { }) => {
const { data: { user } } = await apiRequest.patch("/api/v2/users/me/auth-provider", { const { data: { user } } = await apiRequest.put("/api/v2/users/me/auth-methods", {
authProvider authMethods
}); });
return user; return user;
+7 -3
View File
@@ -1,9 +1,12 @@
import { UserWsKeyPair } from "../keys/types"; import { UserWsKeyPair } from "../keys/types";
export enum AuthProvider { export enum AuthMethod {
EMAIL = "email", EMAIL = "email",
GOOGLE = "google", GOOGLE = "google",
OKTA_SAML = "okta-saml" GITHUB = "github",
OKTA_SAML = "okta-saml",
AZURE_SAML = "azure-saml",
JUMPCLOUD_SAML = "jumpcloud-saml"
} }
export type User = { export type User = {
@@ -12,7 +15,8 @@ export type User = {
email: string; email: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
authProvider?: AuthProvider; authProvider?: AuthMethod;
authMethods: AuthMethod[];
encryptionVersion?: number; encryptionVersion?: number;
protectedKey?: string; protectedKey?: string;
protectedKeyIV?: string; protectedKeyIV?: string;
@@ -1,30 +1,35 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { faGithub, faGoogle, IconDefinition } from "@fortawesome/free-brands-svg-icons";
import { faEnvelope } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { import { Switch } from "@app/components/v2";
Button,
FormControl,
Select,
SelectItem} from "@app/components/v2";
import { useUser } from "@app/context"; import { useUser } from "@app/context";
import { useUpdateUserAuthMethods } from "@app/hooks/api";
import { import {
useUpdateUserAuthProvider AuthMethod
} from "@app/hooks/api"; } from "@app/hooks/api/users/types";
const authMethods = [ interface AuthMethodOption {
{ label: "Email", value: "email" }, label: string,
{ label: "Google SSO", value: "google" }, value: AuthMethod,
{ label: "GitHub SSO", value: "github" }, icon: IconDefinition;
{ label: "Okta SAML", value: "okta-saml" }, }
{ label: "Azure SAML", value: "azure-saml" },
{ label: "JumpCloud SAML", value: "jumpcloud-saml" } const authMethodOpts: AuthMethodOption[] = [
{ label: "Email", value: AuthMethod.EMAIL, icon: faEnvelope },
{ label: "Google", value: AuthMethod.GOOGLE, icon: faGoogle },
{ label: "GitHub", value: AuthMethod.GITHUB, icon: faGithub }
]; ];
const samlProviders = [AuthMethod.OKTA_SAML, AuthMethod.JUMPCLOUD_SAML, AuthMethod.AZURE_SAML];
const schema = yup.object({ const schema = yup.object({
authMethod: yup.string().required("Auth method is required") authMethods: yup.array().required("Auth method is required")
}); });
export type FormData = yup.InferType<typeof schema>; export type FormData = yup.InferType<typeof schema>;
@@ -32,108 +37,102 @@ export type FormData = yup.InferType<typeof schema>;
export const AuthMethodSection = () => { export const AuthMethodSection = () => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { user } = useUser(); const { user } = useUser();
const { mutateAsync, isLoading } = useUpdateUserAuthProvider(); const { mutateAsync } = useUpdateUserAuthMethods();
const { const {
reset, reset,
control, setValue,
handleSubmit watch,
} = useForm<FormData>({ } = useForm<FormData>({
defaultValues: { defaultValues: {
authMethod: user?.authProvider ?? "email" authMethods: user.authMethods,
}, },
resolver: yupResolver(schema) resolver: yupResolver(schema)
}); });
const authMethods = watch("authMethods");
useEffect(() => { useEffect(() => {
if (user) { if (user) {
reset({ reset({
authMethod: user?.authProvider ?? "email" authMethods: user.authMethods,
}); });
} }
}, [user]); }, [user]);
const onAuthMethodToggle = async (value: boolean, authMethodOpt: AuthMethodOption) => {
const hasSamlEnabled = user.authMethods
.some((authMethod: AuthMethod) => samlProviders.includes(authMethod));
const onFormSubmit = async ({ if (hasSamlEnabled) {
authMethod
}: FormData) => {
try {
if (
authMethod === "okta-saml"
|| authMethod === "azure-saml"
|| authMethod === "jumpcloud-saml"
) {
createNotification({
text: "SAML authentication can only be configured in your organization settings",
type: "error"
});
return;
}
await mutateAsync({
authProvider: authMethod
});
createNotification({ createNotification({
text: "Successfully updated authentication method", text: "SAML authentication can only be configured in your organization settings",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update authentication method",
type: "error" type: "error"
}); });
} }
const newAuthMethods = value
? [...authMethods, authMethodOpt.value]
: authMethods.filter(auth => auth !== authMethodOpt.value);
if (value) {
const newUser = await mutateAsync({
authMethods: newAuthMethods
});
setValue("authMethods", newUser.authMethods);
createNotification({
text: "Successfully enabled authentication method",
type: "success"
});
return;
}
if (newAuthMethods.length === 0) {
createNotification({
text: "You must keep at least 1 authentication method enabled",
type: "error"
});
return;
}
const newUser = await mutateAsync({
authMethods: newAuthMethods
});
setValue("authMethods", newUser.authMethods);
createNotification({
text: "Successfully disabled authentication method",
type: "success"
});
} }
return ( return (
<form <div className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600">
className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600" <h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
onSubmit={handleSubmit(onFormSubmit)} Authentication methods
>
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
Authentication Method
</h2> </h2>
<div className="max-w-md mb-4"> <p className="text-gray-400 mb-4">
<Controller By enabling a SSO provider, you are allowing an account with that provider which uses the same email address as your existing Infisical account to be able to log in to Infisical.
control={control} </p>
name="authMethod" <div className="mb-4">
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( {user && authMethodOpts.map((authMethodOpt) => {
<FormControl return (
className="mb-0" <div className="flex p-4 items-center" key={`auth-method-${authMethodOpt.value}`}>
errorText={error?.message} <div className="flex items-center">
isError={Boolean(error)} <FontAwesomeIcon icon={authMethodOpt.icon} className="mr-4" />
> </div>
<Select <Switch
defaultValue={field.value} id={`enable-${authMethodOpt.value}-auth`}
{...field} onCheckedChange={(value) => onAuthMethodToggle(value, authMethodOpt)}
onValueChange={(e) => onChange(e)} isChecked={authMethods?.includes(authMethodOpt.value) ?? false}
className="w-full bg-mineshaft-800 border border-mineshaft-600"
> >
{authMethods.map((authMethod) => { <p className="w-12 mr-4">{authMethodOpt.label}</p>
return ( </Switch>
<SelectItem </div>
value={authMethod.value} );
key={`auth-method-${authMethod.value}`} })}
>
{authMethod.label}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
</div> </div>
<Button </div>
type="submit"
colorSchema="secondary"
isLoading={isLoading}
isDisabled={isLoading}
>
Save
</Button>
</form>
); );
} }