Skip to content

Commit 8310697

Browse files
committed
feat(BRIDGE-205): add AUTHENTICATE IMAP command.
1 parent 31e040c commit 8310697

16 files changed

+428
-49
lines changed

imap/capabilities.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ const (
1010
UIDPLUS Capability = `UIDPLUS`
1111
MOVE Capability = `MOVE`
1212
ID Capability = `ID`
13+
AUTHPLAIN Capability = `AUTH=PLAIN`
1314
)
1415

1516
func IsCapabilityAvailableBeforeAuth(c Capability) bool {
1617
switch c {
17-
case IMAP4rev1, StartTLS, IDLE, ID:
18+
case IMAP4rev1, StartTLS, IDLE, ID, AUTHPLAIN:
1819
return true
1920
case UNSELECT, UIDPLUS, MOVE:
2021
return false

imap/command/authenticate.go

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package command
2+
3+
import (
4+
"bytes"
5+
"encoding/base64"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/ProtonMail/gluon/rfcparser"
10+
)
11+
12+
type Authenticate Login
13+
14+
func (l Authenticate) String() string {
15+
return fmt.Sprintf("AUTHENTICATE '%v' '%v'", l.UserID, l.Password)
16+
}
17+
18+
func (l Authenticate) SanitizedString() string {
19+
return fmt.Sprint("AUTHENTICATE <AUTH_DATA>")
20+
}
21+
22+
type AuthenticateCommandParser struct{}
23+
24+
const (
25+
messageClientAbortedAuthentication = "client aborted authentication"
26+
messageInvalidBase64Content = "invalid base64 content"
27+
messageUnsupportedAuthenticationMechanism = "unsupported authentication mechanism"
28+
messageInvalidAuthenticationData = "invalid authentication data" //nolint:gosec
29+
)
30+
31+
func (AuthenticateCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) {
32+
// authenticate = "AUTHENTICATE" SP auth-type CRLF base64
33+
// auth-type = atom
34+
// base64 = base64 encoded string
35+
if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil {
36+
return nil, err
37+
}
38+
39+
method, err := p.ParseAtom()
40+
if err != nil {
41+
return nil, err
42+
}
43+
44+
if !strings.EqualFold(method, "plain") {
45+
return nil, p.MakeError(messageUnsupportedAuthenticationMechanism)
46+
}
47+
48+
return parseAuthInputString(p)
49+
}
50+
51+
func parseAuthInputString(p *rfcparser.Parser) (*Authenticate, error) {
52+
// The continued response for the AUTHENTICATE can be whether
53+
// `*` , indicating the user aborted the authentication
54+
// a base64 encoded string of the form `identity\0userid\0password`. identity is ignored in IMAP. Some client (Thunderbird) will leave it empty),
55+
// other will use the userID (Apple Mail).
56+
parsed, err := p.ParseStringAfterContinuation("")
57+
if err != nil {
58+
return nil, err
59+
}
60+
61+
input := parsed.Value
62+
if input == "*" && p.Check(rfcparser.TokenTypeCR) { // behave like dovecot: no extra whitespaces allowed after * when cancelling.
63+
return nil, p.MakeError(messageClientAbortedAuthentication)
64+
}
65+
66+
decoded, err := base64.StdEncoding.DecodeString(input)
67+
if err != nil {
68+
return nil, p.MakeError(messageInvalidBase64Content)
69+
}
70+
71+
if len(decoded) < 2 { // min acceptable message be empty username and password (`\x00\x00`).
72+
return nil, p.MakeError(messageInvalidAuthenticationData)
73+
}
74+
75+
split := bytes.Split(decoded[0:], []byte{0})
76+
if len(split) != 3 {
77+
return nil, p.MakeError(messageInvalidAuthenticationData)
78+
}
79+
80+
return &Authenticate{
81+
UserID: string(split[1]),
82+
Password: string(split[2]),
83+
}, nil
84+
}

imap/command/authenticate_test.go

+136
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package command
2+
3+
import (
4+
"bytes"
5+
"encoding/base64"
6+
"fmt"
7+
"testing"
8+
9+
"github.com/ProtonMail/gluon/rfcparser"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func continuationChecker(continued *bool) func(string) error {
14+
return func(string) error { *continued = true; return nil }
15+
}
16+
17+
func TestParser_Authenticate(t *testing.T) {
18+
testData := []*Authenticate{
19+
{UserID: "[email protected]", Password: "pass"},
20+
{UserID: "[email protected]", Password: ""},
21+
{UserID: "", Password: "pass"},
22+
{UserID: "", Password: ""},
23+
}
24+
25+
for i, data := range testData {
26+
var continued bool
27+
28+
tag := fmt.Sprintf("A%04d", i)
29+
authString := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("\x00%s\x00%s", data.UserID, data.Password)))
30+
input := toIMAPLine(tag+` AUTHENTICATE PLAIN`, authString)
31+
s := rfcparser.NewScanner(bytes.NewReader(input))
32+
p := NewParserWithLiteralContinuationCb(s, continuationChecker(&continued))
33+
cmd, err := p.Parse()
34+
message := fmt.Sprintf(" test failed for input %#v", data)
35+
36+
require.NoError(t, err, "error"+message)
37+
require.True(t, continued, "continuation"+message)
38+
require.Equal(t, data, cmd.Payload, "payload"+message)
39+
require.Equal(t, "authenticate", p.LastParsedCommand(), "command"+message)
40+
require.Equal(t, tag, p.LastParsedTag(), "tag"+message)
41+
}
42+
}
43+
44+
func TestParser_AuthenticationWithIdentity(t *testing.T) {
45+
var continued bool
46+
47+
authString := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("identity\x00user\x00pass")))
48+
s := rfcparser.NewScanner(bytes.NewReader(toIMAPLine(`A0001 authenticate plain`, authString)))
49+
p := NewParserWithLiteralContinuationCb(s, continuationChecker(&continued))
50+
cmd, err := p.Parse()
51+
52+
require.NoError(t, err, "error test failed")
53+
require.True(t, continued, "continuation test failed")
54+
require.Equal(t, &Authenticate{UserID: "user", Password: "pass"}, cmd.Payload, "payload test failed")
55+
require.Equal(t, "authenticate", p.LastParsedCommand(), "command test failed")
56+
require.Equal(t, "A0001", p.LastParsedTag(), "tag test failed")
57+
}
58+
59+
func TestParser_AuthenticateFailures(t *testing.T) {
60+
testData := []struct {
61+
input []string
62+
expectedMessage string
63+
continuationExpected bool
64+
description string
65+
}{
66+
{
67+
input: []string{`A003 AUTHENTICATE PLAIN`, `*`},
68+
expectedMessage: messageClientAbortedAuthentication,
69+
continuationExpected: true,
70+
description: "AUTHENTICATE abortion should return an error",
71+
},
72+
{
73+
input: []string{`A003 AUTHENTICATE NONE`, `*`},
74+
expectedMessage: messageUnsupportedAuthenticationMechanism,
75+
continuationExpected: false,
76+
description: "AUTHENTICATE with unknown mechanism should fail",
77+
},
78+
{
79+
input: []string{`A003 AUTHENTICATE PLAIN GARBAGE`, `*`},
80+
expectedMessage: "expected CR",
81+
continuationExpected: false,
82+
description: "AUTHENTICATE with garbage before CRLF should fail",
83+
},
84+
{
85+
input: []string{`A003 AUTHENTICATE PLAIN `, `*`},
86+
expectedMessage: "expected CR",
87+
continuationExpected: false,
88+
description: "AUTHENTICATE with extra space before CRLF should fail",
89+
},
90+
{
91+
input: []string{`A003 AUTHENTICATE PLAIN`, `* `},
92+
expectedMessage: messageInvalidBase64Content,
93+
continuationExpected: true,
94+
description: "AUTHENTICATE with extra space after the abort `*` should fail",
95+
},
96+
{
97+
input: []string{`A003 AUTHENTICATE PLAIN`, `* `},
98+
expectedMessage: messageInvalidBase64Content,
99+
continuationExpected: true,
100+
description: "AUTHENTICATE with extra space after the abort `*` should fail",
101+
},
102+
{
103+
input: []string{`A003 AUTHENTICATE PLAIN`, `not-base64`},
104+
expectedMessage: messageInvalidBase64Content,
105+
continuationExpected: true,
106+
description: "AUTHENTICATE with invalid base 64 message after continuation should fail",
107+
},
108+
{
109+
input: []string{`A003 AUTHENTICATE PLAIN`, base64.StdEncoding.EncodeToString([]byte("username+password"))},
110+
expectedMessage: messageInvalidAuthenticationData,
111+
continuationExpected: true,
112+
description: "AUTHENTICATE with invalid decoded base64 content should fail",
113+
},
114+
{
115+
input: []string{`A003 AUTHENTICATE PLAIN`, base64.StdEncoding.EncodeToString([]byte("\x00username\x00password")) + " "},
116+
expectedMessage: "expected CR",
117+
continuationExpected: true,
118+
description: "AUTHENTICATE with trailing spaces after a valid base64 message should fail",
119+
},
120+
}
121+
122+
for _, test := range testData {
123+
var continued bool
124+
125+
s := rfcparser.NewScanner(bytes.NewReader(toIMAPLine(test.input...)))
126+
p := NewParserWithLiteralContinuationCb(s, continuationChecker(&continued))
127+
_, err := p.Parse()
128+
failureDescription := fmt.Sprintf(" test failed for input %#v", test)
129+
130+
var parserError *rfcparser.Error
131+
132+
require.ErrorAs(t, err, &parserError, "error"+failureDescription)
133+
require.Equal(t, test.expectedMessage, parserError.Message, "error message"+failureDescription)
134+
require.Equal(t, test.continuationExpected, continued, "continuation"+failureDescription)
135+
}
136+
}

