Compare commits

...

17 commits

Author SHA1 Message Date
Daniel Krol afc43a0068 better prometheus tags 2022-09-21 16:23:31 -04:00
Daniel Krol a82a4e7290 Fix timing with README generation
The async websocket output was happening outide of the code blocks so it
rendered wrong.
2022-09-20 14:00:59 -04:00
Daniel Krol 4f074b181c Websocket to notify clients about wallet updates 2022-09-19 18:36:55 -04:00
Daniel Krol 4f97d7761f git ignore the binary 2022-08-27 11:36:08 -04:00
Daniel Krol f090a034de Disallow unknown json fields in request bodies 2022-08-25 16:38:57 -04:00
Daniel Krol f04a01a5a0 Delete some TODOs that I've made into tasks 2022-08-25 15:55:02 -04:00
Daniel Krol aac7ef713e Don't care about various error checking TODOS in test client
Creator of real implementation will obviously know to do this, looking at the server implementation to see what errors come out.
2022-08-25 15:17:01 -04:00
Daniel Krol f244dab036 Oops, forgot to have verification tokens expire 2022-08-25 13:32:14 -04:00
Daniel Krol b86687a0c5 Log a couple more things.
Also change sequence=1 to its own const. Eventually we may want to make it variable per user when we do server switching.
2022-08-25 12:43:33 -04:00
Daniel Krol 48c74350e0 MIT License 2022-08-24 14:32:13 -04:00
Daniel Krol 9c057a5319 Linux only 2022-08-24 14:32:03 -04:00
Daniel Krol 08d57db466 Add timestamps to accounts and wallets tables
To help diagnosing/debugging in the future
2022-08-23 13:34:31 -04:00
Daniel Krol 448892cd82 validatePassword func 2022-08-22 19:41:30 -04:00
Daniel Krol 9046be7c4f const maxBodySize 2022-08-22 17:44:34 -04:00
Daniel Krol 0c22de5186 Simplify hosting details
I sort of did it wrong anyway. Just tell them to use systemd and caddy. If they demand specifics we can supply it later.
2022-08-22 17:34:44 -04:00
Daniel Krol 4843b91ce7 Rename the output. lbry-id -> wallet-sync-server 2022-08-22 12:05:53 -04:00
Daniel Krol 4dfacd8826 Remove comment from .goreleaser.yaml
It's no longer the default; I edited it
2022-08-20 11:58:44 -04:00
42 changed files with 1223 additions and 394 deletions

View file

@ -12,7 +12,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.17
go-version: 1.18
- name: Test
run: go test -v ./...

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
/sql.db
dist/
/wallet-sync-server

View file

@ -1,5 +1,3 @@
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
project_name: wallet-sync-server
before:
hooks:

15
LICENSE Normal file
View file

