Skip to content

Commit 6536da1

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

10 files changed

+407
-36
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() error {
14+
return func() 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/parser.go

+29-28
Original file line numberDiff line numberDiff line change
@@ -29,34 +29,35 @@ func NewParserWithLiteralContinuationCb(s *rfcparser.Scanner, cb func() error) *
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
}

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/session.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ func New(
112112
inputCollector: inputCollector,
113113
scanner: scanner,
114114
backend: backend,
115-
caps: []imap.Capability{imap.IMAP4rev1, imap.IDLE, imap.UNSELECT, imap.UIDPLUS, imap.MOVE, imap.ID},
115+
caps: []imap.Capability{imap.IMAP4rev1, imap.IDLE, imap.UNSELECT, imap.UIDPLUS, imap.MOVE, imap.ID, imap.AUTHPLAIN},
116116
sessionID: sessionID,
117117
eventCh: eventCh,
118118
idleBulkTime: idleBulkTime,

rfcparser/parser.go

+18
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,24 @@ func (p *Parser) ParseLiteral() ([]byte, error) {
227227
return literal, nil
228228
}
229229

230+
func (p *Parser) ParseStringAfterContinuation() (String, error) {
231+
if err := p.Consume(TokenTypeCR, "expected CR"); err != nil {
232+
return String{}, err
233+
}
234+
235+
if p.Check(TokenTypeLF) && p.literalContinuationCb != nil {
236+
if err := p.literalContinuationCb(); err != nil {
237+
return String{}, fmt.Errorf("error occurred during literal continuation callback:%w", err)
238+
}
239+
}
240+
241+
if err := p.Consume(TokenTypeLF, "expected LF after CR"); err != nil {
242+
return String{}, err
243+
}
244+
245+
return p.ParseAString()
246+
}
247+
230248
// ParseNumber parses a non decimal number without any signs.
231249
func (p *Parser) ParseNumber() (int, error) {
232250
if err := p.Consume(TokenTypeDigit, "expected valid digit for number"); err != nil {

0 commit comments

Comments
 (0)