feat: cli login via browser

This commit is contained in:
quinton11
2023-06-14 19:12:56 +00:00
parent 4b99a9ea93
commit 9e9129dd02
9 changed files with 367 additions and 55 deletions

View File

@@ -5,6 +5,7 @@ go 1.19
require (
github.com/99designs/keyring v1.2.2
github.com/charmbracelet/lipgloss v0.5.0
github.com/denisbrodbeck/machineid v1.0.1
github.com/fatih/semgroup v1.2.0
github.com/gitleaks/go-gitdiff v0.8.0
github.com/h2non/filetype v1.1.3
@@ -14,6 +15,7 @@ require (
github.com/muesli/reflow v0.3.0
github.com/muesli/roff v0.1.0
github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9
github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a
github.com/rs/zerolog v1.26.1
github.com/spf13/cobra v1.6.1
github.com/spf13/viper v1.8.1
@@ -28,7 +30,6 @@ require (
github.com/chzyer/readline v1.5.1 // indirect
github.com/danieljoos/wincred v1.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denisbrodbeck/machineid v1.0.1 // indirect
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/go-openapi/errors v0.20.2 // indirect
@@ -47,9 +48,10 @@ require (
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/pelletier/go-toml v1.9.3 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/posthog/posthog-go v0.0.0-20221221115252-24dfed35d71a // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rs/cors v1.9.0 // indirect
github.com/spf13/afero v1.6.0 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect

View File

@@ -283,6 +283,8 @@ github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5d
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9 h1:lL+y4Xv20pVlCGyLzNHRC0I0rIHhIL1lTvHizoS/dU8=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20211021192214-5ab2d9280aa9/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
@@ -297,6 +299,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/cors v1.9.0 h1:l9HGsTsHJcvW14Nk7J9KFz8bzeAWXn3CG6bgt7LsrAE=
github.com/rs/cors v1.9.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc=
github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=
@@ -520,6 +524,7 @@ golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -6,10 +6,14 @@ package cmd
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"strings"
"time"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"regexp"
@@ -22,7 +26,9 @@ import (
"github.com/fatih/color"
"github.com/go-resty/resty/v2"
"github.com/manifoldco/promptui"
"github.com/pkg/browser"
"github.com/posthog/posthog-go"
"github.com/rs/cors"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"golang.org/x/crypto/argon2"
@@ -90,16 +96,43 @@ var loginCmd = &cobra.Command{
}
}
email, password, err := askForLoginCredentials()
var (
email string
password string
loginOneResponse *api.GetLoginOneV2Response
loginTwoResponse *api.GetLoginTwoV2Response
)
//check for --browser flag
browserLogin, err := cmd.Flags().GetBool("browser")
if err != nil {
util.HandleError(err, "Unable to parse email and password for authentication")
util.HandleError(err, "Unable to parse browser flag")
}
loginOneResponse, loginTwoResponse, err := getFreshUserCredentials(email, password)
if err != nil {
fmt.Println("Unable to authenticate with the provided credentials, please try again")
log.Debug().Err(err)
return
if browserLogin {
log.Debug().Msg("Login via browser")
//call browser login function
loginResponse, err := browserCliLogin()
if err != nil {
util.HandleError(err, err.Error())
}
email = loginResponse.Email
password = loginResponse.Password
loginOneResponse = &loginResponse.LoginOneResponse
loginTwoResponse = &loginResponse.LoginTwoResponse
//util.HandleError(errors.New("not implemented"), " login via browser not fully implemented")
} else {
email, password, err = askForLoginCredentials()
if err != nil {
util.HandleError(err, "Unable to parse email and password for authentication")
}
loginOneResponse, loginTwoResponse, err = getFreshUserCredentials(email, password)
if err != nil {
fmt.Println("Unable to authenticate with the provided credentials, please try again")
log.Debug().Err(err)
return
}
}
if loginTwoResponse.MfaEnabled {
@@ -282,6 +315,7 @@ var loginCmd = &cobra.Command{
func init() {
rootCmd.AddCommand(loginCmd)
loginCmd.Flags().BoolP("browser", "b", false, "Login via browser")
}
func DomainOverridePrompt() (bool, error) {
@@ -326,7 +360,8 @@ func askForDomain() error {
if selectedHostingOption == INFISICAL_CLOUD {
//cloud option
config.INFISICAL_URL = util.INFISICAL_DEFAULT_API_URL
config.INFISICAL_URL = fmt.Sprintf("%s/api", util.INFISICAL_DEFAULT_URL)
config.INFISICAL_LOGIN_URL = fmt.Sprintf("%s/login", util.INFISICAL_DEFAULT_URL)
return nil
}
@@ -341,7 +376,7 @@ func askForDomain() error {
domainPrompt := promptui.Prompt{
Label: "Domain",
Validate: urlValidation,
Default: "Example - https://my-self-hosted-instance.com/api",
Default: "Example - https://my-self-hosted-instance.com",
}
domain, err := domainPrompt.Run()
@@ -349,8 +384,9 @@ func askForDomain() error {
return err
}
//set api url
config.INFISICAL_URL = domain
//set api and login url
config.INFISICAL_URL = fmt.Sprintf("%s/api", domain)
config.INFISICAL_LOGIN_URL = fmt.Sprintf("%s/login", domain)
//return nil
return nil
}
@@ -476,3 +512,91 @@ func askForMFACode() string {
return mfaVerifyCode
}
type CliLoginResponse struct {
Email string `json:"email"`
Password string `json:"password"`
LoginOneResponse api.GetLoginOneV2Response `json:"loginOneResponse"`
LoginTwoResponse api.GetLoginTwoV2Response `json:"loginTwoResponse"`
}
// Manages the browser login flow
// returns a CLILoginResponse on success and an error on failure
func browserCliLogin() (CliLoginResponse, error) {
//create listener
listener, err := net.Listen("tcp", ":0")
if err != nil {
return CliLoginResponse{}, err
}
//get callback port
callbackPort := listener.Addr().(*net.TCPAddr).Port
url := fmt.Sprintf("%s?callback_port=%d", config.INFISICAL_LOGIN_URL, callbackPort)
//open browser and login
err = browser.OpenURL(url)
if err != nil {
return CliLoginResponse{}, err
}
//flow channels
success := make(chan CliLoginResponse)
failure := make(chan error)
timeout := time.After(time.Second * 60)
//create handler
c := cors.New(cors.Options{
AllowedOrigins: []string{strings.ReplaceAll(config.INFISICAL_LOGIN_URL, "/login", "")},
AllowCredentials: true,
AllowedMethods: []string{"POST", "OPTIONS"},
AllowedHeaders: []string{"Content-Type"},
Debug: false,
})
corsHandler := c.Handler(browserLoginHandler(success, failure))
log.Debug().Msgf("Callback server listening on port %d", callbackPort)
go http.Serve(listener, corsHandler)
for {
select {
case loginResponse := <-success:
err = closeListener(&listener)
return loginResponse, nil
case err = <-failure:
err = closeListener(&listener)
return CliLoginResponse{}, err
case _ = <-timeout:
err = closeListener(&listener)
return CliLoginResponse{}, errors.New("server timeout")
}
}
}
func closeListener(listener *net.Listener) error {
err := (*listener).Close()
if err != nil {
return err
}
log.Debug().Msg("Callback server shutdown successfully")
return nil
}
func browserLoginHandler(success chan CliLoginResponse, failure chan error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var loginResponse CliLoginResponse
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(&loginResponse)
if err != nil {
failure <- err
}
w.WriteHeader(http.StatusOK)
success <- loginResponse
}
}

View File

@@ -2,3 +2,4 @@ package config
var INFISICAL_URL string
var INFISICAL_URL_MANUAL_OVERRIDE string
var INFISICAL_LOGIN_URL string

View File

@@ -4,6 +4,7 @@ const (
CONFIG_FILE_NAME = "infisical-config.json"
CONFIG_FOLDER_NAME = ".infisical"
INFISICAL_DEFAULT_API_URL = "https://app.infisical.com/api"
INFISICAL_DEFAULT_URL = "https://app.infisical.com"
INFISICAL_WORKSPACE_CONFIG_FILE_NAME = ".infisical.json"
INFISICAL_TOKEN_NAME = "INFISICAL_TOKEN"
SECRET_TYPE_PERSONAL = "personal"

View File

@@ -11,7 +11,7 @@ const ContentSecurityPolicy = `
style-src 'self' https://rsms.me 'unsafe-inline';
child-src https://api.stripe.com;
frame-src https://js.stripe.com/ https://api.stripe.com;
connect-src 'self' https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com;
connect-src 'self' https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com http://localhost:*;
img-src 'self' https://*.stripe.com https://i.ytimg.com/ data:;
media-src;
font-src 'self' https://maxcdn.bootstrapcdn.com https://rsms.me https://fonts.gstatic.com;

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import Link from 'next/link';
import { useRouter } from 'next/router';
import axios from "axios"
import attemptLogin from '@app/components/utilities/attemptLogin';
@@ -9,6 +10,7 @@ import Error from '../basic/Error';
// import { faGoogle } from '@fortawesome/free-brands-svg-icons';
// import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Input } from '../v2';
import attemptCliLogin from '../utilities/attemptCliLogin';
export default function InitialLoginStep({
setStep,
@@ -31,33 +33,60 @@ export default function InitialLoginStep({
const handleLogin = async () => {
try {
if (!email || !password) {
return;
}
setIsLoading(true);
const isLoginSuccessful = await attemptLogin({
email,
password,
});
if (isLoginSuccessful && isLoginSuccessful.success) {
// case: login was successful
if (isLoginSuccessful.mfaEnabled) {
// case: login requires MFA step
setStep(2);
setIsLoading(false);
return;
if (!email || !password) {
return;
}
// case: login does not require MFA step
router.push(`/dashboard/${localStorage.getItem('projectData.id')}`);
}
setIsLoading(true);
const queryParams = new URLSearchParams(location.search)
if (queryParams) {
const callbackPort = queryParams.get("callback_port")
//attemptCliLogin
const isCliLoginSuccessful = await attemptCliLogin({
email,
password,
})
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
// case: login was successful
const cliUrl = `http://localhost:${callbackPort}`
//send request to server endpoint
const instance = axios.create()
const cliResp = await instance.post(cliUrl,{...isCliLoginSuccessful.loginResponse,email,password})
//cli page
router.push("/cli-redirect");
//on success, router.push to cli Login Successful page
}
} else {
const isLoginSuccessful = await attemptLogin({
email,
password,
});
if (isLoginSuccessful && isLoginSuccessful.success) {
// case: login was successful
if (isLoginSuccessful.mfaEnabled) {
// case: login requires MFA step
setStep(2);
setIsLoading(false);
return;
}
// case: login does not require MFA step
router.push(`/dashboard/${localStorage.getItem('projectData.id')}`);
}
}
} catch (err) {
setLoginError(true);
setLoginError(true);
}
setIsLoading(false);
}
@@ -90,18 +119,18 @@ export default function InitialLoginStep({
</div>
</div>
<div className="relative pt-2 md:pt-0 md:px-1.5 flex items-center justify-center lg:w-1/6 w-1/4 min-w-[20rem] md:min-w-[22rem] mx-auto w-full rounded-lg max-h-24 md:max-h-28">
<div className="flex items-center justify-center w-full md:p-2 rounded-lg max-h-24 md:max-h-28">
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="Enter your password..."
isRequired
autoComplete="current-password"
id="current-password"
className="h-12 select:-webkit-autofill:focus"
/>
</div>
<div className="flex items-center justify-center w-full md:p-2 rounded-lg max-h-24 md:max-h-28">
<Input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
placeholder="Enter your password..."
isRequired
autoComplete="current-password"
id="current-password"
className="h-12 select:-webkit-autofill:focus"
/>
</div>
</div>
{!isLoading && loginError && <Error text={t('login.error-login') ?? ''} />}
<div className='lg:w-1/6 w-1/4 min-w-[21.2rem] md:min-w-[20.1rem] text-center rounded-md mt-4'>
@@ -116,18 +145,18 @@ export default function InitialLoginStep({
> Login </Button>
</div>
<div className='lg:w-1/6 w-1/4 min-w-[20rem] flex flex-row items-center mt-4 py-2'>
<div className='w-1/2 border-t border-mineshaft-500'/>
<div className='w-1/2 border-t border-mineshaft-500' />
<span className='px-4 text-sm text-bunker-400'>or</span>
<div className='w-1/2 border-t border-mineshaft-500'/>
<div className='w-1/2 border-t border-mineshaft-500' />
</div>
<div className='lg:w-1/6 w-1/4 min-w-[20rem] text-center rounded-md mt-4'>
<Button
colorSchema="primary"
colorSchema="primary"
variant="outline_bg"
onClick={() => router.push("/saml-sso")}
onClick={() => router.push("/saml-sso")}
isFullWidth
className="h-14 w-full mx-0"
>
>
Continue with SAML SSO
</Button>
</div>

View File

@@ -0,0 +1,124 @@
/* eslint-disable prefer-destructuring */
import jsrp from 'jsrp';
import login1 from '@app/pages/api/auth/Login1';
import login2 from '@app/pages/api/auth/Login2';
import getOrganizations from '@app/pages/api/organization/getOrgs';
import getOrganizationUserProjects from '@app/pages/api/organization/GetOrgUserProjects';
import KeyService from '@app/services/KeyService';
import Telemetry from './telemetry/Telemetry';
import { saveTokenToLocalStorage } from './saveTokenToLocalStorage';
import SecurityClient from './SecurityClient';
// eslint-disable-next-line new-cap
const client = new jsrp.client();
interface IsCliLoginSuccessful {
loginResponse: {
loginOneResponse: {
serverPublicKey: string;
salt: string;
};
loginTwoResponse: {
mfaEnabled: boolean;
token: string;
encryptionVersion?: number;
protectedKey?: string;
protectedKeyIV?: string;
protectedKeyTag?: string;
publicKey?: string;
encryptedPrivateKey?: string;
iv?: string;
tag?: string;
};
};
success: boolean;
}
/**
* Return whether or not login is successful for user with email [email]
* and password [password]
* @param {string} email - email of user to log in
* @param {string} password - password of user to log in
*/
const attemptLogin = async (
{
email,
password,
providerAuthToken,
}: {
email: string;
password: string;
providerAuthToken?: string;
}
): Promise<IsCliLoginSuccessful> => {
const telemetry = new Telemetry().getInstance();
return new Promise((resolve, reject) => {
client.init(
{
username: email,
password
},
async () => {
try {
const clientPublicKey = client.getPublicKey();
const { serverPublicKey, salt } = await login1({
email,
clientPublicKey,
providerAuthToken,
});
client.setSalt(salt);
client.setServerPublicKey(serverPublicKey);
const clientProof = client.getProof(); // called M1
const {
mfaEnabled,
encryptionVersion,
protectedKey,
protectedKeyIV,
protectedKeyTag,
token,
publicKey,
encryptedPrivateKey,
iv,
tag
} = await login2(
{
email,
clientProof,
providerAuthToken,
}
);
resolve({
loginResponse: {
loginOneResponse: { serverPublicKey, salt },
loginTwoResponse: {
mfaEnabled,
encryptionVersion,
protectedKey,
protectedKeyIV,
protectedKeyTag,
token,
publicKey,
encryptedPrivateKey,
iv,
tag
}
},
success: true
})
} catch (err) {
reject(err);
}
}
);
});
};
export default attemptLogin;

View File

@@ -0,0 +1,26 @@
import Head from 'next/head';
import Image from 'next/image';
import Link from 'next/link';
export default function CliRedirect() {
return (
<div className='bg-bunker-800 md:h-screen flex flex-col justify-between'>
<Head>
<title>Infisical Cli | Login Successful!</title>
<link rel='icon' href='/infisical.ico' />
</Head>
<div className='flex flex-col items-center justify-center text-gray-200 h-screen w-screen'>
<p className='text-4xl mt-32'>Head back to your terminal!</p>
<p className='mt-2 mb-1 text-lg'>
You've successfully logged into infisical-cli
</p>
<Image
src='/images/dragon-404.svg'
height={554}
width={942}
alt='infisical dragon - page not found'
/>
</div>
</div>
);
}