@ -0,0 +1,15 @@
The MIT License (MIT)
Copyright (c) 2022 LBRY Inc
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,13 +1,25 @@
# Running
This software is in the pre-testing phase as of now.
Install Golang, at least version 1.17. (Please report any dependencies we seemed to have forgotten)
# Getting The Binary
## Prebuilt
Linux Only. Get the latest [release](https://github.com/lbryio/wallet-sync-server/releases).
## Building
Only tried on Linux. Might work for Windows and Mac. No expectations.
Install Golang, at least version 1.18. (Please report any dependencies we seemed to have forgotten)
Check out the repo and run:
```
go run .
go build .
```
The binary should show up as `wallet-sync-server`.
# Account Creation Settings
When running the server, we should set some environmental variables. These environmental variables determine how account creation is handled. If we do not set these, no users will be able to create an account.
@ -70,17 +82,10 @@ You'll get this in your Mailgun dashboard.
Whether your sending domain is in the EU. This is related to GDPR stuff I think. Valid values are `true` or `false`, defaulting to `false`.
# You could make a script
# Deployment
For now you could store the stuff in a script:
A setup that works is [Caddy server](https://caddyserver.com) and Systemd.
```
#!/usr/bin/bash
Make sure Caddy is set to port 443, because the LBRY clients will expect that.
export ACCOUNT_WHITELIST="my-email@example.com"
go run .
```
**NOTE**: If you're using Mailgun, set the file permissions on this script such that only the administrator can read it, since it will contain the Mailgun private API key
_Side note: Eventually we'll create systemd configurations, at which point we will be able to put the env vars in an `EnvironmentFile` instead of a script like this._
If you're using Mailgun, take care to keep the environmental vars secure. [See here](https://serverfault.com/questions/413397/how-to-set-environment-variable-in-systemd-service/910655#910655) for how to do this with systemd.

View file

@ -27,7 +27,6 @@ const ScopeFull = AuthScope("*")
// For test stubs
type AuthInterface interface {
// TODO maybe have a "refresh token" thing if the client won't have email available all the time?
NewAuthToken(UserId, DeviceId, AuthScope) (*AuthToken, error)
NewVerifyTokenString() (VerifyTokenString, error)
}
@ -46,7 +45,7 @@ const TokenLength = 32
func (a *Auth) NewAuthToken(userId UserId, deviceId DeviceId, scope AuthScope) (*AuthToken, error) {
b := make([]byte, TokenLength)
// TODO - Is this is a secure random function? (Maybe audit)
// TODO - Audit: Is this is a secure random function?
if _, err := rand.Read(b); err != nil {
return nil, fmt.Errorf("Error generating token: %+v", err)
}
@ -62,7 +61,7 @@ func (a *Auth) NewAuthToken(userId UserId, deviceId DeviceId, scope AuthScope) (
func (a *Auth) NewVerifyTokenString() (VerifyTokenString, error) {
b := make([]byte, TokenLength)
// TODO - Is this is a secure random function? (Maybe audit)
// TODO - Audit: Is this is a secure random function?
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("Error generating token: %+v", err)
}
@ -152,6 +151,10 @@ func (c ClientSaltSeed) Validate() bool {
return len(c) == seedHexLength && err == nil
}
func (p Password) Validate() bool {
return len(p) >= 8 // Should be much longer but it's a sanity check.
}
// TODO consider unicode. Also some providers might be case sensitive, and/or
// may have other ways of having email addresses be equivalent (which we may
// not care about though)

2
env/env.go vendored
View file

@ -5,7 +5,7 @@ import (
"os"
"strings"
"lbryio/lbry-id/auth"
"lbryio/wallet-sync-server/auth"
)
// NOTE for users: If you have weird characters in your email address, please

2
env/env_test.go vendored
View file

@ -5,7 +5,7 @@ import (
"reflect"
"testing"
"lbryio/lbry-id/auth"
"lbryio/wallet-sync-server/auth"
)
func TestAccountVerificationMode(t *testing.T) {

5
go.mod
View file

@ -1,8 +1,9 @@
module lbryio/lbry-id
module lbryio/wallet-sync-server
go 1.17
go 1.18
require (
github.com/gorilla/websocket v1.5.0
github.com/mailgun/mailgun-go/v4 v4.8.1
github.com/mattn/go-sqlite3 v1.14.9
github.com/prometheus/client_golang v1.11.0

2
go.sum
View file

@ -47,6 +47,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=

View file

@ -1,25 +0,0 @@
These are some simple instructions to get your lbry wallet sync server running. This is mostly a note to self and suggestion to others, but you can host however you want. We use supervisord and the Caddy web server. Ideally we'll probably switch to systemd and we'd include a Caddyfile.
Install the latest version of golang. Check out this repository, and run `go build .` in the repository root.
Insteall the [caddy web server](https://caddyserver.com/). The website has its own debian repos. You can also install from source (golang). Which ever (if either) you feel comfortable with. No specific recommendations here.
You'll need to adjust your firewall to allow http (port 80) for caddy to obtain an SSL cert via ACME (ZeroSSL or LetsEncrypt) and also allow https (port 443) to have caddy serve our wallet sync server. If you use `ufw`:
```
sudo ufw allow http
sudo ufw allow https
```
To avoid running Caddy as root, you'll need to allow it to serve from ports 80 and 443 ([see here](https://superuser.com/a/892391)):
```
sudo setcap 'cap_net_bind_service=+ep' /home/lbry/caddy/cmd/caddy/caddy
```
Finally, included are a couple quick conf scripts for supervisord to help get lbry.id running. Fill in the `{blank}`s and put under `/etc/supervisor/conf.d/`:
* `{caddy-cmd}`: The command to run caddy. If you're building from source, it will be `{caddy git repo root}/cmd/caddy/caddy`. If you're installing from a debian repo it's probably just `caddy`, but refer to the Caddy docs.
* `{lbry-user}`: The user you're using to run Caddy and the sync server.
* `{lbry-user-home}`: The home directory of `{lbry-user}`.
* `{sync-server-dir}`: The git repo root of the lbry sync server.

View file

@ -1,8 +0,0 @@
[program:caddy]
command={caddy-cmd} reverse-proxy --from dev.lbry.id:443 --to localhost:8090
user={lbry-user}
autostart=true
autorestart=true
stderr_logfile=/var/log/caddy.err.log
stdout_logfile=/var/log/caddy.out.log
environment=HOME={lbry-user-home}

View file

@ -1,9 +0,0 @@
[program:lbry-id]
directory={sync-server-dir}
command={sync-server-dir}/lbry-id
user={lbry-user}
autostart=true
autorestart=true
stderr_logfile=/var/log/lbry-id.err.log
stdout_logfile=/var/log/lbry-id.out.log
environment=ACCOUNT_VERIFICATION_MODE=AllowAll # ONLY FOR DEV ENVIRONMENTS

View file

@ -8,9 +8,9 @@ import (
"github.com/mailgun/mailgun-go/v4"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/env"
"lbryio/lbry-id/server/paths"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/env"
"lbryio/wallet-sync-server/server/paths"
)
const MAILGUN_DEBUG = false

View file

@ -4,7 +4,7 @@ import (
"strings"
"testing"
"lbryio/lbry-id/auth"
"lbryio/wallet-sync-server/auth"
)
type TestEnv struct {

10
main.go
View file

@ -3,11 +3,11 @@ package main
import (
"log"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/env"
"lbryio/lbry-id/mail"
"lbryio/lbry-id/server"
"lbryio/lbry-id/store"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/env"
"lbryio/wallet-sync-server/mail"
"lbryio/wallet-sync-server/server"
"lbryio/wallet-sync-server/store"
)
func storeInit() (s store.Store) {

View file

@ -13,10 +13,18 @@ var (
// Prometheus?
Help: "Total number of requests to various endpoints",
},
[]string{"method"},
[]string{"method", "endpoint"},
)
ErrorsCount = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "wallet_sync_error_count",
Help: "Total number of various kinds of errors",
},
[]string{"error_type"},
)
)
func init() {
prometheus.MustRegister(RequestsCount)
prometheus.MustRegister(ErrorsCount)
}

View file

@ -6,9 +6,9 @@ import (
"log"
"net/http"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/env"
"lbryio/lbry-id/store"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/env"
"lbryio/wallet-sync-server/store"
)
type RegisterRequest struct {
@ -25,8 +25,8 @@ func (r *RegisterRequest) validate() error {
if !r.Email.Validate() {
return fmt.Errorf("Invalid or missing 'email'")
}
if r.Password == "" {
return fmt.Errorf("Missing 'password'")
if !r.Password.Validate() {
return fmt.Errorf("Invalid or missing 'password'")
}
if !r.ClientSaltSeed.Validate() {
@ -122,6 +122,7 @@ modes:
// TODO StatusCreated also for first wallet and/or for get auth token?
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, string(response))
log.Printf("User %s has registered", registerRequest.Email)
}
// TODO - There's probably a struct-based solution here like with POST/PUT.
@ -228,4 +229,8 @@ func (s *Server) verify(w http.ResponseWriter, req *http.Request) {
}
fmt.Fprintf(w, "Your account has been verified.")
// if we really want to log the user's email at some point
// we can put in the effort then to fetch it
log.Printf("User has been verified with token %s", token)
}

View file

@ -10,8 +10,8 @@ import (
"strings"
"testing"
"lbryio/lbry-id/server/paths"
"lbryio/lbry-id/store"
"lbryio/wallet-sync-server/server/paths"
"lbryio/wallet-sync-server/store"
)
// TODO - maybe this test could just be one of the TestServerRegisterAccountVerification tests now
@ -22,9 +22,9 @@ func TestServerRegisterSuccess(t *testing.T) {
}
testMail := TestMail{}
testAuth := TestAuth{TestNewVerifyTokenString: "abcd1234abcd1234abcd1234abcd1234"}
s := Server{&testAuth, testStore, &TestEnv{env}, &testMail, TestPort}
s := Init(&testAuth, testStore, &TestEnv{env}, &testMail, TestPort)
requestBody := []byte(`{"email": "abc@example.com", "password": "123", "clientSaltSeed": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" }`)
requestBody := []byte(`{"email": "abc@example.com", "password": "12345678", "clientSaltSeed": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" }`)
req := httptest.NewRequest(http.MethodPost, paths.PathRegister, bytes.NewBuffer(requestBody))
w := httptest.NewRecorder()
@ -129,10 +129,10 @@ func TestServerRegisterErrors(t *testing.T) {
testAuth := TestAuth{TestNewVerifyTokenString: "abcd1234abcd1234abcd1234abcd1234", FailGenToken: tc.failGenToken}
testMail := TestMail{SendVerificationEmailError: tc.mailError}
testStore := TestStore{Errors: tc.storeErrors}
s := Server{&testAuth, &testStore, &TestEnv{env}, &testMail, TestPort}
s := Init(&testAuth, &testStore, &TestEnv{env}, &testMail, TestPort)
// Make request
requestBody := fmt.Sprintf(`{"email": "%s", "password": "123", "clientSaltSeed": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}`, tc.email)
requestBody := fmt.Sprintf(`{"email": "%s", "password": "12345678", "clientSaltSeed": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}`, tc.email)
req := httptest.NewRequest(http.MethodPost, paths.PathAuthToken, bytes.NewBuffer([]byte(requestBody)))
w := httptest.NewRecorder()
@ -227,9 +227,9 @@ func TestServerRegisterAccountVerification(t *testing.T) {
testStore := &TestStore{}
testAuth := TestAuth{TestNewVerifyTokenString: "abcd1234abcd1234abcd1234abcd1234"}
testMail := TestMail{}
s := Server{&testAuth, testStore, &TestEnv{tc.env}, &testMail, TestPort}
s := Init(&testAuth, testStore, &TestEnv{tc.env}, &testMail, TestPort)
requestBody := []byte(`{"email": "abc@example.com", "password": "123", "clientSaltSeed": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" }`)
requestBody := []byte(`{"email": "abc@example.com", "password": "12345678", "clientSaltSeed": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" }`)
req := httptest.NewRequest(http.MethodPost, paths.PathRegister, bytes.NewBuffer(requestBody))
w := httptest.NewRecorder()
@ -274,12 +274,12 @@ func TestServerRegisterAccountVerification(t *testing.T) {
}
func TestServerValidateRegisterRequest(t *testing.T) {
registerRequest := RegisterRequest{Email: "joe@example.com", Password: "aoeu", ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}
registerRequest := RegisterRequest{Email: "joe@example.com", Password: "12345678", ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}
if registerRequest.validate() != nil {
t.Errorf("Expected valid RegisterRequest to successfully validate")
}
registerRequest = RegisterRequest{Email: "joe-example.com", Password: "aoeu", ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}
registerRequest = RegisterRequest{Email: "joe-example.com", Password: "12345678", ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}
err := registerRequest.validate()
if !strings.Contains(err.Error(), "email") {
t.Errorf("Expected RegisterRequest with invalid email to return an appropriate error")
@ -288,13 +288,13 @@ func TestServerValidateRegisterRequest(t *testing.T) {
// Note that Golang's email address parser, which I use, will accept
// "Joe <joe@example.com>" so we need to make sure to avoid accepting it. See
// the implementation.
registerRequest = RegisterRequest{Email: "Joe <joe@example.com>", Password: "aoeu", ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}
registerRequest = RegisterRequest{Email: "Joe <joe@example.com>", Password: "12345678", ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}
err = registerRequest.validate()
if !strings.Contains(err.Error(), "email") {
t.Errorf("Expected RegisterRequest with email with unexpected formatting to return an appropriate error")
}
registerRequest = RegisterRequest{Password: "aoeu", ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}
registerRequest = RegisterRequest{Password: "12345678", ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"}
err = registerRequest.validate()
if !strings.Contains(err.Error(), "email") {
t.Errorf("Expected RegisterRequest with missing email to return an appropriate error")
@ -306,19 +306,19 @@ func TestServerValidateRegisterRequest(t *testing.T) {
t.Errorf("Expected RegisterRequest with missing password to return an appropriate error")
}
registerRequest = RegisterRequest{Email: "joe@example.com", Password: "aoeu"}
registerRequest = RegisterRequest{Email: "joe@example.com", Password: "12345678"}
err = registerRequest.validate()
if !strings.Contains(err.Error(), "clientSaltSeed") {
t.Errorf("Expected RegisterRequest with missing clientSaltSeed to return an appropriate error")
}
registerRequest = RegisterRequest{Email: "joe@example.com", Password: "aoeu", ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234"}
registerRequest = RegisterRequest{Email: "joe@example.com", Password: "12345678", ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234"}
err = registerRequest.validate()
if !strings.Contains(err.Error(), "clientSaltSeed") {
t.Errorf("Expected RegisterRequest with clientSaltSeed of wrong length to return an appropriate error")
}
registerRequest = RegisterRequest{Email: "joe@example.com", Password: "aoeu", ClientSaltSeed: "xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234"}
registerRequest = RegisterRequest{Email: "joe@example.com", Password: "12345678", ClientSaltSeed: "xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234"}
err = registerRequest.validate()
if !strings.Contains(err.Error(), "clientSaltSeed") {
t.Errorf("Expected RegisterRequest with clientSaltSeed with a non-hex string to return an appropriate error")
@ -332,7 +332,7 @@ func TestServerResendVerifyEmailSuccess(t *testing.T) {
env := map[string]string{
"ACCOUNT_VERIFICATION_MODE": "EmailVerify",
}
s := Server{&TestAuth{}, &testStore, &TestEnv{env}, &testMail, TestPort}
s := Init(&TestAuth{}, &testStore, &TestEnv{env}, &testMail, TestPort)
requestBody := []byte(`{"email": "abc@example.com"}`)
req := httptest.NewRequest(http.MethodPost, paths.PathVerify, bytes.NewBuffer(requestBody))
@ -429,7 +429,7 @@ func TestServerResendVerifyEmailErrors(t *testing.T) {
// Set this up to fail according to specification
testStore := TestStore{Errors: tc.storeErrors}
testMail := TestMail{SendVerificationEmailError: tc.mailError}
s := Server{&TestAuth{}, &testStore, &TestEnv{env}, &testMail, TestPort}
s := Init(&TestAuth{}, &testStore, &TestEnv{env}, &testMail, TestPort)
// Make request
var requestBody []byte
@ -468,7 +468,7 @@ func TestServerResendVerifyEmailErrors(t *testing.T) {
func TestServerVerifyAccountSuccess(t *testing.T) {
testStore := TestStore{}
s := Server{&TestAuth{}, &testStore, &TestEnv{}, &TestMail{}, TestPort}
s := Init(&TestAuth{}, &testStore, &TestEnv{}, &TestMail{}, TestPort)
req := httptest.NewRequest(http.MethodGet, paths.PathVerify, nil)
q := req.URL.Query()
@ -529,7 +529,7 @@ func TestServerVerifyAccountErrors(t *testing.T) {
// Set this up to fail according to specification
testStore := TestStore{Errors: tc.storeErrors}
s := Server{&TestAuth{}, &testStore, &TestEnv{}, &TestMail{}, TestPort}
s := Init(&TestAuth{}, &testStore, &TestEnv{}, &TestMail{}, TestPort)
// Make request
req := httptest.NewRequest(http.MethodGet, paths.PathVerify, nil)

View file

@ -5,8 +5,8 @@ import (
"fmt"
"net/http"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/store"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/store"
)
// DeviceId is decided by the device. UserId is decided by the server, and is
@ -21,8 +21,8 @@ func (r *AuthRequest) validate() error {
if !r.Email.Validate() {
return fmt.Errorf("Invalid 'email'")
}
if r.Password == "" {
return fmt.Errorf("Missing 'password'")
if !r.Password.Validate() {
return fmt.Errorf("Invalid or missing 'password'")
}
if r.DeviceId == "" {
return fmt.Errorf("Missing 'deviceId'")

View file

@ -10,17 +10,17 @@ import (
"strings"
"testing"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/server/paths"
"lbryio/lbry-id/store"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/server/paths"
"lbryio/wallet-sync-server/store"
)
func TestServerAuthHandlerSuccess(t *testing.T) {
testAuth := TestAuth{TestNewAuthTokenString: auth.AuthTokenString("seekrit")}
testStore := TestStore{}
s := Server{&testAuth, &testStore, &TestEnv{}, &TestMail{}, TestPort}
s := Init(&testAuth, &testStore, &TestEnv{}, &TestMail{}, TestPort)
requestBody := []byte(`{"deviceId": "dev-1", "email": "abc@example.com", "password": "123"}`)
requestBody := []byte(`{"deviceId": "dev-1", "email": "abc@example.com", "password": "12345678"}`)
req := httptest.NewRequest(http.MethodPost, paths.PathAuthToken, bytes.NewBuffer(requestBody))
w := httptest.NewRecorder()
@ -104,11 +104,11 @@ func TestServerAuthHandlerErrors(t *testing.T) {
if tc.authFailGenToken { // TODO - TestAuth{Errors:authErrors}
testAuth.FailGenToken = true
}
server := Server{&testAuth, &testStore, &TestEnv{}, &TestMail{}, TestPort}
server := Init(&testAuth, &testStore, &TestEnv{}, &TestMail{}, TestPort)
// Make request
// So long as the JSON is well-formed, the content doesn't matter here since the password check will be stubbed out
requestBody := fmt.Sprintf(`{"deviceId": "dev-1", "email": "%s", "password": "123"}`, tc.email)
requestBody := fmt.Sprintf(`{"deviceId": "dev-1", "email": "%s", "password": "12345678"}`, tc.email)
req := httptest.NewRequest(http.MethodPost, paths.PathAuthToken, bytes.NewBuffer([]byte(requestBody)))
w := httptest.NewRecorder()
@ -123,7 +123,7 @@ func TestServerAuthHandlerErrors(t *testing.T) {
}
func TestServerValidateAuthRequest(t *testing.T) {
authRequest := AuthRequest{DeviceId: "dId", Email: "joe@example.com", Password: "aoeu"}
authRequest := AuthRequest{DeviceId: "dId", Email: "joe@example.com", Password: "12345678"}
if authRequest.validate() != nil {
t.Errorf("Expected valid AuthRequest to successfully validate")
}
@ -134,22 +134,22 @@ func TestServerValidateAuthRequest(t *testing.T) {
failureDescription string
}{
{
AuthRequest{Email: "joe@example.com", Password: "aoeu"},
AuthRequest{Email: "joe@example.com", Password: "12345678"},
"deviceId",
"Expected AuthRequest with missing device to not successfully validate",
}, {
AuthRequest{DeviceId: "dId", Email: "joe-example.com", Password: "aoeu"},
AuthRequest{DeviceId: "dId", Email: "joe-example.com", Password: "12345678"},
"email",
"Expected AuthRequest with invalid email to not successfully validate",
}, {
// Note that Golang's email address parser, which I use, will accept
// "Joe <joe@example.com>" so we need to make sure to avoid accepting it. See
// the implementation.
AuthRequest{DeviceId: "dId", Email: "Joe <joe@example.com>", Password: "aoeu"},
AuthRequest{DeviceId: "dId", Email: "Joe <joe@example.com>", Password: "12345678"},
"email",
"Expected AuthRequest with email with unexpected formatting to not successfully validate",
}, {
AuthRequest{DeviceId: "dId", Password: "aoeu"},
AuthRequest{DeviceId: "dId", Password: "12345678"},
"email",
"Expected AuthRequest with missing email to not successfully validate",
}, {

View file

@ -6,8 +6,8 @@ import (
"fmt"
"net/http"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/store"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/store"
)
// Thanks to Standard Notes. See:

View file

@ -9,9 +9,9 @@ import (
"net/http/httptest"
"testing"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/server/paths"
"lbryio/lbry-id/store"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/server/paths"
"lbryio/wallet-sync-server/store"
)
func TestServerGetClientSalt(t *testing.T) {
@ -67,7 +67,7 @@ func TestServerGetClientSalt(t *testing.T) {
Errors: tc.storeErrors,
}
s := Server{&testAuth, &testStore, &TestEnv{}, &TestMail{}, TestPort}
s := Init(&testAuth, &testStore, &TestEnv{}, &TestMail{}, TestPort)
req := httptest.NewRequest(http.MethodGet, paths.PathClientSaltSeed, nil)
q := req.URL.Query()

View file

@ -12,17 +12,16 @@ import (
"reflect"
"strings"
"testing"
"time"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/server/paths"
"lbryio/lbry-id/store"
"lbryio/lbry-id/wallet"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/server/paths"
"lbryio/wallet-sync-server/store"
"lbryio/wallet-sync-server/wallet"
)
// Whereas sever_test.go stubs out auth store and wallet, these will use the real thing, but test fewer paths.
// TODO - test some unhappy paths? Don't want to retest all the unit tests though.
// Integration test requires a real sqlite database
func storeTestInit(t *testing.T) (s store.Store, tmpFile *os.File) {
s = store.Store{}
@ -101,7 +100,7 @@ func TestIntegrationWalletUpdates(t *testing.T) {
env := map[string]string{
"ACCOUNT_WHITELIST": "abc@example.com",
}
s := Server{&auth.Auth{}, &st, &TestEnv{env}, &TestMail{}, TestPort}
s := Init(&auth.Auth{}, &st, &TestEnv{env}, &TestMail{}, TestPort)
////////////////////
t.Log("Request: Register email address - any device")
@ -114,7 +113,7 @@ func TestIntegrationWalletUpdates(t *testing.T) {
s.register,
paths.PathRegister,
&registerResponse,
`{"email": "abc@example.com", "password": "123", "clientSaltSeed": "1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd"}`,
`{"email": "abc@example.com", "password": "12345678", "clientSaltSeed": "1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd"}`,
)
checkStatusCode(t, statusCode, responseBody, http.StatusCreated)
@ -130,7 +129,7 @@ func TestIntegrationWalletUpdates(t *testing.T) {
s.getAuthToken,
paths.PathAuthToken,
&authToken1,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "123"}`,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "12345678"}`,
)
checkStatusCode(t, statusCode, responseBody)
@ -158,7 +157,7 @@ func TestIntegrationWalletUpdates(t *testing.T) {
s.getAuthToken,
paths.PathAuthToken,
&authToken2,
`{"deviceId": "dev-2", "email": "abc@example.com", "password": "123"}`,
`{"deviceId": "dev-2", "email": "abc@example.com", "password": "12345678"}`,
)
checkStatusCode(t, statusCode, responseBody)
@ -271,7 +270,13 @@ func TestIntegrationChangePassword(t *testing.T) {
env := map[string]string{
"ACCOUNT_WHITELIST": "abc@example.com",
}
s := Server{&auth.Auth{}, &st, &TestEnv{env}, &TestMail{}, TestPort}
s := Init(&auth.Auth{}, &st, &TestEnv{env}, &TestMail{}, TestPort)
// Still need to mock this until we're doing a real integration test
// where we call Serve(), which brings up the real websocket manager.
// Note that in the integration test, we're only using this for requests
// that would get blocked without it.
wsmm := wsMockManager{s: s, done: make(chan bool)}
////////////////////
t.Log("Request: Register email address")
@ -284,7 +289,7 @@ func TestIntegrationChangePassword(t *testing.T) {
s.register,
paths.PathRegister,
&registerResponse,
`{"email": "abc@example.com", "password": "123", "clientSaltSeed": "1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd"}`,
`{"email": "abc@example.com", "password": "12345678", "clientSaltSeed": "1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd"}`,
)
checkStatusCode(t, statusCode, responseBody, http.StatusCreated)
@ -322,7 +327,7 @@ func TestIntegrationChangePassword(t *testing.T) {
s.getAuthToken,
paths.PathAuthToken,
&authToken,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "123"}`,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "12345678"}`,
)
checkStatusCode(t, statusCode, responseBody)
@ -343,6 +348,9 @@ func TestIntegrationChangePassword(t *testing.T) {
t.Log("Request: Change password")
////////////////////
// Giving it a whole second of timeout because this request seems to be a bit
// slow.
go wsmm.getOneMessage(time.Second)
var changePasswordResponse struct{}
responseBody, statusCode = request(
t,
@ -350,8 +358,9 @@ func TestIntegrationChangePassword(t *testing.T) {
s.changePassword,
paths.PathPassword,
&changePasswordResponse,
`{"email": "abc@example.com", "oldPassword": "123", "newPassword": "456", "clientSaltSeed": "8678def95678def98678def95678def98678def95678def98678def95678def9"}`,
`{"email": "abc@example.com", "oldPassword": "12345678", "newPassword": "45678901", "clientSaltSeed": "8678def95678def98678def95678def98678def95678def98678def95678def9"}`,
)
<-wsmm.done
checkStatusCode(t, statusCode, responseBody)
@ -405,7 +414,7 @@ func TestIntegrationChangePassword(t *testing.T) {
s.getAuthToken,
paths.PathAuthToken,
&authToken,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "456"}`,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "45678901"}`,
)
checkStatusCode(t, statusCode, responseBody)
@ -446,6 +455,9 @@ func TestIntegrationChangePassword(t *testing.T) {
t.Log("Request: Change password again, this time including a wallet (since there is a wallet to update)")
////////////////////
// Giving it a whole second of timeout because this request seems to be a bit
// slow.
go wsmm.getOneMessage(time.Second)
responseBody, statusCode = request(
t,
http.MethodPost,
@ -457,11 +469,12 @@ func TestIntegrationChangePassword(t *testing.T) {
"sequence": 2,
"hmac": "my-hmac-2",
"email": "abc@example.com",
"oldPassword": "456",
"newPassword": "789",
"oldPassword": "45678901",
"newPassword": "78901234",
"clientSaltSeed": "0000ffff0000ffff0000ffff0000ffff0000ffff0000ffff0000ffff0000ffff"
}`),
)
<-wsmm.done
checkStatusCode(t, statusCode, responseBody)
@ -510,7 +523,7 @@ func TestIntegrationChangePassword(t *testing.T) {
s.getAuthToken,
paths.PathAuthToken,
&authToken,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "789"}`,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "78901234"}`,
)
checkStatusCode(t, statusCode, responseBody)
@ -562,7 +575,7 @@ func TestIntegrationVerifyAccount(t *testing.T) {
"ACCOUNT_VERIFICATION_MODE": "EmailVerify",
}
testMail := TestMail{}
s := Server{&auth.Auth{}, &st, &TestEnv{env}, &testMail, TestPort}
s := Init(&auth.Auth{}, &st, &TestEnv{env}, &testMail, TestPort)
////////////////////
t.Log("Request: Register email address")
@ -575,7 +588,7 @@ func TestIntegrationVerifyAccount(t *testing.T) {
s.register,
paths.PathRegister,
&registerResponse,
`{"email": "abc@example.com", "password": "123", "clientSaltSeed": "1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd"}`,
`{"email": "abc@example.com", "password": "12345678", "clientSaltSeed": "1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd"}`,
)
checkStatusCode(t, statusCode, responseBody, http.StatusCreated)
@ -619,7 +632,7 @@ func TestIntegrationVerifyAccount(t *testing.T) {
s.getAuthToken,
paths.PathAuthToken,
&authToken,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "123"}`,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "12345678"}`,
)
checkStatusCode(t, statusCode, responseBody, http.StatusUnauthorized)
@ -652,7 +665,7 @@ func TestIntegrationVerifyAccount(t *testing.T) {
s.getAuthToken,
paths.PathAuthToken,
&authToken,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "123"}`,
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "12345678"}`,
)
checkStatusCode(t, statusCode, responseBody)

View file

@ -3,11 +3,16 @@ package server
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/store"
"lbryio/lbry-id/wallet"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/metrics"
"lbryio/wallet-sync-server/store"
"lbryio/wallet-sync-server/wallet"
"github.com/prometheus/client_golang/prometheus"
)
type ChangePasswordRequest struct {
@ -28,11 +33,11 @@ func (r *ChangePasswordRequest) validate() error {
if !r.Email.Validate() {
return fmt.Errorf("Invalid or missing 'email'")
}
if r.OldPassword == "" {
return fmt.Errorf("Missing 'oldPassword'")
if !r.OldPassword.Validate() {
return fmt.Errorf("Invalid or missing 'oldPassword'")
}
if r.NewPassword == "" {
return fmt.Errorf("Missing 'newPassword'")
if !r.NewPassword.Validate() {
return fmt.Errorf("Invalid or missing 'newPassword'")
}
// Too bad we can't do this so easily with clientSaltSeed
if r.OldPassword == r.NewPassword {
@ -69,8 +74,9 @@ func (s *Server) changePassword(w http.ResponseWriter, req *http.Request) {
// unverified accounts here for simplicity.
var err error
var userId auth.UserId
if changePasswordRequest.EncryptedWallet != "" {
err = s.store.ChangePasswordWithWallet(
userId, err = s.store.ChangePasswordWithWallet(
changePasswordRequest.Email,
changePasswordRequest.OldPassword,
changePasswordRequest.NewPassword,
@ -83,7 +89,7 @@ func (s *Server) changePassword(w http.ResponseWriter, req *http.Request) {
return
}
} else {
err = s.store.ChangePasswordNoWallet(
userId, err = s.store.ChangePasswordNoWallet(
changePasswordRequest.Email,
changePasswordRequest.OldPassword,
changePasswordRequest.NewPassword,
@ -107,6 +113,42 @@ func (s *Server) changePassword(w http.ResponseWriter, req *http.Request) {
return
}
// TODO - A socket connection request using an old auth token could still
// succeed in a race condition:
// * websocket handler: checkAuth passes with token
// * password change handler: change password, invalidate token
// * password change handler: send userRemove message
// * websocket manager: process userRemove message, ending all websocket connections for user
// * websocket handler: new websocket connection is established
//
// It would require the websocket handler to be very slow, but I don't want to
// rule it out.
//
// But a much more likely scenario could happen: the buffer on the userRemove
// channel could get full and it could time out, and not boot any of the
// users' clients.
//
// These aren't horribly important now since the only message is a
// notification that a new wallet version exists, but who knows what we
// could use websockets for. Maybe we start doing something crazy like
// updating the wallet over the channel, in which case we absolutely want
// to prevent an old client from doing so after a password change on
// another client.
//
// We'd have to think a fair amount about how to make these foolproof if it
// becomes important. Maybe we just pass the auth token to the websocket
// writer, and pass it to every wallet update db call, and have it check
// the auth token within the same transaction as the wallet update.
timeout := time.NewTicker(100 * time.Millisecond)
select {
case s.userRemove <- wsClientForUser{userId, nil}:
case <-timeout.C:
metrics.ErrorsCount.With(prometheus.Labels{"error_type": "ws-user-remove"}).Inc()
return
}
timeout.Stop()
var changePasswordResponse struct{} // no data to respond with, but keep it JSON
var response []byte
response, err = json.Marshal(changePasswordResponse)
@ -118,4 +160,5 @@ func (s *Server) changePassword(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, string(response))
log.Printf("User %s has changed their password", changePasswordRequest.Email)
}

View file

@ -8,11 +8,12 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/server/paths"
"lbryio/lbry-id/store"
"lbryio/lbry-id/wallet"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/server/paths"
"lbryio/wallet-sync-server/store"
"lbryio/wallet-sync-server/wallet"
)
func TestServerChangePassword(t *testing.T) {
@ -25,6 +26,8 @@ func TestServerChangePassword(t *testing.T) {
// Whether we expect the call to ChangePassword*Wallet to happen
expectChangePasswordCall bool
expectWsMsg bool
// `new...` refers to what is being passed into the via POST request (and
// what we expect to get passed into SetWallet for the *non-error* cases
// below)
@ -42,6 +45,7 @@ func TestServerChangePassword(t *testing.T) {
expectedStatusCode: http.StatusOK,
expectChangePasswordCall: true,
expectWsMsg: true,
newEncryptedWallet: "my-enc-wallet",
newSequence: 2,
@ -54,6 +58,7 @@ func TestServerChangePassword(t *testing.T) {
expectedStatusCode: http.StatusOK,
expectChangePasswordCall: true,
expectWsMsg: true,
email: "abc@example.com",
}, {
@ -168,8 +173,9 @@ func TestServerChangePassword(t *testing.T) {
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
testStore := TestStore{Errors: tc.storeErrors}
s := Server{&TestAuth{}, &testStore, &TestEnv{}, &TestMail{}, TestPort}
testStore := TestStore{Errors: tc.storeErrors, TestUserId: 37}
s := Init(&TestAuth{}, &testStore, &TestEnv{}, &TestMail{}, TestPort)
wsmm := wsMockManager{s: s, done: make(chan bool)}
// Whether we passed in wallet fields (these test cases should be passing
// in all of them or none of them, so we only test EncryptedWallet). This
@ -196,7 +202,15 @@ func TestServerChangePassword(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, paths.PathPassword, bytes.NewBuffer(requestBody))
w := httptest.NewRecorder()
go wsmm.getOneMessage(100 * time.Millisecond)
s.changePassword(w, req)
<-wsmm.done
if tc.expectWsMsg && wsmm.removedUserId != testStore.TestUserId {
t.Error("Expected websocket message to remove user id")
}
if !tc.expectWsMsg && !wsmm.noMessage {
t.Error("Expected no websocket message to remove user id")
}
body, _ := ioutil.ReadAll(w.Body)
@ -260,8 +274,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
Hmac: "my-hmac",
Sequence: 2,
Email: "abc@example.com",
OldPassword: "123",
NewPassword: "456",
OldPassword: "12345678",
NewPassword: "45678901",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
}
if changePasswordRequest.validate() != nil {
@ -270,8 +284,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
changePasswordRequest = ChangePasswordRequest{
Email: "abc@example.com",
OldPassword: "123",
NewPassword: "456",
OldPassword: "12345678",
NewPassword: "45678901",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
}
if changePasswordRequest.validate() != nil {
@ -289,8 +303,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
Hmac: "my-hmac",
Sequence: 2,
Email: "abc-example.com",
OldPassword: "123",
NewPassword: "456",
OldPassword: "12345678",
NewPassword: "45678901",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
},
"email",
@ -304,8 +318,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
Hmac: "my-hmac",
Sequence: 2,
Email: "Abc <abc@example.com>",
OldPassword: "123",
NewPassword: "456",
OldPassword: "12345678",
NewPassword: "45678901",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
},
"email",
@ -315,8 +329,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
EncryptedWallet: "my-encrypted-wallet",
Hmac: "my-hmac",
Sequence: 2,
OldPassword: "123",
NewPassword: "456",
OldPassword: "12345678",
NewPassword: "45678901",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
},
"email",
@ -327,7 +341,7 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
Hmac: "my-hmac",
Sequence: 2,
Email: "abc@example.com",
NewPassword: "456",
NewPassword: "45678901",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
},
"oldPassword",
@ -338,7 +352,7 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
Hmac: "my-hmac",
Sequence: 2,
Email: "abc@example.com",
OldPassword: "123",
OldPassword: "12345678",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
},
"newPassword",
@ -349,8 +363,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
Hmac: "my-hmac",
Sequence: 2,
Email: "abc@example.com",
OldPassword: "123",
NewPassword: "456",
OldPassword: "12345678",
NewPassword: "45678901",
},
"clientSaltSeed",
"Expected ChangePasswordRequest with missing clientSaltSeed to return an appropriate error",
@ -360,8 +374,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
Hmac: "my-hmac",
Sequence: 2,
Email: "abc@example.com",
OldPassword: "123",
NewPassword: "456",
OldPassword: "12345678",
NewPassword: "45678901",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234",
},
"clientSaltSeed",
@ -372,8 +386,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
Hmac: "my-hmac",
Sequence: 2,
Email: "abc@example.com",
OldPassword: "123",
NewPassword: "456",
OldPassword: "12345678",
NewPassword: "45678901",
ClientSaltSeed: "xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234xxxx1234",
},
"clientSaltSeed",
@ -383,8 +397,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
Hmac: "my-hmac",
Sequence: 2,
Email: "abc@example.com",
OldPassword: "123",
NewPassword: "456",
OldPassword: "12345678",
NewPassword: "45678901",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
},
"'encryptedWallet', 'sequence', and 'hmac'", // More likely to fail when we change the error message but whatever
@ -394,8 +408,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
EncryptedWallet: "my-encrypted-wallet",
Sequence: 2,
Email: "abc@example.com",
OldPassword: "123",
NewPassword: "456",
OldPassword: "12345678",
NewPassword: "45678901",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
},
"'encryptedWallet', 'sequence', and 'hmac'", // More likely to fail when we change the error message but whatever
@ -406,8 +420,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
Hmac: "my-hmac",
Sequence: 0,
Email: "abc@example.com",
OldPassword: "123",
NewPassword: "456",
OldPassword: "12345678",
NewPassword: "45678901",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
},
"'encryptedWallet', 'sequence', and 'hmac'", // More likely to fail when we change the error message but whatever
@ -418,8 +432,8 @@ func TestServerValidateChangePasswordRequest(t *testing.T) {
Hmac: "my-hmac",
Sequence: 2,
Email: "abc@example.com",
OldPassword: "123",
NewPassword: "123",
OldPassword: "12345678",
NewPassword: "12345678",
ClientSaltSeed: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
},
"should not be the same",

View file

@ -1,7 +1,5 @@
package paths
// TODO proper doc comments!
const ApiVersion = "3"
const PathPrefix = "/api/" + ApiVersion
@ -13,6 +11,10 @@ const PathVerify = PathPrefix + "/verify"
const PathResendVerify = PathPrefix + "/verify/resend"
const PathClientSaltSeed = PathPrefix + "/client-salt-seed"
// Using such a generic name since, as I understand, we can do a bunch of
// different stuff over this one websocket.
const PathWebsocket = PathPrefix + "/websocket"
const PathUnknownEndpoint = PathPrefix + "/"
const PathWrongApiVersion = "/api/"

View file

@ -1,38 +1,71 @@
package server
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"github.com/prometheus/client_golang/prometheus/promhttp"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/env"
"lbryio/lbry-id/mail"
"lbryio/lbry-id/server/paths"
"lbryio/lbry-id/store"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/env"
"lbryio/wallet-sync-server/mail"
"lbryio/wallet-sync-server/server/paths"
"lbryio/wallet-sync-server/store"
"lbryio/wallet-sync-server/wallet"
)
const maxBodySize = 100000
// Message sent from the wallet POST request handler to the websocket manager,
// indicating that a user's client should receive a (different) message that
// their wallet has an update on the server.
type walletUpdateMsg struct {
userId auth.UserId
sequence wallet.Sequence
}
type Server struct {
auth auth.AuthInterface
store store.StoreInterface
env env.EnvInterface
mail mail.MailInterface
port int
clientAdd chan wsClientForUser
clientRemove chan wsClientForUser
userRemove chan wsClientForUser
walletUpdates chan walletUpdateMsg
}
// TODO If I capitalize the `auth` `store` and `env` fields of Store{} I can
// create Store{} structs directly from main.go.
func Init(
auth auth.AuthInterface,
store store.StoreInterface,
env env.EnvInterface,
mail mail.MailInterface,
authInterface auth.AuthInterface,
storeInterface store.StoreInterface,
envInterface env.EnvInterface,
mailInterface mail.MailInterface,
port int,
) *Server {
return &Server{auth, store, env, mail, port}
return &Server{
auth: authInterface,
store: storeInterface,
env: envInterface,
mail: mailInterface,
port: port,
// Anything that could get backed up by a lot of requests, let's just
// give it a buffer. Starting small until we start to see dashboard
// stats on this. I want a sense of how this grows with the number of
// users or whatnot.
clientAdd: make(chan wsClientForUser),
clientRemove: make(chan wsClientForUser),
userRemove: make(chan wsClientForUser, 5),
walletUpdates: make(chan walletUpdateMsg, 5),
}
}
type ErrorResponse struct {
@ -75,7 +108,6 @@ func internalServiceErrorJson(w http.ResponseWriter, serverErr error, errContext
// Cut down on code repetition. No need to return errors since it can all be
// handled here. Just return a bool to indicate success.
// TODO the names `getPostData` and `getGetData` don't fully describe what they do
func requestOverhead(w http.ResponseWriter, req *http.Request, method string) bool {
if req.Method != method {
@ -92,10 +124,6 @@ type PostRequest interface {
validate() error
}
// TODO decoder.DisallowUnknownFields?
// TODO GET params too large (like StatusRequestEntityTooLarge)? Or is that
// somehow handled by the http library due to a size limit in the http spec?
// Confirm it's a Post request, various overhead, decode the json, validate the struct
func getPostData(w http.ResponseWriter, req *http.Request, reqStruct PostRequest) bool {
if !requestOverhead(w, req, http.MethodPost) {
@ -105,17 +133,26 @@ func getPostData(w http.ResponseWriter, req *http.Request, reqStruct PostRequest
// Make the limit 100k. Increase from there as needed. I'd rather block some
// people's large wallets and increase the limit than OOM for everybody and
// decrease the limit.
req.Body = http.MaxBytesReader(w, req.Body, 100000)
err := json.NewDecoder(req.Body).Decode(&reqStruct)
req.Body = http.MaxBytesReader(w, req.Body, maxBodySize)
decoder := json.NewDecoder(req.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&reqStruct)
switch {
case err == nil:
break
case err.Error() == "http: request body too large":
errorJson(w, http.StatusRequestEntityTooLarge, "")
return false
case strings.HasPrefix(err.Error(), "json: unknown field"):
// The error is coming straight out of the json decoder. I think the prefix
// we check for determines what it is pretty reliably. I'd think it's safe
// to give back to the requesting client (unlike an arbitrary error
// message).
errorJson(w, http.StatusBadRequest, err.Error())
return false
default:
// Maybe we can suss out specific errors later. Need to study what errors
// come from Decode.
// Maybe we can suss out more specific errors later. Need to study what
// errors come from Decode.
errorJson(w, http.StatusBadRequest, "Error parsing JSON")
return false
}
@ -135,7 +172,7 @@ func getGetData(w http.ResponseWriter, req *http.Request) bool {
}
// TODO - probably don't return all of authToken since we only need userId and
// deviceId. Also this is apparently not idiomatic go error handling.
// deviceId.
func (s *Server) checkAuth(
w http.ResponseWriter,
token auth.AuthTokenString,
@ -159,6 +196,22 @@ func (s *Server) checkAuth(
return authToken
}
// Useful for any request where token is the only GET param to get
// TODO - There's probably a struct-based solution here like with POST/PUT.
func getTokenParam(req *http.Request) (token auth.AuthTokenString, err error) {
tokenSlice, hasTokenSlice := req.URL.Query()["token"]
if !hasTokenSlice || tokenSlice[0] == "" {
err = fmt.Errorf("Missing token parameter")
}
if err == nil {
token = auth.AuthTokenString(tokenSlice[0])
}
return
}
// TODO - both wallet and token requests should be PUT, not POST.
// PUT = "...creates a new resource or replaces a representation of the target resource with the request payload."
@ -172,6 +225,14 @@ func (s *Server) wrongApiVersion(w http.ResponseWriter, req *http.Request) {
return
}
func serve(server *http.Server, done chan bool) {
log.Print("Server start")
server.ListenAndServe()
log.Print("Server finish")
done <- true
}
func (s *Server) Serve() {
http.HandleFunc(paths.PathAuthToken, s.getAuthToken)
http.HandleFunc(paths.PathWallet, s.handleWallet)
@ -180,6 +241,7 @@ func (s *Server) Serve() {
http.HandleFunc(paths.PathVerify, s.verify)
http.HandleFunc(paths.PathResendVerify, s.resendVerifyEmail)
http.HandleFunc(paths.PathClientSaltSeed, s.getClientSaltSeed)
http.HandleFunc(paths.PathWebsocket, s.websocket)
http.HandleFunc(paths.PathUnknownEndpoint, s.unknownEndpoint)
http.HandleFunc(paths.PathWrongApiVersion, s.wrongApiVersion)
@ -187,5 +249,38 @@ func (s *Server) Serve() {
http.Handle(paths.PathPrometheus, promhttp.Handler())
log.Printf("Serving at localhost:%d\n", s.port)
http.ListenAndServe(fmt.Sprintf("localhost:%d", s.port), nil)
// Signal *to* socket manager that it should finish (we use server.Shutdown
// to tell the server to finish)
socketsFinish := make(chan bool)
// Signal *from* server and socket manager that they are done:
serverDone := make(chan bool)
socketsDone := make(chan bool)
go s.manageSockets(socketsDone, socketsFinish)
server := http.Server{Addr: fmt.Sprintf("localhost:%d", s.port)}
go serve(&server, serverDone)
// Make sure that both the server and the websocket manager close properly on interrupt
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
// Wait for the interrupt signal
<-interrupt
// Tell the server to finish and wait for it to do so. We want it to finish
// to guarantee no more incoming sockets before we turn off the socket
// manager.
server.Shutdown(context.Background())
<-serverDone
// The socket manager's cleanup procedure assumes that there will be no new
// socket connections. Now that the server is done, no new socket
// connections will be coming in, so we can close the socket manager.
socketsFinish <- true
<-socketsDone
log.Printf("All done")
}

View file

@ -9,11 +9,12 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/server/paths"
"lbryio/lbry-id/store"
"lbryio/lbry-id/wallet"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/server/paths"
"lbryio/wallet-sync-server/store"
"lbryio/wallet-sync-server/wallet"
)
const TestPort = 8090
@ -131,6 +132,7 @@ type TestStore struct {
Errors TestStoreFunctionsErrors
TestAuthToken auth.AuthToken
TestUserId auth.UserId
TestEncryptedWallet wallet.EncryptedWallet
TestSequence wallet.Sequence
@ -203,7 +205,7 @@ func (s *TestStore) ChangePasswordWithWallet(
encryptedWallet wallet.EncryptedWallet,
sequence wallet.Sequence,
hmac wallet.WalletHmac,
) (err error) {
) (auth.UserId, error) {
s.Called.ChangePasswordWithWallet = ChangePasswordWithWalletCall{
EncryptedWallet: encryptedWallet,
Sequence: sequence,
@ -213,7 +215,7 @@ func (s *TestStore) ChangePasswordWithWallet(
NewPassword: newPassword,
ClientSaltSeed: clientSaltSeed,
}
return s.Errors.ChangePasswordWithWallet
return s.TestUserId, s.Errors.ChangePasswordWithWallet
}
func (s *TestStore) ChangePasswordNoWallet(
@ -221,14 +223,14 @@ func (s *TestStore) ChangePasswordNoWallet(
oldPassword auth.Password,
newPassword auth.Password,
clientSaltSeed auth.ClientSaltSeed,
) (err error) {
) (auth.UserId, error) {
s.Called.ChangePasswordNoWallet = ChangePasswordNoWalletCall{
Email: email,
OldPassword: oldPassword,
NewPassword: newPassword,
ClientSaltSeed: clientSaltSeed,
}
return s.Errors.ChangePasswordNoWallet
return s.TestUserId, s.Errors.ChangePasswordNoWallet
}
func (s *TestStore) GetClientSaltSeed(email auth.Email) (seed auth.ClientSaltSeed, err error) {
@ -269,6 +271,35 @@ func expectErrorString(t *testing.T, body []byte, expectedErrorString string) {
}
}
type wsMockManager struct {
s *Server
done chan bool
addedClientUserId auth.UserId
removedClientUserId auth.UserId
removedUserId auth.UserId
walletUpdateUserId auth.UserId
noMessage bool
}
func (m *wsMockManager) getOneMessage(timeout time.Duration) {
t := time.NewTicker(timeout)
select {
case msg := <-m.s.clientAdd:
m.addedClientUserId = msg.userId
case msg := <-m.s.clientRemove:
m.removedClientUserId = msg.userId
case msg := <-m.s.userRemove:
m.removedUserId = msg.userId
case msg := <-m.s.walletUpdates:
m.walletUpdateUserId = msg.userId
case <-t.C:
m.noMessage = true
}
t.Stop()
m.done <- true
}
func TestServerHelperCheckAuth(t *testing.T) {
tt := []struct {
name string
@ -324,7 +355,7 @@ func TestServerHelperCheckAuth(t *testing.T) {
Errors: tc.storeErrors,
TestAuthToken: auth.AuthToken{Token: auth.AuthTokenString("seekrit"), Scope: tc.userScope},
}
s := Server{&TestAuth{}, &testStore, &TestEnv{}, &TestMail{}, TestPort}
s := Init(&TestAuth{}, &testStore, &TestEnv{}, &TestMail{}, TestPort)
w := httptest.NewRecorder()
authToken := s.checkAuth(w, testStore.TestAuthToken.Token, tc.requiredScope)
@ -417,6 +448,13 @@ func TestServerHelperGetPostDataErrors(t *testing.T) {
expectedStatusCode: http.StatusBadRequest,
expectedErrorString: http.StatusText(http.StatusBadRequest) + ": Request failed validation: TestReq Error",
},
{
name: "body JSON has unknown field",
method: http.MethodPost,
requestBody: `{"lol": "wut"}`,
expectedStatusCode: http.StatusBadRequest,
expectedErrorString: http.StatusText(http.StatusBadRequest) + `: json: unknown field "lol"`,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {

View file

@ -3,14 +3,16 @@ package server
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/metrics"
"lbryio/lbry-id/store"
"lbryio/lbry-id/wallet"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/metrics"
"lbryio/wallet-sync-server/store"
"lbryio/wallet-sync-server/wallet"
)
type WalletRequest struct {
@ -30,7 +32,7 @@ func (r *WalletRequest) validate() error {
if r.Hmac == "" {
return fmt.Errorf("Missing 'hmac'")
}
if r.Sequence < 1 {
if r.Sequence < store.InitialWalletSequence {
return fmt.Errorf("Missing or zero-value 'sequence'")
}
return nil
@ -52,30 +54,14 @@ func (s *Server) handleWallet(w http.ResponseWriter, req *http.Request) {
}
}
// TODO - There's probably a struct-based solution here like with POST/PUT.
// We could put that struct up top as well.
func getWalletParams(req *http.Request) (token auth.AuthTokenString, err error) {
tokenSlice, hasTokenSlice := req.URL.Query()["token"]
if !hasTokenSlice || tokenSlice[0] == "" {
err = fmt.Errorf("Missing token parameter")
}
if err == nil {
token = auth.AuthTokenString(tokenSlice[0])
}
return
}
func (s *Server) getWallet(w http.ResponseWriter, req *http.Request) {
metrics.RequestsCount.With(prometheus.Labels{"method": "GET wallet"}).Inc()
metrics.RequestsCount.With(prometheus.Labels{"method": "GET", "endpoint": "wallet"}).Inc()
if !getGetData(w, req) {
return
}
token, paramsErr := getWalletParams(req)
token, paramsErr := getTokenParam(req)
if paramsErr != nil {
// In this specific case, the error is limited to values that are safe to
@ -123,7 +109,7 @@ func (s *Server) getWallet(w http.ResponseWriter, req *http.Request) {
// current wallet's sequence
// 500: Update unsuccessful for unanticipated reasons
func (s *Server) postWallet(w http.ResponseWriter, req *http.Request) {
metrics.RequestsCount.With(prometheus.Labels{"method": "POST wallet"}).Inc()
metrics.RequestsCount.With(prometheus.Labels{"method": "POST", "endpoint": "wallet"}).Inc()
var walletRequest WalletRequest
if !getPostData(w, req, &walletRequest) {
@ -156,4 +142,20 @@ func (s *Server) postWallet(w http.ResponseWriter, req *http.Request) {
}
fmt.Fprintf(w, string(response))
if walletRequest.Sequence == store.InitialWalletSequence {
log.Printf("Initial wallet created for user id %d", authToken.UserId)
}
// Inform the other clients over websockets. If we can't do it within 100
// milliseconds, don't bother. It's a nice-to-have, not mission critical.
// But, count the misses on the dashboard. If it happens a lot we should
// probably increase the buffer on the notify chans for the clients. Those
// will be a bottleneck within the socket manager.
timeout := time.NewTicker(100 * time.Millisecond)
select {
case s.walletUpdates <- walletUpdateMsg{authToken.UserId, walletRequest.Sequence}:
case <-timeout.C:
metrics.ErrorsCount.With(prometheus.Labels{"error_type": "ws-client-notify"}).Inc()
}
timeout.Stop()
}

View file

@ -9,11 +9,12 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/server/paths"
"lbryio/lbry-id/store"
"lbryio/lbry-id/wallet"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/server/paths"
"lbryio/wallet-sync-server/store"
"lbryio/wallet-sync-server/wallet"
)
func TestServerGetWallet(t *testing.T) {
@ -78,7 +79,7 @@ func TestServerGetWallet(t *testing.T) {
}
testEnv := TestEnv{}
s := Server{&testAuth, &testStore, &testEnv, &TestMail{}, TestPort}
s := Init(&testAuth, &testStore, &testEnv, &TestMail{}, TestPort)
req := httptest.NewRequest(http.MethodGet, paths.PathWallet, nil)
q := req.URL.Query()
@ -135,6 +136,7 @@ func TestServerPostWallet(t *testing.T) {
expectedStatusCode int
expectedErrorString string
expectSetWalletCall bool
expectWsMsg bool
// This is getting messy, but in the case of validation failures, we don't
// even get around to trying to get an auth token, since the token string is
@ -155,6 +157,7 @@ func TestServerPostWallet(t *testing.T) {
name: "success",
expectedStatusCode: http.StatusOK,
expectSetWalletCall: true,
expectWsMsg: true,
// Simulates a situation where the existing sequence is 1, the new
// sequence is 2.
@ -225,18 +228,19 @@ func TestServerPostWallet(t *testing.T) {
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
testAuth := TestAuth{}
testStore := TestStore{
TestAuthToken: auth.AuthToken{
Token: auth.AuthTokenString("seekrit"),
Scope: auth.ScopeFull,
UserId: auth.UserId(37),
},
Errors: tc.storeErrors,
}
s := Server{&testAuth, &testStore, &TestEnv{}, &TestMail{}, TestPort}
s := Init(&testAuth, &testStore, &TestEnv{}, &TestMail{}, TestPort)
wsmm := wsMockManager{s: s, done: make(chan bool)}
requestBody := []byte(
fmt.Sprintf(`{
@ -252,7 +256,15 @@ func TestServerPostWallet(t *testing.T) {
// test handleWallet while we're at it, which is a dispatch for get and post
// wallet
go wsmm.getOneMessage(100 * time.Millisecond)
s.handleWallet(w, req)
<-wsmm.done
if tc.expectWsMsg && wsmm.walletUpdateUserId != testStore.TestAuthToken.UserId {
t.Error("Expected websocket message to update wallet")
}
if !tc.expectWsMsg && wsmm.walletUpdateUserId == testStore.TestAuthToken.UserId {
t.Error("Expected no websocket message to update wallet")
}
// Make sure we tried to get an auth based on the `token` param (whether or
// not it was a valid `token`)

285
server/websocket.go Normal file
View file

@ -0,0 +1,285 @@
package server
import (
"fmt"
"log"
"net/http"
"time"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/wallet"
"github.com/gorilla/websocket"
)
// Using this as a guide:
// https://github.com/gorilla/websocket/blob/master/examples/chat/
//
// Skipping some things that seem like maybe overkill for a simple application,
// given that this isn't mission critical, and given that I'm not sure what a
// lot of it does. In particular the wsWriter ping stuff. But, we can add it if
// the performance is bad.
const pongWait = 60 * time.Second
const writeWait = 10 * time.Second
type wsClientNotifyType int
const (
// The channel is closed (i.e. this is the zero-value), so the socket should
// be closed too. We might not actually check for a closed channel using
// this value, but it's here for completeness.
wsClientNotifyFinish = wsClientNotifyType(iota)
// Inform the client about a wallet update
wsClientNotifyUpdate
)
// wsClientNotifyMsg is sent over wsClient.notify by the websocket manager
type wsClientNotifyMsg struct {
notifyType wsClientNotifyType
sequence wallet.Sequence
}
const notifyChanBuffer = 5 // Each client shouldn't be getting a lot of concurrent messages
// Given a wsClientNotifyMsg of type wsClientNotifyUpdate, turn it into an
// appropriate message to the client to be sent over websocket
func walletUpdateWSMessage(msg wsClientNotifyMsg) []byte {
return []byte(fmt.Sprintf("wallet-update:%d", msg.sequence))
}
// Poor man's debug log
const debugWebsockets = false
func debugLog(format string, v ...any) {
if debugWebsockets {
log.Printf(format, v...)
}
}
// Represents a connection to a client.
type wsClient struct {
socket *websocket.Conn
notify chan wsClientNotifyMsg
}
// Each user with at least one actively connected client will have one of these
// associated.
type wsClientSet map[*wsClient]bool
// A message sent over a channel to indicate that the given client is
// connecting or disconnecting for the given user.
type wsClientForUser struct {
userId auth.UserId
client *wsClient
}
var upgrader = websocket.Upgrader{} // use default options
// Just handle ping/pong
func (s *Server) wsReader(userId auth.UserId, client *wsClient) {
defer func() {
// Since wsWriter is waiting on the notify channel, tell the manager to
// close it. This will make wsWriter stop (if it hasn't already).
s.clientRemove <- wsClientForUser{userId, client}
client.socket.Close()
debugLog("Done with wsReader %+v", client)
}()
client.socket.SetReadLimit(512)
client.socket.SetReadDeadline(time.Now().Add(pongWait))
client.socket.SetPongHandler(func(string) error { client.socket.SetReadDeadline(time.Now().Add(pongWait)); return nil })
for {
_, _, err := client.socket.ReadMessage()
if err != nil {
debugLog("wsReader: %s\n", err.Error())
break
}
}
}
func (s *Server) wsWriter(userId auth.UserId, client *wsClient) {
defer func() {
// Whatever the cause of closure here, closing the socket (if it's not
// closed already) will cause wsReader to stop (if it hasn't stopped
// already) since it's waiting on the socket.
client.socket.Close()
debugLog("Done with wsWriter %+v", client)
}()
for notifyMsg := range client.notify {
if notifyMsg.notifyType != wsClientNotifyUpdate {
log.Printf("wsWriter: Got an unknown message type! %+v", notifyMsg)
continue
}
debugLog("wsWriter: notify update")
client.socket.SetWriteDeadline(time.Now().Add(writeWait))
err := client.socket.WriteMessage(websocket.TextMessage, walletUpdateWSMessage(notifyMsg))
if err != nil {
debugLog("wsWriter: %s\n", err.Error())
return // skip close message
}
}
// Not sure what the point of this is, given that this probably
// wouldn't get triggered unless the socket already closed, but the
// example did this.
debugLog("wsWriter: sending CloseMessage")
client.socket.SetWriteDeadline(time.Now().Add(writeWait))
client.socket.WriteMessage(websocket.CloseMessage, []byte{})
}
// This is the server endpoint that initiates a new websocket
func (s *Server) websocket(w http.ResponseWriter, req *http.Request) {
token, paramsErr := getTokenParam(req)
if paramsErr != nil {
// In this specific case, the error is limited to values that are safe to
// give to the user.
errorJson(w, http.StatusBadRequest, paramsErr.Error())
return
}
authToken := s.checkAuth(w, token, auth.ScopeFull)
if authToken == nil {
return
}
upgrader.CheckOrigin = func(r *http.Request) bool { return true }
ws, err := upgrader.Upgrade(w, req, nil)
if err != nil {
log.Println(err)
return
}
client := wsClient{ws, make(chan wsClientNotifyMsg, notifyChanBuffer)}
newClient := wsClientForUser{authToken.UserId, &client}
s.clientAdd <- newClient
go s.wsReader(authToken.UserId, &client)
go s.wsWriter(authToken.UserId, &client)
log.Println("Client Connected")
}
func (s *Server) manageSockets(done chan bool, finish chan bool) {
log.Println("Socket manager start")
clientsByUser := make(map[auth.UserId]wsClientSet)
removeClient := func(userId auth.UserId, client *wsClient) {
debugLog("removeClient %+v", client)
if _, ok := clientsByUser[userId]; !ok {
return
}
if _, ok := clientsByUser[userId][client]; !ok {
return
}
close(client.notify)
delete(clientsByUser[userId], client)
if len(clientsByUser[userId]) == 0 {
delete(clientsByUser, userId)
}
}
removeUser := func(userId auth.UserId) {
debugLog("removeUser (which calls removeClient) %d", userId)
for client := range clientsByUser[userId] {
removeClient(userId, client)
}
}
addClient := func(userId auth.UserId, client *wsClient) {
debugLog("addClient %+v", client)
if _, ok := clientsByUser[userId]; !ok {
clientsByUser[userId] = make(wsClientSet)
}
clientsByUser[userId][client] = true
}
manage:
for {
select {
case msg := <-s.walletUpdates:
for client := range clientsByUser[msg.userId] {
select {
case client.notify <- wsClientNotifyMsg{wsClientNotifyUpdate, msg.sequence}:
default:
log.Println("This is a bug: Channel was somehow closed but the manager has not (yet) received a clientRemove message.")
// The example program had this, but I don't see why.
removeClient(msg.userId, client)
}
}
case removedUser := <-s.userRemove:
removeUser(removedUser.userId)
case retiredClient := <-s.clientRemove:
removeClient(retiredClient.userId, retiredClient.client)
case newClient := <-s.clientAdd:
addClient(newClient.userId, newClient.client)
case <-finish:
break manage
}
}
log.Println("Cleaning up sockets")
debugLog("Running any addClient messages that snuck in...")
// By the time the `finish` channel has triggered, the web server has shut
// down, so we won't have any clientAdd events _triggered_ by this point.
// However, one (or more, if we add a buffer later) may be in the queue, so
// let's keep track of them here so we can close them. But we close the
// clientAdd channel first so we can break out of this loop.
// We assume that the server is done writing at this point, thus it's safe
// to close this channel here.
close(s.clientAdd)
for newClient := range s.clientAdd {
addClient(newClient.userId, newClient.client)
}
// Now that we know about every running client, just close all of the sockets
// (double-closing seems to be safe, so we don't care about races here). But
// if it takes more than 10 seconds for whatever reason, just bail.
ticker := time.NewTicker(10 * time.Second)
go func() {
select {
case <-ticker.C:
log.Println("Giving up on closing remaining sockets cleanly.")
// This will signal to main to exit, which will end the program
done <- true
log.Println("Socket manager impolite finish")
}
}()
debugLog("Closing sockets...")
for _, userClients := range clientsByUser {
for client := range userClients {
debugLog("Closing socket for %+v", client)
client.socket.SetWriteDeadline(time.Now().Add(writeWait))
client.socket.WriteMessage(websocket.CloseMessage, []byte{})
// TODO - wait for receiving the CloseMessage?
client.socket.Close()
debugLog("Closed socket for %+v", client)
}
}
// TODO - Do we need to wait for the sockets to actually close after
// calling Close(), before exiting the program? Alternately: do they close
// automatically anyway and I don't need this cleanup stuff in the first
// place? (Probably doesn't automatically send the Close message at least.)
done <- true
log.Println("Socket manager finish")
}

35
server/websocket_test.go Normal file
View file

@ -0,0 +1,35 @@
package server
import (
"testing"
"time"
)
func TestWebsocketManagerQuits(t *testing.T) {
s := Init(&TestAuth{}, &TestStore{}, &TestEnv{}, &TestMail{}, TestPort)
done := make(chan bool)
finish := make(chan bool)
go s.manageSockets(done, finish)
select {
case <-done:
t.Fatal("Websocket handler shouldn't be done yet")
default:
}
finish <- true
ticker := time.NewTicker(100 * time.Millisecond)
select {
case <-done:
case <-ticker.C:
t.Fatal("Websocket handler should be done by now")
}
}
// TODO Add some real tests. Making a meaningful test, given that we're dealing
// with websockets here, is a real pain in the ass, and it's probably not the
// highest priority right now. If websockets become higher profile we can work
// on it again.

View file

@ -8,7 +8,7 @@ import (
"github.com/mattn/go-sqlite3"
"lbryio/lbry-id/auth"
"lbryio/wallet-sync-server/auth"
)
func expectAccountMatch(
@ -20,17 +20,21 @@ func expectAccountMatch(
seed auth.ClientSaltSeed,
expectedVerifyTokenString *auth.VerifyTokenString,
approxVerifyExpiration *time.Time,
approxCreated time.Time,
approxUpdated time.Time,
) {
var key auth.KDFKey
var salt auth.ServerSalt
var email auth.Email
var verifyExpiration *time.Time
var verifyTokenString *auth.VerifyTokenString
var created time.Time
var updated time.Time
err := s.db.QueryRow(
`SELECT key, server_salt, email, verify_token, verify_expiration from accounts WHERE normalized_email=? AND client_salt_seed=?`,
`SELECT key, server_salt, email, verify_token, verify_expiration, created, updated from accounts WHERE normalized_email=? AND client_salt_seed=?`,
normEmail, seed,
).Scan(&key, &salt, &email, &verifyTokenString, &verifyExpiration)
).Scan(&key, &salt, &email, &verifyTokenString, &verifyExpiration, &created, &updated)
if err != nil {
t.Fatalf("Error finding account for: %s %s - %+v", normEmail, password, err)
}
@ -73,14 +77,32 @@ func expectAccountMatch(
if time.Second < expDiff || expDiff < -time.Second {
t.Fatalf(
"Verify expiration not as expected. Want approximately: %s Got: %s",
verifyExpiration,
approxVerifyExpiration,
verifyExpiration,
)
}
}
if approxVerifyExpiration == nil && verifyExpiration != nil {
t.Fatalf("Expected verify expiration to be nil. Got: %+v", verifyExpiration)
}
expDiff := approxCreated.Sub(created)
if time.Second*2 < expDiff || expDiff < -time.Second*2 {
t.Fatalf(
"Created timestamp not as expected. Want approximately: %s Got: %s",
approxCreated,
created,
)
}
expDiff = approxUpdated.Sub(updated)
if time.Second*2 < expDiff || expDiff < -time.Second*2 {
t.Fatalf(
"Updated timestamp not as expected. Want approximately: %s Got: %s",
approxUpdated,
updated,
)
}
}
func expectAccountNotExists(t *testing.T, s *Store, normEmail auth.NormalizedEmail) {
@ -119,7 +141,7 @@ func TestStoreCreateAccount(t *testing.T) {
}
// Get and confirm the account we just put in
expectAccountMatch(t, &s, normEmail, email, password, seed, nil, nil)
expectAccountMatch(t, &s, normEmail, email, password, seed, nil, nil, time.Now().UTC(), time.Now().UTC())
newPassword := auth.Password("xyz")
@ -138,7 +160,7 @@ func TestStoreCreateAccount(t *testing.T) {
}
// Get the email and same *first* password we successfully put in
expectAccountMatch(t, &s, normEmail, email, password, seed, nil, nil)
expectAccountMatch(t, &s, normEmail, email, password, seed, nil, nil, time.Now().UTC(), time.Now().UTC())
}
// Test that I can use CreateAccount twice for different emails with no veriy token
@ -165,8 +187,8 @@ func TestStoreCreateAccountTwoVerifiedSucceed(t *testing.T) {
}
// Get and confirm the accounts we just put in
expectAccountMatch(t, &s, normEmail1, email1, password1, seed1, nil, nil)
expectAccountMatch(t, &s, normEmail2, email2, password2, seed2, nil, nil)
expectAccountMatch(t, &s, normEmail1, email1, password1, seed1, nil, nil, time.Now().UTC(), time.Now().UTC())
expectAccountMatch(t, &s, normEmail2, email2, password2, seed2, nil, nil, time.Now().UTC(), time.Now().UTC())
}
// Test that I cannot use CreateAccount twice with the same verify token, but
@ -204,8 +226,8 @@ func TestStoreCreateAccountTwoSameVerfiyTokenFail(t *testing.T) {
// Get and confirm the accounts we just put in
approxVerifyExpiration := time.Now().Add(time.Hour * 24 * 2).UTC()
expectAccountMatch(t, &s, normEmail1, email1, password1, seed1, &verifyToken1, &approxVerifyExpiration)
expectAccountMatch(t, &s, normEmail2, email2, password2, seed2, &verifyToken2, &approxVerifyExpiration)
expectAccountMatch(t, &s, normEmail1, email1, password1, seed1, &verifyToken1, &approxVerifyExpiration, time.Now().UTC(), time.Now().UTC())
expectAccountMatch(t, &s, normEmail2, email2, password2, seed2, &verifyToken2, &approxVerifyExpiration, time.Now().UTC(), time.Now().UTC())
}
// Try CreateAccount with a verification string, thus unverified
@ -224,7 +246,7 @@ func TestStoreCreateAccountUnverified(t *testing.T) {
// Get and confirm the account we just put in
approxVerifyExpiration := time.Now().Add(time.Hour * 24 * 2).UTC()
expectAccountMatch(t, &s, normEmail, email, password, seed, &verifyToken, &approxVerifyExpiration)
expectAccountMatch(t, &s, normEmail, email, password, seed, &verifyToken, &approxVerifyExpiration, time.Now().UTC(), time.Now().UTC())
}
// Test GetUserId for nonexisting email
@ -380,12 +402,12 @@ func TestUpdateVerifyTokenStringSuccess(t *testing.T) {
if err := s.UpdateVerifyTokenString(lowerEmail, verifyTokenString2); err != nil {
t.Fatalf("Unexpected error in UpdateVerifyTokenString: err: %+v", err)
}
expectAccountMatch(t, &s, normEmail, email, password, createdSeed, &verifyTokenString2, &approxVerifyExpiration)
expectAccountMatch(t, &s, normEmail, email, password, createdSeed, &verifyTokenString2, &approxVerifyExpiration, time.Now().UTC(), time.Now().UTC())
if err := s.UpdateVerifyTokenString(upperEmail, verifyTokenString3); err != nil {
t.Fatalf("Unexpected error in UpdateVerifyTokenString: err: %+v", err)
}
expectAccountMatch(t, &s, normEmail, email, password, createdSeed, &verifyTokenString3, &approxVerifyExpiration)
expectAccountMatch(t, &s, normEmail, email, password, createdSeed, &verifyTokenString3, &approxVerifyExpiration, time.Now().UTC(), time.Now().UTC())
}
// Test UpdateVerifyTokenString for nonexisting email
@ -418,9 +440,9 @@ func TestUpdateVerifyAccountSuccess(t *testing.T) {
defer StoreTestCleanup(sqliteTmpFile)
verifyTokenString := auth.VerifyTokenString("abcd1234abcd1234abcd1234abcd1234")
time1 := time.Time{}
verifyExpiration := time.Now().Add(time.Second * 10).UTC() // expires in one second
_, email, password, createdSeed := makeTestUser(t, &s, &verifyTokenString, &time1)
_, email, password, createdSeed := makeTestUser(t, &s, &verifyTokenString, &verifyExpiration)
// we're not testing normalization features so we'll just use this here
normEmail := email.Normalize()
@ -428,7 +450,7 @@ func TestUpdateVerifyAccountSuccess(t *testing.T) {
if err := s.VerifyAccount(verifyTokenString); err != nil {
t.Fatalf("Unexpected error in VerifyAccount: err: %+v", err)
}
expectAccountMatch(t, &s, normEmail, email, password, createdSeed, nil, nil)
expectAccountMatch(t, &s, normEmail, email, password, createdSeed, nil, nil, time.Now().UTC(), time.Now().UTC())
}
// Test VerifyAccount for nonexisting token
@ -440,3 +462,23 @@ func TestStoreVerifyAccountTokenNotExists(t *testing.T) {
t.Fatalf(`VerifyAccount error for nonexistant token: wanted "%+v", got "%+v."`, ErrNoTokenForUser, err)
}
}
// Test VerifyAccount for expired token
func TestUpdateVerifyAccountTokenExpired(t *testing.T) {
s, sqliteTmpFile := StoreTestInit(t)
defer StoreTestCleanup(sqliteTmpFile)
verifyTokenString := auth.VerifyTokenString("abcd1234abcd1234abcd1234abcd1234")
verifyExpiration := time.Now().Add(time.Second * (-1)).UTC() // expired one second ago
_, email, password, createdSeed := makeTestUser(t, &s, &verifyTokenString, &verifyExpiration)
// we're not testing normalization features so we'll just use this here
normEmail := email.Normalize()
if err := s.VerifyAccount(verifyTokenString); err != ErrNoTokenForUser {
t.Fatalf(`VerifyAccount error for expired token: wanted "%+v", got "%+v."`, ErrNoTokenForUser, err)
}
expectAccountMatch(t, &s, normEmail, email, password, createdSeed, &verifyTokenString, &verifyExpiration, time.Now().UTC(), time.Now().UTC())
}

View file

@ -9,7 +9,7 @@ import (
"github.com/mattn/go-sqlite3"
"lbryio/lbry-id/auth"
"lbryio/wallet-sync-server/auth"
)
func expectTokenExists(t *testing.T, s *Store, expectedToken auth.AuthToken) {
@ -310,7 +310,7 @@ func TestStoreGetToken(t *testing.T) {
}
// Update the token to be expired
expirationOld := time.Now().Add(time.Second * (-1))
expirationOld := time.Now().Add(time.Second * (-1)).UTC()
if err := s.updateToken(&authToken, expirationOld); err != nil {
t.Fatalf("Unexpected error in updateToken: %+v", err)
}

View file

@ -5,8 +5,8 @@ import (
"testing"
"time"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/wallet"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/wallet"
)
// It involves both wallet and account tables. Should it go in wallet_test.go
@ -28,11 +28,11 @@ func TestStoreChangePasswordSuccess(t *testing.T) {
}
_, err = s.db.Exec(
"INSERT INTO wallets (user_id, encrypted_wallet, sequence, hmac) VALUES(?,?,?,?)",
"INSERT INTO wallets (user_id, encrypted_wallet, sequence, hmac, updated) VALUES(?,?,?,?,datetime('now'))",
userId, "my-enc-wallet", 1, "my-hmac",
)
if err != nil {
t.Fatalf("Error creating test wallet")
t.Fatalf("Error creating test wallet: %s", err.Error())
}
newPassword := oldPassword + auth.Password("_new")
@ -43,12 +43,16 @@ func TestStoreChangePasswordSuccess(t *testing.T) {
lowerEmail := auth.Email(strings.ToLower(string(email)))
if err := s.ChangePasswordWithWallet(lowerEmail, oldPassword, newPassword, newSeed, encryptedWallet, sequence, hmac); err != nil {
pwUserId, err := s.ChangePasswordWithWallet(lowerEmail, oldPassword, newPassword, newSeed, encryptedWallet, sequence, hmac)
if err != nil {
t.Errorf("ChangePasswordWithWallet (lower case email): unexpected error: %+v", err)
}
if userId != pwUserId {
t.Errorf("Expected ChangePasswordWithWallet to return correct user Id. Want %d got %d", userId, pwUserId)
}
expectAccountMatch(t, &s, email.Normalize(), email, newPassword, newSeed, nil, nil)
expectWalletExists(t, &s, userId, encryptedWallet, sequence, hmac)
expectAccountMatch(t, &s, email.Normalize(), email, newPassword, newSeed, nil, nil, time.Now().UTC(), time.Now().UTC())
expectWalletExists(t, &s, userId, encryptedWallet, sequence, hmac, time.Now().UTC())
expectTokenNotExists(t, &s, token)
newNewPassword := newPassword + auth.Password("_new")
@ -59,11 +63,15 @@ func TestStoreChangePasswordSuccess(t *testing.T) {
upperEmail := auth.Email(strings.ToUpper(string(email)))
if err := s.ChangePasswordWithWallet(upperEmail, newPassword, newNewPassword, newNewSeed, newEncryptedWallet, newSequence, newHmac); err != nil {
pwUserId, err = s.ChangePasswordWithWallet(upperEmail, newPassword, newNewPassword, newNewSeed, newEncryptedWallet, newSequence, newHmac)
if err != nil {
t.Errorf("ChangePasswordWithWallet (upper case email): unexpected error: %+v", err)
}
if userId != pwUserId {
t.Errorf("Expected ChangePasswordWithWallet to return correct user Id. Want %d got %d", userId, pwUserId)
}
expectAccountMatch(t, &s, email.Normalize(), email, newNewPassword, newNewSeed, nil, nil)
expectAccountMatch(t, &s, email.Normalize(), email, newNewPassword, newNewSeed, nil, nil, time.Now().UTC(), time.Now().UTC())
}
func TestStoreChangePasswordErrors(t *testing.T) {
@ -152,11 +160,11 @@ func TestStoreChangePasswordErrors(t *testing.T) {
if tc.hasWallet {
_, err := s.db.Exec(
"INSERT INTO wallets (user_id, encrypted_wallet, sequence, hmac) VALUES(?,?,?,?)",
"INSERT INTO wallets (user_id, encrypted_wallet, sequence, hmac, updated) VALUES(?,?,?,?,datetime('now'))",
userId, oldEncryptedWallet, oldSequence, oldHmac,
)
if err != nil {
t.Fatalf("Error creating test wallet")
t.Fatalf("Error creating test wallet: %s", err.Error())
}
}
@ -165,7 +173,7 @@ func TestStoreChangePasswordErrors(t *testing.T) {
newPassword := oldPassword + auth.Password("_new") // Make the new password different (as it should be)
newSeed := auth.ClientSaltSeed("edf98765edf98765edf98765edf98765edf98765edf98765edf98765edf98765")
if err := s.ChangePasswordWithWallet(submittedEmail, submittedOldPassword, newPassword, newSeed, newEncryptedWallet, tc.sequence, newHmac); err != tc.expectedError {
if _, err := s.ChangePasswordWithWallet(submittedEmail, submittedOldPassword, newPassword, newSeed, newEncryptedWallet, tc.sequence, newHmac); err != tc.expectedError {
t.Errorf("ChangePasswordWithWallet: unexpected value for err. want: %+v, got: %+v", tc.expectedError, err)
}
@ -173,9 +181,9 @@ func TestStoreChangePasswordErrors(t *testing.T) {
// This tests the transaction rollbacks in particular, given the errors
// that are at a couple different stages of the txn, triggered by these
// tests.
expectAccountMatch(t, &s, email.Normalize(), email, oldPassword, oldSeed, tc.verifyToken, tc.verifyExpiration)
expectAccountMatch(t, &s, email.Normalize(), email, oldPassword, oldSeed, tc.verifyToken, tc.verifyExpiration, time.Now().UTC(), time.Now().UTC())
if tc.hasWallet {
expectWalletExists(t, &s, userId, oldEncryptedWallet, oldSequence, oldHmac)
expectWalletExists(t, &s, userId, oldEncryptedWallet, oldSequence, oldHmac, time.Now().UTC())
} else {
expectWalletNotExists(t, &s, userId)
}
@ -204,11 +212,15 @@ func TestStoreChangePasswordNoWalletSuccess(t *testing.T) {
lowerEmail := auth.Email(strings.ToLower(string(email)))
if err := s.ChangePasswordNoWallet(lowerEmail, oldPassword, newPassword, newSeed); err != nil {
pwUserId, err := s.ChangePasswordNoWallet(lowerEmail, oldPassword, newPassword, newSeed)
if err != nil {
t.Errorf("ChangePasswordNoWallet (lower case email): unexpected error: %+v", err)
}
if userId != pwUserId {
t.Errorf("Expected ChangePasswordNoWallet to return correct user Id. Want %d got %d", userId, pwUserId)
}
expectAccountMatch(t, &s, email.Normalize(), email, newPassword, newSeed, nil, nil)
expectAccountMatch(t, &s, email.Normalize(), email, newPassword, newSeed, nil, nil, time.Now().UTC(), time.Now().UTC())
expectWalletNotExists(t, &s, userId)
expectTokenNotExists(t, &s, token)
@ -217,11 +229,16 @@ func TestStoreChangePasswordNoWalletSuccess(t *testing.T) {
upperEmail := auth.Email(strings.ToUpper(string(email)))
if err := s.ChangePasswordNoWallet(upperEmail, newPassword, newNewPassword, newNewSeed); err != nil {
pwUserId, err = s.ChangePasswordNoWallet(upperEmail, newPassword, newNewPassword, newNewSeed)
if err != nil {
t.Errorf("ChangePasswordNoWallet (upper case email): unexpected error: %+v", err)
}
if userId != pwUserId {
t.Errorf("Expected ChangePasswordNoWallet to return correct user Id. Want %d got %d", userId, pwUserId)
}
expectAccountMatch(t, &s, email.Normalize(), email, newNewPassword, newNewSeed, nil, nil)
expectAccountMatch(t, &s, email.Normalize(), email, newNewPassword, newNewSeed, nil, nil, time.Now().UTC(), time.Now().UTC())
}
func TestStoreChangePasswordNoWalletErrors(t *testing.T) {
@ -295,11 +312,11 @@ func TestStoreChangePasswordNoWalletErrors(t *testing.T) {
if tc.hasWallet {
_, err := s.db.Exec(
"INSERT INTO wallets (user_id, encrypted_wallet, sequence, hmac) VALUES(?,?,?,?)",
"INSERT INTO wallets (user_id, encrypted_wallet, sequence, hmac, updated) VALUES(?,?,?,?,datetime('now'))",
userId, encryptedWallet, sequence, hmac,
)
if err != nil {
t.Fatalf("Error creating test wallet")
t.Fatalf("Error creating test wallet: %s", err.Error())
}
}
@ -308,7 +325,7 @@ func TestStoreChangePasswordNoWalletErrors(t *testing.T) {
newPassword := oldPassword + auth.Password("_new") // Possibly make the new password different (as it should be)
newSeed := auth.ClientSaltSeed("edf98765edf98765edf98765edf98765edf98765edf98765edf98765edf98765")
if err := s.ChangePasswordNoWallet(submittedEmail, submittedOldPassword, newPassword, newSeed); err != tc.expectedError {
if _, err := s.ChangePasswordNoWallet(submittedEmail, submittedOldPassword, newPassword, newSeed); err != tc.expectedError {
t.Errorf("ChangePasswordNoWallet: unexpected value for err. want: %+v, got: %+v", tc.expectedError, err)
}
@ -316,9 +333,9 @@ func TestStoreChangePasswordNoWalletErrors(t *testing.T) {
// deleted. This tests the transaction rollbacks in particular, given the
// errors that are at a couple different stages of the txn, triggered by
// these tests.
expectAccountMatch(t, &s, email.Normalize(), email, oldPassword, oldSeed, tc.verifyToken, tc.verifyExpiration)
expectAccountMatch(t, &s, email.Normalize(), email, oldPassword, oldSeed, tc.verifyToken, tc.verifyExpiration, time.Now().UTC(), time.Now().UTC())
if tc.hasWallet {
expectWalletExists(t, &s, userId, encryptedWallet, sequence, hmac)
expectWalletExists(t, &s, userId, encryptedWallet, sequence, hmac, time.Now().UTC())
} else {
expectWalletNotExists(t, &s, userId)
}

View file

@ -11,8 +11,8 @@ import (
"github.com/mattn/go-sqlite3"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/wallet"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/wallet"
)
var (
@ -37,6 +37,10 @@ var (
const (
AuthTokenLifespan = time.Hour * 24 * 14
VerifyTokenLifespan = time.Hour * 24 * 2
// Eventually it could become variable when we introduce server switching. A user
// might be on a later sequence when they switch from another server.
InitialWalletSequence = 1
)
// For test stubs
@ -49,8 +53,8 @@ type StoreInterface interface {
CreateAccount(auth.Email, auth.Password, auth.ClientSaltSeed, *auth.VerifyTokenString) error
UpdateVerifyTokenString(auth.Email, auth.VerifyTokenString) error
VerifyAccount(auth.VerifyTokenString) error
ChangePasswordWithWallet(auth.Email, auth.Password, auth.Password, auth.ClientSaltSeed, wallet.EncryptedWallet, wallet.Sequence, wallet.WalletHmac) error
ChangePasswordNoWallet(auth.Email, auth.Password, auth.Password, auth.ClientSaltSeed) error
ChangePasswordWithWallet(auth.Email, auth.Password, auth.Password, auth.ClientSaltSeed, wallet.EncryptedWallet, wallet.Sequence, wallet.WalletHmac) (auth.UserId, error)
ChangePasswordNoWallet(auth.Email, auth.Password, auth.Password, auth.ClientSaltSeed) (auth.UserId, error)
GetClientSaltSeed(auth.Email) (auth.ClientSaltSeed, error)
}
@ -111,6 +115,8 @@ func (s *Store) Migrate() error {
encrypted_wallet TEXT NOT NULL,
sequence INTEGER NOT NULL,
hmac TEXT NOT NULL,
updated DATETIME NOT NULL,
PRIMARY KEY (user_id)
FOREIGN KEY (user_id) REFERENCES accounts(user_id)
CHECK (
@ -134,6 +140,8 @@ func (s *Store) Migrate() error {
verify_expiration DATETIME,
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
created DATETIME DEFAULT (DATETIME('now')),
updated DATETIME NOT NULL,
CHECK (
email <> '' AND
normalized_email <> '' AND
@ -277,12 +285,12 @@ func (s *Store) insertFirstWallet(
encryptedWallet wallet.EncryptedWallet,
hmac wallet.WalletHmac,
) (err error) {
// This will only be used to attempt to insert the first wallet (sequence=1).
// This will only be used to attempt to insert the first wallet (sequence=InitialWalletSequence).
// The database will enforce that this will not be set if this user already
// has a wallet.
_, err = s.db.Exec(
"INSERT INTO wallets (user_id, encrypted_wallet, sequence, hmac) VALUES(?,?,?,?)",
userId, encryptedWallet, 1, hmac,
"INSERT INTO wallets (user_id, encrypted_wallet, sequence, hmac, updated) VALUES(?,?,?,?, datetime('now'))",
userId, encryptedWallet, InitialWalletSequence, hmac,
)
var sqliteErr sqlite3.Error
@ -305,12 +313,12 @@ func (s *Store) updateWalletToSequence(
sequence wallet.Sequence,
hmac wallet.WalletHmac,
) (err error) {
// This will be used for wallets with sequence > 1.
// This will be used for wallets with sequence > InitialWalletSequence.
// Use the database to enforce that we only update if we are incrementing the sequence.
// This way, if two clients attempt to update at the same time, it will return
// an error for the second one.
res, err := s.db.Exec(
"UPDATE wallets SET encrypted_wallet=?, sequence=?, hmac=? WHERE user_id=? AND sequence=?",
"UPDATE wallets SET encrypted_wallet=?, sequence=?, hmac=?, updated=datetime('now') WHERE user_id=? AND sequence=?",
encryptedWallet, sequence, hmac, userId, sequence-1,
)
if err != nil {
@ -329,22 +337,22 @@ func (s *Store) updateWalletToSequence(
return
}
// Assumption: Sequence has been validated (>=1)
// Assumption: Sequence has been validated (>=InitialWalletSequence)
// Assumption: Auth token has been checked (thus account is verified)
func (s *Store) SetWallet(userId auth.UserId, encryptedWallet wallet.EncryptedWallet, sequence wallet.Sequence, hmac wallet.WalletHmac) (err error) {
if sequence == 1 {
// If sequence == 1, the client assumed that this is our first
if sequence == InitialWalletSequence {
// If sequence == InitialWalletSequence, the client assumed that this is our first
// wallet. Try to insert. If we get a conflict, the client
// assumed incorrectly and we proceed below to return the latest
// wallet from the db.
err = s.insertFirstWallet(userId, encryptedWallet, hmac)
if err == ErrDuplicateWallet {
// A wallet already exists. That means the input sequence should not be 1.
// A wallet already exists. That means the input sequence should not be InitialWalletSequence.
// To the caller, this means the sequence was wrong.
err = ErrWrongSequence
}
} else {
// If sequence > 1, the client assumed that it is replacing wallet
// If sequence > InitialWalletSequence, the client assumed that it is replacing wallet
// with sequence - 1. Explicitly try to update the wallet with
// sequence - 1. If we updated no rows, the client assumed incorrectly
// and we proceed below to return the latest wallet from the db.
@ -403,7 +411,7 @@ func (s *Store) CreateAccount(email auth.Email, password auth.Password, seed aut
// userId auto-increments
_, err = s.db.Exec(
"INSERT INTO accounts (normalized_email, email, key, server_salt, client_salt_seed, verify_token, verify_expiration) VALUES(?,?,?,?,?,?,?)",
"INSERT INTO accounts (normalized_email, email, key, server_salt, client_salt_seed, verify_token, verify_expiration, updated) VALUES(?,?,?,?,?,?,?, datetime('now'))",
email.Normalize(), email, key, salt, seed, verifyToken, verifyExpiration,
)
var sqliteErr sqlite3.Error
@ -426,7 +434,7 @@ func (s *Store) UpdateVerifyTokenString(email auth.Email, verifyTokenString auth
expiration := time.Now().UTC().Add(VerifyTokenLifespan)
res, err := s.db.Exec(
`UPDATE accounts SET verify_token=?, verify_expiration=? WHERE normalized_email=? and verify_token is not null`,
`UPDATE accounts SET verify_token=?, verify_expiration=?, updated=datetime('now') WHERE normalized_email=? and verify_token is not null`,
verifyTokenString, expiration, email.Normalize(),
)
if err != nil {
@ -456,9 +464,11 @@ func (s *Store) UpdateVerifyTokenString(email auth.Email, verifyTokenString auth
}
func (s *Store) VerifyAccount(verifyTokenString auth.VerifyTokenString) (err error) {
expirationCutoff := time.Now().UTC()
res, err := s.db.Exec(
"UPDATE accounts SET verify_token=null, verify_expiration=null WHERE verify_token=?",
verifyTokenString,
"UPDATE accounts SET verify_token=null, verify_expiration=null, updated=datetime('now') WHERE verify_token=? AND verify_expiration>?",
verifyTokenString, expirationCutoff,
)
if err != nil {
return
@ -482,6 +492,22 @@ func (s *Store) VerifyAccount(verifyTokenString auth.VerifyTokenString) (err err
// Also delete all auth tokens to force clients to update their root password
// to get a new token. This prevents other clients from posting a wallet
// encrypted with the old key.
//
// Return userId as a pure convenience for the calling request handler.
//
// TODO - A wallet encrypted with the old key could still save successfully in
// a race condition:
// 1) get auth token request passes old password check
// 2) password change transaction begins and ends
// 3) get auth token request saves and returns a new token
// 4) post wallet using the auth token that snuck by
// One obvious solution would be to integrate everything into one database
// transaction. This problem could apply to other requests as well. Not just
// database ones: there's a similar potential race condition trying to boot
// users from all of their websockets on password change. We should think
// about it. Maybe we could have a counter for password changes, similar to
// Sequence? And the tokens have that number attached to it. We can check it
// as an extra validation of the token.
func (s *Store) ChangePasswordWithWallet(
email auth.Email,
oldPassword auth.Password,
@ -490,7 +516,7 @@ func (s *Store) ChangePasswordWithWallet(
encryptedWallet wallet.EncryptedWallet,
sequence wallet.Sequence,
hmac wallet.WalletHmac,
) (err error) {
) (userId auth.UserId, err error) {
return s.changePassword(
email,
oldPassword,
@ -508,12 +534,14 @@ func (s *Store) ChangePasswordWithWallet(
// Also delete all auth tokens to force clients to update their root password
// to get a new token. This prevents other clients from posting a wallet
// encrypted with the old key.
//
// Return userId as a pure convenience for the calling request handler.
func (s *Store) ChangePasswordNoWallet(
email auth.Email,
oldPassword auth.Password,
newPassword auth.Password,
clientSaltSeed auth.ClientSaltSeed,
) (err error) {
) (userId auth.UserId, err error) {
return s.changePassword(
email,
oldPassword,
@ -534,8 +562,7 @@ func (s *Store) changePassword(
encryptedWallet wallet.EncryptedWallet,
sequence wallet.Sequence,
hmac wallet.WalletHmac,
) (err error) {
var userId auth.UserId
) (userId auth.UserId, err error) {
tx, err := s.db.Begin()
if err != nil {
@ -585,7 +612,7 @@ func (s *Store) changePassword(
}
res, err := tx.Exec(
"UPDATE accounts SET key=?, server_salt=?, client_salt_seed=? WHERE user_id=?",
"UPDATE accounts SET key=?, server_salt=?, client_salt_seed=?, updated=datetime('now') WHERE user_id=?",
newKey, newSalt, clientSaltSeed, userId,
)
if err != nil {
@ -605,7 +632,7 @@ func (s *Store) changePassword(
// With a wallet expected: update it.
res, err = tx.Exec(
`UPDATE wallets SET encrypted_wallet=?, sequence=?, hmac=?
`UPDATE wallets SET encrypted_wallet=?, sequence=?, hmac=?, updated=datetime('now')
WHERE user_id=? AND sequence=?`,
encryptedWallet, sequence, hmac, userId, sequence-1,
)
@ -636,8 +663,10 @@ func (s *Store) changePassword(
}
}
// Don't care how many I delete here. Might even be zero. No login token while
// changing password seems plausible.
// Don't care how many I delete here. Might even be zero (no login token
// while changing password seems plausible). The main reason for this is
// that we want to prevent any client from saving a subsequent wallet
// without changing its password first.
_, err = tx.Exec("DELETE FROM auth_tokens WHERE user_id=?", userId)
return
}

View file

@ -6,7 +6,7 @@ import (
"testing"
"time"
"lbryio/lbry-id/auth"
"lbryio/wallet-sync-server/auth"
)
func StoreTestInit(t *testing.T) (s Store, tmpFile *os.File) {
@ -51,7 +51,7 @@ func makeTestUser(
seed = auth.ClientSaltSeed("abcd1234abcd1234")
rows, err := s.db.Query(
"INSERT INTO accounts (normalized_email, email, key, server_salt, client_salt_seed, verify_token, verify_expiration) values(?,?,?,?,?,?,?) returning user_id",
"INSERT INTO accounts (normalized_email, email, key, server_salt, client_salt_seed, verify_token, verify_expiration, updated) values(?,?,?,?,?,?,?, datetime('now')) returning user_id",
normEmail, email, key, salt, seed, verifyToken, verifyExpiration,
)
if err != nil {

View file

@ -3,11 +3,12 @@ package store
import (
"errors"
"testing"
"time"
"github.com/mattn/go-sqlite3"
"lbryio/lbry-id/auth"
"lbryio/lbry-id/wallet"
"lbryio/wallet-sync-server/auth"
"lbryio/wallet-sync-server/wallet"
)
func expectWalletExists(
@ -17,9 +18,10 @@ func expectWalletExists(
expectedEncryptedWallet wallet.EncryptedWallet,
expectedSequence wallet.Sequence,
expectedHmac wallet.WalletHmac,
approxUpdated time.Time,
) {
rows, err := s.db.Query(
"SELECT encrypted_wallet, sequence, hmac FROM wallets WHERE user_id=?", userId)
"SELECT encrypted_wallet, sequence, hmac, updated FROM wallets WHERE user_id=?", userId)
if err != nil {
t.Fatalf("Error finding wallet for user_id=%d: %+v", userId, err)
}
@ -28,6 +30,7 @@ func expectWalletExists(
var encryptedWallet wallet.EncryptedWallet
var sequence wallet.Sequence
var hmac wallet.WalletHmac
var updated time.Time
for rows.Next() {
@ -35,6 +38,7 @@ func expectWalletExists(
&encryptedWallet,
&sequence,
&hmac,
&updated,
)
if err != nil {
@ -45,6 +49,15 @@ func expectWalletExists(
t.Fatalf("Unexpected values for wallet: encrypted wallet: %+v sequence: %+v hmac: %+v err: %+v", encryptedWallet, sequence, hmac, err)
}
expDiff := approxUpdated.Sub(updated)
if time.Second*2 < expDiff || expDiff < -time.Second*2 {
t.Fatalf(
"Updated timestamp not as expected. Want approximately: %s Got: %s",
approxUpdated,
updated,
)
}
return // found a match, we're good
}
t.Fatalf("Expected wallet for user_id=%d: %+v", userId, err)
@ -97,7 +110,7 @@ func TestStoreInsertWallet(t *testing.T) {
}
// Get a wallet, have the values we put in with a sequence of 1
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet"), wallet.Sequence(1), wallet.WalletHmac("my-hmac"))
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet"), wallet.Sequence(1), wallet.WalletHmac("my-hmac"), time.Now().UTC())
// Put in a first wallet for a second time, have an error for trying
if err := s.insertFirstWallet(userId, wallet.EncryptedWallet("my-enc-wallet-2"), wallet.WalletHmac("my-hmac-2")); err != ErrDuplicateWallet {
@ -105,7 +118,7 @@ func TestStoreInsertWallet(t *testing.T) {
}
// Get the same *first* wallet we successfully put in
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet"), wallet.Sequence(1), wallet.WalletHmac("my-hmac"))
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet"), wallet.Sequence(1), wallet.WalletHmac("my-hmac"), time.Now().UTC())
}
// Test updateWalletToSequence, using insertFirstWallet as a helper
@ -139,7 +152,7 @@ func TestStoreUpdateWallet(t *testing.T) {
}
// Get the same wallet we initially *inserted*, since it didn't update
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-a"), wallet.Sequence(1), wallet.WalletHmac("my-hmac-a"))
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-a"), wallet.Sequence(1), wallet.WalletHmac("my-hmac-a"), time.Now().UTC())
// Update the wallet successfully, with the right sequence
if err := s.updateWalletToSequence(userId, wallet.EncryptedWallet("my-enc-wallet-b"), wallet.Sequence(2), wallet.WalletHmac("my-hmac-b")); err != nil {
@ -147,7 +160,7 @@ func TestStoreUpdateWallet(t *testing.T) {
}
// Get a wallet, have the values we put in
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-b"), wallet.Sequence(2), wallet.WalletHmac("my-hmac-b"))
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-b"), wallet.Sequence(2), wallet.WalletHmac("my-hmac-b"), time.Now().UTC())
// Update the wallet again successfully
if err := s.updateWalletToSequence(userId, wallet.EncryptedWallet("my-enc-wallet-c"), wallet.Sequence(3), wallet.WalletHmac("my-hmac-c")); err != nil {
@ -155,13 +168,13 @@ func TestStoreUpdateWallet(t *testing.T) {
}
// Get a wallet, have the values we put in
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-c"), wallet.Sequence(3), wallet.WalletHmac("my-hmac-c"))
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-c"), wallet.Sequence(3), wallet.WalletHmac("my-hmac-c"), time.Now().UTC())
}
// NOTE - the "behind the scenes" comments give a view of what we're expecting
// to happen, and why we're testing what we are. Sometimes it should insert,
// sometimes it should update. It depends on whether it's the first wallet
// submitted, and that's easily determined by sequence=1. However, if we switch
// submitted, and that's easily determined by sequence=store.InitialWalletSequence. However, if we switch
// to a database with "upserts" and take advantage of it, what happens behind
// the scenes will change a little, so the comments should be updated. Though,
// we'd probably best test the same cases.
@ -186,33 +199,33 @@ func TestStoreSetWallet(t *testing.T) {
if err := s.SetWallet(userId, wallet.EncryptedWallet("my-enc-wallet-a"), wallet.Sequence(1), wallet.WalletHmac("my-hmac-a")); err != nil {
t.Fatalf("Unexpected error in SetWallet: %+v", err)
}
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-a"), wallet.Sequence(1), wallet.WalletHmac("my-hmac-a"))
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-a"), wallet.Sequence(1), wallet.WalletHmac("my-hmac-a"), time.Now().UTC())
// Sequence 1 - fails - out of sequence (behind the scenes, tries to insert but there's something there already)
if err := s.SetWallet(userId, wallet.EncryptedWallet("my-enc-wallet-b"), wallet.Sequence(1), wallet.WalletHmac("my-hmac-b")); err != ErrWrongSequence {
t.Fatalf(`SetWallet err: wanted "%+v", got "%+v"`, ErrWrongSequence, err)
}
// Expect the *first* wallet to still be there
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-a"), wallet.Sequence(1), wallet.WalletHmac("my-hmac-a"))
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-a"), wallet.Sequence(1), wallet.WalletHmac("my-hmac-a"), time.Now().UTC())
// Sequence 3 - fails - out of sequence (behind the scenes: tries via update, which is appropriate here)
if err := s.SetWallet(userId, wallet.EncryptedWallet("my-enc-wallet-b"), wallet.Sequence(3), wallet.WalletHmac("my-hmac-b")); err != ErrWrongSequence {
t.Fatalf(`SetWallet err: wanted "%+v", got "%+v"`, ErrWrongSequence, err)
}
// Expect the *first* wallet to still be there
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-a"), wallet.Sequence(1), wallet.WalletHmac("my-hmac-a"))
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-a"), wallet.Sequence(1), wallet.WalletHmac("my-hmac-a"), time.Now().UTC())
// Sequence 2 - succeeds - (behind the scenes, does an update. Tests successful update-after-insert)
if err := s.SetWallet(userId, wallet.EncryptedWallet("my-enc-wallet-b"), wallet.Sequence(2), wallet.WalletHmac("my-hmac-b")); err != nil {
t.Fatalf("Unexpected error in SetWallet: %+v", err)
}
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-b"), wallet.Sequence(2), wallet.WalletHmac("my-hmac-b"))
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-b"), wallet.Sequence(2), wallet.WalletHmac("my-hmac-b"), time.Now().UTC())
// Sequence 3 - succeeds - (behind the scenes, does an update. Tests successful update-after-update. Maybe gratuitous?)
if err := s.SetWallet(userId, wallet.EncryptedWallet("my-enc-wallet-c"), wallet.Sequence(3), wallet.WalletHmac("my-hmac-c")); err != nil {
t.Fatalf("Unexpected error in SetWallet: %+v", err)
}
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-c"), wallet.Sequence(3), wallet.WalletHmac("my-hmac-c"))
expectWalletExists(t, &s, userId, wallet.EncryptedWallet("my-enc-wallet-c"), wallet.Sequence(3), wallet.WalletHmac("my-hmac-c"), time.Now().UTC())
}
// Pretty simple, only two cases: wallet is there or it's not.

View file

@ -10,9 +10,11 @@ For this example we will be working with a locally running server so that we don
```
>>> from test_client import Client
>>> c1 = Client("joe2@example.com", "123abc2", 'test_wallet_1', local=True)
>>> import time
>>> email = "joe-%s@example.com" % int(time.time())
>>> c1 = Client("c1", email, "123abc2", 'test_wallet_1', local=True)
Connecting to Wallet API at http://localhost:8090
>>> c2 = Client("joe2@example.com", "123abc2", 'test_wallet_2', local=True)
>>> c2 = Client("c2", email, "123abc2", 'test_wallet_2', local=True)
Connecting to Wallet API at http://localhost:8090
```
@ -24,7 +26,7 @@ Generating keys...
Done generating keys
Registered
>>> c1.salt_seed
'1d52635c14b34f0fefcf86368d4e0b82e3555de9d3c93a6f22cd5500fd120c0d'
'8a77dcb8b2854c2fecabbde74a721fde5e326164f2cf1a7f6810d0e1f340d043'
```
Set up the other client. See that it got the same salt seed from the server in the process, which it needs to make sure we have the correct encryption key and login password.
@ -34,16 +36,16 @@ Set up the other client. See that it got the same salt seed from the server in t
Generating keys...
Done generating keys
>>> c2.salt_seed
'1d52635c14b34f0fefcf86368d4e0b82e3555de9d3c93a6f22cd5500fd120c0d'
'8a77dcb8b2854c2fecabbde74a721fde5e326164f2cf1a7f6810d0e1f340d043'
```
Now that the account exists, grab an auth token with both clients.
```
>>> c1.get_auth_token()
Got auth token: e52f6e893fe3fa92d677d85f32e77357d68afd313c303a91d3af176ec684aa0d
Got auth token: 9cfbed8d587440b899beb0ea534caaff96981d1f212d83d606642a900deddd1c
>>> c2.get_auth_token()
Got auth token: b9fc2620990447d5f0305ecafc9f75e2a5f928a31bd86806aa8989567cad57d0
Got auth token: 99725c84039e323a880e936d14789d8a79b2fc7efdcae08ed4282e05160f5204
```
## Syncing
@ -55,7 +57,7 @@ Create a new wallet + metadata (we'll wrap it in a struct we'll call `WalletStat
>>> c1.update_remote_wallet()
Successfully updated wallet state on server
Synced walletState:
WalletState(sequence=1, encrypted_wallet='czo4MTkyOjE2OjE6XBEQgEACPvxgUFW3MGnY9tG5VYh/Hx7iNG6DAX+q4zTbVZM17OQ/5D1+IOjxS7jxOB+dZmtxmo6qwGtizjc4+YBhNk/eKb+uIU8T6HQ4T3m+PiWpedLnBwF4RStPPBp1M2WNFTIZQPKirETPO3GqRQSzveB17A3iESqYTqHnGeE=')
WalletState(sequence=1, encrypted_wallet='czo4MTkyOjE2OjE6MArKm6fT20MSDlsRPAxl5gk49wHPwBxjNnouMGsTEi2uQaMwVOyDRETIRLBTPHFHn6Uz5j+9a5o6RfAbChvToaFRpe4FWZGtlSBiRqdnatxnYzwTRK9OxAttPJdO6BJ0tO1pmn5ipSHfkQdbTT/POTSnElsrnfDU+4AtdoO9kNA=')
'Success'
```
@ -67,7 +69,7 @@ Now, call `init_wallet_state` with the other client. Then, we call `get_remote_w
>>> c2.init_wallet_state()
>>> c2.get_remote_wallet()
Got latest walletState:
WalletState(sequence=1, encrypted_wallet='czo4MTkyOjE2OjE6XBEQgEACPvxgUFW3MGnY9tG5VYh/Hx7iNG6DAX+q4zTbVZM17OQ/5D1+IOjxS7jxOB+dZmtxmo6qwGtizjc4+YBhNk/eKb+uIU8T6HQ4T3m+PiWpedLnBwF4RStPPBp1M2WNFTIZQPKirETPO3GqRQSzveB17A3iESqYTqHnGeE=')
WalletState(sequence=1, encrypted_wallet='czo4MTkyOjE2OjE6MArKm6fT20MSDlsRPAxl5gk49wHPwBxjNnouMGsTEi2uQaMwVOyDRETIRLBTPHFHn6Uz5j+9a5o6RfAbChvToaFRpe4FWZGtlSBiRqdnatxnYzwTRK9OxAttPJdO6BJ0tO1pmn5ipSHfkQdbTT/POTSnElsrnfDU+4AtdoO9kNA=')
'Success'
```
@ -79,12 +81,12 @@ Push a new version, GET it with the other client. Even though we haven't edited
>>> c2.update_remote_wallet()
Successfully updated wallet state on server
Synced walletState:
WalletState(sequence=2, encrypted_wallet='czo4MTkyOjE2OjE6gL9aGNjy4U+6mBQZRzx+GS+/1dhl54+5sBzVtBQz51az7HQ3HFI2PjUL7XkeTcjdsaPEKh3eFTQwly9fNFKJIya5YvmtY8zhxe8FCqCkTITrn2EPwZFYXF6E3Wi1gLaPMpZlb2EXIZ1E7Gbg1Uxcpj+s1CB4ttjIZdnFwUrfAw4=')
WalletState(sequence=2, encrypted_wallet='czo4MTkyOjE2OjE6RbDcGPPGipR3f++iY2IV4TseRvEuZ18HX/SWzzGrw0qbAlChXgSRUTvAlCV1sGyKJEHhBIlGfC+KOCKEGPaK9fx7BmhhcHvCDmwIlcpJ3VwMtwTjxTJZE9+Q8YLOXjZM1RZhPPiCDqxUzNVPaJm2F1MLSn3tDtX5Duz15ll998Y=')
'Success'
>>> c1.get_remote_wallet()
Nothing to merge. Taking remote walletState as latest walletState.
Got latest walletState:
WalletState(sequence=2, encrypted_wallet='czo4MTkyOjE2OjE6gL9aGNjy4U+6mBQZRzx+GS+/1dhl54+5sBzVtBQz51az7HQ3HFI2PjUL7XkeTcjdsaPEKh3eFTQwly9fNFKJIya5YvmtY8zhxe8FCqCkTITrn2EPwZFYXF6E3Wi1gLaPMpZlb2EXIZ1E7Gbg1Uxcpj+s1CB4ttjIZdnFwUrfAw4=')
WalletState(sequence=2, encrypted_wallet='czo4MTkyOjE2OjE6RbDcGPPGipR3f++iY2IV4TseRvEuZ18HX/SWzzGrw0qbAlChXgSRUTvAlCV1sGyKJEHhBIlGfC+KOCKEGPaK9fx7BmhhcHvCDmwIlcpJ3VwMtwTjxTJZE9+Q8YLOXjZM1RZhPPiCDqxUzNVPaJm2F1MLSn3tDtX5Duz15ll998Y=')
'Success'
```
@ -110,12 +112,12 @@ The wallet is synced between the clients. The client with the changed preference
>>> c1.update_remote_wallet()
Successfully updated wallet state on server
Synced walletState:
WalletState(sequence=3, encrypted_wallet='czo4MTkyOjE2OjE6JwVggGDjqoLy9YUqFXzIltph5bvO46SwJoAbLydlLg1mjfoXksGm9NsWbmYYmBoiXmiIbJPIsj8xfOjO5JlCH+EHSdyjCXizzwClYwgM4UD1+/ltuv1TH7H59cXd6Kztefn4y9IL/97rs+2DxDHM6cb/AdYGohIc3VaCmYBSbYRQFjTbQHaaScW6ntYuXAyE')
WalletState(sequence=3, encrypted_wallet='czo4MTkyOjE2OjE6H0sr9zU/SYL2/0abnfmb4y1WqSnRFuylbket0kahuBi42l3RzZunVY5qp7DHFheQ5RNI/KvaEMV6efC9a7EZc/J5nqZOolgdv0dCSPpgwDS0TxUtsCSH6DGZ3htLxqU2r3ZqKX5XCP4f93lTc8loPGvB+e8k6+CYAeXnkS57ske5U6ZYvJtlMMQpYPSVU3xN')
'Success'
>>> c2.get_remote_wallet()
Nothing to merge. Taking remote walletState as latest walletState.
Got latest walletState:
WalletState(sequence=3, encrypted_wallet='czo4MTkyOjE2OjE6JwVggGDjqoLy9YUqFXzIltph5bvO46SwJoAbLydlLg1mjfoXksGm9NsWbmYYmBoiXmiIbJPIsj8xfOjO5JlCH+EHSdyjCXizzwClYwgM4UD1+/ltuv1TH7H59cXd6Kztefn4y9IL/97rs+2DxDHM6cb/AdYGohIc3VaCmYBSbYRQFjTbQHaaScW6ntYuXAyE')
WalletState(sequence=3, encrypted_wallet='czo4MTkyOjE2OjE6H0sr9zU/SYL2/0abnfmb4y1WqSnRFuylbket0kahuBi42l3RzZunVY5qp7DHFheQ5RNI/KvaEMV6efC9a7EZc/J5nqZOolgdv0dCSPpgwDS0TxUtsCSH6DGZ3htLxqU2r3ZqKX5XCP4f93lTc8loPGvB+e8k6+CYAeXnkS57ske5U6ZYvJtlMMQpYPSVU3xN')
'Success'
>>> c2.get_preferences()
{'animal': 'cow', 'car': ''}
@ -142,7 +144,7 @@ One client POSTs its change first.
>>> c1.update_remote_wallet()
Successfully updated wallet state on server
Synced walletState:
WalletState(sequence=4, encrypted_wallet='czo4MTkyOjE2OjE6xMKOvjQ9RBAWCac5Cj5d30YSI4PaMh3T+99fLdHKJC2RCcwrbhCurNIDBln6QJWCfa3gRp2/sY9k47XwZNsknCTrdIe4c3YJejvL/WCZTzoJ81m9QGbP/05DHQUV5c7z30taIESp4qOFwpSwYMB972gn6ZXOhn1iNDKSCLN3nSLHFnA0arjCAPQof//lJriz')
WalletState(sequence=4, encrypted_wallet='czo4MTkyOjE2OjE6W9I5lyVJgKYlT10doJs9NOGHygtBMXyyXVtDI3doUx/eoPwX+qPxHl/Cz3mjDCEWojZgFZqS70ZPBjbRITWQ9iizPWyUG+FtddUVgadW+nCGiSeKHqVmu5n0MihrvNrtKgEka10dmdtj3U4JJsF/0CwlsyzRKLhPgjlJvzn3miW5DOKrNNtJaFmWFJXIJke5')
'Success'
```
@ -154,7 +156,7 @@ Eventually, the client will be responsible (or at least more responsible) for me
>>> c2.get_remote_wallet()
Merging local changes with remote changes to create latest walletState.
Got latest walletState:
WalletState(sequence=4, encrypted_wallet='czo4MTkyOjE2OjE6xMKOvjQ9RBAWCac5Cj5d30YSI4PaMh3T+99fLdHKJC2RCcwrbhCurNIDBln6QJWCfa3gRp2/sY9k47XwZNsknCTrdIe4c3YJejvL/WCZTzoJ81m9QGbP/05DHQUV5c7z30taIESp4qOFwpSwYMB972gn6ZXOhn1iNDKSCLN3nSLHFnA0arjCAPQof//lJriz')
WalletState(sequence=4, encrypted_wallet='czo4MTkyOjE2OjE6W9I5lyVJgKYlT10doJs9NOGHygtBMXyyXVtDI3doUx/eoPwX+qPxHl/Cz3mjDCEWojZgFZqS70ZPBjbRITWQ9iizPWyUG+FtddUVgadW+nCGiSeKHqVmu5n0MihrvNrtKgEka10dmdtj3U4JJsF/0CwlsyzRKLhPgjlJvzn3miW5DOKrNNtJaFmWFJXIJke5')
'Success'
>>> c2.get_preferences()
{'animal': 'horse', 'car': 'Audi'}
@ -166,12 +168,12 @@ Finally, the client with the merged wallet pushes it to the server, and the othe
>>> c2.update_remote_wallet()
Successfully updated wallet state on server
Synced walletState:
WalletState(sequence=5, encrypted_wallet='czo4MTkyOjE2OjE6L+PCpF1qh1ayai/fqnc7kwa2eBJc1n6L6FuaLps8gdZhY9UdaBMc/BckvgUF9OXR7yOvndrFy73+5EzWxpmffBfZGqq42XjtbmHGScEERjuzra8UB2vLn+N2oe5s+e2O+7lJxPKYBD2pX4xKm3HjKqAso+D0MsWHMz9hqRLFekJfv5pVglUVkweW+h8yNxn1')
WalletState(sequence=5, encrypted_wallet='czo4MTkyOjE2OjE60UtNFnnuzYfjdEV2VH/QWO6WTqfSlu0KpUxOm2CuEij9erCmNkiuAQCzihsAZPVSFvt3N9UJRGQdeDRwjN9P2yKr89ED/qBhNHSZzEI8dwR7qrPyqPE5vchiw0UclZPeUdrQiAyyCvZSkThiQNEnwvQyeoucxCZ8P3Gi48Vht48MQ8W07zloMXmndiF81h7G')
'Success'
>>> c1.get_remote_wallet()
Nothing to merge. Taking remote walletState as latest walletState.
Got latest walletState:
WalletState(sequence=5, encrypted_wallet='czo4MTkyOjE2OjE6L+PCpF1qh1ayai/fqnc7kwa2eBJc1n6L6FuaLps8gdZhY9UdaBMc/BckvgUF9OXR7yOvndrFy73+5EzWxpmffBfZGqq42XjtbmHGScEERjuzra8UB2vLn+N2oe5s+e2O+7lJxPKYBD2pX4xKm3HjKqAso+D0MsWHMz9hqRLFekJfv5pVglUVkweW+h8yNxn1')
WalletState(sequence=5, encrypted_wallet='czo4MTkyOjE2OjE60UtNFnnuzYfjdEV2VH/QWO6WTqfSlu0KpUxOm2CuEij9erCmNkiuAQCzihsAZPVSFvt3N9UJRGQdeDRwjN9P2yKr89ED/qBhNHSZzEI8dwR7qrPyqPE5vchiw0UclZPeUdrQiAyyCvZSkThiQNEnwvQyeoucxCZ8P3Gi48Vht48MQ8W07zloMXmndiF81h7G')
'Success'
>>> c1.get_preferences()
{'animal': 'horse', 'car': 'Audi'}
@ -202,7 +204,7 @@ We try to POST both of them to the server. The second one fails because of the c
>>> c2.update_remote_wallet()
Successfully updated wallet state on server
Synced walletState:
WalletState(sequence=6, encrypted_wallet='czo4MTkyOjE2OjE6HieQoVznUMTBF6x643Mg/AQUZadaikkiuRZsw3IaQsapK56WL3IBGrlemOjSH6uTfBWsWaLDMXEz+X7j5wqchSAt/wle2+I9dKgyDdFhWMOaEd61pT6r+lS8O8AbSKUJ6r5FSDgJRE/vz5l4xP/W9AVrK4l0u9ZqpvsKAet3UlfVV48cOnhwgPqlPoGBQ1xF')
WalletState(sequence=6, encrypted_wallet='czo4MTkyOjE2OjE6e2cIHgNvUiveemLTBYx1BDyueh5JNh4ojvIMnSaass14+Li5eKjVCZaU1LQ1gT4zB6ibqoSu3P60MuOc6/A8GUAwh4KVzQfBBJHjmHWN5ZYoBlJY7AdflrFo0mkUwD1pzTYA0+9iexnI+s0v7ya/rFvw77GtErotgLCnlvOoZiJs3EUyRkltjukz2UCy5LKc')
'Success'
>>> c1.update_remote_wallet()
Submitted wallet is out of date.
@ -218,14 +220,14 @@ The client that is out of date will then call `get_remote_wallet`, which GETs an
>>> c1.get_remote_wallet()
Merging local changes with remote changes to create latest walletState.
Got latest walletState:
WalletState(sequence=6, encrypted_wallet='czo4MTkyOjE2OjE6HieQoVznUMTBF6x643Mg/AQUZadaikkiuRZsw3IaQsapK56WL3IBGrlemOjSH6uTfBWsWaLDMXEz+X7j5wqchSAt/wle2+I9dKgyDdFhWMOaEd61pT6r+lS8O8AbSKUJ6r5FSDgJRE/vz5l4xP/W9AVrK4l0u9ZqpvsKAet3UlfVV48cOnhwgPqlPoGBQ1xF')
WalletState(sequence=6, encrypted_wallet='czo4MTkyOjE2OjE6e2cIHgNvUiveemLTBYx1BDyueh5JNh4ojvIMnSaass14+Li5eKjVCZaU1LQ1gT4zB6ibqoSu3P60MuOc6/A8GUAwh4KVzQfBBJHjmHWN5ZYoBlJY7AdflrFo0mkUwD1pzTYA0+9iexnI+s0v7ya/rFvw77GtErotgLCnlvOoZiJs3EUyRkltjukz2UCy5LKc')
'Success'
>>> c1.get_preferences()
{'animal': 'beaver', 'car': 'Toyota'}
>>> c1.update_remote_wallet()
Successfully updated wallet state on server
Synced walletState:
WalletState(sequence=7, encrypted_wallet='czo4MTkyOjE2OjE6ypuX2e/wjiVZZVrLQEwoEuHZH7xhs6B3/awzxH/5WZITlKOo7TvV2Mjke/MSdTk2/YyWhfN8U0e4IwGxKW9VIpnF3ElEtEZxvJBklzDXeDNh5pWMgeZkBH5EempDQ6VzT0206z89EeiCK+3QSofUv7Ob90xNVUOdJq5/OBrG4LAGFh2ZVrh5KnqDm1+d8/ls')
WalletState(sequence=7, encrypted_wallet='czo4MTkyOjE2OjE6mAo3n7J3aNpyiOLHPaHvOypgop4C2Re+fGzMXrdbSLnrMPxVZxlGS3KRzN58jrQ8gkMUqg3aPviXXyII/W8I8MB3lvRoZPRvD1Mkw48sMhPZ5/QsLvNlJT/29ZzqCo4PQAM3PSVo/ho6i7Bilh7Z6pcm7b8u/fuegbsR5NNdTkQ2nbnZl2sDKH6G3idHGc11')
'Success'
```
@ -243,7 +245,7 @@ Generating keys...
Done generating keys
Successfully updated password and wallet state on server
Synced walletState:
WalletState(sequence=8, encrypted_wallet='czo4MTkyOjE2OjE6Kd/DnozNDXYia8yYqrVI6OJ56tDAo5X4/Il+Ein/E6GRQ6K8/niK8Sjx1Cmpf7ecru14QS51pTwlFpS9mbwNE7CZ1wjAZHoLlL5B+dAECkSCFBHgBvq/29cXt6gG7KP+TLRLxZzGtgQRQiq6fsMBIIirw1ZCmpUNQP/PCHIJRfjJS0MNAGN8+srlPv+eUXIn')
WalletState(sequence=8, encrypted_wallet='czo4MTkyOjE2OjE6BuNkOY60NKVP4vIjXKfiy4qV75pDzf3YKzBQwU02yhlUR/Jh6ZZTdpEKrKnScwYTVFrSCO+0V7TyPTEVZrh4eLIHJoLgPgDPGl7BNP1aXH4VH+eroqwPQPMLeMJztInWFJt4U2gM+TqExfjG4pNTm5CbD5qiTkv9RMPLBvapcLbD3xeVQkpAYhTi0I458Hsn')
'Success'
```
@ -251,7 +253,7 @@ We generate a new salt seed when we change the password
```
>>> c1.salt_seed
'155b6e8a9a8c9406844b6b0c4a40c3204ab1f06668470faa89e28aa89fefe3cf'
'e128e66be3c433b30ba40c72d2a42dac8ee84d37a182d4fa2022d4b857d156b0'
```
This operation invalidates all of the user's auth tokens. This prevents other clients from accidentally pushing a wallet encrypted with the old password.
@ -271,19 +273,19 @@ The client that changed its password can easily get a new token because it has t
```
>>> c1.get_auth_token()
Got auth token: 68a3db244e21709429e69e67352d02a3b26542c5ef2ac3377e19b17de71942d6
Got auth token: 10c06893d0f6b5c6506d75c55f6bdea361df1514ad8e9d04b19e7cf6852f6352
>>> c2.get_auth_token()
Error 401
b'{"error":"Unauthorized: No match for email and/or password"}\n'
Failed to get the auth token. Do you need to update this client's password (set_local_password())?
Failed to get the auth token. Do you need to verify your email address? Or update this client's password (set_local_password())?
Or, in the off-chance the user changed their password back and forth, try updating secrets (update_derived_secrets()) to get the latest salt seed.
>>> c2.set_local_password("eggsandwich")
Generating keys...
Done generating keys
>>> c2.salt_seed
'155b6e8a9a8c9406844b6b0c4a40c3204ab1f06668470faa89e28aa89fefe3cf'
'e128e66be3c433b30ba40c72d2a42dac8ee84d37a182d4fa2022d4b857d156b0'
>>> c2.get_auth_token()
Got auth token: 3917215675c5cc7fb5c5e24d583fddcd0a14c4370140e2274cf4c5da7eaae7bb
Got auth token: 90689d10cda131747d9dcaddbd474806c16e3d218b54cff4a4ea8fc9d2888e23
```
We don't allow password changes if we have pending wallet changes to push. This is to prevent a situation where the user has to merge local and remote changes in the middle of a password change.
@ -296,16 +298,79 @@ Generating keys...
Done generating keys
Local changes found. Update remote wallet before changing password.
'Failure'
```
If we update the wallet first, we can do it.
```
>>> c1.update_remote_wallet()
Successfully updated wallet state on server
Synced walletState:
WalletState(sequence=9, encrypted_wallet='czo4MTkyOjE2OjE6Hspn+wbfHEzSv+1zsM/sFUaJJZuLLP7jLtCl3Ou3OQhXGEpkC0pP7WcbdGdQ+4foakTaB/y/b9All85rJ1ZiGWFnaK8SS9Rd7JT1UCEHs0BhN5+SfIK58yukIefzP39ZlSGUomE3eifOqso8C/gY2FltO96TS8WXx6czxqm6M/dvLk6q10LpODCQEH5auTA6')
WalletState(sequence=9, encrypted_wallet='czo4MTkyOjE2OjE6JB6y6xDMds4pp4eDh8TDfXvpENnfaQztvwua+ipXE7XSlkOJDjQ7GEv7NtnSPP+Whsny1p6GIWgf22WJ76KGMg16oMTxXWVwK0yTzSiQVhQhc5lNHGMSQDu6wOlHhQAfwta3xwMKbFnklq0GlTRYayeUNspFmFRxKulC5dsdU72V6bPnk3lE5K15JKpNGSQ5')
'Success'
>>> c1.change_password("starboard")
Generating keys...
Done generating keys
Successfully updated password and wallet state on server
Synced walletState:
WalletState(sequence=10, encrypted_wallet='czo4MTkyOjE2OjE6Cnditb9t+rU56hfcMq6gW+lx1ek3TzyBZ4633FoiWCzTxIenbMyapolU0gnpWHasP8olOoL56LfSGVzP8eKG4JoRsU9VmOYXjkpY9QZCcKomVC4fJ17jPq/e2gJWDSv03pA1xbDhRpXRnZr3wd+37znTUyLpYzRDRAHpb2IGDi9FforobQRNcZUhx0DY8WIR')
WalletState(sequence=10, encrypted_wallet='czo4MTkyOjE2OjE64YLYwQx95KgCS+kqWhVm3pHBV18bFANe+Kb3Fi3+m9k73p78l/4RF82pxxsYgJeZPrAcpV3pz21dQPVgBDuWszDqk8LkvSQXK/OAmrL3gPFmvuXEV4E504UPcxWeBqiFHNxV1cbWs7UJf31ofVJTEDwqhd0jVitJr/U8+0YU1enEnQd6UavKBxhiaIcy4+xd')
'Success'
>>> c1.get_auth_token()
Got auth token: ab5820fa0f92ca11698b88ca11c7a38e778e4760b85e2fdaedd761561d87951e
>>> c2.set_local_password("starboard")
Generating keys...
Done generating keys
>>> c2.get_auth_token()
Got auth token: 4c32f3348d51662519b7d8675b69571a1ce4d76de88a7b495a4848c5dbb4da7f
```
# Websockets
A client can make a websocket connection to the server and receive notifications whenever another client updates the wallet on the server. The message will contain the sequence number so that the client can know whether they happen to be up to date. (The client that made the update will of course be up to date).
This test client will have a thread listening to the websocket which just prints info about new messages as they come in. A real client would likely choose to get the latest wallet from the server as soon as a messag ecomes through, assuming the sequence is newer than what the client has.
```
>>> c1.start_websocket()
c1 connected for now
>>> c2.start_websocket()
c2 connected for now
```
Now make an update and see:
```
>>> c1.update_remote_wallet()
c2 got notified of a wallet update, sequence=11. If your client is behind this sequence, you should get the latest from the server.
Successfully updated wallet state on server
Synced walletState:
WalletState(sequence=11, encrypted_wallet='czo4MTkyOjE2OjE6h5sx/erPDmRUd7lUwhW9xDkWHmWOvooebx99Je6WMvG+XXd98MjOuBYTIFHbtr0XzR2ARzvNCmnvxSUBGfRoCiF9Um6OqimJTuc636E2pCgLzvEq8W39qYb3enlu6zd+NiGUXEo85j9WY1FvxrSPBxvV21cM4HLWijDGBob/adYTx33sT8o6tZ/18axxNdNK')
'Success'
c1 got notified of a wallet update, sequence=11. If your client is behind this sequence, you should get the latest from the server.
```
Update again and we'll see the new sequence number:
```
>>> c1.update_remote_wallet()
c2 got notified of a wallet update, sequence=12. If your client is behind this sequence, you should get the latest from the server.
Successfully updated wallet state on server
Synced walletState:
WalletState(sequence=12, encrypted_wallet='czo4MTkyOjE2OjE6WvomdZhbeg1ypEkHVR07Z4yI2uj1thLkzolgIcvPfE2i+vexeTfG6WE+bsVlhMwn7wXzQ3tIeW7P8IGpAS+HtcySxXyAyZXJxX/2N6fzqd9Dai8pNO1Ed2RyxGGjMf4spMDT3wJOQ93Plsc96y36iPIzNXQ8gtL15IjAfbiImR+KPmIG8E0IDyYVuOoaTF+A')
'Success'
c1 got notified of a wallet update, sequence=12. If your client is behind this sequence, you should get the latest from the server.
```
When we change a password, just as all auth tokens are invalidated, all sockets are also disconnected.
```
>>> c1.change_password("ihatesockets")
Generating keys...
Done generating keys
c2 disconnected for now: code = 1005 (no status code [internal]), no reason
c1 disconnected for now: code = 1005 (no status code [internal]), no reason
Successfully updated password and wallet state on server
Synced walletState:
WalletState(sequence=13, encrypted_wallet='czo4MTkyOjE2OjE6GGAs7vW0bt+fzo0xNwQQLd0nY9CM9r1flMtwXNNktXuEA0TkaWVSpP1r/pyN3qLkY8it+OAD2dm44usxzEtNnq1hH40vDPgAYpMtSXRVV1aPTZK6Zv2W1MQN88N4qI592tqpGEm8q2FtqrvA61Rf/CIUGbVKmf5jiPx9aiON6t3etENkZD9t+DEMJo6TOlO9')
'Success'
```

View file

@ -2,7 +2,6 @@
# Generate the README since I want real behavior interspersed with comments
# Come to think of it, this is accidentally a pretty okay integration test for client and server
# NOTE - delete the database before running this, or else you'll get an error for registering. also we want the wallet to start empty
# NOTE - in the SDK, create wallets called "test_wallet_1" and "test_wallet_2"
import time
@ -15,7 +14,7 @@ for wallet in ['test_wallet_1', 'test_wallet_2']:
# Make sure the next preference changes have a later timestamp!
time.sleep(1.1)
def code_block(code):
def code_block(code, stall=0):
print ("```")
for line in code.strip().split('\n'):
print(">>> " + line)
@ -25,9 +24,7 @@ def code_block(code):
result = eval(line)
if result is not None:
print(repr(result))
if 'set_preference' in line:
# Make sure the next preference changes have a later timestamp!
time.sleep(1.1)
time.sleep(stall) # Some commands we want to give some async aspect to finish before continuing
print ("```")
print("""# Test Client
@ -44,8 +41,10 @@ For this example we will be working with a locally running server so that we don
code_block("""
from test_client import Client
c1 = Client("joe2@example.com", "123abc2", 'test_wallet_1', local=True)
c2 = Client("joe2@example.com", "123abc2", 'test_wallet_2', local=True)
import time
email = "joe-%s@example.com" % int(time.time())
c1 = Client("c1", email, "123abc2", 'test_wallet_1', local=True)
c2 = Client("c2", email, "123abc2", 'test_wallet_2', local=True)
""")
print("""
@ -120,7 +119,9 @@ c1.get_preferences()
c2.get_preferences()
c1.set_preference('animal', 'cow')
c1.get_preferences()
""")
""",
stall=1.1, # Make sure the next preference changes have a later timestamp!
)
print("""
The wallet is synced between the clients. The client with the changed preference sends its wallet to the server, and the other one GETs it locally.
@ -143,7 +144,9 @@ c1.set_preference('car', 'Audi')
c2.set_preference('animal', 'horse')
c1.get_preferences()
c2.get_preferences()
""")
""",
stall=1.1, # Make sure the next preference changes have a later timestamp!
)
print("""
One client POSTs its change first.
@ -191,7 +194,9 @@ _ = c2.set_preference('animal', 'beaver')
_ = c1.set_preference('car', 'Toyota')
c2.get_preferences()
c1.get_preferences()
""")
""",
stall=1.1, # Make sure the next preference changes have a later timestamp!
)
print("""
We try to POST both of them to the server. The second one fails because of the conflict, and we see that its preferences don't change yet.
@ -263,6 +268,63 @@ We don't allow password changes if we have pending wallet changes to push. This
code_block("""
c1.set_preference('animal', 'leemur')
c1.change_password("starboard")
""",
stall=1.1, # Make sure the next preference changes have a later timestamp!
)
print("""
If we update the wallet first, we can do it.
""")
code_block("""
c1.update_remote_wallet()
c1.change_password("starboard")
c1.get_auth_token()
c2.set_local_password("starboard")
c2.get_auth_token()
""")
print("""
# Websockets
A client can make a websocket connection to the server and receive notifications whenever another client updates the wallet on the server. The message will contain the sequence number so that the client can know whether they happen to be up to date. (The client that made the update will of course be up to date).
This test client will have a thread listening to the websocket which just prints info about new messages as they come in. A real client would likely choose to get the latest wallet from the server as soon as a messag ecomes through, assuming the sequence is newer than what the client has.
""")
code_block("""
c1.start_websocket()
c2.start_websocket()
""",
stall=0.1, # Make sure the messages in the other thread have time to print
)
print("""
Now make an update and see:
""")
code_block("""
c1.update_remote_wallet()
""",
stall=0.1, # Make sure the messages in the other thread have time to print
)
print("""
Update again and we'll see the new sequence number:
""")
code_block("""
c1.update_remote_wallet()
""",
stall=0.1, # Make sure the messages in the other thread have time to print
)
print("""
When we change a password, just as all auth tokens are invalidated, all sockets are also disconnected.
""")
code_block("""
c1.change_password("ihatesockets")
""",
stall=0.1, # Make sure the messages in the other thread have time to print
)

View file

@ -4,11 +4,14 @@ import base64, json, uuid, requests, hashlib, hmac
from pprint import pprint
from hashlib import scrypt, sha256 # TODO - audit! Should I use hazmat `Scrypt` instead for some reason?
import secrets
import threading
WalletState = namedtuple('WalletState', ['sequence', 'encrypted_wallet'])
import asyncio, time
from websockets import connect as websockets_connect
class LBRYSDK():
# TODO - error checking
@staticmethod
def get_wallet(wallet_id, password):
response = requests.post('http://localhost:5279', json.dumps({
@ -30,7 +33,6 @@ class LBRYSDK():
}))
return response.json()['result']
# TODO - error checking
@staticmethod
def update_wallet(wallet_id, password, data):
response = requests.post('http://localhost:5279', json.dumps({
@ -43,7 +45,6 @@ class LBRYSDK():
}))
return response.json()['result']['data']
# TODO - error checking
@staticmethod
def set_preference(wallet_id, key, value):
response = requests.post('http://localhost:5279', json.dumps({
@ -56,7 +57,6 @@ class LBRYSDK():
}))
return response.json()['result']
# TODO - error checking
@staticmethod
def get_preferences(wallet_id):
response = requests.post('http://localhost:5279', json.dumps({
@ -72,20 +72,25 @@ class WalletSync():
self.API_VERSION = 3
if local:
BASE_URL = 'http://localhost:8090'
BASE_HTTP_URL = 'http://localhost:8090'
BASE_WS_URL = 'ws://localhost:8090'
else:
BASE_URL = 'https://dev.lbry.id'
BASE_HTTP_URL = 'https://dev.lbry.id'
BASE_WS_URL = 'wss://dev.lbry.id'
# Avoid confusion. I sometimes forget, at any rate.
print ("Connecting to Wallet API at " + BASE_URL)
print ("Connecting to Wallet API at " + BASE_HTTP_URL)
API_URL = BASE_URL + '/api/%d' % self.API_VERSION
API_HTTP_URL = BASE_HTTP_URL + '/api/%d' % self.API_VERSION
API_WS_URL = BASE_WS_URL + '/api/%d' % self.API_VERSION
self.AUTH_URL = API_URL + '/auth/full'
self.REGISTER_URL = API_URL + '/signup'
self.PASSWORD_URL = API_URL + '/password'
self.WALLET_URL = API_URL + '/wallet'
self.CLIENT_SALT_SEED_URL = API_URL + '/client-salt-seed'
self.AUTH_URL = API_HTTP_URL + '/auth/full'
self.REGISTER_URL = API_HTTP_URL + '/signup'
self.PASSWORD_URL = API_HTTP_URL + '/password'
self.WALLET_URL = API_HTTP_URL + '/wallet'
self.CLIENT_SALT_SEED_URL = API_HTTP_URL + '/client-salt-seed'
self.WEBSOCKET_URL = API_WS_URL + '/websocket'
# def resend_registration_email():
# also rename this to __init__.py later
@ -119,7 +124,7 @@ class WalletSync():
def get_salt_seed(self, email):
params = {
'email': base64.encodestring(bytes(email.encode('utf-8'))),
'email': base64.encodebytes(bytes(email.encode('utf-8'))),
}
response = requests.get(self.CLIENT_SALT_SEED_URL, params=params)
@ -223,6 +228,46 @@ class WalletSync():
print (response.content)
raise Exception("Unexpected status code")
# NOTE this doesn't have a way to explicitly disconnect! Hopefully the real
# thing is designed better.
# NOTE - if you change your password, the server will kick off all existing
# websocket connections for that user. each client will need to change their
# password to connect again.
def start_websocket(self, client_name, token):
DEBUG = False
# Poor man's debug log
debugLog = lambda *x: print(*x) if DEBUG else None
self.try_connect_websocket = True
async def connection():
while self.try_connect_websocket:
debugLog (client_name, "trying to connect")
try:
async with websockets_connect(self.WEBSOCKET_URL + "?token=" + token) as websocket:
print (client_name, "connected for now")
while True:
try:
msg = await websocket.recv()
# ex: 'wallet-update:5'
if msg.startswith('wallet-update'):
sequence = int(msg.split(':')[-1])
print (client_name, "got notified of a wallet update, sequence=" + str(sequence) + ". If your client is behind this sequence, you should get the latest from the server.")
else:
debugLog (client_name, "got an unknown message:", msg)
except Exception as e:
print (client_name, "disconnected for now:", e)
time.sleep(1)
break
except Exception as e:
debugLog (client_name, "failed to connect:", e)
time.sleep(1)
asyncio.run(connection())
# NOTE - this only stops retrying connections and sending messages. If a
# socket is happily connected this won't stop it.
def stop_try_reconnect_websocket(self):
self.try_connect_websocket = False
# Thanks to Standard Notes. See:
# https://docs.standardnotes.com/specification/encryption/
@ -303,12 +348,13 @@ class Client():
return True
def __init__(self, email, root_password, wallet_id='default_wallet', local=False):
def __init__(self, client_name, email, root_password, wallet_id='default_wallet', local=False):
self.wallet_sync_api = WalletSync(local=local)
self.client_name = client_name # Just for async output so we know who's talking
# Represents normal client behavior (though a real client will of course save device id)
self.device_id = str(uuid.uuid4())
self.auth_token = 'bad token'
self.auth_token = 'bad-token'
self.synced_wallet_state = None
self.email = email
@ -316,6 +362,8 @@ class Client():
self.wallet_id = wallet_id
self.ws_thread = None
def register(self):
# Note that for each registration, i.e. for each domain, we generate a
# different salt seed.
@ -398,6 +446,29 @@ class Client():
# TODO - actually set the right hash
self.mark_local_changes_synced_to_empty()
def start_websocket(self):
# NOTE - Not putting any effort into here responsible thread programming
# here. Not accounting for errors, logging out and logging into other
# servers, etc. Only going so far as to make sure we don't start two at
# once.
if self.ws_thread is None:
self.ws_thread = threading.Thread(
target=self.wallet_sync_api.start_websocket,
args=(self.client_name, self.auth_token),
daemon=True,
)
self.ws_thread.start()
else:
print("Websocket already connected (or trying to).")
def stop_try_reconnect_websocket(self):
self.wallet_sync_api.stop_try_reconnect_websocket()
# Not trying to be a responsible thread programmer here, this is just a
# demo, and not a threading demo
self.ws_thread = None
def get_auth_token(self):
token = self.wallet_sync_api.get_auth_token(
self.email,
@ -450,8 +521,6 @@ class Client():
# Returns: status
def get_remote_wallet(self):
# TODO - Do try/catch for other calls I guess. I needed it here in
# particular for the README
try:
new_wallet_state, hmac = self.wallet_sync_api.get_wallet(self.auth_token)
except Exception:
@ -589,11 +658,9 @@ class Client():
return "Failure"
def set_preference(self, key, value):
# TODO - error checking
return LBRYSDK.set_preference(self.wallet_id, key, value)
def get_preferences(self):
# TODO - error checking
return LBRYSDK.get_preferences(self.wallet_id)
def has_unsynced_local_changes(self):
@ -608,7 +675,6 @@ class Client():
self.lbry_sdk_last_synced_hash = ""
def update_local_encrypted_wallet(self, encrypted_wallet):
# TODO - error checking
return LBRYSDK.update_wallet(self.wallet_id, self.root_password, encrypted_wallet)
def get_local_encrypted_wallet(self, sync_password):
@ -622,5 +688,4 @@ class Client():
# between the KDFs. The question is, is it safe to use the same root
# password on two two different KDFs like this?
# TODO - error checking
return LBRYSDK.get_wallet(self.wallet_id, sync_password)