imap/command/list_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func TestParser_ListCommandLiteral(t *testing.T) {
6363
input := toIMAPLine(`tag LIST {5}`, `"bar" %`)
6464
s := rfcparser.NewScanner(bytes.NewReader(input))
6565
continuationCalled := false
66-
p := NewParserWithLiteralContinuationCb(s, func() error {
66+
p := NewParserWithLiteralContinuationCb(s, func(string) error {
6767
continuationCalled = true
6868
return nil
6969
})

imap/command/parser.go

+30-29
Original file line numberDiff line numberDiff line change
@@ -24,39 +24,40 @@ func NewParser(s *rfcparser.Scanner) *Parser {
2424
return NewParserWithLiteralContinuationCb(s, nil)
2525
}
2626

27-
func NewParserWithLiteralContinuationCb(s *rfcparser.Scanner, cb func() error) *Parser {
27+
func NewParserWithLiteralContinuationCb(s *rfcparser.Scanner, cb func(string) error) *Parser {
2828
return &Parser{
2929
scanner: s,
3030
parser: rfcparser.NewParserWithLiteralContinuationCb(s, cb),
3131
commands: map[string]Builder{
32-
"list": &ListCommandParser{},
33-
"append": &AppendCommandParser{},
34-
"search": &SearchCommandParser{},
35-
"fetch": &FetchCommandParser{},
36-
"capability": &CapabilityCommandParser{},
37-
"idle": &IdleCommandParser{},
38-
"noop": &NoopCommandParser{},
39-
"logout": &LogoutCommandParser{},
40-
"check": &CheckCommandParser{},
41-
"close": &CloseCommandParser{},
42-
"expunge": &ExpungeCommandParser{},
43-
"unselect": &UnselectCommandParser{},
44-
"starttls": &StartTLSCommandParser{},
45-
"status": &StatusCommandParser{},
46-
"select": &SelectCommandParser{},
47-
"examine": &ExamineCommandParser{},
48-
"create": &CreateCommandParser{},
49-
"delete": &DeleteCommandParser{},
50-
"subscribe": &SubscribeCommandParser{},
51-
"unsubscribe": &UnsubscribeCommandParser{},
52-
"rename": &RenameCommandParser{},
53-
"lsub": &LSubCommandParser{},
54-
"login": &LoginCommandParser{},
55-
"store": &StoreCommandParser{},
56-
"copy": &CopyCommandParser{},
57-
"move": &MoveCommandParser{},
58-
"uid": NewUIDCommandParser(),
59-
"id": &IDCommandParser{},
32+
"list": &ListCommandParser{},
33+
"append": &AppendCommandParser{},
34+
"search": &SearchCommandParser{},
35+
"fetch": &FetchCommandParser{},
36+
"capability": &CapabilityCommandParser{},
37+
"idle": &IdleCommandParser{},
38+
"noop": &NoopCommandParser{},
39+
"logout": &LogoutCommandParser{},
40+
"check": &CheckCommandParser{},
41+
"close": &CloseCommandParser{},
42+
"expunge": &ExpungeCommandParser{},
43+
"unselect": &UnselectCommandParser{},
44+
"starttls": &StartTLSCommandParser{},
45+
"status": &StatusCommandParser{},
46+
"select": &SelectCommandParser{},
47+
"examine": &ExamineCommandParser{},
48+
"create": &CreateCommandParser{},
49+
"delete": &DeleteCommandParser{},
50+
"subscribe": &SubscribeCommandParser{},
51+
"unsubscribe": &UnsubscribeCommandParser{},
52+
"rename": &RenameCommandParser{},
53+
"lsub": &LSubCommandParser{},
54+
"login": &LoginCommandParser{},
55+
"store": &StoreCommandParser{},
56+
"copy": &CopyCommandParser{},
57+
"move": &MoveCommandParser{},
58+
"uid": NewUIDCommandParser(),
59+
"id": &IDCommandParser{},
60+
"authenticate": &AuthenticateCommandParser{},
6061
},
6162
}
6263
}

imap/command/parser_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ func TestParser_LiteralWithContinuationSubmission(t *testing.T) {
105105
}()
106106

107107
s := rfcparser.NewScanner(reader)
108-
p := NewParserWithLiteralContinuationCb(s, func() error {
108+
p := NewParserWithLiteralContinuationCb(s, func(string) error {
109109
close(continueCh)
110110
return nil
111111
})

internal/response/continuation.go

+8-4
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@ func Continuation() *continuation {
1212
}
1313
}
1414

15-
func (r *continuation) Send(s Session) error {
16-
return s.WriteResponse(r.String())
15+
func (r *continuation) Send(s Session, message string) error {
16+
return s.WriteResponse(r.String(message))
1717
}
1818

19-
func (r *continuation) String() string {
20-
return strings.Join([]string{r.tag, "Ready"}, " ")
19+
func (r *continuation) String(message string) string {
20+
if len(message) == 0 {
21+
return r.tag
22+
}
23+
24+
return strings.Join([]string{r.tag, message}, " ")
2125
}

internal/response/continuation_test.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ import (
77
)
88

99
func TestContinuation(t *testing.T) {
10-
assert.Equal(t, "+ Ready", Continuation().String())
10+
assert.Equal(t, "+ Ready", Continuation().String("Ready"))
11+
assert.Equal(t, "+", Continuation().String(""))
1112
}

internal/session/command.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func (s *Session) startCommandReader(ctx context.Context) <-chan commandResult {
3333
{0x16, 0x00, 0x00}, // 0.0
3434
}
3535

36-
parser := command.NewParserWithLiteralContinuationCb(s.scanner, func() error { return response.Continuation().Send(s) })
36+
parser := command.NewParserWithLiteralContinuationCb(s.scanner, func(message string) error { return response.Continuation().Send(s, message) })
3737

3838
for {
3939
s.inputCollector.Reset()

internal/session/handle.go

+6-2
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ func (s *Session) handleCommand(
5454
return s.handleAnyCommand(ctx, tag, cmd, ch)
5555

5656
case
57-
*command.Login:
57+
*command.Login,
58+
*command.Authenticate:
5859
return s.handleNotAuthenticatedCommand(ctx, tag, cmd, ch)
5960

6061
case
@@ -127,7 +128,10 @@ func (s *Session) handleNotAuthenticatedCommand(
127128
case *command.Login:
128129
// 6.2.3. LOGIN Command
129130
return s.handleLogin(ctx, tag, cmd, ch)
130-
131+
case *command.Authenticate:
132+
// 6.2.2 AUTHENTICATE Command we only support the PLAIN mechanism,
133+
// it's similar to LOGIN, so we simply handle the command as login
134+
return s.handleLogin(ctx, tag, (*command.Login)(cmd), ch)
131135
default:
132136
return fmt.Errorf("bad command")
133137
}

internal/session/handle_idle.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/ProtonMail/gluon/internal/response"
1010
"github.com/ProtonMail/gluon/logging"
1111
"github.com/ProtonMail/gluon/profiling"
12+
"github.com/ProtonMail/gluon/rfcparser"
1213
)
1314

1415
// GOMSRV-86: What does it mean to do IDLE when you're not selected?
@@ -37,7 +38,7 @@ func (s *Session) handleIdle(ctx context.Context, tag string, _ *command.Idle, c
3738
"SessionID": s.sessionID,
3839
})
3940

40-
if err := response.Continuation().Send(s); err != nil {
41+
if err := response.Continuation().Send(s, rfcparser.DefaultContinuationMessage); err != nil {
4142
return err
4243
}
4344

0 commit comments

Comments
 (0)