package alps import ( "crypto/rand" "encoding/base64" "errors" "fmt" "mime/multipart" "net/http" "os" "sync" "time" imapclient "github.com/emersion/go-imap/client" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" "github.com/google/uuid" "github.com/labstack/echo/v4" ) // TODO: make this configurable const sessionDuration = 30 * time.Minute const maxAttachmentSize = 32 << 20 // 32 MiB func generateToken() (string, error) { b := make([]byte, 32) _, err := rand.Read(b) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil } var ( ErrSessionExpired = errors.New("session expired") ErrAttachmentCacheSize = errors.New("Attachments on session exceed maximum file size") ) // AuthError wraps an authentication error. type AuthError struct { cause error } func (err AuthError) Error() string { return fmt.Sprintf("authentication failed: %v", err.cause) } // Session is an active user session. It may also hold an IMAP connection. // // The session's password is not available to plugins. Plugins should use the // session helpers to authenticate outgoing connections, for instance DoSMTP. type Session struct { manager *SessionManager username, password string token string closed chan struct{} pings chan struct{} timer *time.Timer store Store imapLocker sync.Mutex imapConn *imapclient.Client // protected by locker, can be nil attachmentsLocker sync.Mutex attachments map[string]*Attachment // protected by attachmentsLocker } type Attachment struct { File *multipart.FileHeader Form *multipart.Form } func (s *Session) ping() { s.pings <- struct{}{} } // Username returns the session's username. func (s *Session) Username() string { return s.username } // DoIMAP executes an IMAP operation on this session. The IMAP client can only // be used from inside f. func (s *Session) DoIMAP(f func(*imapclient.Client) error) error { s.imapLocker.Lock() defer s.imapLocker.Unlock() if s.imapConn == nil { var err error s.imapConn, err = s.manager.connectIMAP(s.username, s.password) if err != nil { s.Close() return fmt.Errorf("failed to re-connect to IMAP server: %v", err) } } return f(s.imapConn) } // DoSMTP executes an SMTP operation on this session. The SMTP client can only // be used from inside f. func (s *Session) DoSMTP(f func(*smtp.Client) error) error { c, err := s.manager.dialSMTP() if err != nil { return err } defer c.Close() auth := sasl.NewPlainClient("", s.username, s.password) if err := c.Auth(auth); err != nil { return AuthError{err} } if err := f(c); err != nil { return err } if err := c.Quit(); err != nil { return fmt.Errorf("QUIT failed: %v", err) } return nil } // SetHTTPBasicAuth adds an Authorization header field to the request with // this session's credentials. func (s *Session) SetHTTPBasicAuth(req *http.Request) { // TODO: find a way to make it harder for plugins to steal credentials req.SetBasicAuth(s.username, s.password) } // Close destroys the session. This can be used to log the user out. func (s *Session) Close() { s.attachmentsLocker.Lock() defer s.attachmentsLocker.Unlock() for _, f := range s.attachments { f.Form.RemoveAll() } select { case <-s.closed: // This space is intentionally left blank default: close(s.closed) } } // Puts an attachment and returns a generated UUID func (s *Session) PutAttachment(in *multipart.FileHeader, form *multipart.Form) (string, error) { id := uuid.New() s.attachmentsLocker.Lock() var size int64 for _, a := range s.attachments { size += a.File.Size } if size + in.Size > maxAttachmentSize { return "", ErrAttachmentCacheSize } s.attachments[id.String()] = &Attachment{ File: in, Form: form, } s.attachmentsLocker.Unlock() return id.String(), nil } // Removes an attachment from the session. Returns nil if there was no such // attachment. func (s *Session) PopAttachment(uuid string) *Attachment { s.attachmentsLocker.Lock() defer s.attachmentsLocker.Unlock() a, ok := s.attachments[uuid] if !ok { return nil } delete(s.attachments, uuid) return a } // Store returns a store suitable for storing persistent user data. func (s *Session) Store() Store { return s.store } type ( // DialIMAPFunc connects to the upstream IMAP server. DialIMAPFunc func() (*imapclient.Client, error) // DialSMTPFunc connects to the upstream SMTP server. DialSMTPFunc func() (*smtp.Client, error) ) // SessionManager keeps track of active sessions. It connects and re-connects // to the upstream IMAP server as necessary. It prunes expired sessions. type SessionManager struct { dialIMAP DialIMAPFunc dialSMTP DialSMTPFunc logger echo.Logger debug bool locker sync.Mutex sessions map[string]*Session // protected by locker } func newSessionManager(dialIMAP DialIMAPFunc, dialSMTP DialSMTPFunc, logger echo.Logger, debug bool) *SessionManager { return &SessionManager{ sessions: make(map[string]*Session), dialIMAP: dialIMAP, dialSMTP: dialSMTP, logger: logger, debug: debug, } } func (sm *SessionManager) Close() { for _, s := range sm.sessions { s.Close() } } func (sm *SessionManager) connectIMAP(username, password string) (*imapclient.Client, error) { c, err := sm.dialIMAP() if err != nil { return nil, err } if err := c.Login(username, password); err != nil { c.Logout() return nil, AuthError{err} } if sm.debug { c.SetDebug(os.Stderr) } return c, nil } func (sm *SessionManager) get(token string) (*Session, error) { sm.locker.Lock() defer sm.locker.Unlock() session, ok := sm.sessions[token] if !ok { return nil, ErrSessionExpired } return session, nil } // Put connects to the IMAP server and creates a new session. If authentication // fails, the error will be of type AuthError. func (sm *SessionManager) Put(username, password string) (*Session, error) { c, err := sm.connectIMAP(username, password) if err != nil { return nil, err } sm.locker.Lock() defer sm.locker.Unlock() var token string for { token, err = generateToken() if err != nil { c.Logout() return nil, err } if _, ok := sm.sessions[token]; !ok { break } } s := &Session{ manager: sm, closed: make(chan struct{}), pings: make(chan struct{}, 5), imapConn: c, username: username, password: password, token: token, attachments: make(map[string]*Attachment), } s.store, err = newStore(s, sm.logger) if err != nil { return nil, err } sm.sessions[token] = s go func() { timer := time.NewTimer(sessionDuration) alive := true for alive { var loggedOut <-chan struct{} s.imapLocker.Lock() if s.imapConn != nil { loggedOut = s.imapConn.LoggedOut() } s.imapLocker.Unlock() select { case <-loggedOut: s.imapLocker.Lock() s.imapConn = nil s.imapLocker.Unlock() case <-s.pings: if !timer.Stop() { <-timer.C } timer.Reset(sessionDuration) case <-timer.C: alive = false case <-s.closed: alive = false } } timer.Stop() s.imapLocker.Lock() if s.imapConn != nil { s.imapConn.Logout() } s.imapLocker.Unlock() sm.locker.Lock() delete(sm.sessions, token) sm.locker.Unlock() }() return s, nil }