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..39c629b8 --- /dev/null +++ b/imap/command/authenticate.go @@ -0,0 +1,84 @@ +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" + messageInvalidAuthenticationData = "invalid authentication data" //nolint:gosec +) + +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 `identity\0userid\0password`. identity is ignored in IMAP. Some client (Thunderbird) will leave it empty), + // other will use the userID (Apple Mail). + 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 { // min acceptable message be empty username and password (`\x00\x00`). + return nil, p.MakeError(messageInvalidAuthenticationData) + } + + split := bytes.Split(decoded[0:], []byte{0}) + if len(split) != 3 { + return nil, p.MakeError(messageInvalidAuthenticationData) + } + + return &Authenticate{ + UserID: string(split[1]), + Password: string(split[2]), + }, nil +} diff --git a/imap/command/authenticate_test.go b/imap/command/authenticate_test.go new file mode 100644 index 00000000..d42b8ea2 --- /dev/null +++ b/imap/command/authenticate_test.go @@ -0,0 +1,136 @@ +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 { + var continued bool + + 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)) + 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 TestParser_AuthenticationWithIdentity(t *testing.T) { + var continued bool + + authString := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("identity\x00user\x00pass"))) + s := rfcparser.NewScanner(bytes.NewReader(toIMAPLine(`A0001 authenticate plain`, authString))) + p := NewParserWithLiteralContinuationCb(s, continuationChecker(&continued)) + cmd, err := p.Parse() + + require.NoError(t, err, "error test failed") + require.True(t, continued, "continuation test failed") + require.Equal(t, &Authenticate{UserID: "user", Password: "pass"}, cmd.Payload, "payload test failed") + require.Equal(t, "authenticate", p.LastParsedCommand(), "command test failed") + require.Equal(t, "A0001", p.LastParsedTag(), "tag test failed") +} + +func TestParser_AuthenticateFailures(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: messageInvalidAuthenticationData, + 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 { + var continued bool + + s := rfcparser.NewScanner(bytes.NewReader(toIMAPLine(test.input...))) + p := NewParserWithLiteralContinuationCb(s, continuationChecker(&continued)) + _, err := p.Parse() + failureDescription := fmt.Sprintf(" test failed for input %#v", test) + + var parserError *rfcparser.Error + + 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..d115ec57 --- /dev/null +++ b/tests/authenticate_test.go @@ -0,0 +1,127 @@ +package tests + +import ( + "encoding/base64" + "fmt" + "testing" + "time" + + "github.com/ProtonMail/gluon/async" + "github.com/ProtonMail/gluon/events" + "github.com/stretchr/testify/require" +) + +func base64AuthString(username, password string) string { + return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("\x00%v\x00%v", username, password))) +} + +func TestAuthenticateSuccess(t *testing.T) { + authString := base64AuthString("user", "pass") + + runOneToOneTest(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { + c.C("A001 authenticate PLAIN") + c.S("+ Ready") + c.C(authString).OK("A001") + }) +} + +func TestAuthenticateFailure(t *testing.T) { + authString := base64AuthString("user", "badPass") + + runOneToOneTest(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { + c.C("A001 AUTHENTICATE PLAIN") + c.S("+ Ready") + c.C(authString).NO("A001") + }) +} + +func TestAuthenticateMultiple(t *testing.T) { + authString1 := base64AuthString("user1", "pass1") + authString2 := base64AuthString("user2", "pass2") + + runTest(t, defaultServerOptions(t, withCredentials([]credentials{ + {usernames: []string{"user1"}, password: "pass1"}, + {usernames: []string{"user2"}, password: "pass2"}, + })), []int{1, 2}, func(c map[int]*testConnection, _ *testSession) { + // Login as the first user. + c[1].C("A001 AUTHENTICATE plain") + c[1].S("+ Ready") + c[1].C(authString1).OK("A001") + + // Logout the first user. + c[1].C("A002 logout").OK("A002") + + // Login as the second user. + c[2].C("B001 AUTHENTICATE plain") + c[2].S("+ Ready") + c[2].C(authString2).OK("B001") + + // Logout the second user. + c[2].C("B002 logout").OK("B002") + }) +} + +func TestAuthenticateCapabilities(t *testing.T) { + runOneToOneTest(t, defaultServerOptions(t), func(c *testConnection, _ *testSession) { + c.C("A001 AUTHENTICATE PLAIN") + c.S("+ Ready") + c.C(base64AuthString("user", "pass")) + c.S(`A001 OK [CAPABILITY AUTH=PLAIN ID IDLE IMAP4rev1 MOVE STARTTLS UIDPLUS UNSELECT] Logged in`) + }) +} + +func TestAuthenticateTooManyAttemptsMany(t *testing.T) { + runManyToOneTest(t, defaultServerOptions(t), []int{1, 2, 3}, func(c map[int]*testConnection, s *testSession) { + authString := base64AuthString("user", "badPass") + + // 3 attempts. + for _, i := range []int{1, 2, 3} { + tag := fmt.Sprintf("A%03d", i) + c[1].C(tag + " AUTHENTICATE PLAIN") + c[1].S("+ Ready") + c[1].C(authString).NO(tag) + } + + wg := async.MakeWaitGroup(async.NoopPanicHandler{}) + + // All clients should be jailed for 1 sec. + for _, i := range []int{1, 2, 3} { + i := i + tag := fmt.Sprintf("A%03d", i) + + wg.Go(func() { + require.Greater(t, timeFunc(func() { + c[i].C(tag + " AUTHENTICATE PLAIN") + c[i].S("+ Ready") + c[i].C(authString).NO(tag) + }), time.Second) + }) + } + + wg.Wait() + }) +} + +func TestAuthenticateEvents(t *testing.T) { + runOneToOneTest(t, defaultServerOptions(t), func(c *testConnection, s *testSession) { + require.IsType(t, events.UserAdded{}, <-s.eventCh) + require.IsType(t, events.ListenerAdded{}, <-s.eventCh) + require.IsType(t, events.SessionAdded{}, <-s.eventCh) + + c.C("A001 authenticate PLAIN") + c.S("+ Ready") + c.C(base64AuthString("badUser", "badPass")).NO("A001") + + failedEvent, ok := (<-s.eventCh).(events.LoginFailed) + require.True(t, ok) + require.Equal(t, "badUser", failedEvent.Username) + + c.C("A002 authenticate plain") + c.S("+ Ready") + c.C(base64AuthString("user", "pass")).OK("A002") + + loginEvent, ok := (<-s.eventCh).(events.Login) + require.True(t, ok) + require.Equal(t, s.userIDs["user"], loginEvent.UserID) + }) +} 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`) }) }