Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(BRIDGE-205): add AUTHENTICATE IMAP command. #416

Merged
merged 1 commit into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading