forked from Silverfish/proton-bridge
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51eb2c42cd | |||
| c94d839fbb | |||
| de586e5f12 | |||
| c32a106898 | |||
| 5b20b6a3d0 | |||
| 3b07121f08 | |||
| a53bc4b027 | |||
| 478345e277 | |||
| 0ed78f1ccb | |||
| 6671dd38ea | |||
| 2d5ea669a5 | |||
| c7eb7234a2 | |||
| 73d1fe2f65 | |||
| cf75ea739f | |||
| c920c53243 | |||
| 63379001e3 | |||
| aa8cc3fc4b |
@ -147,8 +147,6 @@ build-linux-qa:
|
||||
.build-darwin-base:
|
||||
extends: .build-base
|
||||
before_script:
|
||||
- eval $(ssh-agent -s)
|
||||
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
|
||||
- export PATH=/usr/local/bin:$PATH
|
||||
- export PATH=/usr/local/opt/git/bin:$PATH
|
||||
- export PATH=/usr/local/opt/make/libexec/gnubin:$PATH
|
||||
|
||||
0
.gitmodules
vendored
0
.gitmodules
vendored
@ -1,7 +1,9 @@
|
||||
# Building ProtonMail Bridge and Import-Export app
|
||||
|
||||
## Prerequisites
|
||||
* 64-bit OS (the go-rfc5322 module cannot currently be compiled for 32-bit OSes)
|
||||
* 64-bit AMD OS:
|
||||
- the go-rfc5322 module cannot currently be compiled for 32-bit OSes
|
||||
- the Apple M1 builds are not supported yet due to dependencies
|
||||
* Go 1.13
|
||||
* Bash with basic build utils: make, gcc, sed, find, grep, ...
|
||||
* For Windows it is recommended to use MinGW 64bit shell from [MSYS2](https://www.msys2.org/)
|
||||
|
||||
32
Changelog.md
32
Changelog.md
@ -2,7 +2,37 @@
|
||||
|
||||
Changelog [format](http://keepachangelog.com/en/1.0.0/)
|
||||
|
||||
## [Bridge 2.1.0] London
|
||||
## [Bridge 2.1.3] London
|
||||
|
||||
## Added
|
||||
GODT-1525: Add keybase/go-keychain/secretservice as new keychain helper.
|
||||
|
||||
## Changed
|
||||
GODT-1527: Change bug report description.
|
||||
|
||||
## Fixed
|
||||
GODT-1537: Manual in-app update mechanism.
|
||||
|
||||
|
||||
## [Bridge 2.1.2] London
|
||||
|
||||
## Added
|
||||
* GODT-1522: Rebuild macOS keychain notification.
|
||||
* GODT-1437 Add new proxy provider (Quad9 with port).
|
||||
* GODT-1516: Return notification on missing keychain.
|
||||
|
||||
## Changed
|
||||
* GODT-1451: Do not check for gnome keyring to allow other implementations of secret-service API. Thanks to @remgodow.
|
||||
* GODT-1516 GODT-1451: KeepassXC is crashing on start. We need to block it until it's fixed.
|
||||
|
||||
## Fixed
|
||||
* GODT-1524: Logout issues with macOS.
|
||||
* GODT-1503 GODT-1492: Improve email validation and username in bug report.
|
||||
* GODT-1507: Enable autostart after Qt setup.
|
||||
* GODT-1515: Do not crash when bridge users got disconnected.
|
||||
|
||||
|
||||
## [Bridge 2.1.1] London
|
||||
|
||||
## Added
|
||||
* GODT-1376: Add first userID to sentry scope.
|
||||
|
||||
4
Makefile
4
Makefile
@ -10,7 +10,7 @@ TARGET_OS?=${GOOS}
|
||||
.PHONY: build build-nogui build-launcher versioner hasher
|
||||
|
||||
# Keep version hardcoded so app build works also without Git repository.
|
||||
BRIDGE_APP_VERSION?=2.1.1+git
|
||||
BRIDGE_APP_VERSION?=2.1.3+git
|
||||
APP_VERSION:=${BRIDGE_APP_VERSION}
|
||||
SRC_ICO:=logo.ico
|
||||
SRC_ICNS:=Bridge.icns
|
||||
@ -85,7 +85,7 @@ hasher:
|
||||
|
||||
${TGZ_TARGET}: ${DEPLOY_DIR}/${TARGET_OS}
|
||||
rm -f $@
|
||||
cd ${DEPLOY_DIR}/${TARGET_OS} && tar czf ../../../../$@ .
|
||||
cd ${DEPLOY_DIR}/${TARGET_OS} && tar -czvf ../../../../$@ .
|
||||
|
||||
${DEPLOY_DIR}/linux: ${EXE_TARGET}
|
||||
cp -pf ./internal/frontend/share/${SRC_SVG} ${DEPLOY_DIR}/linux/logo.svg
|
||||
|
||||
@ -53,10 +53,12 @@ the user for a password.
|
||||
|
||||
## Keychain
|
||||
You need to have a keychain in order to run the ProtonMail Bridge. On Mac or
|
||||
Windows, Bridge uses native credential managers. On Linux, use
|
||||
[Gnome keyring](https://wiki.gnome.org/Projects/GnomeKeyring/)
|
||||
Windows, Bridge uses native credential managers. On Linux, use `secret-service` freedesktop.org API
|
||||
(e.g. [Gnome keyring](https://wiki.gnome.org/Projects/GnomeKeyring/))
|
||||
or
|
||||
[pass](https://www.passwordstore.org/).
|
||||
[pass](https://www.passwordstore.org/). We are working on allowing other secret
|
||||
services (e.g. KeepassXC), but for now only gnome-keyring is usable without
|
||||
major problems.
|
||||
|
||||
|
||||
## Environment Variables
|
||||
|
||||
103
doc/updates.md
Normal file
103
doc/updates.md
Normal file
@ -0,0 +1,103 @@
|
||||
# Update mechanism of Bridge
|
||||
|
||||
There are mulitple options how to change version of application:
|
||||
* Automatic in-app update
|
||||
* Manual in-app update
|
||||
* Manual install
|
||||
|
||||
In-app update ends with restarting bridge into new version. Automatic in-app
|
||||
update is downloading, verifying and installing the new version immediatelly
|
||||
without user confirmation. For manual in-app update user needs to confirm first.
|
||||
Update is done from special update file published on website.
|
||||
|
||||
The manual installation requires user to download, verify and install manually
|
||||
using installer for given OS.
|
||||
|
||||
The bridge is installed and executed differently for given OS:
|
||||
|
||||
* Windows and Linux apps are using launcher mechanism:
|
||||
* There is system protected installation path which is created on first
|
||||
install. It contains bridge exe and launcher exe. When users starts
|
||||
bridge the launcher is executed first. It will check update path compare
|
||||
version with installed one. The newer version then is then executed.
|
||||
* Update mechanism means to replace files in update folder which is located
|
||||
in user space.
|
||||
|
||||
* macOS app does not use launcher
|
||||
* No launcher, only one executable
|
||||
* In-App udpate replaces the bridge files in installation path directly
|
||||
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Frontend
|
||||
U[User requests<br>version check]
|
||||
ManIns((Notify user about<br>manual install<br>is needed))
|
||||
R((Notify user<br>about restart))
|
||||
ManUp((Notify user about<br>manual update))
|
||||
NF((Notify user about<br>force update))
|
||||
|
||||
ManUp -->|Install| InstFront[Install]
|
||||
InstFront -->|Ok| R
|
||||
InstFront -->|Error| ManIns
|
||||
|
||||
U --> CheckFront[Check online]
|
||||
CheckFront -->|Ok| IAFront{Is new version<br>and applicable?}
|
||||
CheckFront -->|Error| ManIns
|
||||
|
||||
IAFront -->|No| Latest((Notify user<br>has latest version))
|
||||
IAFront -->|Yes| CanInstall{Can update?}
|
||||
CanInstall -->|No| ManIns
|
||||
CanInstall -->|Yes| NotifOrInstall{Is automatic<br>update enabled?}
|
||||
NotifOrInstall -->|Manual| ManUp
|
||||
end
|
||||
|
||||
|
||||
subgraph Backend
|
||||
W[Wait for next check]
|
||||
|
||||
W --> Check[Check online]
|
||||
|
||||
Check --> NV{Has new<br>version?}
|
||||
Check -->|Error| W
|
||||
NV -->|No new version| W
|
||||
IA{Is install<br>applicable?}
|
||||
NV -->|New version<br>available| IA
|
||||
IA -->|Local rollout<br>not enough| W
|
||||
IA -->|Yes| AU{Is automatic\nupdate enabled?}
|
||||
|
||||
AU -->|Yes| CanUp{Can update?}
|
||||
CanUp -->|No| ManIns
|
||||
|
||||
CanUp -->|Yes| Ins[Install]
|
||||
Ins -->|Error| ManIns
|
||||
Ins -->|Ok| R
|
||||
|
||||
AU -->|No| ManUp
|
||||
ManUp -->|Ignore| W
|
||||
|
||||
|
||||
F[Force update]
|
||||
F --> NF
|
||||
end
|
||||
|
||||
ManIns --> Web[Open web page]
|
||||
NF --> Web
|
||||
ManUp --> Web
|
||||
R --> Re[Restart]
|
||||
NF --> Q[Quit bridge]
|
||||
NotifOrInstall -->|Automatic| W
|
||||
```
|
||||
|
||||
|
||||
The non-trivial is to combine the update with setting change:
|
||||
* turn off/on automatic in-app updates
|
||||
* change from stable to beta or back
|
||||
|
||||
_TODO fill flow chart details_
|
||||
|
||||
|
||||
We are not support downgrade functionality. Only some circumstances can lead to
|
||||
downgrading the app version.
|
||||
|
||||
_TODO fill flow chart details_
|
||||
6
go.mod
6
go.mod
@ -43,15 +43,18 @@ require (
|
||||
github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect
|
||||
github.com/getsentry/sentry-go v0.12.0
|
||||
github.com/go-resty/resty/v2 v2.6.0
|
||||
github.com/godbus/dbus v4.1.0+incompatible
|
||||
github.com/golang/mock v1.4.4
|
||||
github.com/google/go-cmp v0.5.5
|
||||
github.com/google/uuid v1.1.1
|
||||
github.com/hashicorp/go-multierror v1.1.0
|
||||
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7
|
||||
github.com/keybase/go-keychain v0.0.0-20200502122510-cda31fe0c86d
|
||||
github.com/keybase/go-keychain v0.0.0
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible
|
||||
github.com/mattn/go-runewidth v0.0.9 // indirect
|
||||
github.com/miekg/dns v1.1.41
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
|
||||
github.com/nsf/jsondiff v0.0.0-20200515183724-f29ed568f4ce
|
||||
github.com/olekukonko/tablewriter v0.0.4 // indirect
|
||||
github.com/pkg/errors v0.9.1
|
||||
@ -77,4 +80,5 @@ replace (
|
||||
github.com/emersion/go-imap => github.com/ProtonMail/go-imap v0.0.0-20201228133358-4db68cea0cac
|
||||
github.com/emersion/go-message => github.com/ProtonMail/go-message v0.0.0-20210611055058-fabeff2ec753
|
||||
github.com/jameskeane/bcrypt => github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57
|
||||
github.com/keybase/go-keychain => github.com/cuthix/go-keychain v0.0.0-20220405075754-31e7cee908fe
|
||||
)
|
||||
|
||||
6
go.sum
6
go.sum
@ -102,6 +102,8 @@ github.com/cucumber/godog v0.12.1/go.mod h1:u6SD7IXC49dLpPN35kal0oYEjsXZWee4pW6T
|
||||
github.com/cucumber/messages-go/v16 v16.0.0/go.mod h1:EJcyR5Mm5ZuDsKJnT2N9KRnBK30BGjtYotDKpwQ0v6g=
|
||||
github.com/cucumber/messages-go/v16 v16.0.1 h1:fvkpwsLgnIm0qugftrw2YwNlio+ABe2Iu94Ap8GMYIY=
|
||||
github.com/cucumber/messages-go/v16 v16.0.1/go.mod h1:EJcyR5Mm5ZuDsKJnT2N9KRnBK30BGjtYotDKpwQ0v6g=
|
||||
github.com/cuthix/go-keychain v0.0.0-20220405075754-31e7cee908fe h1:KRj3wdvA9yE92prNmOjS7x5DOqoyjxqdE30qnrmTasc=
|
||||
github.com/cuthix/go-keychain v0.0.0-20220405075754-31e7cee908fe/go.mod h1:ZoZU1fnBy3mOLWr3Pg+Y2+nTKtu6ypDte2kZg9HvSwY=
|
||||
github.com/danieljoos/wincred v1.1.0 h1:3RNcEpBg4IhIChZdFRSdlQt1QjCp1sMAPIrOnm7Yf8g=
|
||||
github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@ -164,6 +166,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4=
|
||||
github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
@ -265,6 +269,8 @@ github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7
|
||||
github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8=
|
||||
github.com/keybase/go-keychain v0.0.0-20200502122510-cda31fe0c86d h1:gVjhBCfVGl32RIBooOANzfw+0UqX8HU+yPlMv8vypcg=
|
||||
github.com/keybase/go-keychain v0.0.0-20200502122510-cda31fe0c86d/go.mod h1:W6EbaYmb4RldPn0N3gvVHjY1wmU59kbymhW9NATWhwY=
|
||||
github.com/keybase/go-keychain v0.0.0-20211119201326-e02f34051621 h1:aMQ7pA4f06yOVXSulygyGvy4xA94fyzjUGs0iqQdMOI=
|
||||
github.com/keybase/go-keychain v0.0.0-20211119201326-e02f34051621/go.mod h1:enrU/ug069Om7vWxuFE6nikLI2BZNwevMiGSo43Kt5w=
|
||||
github.com/keybase/go.dbus v0.0.0-20200324223359-a94be52c0b03/go.mod h1:a8clEhrrGV/d76/f9r2I41BwANMihfZYV9C223vaxqE=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
|
||||
@ -108,10 +108,6 @@ func New(
|
||||
if err := b.SendMetric(metrics.New(metrics.Setup, metrics.FirstStart, metrics.Label(constants.Version))); err != nil {
|
||||
logrus.WithError(err).Error("Failed to send metric")
|
||||
}
|
||||
|
||||
if err := b.EnableAutostart(); err != nil {
|
||||
log.WithError(err).Error("Failed to enable autostart")
|
||||
}
|
||||
setting.SetBool(settings.FirstStartKey, false)
|
||||
}
|
||||
|
||||
|
||||
@ -39,6 +39,12 @@ var ErrSizeTooLarge = errors.New("file is too big")
|
||||
|
||||
// ReportBug reports a new bug from the user.
|
||||
func (b *Bridge) ReportBug(osType, osVersion, description, accountName, address, emailClient string, attachLogs bool) error {
|
||||
if user, err := b.GetUser(address); err == nil {
|
||||
accountName = user.Username()
|
||||
} else if users := b.GetUsers(); len(users) > 0 {
|
||||
accountName = users[0].Username()
|
||||
}
|
||||
|
||||
report := pmapi.ReportBugReq{
|
||||
OS: osType,
|
||||
OSVersion: osVersion,
|
||||
|
||||
@ -101,10 +101,10 @@ func (f *frontendCLI) notifyNeedUpgrade() {
|
||||
f.Println("Please download and install the newest version of application from", version.LandingPage)
|
||||
}
|
||||
|
||||
func (f *frontendCLI) notifyCredentialsError() { // nolint[unused]
|
||||
func (f *frontendCLI) notifyCredentialsError() {
|
||||
// Print in 80-column width.
|
||||
f.Println("ProtonMail Bridge is not able to detect a supported password manager")
|
||||
f.Println("(pass, gnome-keyring). Please install and set up a supported password manager")
|
||||
f.Println("(secret-service or pass). Please install and set up a supported password manager")
|
||||
f.Println("and restart the application.")
|
||||
}
|
||||
|
||||
|
||||
@ -133,7 +133,7 @@ QtObject {
|
||||
return Qt.point(_x, _y)
|
||||
}
|
||||
|
||||
// fir to the right
|
||||
// fit to the right
|
||||
_x = iconRect.right
|
||||
if (isInInterval(_x, screenRect.left, screenRect.right - width)) {
|
||||
// position preferebly in the vertical center but bound to the screen rect
|
||||
|
||||
@ -55,6 +55,26 @@ Window {
|
||||
function getCursorPos() {
|
||||
return BridgePreview.getCursorPos()
|
||||
}
|
||||
|
||||
function restart() {
|
||||
root.quit()
|
||||
console.log("Restarting....")
|
||||
root.openBridge()
|
||||
}
|
||||
|
||||
function openBridge() {
|
||||
bridge = bridgeComponent.createObject()
|
||||
var showSetupGuide = false
|
||||
if (showSetupGuide) {
|
||||
var newUserObject = root.userComponent.createObject(root)
|
||||
newUserObject.username = "LerooooyJenkins@protonmail.com"
|
||||
newUserObject.loggedIn = true
|
||||
newUserObject.setupGuideSeen = false
|
||||
root.users.append( { object: newUserObject } )
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function quit() {
|
||||
if (bridge !== undefined && bridge !== null) {
|
||||
bridge.destroy()
|
||||
@ -367,18 +387,7 @@ Window {
|
||||
|
||||
text: "Open Bridge"
|
||||
enabled: bridge === undefined || bridge === null
|
||||
onClicked: {
|
||||
bridge = bridgeComponent.createObject()
|
||||
var showSetupGuide = false
|
||||
if (showSetupGuide) {
|
||||
var newUserObject = root.userComponent.createObject(root)
|
||||
newUserObject.username = "LerooooyJenkins@protonmail.com"
|
||||
newUserObject.loggedIn = true
|
||||
newUserObject.setupGuideSeen = false
|
||||
root.users.append( { object: newUserObject } )
|
||||
}
|
||||
|
||||
}
|
||||
onClicked: root.openBridge()
|
||||
}
|
||||
|
||||
Button {
|
||||
@ -589,7 +598,15 @@ Window {
|
||||
text: "No keychain"
|
||||
colorScheme: root.colorScheme
|
||||
onClicked: {
|
||||
root.hasNoKeychain()
|
||||
root.notifyHasNoKeychain()
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Rebuild keychain"
|
||||
colorScheme: root.colorScheme
|
||||
onClicked: {
|
||||
root.notifyRebuildKeychain()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -712,6 +729,9 @@ Window {
|
||||
console.log("check updates")
|
||||
}
|
||||
signal checkUpdatesFinished()
|
||||
function installUpdate() {
|
||||
console.log("manuall install update triggered")
|
||||
}
|
||||
|
||||
|
||||
property bool isDiskCacheEnabled: true
|
||||
@ -731,7 +751,19 @@ Window {
|
||||
property bool isAutomaticUpdateOn : true
|
||||
function toggleAutomaticUpdate(makeItActive) {
|
||||
console.debug("-> silent updates", makeItActive, root.isAutomaticUpdateOn)
|
||||
root.isAutomaticUpdateOn = makeItActive
|
||||
var callback = function () {
|
||||
root.isAutomaticUpdateOn = makeItActive;
|
||||
console.debug("-> CHANGED silent updates", makeItActive, root.isAutomaticUpdateOn)
|
||||
}
|
||||
atimer.onTriggered.connect(callback)
|
||||
atimer.restart()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: atimer
|
||||
interval: 2000
|
||||
running: false
|
||||
repeat: false
|
||||
}
|
||||
|
||||
property bool isAutostartOn : true // Example of settings with loading state
|
||||
@ -815,7 +847,8 @@ Window {
|
||||
root.changeKeychainFinished()
|
||||
}
|
||||
signal changeKeychainFinished()
|
||||
signal hasNoKeychain()
|
||||
signal notifyHasNoKeychain()
|
||||
signal notifyRebuildKeychain()
|
||||
|
||||
signal noActiveKeyForRecipient(string email)
|
||||
signal showMainWindow()
|
||||
|
||||
@ -127,19 +127,10 @@ SettingsView {
|
||||
}
|
||||
|
||||
TextEdit {
|
||||
text: {
|
||||
var address = "bridge@protonmail.com"
|
||||
var mailTo = `<a href="mailto://${address}">${address}</a>`
|
||||
return "<style>a:link { color: " + root.colorScheme.interaction_norm + "; }</style>" +qsTr(
|
||||
"These reports are not end-to-end encrypted. In case of sensitive information, contact us at %1."
|
||||
).arg(mailTo)
|
||||
}
|
||||
onLinkActivated: Qt.openUrlExternally(link)
|
||||
textFormat: Text.RichText
|
||||
text: qsTr("Reports are not end-to-end encrypted, please do not send any sensitive information.")
|
||||
|
||||
readOnly: true
|
||||
|
||||
|
||||
Layout.fillWidth: true
|
||||
color: root.colorScheme.text_weak
|
||||
font.family: ProtonStyle.font_family
|
||||
@ -181,7 +172,7 @@ SettingsView {
|
||||
}
|
||||
|
||||
function isValidEmail(text){
|
||||
var reEmail = /\w+@\w+\.\w+/
|
||||
var reEmail = /^[^@]+@[^@]+\.[A-Za-z]+\s*$/
|
||||
return reEmail.test(text)
|
||||
}
|
||||
|
||||
|
||||
@ -182,6 +182,7 @@ ApplicationWindow {
|
||||
colorScheme: root.colorScheme
|
||||
notifications: root.notifications
|
||||
mainWindow: root
|
||||
backend: root.backend
|
||||
}
|
||||
|
||||
function showLocalCacheSettings() { contentWrapper.showLocalCacheSettings() }
|
||||
|
||||
@ -25,6 +25,7 @@ import Notifications 1.0
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property var backend
|
||||
|
||||
property ColorScheme colorScheme
|
||||
property var notifications
|
||||
@ -51,8 +52,11 @@ Item {
|
||||
notification: root.notifications.updateManualReady
|
||||
|
||||
Switch {
|
||||
id:autoUpdate
|
||||
colorScheme: root.colorScheme
|
||||
text: qsTr("Update automatically in the future")
|
||||
checked: root.backend.isAutomaticUpdateOn
|
||||
onClicked: root.backend.toggleAutomaticUpdate(autoUpdate.checked)
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,4 +119,9 @@ Item {
|
||||
colorScheme: root.colorScheme
|
||||
notification: root.notifications.noKeychain
|
||||
}
|
||||
|
||||
NotificationDialog {
|
||||
colorScheme: root.colorScheme
|
||||
notification: root.notifications.rebuildKeychain
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,7 +73,8 @@ QtObject {
|
||||
root.enableLocalCache,
|
||||
root.resetBridge,
|
||||
root.deleteAccount,
|
||||
root.noKeychain
|
||||
root.noKeychain,
|
||||
root.rebuildKeychain
|
||||
]
|
||||
|
||||
// Connection
|
||||
@ -165,8 +166,9 @@ QtObject {
|
||||
}
|
||||
|
||||
property Notification updateManualError: Notification {
|
||||
description: qsTr("Bridge couldn’t update")
|
||||
brief: description
|
||||
title: qsTr("Bridge couldn’t update")
|
||||
brief: title
|
||||
description: qsTr("Please follow manual installation in order to update Bridge.")
|
||||
icon: "./icons/ic-exclamation-circle-filled.svg"
|
||||
type: Notification.NotificationType.Warning
|
||||
group: Notifications.Group.Update
|
||||
@ -191,7 +193,7 @@ QtObject {
|
||||
text: qsTr("Remind me later")
|
||||
|
||||
onTriggered: {
|
||||
root.updateManualReady.active = false
|
||||
root.updateManualError.active = false
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -272,7 +274,7 @@ QtObject {
|
||||
|
||||
onTriggered: {
|
||||
root.backend.quit()
|
||||
root.updateForce.active = false
|
||||
root.updateForceError.active = false
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -870,7 +872,7 @@ QtObject {
|
||||
|
||||
property Notification noKeychain: Notification {
|
||||
title: qsTr("No keychain available")
|
||||
description: qsTr("Bridge is not able to detected a supported password manager (pass, gnome-keyring). Please install and setup supported password manager and restart the application.")
|
||||
description: qsTr("Bridge is not able to detect a supported password manager (pass or secret-service). Please install and setup supported password manager and restart the application.")
|
||||
brief: title
|
||||
icon: "./icons/ic-exclamation-circle-filled.svg"
|
||||
type: Notification.NotificationType.Danger
|
||||
@ -879,7 +881,7 @@ QtObject {
|
||||
Connections {
|
||||
target: root.backend
|
||||
|
||||
onHasNoKeychain: {
|
||||
onNotifyHasNoKeychain: {
|
||||
root.noKeychain.active = true
|
||||
}
|
||||
}
|
||||
@ -891,6 +893,45 @@ QtObject {
|
||||
onTriggered: {
|
||||
root.backend.quit()
|
||||
}
|
||||
},
|
||||
Action {
|
||||
text: qsTr("Restart Bridge")
|
||||
|
||||
onTriggered: {
|
||||
root.backend.restart()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
property Notification rebuildKeychain: Notification {
|
||||
title: qsTr("Your macOS keychain might be corrupted")
|
||||
description: qsTr("Bridge is not able to access your macOS keychain. Please consult the instructions on our support page.")
|
||||
brief: title
|
||||
icon: "./icons/ic-exclamation-circle-filled.svg"
|
||||
type: Notification.NotificationType.Danger
|
||||
group: Notifications.Group.Dialogs | Notifications.Group.Configuration
|
||||
|
||||
property var supportLink: "https://protonmail.com/support/knowledge-base/macos-keychain-corrupted"
|
||||
|
||||
|
||||
Connections {
|
||||
target: root.backend
|
||||
|
||||
onNotifyRebuildKeychain: {
|
||||
console.log("notifications")
|
||||
root.rebuildKeychain.active = true
|
||||
}
|
||||
}
|
||||
|
||||
action: [
|
||||
Action {
|
||||
text: qsTr("Open the support page")
|
||||
|
||||
onTriggered: {
|
||||
Qt.openUrlExternally(root.rebuildKeychain.supportLink)
|
||||
root.backend.quit()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -61,6 +61,7 @@ type FrontendQt struct {
|
||||
log *logrus.Entry
|
||||
initializing sync.WaitGroup
|
||||
initializationDone sync.Once
|
||||
firstTimeAutostart sync.Once
|
||||
|
||||
app *widgets.QApplication
|
||||
engine *qml.QQmlEngine
|
||||
@ -152,7 +153,7 @@ func (f *FrontendQt) NotifySilentUpdateInstalled() {
|
||||
}
|
||||
|
||||
func (f *FrontendQt) NotifySilentUpdateError(err error) {
|
||||
f.log.WithError(err).Warn("Update failed, asking for manual.")
|
||||
f.log.WithError(err).Warn("In-app update failed, asking for manual.")
|
||||
f.qml.UpdateManualError()
|
||||
}
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ import (
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/internal/bridge"
|
||||
"github.com/ProtonMail/proton-bridge/internal/events"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/keychain"
|
||||
)
|
||||
|
||||
func (f *FrontendQt) watchEvents() {
|
||||
@ -64,7 +65,11 @@ func (f *FrontendQt) watchEvents() {
|
||||
if strings.Contains(errorDetails, "SMTP failed") {
|
||||
f.qml.PortIssueSMTP()
|
||||
}
|
||||
case <-credentialsErrorCh:
|
||||
case reason := <-credentialsErrorCh:
|
||||
if reason == keychain.ErrMacKeychainRebuild.Error() {
|
||||
f.qml.NotifyRebuildKeychain()
|
||||
continue
|
||||
}
|
||||
f.qml.NotifyHasNoKeychain()
|
||||
case email := <-noActiveKeyForRecipientCh:
|
||||
f.qml.NoActiveKeyForRecipient(email)
|
||||
|
||||
@ -53,7 +53,7 @@ func (f *FrontendQt) reportBug(description, address, emailClient string, include
|
||||
core.QSysInfo_ProductType(),
|
||||
core.QSysInfo_PrettyProductName(),
|
||||
description,
|
||||
"Unknown account",
|
||||
address,
|
||||
address,
|
||||
emailClient,
|
||||
includeLogs,
|
||||
|
||||
@ -44,6 +44,8 @@ func (f *FrontendQt) initiateQtApplication() error {
|
||||
|
||||
core.QCoreApplication_SetApplicationName(f.programName)
|
||||
core.QCoreApplication_SetApplicationVersion(f.programVersion)
|
||||
core.QCoreApplication_SetOrganizationName("Proton AG")
|
||||
core.QCoreApplication_SetOrganizationDomain("proton.ch")
|
||||
|
||||
// High DPI scaling for windows.
|
||||
core.QCoreApplication_SetAttribute(core.Qt__AA_EnableHighDpiScaling, false)
|
||||
|
||||
@ -76,6 +76,15 @@ func (f *FrontendQt) changeLocalCache(enableDiskCache bool, diskCachePath *core.
|
||||
}
|
||||
|
||||
func (f *FrontendQt) setIsAutostartOn() {
|
||||
// GODT-1507 Windows: autostart needs to be created after Qt is initialized.
|
||||
f.firstTimeAutostart.Do(func() {
|
||||
if !f.bridge.IsFirstStart() {
|
||||
return
|
||||
}
|
||||
if err := f.bridge.EnableAutostart(); err != nil {
|
||||
f.log.WithError(err).Error("Failed to enable autostart")
|
||||
}
|
||||
})
|
||||
f.qml.SetIsAutostartOn(f.bridge.IsAutostartEnabled())
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ import (
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/internal/config/settings"
|
||||
"github.com/ProtonMail/proton-bridge/internal/updater"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var checkingUpdates = sync.Mutex{}
|
||||
@ -62,9 +63,18 @@ func (f *FrontendQt) checkUpdatesAndNotify(isRequestFromUser bool) {
|
||||
|
||||
if !f.updater.CanInstall(f.newVersionInfo) {
|
||||
f.log.Debug("A manual update is required")
|
||||
f.qml.UpdateManualReady(f.newVersionInfo.Version.String())
|
||||
f.qml.UpdateManualError()
|
||||
return
|
||||
}
|
||||
|
||||
if f.settings.GetBool(settings.AutoUpdateKey) {
|
||||
// NOOP will update eventually
|
||||
return
|
||||
}
|
||||
|
||||
if isRequestFromUser {
|
||||
f.qml.UpdateManualReady(f.newVersionInfo.Version.String())
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FrontendQt) updateForce() {
|
||||
@ -113,3 +123,26 @@ func (f *FrontendQt) toggleBeta(makeItEnabled bool) {
|
||||
// Immediately check the updates to set the correct landing page link.
|
||||
f.checkUpdates()
|
||||
}
|
||||
|
||||
func (f *FrontendQt) installUpdate() {
|
||||
checkingUpdates.Lock()
|
||||
defer checkingUpdates.Unlock()
|
||||
|
||||
if !f.updater.CanInstall(f.newVersionInfo) {
|
||||
f.log.Warning("Skipping update installation, current version too old")
|
||||
f.qml.UpdateManualError()
|
||||
return
|
||||
}
|
||||
|
||||
if err := f.updater.InstallUpdate(f.newVersionInfo); err != nil {
|
||||
if errors.Cause(err) == updater.ErrDownloadVerify {
|
||||
f.log.WithError(err).Warning("Skipping update installation due to temporary error")
|
||||
} else {
|
||||
f.log.WithError(err).Error("The update couldn't be installed")
|
||||
f.qml.UpdateManualError()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
f.qml.UpdateSilentRestartNeeded()
|
||||
}
|
||||
|
||||
@ -81,6 +81,7 @@ type QMLBackend struct {
|
||||
_ func() `signal:"updateIsLatestVersion"`
|
||||
_ func() `slot:"checkUpdates"`
|
||||
_ func() `signal:"checkUpdatesFinished"`
|
||||
_ func() `slot:"installUpdate"`
|
||||
|
||||
_ bool `property:"isDiskCacheEnabled"`
|
||||
_ core.QUrl `property:"diskCachePath"`
|
||||
@ -142,6 +143,7 @@ type QMLBackend struct {
|
||||
_ func(keychain string) `slot:"changeKeychain"`
|
||||
_ func() `signal:"changeKeychainFinished"`
|
||||
_ func() `signal:"notifyHasNoKeychain"`
|
||||
_ func() `signal:"notifyRebuildKeychain"`
|
||||
|
||||
_ func(email string) `signal:noActiveKeyForRecipient`
|
||||
_ func() `signal:showMainWindow`
|
||||
@ -212,6 +214,13 @@ func (q *QMLBackend) setup(f *FrontendQt) {
|
||||
}()
|
||||
})
|
||||
|
||||
q.ConnectInstallUpdate(func() {
|
||||
go func() {
|
||||
defer f.panicHandler.HandlePanic()
|
||||
f.installUpdate()
|
||||
}()
|
||||
})
|
||||
|
||||
f.setIsDiskCacheEnabled()
|
||||
f.setDiskCachePath()
|
||||
q.ConnectChangeLocalCache(func(e bool, d *core.QUrl) {
|
||||
|
||||
@ -194,7 +194,7 @@ func (store *Store) BuildAndCacheMessage(ctx context.Context, messageID string)
|
||||
}
|
||||
|
||||
func (store *Store) checkAndRemoveDeletedMessage(err error, msgID string) {
|
||||
if _, ok := err.(pmapi.ErrUnprocessableEntity); !ok {
|
||||
if !pmapi.IsUnprocessableEntity(err) {
|
||||
return
|
||||
}
|
||||
l := store.log.WithError(err).WithField("msgID", msgID)
|
||||
|
||||
@ -127,6 +127,6 @@ func TestCooldownNotSooner(t *testing.T) {
|
||||
assert.True(t, testCooldown.isTooSoon())
|
||||
|
||||
// After given wait time it shouldn't be soon anymore.
|
||||
time.Sleep(waitTime / 2)
|
||||
time.Sleep(waitTime/2 + time.Millisecond)
|
||||
assert.False(t, testCooldown.isTooSoon())
|
||||
}
|
||||
|
||||
@ -243,7 +243,7 @@ func (loop *eventLoop) processNextEvent() (more bool, err error) { // nolint[fun
|
||||
}
|
||||
|
||||
// All errors except ErrUnauthorized (which is not possible to recover from) are ignored.
|
||||
if err != nil && errors.Cause(err) != pmapi.ErrUnauthorized {
|
||||
if err != nil && !pmapi.IsFailedAuth(errors.Cause(err)) && errors.Cause(err) != pmapi.ErrUnauthorized {
|
||||
l.WithError(err).WithField("errors", loop.errCounter).Error("Error skipped")
|
||||
loop.errCounter++
|
||||
if loop.errCounter == errMaxSentry {
|
||||
@ -477,7 +477,7 @@ func (loop *eventLoop) processMessages(eventLog *logrus.Entry, messages []*pmapi
|
||||
msgLog.WithError(err).Warning("Message was not present in DB. Trying fetch...")
|
||||
|
||||
if msg, err = loop.client().GetMessage(context.Background(), message.ID); err != nil {
|
||||
if _, ok := err.(pmapi.ErrUnprocessableEntity); ok {
|
||||
if pmapi.IsUnprocessableEntity(err) {
|
||||
msgLog.WithError(err).Warn("Skipping message update because message exists neither in local DB nor on API")
|
||||
err = nil
|
||||
continue
|
||||
|
||||
@ -69,6 +69,7 @@ func newUser(
|
||||
|
||||
creds, err := credStorer.Get(userID)
|
||||
if err != nil {
|
||||
notifyKeychainRepair(eventListener, err)
|
||||
return nil, nil, errors.Wrap(err, "failed to load user credentials")
|
||||
}
|
||||
|
||||
@ -162,6 +163,7 @@ func (u *User) handleAuthRefresh(auth *pmapi.AuthRefresh) {
|
||||
|
||||
creds, err := u.credStorer.UpdateToken(u.userID, auth.UID, auth.RefreshToken)
|
||||
if err != nil {
|
||||
notifyKeychainRepair(u.listener, err)
|
||||
u.log.WithError(err).Error("Failed to update refresh token in credentials store")
|
||||
return
|
||||
}
|
||||
@ -223,7 +225,7 @@ func (u *User) UpdateSpace(apiUser *pmapi.User) {
|
||||
// values from client.CurrentUser()
|
||||
if apiUser == nil {
|
||||
var err error
|
||||
apiUser, err = u.client.GetUser(pmapi.ContextWithoutRetry(context.Background()))
|
||||
apiUser, err = u.GetClient().GetUser(pmapi.ContextWithoutRetry(context.Background()))
|
||||
if err != nil {
|
||||
u.log.WithError(err).Warning("Cannot update user space")
|
||||
return
|
||||
@ -280,18 +282,23 @@ func (u *User) unlockIfNecessary() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch errors.Cause(err) {
|
||||
case pmapi.ErrNoConnection, pmapi.ErrUpgradeApplication:
|
||||
u.log.WithError(err).Warn("Could not unlock user")
|
||||
return nil
|
||||
}
|
||||
|
||||
if pmapi.IsFailedAuth(err) || pmapi.IsFailedUnlock(err) {
|
||||
if logoutErr := u.logout(); logoutErr != nil {
|
||||
u.log.WithError(logoutErr).Warn("Could not logout user")
|
||||
}
|
||||
return errors.Wrap(err, "failed to unlock user")
|
||||
}
|
||||
|
||||
switch errors.Cause(err) {
|
||||
case pmapi.ErrNoConnection, pmapi.ErrUpgradeApplication:
|
||||
u.log.WithError(err).Warn("Skipping unlock for known reason")
|
||||
default:
|
||||
u.log.WithError(err).Error("Unknown unlock issue")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsCombinedAddressMode returns whether user is set in combined or split mode.
|
||||
// Combined mode is the default mode and is what users typically need.
|
||||
// Split mode is mostly for outlook as it cannot handle sending e-mails from an
|
||||
@ -345,6 +352,10 @@ func (u *User) GetAddressID(address string) (id string, err error) {
|
||||
return u.store.GetAddressID(address)
|
||||
}
|
||||
|
||||
if u.client == nil {
|
||||
return "", errors.New("bridge account is not fully connected to server")
|
||||
}
|
||||
|
||||
addresses := u.client.Addresses()
|
||||
pmapiAddress := addresses.ByEmail(address)
|
||||
if pmapiAddress != nil {
|
||||
@ -399,6 +410,7 @@ func (u *User) UpdateUser(ctx context.Context) error {
|
||||
|
||||
creds, err := u.credStorer.UpdateEmails(u.userID, u.client.Addresses().ActiveEmails())
|
||||
if err != nil {
|
||||
notifyKeychainRepair(u.listener, err)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -436,6 +448,7 @@ func (u *User) SwitchAddressMode() error {
|
||||
|
||||
creds, err := u.credStorer.SwitchAddressMode(u.userID)
|
||||
if err != nil {
|
||||
notifyKeychainRepair(u.listener, err)
|
||||
return errors.Wrap(err, "could not switch credentials store address mode")
|
||||
}
|
||||
|
||||
@ -473,15 +486,19 @@ func (u *User) Logout() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := u.client.AuthDelete(context.Background()); err != nil {
|
||||
if u.client == nil {
|
||||
u.log.Warn("Failed to delete auth: no client")
|
||||
} else if err := u.client.AuthDelete(context.Background()); err != nil {
|
||||
u.log.WithError(err).Warn("Failed to delete auth")
|
||||
}
|
||||
|
||||
creds, err := u.credStorer.Logout(u.userID)
|
||||
if err != nil {
|
||||
notifyKeychainRepair(u.listener, err)
|
||||
u.log.WithError(err).Warn("Could not log user out from credentials store")
|
||||
|
||||
if err := u.credStorer.Delete(u.userID); err != nil {
|
||||
notifyKeychainRepair(u.listener, err)
|
||||
u.log.WithError(err).Error("Could not delete user from credentials store")
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -170,7 +170,7 @@ func TestCheckBridgeLoginLoggedOut(t *testing.T) {
|
||||
// Mock init of user.
|
||||
m.credentialsStore.EXPECT().Get("user").Return(testCredentialsDisconnected, nil),
|
||||
m.pmapiClient.EXPECT().AddAuthRefreshHandler(gomock.Any()),
|
||||
m.pmapiClient.EXPECT().ListLabels(gomock.Any()).Return(nil, errors.New("ErrUnauthorized")),
|
||||
m.pmapiClient.EXPECT().ListLabels(gomock.Any()).Return(nil, pmapi.ErrUnauthorized),
|
||||
m.pmapiClient.EXPECT().Addresses().Return(nil),
|
||||
|
||||
// Mock CheckBridgeLogin.
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/internal/events"
|
||||
"github.com/ProtonMail/proton-bridge/internal/users/credentials"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
r "github.com/stretchr/testify/require"
|
||||
)
|
||||
@ -46,7 +47,7 @@ func TestNewUserUnlockFails(t *testing.T) {
|
||||
m.credentialsStore.EXPECT().Get("user").Return(testCredentials, nil),
|
||||
m.pmapiClient.EXPECT().AddAuthRefreshHandler(gomock.Any()),
|
||||
m.pmapiClient.EXPECT().IsUnlocked().Return(false),
|
||||
m.pmapiClient.EXPECT().Unlock(gomock.Any(), testCredentials.MailboxPassword).Return(errors.New("bad password")),
|
||||
m.pmapiClient.EXPECT().Unlock(gomock.Any(), testCredentials.MailboxPassword).Return(pmapi.ErrUnlockFailed{OriginalError: errors.New("bad password")}),
|
||||
|
||||
// Handle of unlock error.
|
||||
m.pmapiClient.EXPECT().AuthDelete(gomock.Any()).Return(nil),
|
||||
|
||||
@ -27,6 +27,7 @@ import (
|
||||
"github.com/ProtonMail/proton-bridge/internal/events"
|
||||
"github.com/ProtonMail/proton-bridge/internal/metrics"
|
||||
"github.com/ProtonMail/proton-bridge/internal/users/credentials"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/keychain"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/listener"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
@ -130,6 +131,7 @@ func (u *Users) loadUsersFromCredentialsStore() error {
|
||||
|
||||
userIDs, err := u.credStorer.List()
|
||||
if err != nil {
|
||||
notifyKeychainRepair(u.events, err)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -178,14 +180,17 @@ func (u *Users) loadConnectedUser(ctx context.Context, user *User, creds *creden
|
||||
return connectErr
|
||||
}
|
||||
|
||||
if pmapi.IsFailedAuth(connectErr) {
|
||||
if logoutErr := user.logout(); logoutErr != nil {
|
||||
logrus.WithError(logoutErr).Warn("Could not logout user")
|
||||
}
|
||||
}
|
||||
return errors.Wrap(err, "could not refresh token")
|
||||
}
|
||||
|
||||
// Update the user's credentials with the latest auth used to connect this user.
|
||||
if creds, err = u.credStorer.UpdateToken(creds.UserID, auth.UID, auth.RefreshToken); err != nil {
|
||||
notifyKeychainRepair(u.events, err)
|
||||
return errors.Wrap(err, "could not create get user's refresh token")
|
||||
}
|
||||
|
||||
@ -224,12 +229,14 @@ func (u *Users) FinishLogin(client pmapi.Client, auth *pmapi.Auth, password []by
|
||||
|
||||
// Update the user's credentials with the latest auth used to connect this user.
|
||||
if _, err := u.credStorer.UpdateToken(auth.UserID, auth.UID, auth.RefreshToken); err != nil {
|
||||
notifyKeychainRepair(u.events, err)
|
||||
return nil, errors.Wrap(err, "failed to load user credentials")
|
||||
}
|
||||
|
||||
// Update the password in case the user changed it.
|
||||
creds, err := u.credStorer.UpdatePassword(apiUser.ID, passphrase)
|
||||
if err != nil {
|
||||
notifyKeychainRepair(u.events, err)
|
||||
return nil, errors.Wrap(err, "failed to update password of user in credentials store")
|
||||
}
|
||||
|
||||
@ -258,6 +265,7 @@ func (u *Users) addNewUser(client pmapi.Client, apiUser *pmapi.User, auth *pmapi
|
||||
defer u.lock.Unlock()
|
||||
|
||||
if _, err := u.credStorer.Add(apiUser.ID, apiUser.Name, auth.UID, auth.RefreshToken, passphrase, client.Addresses().ActiveEmails()); err != nil {
|
||||
notifyKeychainRepair(u.events, err)
|
||||
return errors.Wrap(err, "failed to add user credentials to credentials store")
|
||||
}
|
||||
|
||||
@ -382,6 +390,7 @@ func (u *Users) DeleteUser(userID string, clearStore bool) error {
|
||||
}
|
||||
|
||||
if err := u.credStorer.Delete(userID); err != nil {
|
||||
notifyKeychainRepair(u.events, err)
|
||||
log.WithError(err).Error("Cannot remove user")
|
||||
return err
|
||||
}
|
||||
@ -441,3 +450,9 @@ func (u *Users) crashBandicoot(username string) {
|
||||
panic("Your wish is my command… I crash!")
|
||||
}
|
||||
}
|
||||
|
||||
func notifyKeychainRepair(l listener.Listener, err error) {
|
||||
if err == keychain.ErrMacKeychainRebuild {
|
||||
l.Emit(events.CredentialsErrorEvent, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/internal/events"
|
||||
"github.com/ProtonMail/proton-bridge/internal/users/credentials"
|
||||
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
r "github.com/stretchr/testify/require"
|
||||
)
|
||||
@ -80,11 +81,11 @@ func TestNewUsersWithConnectedUserWithBadToken(t *testing.T) {
|
||||
m := initMocks(t)
|
||||
defer m.ctrl.Finish()
|
||||
|
||||
m.clientManager.EXPECT().NewClientWithRefresh(gomock.Any(), "uid", "acc").Return(nil, nil, errors.New("bad token"))
|
||||
m.clientManager.EXPECT().NewClientWithRefresh(gomock.Any(), "uid", "acc").Return(nil, nil, pmapi.ErrAuthFailed{OriginalError: errors.New("bad token")})
|
||||
m.clientManager.EXPECT().NewClient("uid", "", "acc", time.Time{}).Return(m.pmapiClient)
|
||||
m.pmapiClient.EXPECT().AddAuthRefreshHandler(gomock.Any())
|
||||
m.pmapiClient.EXPECT().IsUnlocked().Return(false)
|
||||
m.pmapiClient.EXPECT().Unlock(gomock.Any(), testCredentials.MailboxPassword).Return(errors.New("not authorized"))
|
||||
m.pmapiClient.EXPECT().Unlock(gomock.Any(), testCredentials.MailboxPassword).Return(pmapi.ErrAuthFailed{OriginalError: errors.New("not authorized")})
|
||||
m.pmapiClient.EXPECT().AuthDelete(gomock.Any())
|
||||
|
||||
m.credentialsStore.EXPECT().List().Return([]string{"user"}, nil)
|
||||
@ -93,7 +94,6 @@ func TestNewUsersWithConnectedUserWithBadToken(t *testing.T) {
|
||||
|
||||
m.eventListener.EXPECT().Emit(events.UserRefreshEvent, "user")
|
||||
m.eventListener.EXPECT().Emit(events.LogoutEvent, "user")
|
||||
m.eventListener.EXPECT().Emit(events.UserRefreshEvent, "user")
|
||||
m.eventListener.EXPECT().Emit(events.CloseConnectionEvent, "user@pm.me")
|
||||
|
||||
checkUsersNew(t, m, []*credentials.Credentials{testCredentialsDisconnected})
|
||||
|
||||
@ -37,7 +37,6 @@ import (
|
||||
pmapimocks "github.com/ProtonMail/proton-bridge/pkg/pmapi/mocks"
|
||||
tests "github.com/ProtonMail/proton-bridge/test"
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
r "github.com/stretchr/testify/require"
|
||||
)
|
||||
@ -331,7 +330,7 @@ func mockInitDisconnectedUser(m mocks) {
|
||||
m.pmapiClient.EXPECT().AddAuthRefreshHandler(gomock.Any()),
|
||||
|
||||
// Mock of store initialisation for the unauthorized user.
|
||||
m.pmapiClient.EXPECT().ListLabels(gomock.Any()).Return(nil, errors.New("ErrUnauthorized")),
|
||||
m.pmapiClient.EXPECT().ListLabels(gomock.Any()).Return(nil, pmapi.ErrUnauthorized),
|
||||
m.pmapiClient.EXPECT().Addresses().Return(nil),
|
||||
)
|
||||
}
|
||||
|
||||
@ -40,6 +40,16 @@ func init() { // nolint[noinit]
|
||||
defaultHelper = MacOSKeychain
|
||||
}
|
||||
|
||||
func parseError(original error) error {
|
||||
if original == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(original.Error(), "25293") {
|
||||
return ErrMacKeychainRebuild
|
||||
}
|
||||
return original
|
||||
}
|
||||
|
||||
func newMacOSHelper(url string) (credentials.Helper, error) {
|
||||
return &macOSHelper{url: url}, nil
|
||||
}
|
||||
@ -76,7 +86,7 @@ func (h *macOSHelper) Add(creds *credentials.Credentials) error {
|
||||
|
||||
query := newQuery(hostURL, userID)
|
||||
query.SetData([]byte(creds.Secret))
|
||||
return keychain.AddItem(query)
|
||||
return parseError(keychain.AddItem(query))
|
||||
}
|
||||
|
||||
func (h *macOSHelper) Delete(secretURL string) error {
|
||||
@ -87,7 +97,7 @@ func (h *macOSHelper) Delete(secretURL string) error {
|
||||
|
||||
query := newQuery(hostURL, userID)
|
||||
|
||||
return keychain.DeleteItem(query)
|
||||
return parseError(keychain.DeleteItem(query))
|
||||
}
|
||||
|
||||
func (h *macOSHelper) Get(secretURL string) (string, string, error) {
|
||||
@ -102,7 +112,7 @@ func (h *macOSHelper) Get(secretURL string) (string, string, error) {
|
||||
|
||||
results, err := keychain.QueryItem(query)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return "", "", parseError(err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
@ -121,7 +131,7 @@ func (h *macOSHelper) List() (map[string]string, error) {
|
||||
|
||||
userIDs, err := keychain.GetGenericPasswordAccounts(h.url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, parseError(err)
|
||||
}
|
||||
|
||||
for _, userID := range userIDs {
|
||||
|
||||
220
pkg/keychain/helper_dbus_linux.go
Normal file
220
pkg/keychain/helper_dbus_linux.go
Normal file
@ -0,0 +1,220 @@
|
||||
// Copyright (c) 2022 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 keychain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker-credential-helpers/credentials"
|
||||
"github.com/godbus/dbus"
|
||||
"github.com/keybase/go-keychain/secretservice"
|
||||
)
|
||||
|
||||
const (
|
||||
serverAtt = "server"
|
||||
labelAtt = "label"
|
||||
usernameAtt = "username"
|
||||
|
||||
defaulDomain = "protonmail/bridge/users/"
|
||||
defaultLabel = "Docker Credentials"
|
||||
)
|
||||
|
||||
func getSession() (*secretservice.SecretService, *secretservice.Session, error) {
|
||||
service, err := secretservice.NewService()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
session, err := service.OpenSession(secretservice.AuthenticationDHAES)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return service, session, nil
|
||||
}
|
||||
|
||||
func handleTimeout(f func() error) error {
|
||||
err := f()
|
||||
if err == secretservice.ErrPromptTimedOut {
|
||||
return f()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func getItems(service *secretservice.SecretService, attributes map[string]string) ([]dbus.ObjectPath, error) {
|
||||
if err := unlock(service); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var items []dbus.ObjectPath
|
||||
err := handleTimeout(func() error {
|
||||
var err error
|
||||
items, err = service.SearchCollection(
|
||||
secretservice.DefaultCollection,
|
||||
attributes,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return items, err
|
||||
}
|
||||
|
||||
func unlock(service *secretservice.SecretService) error {
|
||||
return handleTimeout(func() error {
|
||||
return service.Unlock([]dbus.ObjectPath{secretservice.DefaultCollection})
|
||||
})
|
||||
}
|
||||
|
||||
// SecretServiceDBusHelper is wrapper around keybase/go-keychain/secretservice
|
||||
// library.
|
||||
type SecretServiceDBusHelper struct{}
|
||||
|
||||
// Add appends credentials to the store.
|
||||
func (s *SecretServiceDBusHelper) Add(creds *credentials.Credentials) error {
|
||||
service, session, err := getSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer service.CloseSession(session)
|
||||
|
||||
if err := unlock(service); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
secret, err := session.NewSecret([]byte(creds.Secret))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
attributes := map[string]string{
|
||||
usernameAtt: creds.Username,
|
||||
serverAtt: creds.ServerURL,
|
||||
labelAtt: defaultLabel,
|
||||
"xdg:schema": "io.docker.Credentials",
|
||||
"docker_cli": "1",
|
||||
}
|
||||
|
||||
return handleTimeout(func() error {
|
||||
_, err = service.CreateItem(
|
||||
secretservice.DefaultCollection,
|
||||
secretservice.NewSecretProperties(creds.ServerURL, attributes),
|
||||
secret,
|
||||
secretservice.ReplaceBehaviorReplace,
|
||||
)
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// Delete removes credentials from the store.
|
||||
func (s *SecretServiceDBusHelper) Delete(serverURL string) error {
|
||||
service, session, err := getSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer service.CloseSession(session)
|
||||
|
||||
items, err := getItems(service, map[string]string{
|
||||
labelAtt: defaultLabel,
|
||||
serverAtt: serverURL,
|
||||
})
|
||||
|
||||
if len(items) == 0 || err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return handleTimeout(func() error {
|
||||
return service.DeleteItem(items[0])
|
||||
})
|
||||
}
|
||||
|
||||
// Get retrieves credentials from the store.
|
||||
// It returns username and secret as strings.
|
||||
func (s *SecretServiceDBusHelper) Get(serverURL string) (string, string, error) {
|
||||
service, session, err := getSession()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer service.CloseSession(session)
|
||||
|
||||
if err := unlock(service); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
items, err := getItems(service, map[string]string{
|
||||
labelAtt: defaultLabel,
|
||||
serverAtt: serverURL,
|
||||
})
|
||||
|
||||
if len(items) == 0 || err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
item := items[0]
|
||||
|
||||
attributes, err := service.GetAttributes(item)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var secretPlaintext []byte
|
||||
err = handleTimeout(func() error {
|
||||
var err error
|
||||
secretPlaintext, err = service.GetSecret(item, *session)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return attributes[usernameAtt], string(secretPlaintext), nil
|
||||
}
|
||||
|
||||
// List returns the stored serverURLs and their associated usernames.
|
||||
func (s *SecretServiceDBusHelper) List() (map[string]string, error) {
|
||||
userIDByURL := make(map[string]string)
|
||||
|
||||
service, session, err := getSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer service.CloseSession(session)
|
||||
|
||||
items, err := getItems(service, map[string]string{labelAtt: defaultLabel})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, it := range items {
|
||||
attributes, err := service.GetAttributes(it)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(attributes[serverAtt], defaulDomain) {
|
||||
continue
|
||||
}
|
||||
|
||||
userIDByURL[attributes[serverAtt]] = attributes[usernameAtt]
|
||||
}
|
||||
|
||||
return userIDByURL, nil
|
||||
}
|
||||
@ -29,34 +29,45 @@ import (
|
||||
|
||||
const (
|
||||
Pass = "pass-app"
|
||||
GnomeKeyring = "gnome-keyring"
|
||||
SecretService = "secret-service"
|
||||
SecretServiceDBus = "secret-service-dbus"
|
||||
)
|
||||
|
||||
func init() { // nolint[noinit]
|
||||
Helpers = make(map[string]helperConstructor)
|
||||
|
||||
if _, err := exec.LookPath("pass"); err == nil {
|
||||
if isUsable(newDBusHelper("")) {
|
||||
Helpers[SecretServiceDBus] = newDBusHelper
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("gnome-keyring"); err == nil && isUsable(newSecretServiceHelper("")) {
|
||||
Helpers[SecretService] = newSecretServiceHelper
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("pass"); err == nil && isUsable(newPassHelper("")) {
|
||||
Helpers[Pass] = newPassHelper
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("gnome-keyring"); err == nil {
|
||||
Helpers[GnomeKeyring] = newGnomeKeyringHelper
|
||||
}
|
||||
defaultHelper = SecretServiceDBus
|
||||
|
||||
// If Pass is available, use it by default.
|
||||
// Otherwise, if GnomeKeyring is available, use it by default.
|
||||
if _, ok := Helpers[Pass]; ok && isUsable(newPassHelper("")) {
|
||||
// Otherwise, if SecretService is available, use it by default.
|
||||
if _, ok := Helpers[Pass]; ok {
|
||||
defaultHelper = Pass
|
||||
} else if _, ok := Helpers[GnomeKeyring]; ok && isUsable(newGnomeKeyringHelper("")) {
|
||||
defaultHelper = GnomeKeyring
|
||||
} else if _, ok := Helpers[SecretService]; ok {
|
||||
defaultHelper = SecretService
|
||||
}
|
||||
}
|
||||
|
||||
func newDBusHelper(string) (credentials.Helper, error) {
|
||||
return &SecretServiceDBusHelper{}, nil
|
||||
}
|
||||
|
||||
func newPassHelper(string) (credentials.Helper, error) {
|
||||
return &pass.Pass{}, nil
|
||||
}
|
||||
|
||||
func newGnomeKeyringHelper(string) (credentials.Helper, error) {
|
||||
func newSecretServiceHelper(string) (credentials.Helper, error) {
|
||||
return &secretservice.Secretservice{}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -37,6 +37,9 @@ var (
|
||||
// ErrNoKeychain indicates that no suitable keychain implementation could be loaded.
|
||||
ErrNoKeychain = errors.New("no keychain") // nolint[noglobals]
|
||||
|
||||
// ErrMacKeychainRebuild is returned on macOS with blocked or corrupted keychain.
|
||||
ErrMacKeychainRebuild = errors.New("keychain error -25293")
|
||||
|
||||
// Helpers holds all discovered keychain helpers. It is populated in init().
|
||||
Helpers map[string]helperConstructor // nolint[noglobals]
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ var (
|
||||
wantOutput = []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
testProcessSleep = 100 // ms
|
||||
runParallelTimeOverhead = 150 // ms
|
||||
windowsCIExtra = 250 // ms - estimated experimentally
|
||||
windowsCIExtra = 500 // ms - estimated experimentally
|
||||
)
|
||||
|
||||
func TestParallel(t *testing.T) {
|
||||
|
||||
@ -19,11 +19,11 @@ package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/gopenpgp/v2/crypto"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Address statuses.
|
||||
@ -201,7 +201,7 @@ func (c *client) unlockAddress(passphrase []byte, address *Address) error {
|
||||
|
||||
kr, err := address.Keys.UnlockAll(passphrase, c.userKeyRing)
|
||||
if err != nil {
|
||||
return err
|
||||
return errors.Wrap(err, "cannot unlock address keys for "+address.ID)
|
||||
}
|
||||
|
||||
c.addrKeyRing[address.ID] = kr
|
||||
|
||||
@ -51,7 +51,7 @@ type TwoFAInfo struct {
|
||||
}
|
||||
|
||||
func (twoFAInfo TwoFAInfo) hasTwoFactor() bool {
|
||||
return twoFAInfo.Enabled > 0
|
||||
return twoFAInfo.Enabled > TwoFADisabled
|
||||
}
|
||||
|
||||
type TwoFAStatus int
|
||||
@ -185,7 +185,7 @@ func (c *client) authRefresh(ctx context.Context) error {
|
||||
|
||||
auth, err := c.manager.authRefresh(ctx, c.uid, c.ref)
|
||||
if err != nil {
|
||||
if err != ErrNoConnection {
|
||||
if IsFailedAuth(err) {
|
||||
c.sendAuthRefresh(nil)
|
||||
}
|
||||
return err
|
||||
|
||||
122
pkg/pmapi/auth_server_test.go
Normal file
122
pkg/pmapi/auth_server_test.go
Normal file
@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2022 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 pmapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testRefreshResponse struct {
|
||||
Code int
|
||||
AccessToken string
|
||||
ExpiresIn int
|
||||
TokenType string
|
||||
Scope string
|
||||
Scopes []string
|
||||
UID string
|
||||
RefreshToken string
|
||||
LocalID int
|
||||
|
||||
r *require.Assertions
|
||||
}
|
||||
|
||||
var tokenID = 0
|
||||
|
||||
func newTestRefreshToken(r *require.Assertions) testRefreshResponse {
|
||||
tokenID++
|
||||
scopes := []string{
|
||||
"full",
|
||||
"self",
|
||||
"parent",
|
||||
"user",
|
||||
"loggedin",
|
||||
"paid",
|
||||
"nondelinquent",
|
||||
"mail",
|
||||
"verified",
|
||||
}
|
||||
return testRefreshResponse{
|
||||
Code: 1000,
|
||||
AccessToken: fmt.Sprintf("acc%d", tokenID),
|
||||
ExpiresIn: 3600,
|
||||
TokenType: "Bearer",
|
||||
Scope: strings.Join(scopes, " "),
|
||||
Scopes: scopes,
|
||||
UID: fmt.Sprintf("uid%d", tokenID),
|
||||
RefreshToken: fmt.Sprintf("ref%d", tokenID),
|
||||
r: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *testRefreshResponse) isCorrectRefreshToken(body io.ReadCloser) int {
|
||||
request := authRefreshReq{}
|
||||
err := json.NewDecoder(body).Decode(&request)
|
||||
r.r.NoError(body.Close())
|
||||
r.r.NoError(err)
|
||||
|
||||
if r.UID != request.UID {
|
||||
return http.StatusUnprocessableEntity
|
||||
}
|
||||
if r.RefreshToken != request.RefreshToken {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
return http.StatusOK
|
||||
}
|
||||
|
||||
func (r *testRefreshResponse) handleAuthRefresh(response http.ResponseWriter, request *http.Request) {
|
||||
if code := r.isCorrectRefreshToken(request.Body); code != http.StatusOK {
|
||||
response.WriteHeader(code)
|
||||
return
|
||||
}
|
||||
|
||||
tokenID++
|
||||
r.AccessToken = fmt.Sprintf("acc%d", tokenID)
|
||||
r.RefreshToken = fmt.Sprintf("ref%d", tokenID)
|
||||
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
response.WriteHeader(http.StatusOK)
|
||||
r.r.NoError(json.NewEncoder(response).Encode(r))
|
||||
}
|
||||
|
||||
func (r *testRefreshResponse) wantAuthRefresh() AuthRefresh {
|
||||
return AuthRefresh{
|
||||
UID: r.UID,
|
||||
AccessToken: r.AccessToken,
|
||||
RefreshToken: r.RefreshToken,
|
||||
ExpiresIn: int64(r.ExpiresIn),
|
||||
Scopes: r.Scopes,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *testRefreshResponse) isAuthorized(header http.Header) bool {
|
||||
return header.Get("x-pm-uid") == r.UID && header.Get("Authorization") == "Bearer "+r.AccessToken
|
||||
}
|
||||
|
||||
func (r *testRefreshResponse) handleAuthCheckOnly(response http.ResponseWriter, request *http.Request) {
|
||||
if r.isAuthorized(request.Header) {
|
||||
response.WriteHeader(http.StatusOK)
|
||||
} else {
|
||||
response.WriteHeader(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
@ -25,179 +25,210 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
a "github.com/stretchr/testify/assert"
|
||||
r "github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAutomaticAuthRefresh(t *testing.T) {
|
||||
var wantAuthRefresh = &AuthRefresh{
|
||||
UID: "testUID",
|
||||
AccessToken: "testAcc",
|
||||
RefreshToken: "testRef",
|
||||
ExpiresIn: 100,
|
||||
}
|
||||
|
||||
r := require.New(t)
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
currentTokens := newTestRefreshToken(r)
|
||||
testUID := currentTokens.UID
|
||||
testAcc := currentTokens.AccessToken
|
||||
testRef := currentTokens.RefreshToken
|
||||
currentTokens.ExpiresIn = 100
|
||||
|
||||
if err := json.NewEncoder(w).Encode(wantAuthRefresh); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
|
||||
mux.HandleFunc("/addresses", currentTokens.handleAuthCheckOnly)
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
|
||||
var gotAuthRefresh *AuthRefresh
|
||||
|
||||
c := New(Config{HostURL: ts.URL}).
|
||||
NewClient("uid", "acc", "ref", time.Now().Add(-time.Second))
|
||||
NewClient(testUID, testAcc, testRef, time.Now().Add(-time.Second))
|
||||
|
||||
c.AddAuthRefreshHandler(func(auth *AuthRefresh) { gotAuthRefresh = auth })
|
||||
|
||||
// Make a request with an access token that already expired one second ago.
|
||||
_, err := c.GetAddresses(context.Background())
|
||||
r.NoError(t, err)
|
||||
r.NoError(err)
|
||||
|
||||
wantAuthRefresh := currentTokens.wantAuthRefresh()
|
||||
|
||||
// The auth callback should have been called.
|
||||
a.Equal(t, *wantAuthRefresh, *gotAuthRefresh)
|
||||
r.NotNil(gotAuthRefresh)
|
||||
r.Equal(wantAuthRefresh, *gotAuthRefresh)
|
||||
|
||||
cl := c.(*client) //nolint[forcetypeassert] we want to panic here
|
||||
a.Equal(t, wantAuthRefresh.AccessToken, cl.acc)
|
||||
a.Equal(t, wantAuthRefresh.RefreshToken, cl.ref)
|
||||
a.WithinDuration(t, expiresIn(100), cl.exp, time.Second)
|
||||
r.Equal(wantAuthRefresh.AccessToken, cl.acc)
|
||||
r.Equal(wantAuthRefresh.RefreshToken, cl.ref)
|
||||
r.WithinDuration(expiresIn(100), cl.exp, time.Second)
|
||||
}
|
||||
|
||||
func Test401AuthRefresh(t *testing.T) {
|
||||
var wantAuthRefresh = &AuthRefresh{
|
||||
UID: "testUID",
|
||||
AccessToken: "testAcc",
|
||||
RefreshToken: "testRef",
|
||||
}
|
||||
r := require.New(t)
|
||||
currentTokens := newTestRefreshToken(r)
|
||||
testUID := currentTokens.UID
|
||||
testRef := currentTokens.RefreshToken
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(wantAuthRefresh); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
var call int
|
||||
|
||||
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
|
||||
call++
|
||||
|
||||
if call == 1 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
|
||||
mux.HandleFunc("/addresses", currentTokens.handleAuthCheckOnly)
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
|
||||
var gotAuthRefresh *AuthRefresh
|
||||
|
||||
// Create a new client.
|
||||
c := New(Config{HostURL: ts.URL}).
|
||||
NewClient("uid", "acc", "ref", time.Now().Add(time.Hour))
|
||||
m := New(Config{HostURL: ts.URL})
|
||||
c := m.NewClient(testUID, "oldAccToken", testRef, time.Now().Add(time.Hour))
|
||||
|
||||
// Register an auth handler.
|
||||
c.AddAuthRefreshHandler(func(auth *AuthRefresh) { gotAuthRefresh = auth })
|
||||
|
||||
// The first request will fail with 401, triggering a refresh and retry.
|
||||
_, err := c.GetAddresses(context.Background())
|
||||
r.NoError(t, err)
|
||||
r.NoError(err)
|
||||
|
||||
// The auth callback should have been called.
|
||||
r.Equal(t, *wantAuthRefresh, *gotAuthRefresh)
|
||||
r.NotNil(gotAuthRefresh)
|
||||
r.Equal(currentTokens.wantAuthRefresh(), *gotAuthRefresh)
|
||||
}
|
||||
|
||||
func Test401RevokedAuth(t *testing.T) {
|
||||
r := require.New(t)
|
||||
currentTokens := newTestRefreshToken(r)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
|
||||
mux.HandleFunc("/addresses", currentTokens.handleAuthCheckOnly)
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
|
||||
c := New(Config{HostURL: ts.URL}).
|
||||
NewClient("uid", "acc", "ref", time.Now().Add(time.Hour))
|
||||
NewClient("badUID", "badAcc", "badRef", time.Now().Add(time.Hour))
|
||||
|
||||
// The request will fail with 401, triggering a refresh.
|
||||
// The retry will also fail with 401, returning an error.
|
||||
_, err := c.GetAddresses(context.Background())
|
||||
r.EqualError(t, err, ErrUnauthorized.Error())
|
||||
r.True(IsFailedAuth(err))
|
||||
}
|
||||
|
||||
func Test401RevokedAuthTokenUpdate(t *testing.T) {
|
||||
var oldAuth = &AuthRefresh{
|
||||
UID: "UID",
|
||||
AccessToken: "oldAcc",
|
||||
RefreshToken: "oldRef",
|
||||
ExpiresIn: 3600,
|
||||
}
|
||||
|
||||
var newAuth = &AuthRefresh{
|
||||
UID: "UID",
|
||||
AccessToken: "newAcc",
|
||||
RefreshToken: "newRef",
|
||||
}
|
||||
func Test401OldRefreshToken(t *testing.T) {
|
||||
r := require.New(t)
|
||||
currentTokens := newTestRefreshToken(r)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
|
||||
mux.HandleFunc("/addresses", currentTokens.handleAuthCheckOnly)
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
|
||||
c := New(Config{HostURL: ts.URL}).
|
||||
NewClient(currentTokens.UID, "oldAcc", "oldRef", time.Now().Add(time.Hour))
|
||||
|
||||
// The request will fail with 401, triggering a refresh.
|
||||
// The retry will also fail with 401, returning an error.
|
||||
_, err := c.GetAddresses(context.Background())
|
||||
r.True(IsFailedAuth(err))
|
||||
}
|
||||
|
||||
func Test401NoAccessToken(t *testing.T) {
|
||||
r := require.New(t)
|
||||
currentTokens := newTestRefreshToken(r)
|
||||
testUID := currentTokens.UID
|
||||
testRef := currentTokens.RefreshToken
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
|
||||
mux.HandleFunc("/addresses", currentTokens.handleAuthCheckOnly)
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
|
||||
c := New(Config{HostURL: ts.URL}).
|
||||
NewClient(testUID, "", testRef, time.Now().Add(time.Hour))
|
||||
|
||||
// The request will fail with 401, triggering a refresh. After the refresh it should succeed.
|
||||
_, err := c.GetAddresses(context.Background())
|
||||
r.NoError(err)
|
||||
}
|
||||
|
||||
func Test401ExpiredAuthUpdateUser(t *testing.T) {
|
||||
r := require.New(t)
|
||||
mux := http.NewServeMux()
|
||||
currentTokens := newTestRefreshToken(r)
|
||||
testUID := currentTokens.UID
|
||||
testRef := currentTokens.RefreshToken
|
||||
|
||||
mux.HandleFunc("/auth/refresh", currentTokens.handleAuthRefresh)
|
||||
|
||||
mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !currentTokens.isAuthorized(r.Header) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(newAuth); err != nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
respObj := struct {
|
||||
Code int
|
||||
User *User
|
||||
}{
|
||||
Code: 1000,
|
||||
User: &User{
|
||||
ID: "MJLke8kWh1BBvG95JBIrZvzpgsZ94hNNgjNHVyhXMiv4g9cn6SgvqiIFR5cigpml2LD_iUk_3DkV29oojTt3eA==",
|
||||
Name: "jason",
|
||||
UsedSpace: &usedSpace,
|
||||
},
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(respObj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("/addresses", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == ("Bearer " + oldAuth.AccessToken) {
|
||||
if !currentTokens.isAuthorized(r.Header) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Header.Get("Authorization") == ("Bearer " + newAuth.AccessToken) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
respObj := struct {
|
||||
Code int
|
||||
Addresses []*Address
|
||||
}{
|
||||
Code: 1000,
|
||||
Addresses: []*Address{},
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(respObj); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
|
||||
c := New(Config{HostURL: ts.URL}).
|
||||
NewClient(oldAuth.UID, oldAuth.AccessToken, oldAuth.RefreshToken, time.Now().Add(time.Hour))
|
||||
m := New(Config{HostURL: ts.URL})
|
||||
c, _, err := m.NewClientWithRefresh(context.Background(), testUID, testRef)
|
||||
r.NoError(err)
|
||||
|
||||
// The request will fail with 401, triggering a refresh. After the refresh it should succeed.
|
||||
_, err := c.GetAddresses(context.Background())
|
||||
r.NoError(t, err)
|
||||
_, err = c.UpdateUser(context.Background())
|
||||
r.NoError(err)
|
||||
}
|
||||
|
||||
func TestAuth2FA(t *testing.T) {
|
||||
r := require.New(t)
|
||||
twoFACode := "code"
|
||||
|
||||
finish, c := newTestClientCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
|
||||
r.NoError(t, checkMethodAndPath(req, "POST", "/auth/2fa"))
|
||||
r.NoError(checkMethodAndPath(req, "POST", "/auth/2fa"))
|
||||
|
||||
var twoFAreq auth2FAReq
|
||||
r.NoError(t, json.NewDecoder(req.Body).Decode(&twoFAreq))
|
||||
r.Equal(t, twoFAreq.TwoFactorCode, twoFACode)
|
||||
r.NoError(json.NewDecoder(req.Body).Decode(&twoFAreq))
|
||||
r.Equal(twoFAreq.TwoFactorCode, twoFACode)
|
||||
|
||||
return "/auth/2fa/post_response.json"
|
||||
},
|
||||
@ -205,31 +236,33 @@ func TestAuth2FA(t *testing.T) {
|
||||
defer finish()
|
||||
|
||||
err := c.Auth2FA(context.Background(), twoFACode)
|
||||
r.NoError(t, err)
|
||||
r.NoError(err)
|
||||
}
|
||||
|
||||
func TestAuth2FA_Fail(t *testing.T) {
|
||||
r := require.New(t)
|
||||
finish, c := newTestClientCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
|
||||
r.NoError(t, checkMethodAndPath(req, "POST", "/auth/2fa"))
|
||||
r.NoError(checkMethodAndPath(req, "POST", "/auth/2fa"))
|
||||
return "/auth/2fa/post_401_bad_password.json"
|
||||
},
|
||||
)
|
||||
defer finish()
|
||||
|
||||
err := c.Auth2FA(context.Background(), "code")
|
||||
r.Equal(t, ErrBad2FACode, err)
|
||||
r.Equal(ErrBad2FACode, err)
|
||||
}
|
||||
|
||||
func TestAuth2FA_Retry(t *testing.T) {
|
||||
r := require.New(t)
|
||||
finish, c := newTestClientCallbacks(t,
|
||||
func(tb testing.TB, w http.ResponseWriter, req *http.Request) string {
|
||||
r.NoError(t, checkMethodAndPath(req, "POST", "/auth/2fa"))
|
||||
r.NoError(checkMethodAndPath(req, "POST", "/auth/2fa"))
|
||||
return "/auth/2fa/post_422_bad_password.json"
|
||||
},
|
||||
)
|
||||
defer finish()
|
||||
|
||||
err := c.Auth2FA(context.Background(), "code")
|
||||
r.Equal(t, ErrBad2FACodeTryAgain, err)
|
||||
r.Equal(ErrBad2FACodeTryAgain, err)
|
||||
}
|
||||
|
||||
@ -19,8 +19,6 @@ package pmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Unlock unlocks all the user and address keys using the given passphrase, creating user and address keyrings.
|
||||
@ -34,26 +32,26 @@ func (c *client) Unlock(ctx context.Context, passphrase []byte) (err error) {
|
||||
|
||||
// unlock unlocks the user's keys but without locking the keyring lock first.
|
||||
// Should only be used internally by methods that first lock the lock.
|
||||
func (c *client) unlock(ctx context.Context, passphrase []byte) (err error) {
|
||||
if _, err = c.CurrentUser(ctx); err != nil {
|
||||
return
|
||||
func (c *client) unlock(ctx context.Context, passphrase []byte) error {
|
||||
if _, err := c.CurrentUser(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.userKeyRing == nil {
|
||||
if err = c.unlockUser(passphrase); err != nil {
|
||||
return errors.Wrap(err, "failed to unlock user")
|
||||
if err := c.unlockUser(passphrase); err != nil {
|
||||
return ErrUnlockFailed{err}
|
||||
}
|
||||
}
|
||||
|
||||
for _, address := range c.addresses {
|
||||
if c.addrKeyRing[address.ID] == nil {
|
||||
if err = c.unlockAddress(passphrase, address); err != nil {
|
||||
return errors.Wrap(err, "failed to unlock address")
|
||||
if err := c.unlockAddress(passphrase, address); err != nil {
|
||||
return ErrUnlockFailed{err}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *client) ReloadKeys(ctx context.Context, passphrase []byte) (err error) {
|
||||
|
||||
@ -60,11 +60,16 @@ func formatAsAddress(rawURL string) string {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
host := url.Host
|
||||
if host == "" {
|
||||
host = url.Path
|
||||
}
|
||||
|
||||
port := "443"
|
||||
if url.Scheme == "http" {
|
||||
port = "80"
|
||||
}
|
||||
return net.JoinHostPort(url.Host, port)
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
// DialTLS dials the given network/address. If it fails, it retries using a proxy.
|
||||
|
||||
@ -36,11 +36,16 @@ const (
|
||||
proxyDoHTimeout = 20 * time.Second
|
||||
proxyCanReachTimeout = 20 * time.Second
|
||||
proxyQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz"
|
||||
|
||||
Quad9Provider = "https://dns11.quad9.net/dns-query"
|
||||
Quad9PortProvider = "https://dns11.quad9.net:5053/dns-query"
|
||||
GoogleProvider = "https://dns.google/dns-query"
|
||||
)
|
||||
|
||||
var dohProviders = []string{ //nolint[gochecknoglobals]
|
||||
"https://dns11.quad9.net/dns-query",
|
||||
"https://dns.google/dns-query",
|
||||
Quad9Provider,
|
||||
Quad9PortProvider,
|
||||
GoogleProvider,
|
||||
}
|
||||
|
||||
// proxyProvider manages known proxies.
|
||||
|
||||
@ -27,12 +27,6 @@ import (
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
)
|
||||
|
||||
const (
|
||||
TestDoHQuery = "dMFYGSLTQOJXXI33ONVQWS3BOMNUA.protonpro.xyz"
|
||||
TestQuad9Provider = "https://dns11.quad9.net/dns-query"
|
||||
TestGoogleProvider = "https://dns.google/dns-query"
|
||||
)
|
||||
|
||||
func TestProxyProvider_FindProxy(t *testing.T) {
|
||||
proxy := getTrustedServer()
|
||||
defer closeServer(proxy)
|
||||
@ -142,17 +136,28 @@ func TestProxyProvider_FindProxy_CanReachTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProxyProvider_DoHLookup_Quad9(t *testing.T) {
|
||||
p := newProxyProvider(Config{}, []string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
|
||||
p := newProxyProvider(Config{}, []string{Quad9Provider, GoogleProvider}, proxyQuery)
|
||||
|
||||
records, err := p.dohLookup(context.Background(), TestDoHQuery, TestQuad9Provider)
|
||||
records, err := p.dohLookup(context.Background(), proxyQuery, Quad9Provider)
|
||||
r.NoError(t, err)
|
||||
r.NotEmpty(t, records)
|
||||
}
|
||||
|
||||
// DISABLEDTestProxyProvider_DoHLookup_Quad9Port cannot run on CI due to custom
|
||||
// port filter. Basic functionality should be covered by other tests. Keeping
|
||||
// code here to be able to run it locally if needed.
|
||||
func DISABLEDTestProxyProviderDoHLookupQuad9Port(t *testing.T) {
|
||||
p := newProxyProvider(Config{}, []string{Quad9PortProvider, GoogleProvider}, proxyQuery)
|
||||
|
||||
records, err := p.dohLookup(context.Background(), proxyQuery, Quad9PortProvider)
|
||||
r.NoError(t, err)
|
||||
r.NotEmpty(t, records)
|
||||
}
|
||||
|
||||
func TestProxyProvider_DoHLookup_Google(t *testing.T) {
|
||||
p := newProxyProvider(Config{}, []string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
|
||||
p := newProxyProvider(Config{}, []string{Quad9Provider, GoogleProvider}, proxyQuery)
|
||||
|
||||
records, err := p.dohLookup(context.Background(), TestDoHQuery, TestGoogleProvider)
|
||||
records, err := p.dohLookup(context.Background(), proxyQuery, GoogleProvider)
|
||||
r.NoError(t, err)
|
||||
r.NotEmpty(t, records)
|
||||
}
|
||||
@ -160,7 +165,7 @@ func TestProxyProvider_DoHLookup_Google(t *testing.T) {
|
||||
func TestProxyProvider_DoHLookup_FindProxy(t *testing.T) {
|
||||
skipIfProxyIsSet(t)
|
||||
|
||||
p := newProxyProvider(Config{}, []string{TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
|
||||
p := newProxyProvider(Config{}, []string{Quad9Provider, GoogleProvider}, proxyQuery)
|
||||
|
||||
url, err := p.findReachableServer()
|
||||
r.NoError(t, err)
|
||||
@ -170,7 +175,7 @@ func TestProxyProvider_DoHLookup_FindProxy(t *testing.T) {
|
||||
func TestProxyProvider_DoHLookup_FindProxyFirstProviderUnreachable(t *testing.T) {
|
||||
skipIfProxyIsSet(t)
|
||||
|
||||
p := newProxyProvider(Config{}, []string{"https://unreachable", TestQuad9Provider, TestGoogleProvider}, TestDoHQuery)
|
||||
p := newProxyProvider(Config{}, []string{"https://unreachable", Quad9Provider, GoogleProvider}, proxyQuery)
|
||||
|
||||
url, err := p.findReachableServer()
|
||||
r.NoError(t, err)
|
||||
|
||||
@ -251,3 +251,18 @@ func TestProxyDialer_UseProxy_FindSecondAlternativeIfFirstFailsAndAPIIsStillBloc
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, formatAsAddress(proxy2.URL), d.proxyAddress)
|
||||
}
|
||||
|
||||
func TestFormatAsAddress(t *testing.T) {
|
||||
r := require.New(t)
|
||||
testData := map[string]string{
|
||||
"sub.domain.tld": "sub.domain.tld:443",
|
||||
"http://sub.domain.tld": "sub.domain.tld:80",
|
||||
"https://sub.domain.tld": "sub.domain.tld:443",
|
||||
"ftp://sub.domain.tld": "sub.domain.tld:443",
|
||||
"//sub.domain.tld": "sub.domain.tld:443",
|
||||
}
|
||||
|
||||
for rawURL, wantURL := range testData {
|
||||
r.Equal(wantURL, formatAsAddress(rawURL))
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,10 +31,58 @@ var (
|
||||
ErrPasswordWrong = errors.New("wrong password")
|
||||
)
|
||||
|
||||
// ErrUnprocessableEntity ...
|
||||
type ErrUnprocessableEntity struct {
|
||||
OriginalError error
|
||||
}
|
||||
|
||||
func IsUnprocessableEntity(err error) bool {
|
||||
_, ok := err.(ErrUnprocessableEntity)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrUnprocessableEntity) Error() string {
|
||||
return err.OriginalError.Error()
|
||||
}
|
||||
|
||||
// ErrBadRequest ...
|
||||
type ErrBadRequest struct {
|
||||
OriginalError error
|
||||
}
|
||||
|
||||
func IsBadRequest(err error) bool {
|
||||
_, ok := err.(ErrBadRequest)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrBadRequest) Error() string {
|
||||
return err.OriginalError.Error()
|
||||
}
|
||||
|
||||
// ErrAuthFailed ...
|
||||
type ErrAuthFailed struct {
|
||||
OriginalError error
|
||||
}
|
||||
|
||||
func IsFailedAuth(err error) bool {
|
||||
_, ok := err.(ErrAuthFailed)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrAuthFailed) Error() string {
|
||||
return err.OriginalError.Error()
|
||||
}
|
||||
|
||||
// ErrUnlockFailed ...
|
||||
type ErrUnlockFailed struct {
|
||||
OriginalError error
|
||||
}
|
||||
|
||||
func IsFailedUnlock(err error) bool {
|
||||
_, ok := err.(ErrUnlockFailed)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrUnlockFailed) Error() string {
|
||||
return err.OriginalError.Error()
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ type manager struct {
|
||||
|
||||
isDown bool
|
||||
locker sync.Locker
|
||||
refreshingAuth sync.Locker
|
||||
connectionObservers []ConnectionObserver
|
||||
proxyDialer *ProxyTLSDialer
|
||||
|
||||
@ -50,6 +51,7 @@ func newManager(cfg Config) *manager {
|
||||
cfg: cfg,
|
||||
rc: resty.New().EnableTrace(),
|
||||
locker: &sync.Mutex{},
|
||||
refreshingAuth: &sync.Mutex{},
|
||||
pingMutex: &sync.RWMutex{},
|
||||
isPinging: false,
|
||||
setSentryUserIDOnce: sync.Once{},
|
||||
|
||||
@ -102,6 +102,9 @@ func (m *manager) auth(ctx context.Context, req AuthReq) (*Auth, error) {
|
||||
}
|
||||
|
||||
func (m *manager) authRefresh(ctx context.Context, uid, ref string) (*AuthRefresh, error) {
|
||||
m.refreshingAuth.Lock()
|
||||
defer m.refreshingAuth.Unlock()
|
||||
|
||||
var req = authRefreshReq{
|
||||
UID: uid,
|
||||
RefreshToken: ref,
|
||||
@ -117,6 +120,9 @@ func (m *manager) authRefresh(ctx context.Context, uid, ref string) (*AuthRefres
|
||||
|
||||
_, err := wrapNoConnection(m.r(ctx).SetBody(req).SetResult(&res).Post("/auth/refresh"))
|
||||
if err != nil {
|
||||
if IsBadRequest(err) || IsUnprocessableEntity(err) {
|
||||
err = ErrAuthFailed{err}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@ -59,16 +59,14 @@ func (m *manager) catchAPIError(_ *resty.Client, res *resty.Response) error {
|
||||
if apiErr, ok := res.Error().(*Error); ok {
|
||||
switch {
|
||||
case apiErr.Code == errCodeUpgradeApplication:
|
||||
err = ErrUpgradeApplication
|
||||
if m.cfg.UpgradeApplicationHandler != nil {
|
||||
m.cfg.UpgradeApplicationHandler()
|
||||
}
|
||||
return ErrUpgradeApplication
|
||||
case apiErr.Code == errCodePasswordWrong:
|
||||
err = ErrPasswordWrong
|
||||
return ErrPasswordWrong
|
||||
case apiErr.Code == errCodeAuthPaidPlanRequired:
|
||||
err = ErrPaidPlanRequired
|
||||
case res.StatusCode() == http.StatusUnprocessableEntity:
|
||||
err = ErrUnprocessableEntity{apiErr}
|
||||
return ErrPaidPlanRequired
|
||||
default:
|
||||
err = apiErr
|
||||
}
|
||||
@ -76,6 +74,13 @@ func (m *manager) catchAPIError(_ *resty.Client, res *resty.Response) error {
|
||||
err = errors.New(res.Status())
|
||||
}
|
||||
|
||||
switch res.StatusCode() {
|
||||
case http.StatusUnprocessableEntity:
|
||||
err = ErrUnprocessableEntity{err}
|
||||
case http.StatusBadRequest:
|
||||
err = ErrBadRequest{err}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
.PHONY: check-go check-godog install-godog test test-bridge test-live test-live-bridge test-stage test-debug test-live-debug bench
|
||||
|
||||
export GO111MODULE=on
|
||||
export BRIDGE_VERSION:=2.1.1+integrationtests
|
||||
export BRIDGE_VERSION:=2.1.3+integrationtests
|
||||
export VERBOSITY?=fatal
|
||||
export TEST_DATA=testdata
|
||||
|
||||
|
||||
@ -172,3 +172,7 @@ func (ctx *TestContext) MessagePreparationStarted(username string) {
|
||||
func (ctx *TestContext) MessagePreparationFinished(username string) {
|
||||
ctx.pmapiController.UnlockEvents(username)
|
||||
}
|
||||
|
||||
func (ctx *TestContext) CredentialsFailsOnWrite(shouldFail bool) {
|
||||
ctx.credStore.(*fakeCredStore).failOnWrite = shouldFail
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ProtonMail/proton-bridge/internal/users/credentials"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// bridgePassword is password to be used for IMAP or SMTP under tests.
|
||||
@ -28,6 +29,8 @@ const bridgePassword = "bridgepassword"
|
||||
|
||||
type fakeCredStore struct {
|
||||
credentials map[string]*credentials.Credentials
|
||||
|
||||
failOnWrite bool
|
||||
}
|
||||
|
||||
// newFakeCredStore returns a fake credentials store (optionally configured with the given credentials).
|
||||
@ -52,6 +55,9 @@ func (c *fakeCredStore) List() (userIDs []string, err error) {
|
||||
}
|
||||
|
||||
func (c *fakeCredStore) Add(userID, userName, uid, ref string, mailboxPassword []byte, emails []string) (*credentials.Credentials, error) {
|
||||
if c.failOnWrite {
|
||||
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
|
||||
}
|
||||
bridgePassword := bridgePassword
|
||||
if c, ok := c.credentials[userID]; ok {
|
||||
bridgePassword = c.BridgePassword
|
||||
@ -73,14 +79,23 @@ func (c *fakeCredStore) Get(userID string) (*credentials.Credentials, error) {
|
||||
}
|
||||
|
||||
func (c *fakeCredStore) SwitchAddressMode(userID string) (*credentials.Credentials, error) {
|
||||
if c.failOnWrite {
|
||||
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
|
||||
}
|
||||
return c.credentials[userID], nil
|
||||
}
|
||||
|
||||
func (c *fakeCredStore) UpdateEmails(userID string, emails []string) (*credentials.Credentials, error) {
|
||||
if c.failOnWrite {
|
||||
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
|
||||
}
|
||||
return c.credentials[userID], nil
|
||||
}
|
||||
|
||||
func (c *fakeCredStore) UpdatePassword(userID string, password []byte) (*credentials.Credentials, error) {
|
||||
if c.failOnWrite {
|
||||
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
|
||||
}
|
||||
creds, err := c.Get(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -90,6 +105,9 @@ func (c *fakeCredStore) UpdatePassword(userID string, password []byte) (*credent
|
||||
}
|
||||
|
||||
func (c *fakeCredStore) UpdateToken(userID, uid, ref string) (*credentials.Credentials, error) {
|
||||
if c.failOnWrite {
|
||||
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
|
||||
}
|
||||
creds, err := c.Get(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -99,12 +117,18 @@ func (c *fakeCredStore) UpdateToken(userID, uid, ref string) (*credentials.Crede
|
||||
}
|
||||
|
||||
func (c *fakeCredStore) Logout(userID string) (*credentials.Credentials, error) {
|
||||
if c.failOnWrite {
|
||||
return nil, errors.New("An invalid attempt to change the owner of an item. (-25244)")
|
||||
}
|
||||
c.credentials[userID].APIToken = ""
|
||||
c.credentials[userID].MailboxPassword = []byte{}
|
||||
return c.credentials[userID], nil
|
||||
}
|
||||
|
||||
func (c *fakeCredStore) Delete(userID string) error {
|
||||
if c.failOnWrite {
|
||||
return errors.New("An invalid attempt to change the owner of an item. (-25244)")
|
||||
}
|
||||
delete(c.credentials, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -46,6 +46,7 @@ type PMAPIController interface {
|
||||
LockEvents(username string)
|
||||
UnlockEvents(username string)
|
||||
RemoveUserMessageWithoutEvent(username, messageID string) error
|
||||
RevokeSession(username string) error
|
||||
}
|
||||
|
||||
func newPMAPIController(listener listener.Listener) (PMAPIController, pmapi.Manager) {
|
||||
|
||||
@ -250,3 +250,10 @@ func (ctl *Controller) RemoveUserMessageWithoutEvent(username string, messageID
|
||||
|
||||
return errors.New("message not found")
|
||||
}
|
||||
|
||||
func (ctl *Controller) RevokeSession(username string) error {
|
||||
for _, session := range ctl.sessionsByUID {
|
||||
session.uid = "revoked"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -74,12 +74,12 @@ func (ctl *Controller) createSession(username string, hasFullScope bool) *fakeSe
|
||||
|
||||
func (ctl *Controller) refreshSessionIfAuthorized(uid, ref string) (*fakeSession, error) {
|
||||
session, ok := ctl.sessionsByUID[uid]
|
||||
if !ok {
|
||||
return nil, pmapi.ErrUnauthorized
|
||||
if !ok || session.uid != uid {
|
||||
return nil, pmapi.ErrAuthFailed{OriginalError: errors.New("bad uid")}
|
||||
}
|
||||
|
||||
if ref != session.ref {
|
||||
return nil, pmapi.ErrUnauthorized
|
||||
return nil, pmapi.ErrAuthFailed{OriginalError: errors.New("bad refresh token")}
|
||||
}
|
||||
|
||||
session.ref = ctl.tokenGenerator.next("ref")
|
||||
|
||||
@ -133,14 +133,32 @@ func (api *FakePMAPI) authRefresh() error {
|
||||
|
||||
session, err := api.controller.refreshSessionIfAuthorized(api.uid, api.ref)
|
||||
if err != nil {
|
||||
if pmapi.IsFailedAuth(err) {
|
||||
go api.handleAuth(nil)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
api.ref = session.ref
|
||||
api.acc = session.acc
|
||||
|
||||
go api.handleAuth(&pmapi.AuthRefresh{
|
||||
UID: api.uid,
|
||||
AccessToken: api.acc,
|
||||
RefreshToken: api.ref,
|
||||
ExpiresIn: 7200,
|
||||
Scopes: []string{"full", "self", "user", "mail"},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *FakePMAPI) handleAuth(auth *pmapi.AuthRefresh) {
|
||||
for _, handle := range api.authHandlers {
|
||||
handle(auth)
|
||||
}
|
||||
}
|
||||
|
||||
func (api *FakePMAPI) setUser(username string) error {
|
||||
api.username = username
|
||||
api.log = api.log.WithField("username", username)
|
||||
|
||||
@ -69,7 +69,7 @@ func (m *fakePMAPIManager) NewClientWithRefresh(_ context.Context, uid, ref stri
|
||||
|
||||
session, err := m.controller.refreshSessionIfAuthorized(uid, ref)
|
||||
if err != nil {
|
||||
return nil, nil, pmapi.ErrUnauthorized
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
user, ok := m.controller.usersByUsername[session.username]
|
||||
|
||||
@ -82,6 +82,10 @@ func (api *FakePMAPI) UpdateUser(context.Context) (*pmapi.User, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := api.checkAndRecordCall(GET, "/addresses", nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return api.user, nil
|
||||
}
|
||||
|
||||
|
||||
@ -79,3 +79,12 @@ Feature: Start bridge
|
||||
And "user" does not have loaded store
|
||||
And "user" does not have running event loop
|
||||
And "user" has zero space
|
||||
|
||||
Scenario: Start with connected user, database file and internet connection, but no write access to credentials
|
||||
Given there is user "user" which just logged in
|
||||
And credentials are locked
|
||||
And there is database file for "user"
|
||||
When bridge starts
|
||||
Then "user" is connected
|
||||
When IMAP client authenticates "user"
|
||||
Then IMAP response is "NO"
|
||||
|
||||
17
test/features/users/revoked_session.feature
Normal file
17
test/features/users/revoked_session.feature
Normal file
@ -0,0 +1,17 @@
|
||||
Feature: Session deleted on API
|
||||
|
||||
@ignore-live
|
||||
Scenario: Session revoked after start
|
||||
Given there is connected user "user"
|
||||
When session was revoked for "user"
|
||||
And the event loop of "user" loops once
|
||||
Then "user" is disconnected
|
||||
|
||||
|
||||
@ignore-live
|
||||
Scenario: Starting with revoked session
|
||||
Given there is user "user" which just logged in
|
||||
And session was revoked for "user"
|
||||
When bridge starts
|
||||
Then "user" is disconnected
|
||||
|
||||
@ -60,3 +60,7 @@ func (ctl *Controller) GetAuthClient(username string) pmapi.Client {
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func (ctl *Controller) RevokeSession(username string) error {
|
||||
return errors.New("revoke live session not implemented")
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ func UsersActionsFeatureContext(s *godog.ScenarioContext) {
|
||||
s.Step(`^user deletes "([^"]*)"$`, userDeletesUser)
|
||||
s.Step(`^user deletes "([^"]*)" with cache$`, userDeletesUserWithCache)
|
||||
s.Step(`^"([^"]*)" swaps address "([^"]*)" with address "([^"]*)"$`, swapsAddressWithAddress)
|
||||
s.Step(`^session was revoked for "([^"]*)"$`, sessionRevoked)
|
||||
}
|
||||
|
||||
func userLogsIn(bddUserID string) error {
|
||||
@ -123,3 +124,8 @@ func swapsAddressWithAddress(bddUserID, bddAddressID1, bddAddressID2 string) err
|
||||
|
||||
return ctx.GetPMAPIController().ReorderAddresses(account.User(), addressIDs)
|
||||
}
|
||||
|
||||
func sessionRevoked(bddUserID string) error {
|
||||
account := ctx.GetTestAccount(bddUserID)
|
||||
return ctx.GetPMAPIController().RevokeSession(account.Username())
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ func UsersSetupFeatureContext(s *godog.ScenarioContext) {
|
||||
s.Step(`^there is database file for "([^"]*)"$`, thereIsDatabaseFileForUser)
|
||||
s.Step(`^there is no database file for "([^"]*)"$`, thereIsNoDatabaseFileForUser)
|
||||
s.Step(`^there is "([^"]*)" in "([^"]*)" address mode$`, thereIsUserWithAddressMode)
|
||||
s.Step(`^credentials? (?:are|is) locked$`, credentialsAreLocked)
|
||||
}
|
||||
|
||||
func thereIsUser(bddUserID string) error {
|
||||
@ -150,3 +151,8 @@ func thereIsUserWithAddressMode(bddUserID, wantAddressMode string) error {
|
||||
ctx.EventuallySyncIsFinishedForUsername(user.Username())
|
||||
return nil
|
||||
}
|
||||
|
||||
func credentialsAreLocked() error {
|
||||
ctx.CredentialsFailsOnWrite(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user