diff --git a/imap/capabilities.go b/imap/capabilities.go index 6d609c39..51013903 100644 --- a/imap/capabilities.go +++ b/imap/capabilities.go @@ -10,11 +10,12 @@ const ( UIDPLUS Capability = `UIDPLUS` MOVE Capability = `MOVE` ID Capability = `ID` + AUTHPLAIN Capability = `AUTH=PLAIN` ) func IsCapabilityAvailableBeforeAuth(c Capability) bool { switch c { - case IMAP4rev1, StartTLS, IDLE, ID: + case IMAP4rev1, StartTLS, IDLE, ID, AUTHPLAIN: return true case UNSELECT, UIDPLUS, MOVE: return false diff --git a/imap/command/authenticate.go b/imap/command/authenticate.go new file mode 100644 index 00000000..f9cd654e --- /dev/null +++ b/imap/command/authenticate.go @@ -0,0 +1,82 @@ +package command + +import ( + "bytes" + "encoding/base64" + "fmt" + "strings" + + "github.com/ProtonMail/gluon/rfcparser" +) + +type Authenticate Login + +func (l Authenticate) String() string { + return fmt.Sprintf("AUTHENTICATE '%v' '%v'", l.UserID, l.Password) +} + +func (l Authenticate) SanitizedString() string { + return fmt.Sprint("AUTHENTICATE ") +} + +type AuthenticateCommandParser struct{} + +const ( + messageClientAbortedAuthentication = "client aborted authentication" + messageInvalidBase64Content = "invalid base64 content" + messageUnsupportedAuthenticationMechanism = "unsupported authentication mechanism" + messageInvalidCredentials = "invalid credentials" +) + +func (AuthenticateCommandParser) FromParser(p *rfcparser.Parser) (Payload, error) { + // authenticate = "AUTHENTICATE" SP auth-type CRLF base64 + // auth-type = atom + // base64 = base64 encoded string + if err := p.Consume(rfcparser.TokenTypeSP, "expected space after command"); err != nil { + return nil, err + } + + method, err := p.ParseAtom() + if err != nil { + return nil, err + } + if !strings.EqualFold(method, "plain") { + return nil, p.MakeError(messageUnsupportedAuthenticationMechanism) + } + + return parseAuthInputString(p) +} + +func parseAuthInputString(p *rfcparser.Parser) (*Authenticate, error) { + // The continued response for the AUTHENTICATE can be whether + // `*` , indicating the user aborted the authentication + // a base64 encoded string of the form `\0userid\0password` + parsed, err := p.ParseStringAfterContinuation() + if err != nil { + return nil, err + } + + input := parsed.Value + if input == "*" && p.Check(rfcparser.TokenTypeCR) { // behave like dovecot: no extra whitespaces allowed after * when cancelling. + return nil, p.MakeError(messageClientAbortedAuthentication) + } + + decoded, err := base64.StdEncoding.DecodeString(input) + if err != nil { + return nil, p.MakeError(messageInvalidBase64Content) + } + + if (len(decoded) < 2) || (decoded[0] != 0) { // min acceptable message be empty username and password (`\x00\x00`). + return nil, p.MakeError(messageInvalidCredentials) + } + + split := bytes.Split(decoded[1:], []byte{0}) + if len(split) != 2 { + return nil, p.MakeError(messageInvalidCredentials) + } + + return &Authenticate{ + UserID: string(split[0]), + Password: string(split[1]), + }, nil +} diff --git a/imap/command/authenticate_test.go b/imap/command/authenticate_test.go new file mode 100644 index 00000000..8ea87c65 --- /dev/null +++ b/imap/command/authenticate_test.go @@ -0,0 +1,119 @@ +package command + +import ( + "bytes" + "encoding/base64" + "fmt" + "testing" + + "github.com/ProtonMail/gluon/rfcparser" + "github.com/stretchr/testify/require" +) + +func continuationChecker(continued *bool) func() error { + return func() error { *continued = true; return nil } +} + +func TestParser_Authenticate(t *testing.T) { + testData := []*Authenticate{ + {UserID: "user1@example.com", Password: "pass"}, + {UserID: "user1@example.com", Password: ""}, + {UserID: "", Password: "pass"}, + {UserID: "", Password: ""}, + } + + for i, data := range testData { + tag := fmt.Sprintf("A%04d", i) + authString := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("\x00%s\x00%s", data.UserID, data.Password))) + input := toIMAPLine(tag+` AUTHENTICATE PLAIN`, authString) + s := rfcparser.NewScanner(bytes.NewReader(input)) + var continued bool + p := NewParserWithLiteralContinuationCb(s, continuationChecker(&continued)) + cmd, err := p.Parse() + message := fmt.Sprintf(" test failed for input %#v", data) + require.NoError(t, err, "error"+message) + require.True(t, continued, "continuation"+message) + require.Equal(t, data, cmd.Payload, "payload"+message) + require.Equal(t, "authenticate", p.LastParsedCommand(), "command"+message) + require.Equal(t, tag, p.LastParsedTag(), "tag"+message) + } +} + +func TestAuthenticateFailures(t *testing.T) { + testData := []struct { + input []string + expectedMessage string + continuationExpected bool + description string + }{ + { + input: []string{`A003 AUTHENTICATE PLAIN`, `*`}, + expectedMessage: messageClientAbortedAuthentication, + continuationExpected: true, + description: "AUTHENTICATE abortion should return an error", + }, + { + input: []string{`A003 AUTHENTICATE NONE`, `*`}, + expectedMessage: messageUnsupportedAuthenticationMechanism, + continuationExpected: false, + description: "AUTHENTICATE with unknown mechanism should fail", + }, + { + input: []string{`A003 AUTHENTICATE PLAIN GARBAGE`, `*`}, + expectedMessage: "expected CR", + continuationExpected: false, + description: "AUTHENTICATE with garbage before CRLF should fail", + }, + { + input: []string{`A003 AUTHENTICATE PLAIN `, `*`}, + expectedMessage: "expected CR", + continuationExpected: false, + description: "AUTHENTICATE with extra space before CRLF should fail", + }, + { + input: []string{`A003 AUTHENTICATE PLAIN`, `* `}, + expectedMessage: messageInvalidBase64Content, + continuationExpected: true, + description: "AUTHENTICATE with extra space after the abort `*` should fail", + }, + { + input: []string{`A003 AUTHENTICATE PLAIN`, `* `}, + expectedMessage: messageInvalidBase64Content, + continuationExpected: true, + description: "AUTHENTICATE with extra space after the abort `*` should fail", + }, + { + input: []string{`A003 AUTHENTICATE PLAIN`, `not-base64`}, + expectedMessage: messageInvalidBase64Content, + continuationExpected: true, + description: "AUTHENTICATE with invalid base 64 message after continuation should fail", + }, + { + input: []string{`A003 AUTHENTICATE PLAIN`, base64.StdEncoding.EncodeToString([]byte("username+password"))}, + expectedMessage: messageInvalidCredentials, + continuationExpected: true, + description: "AUTHENTICATE with invalid decoded base64 content should fail", + }, + { + input: []string{`A003 AUTHENTICATE PLAIN`, base64.StdEncoding.EncodeToString([]byte("\x00username\x00password")) + " "}, + expectedMessage: "expected CR", + continuationExpected: true, + description: "AUTHENTICATE with trailing spaces after a valid base64 message should fail", + }, + } + + for _, test := range testData { + s := rfcparser.NewScanner(bytes.NewReader(toIMAPLine(test.input...))) + var continued bool + p := NewParserWithLiteralContinuationCb(s, continuationChecker(&continued)) + + _, err := p.Parse() + var parserError *rfcparser.Error + + failureDescription := fmt.Sprintf(" test failed for input %#v", test) + + require.ErrorAs(t, err, &parserError, "error"+failureDescription) + require.Equal(t, test.expectedMessage, parserError.Message, "error message"+failureDescription) + require.Equal(t, test.continuationExpected, continued, "continuation"+failureDescription) + } +} diff --git a/imap/command/parser.go b/imap/command/parser.go index 61939119..5cb28b5e 100644 --- a/imap/command/parser.go +++ b/imap/command/parser.go @@ -29,34 +29,35 @@ func NewParserWithLiteralContinuationCb(s *rfcparser.Scanner, cb func() error) * scanner: s, parser: rfcparser.NewParserWithLiteralContinuationCb(s, cb), commands: map[string]Builder{ - "list": &ListCommandParser{}, - "append": &AppendCommandParser{}, - "search": &SearchCommandParser{}, - "fetch": &FetchCommandParser{}, - "capability": &CapabilityCommandParser{}, - "idle": &IdleCommandParser{}, - "noop": &NoopCommandParser{}, - "logout": &LogoutCommandParser{}, - "check": &CheckCommandParser{}, - "close": &CloseCommandParser{}, - "expunge": &ExpungeCommandParser{}, - "unselect": &UnselectCommandParser{}, - "starttls": &StartTLSCommandParser{}, - "status": &StatusCommandParser{}, - "select": &SelectCommandParser{}, - "examine": &ExamineCommandParser{}, - "create": &CreateCommandParser{}, - "delete": &DeleteCommandParser{}, - "subscribe": &SubscribeCommandParser{}, - "unsubscribe": &UnsubscribeCommandParser{}, - "rename": &RenameCommandParser{}, - "lsub": &LSubCommandParser{}, - "login": &LoginCommandParser{}, - "store": &StoreCommandParser{}, - "copy": &CopyCommandParser{}, - "move": &MoveCommandParser{}, - "uid": NewUIDCommandParser(), - "id": &IDCommandParser{}, + "list": &ListCommandParser{}, + "append": &AppendCommandParser{}, + "search": &SearchCommandParser{}, + "fetch": &FetchCommandParser{}, + "capability": &CapabilityCommandParser{}, + "idle": &IdleCommandParser{}, + "noop": &NoopCommandParser{}, + "logout": &LogoutCommandParser{}, + "check": &CheckCommandParser{}, + "close": &CloseCommandParser{}, + "expunge": &ExpungeCommandParser{}, + "unselect": &UnselectCommandParser{}, + "starttls": &StartTLSCommandParser{}, + "status": &StatusCommandParser{}, + "select": &SelectCommandParser{}, + "examine": &ExamineCommandParser{}, + "create": &CreateCommandParser{}, + "delete": &DeleteCommandParser{}, + "subscribe": &SubscribeCommandParser{}, + "unsubscribe": &UnsubscribeCommandParser{}, + "rename": &RenameCommandParser{}, + "lsub": &LSubCommandParser{}, + "login": &LoginCommandParser{}, + "store": &StoreCommandParser{}, + "copy": &CopyCommandParser{}, + "move": &MoveCommandParser{}, + "uid": NewUIDCommandParser(), + "id": &IDCommandParser{}, + "authenticate": &AuthenticateCommandParser{}, }, } } diff --git a/internal/session/handle.go b/internal/session/handle.go index eda886e3..f7a85ef9 100644 --- a/internal/session/handle.go +++ b/internal/session/handle.go @@ -54,7 +54,8 @@ func (s *Session) handleCommand( return s.handleAnyCommand(ctx, tag, cmd, ch) case - *command.Login: + *command.Login, + *command.Authenticate: return s.handleNotAuthenticatedCommand(ctx, tag, cmd, ch) case @@ -127,7 +128,10 @@ func (s *Session) handleNotAuthenticatedCommand( case *command.Login: // 6.2.3. LOGIN Command return s.handleLogin(ctx, tag, cmd, ch) - + case *command.Authenticate: + // 6.2.2 AUTHENTICATE Command we only support the PLAIN mechanism, + // it's similar to LOGIN, so we simply handle the command as login + return s.handleLogin(ctx, tag, (*command.Login)(cmd), ch) default: return fmt.Errorf("bad command") } diff --git a/internal/session/session.go b/internal/session/session.go index b2344848..48ead4be 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -112,7 +112,7 @@ func New( inputCollector: inputCollector, scanner: scanner, backend: backend, - caps: []imap.Capability{imap.IMAP4rev1, imap.IDLE, imap.UNSELECT, imap.UIDPLUS, imap.MOVE, imap.ID}, + caps: []imap.Capability{imap.IMAP4rev1, imap.IDLE, imap.UNSELECT, imap.UIDPLUS, imap.MOVE, imap.ID, imap.AUTHPLAIN}, sessionID: sessionID, eventCh: eventCh, idleBulkTime: idleBulkTime, diff --git a/rfcparser/parser.go b/rfcparser/parser.go index ddde24cf..965259c7 100644 --- a/rfcparser/parser.go +++ b/rfcparser/parser.go @@ -227,6 +227,24 @@ func (p *Parser) ParseLiteral() ([]byte, error) { return literal, nil } +func (p *Parser) ParseStringAfterContinuation() (String, error) { + if err := p.Consume(TokenTypeCR, "expected CR"); err != nil { + return String{}, err + } + + if p.Check(TokenTypeLF) && p.literalContinuationCb != nil { + if err := p.literalContinuationCb(); err != nil { + return String{}, fmt.Errorf("error occurred during literal continuation callback:%w", err) + } + } + + if err := p.Consume(TokenTypeLF, "expected LF after CR"); err != nil { + return String{}, err + } + + return p.ParseAString() +} + // ParseNumber parses a non decimal number without any signs. func (p *Parser) ParseNumber() (int, error) { if err := p.Consume(TokenTypeDigit, "expected valid digit for number"); err != nil { diff --git a/tests/authenticate_test.go b/tests/authenticate_test.go new file mode 100644 index 00000000..6ad6af1f --- /dev/null +++ b/tests/authenticate_test.go @@ -0,0 +1,11 @@ +package tests + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAuthenticateSuccess(t *testing.T) { + require.True(t, true) +} diff --git a/tests/capability_test.go b/tests/capability_test.go index 87e64b98..3d7ade3b 100644 --- a/tests/capability_test.go +++ b/tests/capability_test.go @@ -7,14 +7,14 @@ import ( func TestCapability(t *testing.T) { runOneToOneTest(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { c.C("A001 Capability") - c.S(`* CAPABILITY ID IDLE IMAP4rev1 STARTTLS`) + c.S(`* CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 STARTTLS`) c.S("A001 OK CAPABILITY") c.C(`A002 login "user" "pass"`) - c.S(`A002 OK [CAPABILITY ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT] Logged in`) + c.S(`A002 OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT] Logged in`) c.C("A003 Capability") - c.S(`* CAPABILITY ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT`) + c.S(`* CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT`) c.S("A003 OK CAPABILITY") }) } diff --git a/tests/login_test.go b/tests/login_test.go index d8e7f48e..be591e46 100644 --- a/tests/login_test.go +++ b/tests/login_test.go @@ -95,7 +95,7 @@ func TestLoginLiteralFailure(t *testing.T) { func TestLoginCapabilities(t *testing.T) { runOneToOneTest(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { c.C("A001 login user pass") - c.S(`A001 OK [CAPABILITY ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT] Logged in`) + c.S(`A001 OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT] Logged in`) }) }