As we reevaluate how to best support and maintain Staging Ref in the future, we encourage development teams using this environment to highlight their use cases in the following issue: https://gitlab.com/gitlab-com/gl-infra/software-delivery/framework/software-delivery-framework-issue-tracker/-/issues/36.

Skip to content
Snippets Groups Projects
Commit fe5feeea authored by kmcknight's avatar kmcknight Committed by Igor Drozdov
Browse files

Implement Push Auth support for 2FA verification

When `2fa_verify` command is executed:

- A user is asked to enter OTP
- A blocking call for push auth is performed

Then:

- If the push auth request fails, the user is still able to enter
OTP
- If OTP is invalid, the `2fa_verify` command ends the execution
- If OTP is valid or push auth request succeeded, then the user is
successfully authenticated
- If 30 seconds passed while no OTP or Push have been provided,
then the `2fa_verify` command ends the execution
parent 9cacca57
No related branches found
No related tags found
No related merge requests found
Loading
Loading
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"time"
"gitlab.com/gitlab-org/labkit/log"
Loading
Loading
@@ -15,23 +16,96 @@ import (
type Command struct {
Config *config.Config
Client *twofactorverify.Client
Args *commandargs.Shell
ReadWriter *readwriter.ReadWriter
}
type Result struct {
Error error
Status string
Success bool
}
func (c *Command) Execute(ctx context.Context) error {
ctxlog := log.ContextLogger(ctx)
ctxlog.Info("twofactorverify: execute: waiting for user input")
otp := c.getOTP(ctx)
ctxlog.Info("twofactorverify: execute: verifying entered OTP")
err := c.verifyOTP(ctx, otp)
// config.GetHTTPClient isn't thread-safe so save Client in struct for concurrency
// workaround until #518 is fixed
var err error
c.Client, err = twofactorverify.NewClient(c.Config)
if err != nil {
ctxlog.WithError(err).Error("twofactorverify: execute: OTP verification failed")
return err
}
ctxlog.WithError(err).Info("twofactorverify: execute: OTP verified")
// Create timeout context
// TODO: make timeout configurable
const ctxTimeout = 30
timeoutCtx, cancelTimeout := context.WithTimeout(ctx, ctxTimeout*time.Second)
defer cancelTimeout()
// Background push notification with timeout
pushauth := make(chan Result)
go func() {
defer close(pushauth)
ctxlog.Info("twofactorverify: execute: waiting for push auth")
status, success, err := c.pushAuth(timeoutCtx)
ctxlog.WithError(err).Info("twofactorverify: execute: push auth verified")
select {
case <-timeoutCtx.Done(): // push cancelled by manual OTP
// skip writing to channel
ctxlog.Info("twofactorverify: execute: push auth cancelled")
default:
pushauth <- Result{Error: err, Status: status, Success: success}
}
}()
// Also allow manual OTP entry while waiting for push, with same timeout as push
verify := make(chan Result)
go func() {
defer close(verify)
ctxlog.Info("twofactorverify: execute: waiting for user input")
answer := ""
answer = c.getOTP(timeoutCtx)
ctxlog.Info("twofactorverify: execute: user input received")
select {
case <-timeoutCtx.Done(): // manual OTP cancelled by push
// skip writing to channel
ctxlog.Info("twofactorverify: execute: verify cancelled")
default:
ctxlog.Info("twofactorverify: execute: verifying entered OTP")
status, success, err := c.verifyOTP(timeoutCtx, answer)
ctxlog.WithError(err).Info("twofactorverify: execute: OTP verified")
verify <- Result{Error: err, Status: status, Success: success}
}
}()
for {
select {
case res := <-verify:
if res.Status == "" {
// channel closed; don't print anything
} else {
fmt.Fprint(c.ReadWriter.Out, res.Status)
return nil
}
case res := <-pushauth:
if res.Status == "" {
// channel closed; don't print anything
} else {
fmt.Fprint(c.ReadWriter.Out, res.Status)
return nil
}
case <-timeoutCtx.Done(): // push timed out
fmt.Fprint(c.ReadWriter.Out, "\nOTP verification timed out\n")
return nil
}
}
return nil
}
Loading
Loading
@@ -49,18 +123,38 @@ func (c *Command) getOTP(ctx context.Context) string {
return answer
}
func (c *Command) verifyOTP(ctx context.Context, otp string) error {
client, err := twofactorverify.NewClient(c.Config)
if err != nil {
return err
func (c *Command) verifyOTP(ctx context.Context, otp string) (status string, success bool, err error) {
reason := ""
success, reason, err = c.Client.VerifyOTP(ctx, c.Args, otp)
if success {
status = fmt.Sprintf("\nOTP validation successful. Git operations are now allowed.\n")
} else {
if err != nil {
status = fmt.Sprintf("\nOTP validation failed.\n%v\n", err)
} else {
status = fmt.Sprintf("\nOTP validation failed.\n%v\n", reason)
}
}
err = client.VerifyOTP(ctx, c.Args, otp)
if err == nil {
fmt.Fprint(c.ReadWriter.Out, "\nOTP validation successful. Git operations are now allowed.\n")
err = nil
return
}
func (c *Command) pushAuth(ctx context.Context) (status string, success bool, err error) {
reason := ""
success, reason, err = c.Client.PushAuth(ctx, c.Args)
if success {
status = fmt.Sprintf("\nPush OTP validation successful. Git operations are now allowed.\n")
} else {
fmt.Fprintf(c.ReadWriter.Out, "\nOTP validation failed.\n%v\n", err)
if err != nil {
status = fmt.Sprintf("\nPush OTP validation failed.\n%v\n", err)
} else {
status = fmt.Sprintf("\nPush OTP validation failed.\n%v\n", reason)
}
}
return nil
return
}
Loading
Loading
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
Loading
Loading
@@ -17,10 +18,10 @@ import (
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/twofactorverify"
)
func setup(t *testing.T) []testserver.TestRequestHandler {
func setupManual(t *testing.T) []testserver.TestRequestHandler {
requests := []testserver.TestRequestHandler{
{
Path: "/api/v4/internal/two_factor_otp_check",
Path: "/api/v4/internal/two_factor_manual_otp_check",
Handler: func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
defer r.Body.Close()
Loading
Loading
@@ -30,14 +31,15 @@ func setup(t *testing.T) []testserver.TestRequestHandler {
var requestBody *twofactorverify.RequestBody
require.NoError(t, json.Unmarshal(b, &requestBody))
var body map[string]interface{}
switch requestBody.KeyId {
case "1":
body := map[string]interface{}{
body = map[string]interface{}{
"success": true,
}
json.NewEncoder(w).Encode(body)
case "error":
body := map[string]interface{}{
body = map[string]interface{}{
"success": false,
"message": "error message",
}
Loading
Loading
@@ -47,18 +49,55 @@ func setup(t *testing.T) []testserver.TestRequestHandler {
}
},
},
{
Path: "/api/v4/internal/two_factor_push_otp_check",
Handler: func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
defer r.Body.Close()
require.NoError(t, err)
var requestBody *twofactorverify.RequestBody
require.NoError(t, json.Unmarshal(b, &requestBody))
time.Sleep(5 * time.Second)
var body map[string]interface{}
switch requestBody.KeyId {
case "1":
body = map[string]interface{}{
"success": true,
}
json.NewEncoder(w).Encode(body)
case "error":
body = map[string]interface{}{
"success": false,
"message": "error message",
}
require.NoError(t, json.NewEncoder(w).Encode(body))
case "broken":
w.WriteHeader(http.StatusInternalServerError)
default:
body = map[string]interface{}{
"success": true,
"message": "default message",
}
json.NewEncoder(w).Encode(body)
}
},
},
}
return requests
}
const (
question = "OTP: \n"
errorHeader = "OTP validation failed.\n"
manualQuestion = "OTP: \n"
manualErrorHeader = "OTP validation failed.\n"
)
func TestExecute(t *testing.T) {
requests := setup(t)
func TestExecuteManual(t *testing.T) {
requests := setupManual(t)
url := testserver.StartSocketHttpServer(t, requests)
Loading
Loading
@@ -69,41 +108,35 @@ func TestExecute(t *testing.T) {
expectedOutput string
}{
{
desc: "With a known key id",
arguments: &commandargs.Shell{GitlabKeyId: "1"},
answer: "123456\n",
expectedOutput: question +
"OTP validation successful. Git operations are now allowed.\n",
desc: "With a known key id",
arguments: &commandargs.Shell{GitlabKeyId: "1"},
answer: "123456\n",
expectedOutput: manualQuestion + "OTP validation successful. Git operations are now allowed.\n",
},
{
desc: "With bad response",
arguments: &commandargs.Shell{GitlabKeyId: "-1"},
answer: "123456\n",
expectedOutput: question + errorHeader + "Parsing failed\n",
expectedOutput: manualQuestion + manualErrorHeader + "Parsing failed\n",
},
{
desc: "With API returns an error",
arguments: &commandargs.Shell{GitlabKeyId: "error"},
answer: "yes\n",
expectedOutput: question + errorHeader + "error message\n",
expectedOutput: manualQuestion + manualErrorHeader + "error message\n",
},
{
desc: "With API fails",
arguments: &commandargs.Shell{GitlabKeyId: "broken"},
answer: "yes\n",
expectedOutput: question + errorHeader + "Internal API error (500)\n",
},
{
desc: "With missing arguments",
arguments: &commandargs.Shell{},
answer: "yes\n",
expectedOutput: question + errorHeader + "who='' is invalid\n",
expectedOutput: manualQuestion + manualErrorHeader + "Internal API error (500)\n",
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
output := &bytes.Buffer{}
input := bytes.NewBufferString(tc.answer)
cmd := &Command{
Loading
Loading
package twofactorverify
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/readwriter"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/config"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/twofactorverify"
)
func setupPush(t *testing.T) []testserver.TestRequestHandler {
requests := []testserver.TestRequestHandler{
{
Path: "/api/v4/internal/two_factor_push_otp_check",
Handler: func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
defer r.Body.Close()
require.NoError(t, err)
var requestBody *twofactorverify.RequestBody
require.NoError(t, json.Unmarshal(b, &requestBody))
var body map[string]interface{}
switch requestBody.KeyId {
case "1":
body = map[string]interface{}{
"success": true,
}
json.NewEncoder(w).Encode(body)
case "error":
body = map[string]interface{}{
"success": false,
"message": "error message",
}
require.NoError(t, json.NewEncoder(w).Encode(body))
case "broken":
w.WriteHeader(http.StatusInternalServerError)
}
},
},
{
Path: "/api/v4/internal/two_factor_manual_otp_check",
Handler: func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
defer r.Body.Close()
require.NoError(t, err)
var requestBody *twofactorverify.RequestBody
require.NoError(t, json.Unmarshal(b, &requestBody))
time.Sleep(20 * time.Second)
var body map[string]interface{}
switch requestBody.KeyId {
case "1":
body = map[string]interface{}{
"success": true,
}
json.NewEncoder(w).Encode(body)
case "error":
body = map[string]interface{}{
"success": false,
"message": "error message",
}
require.NoError(t, json.NewEncoder(w).Encode(body))
case "broken":
w.WriteHeader(http.StatusInternalServerError)
}
},
},
}
return requests
}
const (
pushQuestion = "OTP: \n"
pushErrorHeader = "Push OTP validation failed.\n"
)
func TestExecutePush(t *testing.T) {
requests := setupPush(t)
url := testserver.StartSocketHttpServer(t, requests)
testCases := []struct {
desc string
arguments *commandargs.Shell
answer string
expectedOutput string
}{
{
desc: "When push is provided",
arguments: &commandargs.Shell{GitlabKeyId: "1"},
answer: "",
expectedOutput: pushQuestion + "Push OTP validation successful. Git operations are now allowed.\n",
},
{
desc: "With API returns an error",
arguments: &commandargs.Shell{GitlabKeyId: "error"},
answer: "",
expectedOutput: pushQuestion + pushErrorHeader + "error message\n",
},
{
desc: "With API fails",
arguments: &commandargs.Shell{GitlabKeyId: "broken"},
answer: "",
expectedOutput: pushQuestion + pushErrorHeader + "Internal API error (500)\n",
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
output := &bytes.Buffer{}
var input io.Reader
// make input wait for push auth tests
input, _ = io.Pipe()
cmd := &Command{
Config: &config.Config{GitlabUrl: url},
Args: tc.arguments,
ReadWriter: &readwriter.ReadWriter{Out: output, In: input},
}
err := cmd.Execute(context.Background())
require.NoError(t, err)
require.Equal(t, tc.expectedOutput, output.String())
})
}
}
Loading
Loading
@@ -2,7 +2,6 @@ package twofactorverify
import (
"context"
"errors"
"fmt"
"net/http"
Loading
Loading
@@ -38,32 +37,48 @@ func NewClient(config *config.Config) (*Client, error) {
return &Client{config: config, client: client}, nil
}
func (c *Client) VerifyOTP(ctx context.Context, args *commandargs.Shell, otp string) error {
func (c *Client) VerifyOTP(ctx context.Context, args *commandargs.Shell, otp string) (bool, string, error) {
requestBody, err := c.getRequestBody(ctx, args, otp)
if err != nil {
return err
return false, "", err
}
response, err := c.client.Post(ctx, "/two_factor_otp_check", requestBody)
response, err := c.client.Post(ctx, "/two_factor_manual_otp_check", requestBody)
if err != nil {
return err
return false, "", err
}
defer response.Body.Close()
return parse(response)
}
func parse(hr *http.Response) error {
func (c *Client) PushAuth(ctx context.Context, args *commandargs.Shell) (bool, string, error) {
// enable push auth in internal rest api
requestBody, err := c.getRequestBody(ctx, args, "")
if err != nil {
return false, "", err
}
response, err := c.client.Post(ctx, "/two_factor_push_otp_check", requestBody)
if err != nil {
return false, "", err
}
defer response.Body.Close()
return parse(response)
}
func parse(hr *http.Response) (bool, string, error) {
response := &Response{}
if err := gitlabnet.ParseJSON(hr, response); err != nil {
return err
return false, "", err
}
if !response.Success {
return errors.New(response.Message)
return false, response.Message, nil
}
return nil
return true, response.Message, nil
}
func (c *Client) getRequestBody(ctx context.Context, args *commandargs.Shell, otp string) (*RequestBody, error) {
Loading
Loading
Loading
Loading
@@ -16,10 +16,10 @@ import (
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/config"
)
func initialize(t *testing.T) []testserver.TestRequestHandler {
func initializeManual(t *testing.T) []testserver.TestRequestHandler {
requests := []testserver.TestRequestHandler{
{
Path: "/api/v4/internal/two_factor_otp_check",
Path: "/api/v4/internal/two_factor_manual_otp_check",
Handler: func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
defer r.Body.Close()
Loading
Loading
@@ -78,35 +78,35 @@ func initialize(t *testing.T) []testserver.TestRequestHandler {
}
const (
otpAttempt = "123456"
manualOtpAttempt = "123456"
)
func TestVerifyOTPByKeyId(t *testing.T) {
client := setup(t)
client := setupManual(t)
args := &commandargs.Shell{GitlabKeyId: "0"}
err := client.VerifyOTP(context.Background(), args, otpAttempt)
_, _, err := client.VerifyOTP(context.Background(), args, manualOtpAttempt)
require.NoError(t, err)
}
func TestVerifyOTPByUsername(t *testing.T) {
client := setup(t)
client := setupManual(t)
args := &commandargs.Shell{GitlabUsername: "jane-doe"}
err := client.VerifyOTP(context.Background(), args, otpAttempt)
_, _, err := client.VerifyOTP(context.Background(), args, manualOtpAttempt)
require.NoError(t, err)
}
func TestErrorMessage(t *testing.T) {
client := setup(t)
client := setupManual(t)
args := &commandargs.Shell{GitlabKeyId: "1"}
err := client.VerifyOTP(context.Background(), args, otpAttempt)
require.Equal(t, "error message", err.Error())
_, reason, _ := client.VerifyOTP(context.Background(), args, manualOtpAttempt)
require.Equal(t, "error message", reason)
}
func TestErrorResponses(t *testing.T) {
client := setup(t)
client := setupManual(t)
testCases := []struct {
desc string
Loading
Loading
@@ -133,15 +133,15 @@ func TestErrorResponses(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
args := &commandargs.Shell{GitlabKeyId: tc.fakeId}
err := client.VerifyOTP(context.Background(), args, otpAttempt)
_, _, err := client.VerifyOTP(context.Background(), args, manualOtpAttempt)
require.EqualError(t, err, tc.expectedError)
})
}
}
func setup(t *testing.T) *Client {
requests := initialize(t)
func setupManual(t *testing.T) *Client {
requests := initializeManual(t)
url := testserver.StartSocketHttpServer(t, requests)
client, err := NewClient(&config.Config{GitlabUrl: url})
Loading
Loading
package twofactorverify
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/discover"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/gitlab-shell/v14/client"
"gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/command/commandargs"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/config"
)
func initializePush(t *testing.T) []testserver.TestRequestHandler {
requests := []testserver.TestRequestHandler{
{
Path: "/api/v4/internal/two_factor_push_otp_check",
Handler: func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
defer r.Body.Close()
require.NoError(t, err)
var requestBody *RequestBody
require.NoError(t, json.Unmarshal(b, &requestBody))
switch requestBody.KeyId {
case "0":
body := map[string]interface{}{
"success": true,
}
require.NoError(t, json.NewEncoder(w).Encode(body))
case "1":
body := map[string]interface{}{
"success": false,
"message": "error message",
}
require.NoError(t, json.NewEncoder(w).Encode(body))
case "2":
w.WriteHeader(http.StatusForbidden)
body := &client.ErrorResponse{
Message: "Not allowed!",
}
require.NoError(t, json.NewEncoder(w).Encode(body))
case "3":
w.Write([]byte("{ \"message\": \"broken json!\""))
case "4":
w.WriteHeader(http.StatusForbidden)
}
if requestBody.UserId == 1 {
body := map[string]interface{}{
"success": true,
}
require.NoError(t, json.NewEncoder(w).Encode(body))
}
},
},
{
Path: "/api/v4/internal/discover",
Handler: func(w http.ResponseWriter, r *http.Request) {
body := &discover.Response{
UserId: 1,
Username: "jane-doe",
Name: "Jane Doe",
}
require.NoError(t, json.NewEncoder(w).Encode(body))
},
},
}
return requests
}
func TestVerifyPush(t *testing.T) {
client := setupPush(t)
args := &commandargs.Shell{GitlabKeyId: "0"}
_, _, err := client.PushAuth(context.Background(), args)
require.NoError(t, err)
}
func TestErrorMessagePush(t *testing.T) {
client := setupPush(t)
args := &commandargs.Shell{GitlabKeyId: "1"}
_, reason, _ := client.PushAuth(context.Background(), args)
require.Equal(t, "error message", reason)
}
func TestErrorResponsesPush(t *testing.T) {
client := setupPush(t)
testCases := []struct {
desc string
fakeId string
expectedError string
}{
{
desc: "A response with an error message",
fakeId: "2",
expectedError: "Not allowed!",
},
{
desc: "A response with bad JSON",
fakeId: "3",
expectedError: "Parsing failed",
},
{
desc: "An error response without message",
fakeId: "4",
expectedError: "Internal API error (403)",
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
args := &commandargs.Shell{GitlabKeyId: tc.fakeId}
_, _, err := client.PushAuth(context.Background(), args)
require.EqualError(t, err, tc.expectedError)
})
}
}
func setupPush(t *testing.T) *Client {
requests := initializePush(t)
url := testserver.StartSocketHttpServer(t, requests)
client, err := NewClient(&config.Config{GitlabUrl: url})
require.NoError(t, err)
return client
}
Loading
Loading
@@ -3,7 +3,7 @@ require_relative 'spec_helper'
require 'open3'
require 'json'
describe 'bin/gitlab-shell 2fa_verify' do
describe 'bin/gitlab-shell 2fa_verify manual' do
include_context 'gitlab shell'
let(:env) do
Loading
Loading
@@ -16,7 +16,7 @@ describe 'bin/gitlab-shell 2fa_verify' do
end
def mock_server(server)
server.mount_proc('/api/v4/internal/two_factor_otp_check') do |req, res|
server.mount_proc('/api/v4/internal/two_factor_manual_otp_check') do |req, res|
res.content_type = 'application/json'
res.status = 200
Loading
Loading
@@ -30,6 +30,17 @@ describe 'bin/gitlab-shell 2fa_verify' do
end
end
server.mount_proc('/api/v4/internal/two_factor_push_otp_check') do |req, res|
res.content_type = 'application/json'
res.status = 200
params = JSON.parse(req.body)
key_id = params['key_id'] || params['user_id'].to_s
sleep 5 # simulate Fortinet API timing
res.body = { success: false, message: 'boom!' }.to_json
end
server.mount_proc('/api/v4/internal/discover') do |_, res|
res.status = 200
res.content_type = 'application/json'
Loading
Loading
require_relative 'spec_helper'
require 'open3'
require 'json'
describe 'bin/gitlab-shell 2fa_verify push' do
include_context 'gitlab shell'
let(:env) do
{ 'SSH_CONNECTION' => 'fake',
'SSH_ORIGINAL_COMMAND' => '2fa_verify' }
end
before(:context) do
write_config('gitlab_url' => "http+unix://#{CGI.escape(tmp_socket_path)}")
end
def mock_server(server)
server.mount_proc('/api/v4/internal/two_factor_push_otp_check') do |req, res|
res.content_type = 'application/json'
res.status = 200
params = JSON.parse(req.body)
key_id = params['key_id'] || params['user_id'].to_s
if key_id == '100'
res.body = { success: false }.to_json
elsif key_id == '102'
res.body = { success: true }.to_json
else
res.body = { success: false, message: 'boom!' }.to_json
end
end
server.mount_proc('/api/v4/internal/discover') do |_, res|
res.status = 200
res.content_type = 'application/json'
res.body = { id: 100, name: 'Some User', username: 'someuser' }.to_json
end
end
describe 'command' do
context 'when push is provided' do
let(:cmd) { "#{gitlab_shell_path} key-102" }
it 'prints a successful push verification message' do
verify_successful_verification_push!(cmd)
end
end
context 'when API error occurs' do
let(:cmd) { "#{gitlab_shell_path} key-101" }
it 'prints the error message' do
Open3.popen2(env, cmd) do |stdin, stdout|
expect(stdout.gets(5)).to eq('OTP: ')
expect(stdout.flush.read).to eq("\nPush OTP validation failed.\nboom!\n")
end
end
end
end
def verify_successful_verification_push!(cmd)
Open3.popen2(env, cmd) do |stdin, stdout|
expect(stdout.gets(5)).to eq('OTP: ')
expect(stdout.flush.read).to eq("\nPush OTP validation successful. Git operations are now allowed.\n")
end
end
end
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment