Skip to content

Commit

Permalink
feat(BRIDGE-205): add AUTHENTICATE IMAP command.
Browse files Browse the repository at this point in the history
  • Loading branch information
xmichelo committed Nov 11, 2024
1 parent 31e040c commit 6536da1
Show file tree
Hide file tree
Showing 10 changed files with 407 additions and 36 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() error {
return func() 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)
}
}
57 changes: 29 additions & 28 deletions imap/command/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
},
}
}
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
2 changes: 1 addition & 1 deletion internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions rfcparser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 6536da1

Please sign in to comment.