Skip to content

Commit

Permalink
feat(BRIDGE-205): add AUTHENTICATE IMAP command. (#416)
Browse files Browse the repository at this point in the history
  • Loading branch information
xmichelo authored Nov 12, 2024
1 parent 31e040c commit f4ac4c4
Show file tree
Hide file tree
Showing 16 changed files with 428 additions and 49 deletions.
3 changes: 2 additions & 1 deletion imap/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions imap/command/authenticate.go
Original file line number Diff line number Diff line change
@@ -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 <AUTH_DATA>")
}

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
}
136 changes: 136 additions & 0 deletions imap/command/authenticate_test.go
Original file line number Diff line number Diff line change
@@ -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(string) error {
return func(string) error { *continued = true; return nil }
}

func TestParser_Authenticate(t *testing.T) {
testData := []*Authenticate{
{UserID: "[email protected]", Password: "pass"},
{UserID: "[email protected]", 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)
}
}
2 changes: 1 addition & 1 deletion imap/command/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func TestParser_ListCommandLiteral(t *testing.T) {
input := toIMAPLine(`tag LIST {5}`, `"bar" %`)
s := rfcparser.NewScanner(bytes.NewReader(input))
continuationCalled := false
p := NewParserWithLiteralContinuationCb(s, func() error {
p := NewParserWithLiteralContinuationCb(s, func(string) error {
continuationCalled = true
return nil
})
Expand Down
59 changes: 30 additions & 29 deletions imap/command/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,39 +24,40 @@ func NewParser(s *rfcparser.Scanner) *Parser {
return NewParserWithLiteralContinuationCb(s, nil)
}

func NewParserWithLiteralContinuationCb(s *rfcparser.Scanner, cb func() error) *Parser {
func NewParserWithLiteralContinuationCb(s *rfcparser.Scanner, cb func(string) error) *Parser {
return &Parser{
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{},
},
}
}
Expand Down
2 changes: 1 addition & 1 deletion imap/command/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func TestParser_LiteralWithContinuationSubmission(t *testing.T) {
}()

s := rfcparser.NewScanner(reader)
p := NewParserWithLiteralContinuationCb(s, func() error {
p := NewParserWithLiteralContinuationCb(s, func(string) error {
close(continueCh)
return nil
})
Expand Down
12 changes: 8 additions & 4 deletions internal/response/continuation.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ func Continuation() *continuation {
}
}

func (r *continuation) Send(s Session) error {
return s.WriteResponse(r.String())
func (r *continuation) Send(s Session, message string) error {
return s.WriteResponse(r.String(message))
}

func (r *continuation) String() string {
return strings.Join([]string{r.tag, "Ready"}, " ")
func (r *continuation) String(message string) string {
if len(message) == 0 {
return r.tag
}

return strings.Join([]string{r.tag, message}, " ")
}
3 changes: 2 additions & 1 deletion internal/response/continuation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ import (
)

func TestContinuation(t *testing.T) {
assert.Equal(t, "+ Ready", Continuation().String())
assert.Equal(t, "+ Ready", Continuation().String("Ready"))
assert.Equal(t, "+", Continuation().String(""))
}
2 changes: 1 addition & 1 deletion internal/session/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func (s *Session) startCommandReader(ctx context.Context) <-chan commandResult {
{0x16, 0x00, 0x00}, // 0.0
}

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

for {
s.inputCollector.Reset()
Expand Down
8 changes: 6 additions & 2 deletions internal/session/handle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
3 changes: 2 additions & 1 deletion internal/session/handle_idle.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/ProtonMail/gluon/internal/response"
"github.com/ProtonMail/gluon/logging"
"github.com/ProtonMail/gluon/profiling"
"github.com/ProtonMail/gluon/rfcparser"
)

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

if err := response.Continuation().Send(s); err != nil {
if err := response.Continuation().Send(s, rfcparser.DefaultContinuationMessage); err != nil {
return err
}

Expand Down
Loading

0 comments on commit f4ac4c4

Please sign in to comment.