Skip to content

Commit 0790e49

Browse files
author
Patrick Bajao
committed
Merge branch '506-twofactorverify-command-to-support-push-notification' into 'main'
Implement Push Auth support for 2FA verification Closes #506 See merge request gitlab-org/gitlab-shell!454
2 parents 9cacca5 + f6feedf commit 0790e49

File tree

5 files changed

+326
-116
lines changed

5 files changed

+326
-116
lines changed

internal/command/twofactorverify/twofactorverify.go

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"io"
7+
"time"
78

89
"gitlab.com/gitlab-org/labkit/log"
910

@@ -13,54 +14,77 @@ import (
1314
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/twofactorverify"
1415
)
1516

17+
const (
18+
timeout = 30 * time.Second
19+
prompt = "OTP: "
20+
)
21+
1622
type Command struct {
1723
Config *config.Config
1824
Args *commandargs.Shell
1925
ReadWriter *readwriter.ReadWriter
2026
}
2127

2228
func (c *Command) Execute(ctx context.Context) error {
23-
ctxlog := log.ContextLogger(ctx)
24-
ctxlog.Info("twofactorverify: execute: waiting for user input")
25-
otp := c.getOTP(ctx)
26-
27-
ctxlog.Info("twofactorverify: execute: verifying entered OTP")
28-
err := c.verifyOTP(ctx, otp)
29+
client, err := twofactorverify.NewClient(c.Config)
2930
if err != nil {
30-
ctxlog.WithError(err).Error("twofactorverify: execute: OTP verification failed")
3131
return err
3232
}
3333

34-
ctxlog.WithError(err).Info("twofactorverify: execute: OTP verified")
35-
return nil
36-
}
34+
ctx, cancel := context.WithTimeout(ctx, timeout)
35+
defer cancel()
3736

