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

View File

@@ -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();
}

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

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?",
});
}
);
};

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,

View File

@@ -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],
}
);
}

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.")
}
}
}

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,

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,

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(

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

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"]
}
]
);
}

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

View File

@@ -16,6 +16,7 @@ type Props = {
position?: "item-aligned" | "popper";
isDisabled?: boolean;
icon?: IconProp;
isMulti?: boolean;
};
export type SelectProps = Omit<SelectPrimitive.SelectProps, "disabled"> & Props;

View File

@@ -17,4 +17,5 @@ export {
useRevokeMySessions,
useUpdateMfaEnabled,
useUpdateOrgUserRole,
useUpdateUserAuthProvider} from "./queries";
useUpdateUserAuthMethods
} from "./queries";

View File

@@ -13,14 +13,14 @@ import {
AddUserToWsDTO,
AddUserToWsRes,
APIKeyData,
AuthMethod,
CreateAPIKeyRes,
DeletOrgMembershipDTO,
OrgUser,
RenameUserDTO,
TokenVersion,
UpdateOrgUserRoleDTO,
User
} from "./types";
User} from "./types";
const userKeys = {
getUser: ["user"] as const,
@@ -61,17 +61,18 @@ export const useRenameUser = () => {
});
};
export const useUpdateUserAuthProvider = () => {
export const useUpdateUserAuthMethods = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
authProvider
authMethods
}: {
authProvider: string;
authMethods: AuthMethod[];
}) => {
const { data: { user } } = await apiRequest.patch("/api/v2/users/me/auth-provider", {
authProvider
const { data: { user } } = await apiRequest.put("/api/v2/users/me/auth-methods", {
authMethods
});
return user;

View File

@@ -1,9 +1,12 @@
import { UserWsKeyPair } from "../keys/types";
export enum AuthProvider {
export enum AuthMethod {
EMAIL = "email",
GOOGLE = "google",
OKTA_SAML = "okta-saml"
GITHUB = "github",
OKTA_SAML = "okta-saml",
AZURE_SAML = "azure-saml",
JUMPCLOUD_SAML = "jumpcloud-saml"
}
export type User = {
@@ -12,7 +15,8 @@ export type User = {
email: string;
firstName?: string;
lastName?: string;
authProvider?: AuthProvider;
authProvider?: AuthMethod;
authMethods: AuthMethod[];
encryptionVersion?: number;
protectedKey?: string;
protectedKeyIV?: string;

View File

@@ -1,30 +1,35 @@
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 * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
FormControl,
Select,
SelectItem} from "@app/components/v2";
import { Switch } from "@app/components/v2";
import { useUser } from "@app/context";
import { useUpdateUserAuthMethods } from "@app/hooks/api";
import {
useUpdateUserAuthProvider
} from "@app/hooks/api";
AuthMethod
} from "@app/hooks/api/users/types";
const authMethods = [
{ label: "Email", value: "email" },
{ label: "Google SSO", value: "google" },
{ label: "GitHub SSO", value: "github" },
{ label: "Okta SAML", value: "okta-saml" },
{ label: "Azure SAML", value: "azure-saml" },
{ label: "JumpCloud SAML", value: "jumpcloud-saml" }
interface AuthMethodOption {
label: string,
value: AuthMethod,
icon: IconDefinition;
}
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({
authMethod: yup.string().required("Auth method is required")
authMethods: yup.array().required("Auth method is required")
});
export type FormData = yup.InferType<typeof schema>;
@@ -32,108 +37,102 @@ export type FormData = yup.InferType<typeof schema>;
export const AuthMethodSection = () => {
const { createNotification } = useNotificationContext();
const { user } = useUser();
const { mutateAsync, isLoading } = useUpdateUserAuthProvider();
const { mutateAsync } = useUpdateUserAuthMethods();
const {
reset,
control,
handleSubmit
setValue,
watch,
} = useForm<FormData>({
defaultValues: {
authMethod: user?.authProvider ?? "email"
authMethods: user.authMethods,
},
resolver: yupResolver(schema)
});
const authMethods = watch("authMethods");
useEffect(() => {
if (user) {
reset({
authMethod: user?.authProvider ?? "email"
authMethods: user.authMethods,
});
}
}, [user]);
const onAuthMethodToggle = async (value: boolean, authMethodOpt: AuthMethodOption) => {
const hasSamlEnabled = user.authMethods
.some((authMethod: AuthMethod) => samlProviders.includes(authMethod));
const onFormSubmit = async ({
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
});
if (hasSamlEnabled) {
createNotification({
text: "Successfully updated authentication method",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update authentication method",
text: "SAML authentication can only be configured in your organization settings",
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 (
<form
className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600"
onSubmit={handleSubmit(onFormSubmit)}
>
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
Authentication Method
<div 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">
Authentication methods
</h2>
<div className="max-w-md mb-4">
<Controller
control={control}
name="authMethod"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full bg-mineshaft-800 border border-mineshaft-600"
<p className="text-gray-400 mb-4">
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.
</p>
<div className="mb-4">
{user && authMethodOpts.map((authMethodOpt) => {
return (
<div className="flex p-4 items-center" key={`auth-method-${authMethodOpt.value}`}>
<div className="flex items-center">
<FontAwesomeIcon icon={authMethodOpt.icon} className="mr-4" />
</div>
<Switch
id={`enable-${authMethodOpt.value}-auth`}
onCheckedChange={(value) => onAuthMethodToggle(value, authMethodOpt)}
isChecked={authMethods?.includes(authMethodOpt.value) ?? false}
>
{authMethods.map((authMethod) => {
return (
<SelectItem
value={authMethod.value}
key={`auth-method-${authMethod.value}`}
>
{authMethod.label}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<p className="w-12 mr-4">{authMethodOpt.label}</p>
</Switch>
</div>
);
})}
</div>
<Button
type="submit"
colorSchema="secondary"
isLoading={isLoading}
isDisabled={isLoading}
>
Save
</Button>
</form>
</div>
);
}
}