mirror of
https://github.com/ProtonMail/proton-bridge.git
synced 2025-12-10 04:36:43 +00:00
596 lines
17 KiB
Go
596 lines
17 KiB
Go
// Copyright (c) 2020 Proton Technologies AG
|
|
//
|
|
// This file is part of ProtonMail Bridge.
|
|
//
|
|
// ProtonMail Bridge is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// ProtonMail Bridge is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
// Package bridge provides core business logic providing API over credentials store and PM API.
|
|
package bridge
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ProtonMail/proton-bridge/internal/events"
|
|
"github.com/ProtonMail/proton-bridge/internal/metrics"
|
|
"github.com/ProtonMail/proton-bridge/internal/preferences"
|
|
"github.com/ProtonMail/proton-bridge/internal/store"
|
|
"github.com/ProtonMail/proton-bridge/pkg/listener"
|
|
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
|
"github.com/hashicorp/go-multierror"
|
|
"github.com/pkg/errors"
|
|
logrus "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
var (
|
|
log = logrus.WithField("pkg", "bridge") //nolint[gochecknoglobals]
|
|
isApplicationOutdated = false //nolint[gochecknoglobals]
|
|
)
|
|
|
|
// Bridge is a struct handling users.
|
|
type Bridge struct {
|
|
config Configer
|
|
pref PreferenceProvider
|
|
panicHandler PanicHandler
|
|
events listener.Listener
|
|
version string
|
|
clientManager ClientManager
|
|
credStorer CredentialsStorer
|
|
storeCache *store.Cache
|
|
|
|
// users is a list of accounts that have been added to bridge.
|
|
// They are stored sorted in the credentials store in the order
|
|
// that they were added to bridge chronologically.
|
|
// People are used to that and so we preserve that ordering here.
|
|
users []*User
|
|
|
|
// idleUpdates is a channel which the imap backend listens to and which it uses
|
|
// to send idle updates to the mail client (eg thunderbird).
|
|
// The user stores should send idle updates on this channel.
|
|
idleUpdates chan interface{}
|
|
|
|
lock sync.RWMutex
|
|
|
|
// stopAll can be closed to stop all goroutines from looping (watchBridgeOutdated, watchAPIAuths, heartbeat etc).
|
|
stopAll chan struct{}
|
|
|
|
userAgentClientName string
|
|
userAgentClientVersion string
|
|
userAgentOS string
|
|
}
|
|
|
|
func New(
|
|
config Configer,
|
|
pref PreferenceProvider,
|
|
panicHandler PanicHandler,
|
|
eventListener listener.Listener,
|
|
version string,
|
|
clientManager ClientManager,
|
|
credStorer CredentialsStorer,
|
|
) *Bridge {
|
|
log.Trace("Creating new bridge")
|
|
|
|
b := &Bridge{
|
|
config: config,
|
|
pref: pref,
|
|
panicHandler: panicHandler,
|
|
events: eventListener,
|
|
version: version,
|
|
clientManager: clientManager,
|
|
credStorer: credStorer,
|
|
storeCache: store.NewCache(config.GetIMAPCachePath()),
|
|
idleUpdates: make(chan interface{}),
|
|
lock: sync.RWMutex{},
|
|
stopAll: make(chan struct{}),
|
|
}
|
|
|
|
// Allow DoH before starting bridge if the user has previously set this setting.
|
|
// This allows us to start even if protonmail is blocked.
|
|
if pref.GetBool(preferences.AllowProxyKey) {
|
|
b.AllowProxy()
|
|
}
|
|
|
|
go func() {
|
|
defer panicHandler.HandlePanic()
|
|
b.watchBridgeOutdated()
|
|
}()
|
|
|
|
go func() {
|
|
defer panicHandler.HandlePanic()
|
|
b.watchAPIAuths()
|
|
}()
|
|
|
|
go b.heartbeat()
|
|
|
|
if b.credStorer == nil {
|
|
log.Error("Bridge has no credentials store")
|
|
} else if err := b.loadUsersFromCredentialsStore(); err != nil {
|
|
log.WithError(err).Error("Could not load all users from credentials store")
|
|
}
|
|
|
|
if pref.GetBool(preferences.FirstStartKey) {
|
|
b.SendMetric(metrics.New(metrics.Setup, metrics.FirstStart, metrics.Label(version)))
|
|
}
|
|
|
|
return b
|
|
}
|
|
|
|
// heartbeat sends a heartbeat signal once a day.
|
|
func (b *Bridge) heartbeat() {
|
|
ticker := time.NewTicker(1 * time.Minute)
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
next, err := strconv.ParseInt(b.pref.Get(preferences.NextHeartbeatKey), 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
nextTime := time.Unix(next, 0)
|
|
if time.Now().After(nextTime) {
|
|
b.SendMetric(metrics.New(metrics.Heartbeat, metrics.Daily, metrics.NoLabel))
|
|
nextTime = nextTime.Add(24 * time.Hour)
|
|
b.pref.Set(preferences.NextHeartbeatKey, strconv.FormatInt(nextTime.Unix(), 10))
|
|
}
|
|
|
|
case <-b.stopAll:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *Bridge) loadUsersFromCredentialsStore() (err error) {
|
|
b.lock.Lock()
|
|
defer b.lock.Unlock()
|
|
|
|
userIDs, err := b.credStorer.List()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, userID := range userIDs {
|
|
l := log.WithField("user", userID)
|
|
|
|
user, newUserErr := newUser(b.panicHandler, userID, b.events, b.credStorer, b.clientManager, b.storeCache, b.config.GetDBDir())
|
|
if newUserErr != nil {
|
|
l.WithField("user", userID).WithError(newUserErr).Warn("Could not load user, skipping")
|
|
continue
|
|
}
|
|
|
|
b.users = append(b.users, user)
|
|
|
|
if initUserErr := user.init(b.idleUpdates); initUserErr != nil {
|
|
l.WithField("user", userID).WithError(initUserErr).Warn("Could not initialise user")
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func (b *Bridge) watchBridgeOutdated() {
|
|
ch := make(chan string)
|
|
|
|
b.events.Add(events.UpgradeApplicationEvent, ch)
|
|
|
|
for {
|
|
select {
|
|
case <-ch:
|
|
isApplicationOutdated = true
|
|
b.closeAllConnections()
|
|
|
|
case <-b.stopAll:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// watchAPIAuths receives auths from the client manager and sends them to the appropriate user.
|
|
func (b *Bridge) watchAPIAuths() {
|
|
for {
|
|
select {
|
|
case auth := <-b.clientManager.GetAuthUpdateChannel():
|
|
log.Debug("Bridge received auth from ClientManager")
|
|
|
|
user, ok := b.hasUser(auth.UserID)
|
|
if !ok {
|
|
log.WithField("userID", auth.UserID).Info("User not available for auth update")
|
|
continue
|
|
}
|
|
|
|
if auth.Auth != nil {
|
|
user.updateAuthToken(auth.Auth)
|
|
} else if err := user.logout(); err != nil {
|
|
log.WithError(err).
|
|
WithField("userID", auth.UserID).
|
|
Error("User logout failed while watching API auths")
|
|
}
|
|
|
|
case <-b.stopAll:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *Bridge) closeAllConnections() {
|
|
for _, user := range b.users {
|
|
user.closeAllConnections()
|
|
}
|
|
}
|
|
|
|
// Login authenticates a user.
|
|
// The login flow:
|
|
// * Authenticate user:
|
|
// client, auth, err := bridge.Authenticate(username, password)
|
|
//
|
|
// * In case user `auth.HasTwoFactor()`, ask for it and fully authenticate the user.
|
|
// auth2FA, err := client.Auth2FA(twoFactorCode)
|
|
//
|
|
// * In case user `auth.HasMailboxPassword()`, ask for it, otherwise use `password`
|
|
// and then finish the login procedure.
|
|
// user, err := bridge.FinishLogin(client, auth, mailboxPassword)
|
|
func (b *Bridge) Login(username, password string) (authClient pmapi.Client, auth *pmapi.Auth, err error) {
|
|
b.crashBandicoot(username)
|
|
|
|
// We need to use anonymous client because we don't yet have userID and so can't save auth tokens yet.
|
|
authClient = b.clientManager.GetAnonymousClient()
|
|
|
|
authInfo, err := authClient.AuthInfo(username)
|
|
if err != nil {
|
|
log.WithField("username", username).WithError(err).Error("Could not get auth info for user")
|
|
return
|
|
}
|
|
|
|
if auth, err = authClient.Auth(username, password, authInfo); err != nil {
|
|
log.WithField("username", username).WithError(err).Error("Could not get auth for user")
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// FinishLogin finishes the login procedure and adds the user into the credentials store.
|
|
// See `Login` for more details of the login flow.
|
|
func (b *Bridge) FinishLogin(authClient pmapi.Client, auth *pmapi.Auth, mbPassword string) (user *User, err error) { //nolint[funlen]
|
|
defer func() {
|
|
if err == pmapi.ErrUpgradeApplication {
|
|
b.events.Emit(events.UpgradeApplicationEvent, "")
|
|
}
|
|
if err != nil {
|
|
log.WithError(err).Debug("Login not finished; removing auth session")
|
|
if delAuthErr := authClient.DeleteAuth(); delAuthErr != nil {
|
|
log.WithError(delAuthErr).Error("Failed to clear login session after unlock")
|
|
}
|
|
}
|
|
// The anonymous client will be removed from list and authentication will not be deleted.
|
|
authClient.Logout()
|
|
}()
|
|
|
|
apiUser, hashedPassword, err := getAPIUser(authClient, auth, mbPassword)
|
|
if err != nil {
|
|
log.WithError(err).Error("Failed to get API user")
|
|
return
|
|
}
|
|
|
|
var ok bool
|
|
if user, ok = b.hasUser(apiUser.ID); ok {
|
|
if err = b.connectExistingUser(user, auth, hashedPassword); err != nil {
|
|
log.WithError(err).Error("Failed to connect existing user")
|
|
return
|
|
}
|
|
} else {
|
|
if err = b.addNewUser(apiUser, auth, hashedPassword); err != nil {
|
|
log.WithError(err).Error("Failed to add new user")
|
|
return
|
|
}
|
|
}
|
|
|
|
b.events.Emit(events.UserRefreshEvent, apiUser.ID)
|
|
|
|
return b.GetUser(apiUser.ID)
|
|
}
|
|
|
|
// connectExistingUser connects an existing bridge user to the bridge.
|
|
func (b *Bridge) connectExistingUser(user *User, auth *pmapi.Auth, hashedPassword string) (err error) {
|
|
if user.IsConnected() {
|
|
return errors.New("user is already connected")
|
|
}
|
|
|
|
// Update the user's password in the cred store in case they changed it.
|
|
if err = b.credStorer.UpdatePassword(user.ID(), hashedPassword); err != nil {
|
|
return errors.Wrap(err, "failed to update password of user in credentials store")
|
|
}
|
|
|
|
client := b.clientManager.GetClient(user.ID())
|
|
|
|
if auth, err = client.AuthRefresh(auth.GenToken()); err != nil {
|
|
return errors.Wrap(err, "failed to refresh auth token of new client")
|
|
}
|
|
|
|
if err = b.credStorer.UpdateToken(user.ID(), auth.GenToken()); err != nil {
|
|
return errors.Wrap(err, "failed to update token of user in credentials store")
|
|
}
|
|
|
|
if err = user.init(b.idleUpdates); err != nil {
|
|
return errors.Wrap(err, "failed to initialise user")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// addNewUser adds a new bridge user to the bridge.
|
|
func (b *Bridge) addNewUser(user *pmapi.User, auth *pmapi.Auth, hashedPassword string) (err error) {
|
|
b.lock.Lock()
|
|
defer b.lock.Unlock()
|
|
|
|
client := b.clientManager.GetClient(user.ID)
|
|
|
|
if auth, err = client.AuthRefresh(auth.GenToken()); err != nil {
|
|
return errors.Wrap(err, "failed to refresh token in new client")
|
|
}
|
|
|
|
if user, err = client.CurrentUser(); err != nil {
|
|
return errors.Wrap(err, "failed to update API user")
|
|
}
|
|
|
|
activeEmails := client.Addresses().ActiveEmails()
|
|
|
|
if _, err = b.credStorer.Add(user.ID, user.Name, auth.GenToken(), hashedPassword, activeEmails); err != nil {
|
|
return errors.Wrap(err, "failed to add user to credentials store")
|
|
}
|
|
|
|
bridgeUser, err := newUser(b.panicHandler, user.ID, b.events, b.credStorer, b.clientManager, b.storeCache, b.config.GetDBDir())
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to create user")
|
|
}
|
|
|
|
// The user needs to be part of the users list in order for it to receive an auth during initialisation.
|
|
b.users = append(b.users, bridgeUser)
|
|
|
|
if err = bridgeUser.init(b.idleUpdates); err != nil {
|
|
b.users = b.users[:len(b.users)-1]
|
|
return errors.Wrap(err, "failed to initialise user")
|
|
}
|
|
|
|
b.SendMetric(metrics.New(metrics.Setup, metrics.NewUser, metrics.NoLabel))
|
|
|
|
return err
|
|
}
|
|
|
|
func getAPIUser(client pmapi.Client, auth *pmapi.Auth, mbPassword string) (user *pmapi.User, hashedPassword string, err error) {
|
|
hashedPassword, err = pmapi.HashMailboxPassword(mbPassword, auth.KeySalt)
|
|
if err != nil {
|
|
log.WithError(err).Error("Could not hash mailbox password")
|
|
return
|
|
}
|
|
|
|
// We unlock the user's PGP key here to detect if the user's mailbox password is wrong.
|
|
if _, err = client.Unlock(hashedPassword); err != nil {
|
|
log.WithError(err).Error("Wrong mailbox password")
|
|
return
|
|
}
|
|
|
|
if user, err = client.CurrentUser(); err != nil {
|
|
log.WithError(err).Error("Could not load API user")
|
|
return
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// GetUsers returns all added users into keychain (even logged out users).
|
|
func (b *Bridge) GetUsers() []*User {
|
|
b.lock.RLock()
|
|
defer b.lock.RUnlock()
|
|
|
|
return b.users
|
|
}
|
|
|
|
// GetUser returns a user by `query` which is compared to users' ID, username or any attached e-mail address.
|
|
func (b *Bridge) GetUser(query string) (*User, error) {
|
|
b.crashBandicoot(query)
|
|
|
|
b.lock.RLock()
|
|
defer b.lock.RUnlock()
|
|
|
|
for _, user := range b.users {
|
|
if strings.EqualFold(user.ID(), query) || strings.EqualFold(user.Username(), query) {
|
|
return user, nil
|
|
}
|
|
for _, address := range user.GetAddresses() {
|
|
if strings.EqualFold(address, query) {
|
|
return user, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, errors.New("user " + query + " not found")
|
|
}
|
|
|
|
// ClearData closes all connections (to release db files and so on) and clears all data.
|
|
func (b *Bridge) ClearData() error {
|
|
var result *multierror.Error
|
|
for _, user := range b.users {
|
|
if err := user.Logout(); err != nil {
|
|
result = multierror.Append(result, err)
|
|
}
|
|
if err := user.closeStore(); err != nil {
|
|
result = multierror.Append(result, err)
|
|
}
|
|
}
|
|
if err := b.config.ClearData(); err != nil {
|
|
result = multierror.Append(result, err)
|
|
}
|
|
return result.ErrorOrNil()
|
|
}
|
|
|
|
// DeleteUser deletes user completely; it logs user out from the API, stops any
|
|
// active connection, deletes from credentials store and removes from the Bridge struct.
|
|
func (b *Bridge) DeleteUser(userID string, clearStore bool) error {
|
|
b.lock.Lock()
|
|
defer b.lock.Unlock()
|
|
|
|
log := log.WithField("user", userID)
|
|
|
|
for idx, user := range b.users {
|
|
if user.ID() == userID {
|
|
if err := user.Logout(); err != nil {
|
|
log.WithError(err).Error("Cannot logout user")
|
|
// We can try to continue to remove the user.
|
|
// Token will still be valid, but will expire eventually.
|
|
}
|
|
|
|
if err := user.closeStore(); err != nil {
|
|
log.WithError(err).Error("Failed to close user store")
|
|
}
|
|
if clearStore {
|
|
// Clear cache after closing connections (done in logout).
|
|
if err := user.clearStore(); err != nil {
|
|
log.WithError(err).Error("Failed to clear user")
|
|
}
|
|
}
|
|
|
|
if err := b.credStorer.Delete(userID); err != nil {
|
|
log.WithError(err).Error("Cannot remove user")
|
|
return err
|
|
}
|
|
b.users = append(b.users[:idx], b.users[idx+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return errors.New("user " + userID + " not found")
|
|
}
|
|
|
|
// ReportBug reports a new bug from the user.
|
|
func (b *Bridge) ReportBug(osType, osVersion, description, accountName, address, emailClient string) error {
|
|
c := b.clientManager.GetAnonymousClient()
|
|
defer c.Logout()
|
|
|
|
title := "[Bridge] Bug"
|
|
if err := c.ReportBugWithEmailClient(
|
|
osType,
|
|
osVersion,
|
|
title,
|
|
description,
|
|
accountName,
|
|
address,
|
|
emailClient,
|
|
); err != nil {
|
|
log.Error("Reporting bug failed: ", err)
|
|
return err
|
|
}
|
|
|
|
log.Info("Bug successfully reported")
|
|
|
|
return nil
|
|
}
|
|
|
|
// SendMetric sends a metric. We don't want to return any errors, only log them.
|
|
func (b *Bridge) SendMetric(m metrics.Metric) {
|
|
c := b.clientManager.GetAnonymousClient()
|
|
defer c.Logout()
|
|
|
|
cat, act, lab := m.Get()
|
|
if err := c.SendSimpleMetric(string(cat), string(act), string(lab)); err != nil {
|
|
log.Error("Sending metric failed: ", err)
|
|
}
|
|
|
|
log.WithFields(logrus.Fields{
|
|
"cat": cat,
|
|
"act": act,
|
|
"lab": lab,
|
|
}).Debug("Metric successfully sent")
|
|
}
|
|
|
|
// GetCurrentClient returns currently connected client (e.g. Thunderbird).
|
|
func (b *Bridge) GetCurrentClient() string {
|
|
res := b.userAgentClientName
|
|
if b.userAgentClientVersion != "" {
|
|
res = res + " " + b.userAgentClientVersion
|
|
}
|
|
return res
|
|
}
|
|
|
|
// SetCurrentClient updates client info (e.g. Thunderbird) and sets the user agent
|
|
// on pmapi. By default no client is used, IMAP has to detect it on first login.
|
|
func (b *Bridge) SetCurrentClient(clientName, clientVersion string) {
|
|
b.userAgentClientName = clientName
|
|
b.userAgentClientVersion = clientVersion
|
|
b.updateCurrentUserAgent()
|
|
}
|
|
|
|
// SetCurrentOS updates OS and sets the user agent on pmapi. By default we use
|
|
// `runtime.GOOS`, but this can be overridden in case of better detection.
|
|
func (b *Bridge) SetCurrentOS(os string) {
|
|
b.userAgentOS = os
|
|
b.updateCurrentUserAgent()
|
|
}
|
|
|
|
// GetIMAPUpdatesChannel sets the channel on which idle events should be sent.
|
|
func (b *Bridge) GetIMAPUpdatesChannel() chan interface{} {
|
|
if b.idleUpdates == nil {
|
|
log.Warn("Bridge updates channel is nil")
|
|
}
|
|
|
|
return b.idleUpdates
|
|
}
|
|
|
|
// AllowProxy instructs bridge to use DoH to access an API proxy if necessary.
|
|
// It also needs to work before bridge is initialised (because we may need to use the proxy at startup).
|
|
func (b *Bridge) AllowProxy() {
|
|
b.clientManager.AllowProxy()
|
|
}
|
|
|
|
// DisallowProxy instructs bridge to not use DoH to access an API proxy if necessary.
|
|
// It also needs to work before bridge is initialised (because we may need to use the proxy at startup).
|
|
func (b *Bridge) DisallowProxy() {
|
|
b.clientManager.DisallowProxy()
|
|
}
|
|
|
|
// CheckConnection returns whether there is an internet connection.
|
|
// This should use the connection manager when it is eventually implemented.
|
|
func (b *Bridge) CheckConnection() error {
|
|
return b.clientManager.CheckConnection()
|
|
}
|
|
|
|
// StopWatchers stops all bridge goroutines.
|
|
func (b *Bridge) StopWatchers() {
|
|
close(b.stopAll)
|
|
}
|
|
|
|
func (b *Bridge) updateCurrentUserAgent() {
|
|
UpdateCurrentUserAgent(b.version, b.userAgentOS, b.userAgentClientName, b.userAgentClientVersion)
|
|
}
|
|
|
|
// hasUser returns whether the bridge currently has a user with ID `id`.
|
|
func (b *Bridge) hasUser(id string) (user *User, ok bool) {
|
|
for _, u := range b.users {
|
|
if u.ID() == id {
|
|
user, ok = u, true
|
|
return
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// "Easter egg" for testing purposes.
|
|
func (b *Bridge) crashBandicoot(username string) {
|
|
if username == "crash@bandicoot" {
|
|
panic("Your wish is my command… I crash!")
|
|
}
|
|
}
|