38-
func (c *Command) getOTP(ctx context.Context) string {
39-
prompt := "OTP: "
4037
fmt.Fprint(c.ReadWriter.Out, prompt)
4138

39+
resultCh := make(chan string)
40+
go func() {
41+
err := client.PushAuth(ctx, c.Args)
42+
if err == nil {
43+
resultCh <- "OTP has been validated by Push Authentication. Git operations are now allowed."
44+
}
45+
}()
46+
47+
go func() {
48+
answer, err := c.getOTP(ctx)
49+
if err != nil {
50+
resultCh <- formatErr(err)
51+
}
52+
53+
if err := client.VerifyOTP(ctx, c.Args, answer); err != nil {
54+
resultCh <- formatErr(err)
55+
} else {
56+
resultCh <- "OTP validation successful. Git operations are now allowed."
57+
}
58+
}()
59+
60+
var message string
61+
select {
62+
case message = <-resultCh:
63+
case <-ctx.Done():
64+
message = formatErr(ctx.Err())
65+
}
66+
67+
log.WithContextFields(ctx, log.Fields{"message": message}).Info("Two factor verify command finished")
68+
fmt.Fprintf(c.ReadWriter.Out, "\n%v\n", message)
69+
70+
return nil
71+
}
72+
73+
func (c *Command) getOTP(ctx context.Context) (string, error) {
4274
var answer string
4375
otpLength := int64(64)
4476
reader := io.LimitReader(c.ReadWriter.In, otpLength)
4577
if _, err := fmt.Fscanln(reader, &answer); err != nil {
4678
log.ContextLogger(ctx).WithError(err).Debug("twofactorverify: getOTP: Failed to get user input")
4779
}
4880

49-
return answer
50-
}
51-
52-
func (c *Command) verifyOTP(ctx context.Context, otp string) error {
53-
client, err := twofactorverify.NewClient(c.Config)
54-
if err != nil {
55-
return err
81+
if answer == "" {
82+
return "", fmt.Errorf("OTP cannot be blank.")
5683
}
5784

58-
err = client.VerifyOTP(ctx, c.Args, otp)
59-
if err == nil {
60-
fmt.Fprint(c.ReadWriter.Out, "\nOTP validation successful. Git operations are now allowed.\n")
61-
} else {
62-
fmt.Fprintf(c.ReadWriter.Out, "\nOTP validation failed.\n%v\n", err)
63-
}
85+
return answer, nil
86+
}
6487

65-
return nil
88+
func formatErr(err error) string {
89+
return fmt.Sprintf("OTP validation failed: %v", err)
6690
}

internal/command/twofactorverify/twofactorverify_test.go

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,20 @@ import (
1717
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/twofactorverify"
1818
)
1919

20+
type blockingReader struct{}
21+
22+
func (*blockingReader) Read([]byte) (int, error) {
23+
waitInfinitely := make(chan struct{})
24+
<-waitInfinitely
25+
26+
return 0, nil
27+
}
28+
2029
func setup(t *testing.T) []testserver.TestRequestHandler {
30+
waitInfinitely := make(chan struct{})
2131
requests := []testserver.TestRequestHandler{
2232
{
23-
Path: "/api/v4/internal/two_factor_otp_check",
33+
Path: "/api/v4/internal/two_factor_manual_otp_check",
2434
Handler: func(w http.ResponseWriter, r *http.Request) {
2535
b, err := io.ReadAll(r.Body)
2636
defer r.Body.Close()
@@ -31,11 +41,13 @@ func setup(t *testing.T) []testserver.TestRequestHandler {
3141
require.NoError(t, json.Unmarshal(b, &requestBody))
3242

3343
switch requestBody.KeyId {
34-
case "1":
44+
case "verify_via_otp", "verify_via_otp_with_push_error":
3545
body := map[string]interface{}{
3646
"success": true,
3747
}
3848
json.NewEncoder(w).Encode(body)
49+
case "wait_infinitely":
50+
<-waitInfinitely
3951
case "error":
4052
body := map[string]interface{}{
4153
"success": false,
@@ -47,15 +59,36 @@ func setup(t *testing.T) []testserver.TestRequestHandler {
4759
}
4860
},
4961
},
62+
{
63+
Path: "/api/v4/internal/two_factor_push_otp_check",
64+
Handler: func(w http.ResponseWriter, r *http.Request) {
65+
b, err := io.ReadAll(r.Body)
66+
defer r.Body.Close()
67+
68+
require.NoError(t, err)
69+
70+
var requestBody *twofactorverify.RequestBody
71+
require.NoError(t, json.Unmarshal(b, &requestBody))
72+
73+
switch requestBody.KeyId {
74+
case "verify_via_push":
75+
body := map[string]interface{}{
76+
"success": true,
77+
}
78+
json.NewEncoder(w).Encode(body)
79+
case "verify_via_otp_with_push_error":
80+
w.WriteHeader(http.StatusInternalServerError)
81+
default:
82+
<-waitInfinitely
83+
}
84+
},
85+
},
5086
}
5187

5288
return requests
5389
}
5490

55-
const (
56-
question = "OTP: \n"
57-
errorHeader = "OTP validation failed.\n"
58-
)
91+
const errorHeader = "OTP validation failed: "
5992

6093
func TestExecute(t *testing.T) {
6194
requests := setup(t)
@@ -65,46 +98,61 @@ func TestExecute(t *testing.T) {
6598
testCases := []struct {
6699
desc string
67100
arguments *commandargs.Shell
68-
answer string
101+
input io.Reader
69102
expectedOutput string
70103
}{
71104
{
72-
desc: "With a known key id",
73-
arguments: &commandargs.Shell{GitlabKeyId: "1"},
74-
answer: "123456\n",
75-
expectedOutput: question +
76-
"OTP validation successful. Git operations are now allowed.\n",
105+
desc: "Verify via OTP",
106+
arguments: &commandargs.Shell{GitlabKeyId: "verify_via_otp"},
107+
expectedOutput: "OTP validation successful. Git operations are now allowed.\n",
108+
},
109+
{
110+
desc: "Verify via OTP",
111+
arguments: &commandargs.Shell{GitlabKeyId: "verify_via_otp_with_push_error"},
112+
expectedOutput: "OTP validation successful. Git operations are now allowed.\n",
113+
},
114+
{
115+
desc: "Verify via push authentication",
116+
arguments: &commandargs.Shell{GitlabKeyId: "verify_via_push"},
117+
input: &blockingReader{},
118+
expectedOutput: "OTP has been validated by Push Authentication. Git operations are now allowed.\n",
119+
},
120+
{
121+
desc: "With an empty OTP",
122+
arguments: &commandargs.Shell{GitlabKeyId: "verify_via_otp"},
123+
input: bytes.NewBufferString("\n"),
124+
expectedOutput: errorHeader + "OTP cannot be blank.\n",
77125
},
78126
{
79127
desc: "With bad response",
80128
arguments: &commandargs.Shell{GitlabKeyId: "-1"},
81-
answer: "123456\n",
82-
expectedOutput: question + errorHeader + "Parsing failed\n",
129+
expectedOutput: errorHeader + "Parsing failed\n",
83130
},
84131
{
85132
desc: "With API returns an error",
86133
arguments: &commandargs.Shell{GitlabKeyId: "error"},
87-
answer: "yes\n",
88-
expectedOutput: question + errorHeader + "error message\n",
134+
expectedOutput: errorHeader + "error message\n",
89135
},
90136
{
91137
desc: "With API fails",
92138
arguments: &commandargs.Shell{GitlabKeyId: "broken"},
93-
answer: "yes\n",
94-
expectedOutput: question + errorHeader + "Internal API error (500)\n",
139+
expectedOutput: errorHeader + "Internal API error (500)\n",
95140
},
96141
{
97142
desc: "With missing arguments",
98143
arguments: &commandargs.Shell{},
99-
answer: "yes\n",
100-
expectedOutput: question + errorHeader + "who='' is invalid\n",
144+
expectedOutput: errorHeader + "who='' is invalid\n",
101145
},
102146
}
103147

104148
for _, tc := range testCases {
105149
t.Run(tc.desc, func(t *testing.T) {
106150
output := &bytes.Buffer{}
107-
input := bytes.NewBufferString(tc.answer)
151+
152+
input := tc.input
153+
if input == nil {
154+
input = bytes.NewBufferString("123456\n")
155+
}
108156

109157
cmd := &Command{
110158
Config: &config.Config{GitlabUrl: url},
@@ -115,7 +163,29 @@ func TestExecute(t *testing.T) {
115163
err := cmd.Execute(context.Background())
116164

117165
require.NoError(t, err)
118-
require.Equal(t, tc.expectedOutput, output.String())
166+
require.Equal(t, prompt+"\n"+tc.expectedOutput, output.String())
119167
})
120168
}
121169
}
170+
171+
func TestCanceledContext(t *testing.T) {
172+
requests := setup(t)
173+
174+
output := &bytes.Buffer{}
175+
176+
url := testserver.StartSocketHttpServer(t, requests)
177+
cmd := &Command{
178+
Config: &config.Config{GitlabUrl: url},
179+
Args: &commandargs.Shell{GitlabKeyId: "wait_infinitely"},
180+
ReadWriter: &readwriter.ReadWriter{Out: output, In: &bytes.Buffer{}},
181+
}
182+
183+
ctx, cancel := context.WithCancel(context.Background())
184+
185+
errCh := make(chan error)
186+
go func() { errCh <- cmd.Execute(ctx) }()
187+
cancel()
188+
189+
require.NoError(t, <-errCh)
190+
require.Equal(t, prompt+"\n"+errorHeader+"context canceled\n", output.String())
191+
}

internal/gitlabnet/twofactorverify/client.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ type Response struct {
2626
type RequestBody struct {
2727
KeyId string `json:"key_id,omitempty"`
2828
UserId int64 `json:"user_id,omitempty"`
29-
OTPAttempt string `json:"otp_attempt"`
29+
OTPAttempt string `json:"otp_attempt,omitempty"`
3030
}
3131

3232
func NewClient(config *config.Config) (*Client, error) {
@@ -44,7 +44,22 @@ func (c *Client) VerifyOTP(ctx context.Context, args *commandargs.Shell, otp str
4444
return err
4545
}
4646

47-
response, err := c.client.Post(ctx, "/two_factor_otp_check", requestBody)
47+
response, err := c.client.Post(ctx, "/two_factor_manual_otp_check", requestBody)
48+
if err != nil {
49+
return err
50+
}
51+
defer response.Body.Close()
52+
53+
return parse(response)
54+
}
55+
56+
func (c *Client) PushAuth(ctx context.Context, args *commandargs.Shell) error {
57+
requestBody, err := c.getRequestBody(ctx, args, "")
58+
if err != nil {
59+
return err
60+
}
61+
62+
response, err := c.client.Post(ctx, "/two_factor_push_otp_check", requestBody)
4863
if err != nil {
4964
return err
5065
}

0 commit comments

Comments
 (0)