Get/Post WalletState, account recover, test client
A few things at once because it was faster to get a demo out the door. Skipping most test implementation though I made failing stubs so I know what to fill in later. * Get/Post WalletState * downloadKey/email so that a second client can log in, and/or recover from lost client * Test client in Python to demonstrate the above * Organize into packages
This commit is contained in:
parent
52ed6d8d2c
commit
2fbcf6ee6d
25 changed files with 2312 additions and 671 deletions
65
auth.go
65
auth.go
|
@ -1,65 +0,0 @@
|
|||
package main // TODO - make it its own `auth` package later
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TODO - Learn how to use https://github.com/golang/oauth2 instead
|
||||
// TODO - Look into jwt, etc.
|
||||
// For now I just want a process that's shaped like what I'm looking for (pubkey signatures, downloadKey, etc)
|
||||
|
||||
type AuthTokenString string
|
||||
type PublicKey string
|
||||
|
||||
type AuthInterface interface {
|
||||
NewFullToken(pubKey PublicKey, tokenRequest *TokenRequest) (*AuthToken, error)
|
||||
IsValidSignature(pubKey PublicKey, payload string, signature string) bool
|
||||
|
||||
// for future request:
|
||||
// IsValidToken(AuthTokenString) bool
|
||||
}
|
||||
|
||||
type Auth struct{}
|
||||
|
||||
func (a *Auth) IsValidSignature(pubKey PublicKey, payload string, signature string) bool {
|
||||
// TODO - a real check
|
||||
return signature == "Good Signature"
|
||||
}
|
||||
|
||||
type AuthToken struct {
|
||||
Token AuthTokenString `json:"token"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Scope string `json:"scope"`
|
||||
PubKey PublicKey `json:"publicKey"`
|
||||
Expiration *time.Time `json:"expiration"`
|
||||
}
|
||||
|
||||
type TokenRequest struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
}
|
||||
|
||||
// TODO - probably shouldn't be (s *Server) in this file
|
||||
func (s *Server) validateTokenRequest(tokenRequest *TokenRequest) bool {
|
||||
// TODO
|
||||
return true
|
||||
}
|
||||
|
||||
const tokenLength = 32
|
||||
|
||||
func (a *Auth) NewFullToken(pubKey PublicKey, tokenRequest *TokenRequest) (*AuthToken, error) {
|
||||
b := make([]byte, tokenLength)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, fmt.Errorf("Error generating token: %+v", err)
|
||||
}
|
||||
|
||||
return &AuthToken{
|
||||
Token: AuthTokenString(hex.EncodeToString(b)),
|
||||
DeviceID: tokenRequest.DeviceID,
|
||||
Scope: "*",
|
||||
PubKey: pubKey,
|
||||
// TODO add Expiration here instead of putting it in store.go. and thus redo store.go. d'oh.
|
||||
}, nil
|
||||
}
|
111
auth/auth.go
Normal file
111
auth/auth.go
Normal file
|
@ -0,0 +1,111 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TODO - Learn how to use https://github.com/golang/oauth2 instead
|
||||
// TODO - Look into jwt, etc.
|
||||
// For now I just want a process that's shaped like what I'm looking for (pubkey signatures, downloadKey, etc)
|
||||
|
||||
type AuthTokenString string
|
||||
type PublicKey string
|
||||
type DownloadKey string
|
||||
type Signature string
|
||||
|
||||
type AuthScope string
|
||||
|
||||
const ScopeFull = AuthScope("*")
|
||||
const ScopeGetWalletState = AuthScope("get-wallet-state")
|
||||
|
||||
// For test stubs
|
||||
type AuthInterface interface {
|
||||
NewToken(pubKey PublicKey, DeviceID string, Scope AuthScope) (*AuthToken, error)
|
||||
IsValidSignature(pubKey PublicKey, payload string, signature Signature) bool
|
||||
ValidateTokenRequest(tokenRequest *TokenRequest) bool
|
||||
}
|
||||
|
||||
type Auth struct{}
|
||||
|
||||
func (a *Auth) IsValidSignature(pubKey PublicKey, payload string, signature Signature) bool {
|
||||
// TODO - a real check
|
||||
return signature == "Good Signature"
|
||||
}
|
||||
|
||||
// Note that everything here is given to anybody who presents a valid
|
||||
// downloadKey and associated email. Currently these fields are safe to give
|
||||
// at that low security level, but keep this in mind as we change this struct.
|
||||
type AuthToken struct {
|
||||
Token AuthTokenString `json:"token"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Scope AuthScope `json:"scope"`
|
||||
PubKey PublicKey `json:"publicKey"`
|
||||
Expiration *time.Time `json:"expiration"`
|
||||
}
|
||||
|
||||
type TokenRequest struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
RequestTime int64 `json:"requestTime"`
|
||||
// TODO - add target domain as well. anything to limit the scope of the
|
||||
// request to mitigate replays.
|
||||
}
|
||||
|
||||
func (a *Auth) ValidateTokenRequest(tokenRequest *TokenRequest) bool {
|
||||
if tokenRequest.DeviceID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Since we're going by signatures with a key that we don't want to change,
|
||||
// let's avoid replays.
|
||||
timeDiff := time.Now().Unix() - tokenRequest.RequestTime
|
||||
if timeDiff < -2 {
|
||||
// Maybe time drift will cause the request time to be in the future. This
|
||||
// would also include request time. Only allow a few seconds of this.
|
||||
return false
|
||||
}
|
||||
if timeDiff > 60 {
|
||||
// Maybe the request is slow. Allow for a minute of lag time.
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const AuthTokenLength = 32
|
||||
|
||||
func (a *Auth) NewToken(pubKey PublicKey, DeviceID string, Scope AuthScope) (*AuthToken, error) {
|
||||
b := make([]byte, AuthTokenLength)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, fmt.Errorf("Error generating token: %+v", err)
|
||||
}
|
||||
|
||||
return &AuthToken{
|
||||
Token: AuthTokenString(hex.EncodeToString(b)),
|
||||
DeviceID: DeviceID,
|
||||
Scope: Scope,
|
||||
PubKey: pubKey,
|
||||
// TODO add Expiration here instead of putting it in store.go. and thus redo store.go. d'oh.
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NOTE - not stubbing methods of structs like this. more convoluted than it's worth right now
|
||||
func (at *AuthToken) ScopeValid(required AuthScope) bool {
|
||||
// So far the only two scopes issued
|
||||
if at.Scope == ScopeFull {
|
||||
return true
|
||||
}
|
||||
if at.Scope == ScopeGetWalletState && required == ScopeGetWalletState {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d DownloadKey) Obfuscate() string {
|
||||
// TODO KDF instead
|
||||
hash := sha256.Sum256([]byte(d))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
47
auth/auth_test.go
Normal file
47
auth/auth_test.go
Normal file
|
@ -0,0 +1,47 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Test stubs for now
|
||||
|
||||
func TestAuthValidateTokenRequest(t *testing.T) {
|
||||
// also add a basic test case for this in TestServerAuthHandlerErrors to make sure it's called at all
|
||||
t.Fatalf("Test me: Implement and test ValidateTokenRequest")
|
||||
}
|
||||
|
||||
func TestAuthSignaturePass(t *testing.T) {
|
||||
t.Fatalf("Test me: Valid siganture passes")
|
||||
}
|
||||
|
||||
func TestAuthSignatureFail(t *testing.T) {
|
||||
t.Fatalf("Test me: Valid siganture fails")
|
||||
}
|
||||
|
||||
func TestAuthNewTokenSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: New token passes. Different scopes etc.")
|
||||
}
|
||||
|
||||
func TestAuthNewTokenFail(t *testing.T) {
|
||||
t.Fatalf("Test me: New token fails (error generating random string? others?)")
|
||||
}
|
||||
|
||||
func TestAuthScopeValid(t *testing.T) {
|
||||
t.Fatalf("Test me: Scope Valid tests")
|
||||
/*
|
||||
authToken.Scope = "get-wallet-state"; authToken.ScopeValid("*")
|
||||
authToken.Scope = "get-wallet-state"; authToken.ScopeValid("get-wallet-state")
|
||||
|
||||
// even things that haven't been defined yet, for simplicity
|
||||
authToken.Scope = "bananas"; authToken.ScopeValid("*")
|
||||
*/
|
||||
}
|
||||
|
||||
func TestAuthScopeInvalid(t *testing.T) {
|
||||
t.Fatalf("Test me: Scope Invalid tests")
|
||||
/*
|
||||
authToken.Scope = "get-wallet-state"; authToken.ScopeValid("bananas")
|
||||
authToken.Scope = "bananas"; authToken.ScopeValid("get-wallet-state")
|
||||
*/
|
||||
}
|
23
auth_test.go
23
auth_test.go
|
@ -1,23 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Test stubs for now
|
||||
|
||||
func TestAuthSignaturePass(t *testing.T) {
|
||||
t.Fatalf("Valid siganture passes")
|
||||
}
|
||||
|
||||
func TestAuthSignatureFail(t *testing.T) {
|
||||
t.Fatalf("Valid siganture fails")
|
||||
}
|
||||
|
||||
func TestAuthNewFullTokenSuccess(t *testing.T) {
|
||||
t.Fatalf("New token passes")
|
||||
}
|
||||
|
||||
func TestAuthNewFullTokenFail(t *testing.T) {
|
||||
t.Fatalf("New token fails (error generating random string? others?)")
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TODO - test some unhappy paths? Don't want to retest all the unit tests though.
|
||||
|
||||
func checkStatusCode(t *testing.T, statusCode int) {
|
||||
if want, got := http.StatusOK, statusCode; want != got {
|
||||
t.Errorf("StatusCode: expected %s (%d), got %s (%d)", http.StatusText(want), want, http.StatusText(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
func request(t *testing.T, method string, handler func(http.ResponseWriter, *http.Request), path string, jsonResult interface{}, requestBody string) ([]byte, int) {
|
||||
req := httptest.NewRequest(
|
||||
method,
|
||||
path,
|
||||
bytes.NewBuffer([]byte(requestBody)),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
responseBody, _ := ioutil.ReadAll(w.Body)
|
||||
|
||||
err := json.Unmarshal(responseBody, &jsonResult)
|
||||
if err != nil {
|
||||
t.Errorf("Error unmarshalling response body err: %+v body: %s", err, responseBody)
|
||||
}
|
||||
|
||||
return responseBody, w.Result().StatusCode
|
||||
}
|
||||
|
||||
func TestIntegrationFlow(t *testing.T) {
|
||||
store, tmpFile := storeTestInit(t)
|
||||
defer storeTestCleanup(tmpFile)
|
||||
|
||||
s := Server{
|
||||
&Auth{},
|
||||
&store,
|
||||
}
|
||||
|
||||
var authToken AuthToken
|
||||
responseBody, statusCode := request(
|
||||
t,
|
||||
http.MethodPost,
|
||||
s.getAuthToken,
|
||||
PathGetAuthToken,
|
||||
&authToken,
|
||||
`{
|
||||
"tokenRequestJSON": "{\"deviceID\": \"devID\"}",
|
||||
"publickey": "testPubKey",
|
||||
"signature": "Good Signature"
|
||||
}`,
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode)
|
||||
|
||||
// result.Token is in hex, tokenLength is bytes in the original
|
||||
expectedTokenLength := tokenLength * 2
|
||||
if len(authToken.Token) != expectedTokenLength {
|
||||
t.Errorf("Expected auth response to contain token length 32: result: %+v", string(responseBody))
|
||||
}
|
||||
if authToken.DeviceID != "devID" {
|
||||
t.Errorf("Unexpected auth response DeviceID. want: %+v got: %+v", "devID", authToken.DeviceID)
|
||||
}
|
||||
|
||||
}
|
28
main.go
Normal file
28
main.go
Normal file
|
@ -0,0 +1,28 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"orblivion/lbry-id/auth"
|
||||
"orblivion/lbry-id/server"
|
||||
"orblivion/lbry-id/store"
|
||||
"orblivion/lbry-id/wallet"
|
||||
)
|
||||
|
||||
func storeInit() (s store.Store) {
|
||||
s = store.Store{}
|
||||
|
||||
s.Init("sql.db")
|
||||
|
||||
err := s.Migrate()
|
||||
if err != nil {
|
||||
log.Fatalf("DB setup failure: %+v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func main() {
|
||||
store := storeInit()
|
||||
srv := server.Init(&auth.Auth{}, &store, &wallet.WalletUtil{})
|
||||
srv.Serve()
|
||||
}
|
9
run.sh
9
run.sh
|
@ -1,9 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -exuo pipefail
|
||||
|
||||
# A simple helper script. There's probably something more proper that I'll replace this with later.
|
||||
|
||||
go fmt *.go
|
||||
go mod tidy
|
||||
go run .
|
197
server.go
197
server.go
|
@ -1,197 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// TODO proper doc comments!
|
||||
|
||||
const PathGetAuthToken = "/auth"
|
||||
|
||||
// Server: The interface definition for the Server module
|
||||
type Server struct {
|
||||
auth AuthInterface
|
||||
store StoreInterface
|
||||
}
|
||||
|
||||
////
|
||||
// Requests
|
||||
////
|
||||
|
||||
/*
|
||||
TODO - consider reworking the naming convention
|
||||
|
||||
Rename `AuthRequest` struct to:
|
||||
|
||||
type TokenRequest struct {
|
||||
BodyJSON string // or maybe PayloadJSON
|
||||
PubKey PublicKey
|
||||
Signature string
|
||||
}
|
||||
|
||||
And then rename the existing `TokenRequest` to `TokenRequestBody` (this is what `BodyJSON` unmarhals to).
|
||||
|
||||
The reason is that we'll need this format for walletState eventually. The walletState as such, saved on devices,
|
||||
passed around, etc, should contain the signature and the public key, but of course the signed portion itself
|
||||
cannot contain the signature.
|
||||
|
||||
So, the part not containing the signature we'll similarly call something lke `WalletStateBody`. `WaletStateBody`
|
||||
will still contain other metadata about the encrypted wallet such as DeviceID and Sequence. We only keep the
|
||||
signature and PubKey outside because of the verification process. We could keep the PubKey inside the Body but
|
||||
it's more convenient this way, and the signature process will verify it along with the body.
|
||||
*/
|
||||
|
||||
type AuthRequest struct {
|
||||
// TokenRequestJSON: json string within json, so that the string representation is
|
||||
// unambiguous for the purposes of signing. This means we need to deserialize the
|
||||
// request body twice.
|
||||
TokenRequestJSON string `json:"tokenRequestJSON"`
|
||||
PubKey PublicKey `json:"publicKey"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
func (s *Server) validateAuthRequest(payload *AuthRequest) bool {
|
||||
// TODO
|
||||
return true
|
||||
}
|
||||
|
||||
////
|
||||
// Responses
|
||||
////
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func errorJSON(w http.ResponseWriter, code int, extra string) {
|
||||
errorStr := http.StatusText(code)
|
||||
if extra != "" {
|
||||
errorStr = errorStr + ": " + extra
|
||||
}
|
||||
authErrorJSON, err := json.Marshal(ErrorResponse{Error: errorStr})
|
||||
if err != nil {
|
||||
// In case something really stupid happens
|
||||
http.Error(w, `{"error": "error when JSON-encoding error message"}`, code)
|
||||
}
|
||||
http.Error(w, string(authErrorJSON), code)
|
||||
return
|
||||
}
|
||||
|
||||
////
|
||||
// Handlers
|
||||
////
|
||||
|
||||
func (s *Server) getAuthToken(w http.ResponseWriter, req *http.Request) {
|
||||
/*
|
||||
(This comment may only be needed for WIP)
|
||||
|
||||
Server should be in charge of such things as:
|
||||
* Request body size check (in particular to not tie up signature check)
|
||||
* JSON validation/deserialization
|
||||
|
||||
Auth should be in charge of such things as:
|
||||
* Checking signatures
|
||||
* Generating tokens
|
||||
|
||||
The order of events:
|
||||
* Server checks the request body size
|
||||
* Server deserializes and then validates the AuthRequest
|
||||
* Auth checks the signature of authRequest.TokenRequestJSON
|
||||
* This the awkward bit, since Auth is being passed a (serialized) JSON string.
|
||||
However, it's not deserializing it. It's ONLY checking the signature of it
|
||||
as a string per se. (The same function will be used for signed walletState)
|
||||
* Server deserializes and then validates the TokenRequest
|
||||
* Auth takes TokenRequest and PubKey and generates a token
|
||||
* DataStore stores the token. The pair (PubKey, TokenRequest.DeviceID) is the primary key.
|
||||
We should have one token for each device.
|
||||
*/
|
||||
|
||||
if req.Method != http.MethodPost {
|
||||
errorJSON(w, http.StatusMethodNotAllowed, "")
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
TODO - http.StatusRequestEntityTooLarge for some arbitrary large size
|
||||
see:
|
||||
* MaxBytesReader or LimitReader
|
||||
* https://pkg.go.dev/net/http#Request.ParseForm
|
||||
* some library/framework that handles it (along with req.Method)
|
||||
*/
|
||||
|
||||
var authRequest AuthRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&authRequest); err != nil {
|
||||
errorJSON(w, http.StatusBadRequest, "Malformed request body JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if s.validateAuthRequest(&authRequest) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
if !s.auth.IsValidSignature(authRequest.PubKey, authRequest.TokenRequestJSON, authRequest.Signature) {
|
||||
errorJSON(w, http.StatusForbidden, "Bad signature")
|
||||
return
|
||||
}
|
||||
|
||||
var tokenRequest TokenRequest
|
||||
if err := json.Unmarshal([]byte(authRequest.TokenRequestJSON), &tokenRequest); err != nil {
|
||||
errorJSON(w, http.StatusBadRequest, "Malformed tokenRequest JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if s.validateTokenRequest(&tokenRequest) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
authToken, err := s.auth.NewFullToken(authRequest.PubKey, &tokenRequest)
|
||||
|
||||
if err != nil {
|
||||
errorJSON(w, http.StatusInternalServerError, "Error generating auth token")
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := json.Marshal(&authToken)
|
||||
|
||||
if err != nil {
|
||||
errorJSON(w, http.StatusInternalServerError, "Error generating auth token")
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.SaveToken(authToken); err != nil {
|
||||
errorJSON(w, http.StatusInternalServerError, "Error saving auth token")
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, string(response))
|
||||
}
|
||||
|
||||
func (s *Server) getWalletState(w http.ResponseWriter, req *http.Request) {
|
||||
// TODO
|
||||
// GET request only
|
||||
// !(AuthToken.Valid && (AuthToken.Scope == "*" || AuthToken.Scope == "download")) -> http.StatusNotAllowed
|
||||
}
|
||||
|
||||
func (s *Server) putWalletState(w http.ResponseWriter, req *http.Request) {
|
||||
// TODO
|
||||
// POST request only
|
||||
// !(AuthToken.Valid && AuthToken.Scope == "*") -> http.StatusNotAllowed
|
||||
}
|
||||
|
||||
func main() {
|
||||
server := Server{&Auth{}, &Store{}}
|
||||
|
||||
http.HandleFunc(PathGetAuthToken, server.getAuthToken)
|
||||
|
||||
// TODO
|
||||
//http.HandleFunc("/get-wallet-state", server.getWalletState)
|
||||
//http.HandleFunc("/put-wallet-state", server.putWalletState)
|
||||
|
||||
http.ListenAndServe(":8090", nil)
|
||||
}
|
169
server/auth.go
Normal file
169
server/auth.go
Normal file
|
@ -0,0 +1,169 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"orblivion/lbry-id/auth"
|
||||
"orblivion/lbry-id/store"
|
||||
)
|
||||
|
||||
/*
|
||||
TODO - Consider reworking the naming convention in (currently named)
|
||||
`AuthFullRequest` so we can reuse code with `WalletStateRequest`. Both structs
|
||||
have a pubkey, a signature, and a signed payload (which is in turn an encoded
|
||||
json string). We verify the signature for both in a similar pattern.
|
||||
*/
|
||||
|
||||
type AuthFullRequest struct {
|
||||
// TokenRequestJSON: json string within json, so that the string representation is
|
||||
// unambiguous for the purposes of signing. This means we need to deserialize the
|
||||
// request body twice.
|
||||
TokenRequestJSON string `json:"tokenRequestJSON"`
|
||||
PubKey auth.PublicKey `json:"publicKey"`
|
||||
Signature auth.Signature `json:"signature"`
|
||||
}
|
||||
|
||||
func (r *AuthFullRequest) validate() bool {
|
||||
return (r.TokenRequestJSON != "" &&
|
||||
r.PubKey != auth.PublicKey("") &&
|
||||
r.Signature != auth.Signature(""))
|
||||
}
|
||||
|
||||
type AuthForGetWalletStateRequest struct {
|
||||
Email string `json:"email"`
|
||||
DownloadKey auth.DownloadKey `json:"downloadKey"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
}
|
||||
|
||||
func (r *AuthForGetWalletStateRequest) validate() bool {
|
||||
return (r.Email != "" &&
|
||||
r.DownloadKey != auth.DownloadKey("") &&
|
||||
r.DeviceID != "")
|
||||
}
|
||||
|
||||
// NOTE - (Perhaps for docs)
|
||||
//
|
||||
// This is not very well authenticated. Requiring the downloadKey and email
|
||||
// isn't very high security. It adds an entry into the same auth_tokens db
|
||||
// table as full auth tokens. There won't be a danger of a malicious actor
|
||||
// overriding existing auth tokens so long as the legitimate devices choose
|
||||
// unique DeviceIDs. (DeviceID being part of the primary key in the auth token
|
||||
// table.)
|
||||
//
|
||||
// A malicious actor could try to flood the auth token table to take down the
|
||||
// server, but then again they could do this with a legitimate account as well.
|
||||
// We could perhaps require registration (valid email) for full auth tokens and
|
||||
// limit to 10 get-wallet-state auth tokens per account.
|
||||
|
||||
func (s *Server) getAuthTokenForGetWalletState(w http.ResponseWriter, req *http.Request) {
|
||||
var authRequest AuthForGetWalletStateRequest
|
||||
if !getPostData(w, req, &authRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
pubKey, err := s.store.GetPublicKey(authRequest.Email, authRequest.DownloadKey)
|
||||
if err == store.ErrNoPubKey {
|
||||
errorJSON(w, http.StatusUnauthorized, "No match for email and password")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error getting public key")
|
||||
return
|
||||
}
|
||||
|
||||
authToken, err := s.auth.NewToken(pubKey, authRequest.DeviceID, auth.ScopeGetWalletState)
|
||||
|
||||
if err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error generating auth token")
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE - see comment on auth.AuthToken definition regarding what we may
|
||||
// want to present to the client that has only presented a valid
|
||||
// downloadKey and email
|
||||
response, err := json.Marshal(&authToken)
|
||||
|
||||
if err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error generating auth token")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.SaveToken(authToken); err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error saving auth token")
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, string(response))
|
||||
}
|
||||
|
||||
func (s *Server) getAuthTokenFull(w http.ResponseWriter, req *http.Request) {
|
||||
/*
|
||||
(This comment may only be needed for WIP)
|
||||
|
||||
Server should be in charge of such things as:
|
||||
* Request body size check (in particular to not tie up signature check)
|
||||
* JSON validation/deserialization
|
||||
|
||||
auth.Auth should be in charge of such things as:
|
||||
* Checking signatures
|
||||
* Generating tokens
|
||||
|
||||
The order of events:
|
||||
* Server checks the request body size
|
||||
* Server deserializes and then validates the AuthFullRequest
|
||||
* auth.Auth checks the signature of authRequest.TokenRequestJSON
|
||||
* This the awkward bit, since auth.Auth is being passed a (serialized) JSON string.
|
||||
However, it's not deserializing it. It's ONLY checking the signature of it
|
||||
as a string per se. (The same function will be used for signed walletState)
|
||||
* Server deserializes and then validates the auth.TokenRequest
|
||||
* auth.Auth takes auth.TokenRequest and PubKey and generates a token
|
||||
* DataStore stores the token. The pair (PubKey, TokenRequest.DeviceID) is the primary key.
|
||||
We should have one token for each device.
|
||||
*/
|
||||
|
||||
var authRequest AuthFullRequest
|
||||
if !getPostData(w, req, &authRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.auth.IsValidSignature(authRequest.PubKey, authRequest.TokenRequestJSON, authRequest.Signature) {
|
||||
errorJSON(w, http.StatusForbidden, "Bad signature")
|
||||
return
|
||||
}
|
||||
|
||||
var tokenRequest auth.TokenRequest
|
||||
if err := json.Unmarshal([]byte(authRequest.TokenRequestJSON), &tokenRequest); err != nil {
|
||||
errorJSON(w, http.StatusBadRequest, "Malformed tokenRequest JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if !s.auth.ValidateTokenRequest(&tokenRequest) {
|
||||
errorJSON(w, http.StatusBadRequest, "tokenRequest failed validation")
|
||||
return
|
||||
}
|
||||
|
||||
authToken, err := s.auth.NewToken(authRequest.PubKey, tokenRequest.DeviceID, auth.ScopeFull)
|
||||
|
||||
if err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error generating auth token")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := json.Marshal(&authToken)
|
||||
|
||||
if err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error generating auth token")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.SaveToken(authToken); err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error saving auth token")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, string(response))
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package main
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
@ -7,67 +7,26 @@ import (
|
|||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"orblivion/lbry-id/auth"
|
||||
"orblivion/lbry-id/wallet"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
////////////////
|
||||
// TODO move to testing helper file
|
||||
|
||||
type TestStore struct {
|
||||
FailSave bool
|
||||
|
||||
SaveTokenCalled bool
|
||||
}
|
||||
|
||||
type TestAuth struct {
|
||||
TestToken AuthTokenString
|
||||
FailSigCheck bool
|
||||
FailGenToken bool
|
||||
}
|
||||
|
||||
func (a *TestAuth) NewFullToken(pubKey PublicKey, tokenRequest *TokenRequest) (*AuthToken, error) {
|
||||
if a.FailGenToken {
|
||||
return nil, fmt.Errorf("Test error: fail to generate token")
|
||||
}
|
||||
return &AuthToken{Token: a.TestToken}, nil
|
||||
}
|
||||
|
||||
func (a *TestAuth) IsValidSignature(pubKey PublicKey, payload string, signature string) bool {
|
||||
return !a.FailSigCheck
|
||||
}
|
||||
|
||||
func (s *TestStore) SaveToken(token *AuthToken) error {
|
||||
if s.FailSave {
|
||||
return fmt.Errorf("TestStore.SaveToken fail")
|
||||
}
|
||||
s.SaveTokenCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
////////////////
|
||||
|
||||
func TestServerAuthHandlerSuccess(t *testing.T) {
|
||||
testAuth := TestAuth{TestToken: AuthTokenString("seekrit")}
|
||||
testAuth := TestAuth{TestToken: auth.AuthTokenString("seekrit")}
|
||||
testStore := TestStore{}
|
||||
s := Server{
|
||||
&testAuth,
|
||||
&testStore,
|
||||
}
|
||||
s := Server{&testAuth, &testStore, &wallet.WalletUtil{}}
|
||||
|
||||
requestBody := []byte(`
|
||||
{
|
||||
"tokenRequestJSON": "{}"
|
||||
}
|
||||
`)
|
||||
requestBody := []byte(`{"tokenRequestJSON": "{}", "publicKey": "abc", "signature": "123"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, PathGetAuthToken, bytes.NewBuffer(requestBody))
|
||||
req := httptest.NewRequest(http.MethodPost, PathAuthTokenFull, bytes.NewBuffer(requestBody))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
s.getAuthToken(w, req)
|
||||
s.getAuthTokenFull(w, req)
|
||||
body, _ := ioutil.ReadAll(w.Body)
|
||||
|
||||
var result AuthToken
|
||||
var result auth.AuthToken
|
||||
|
||||
if want, got := http.StatusOK, w.Result().StatusCode; want != got {
|
||||
t.Errorf("StatusCode: expected %s (%d), got %s (%d)", http.StatusText(want), want, http.StatusText(got), got)
|
||||
|
@ -106,7 +65,7 @@ func TestServerAuthHandlerErrors(t *testing.T) {
|
|||
{
|
||||
name: "request body too large",
|
||||
method: http.MethodPost,
|
||||
requestBody: fmt.Sprintf("{\"tokenRequestJSON\": \"%s\"}", strings.Repeat("a", 10000)),
|
||||
requestBody: fmt.Sprintf(`{"tokenRequestJSON": "%s"}`, strings.Repeat("a", 10000)),
|
||||
expectedStatusCode: http.StatusRequestEntityTooLarge,
|
||||
expectedErrorString: http.StatusText(http.StatusRequestEntityTooLarge),
|
||||
},
|
||||
|
@ -117,11 +76,18 @@ func TestServerAuthHandlerErrors(t *testing.T) {
|
|||
expectedStatusCode: http.StatusBadRequest,
|
||||
expectedErrorString: http.StatusText(http.StatusBadRequest) + ": Malformed request body JSON",
|
||||
},
|
||||
{
|
||||
name: "body JSON failed validation",
|
||||
method: http.MethodPost,
|
||||
requestBody: "{}",
|
||||
expectedStatusCode: http.StatusBadRequest,
|
||||
expectedErrorString: http.StatusText(http.StatusBadRequest) + ": Request failed validation",
|
||||
},
|
||||
{
|
||||
name: "signature check fail",
|
||||
method: http.MethodPost,
|
||||
// so long as the JSON is well-formed, the content doesn't matter here since the signature check will be stubbed out
|
||||
requestBody: "{\"tokenRequestJSON\": \"{}\"}",
|
||||
requestBody: `{"tokenRequestJSON": "{}", "publicKey": "abc", "signature": "123"}`,
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
expectedErrorString: http.StatusText(http.StatusForbidden) + ": Bad signature",
|
||||
|
||||
|
@ -130,25 +96,25 @@ func TestServerAuthHandlerErrors(t *testing.T) {
|
|||
{
|
||||
name: "malformed tokenRequest JSON",
|
||||
method: http.MethodPost,
|
||||
requestBody: "{\"tokenRequestJSON\": \"{\"}",
|
||||
requestBody: `{"tokenRequestJSON": "{", "publicKey": "abc", "signature": "123"}`,
|
||||
expectedStatusCode: http.StatusBadRequest,
|
||||
expectedErrorString: http.StatusText(http.StatusBadRequest) + ": Malformed tokenRequest JSON",
|
||||
},
|
||||
{
|
||||
name: "generate token fail",
|
||||
method: http.MethodPost,
|
||||
requestBody: "{\"tokenRequestJSON\": \"{}\"}",
|
||||
requestBody: `{"tokenRequestJSON": "{}", "publicKey": "abc", "signature": "123"}`,
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
expectedErrorString: http.StatusText(http.StatusInternalServerError) + ": Error generating auth token",
|
||||
expectedErrorString: http.StatusText(http.StatusInternalServerError),
|
||||
|
||||
authFailGenToken: true,
|
||||
},
|
||||
{
|
||||
name: "save token fail",
|
||||
method: http.MethodPost,
|
||||
requestBody: "{\"tokenRequestJSON\": \"{}\"}",
|
||||
requestBody: `{"tokenRequestJSON": "{}", "publicKey": "abc", "signature": "123"}`,
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
expectedErrorString: http.StatusText(http.StatusInternalServerError) + ": Error saving auth token",
|
||||
expectedErrorString: http.StatusText(http.StatusInternalServerError),
|
||||
|
||||
storeFailSave: true,
|
||||
},
|
||||
|
@ -157,7 +123,7 @@ func TestServerAuthHandlerErrors(t *testing.T) {
|
|||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
// Set this up to fail according to specification
|
||||
testAuth := TestAuth{TestToken: AuthTokenString("seekrit")}
|
||||
testAuth := TestAuth{TestToken: auth.AuthTokenString("seekrit")}
|
||||
testStore := TestStore{}
|
||||
if tc.authFailSigCheck {
|
||||
testAuth.FailSigCheck = true
|
||||
|
@ -166,15 +132,15 @@ func TestServerAuthHandlerErrors(t *testing.T) {
|
|||
} else if tc.storeFailSave {
|
||||
testStore.FailSave = true
|
||||
} else {
|
||||
testAuth.TestToken = AuthTokenString("seekrit")
|
||||
testAuth.TestToken = auth.AuthTokenString("seekrit")
|
||||
}
|
||||
server := Server{&testAuth, &testStore}
|
||||
server := Server{&testAuth, &testStore, &wallet.WalletUtil{}}
|
||||
|
||||
// Make request
|
||||
req := httptest.NewRequest(tc.method, PathGetAuthToken, bytes.NewBuffer([]byte(tc.requestBody)))
|
||||
req := httptest.NewRequest(tc.method, PathAuthTokenFull, bytes.NewBuffer([]byte(tc.requestBody)))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
server.getAuthToken(w, req)
|
||||
server.getAuthTokenFull(w, req)
|
||||
|
||||
if want, got := tc.expectedStatusCode, w.Result().StatusCode; want != got {
|
||||
t.Errorf("StatusCode: expected %d, got %d", want, got)
|
||||
|
@ -194,29 +160,18 @@ func TestServerAuthHandlerErrors(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestServerValidateAuthRequest(t *testing.T) {
|
||||
// also add a basic test case for this in TestAuthHandlerErrors to make sure it's called at all
|
||||
// Maybe 401 specifically for missing signature?
|
||||
t.Fatalf("Implement and test validateAuthRequest")
|
||||
func TestServerValidateAuthFullRequest(t *testing.T) {
|
||||
t.Fatalf("Test me: Implement and test AuthFullRequest.validate()")
|
||||
}
|
||||
|
||||
func TestServerValidateTokenRequest(t *testing.T) {
|
||||
// also add a basic test case for this in TestAuthHandlerErrors to make sure it's called at all
|
||||
t.Fatalf("Implement and test validateTokenRequest")
|
||||
func TestServerValidateAuthForGetWalletStateRequest(t *testing.T) {
|
||||
t.Fatalf("Test me: Implement and test AuthForGetWalletStateRequest.validate()")
|
||||
}
|
||||
|
||||
func TestServerGetWalletSuccess(t *testing.T) {
|
||||
t.Fatalf("GetWallet succeeds")
|
||||
func TestServerAuthHandlerForGetWalletStateSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: getAuthTokenForGetWalletState success")
|
||||
}
|
||||
|
||||
func TestServerGetWalletErrors(t *testing.T) {
|
||||
t.Fatalf("GetWallet fails for various reasons (malformed, auth, db fail)")
|
||||
}
|
||||
|
||||
func TestServerPutWalletSuccess(t *testing.T) {
|
||||
t.Fatalf("GetWallet succeeds")
|
||||
}
|
||||
|
||||
func TestServerPutWalletErrors(t *testing.T) {
|
||||
t.Fatalf("GetWallet fails for various reasons (malformed, auth, db fail)")
|
||||
func TestServerAuthHandlerForGetWalletStateErrors(t *testing.T) {
|
||||
t.Fatalf("Test me: getAuthTokenForGetWalletState failure")
|
||||
}
|
408
server/integration_test.go
Normal file
408
server/integration_test.go
Normal file
|
@ -0,0 +1,408 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"orblivion/lbry-id/auth"
|
||||
"orblivion/lbry-id/store"
|
||||
"orblivion/lbry-id/wallet"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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.
|
||||
|
||||
func checkStatusCode(t *testing.T, statusCode int, responseBody []byte, expectedStatusCodeSlice ...int) {
|
||||
var expectedStatusCode int
|
||||
if len(expectedStatusCodeSlice) == 1 {
|
||||
expectedStatusCode = expectedStatusCodeSlice[0]
|
||||
} else {
|
||||
expectedStatusCode = http.StatusOK
|
||||
}
|
||||
|
||||
if want, got := expectedStatusCode, statusCode; want != got {
|
||||
t.Errorf("StatusCode: expected %s (%d), got %s (%d)", http.StatusText(want), want, http.StatusText(got), got)
|
||||
var errorResponse ErrorResponse
|
||||
err := json.Unmarshal(responseBody, &errorResponse)
|
||||
if err == nil {
|
||||
t.Fatalf("http response: %+v", errorResponse)
|
||||
} else {
|
||||
t.Fatalf("%s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func request(t *testing.T, method string, handler func(http.ResponseWriter, *http.Request), path string, jsonResult interface{}, requestBody string) ([]byte, int) {
|
||||
req := httptest.NewRequest(
|
||||
method,
|
||||
path,
|
||||
bytes.NewBuffer([]byte(requestBody)),
|
||||
)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
responseBody, _ := ioutil.ReadAll(w.Body)
|
||||
|
||||
err := json.Unmarshal(responseBody, &jsonResult)
|
||||
if err != nil {
|
||||
t.Errorf("Error unmarshalling response body err: %+v body: %s", err, responseBody)
|
||||
}
|
||||
|
||||
return responseBody, w.Result().StatusCode
|
||||
}
|
||||
|
||||
// Test some flows with syncing two devices that have the wallet locally.
|
||||
func TestIntegrationWalletUpdates(t *testing.T) {
|
||||
st, tmpFile := store.StoreTestInit(t)
|
||||
defer store.StoreTestCleanup(tmpFile)
|
||||
|
||||
s := Init(
|
||||
&auth.Auth{},
|
||||
&st,
|
||||
&wallet.WalletUtil{},
|
||||
)
|
||||
|
||||
////////////////////
|
||||
// Get auth token - device 1
|
||||
////////////////////
|
||||
|
||||
var authToken1 auth.AuthToken
|
||||
responseBody, statusCode := request(
|
||||
t,
|
||||
http.MethodPost,
|
||||
s.getAuthTokenFull,
|
||||
PathAuthTokenFull,
|
||||
&authToken1,
|
||||
fmt.Sprintf(`{
|
||||
"tokenRequestJSON": "{\"deviceID\": \"dev-1\", \"requestTime\": %d}",
|
||||
"publickey": "testPubKey",
|
||||
"signature": "Good Signature"
|
||||
}`,
|
||||
time.Now().Unix(),
|
||||
),
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode, responseBody)
|
||||
|
||||
// result.Token is in hex, auth.AuthTokenLength is bytes in the original
|
||||
expectedTokenLength := auth.AuthTokenLength * 2
|
||||
if len(authToken1.Token) != expectedTokenLength {
|
||||
t.Fatalf("Expected auth response to contain token length 32: result: %+v", string(responseBody))
|
||||
}
|
||||
if authToken1.DeviceID != "dev-1" {
|
||||
t.Fatalf("Unexpected response DeviceID. want: %+v got: %+v", "dev-1", authToken1.DeviceID)
|
||||
}
|
||||
if authToken1.Scope != auth.ScopeFull {
|
||||
t.Fatalf("Unexpected response Scope. want: %+v got: %+v", auth.ScopeFull, authToken1.Scope)
|
||||
}
|
||||
|
||||
////////////////////
|
||||
// Get auth token - device 2
|
||||
////////////////////
|
||||
|
||||
var authToken2 auth.AuthToken
|
||||
responseBody, statusCode = request(
|
||||
t,
|
||||
http.MethodPost,
|
||||
s.getAuthTokenFull,
|
||||
PathAuthTokenFull,
|
||||
&authToken2,
|
||||
fmt.Sprintf(`{
|
||||
"tokenRequestJSON": "{\"deviceID\": \"dev-2\", \"requestTime\": %d}",
|
||||
"publickey": "testPubKey",
|
||||
"signature": "Good Signature"
|
||||
}`,
|
||||
time.Now().Unix(),
|
||||
),
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode, responseBody)
|
||||
|
||||
if authToken2.DeviceID != "dev-2" {
|
||||
t.Fatalf("Unexpected response DeviceID. want: %+v got: %+v", "dev-2", authToken2.DeviceID)
|
||||
}
|
||||
|
||||
////////////////////
|
||||
// Put first wallet state - device 1
|
||||
////////////////////
|
||||
|
||||
var walletStateResponse WalletStateResponse
|
||||
responseBody, statusCode = request(
|
||||
t,
|
||||
http.MethodPost,
|
||||
s.postWalletState,
|
||||
PathWalletState,
|
||||
&walletStateResponse,
|
||||
fmt.Sprintf(`{
|
||||
"token": "%s",
|
||||
"bodyJSON": "{\"encryptedWallet\": \"blah\", \"lastSynced\":{\"dev-1\": 1}, \"deviceId\": \"dev-1\" }",
|
||||
"publickey": "testPubKey",
|
||||
"downloadKey": "myDownloadKey",
|
||||
"signature": "Good Signature"
|
||||
}`, authToken1.Token),
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode, responseBody)
|
||||
|
||||
var walletState wallet.WalletState
|
||||
err := json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %+v", err)
|
||||
}
|
||||
|
||||
sequence := walletState.Sequence()
|
||||
if sequence != 1 {
|
||||
t.Fatalf("Unexpected response Sequence(). want: %+v got: %+v", 1, sequence)
|
||||
}
|
||||
|
||||
////////////////////
|
||||
// Get wallet state - device 2
|
||||
////////////////////
|
||||
|
||||
responseBody, statusCode = request(
|
||||
t,
|
||||
http.MethodGet,
|
||||
s.getWalletState,
|
||||
fmt.Sprintf(
|
||||
"%s?token=%s&publicKey=%s&deviceId=%s",
|
||||
PathWalletState, authToken2.Token, authToken2.PubKey, "dev-2"),
|
||||
&walletStateResponse,
|
||||
"",
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode, responseBody)
|
||||
|
||||
err = json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %+v", err)
|
||||
}
|
||||
|
||||
sequence = walletState.Sequence()
|
||||
if sequence != 1 {
|
||||
t.Fatalf("Unexpected response Sequence(). want: %+v got: %+v", 1, sequence)
|
||||
}
|
||||
|
||||
////////////////////
|
||||
// Put second wallet state - device 2
|
||||
////////////////////
|
||||
|
||||
responseBody, statusCode = request(
|
||||
t,
|
||||
http.MethodPost,
|
||||
s.postWalletState,
|
||||
PathWalletState,
|
||||
&walletStateResponse,
|
||||
fmt.Sprintf(`{
|
||||
"token": "%s",
|
||||
"bodyJSON": "{\"encryptedWallet\": \"blah2\", \"lastSynced\":{\"dev-1\": 1, \"dev-2\": 2}, \"deviceId\": \"dev-2\" }",
|
||||
"publickey": "testPubKey",
|
||||
"downloadKey": "myDownloadKey",
|
||||
"signature": "Good Signature"
|
||||
}`, authToken2.Token),
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode, responseBody)
|
||||
|
||||
err = json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %+v", err)
|
||||
}
|
||||
|
||||
sequence = walletState.Sequence()
|
||||
if sequence != 2 {
|
||||
t.Fatalf("Unexpected response Sequence(). want: %+v got: %+v", 2, sequence)
|
||||
}
|
||||
|
||||
////////////////////
|
||||
// Get wallet state - device 1
|
||||
////////////////////
|
||||
|
||||
responseBody, statusCode = request(
|
||||
t,
|
||||
http.MethodGet,
|
||||
s.getWalletState,
|
||||
fmt.Sprintf(
|
||||
"%s?token=%s&publicKey=%s&deviceId=%s",
|
||||
PathWalletState, authToken1.Token, authToken1.PubKey, "dev-1"),
|
||||
&walletStateResponse,
|
||||
"",
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode, responseBody)
|
||||
|
||||
err = json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %+v", err)
|
||||
}
|
||||
|
||||
sequence = walletState.Sequence()
|
||||
if sequence != 2 {
|
||||
t.Fatalf("Unexpected response Sequence(). want: %+v got: %+v", 2, sequence)
|
||||
}
|
||||
}
|
||||
|
||||
// Test a flow with a new device that needs to use the download key
|
||||
func TestIntegrationNewDevice(t *testing.T) {
|
||||
st, tmpFile := store.StoreTestInit(t)
|
||||
defer store.StoreTestCleanup(tmpFile)
|
||||
|
||||
s := Server{
|
||||
&auth.Auth{},
|
||||
&st,
|
||||
&wallet.WalletUtil{},
|
||||
}
|
||||
|
||||
////////////////////
|
||||
// Get full auth token - device 1
|
||||
////////////////////
|
||||
|
||||
var authToken1 auth.AuthToken
|
||||
responseBody, statusCode := request(
|
||||
t,
|
||||
http.MethodPost,
|
||||
s.getAuthTokenFull,
|
||||
PathAuthTokenFull,
|
||||
&authToken1,
|
||||
fmt.Sprintf(`{
|
||||
"tokenRequestJSON": "{\"deviceID\": \"dev-1\", \"requestTime\": %d}",
|
||||
"publickey": "testPubKey",
|
||||
"signature": "Good Signature"
|
||||
}`,
|
||||
time.Now().Unix(),
|
||||
),
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode, responseBody)
|
||||
|
||||
if authToken1.DeviceID != "dev-1" {
|
||||
t.Fatalf("Unexpected response DeviceID. want: %+v got: %+v", "dev-1", authToken1.DeviceID)
|
||||
}
|
||||
if authToken1.Scope != auth.ScopeFull {
|
||||
t.Fatalf("Unexpected response Scope. want: %+v got: %+v", auth.ScopeFull, authToken1.Scope)
|
||||
}
|
||||
|
||||
////////////////////
|
||||
// Register email address - device 1
|
||||
////////////////////
|
||||
|
||||
var registerResponse struct{}
|
||||
responseBody, statusCode = request(
|
||||
t,
|
||||
http.MethodPost,
|
||||
s.register,
|
||||
PathRegister,
|
||||
®isterResponse,
|
||||
fmt.Sprintf(`{
|
||||
"token": "%s",
|
||||
"publicKey": "testPubKey",
|
||||
"deviceId": "dev-1",
|
||||
"email": "address@example.com"
|
||||
}`, authToken1.Token),
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode, responseBody, http.StatusCreated)
|
||||
|
||||
////////////////////
|
||||
// Put wallet state - device 1
|
||||
////////////////////
|
||||
|
||||
var walletStateResponse WalletStateResponse
|
||||
responseBody, statusCode = request(
|
||||
t,
|
||||
http.MethodPost,
|
||||
s.postWalletState,
|
||||
PathWalletState,
|
||||
&walletStateResponse,
|
||||
fmt.Sprintf(`{
|
||||
"token": "%s",
|
||||
"bodyJSON": "{\"encryptedWallet\": \"blah\", \"lastSynced\":{\"dev-1\": 1}, \"deviceId\": \"dev-1\" }",
|
||||
"publickey": "testPubKey",
|
||||
"downloadKey": "myDownloadKey",
|
||||
"signature": "Good Signature"
|
||||
}`, authToken1.Token),
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode, responseBody)
|
||||
|
||||
var walletState wallet.WalletState
|
||||
err := json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %+v", err)
|
||||
}
|
||||
|
||||
sequence := walletState.Sequence()
|
||||
if sequence != 1 {
|
||||
t.Fatalf("Unexpected response Sequence(). want: %+v got: %+v", 1, sequence)
|
||||
}
|
||||
|
||||
////////////////////
|
||||
// Get get-wallet-state auth token - device 2
|
||||
////////////////////
|
||||
|
||||
var authToken2 auth.AuthToken
|
||||
responseBody, statusCode = request(
|
||||
t,
|
||||
http.MethodPost,
|
||||
s.getAuthTokenForGetWalletState,
|
||||
PathAuthTokenGetWalletState,
|
||||
&authToken2,
|
||||
`{
|
||||
"email": "address@example.com",
|
||||
"downloadKey": "myDownloadKey",
|
||||
"deviceID": "dev-2"
|
||||
}`,
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode, responseBody)
|
||||
|
||||
// result.Token is in hex, auth.AuthTokenLength is bytes in the original
|
||||
expectedTokenLength := auth.AuthTokenLength * 2
|
||||
if len(authToken2.Token) != expectedTokenLength {
|
||||
t.Fatalf("Expected auth response to contain token length 32: result: %+v", string(responseBody))
|
||||
}
|
||||
if authToken2.DeviceID != "dev-2" {
|
||||
t.Fatalf("Unexpected response DeviceID. want: %+v got: %+v", "dev-2", authToken2.DeviceID)
|
||||
}
|
||||
if authToken2.Scope != auth.ScopeGetWalletState {
|
||||
t.Fatalf("Unexpected response Scope. want: %+v got: %+v", auth.ScopeGetWalletState, authToken1.Scope)
|
||||
}
|
||||
|
||||
////////////////////
|
||||
// Get wallet state - device 2
|
||||
////////////////////
|
||||
|
||||
responseBody, statusCode = request(
|
||||
t,
|
||||
http.MethodGet,
|
||||
s.getWalletState,
|
||||
fmt.Sprintf(
|
||||
"%s?token=%s&publicKey=%s&deviceId=%s",
|
||||
PathWalletState, authToken2.Token, authToken2.PubKey, "dev-2"),
|
||||
&walletStateResponse,
|
||||
"",
|
||||
)
|
||||
|
||||
checkStatusCode(t, statusCode, responseBody)
|
||||
|
||||
err = json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %+v", err)
|
||||
}
|
||||
|
||||
sequence = walletState.Sequence()
|
||||
if sequence != 1 {
|
||||
t.Fatalf("Unexpected response Sequence(). want: %+v got: %+v", 1, sequence)
|
||||
}
|
||||
}
|
66
server/register.go
Normal file
66
server/register.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"orblivion/lbry-id/auth"
|
||||
"orblivion/lbry-id/store"
|
||||
)
|
||||
|
||||
type RegisterRequest struct {
|
||||
Token auth.AuthTokenString `json:"token"`
|
||||
PubKey auth.PublicKey `json:"publicKey"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (r *RegisterRequest) validate() bool {
|
||||
return (r.Token != auth.AuthTokenString("") &&
|
||||
r.PubKey != auth.PublicKey("") &&
|
||||
r.DeviceID != "" &&
|
||||
r.Email != "")
|
||||
}
|
||||
|
||||
func (s *Server) register(w http.ResponseWriter, req *http.Request) {
|
||||
var registerRequest RegisterRequest
|
||||
if !getPostData(w, req, ®isterRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.checkAuth(
|
||||
w,
|
||||
registerRequest.PubKey,
|
||||
registerRequest.DeviceID,
|
||||
registerRequest.Token,
|
||||
auth.ScopeFull,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
err := s.store.InsertEmail(registerRequest.PubKey, registerRequest.Email)
|
||||
|
||||
if err != nil {
|
||||
if err == store.ErrDuplicateEmail || err == store.ErrDuplicateAccount {
|
||||
errorJSON(w, http.StatusConflict, "Error registering")
|
||||
} else {
|
||||
internalServiceErrorJSON(w, err, "Error registering")
|
||||
}
|
||||
log.Print(err)
|
||||
return
|
||||
}
|
||||
|
||||
var registerResponse struct{} // no data to respond with, but keep it JSON
|
||||
var response []byte
|
||||
response, err = json.Marshal(registerResponse)
|
||||
|
||||
if err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error generating register response")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO StatusCreated also for first walletState and/or for get auth token?
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
fmt.Fprintf(w, string(response))
|
||||
}
|
17
server/register_test.go
Normal file
17
server/register_test.go
Normal file
|
@ -0,0 +1,17 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerRegisterSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me:")
|
||||
}
|
||||
|
||||
func TestServerRegisterErrors(t *testing.T) {
|
||||
t.Fatalf("Test me:")
|
||||
}
|
||||
|
||||
func TestServerValidateRegisterRequest(t *testing.T) {
|
||||
t.Fatalf("Test me: Implement and test RegisterRequest.validate()")
|
||||
}
|
168
server/server.go
Normal file
168
server/server.go
Normal file
|
@ -0,0 +1,168 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"orblivion/lbry-id/auth"
|
||||
"orblivion/lbry-id/store"
|
||||
"orblivion/lbry-id/wallet"
|
||||
)
|
||||
|
||||
// TODO proper doc comments!
|
||||
|
||||
const PathAuthTokenFull = "/auth/full"
|
||||
const PathAuthTokenGetWalletState = "/auth/get-wallet-state"
|
||||
const PathRegister = "/signup"
|
||||
const PathWalletState = "/wallet-state"
|
||||
|
||||
type Server struct {
|
||||
auth auth.AuthInterface
|
||||
store store.StoreInterface
|
||||
walletUtil wallet.WalletUtilInterface
|
||||
}
|
||||
|
||||
func Init(
|
||||
auth auth.AuthInterface,
|
||||
store store.StoreInterface,
|
||||
walletUtil wallet.WalletUtilInterface,
|
||||
) *Server {
|
||||
return &Server{
|
||||
auth: auth,
|
||||
store: store,
|
||||
walletUtil: walletUtil,
|
||||
}
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func errorJSON(w http.ResponseWriter, code int, extra string) {
|
||||
errorStr := http.StatusText(code)
|
||||
if extra != "" {
|
||||
errorStr = errorStr + ": " + extra
|
||||
}
|
||||
authErrorJSON, err := json.Marshal(ErrorResponse{Error: errorStr})
|
||||
if err != nil {
|
||||
// In case something really stupid happens
|
||||
http.Error(w, `{"error": "error when JSON-encoding error message"}`, code)
|
||||
}
|
||||
http.Error(w, string(authErrorJSON), code)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't report any details to the user. Log it instead.
|
||||
func internalServiceErrorJSON(w http.ResponseWriter, err error, errContext string) {
|
||||
errorStr := http.StatusText(http.StatusInternalServerError)
|
||||
authErrorJSON, err := json.Marshal(ErrorResponse{Error: errorStr})
|
||||
if err != nil {
|
||||
// In case something really stupid happens
|
||||
http.Error(w, `{"error": "error when JSON-encoding error message"}`, http.StatusInternalServerError)
|
||||
}
|
||||
http.Error(w, string(authErrorJSON), http.StatusInternalServerError)
|
||||
log.Printf("%s: %+v\n", errContext, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//////////////////
|
||||
// Handler Helpers
|
||||
//////////////////
|
||||
|
||||
// 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 {
|
||||
errorJSON(w, http.StatusMethodNotAllowed, "")
|
||||
return false
|
||||
}
|
||||
|
||||
/*
|
||||
TODO - http.StatusRequestEntityTooLarge for some arbitrary large size
|
||||
see:
|
||||
* MaxBytesReader or LimitReader
|
||||
* https://pkg.go.dev/net/http#Request.ParseForm
|
||||
* some library/framework that handles it (along with req.Method)
|
||||
|
||||
also - GET params too large?
|
||||
*/
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// All structs representing incoming json request body should implement this
|
||||
type PostRequest interface {
|
||||
validate() bool
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(req.Body).Decode(&reqStruct); err != nil {
|
||||
errorJSON(w, http.StatusBadRequest, "Malformed request body JSON")
|
||||
return false
|
||||
}
|
||||
|
||||
if !reqStruct.validate() {
|
||||
// TODO validate() should return useful error messages instead of a bool.
|
||||
errorJSON(w, http.StatusBadRequest, "Request failed validation")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Confirm it's a Get request, various overhead
|
||||
func getGetData(w http.ResponseWriter, req *http.Request) bool {
|
||||
return requestOverhead(w, req, http.MethodGet)
|
||||
}
|
||||
|
||||
func (s *Server) checkAuth(
|
||||
w http.ResponseWriter,
|
||||
pubKey auth.PublicKey,
|
||||
deviceId string,
|
||||
token auth.AuthTokenString,
|
||||
scope auth.AuthScope,
|
||||
) bool {
|
||||
authToken, err := s.store.GetToken(pubKey, deviceId)
|
||||
if err == store.ErrNoToken {
|
||||
errorJSON(w, http.StatusUnauthorized, "Token Not Found")
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error getting Token")
|
||||
return false
|
||||
}
|
||||
|
||||
if authToken.Token != token {
|
||||
errorJSON(w, http.StatusUnauthorized, "Token Invalid")
|
||||
return false
|
||||
}
|
||||
|
||||
if !authToken.ScopeValid(scope) {
|
||||
errorJSON(w, http.StatusForbidden, "Scope")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 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."
|
||||
|
||||
func (s *Server) Serve() {
|
||||
http.HandleFunc(PathAuthTokenGetWalletState, s.getAuthTokenForGetWalletState)
|
||||
http.HandleFunc(PathAuthTokenFull, s.getAuthTokenFull)
|
||||
http.HandleFunc(PathWalletState, s.handleWalletState)
|
||||
http.HandleFunc(PathRegister, s.register)
|
||||
|
||||
fmt.Println("Serving at :8090")
|
||||
http.ListenAndServe(":8090", nil)
|
||||
}
|
100
server/server_test.go
Normal file
100
server/server_test.go
Normal file
|
@ -0,0 +1,100 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"orblivion/lbry-id/auth"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Implementing interfaces for stubbed out packages
|
||||
|
||||
type TestAuth struct {
|
||||
TestToken auth.AuthTokenString
|
||||
FailSigCheck bool
|
||||
FailGenToken bool
|
||||
}
|
||||
|
||||
func (a *TestAuth) NewToken(pubKey auth.PublicKey, DeviceID string, Scope auth.AuthScope) (*auth.AuthToken, error) {
|
||||
if a.FailGenToken {
|
||||
return nil, fmt.Errorf("Test error: fail to generate token")
|
||||
}
|
||||
return &auth.AuthToken{Token: a.TestToken, Scope: Scope}, nil
|
||||
}
|
||||
|
||||
func (a *TestAuth) IsValidSignature(pubKey auth.PublicKey, payload string, signature auth.Signature) bool {
|
||||
return !a.FailSigCheck
|
||||
}
|
||||
|
||||
func (a *TestAuth) ValidateTokenRequest(tokenRequest *auth.TokenRequest) bool {
|
||||
// TODO
|
||||
return true
|
||||
}
|
||||
|
||||
type TestStore struct {
|
||||
FailSave bool
|
||||
|
||||
SaveTokenCalled bool
|
||||
}
|
||||
|
||||
func (s *TestStore) SaveToken(token *auth.AuthToken) error {
|
||||
if s.FailSave {
|
||||
return fmt.Errorf("TestStore.SaveToken fail")
|
||||
}
|
||||
s.SaveTokenCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TestStore) GetToken(auth.PublicKey, string) (*auth.AuthToken, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *TestStore) GetPublicKey(string, auth.DownloadKey) (auth.PublicKey, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (s *TestStore) InsertEmail(auth.PublicKey, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TestStore) SetWalletState(
|
||||
pubKey auth.PublicKey,
|
||||
walletStateJson string,
|
||||
sequence int,
|
||||
signature auth.Signature,
|
||||
downloadKey auth.DownloadKey,
|
||||
) (latestWalletStateJson string, latestSignature auth.Signature, updated bool, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (s *TestStore) GetWalletState(pubKey auth.PublicKey) (walletStateJSON string, signature auth.Signature, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func TestServerHelperCheckAuthSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: checkAuth success")
|
||||
}
|
||||
|
||||
func TestServerHelperCheckAuthErrors(t *testing.T) {
|
||||
t.Fatalf("Test me: checkAuth failure")
|
||||
}
|
||||
|
||||
func TestServerHelperGetGetDataSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: getGetData success")
|
||||
}
|
||||
func TestServerHelperGetGetDataErrors(t *testing.T) {
|
||||
t.Fatalf("Test me: getGetData failure")
|
||||
}
|
||||
|
||||
func TestServerHelperGetPostDataSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: getPostData success")
|
||||
}
|
||||
func TestServerHelperGetPostDataErrors(t *testing.T) {
|
||||
t.Fatalf("Test me: getPostData failure")
|
||||
}
|
||||
|
||||
func TestServerHelperRequestOverheadSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: requestOverhead success")
|
||||
}
|
||||
func TestServerHelperRequestOverheadErrors(t *testing.T) {
|
||||
t.Fatalf("Test me: requestOverhead failures")
|
||||
}
|
206
server/wallet_state.go
Normal file
206
server/wallet_state.go
Normal file
|
@ -0,0 +1,206 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"orblivion/lbry-id/auth"
|
||||
"orblivion/lbry-id/store"
|
||||
"orblivion/lbry-id/wallet"
|
||||
)
|
||||
|
||||
type WalletStateRequest struct {
|
||||
Token auth.AuthTokenString `json:"token"`
|
||||
BodyJSON string `json:"bodyJSON"`
|
||||
PubKey auth.PublicKey `json:"publicKey"`
|
||||
Signature auth.Signature `json:"signature"`
|
||||
|
||||
// downloadKey is derived from the same password used to encrypt the wallet.
|
||||
// We want to keep it all in sync so we update it at the same time.
|
||||
DownloadKey auth.DownloadKey `json:"downloadKey"`
|
||||
}
|
||||
|
||||
func (r *WalletStateRequest) validate() bool {
|
||||
return (r.Token != auth.AuthTokenString("") &&
|
||||
r.BodyJSON != "" &&
|
||||
r.PubKey != auth.PublicKey("") &&
|
||||
r.Signature != auth.Signature(""))
|
||||
}
|
||||
|
||||
type WalletStateResponse struct {
|
||||
BodyJSON string `json:"bodyJSON"`
|
||||
Signature auth.Signature `json:"signature"`
|
||||
Error string `json:"error"` // in case of 409 Conflict responses. TODO - make field not show up if it's empty, to avoid confusion
|
||||
}
|
||||
|
||||
func (s *Server) handleWalletState(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodGet {
|
||||
s.getWalletState(w, req)
|
||||
} else if req.Method == http.MethodPost {
|
||||
s.postWalletState(w, req)
|
||||
} else {
|
||||
errorJSON(w, http.StatusMethodNotAllowed, "")
|
||||
}
|
||||
}
|
||||
|
||||
// TODO - There's probably a struct-based solution here like with POST/PUT.
|
||||
// We could put that struct up top as well.
|
||||
func getWalletStateParams(req *http.Request) (pubKey auth.PublicKey, deviceId string, token auth.AuthTokenString, err error) {
|
||||
tokenSlice, hasTokenSlice := req.URL.Query()["token"]
|
||||
deviceIDSlice, hasDeviceId := req.URL.Query()["deviceId"]
|
||||
pubKeySlice, hasPubKey := req.URL.Query()["publicKey"]
|
||||
|
||||
if !hasDeviceId {
|
||||
err = fmt.Errorf("Missing deviceId parameter")
|
||||
}
|
||||
if !hasTokenSlice {
|
||||
err = fmt.Errorf("Missing token parameter")
|
||||
}
|
||||
if !hasPubKey {
|
||||
err = fmt.Errorf("Missing publicKey parameter")
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
deviceId = deviceIDSlice[0]
|
||||
token = auth.AuthTokenString(tokenSlice[0])
|
||||
pubKey = auth.PublicKey(pubKeySlice[0])
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) getWalletState(w http.ResponseWriter, req *http.Request) {
|
||||
if !getGetData(w, req) {
|
||||
return
|
||||
}
|
||||
|
||||
pubKey, deviceId, token, err := getWalletStateParams(req)
|
||||
|
||||
if err != nil {
|
||||
// In this specific case, err is limited to values that are safe to give to
|
||||
// the user
|
||||
errorJSON(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !s.checkAuth(w, pubKey, deviceId, token, auth.ScopeGetWalletState) {
|
||||
return
|
||||
}
|
||||
|
||||
latestWalletStateJSON, latestSignature, err := s.store.GetWalletState(pubKey)
|
||||
|
||||
var response []byte
|
||||
|
||||
if err == store.ErrNoWalletState {
|
||||
errorJSON(w, http.StatusNotFound, "No wallet state")
|
||||
return
|
||||
} else if err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error retrieving walletState")
|
||||
return
|
||||
}
|
||||
|
||||
walletStateResponse := WalletStateResponse{
|
||||
BodyJSON: latestWalletStateJSON,
|
||||
Signature: latestSignature,
|
||||
}
|
||||
response, err = json.Marshal(walletStateResponse)
|
||||
|
||||
if err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error generating latestWalletState response")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, string(response))
|
||||
}
|
||||
|
||||
func (s *Server) postWalletState(w http.ResponseWriter, req *http.Request) {
|
||||
var walletStateRequest WalletStateRequest
|
||||
if !getPostData(w, req, &walletStateRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.auth.IsValidSignature(walletStateRequest.PubKey, walletStateRequest.BodyJSON, walletStateRequest.Signature) {
|
||||
errorJSON(w, http.StatusBadRequest, "Bad signature")
|
||||
return
|
||||
}
|
||||
|
||||
var walletState wallet.WalletState
|
||||
if err := json.Unmarshal([]byte(walletStateRequest.BodyJSON), &walletState); err != nil {
|
||||
errorJSON(w, http.StatusBadRequest, "Malformed walletState JSON")
|
||||
return
|
||||
}
|
||||
|
||||
if s.walletUtil.ValidateWalletState(&walletState) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
if !s.checkAuth(
|
||||
w,
|
||||
walletStateRequest.PubKey,
|
||||
walletState.DeviceID,
|
||||
walletStateRequest.Token,
|
||||
auth.ScopeFull,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO - We could do an extra check - pull from db, make sure the new
|
||||
// walletState doesn't regress lastSynced for any given device.
|
||||
// This is primarily the responsibility of the clients, but we may want to
|
||||
// trade a db call here for a double-check against bugs in the client.
|
||||
// We do already do some validation checks here, but those doesn't require
|
||||
// new database calls.
|
||||
|
||||
latestWalletStateJSON, latestSignature, updated, err := s.store.SetWalletState(
|
||||
walletStateRequest.PubKey,
|
||||
walletStateRequest.BodyJSON,
|
||||
walletState.Sequence(),
|
||||
walletStateRequest.Signature,
|
||||
walletStateRequest.DownloadKey,
|
||||
)
|
||||
|
||||
var response []byte
|
||||
|
||||
if err == store.ErrNoWalletState {
|
||||
// We failed to update, and when we tried pulling the latest wallet state,
|
||||
// there was nothing there. This should only happen if the client sets
|
||||
// sequence != 1 for the first walletState, which would be a bug.
|
||||
// TODO - figure out better error messages and/or document this
|
||||
errorJSON(w, http.StatusConflict, "Bad sequence number (No existing wallet state)")
|
||||
return
|
||||
} else if err != nil {
|
||||
// Something other than sequence error
|
||||
internalServiceErrorJSON(w, err, "Error saving walletState")
|
||||
return
|
||||
}
|
||||
|
||||
walletStateResponse := WalletStateResponse{
|
||||
BodyJSON: latestWalletStateJSON,
|
||||
Signature: latestSignature,
|
||||
}
|
||||
if !updated {
|
||||
// TODO - should we even call this an error?
|
||||
walletStateResponse.Error = "Bad sequence number"
|
||||
}
|
||||
response, err = json.Marshal(walletStateResponse)
|
||||
|
||||
if err != nil {
|
||||
internalServiceErrorJSON(w, err, "Error generating walletState response")
|
||||
return
|
||||
}
|
||||
|
||||
// Response Code:
|
||||
// 200: Update successful
|
||||
// 409: Update unsuccessful, probably due to new walletState's
|
||||
// sequence not being 1 + current walletState's sequence
|
||||
//
|
||||
// Response Body:
|
||||
// Current walletState (if it exists). If update successful, we just return
|
||||
// the same one passed in. If update not successful, return the latest one
|
||||
// from the db for the client to merge.
|
||||
if updated {
|
||||
fmt.Fprintf(w, string(response))
|
||||
} else {
|
||||
http.Error(w, string(response), http.StatusConflict)
|
||||
}
|
||||
}
|
42
server/wallet_state_test.go
Normal file
42
server/wallet_state_test.go
Normal file
|
@ -0,0 +1,42 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerGetWalletSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: GetWallet succeeds")
|
||||
}
|
||||
|
||||
func TestServerGetWalletErrors(t *testing.T) {
|
||||
t.Fatalf("Test me: GetWallet fails for various reasons (malformed, auth, db fail)")
|
||||
}
|
||||
|
||||
func TestServerGetWalletStateParams(t *testing.T) {
|
||||
t.Fatalf("Test me: getWalletStateParams")
|
||||
}
|
||||
|
||||
func TestServerPostWalletSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: PostWallet succeeds and returns the new wallet, PostWallet succeeds but is preempted")
|
||||
}
|
||||
|
||||
func TestServerPostWalletTooLate(t *testing.T) {
|
||||
t.Fatalf("Test me: PostWallet fails for sequence being too low, returns the latest wallet")
|
||||
}
|
||||
|
||||
func TestServerPostWalletErrors(t *testing.T) {
|
||||
// (malformed json, db fail, auth token not found, walletstate signature fail, walletstate invalid (via stub, make sure the validation function is even called), sequence too high, device id doesn't match token device id)
|
||||
// Client sends sequence != 1 for first entry
|
||||
// Client sends sequence == x + 10 for xth entry or whatever
|
||||
t.Fatalf("Test me: PostWallet fails for various reasons")
|
||||
}
|
||||
|
||||
func TestServerValidateWalletStateRequest(t *testing.T) {
|
||||
// also add a basic test case for this in TestServerAuthHandlerSuccess to make sure it's called at all
|
||||
// Maybe 401 specifically for missing signature?
|
||||
t.Fatalf("Test me: Implement and test WalletStateRequest.validate()")
|
||||
}
|
||||
|
||||
func TestServerHandleWalletState(t *testing.T) {
|
||||
t.Fatalf("Test me: Call the get or post function as appropriate. Alternately: call handleWalletState for the existing tests.")
|
||||
}
|
165
store.go
165
store.go
|
@ -1,165 +0,0 @@
|
|||
package main // TODO - make it its own `store` package later
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDuplicateToken = fmt.Errorf("Token already exists for this user and device")
|
||||
ErrNoToken = fmt.Errorf("Token does not exist for this user and device")
|
||||
)
|
||||
|
||||
type StoreInterface interface {
|
||||
SaveToken(*AuthToken) error
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *Store) Migrate() error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens(
|
||||
token TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
expiration DATETIME NOT NULL,
|
||||
PRIMARY KEY (public_key, device_id)
|
||||
);
|
||||
`
|
||||
|
||||
_, err := s.db.Exec(query)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetToken(pubKey PublicKey, deviceID string) (*AuthToken, error) {
|
||||
expirationCutoff := time.Now().UTC()
|
||||
|
||||
rows, err := s.db.Query("SELECT * FROM auth_tokens WHERE public_key=? AND device_id=? AND expiration>?",
|
||||
pubKey, deviceID, expirationCutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var authToken AuthToken
|
||||
for rows.Next() {
|
||||
|
||||
err := rows.Scan(
|
||||
&authToken.Token,
|
||||
&authToken.PubKey,
|
||||
&authToken.DeviceID,
|
||||
&authToken.Expiration,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &authToken, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Store) insertToken(authToken *AuthToken, expiration time.Time) (err error) {
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO auth_tokens (token, public_key, device_id, expiration) values(?,?,?,?)",
|
||||
authToken.Token, authToken.PubKey, authToken.DeviceID, expiration,
|
||||
)
|
||||
|
||||
var sqliteErr sqlite3.Error
|
||||
if errors.As(err, &sqliteErr) {
|
||||
// I initially expected to need to check for ErrConstraintUnique.
|
||||
// Maybe for psql it will be?
|
||||
if errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintPrimaryKey) {
|
||||
err = ErrDuplicateToken
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) updateToken(authToken *AuthToken, experation time.Time) (err error) {
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE auth_tokens SET token=?, expiration=? WHERE public_key=? AND device_id=?",
|
||||
authToken.Token, experation, authToken.PubKey, authToken.DeviceID,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
numRows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if numRows == 0 {
|
||||
err = ErrNoToken
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) SaveToken(token *AuthToken) (err error) {
|
||||
// TODO: For psql, do upsert here instead of separate insertToken and updateToken functions
|
||||
|
||||
// TODO - Should we auto-delete expired tokens?
|
||||
|
||||
expiration := time.Now().UTC().Add(time.Hour * 24 * 14)
|
||||
|
||||
// This is most likely not the first time calling this function for this
|
||||
// device, so there's probably already a token in there.
|
||||
err = s.updateToken(token, expiration)
|
||||
|
||||
if err == ErrNoToken {
|
||||
// If we don't have a token already saved, insert a new one:
|
||||
err = s.insertToken(token, expiration)
|
||||
|
||||
if err == ErrDuplicateToken {
|
||||
// By unlikely coincidence, a token was created between trying `updateToken`
|
||||
// and trying `insertToken`. At this point we can safely `updateToken`.
|
||||
err = s.updateToken(token, expiration)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
token.Expiration = &expiration
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) Init(fileName string) {
|
||||
db, err := sql.Open("sqlite3", fileName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
s.db = db
|
||||
}
|
||||
|
||||
/* TODO:
|
||||
authToken table contains:
|
||||
|
||||
...?
|
||||
|
||||
downloadKey table:
|
||||
|
||||
publicKey, email, KDF(downloadKey)
|
||||
|
||||
walletState table contains:
|
||||
|
||||
email, publicKey, walletState, sequence
|
||||
|
||||
(sequence is redundant since it's already in walletState but needed for transaction safety):
|
||||
|
||||
insert where publicKey=publicKey, sequence=sequence-1
|
||||
|
||||
if success, return success
|
||||
if fail, select where publicKey=publicKey, return walletState
|
||||
|
||||
downloadKey:
|
||||
|
||||
select email=email (They won't have their public key if they need their downloadKey). check KDF(downloadKey)
|
||||
*/
|
367
store/store.go
Normal file
367
store/store.go
Normal file
|
@ -0,0 +1,367 @@
|
|||
package store
|
||||
|
||||
// TODO - DeviceID - What about clients that lie about deviceID? Maybe require a certain format to make sure it gives a real value? Something it wouldn't come up with by accident.
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"log"
|
||||
"orblivion/lbry-id/auth"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDuplicateToken = fmt.Errorf("Token already exists for this user and device")
|
||||
ErrNoToken = fmt.Errorf("Token does not exist for this user and device")
|
||||
|
||||
ErrDuplicateWalletState = fmt.Errorf("WalletState already exists for this user")
|
||||
ErrNoWalletState = fmt.Errorf("WalletState does not exist for this user at this sequence")
|
||||
|
||||
ErrDuplicateEmail = fmt.Errorf("Email already exists for this user")
|
||||
ErrDuplicateAccount = fmt.Errorf("User already has an account")
|
||||
|
||||
ErrNoPubKey = fmt.Errorf("Public Key not found with these credentials")
|
||||
)
|
||||
|
||||
// For test stubs
|
||||
type StoreInterface interface {
|
||||
SaveToken(*auth.AuthToken) error
|
||||
GetToken(auth.PublicKey, string) (*auth.AuthToken, error)
|
||||
SetWalletState(auth.PublicKey, string, int, auth.Signature, auth.DownloadKey) (string, auth.Signature, bool, error)
|
||||
GetWalletState(auth.PublicKey) (string, auth.Signature, error)
|
||||
GetPublicKey(string, auth.DownloadKey) (auth.PublicKey, error)
|
||||
InsertEmail(auth.PublicKey, string) (err error)
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *Store) Init(fileName string) {
|
||||
db, err := sql.Open("sqlite3", fileName)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
s.db = db
|
||||
}
|
||||
|
||||
func (s *Store) Migrate() error {
|
||||
// We store `sequence` as a seprate field in the `wallet_state` table, even
|
||||
// though it's also saved as part of the `walle_state_blob` column. We do
|
||||
// this for transaction safety. For instance, let's say two different clients
|
||||
// are trying to update the sequence from 5 to 6. The update command will
|
||||
// specify "WHERE sequence=5". Only one of these commands will succeed, and
|
||||
// the other will get back an error.
|
||||
|
||||
// TODO does it actually fail with empty "NOT NULL" fields?
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens(
|
||||
token TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
expiration DATETIME NOT NULL,
|
||||
PRIMARY KEY (device_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS wallet_states(
|
||||
public_key TEXT NOT NULL,
|
||||
wallet_state_blob TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
signature INTEGER NOT NULL,
|
||||
download_key TEXT NOT NULL,
|
||||
PRIMARY KEY (public_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS accounts(
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
PRIMARY KEY (public_key),
|
||||
FOREIGN KEY (public_key) REFERENCES wallet_states(public_key)
|
||||
);
|
||||
`
|
||||
|
||||
_, err := s.db.Exec(query)
|
||||
return err
|
||||
}
|
||||
|
||||
////////////////
|
||||
// Auth Token //
|
||||
////////////////
|
||||
|
||||
func (s *Store) GetToken(pubKey auth.PublicKey, deviceID string) (*auth.AuthToken, error) {
|
||||
expirationCutoff := time.Now().UTC()
|
||||
|
||||
rows, err := s.db.Query(
|
||||
"SELECT * FROM auth_tokens WHERE public_key=? AND device_id=? AND expiration>?",
|
||||
pubKey, deviceID, expirationCutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var authToken auth.AuthToken
|
||||
for rows.Next() {
|
||||
|
||||
err := rows.Scan(
|
||||
&authToken.Token,
|
||||
&authToken.PubKey,
|
||||
&authToken.DeviceID,
|
||||
&authToken.Scope,
|
||||
&authToken.Expiration,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &authToken, nil
|
||||
}
|
||||
return nil, ErrNoToken // TODO - will need to test
|
||||
}
|
||||
|
||||
func (s *Store) insertToken(authToken *auth.AuthToken, expiration time.Time) (err error) {
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO auth_tokens (token, public_key, device_id, scope, expiration) values(?,?,?,?,?)",
|
||||
authToken.Token, authToken.PubKey, authToken.DeviceID, authToken.Scope, expiration,
|
||||
)
|
||||
|
||||
var sqliteErr sqlite3.Error
|
||||
if errors.As(err, &sqliteErr) {
|
||||
// I initially expected to need to check for ErrConstraintUnique.
|
||||
// Maybe for psql it will be?
|
||||
if errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintPrimaryKey) {
|
||||
err = ErrDuplicateToken
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) updateToken(authToken *auth.AuthToken, experation time.Time) (err error) {
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE auth_tokens SET token=?, expiration=?, scope=? WHERE public_key=? AND device_id=?",
|
||||
authToken.Token, experation, authToken.Scope, authToken.PubKey, authToken.DeviceID,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
numRows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if numRows == 0 {
|
||||
err = ErrNoToken
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) SaveToken(token *auth.AuthToken) (err error) {
|
||||
// TODO: For psql, do upsert here instead of separate insertToken and updateToken functions
|
||||
|
||||
// TODO - Should we auto-delete expired tokens?
|
||||
|
||||
expiration := time.Now().UTC().Add(time.Hour * 24 * 14)
|
||||
|
||||
// This is most likely not the first time calling this function for this
|
||||
// device, so there's probably already a token in there.
|
||||
err = s.updateToken(token, expiration)
|
||||
|
||||
if err == ErrNoToken {
|
||||
// If we don't have a token already saved, insert a new one:
|
||||
err = s.insertToken(token, expiration)
|
||||
|
||||
if err == ErrDuplicateToken {
|
||||
// By unlikely coincidence, a token was created between trying `updateToken`
|
||||
// and trying `insertToken`. At this point we can safely `updateToken`.
|
||||
// TODO - reconsider this - if one client has two concurrent requests
|
||||
// that create this situation, maybe the second one should just fail?
|
||||
err = s.updateToken(token, expiration)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
token.Expiration = &expiration
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
/////////////////////////////////
|
||||
// Wallet State / Download Key //
|
||||
/////////////////////////////////
|
||||
|
||||
func (s *Store) GetWalletState(pubKey auth.PublicKey) (walletStateJSON string, signature auth.Signature, err error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT wallet_state_blob, signature FROM wallet_states WHERE public_key=?",
|
||||
pubKey,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
err = rows.Scan(
|
||||
&walletStateJSON,
|
||||
&signature,
|
||||
)
|
||||
return
|
||||
}
|
||||
err = ErrNoWalletState
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) insertFirstWalletState(
|
||||
pubKey auth.PublicKey,
|
||||
walletStateJSON string,
|
||||
signature auth.Signature,
|
||||
downloadKey auth.DownloadKey,
|
||||
) (err error) {
|
||||
// This will only be used to attempt to insert the first wallet state
|
||||
// (sequence=1). The database will enforce that this will not be set
|
||||
// if this user already has a walletState.
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO wallet_states (public_key, wallet_state_blob, sequence, signature, download_key) values(?,?,?,?,?)",
|
||||
pubKey, walletStateJSON, 1, signature, downloadKey.Obfuscate(),
|
||||
)
|
||||
|
||||
var sqliteErr sqlite3.Error
|
||||
if errors.As(err, &sqliteErr) {
|
||||
// I initially expected to need to check for ErrConstraintUnique.
|
||||
// Maybe for psql it will be?
|
||||
if errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintPrimaryKey) {
|
||||
err = ErrDuplicateWalletState
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) updateWalletStateToSequence(
|
||||
pubKey auth.PublicKey,
|
||||
walletStateJSON string,
|
||||
sequence int,
|
||||
signature auth.Signature,
|
||||
downloadKey auth.DownloadKey,
|
||||
) (err error) {
|
||||
// This will be used for wallet states with sequence > 1.
|
||||
// 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
|
||||
// ErrNoWalletState for the second one.
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE wallet_states SET wallet_state_blob=?, sequence=?, signature=?, download_key=? WHERE public_key=? AND sequence=?",
|
||||
walletStateJSON, sequence, signature, downloadKey.Obfuscate(), pubKey, sequence-1,
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
numRows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if numRows == 0 {
|
||||
err = ErrNoWalletState
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Assumption: walletState has been validated (sequence >=1, etc)
|
||||
// Assumption: Sequence matches walletState.Sequence()
|
||||
// Sequence is only passed in here to avoid deserializing walletStateJSON again
|
||||
// WalletState *struct* is not passed in because we need the exact signed string
|
||||
func (s *Store) SetWalletState(
|
||||
pubKey auth.PublicKey,
|
||||
walletStateJSON string,
|
||||
sequence int,
|
||||
signature auth.Signature,
|
||||
downloadKey auth.DownloadKey,
|
||||
) (latestWalletStateJSON string, latestSignature auth.Signature, updated bool, err error) {
|
||||
if sequence == 1 {
|
||||
// If sequence == 1, the client assumed that this is our first
|
||||
// walletState. Try to insert. If we get a conflict, the client
|
||||
// assumed incorrectly and we proceed below to return the latest
|
||||
// walletState from the db.
|
||||
err = s.insertFirstWalletState(pubKey, walletStateJSON, signature, downloadKey)
|
||||
if err == nil {
|
||||
// Successful update
|
||||
latestWalletStateJSON = walletStateJSON
|
||||
latestSignature = signature
|
||||
updated = true
|
||||
return
|
||||
} else if err != ErrDuplicateWalletState {
|
||||
// Unsuccessful update for reasons other than sequence conflict
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// If sequence > 1, the client assumed that it is replacing walletState
|
||||
// with sequence - 1. Explicitly try to update the walletState with
|
||||
// sequence - 1. If we updated no rows, the client assumed incorrectly
|
||||
// and we proceed below to return the latest walletState from the db.
|
||||
err = s.updateWalletStateToSequence(pubKey, walletStateJSON, sequence, signature, downloadKey)
|
||||
if err == nil {
|
||||
latestWalletStateJSON = walletStateJSON
|
||||
latestSignature = signature
|
||||
updated = true
|
||||
return
|
||||
} else if err != ErrNoWalletState {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// We failed to update above due to a sequence conflict. Perhaps the client
|
||||
// was unaware of an update done by another client. Let's send back the latest
|
||||
// version right away so the requesting client can take care of it.
|
||||
//
|
||||
// Note that this means that `err` will not be `nil` at this point, but we
|
||||
// already accounted for it with `updated=false`. Instead, we'll pass on any
|
||||
// errors from calling `GetWalletState`.
|
||||
latestWalletStateJSON, latestSignature, err = s.GetWalletState(pubKey)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Store) GetPublicKey(email string, downloadKey auth.DownloadKey) (pubKey auth.PublicKey, err error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT ws.public_key from wallet_states ws INNER JOIN accounts a
|
||||
ON a.public_key=ws.public_key
|
||||
WHERE email=? AND download_key=?`,
|
||||
email, downloadKey.Obfuscate(),
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
err = rows.Scan(&pubKey)
|
||||
return
|
||||
}
|
||||
err = ErrNoPubKey
|
||||
return
|
||||
}
|
||||
|
||||
///////////
|
||||
// Email //
|
||||
///////////
|
||||
|
||||
func (s *Store) InsertEmail(pubKey auth.PublicKey, email string) (err error) {
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO accounts (public_key, email) values(?,?)",
|
||||
pubKey, email,
|
||||
)
|
||||
|
||||
var sqliteErr sqlite3.Error
|
||||
if errors.As(err, &sqliteErr) {
|
||||
// I initially expected to need to check for ErrConstraintUnique.
|
||||
// Maybe for psql it will be?
|
||||
if errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintPrimaryKey) {
|
||||
err = ErrDuplicateEmail
|
||||
}
|
||||
if errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintUnique) {
|
||||
err = ErrDuplicateAccount
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
|
@ -1,46 +1,20 @@
|
|||
package main
|
||||
package store
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"orblivion/lbry-id/auth"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func storeTestInit(t *testing.T) (s Store, tmpFile *os.File) {
|
||||
s = Store{}
|
||||
|
||||
tmpFile, err := ioutil.TempFile(os.TempDir(), "sqlite-test-")
|
||||
if err != nil {
|
||||
t.Fatalf("DB setup failure: %+v", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.Init(tmpFile.Name())
|
||||
|
||||
err = s.Migrate()
|
||||
if err != nil {
|
||||
t.Fatalf("DB setup failure: %+v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func storeTestCleanup(tmpFile *os.File) {
|
||||
if tmpFile != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Test insertToken, using GetToken as a helper
|
||||
// Try insertToken twice with the same public key, error the second time
|
||||
func TestStoreInsertToken(t *testing.T) {
|
||||
|
||||
s, tmpFile := storeTestInit(t)
|
||||
defer storeTestCleanup(tmpFile)
|
||||
s, tmpFile := StoreTestInit(t)
|
||||
defer StoreTestCleanup(tmpFile)
|
||||
|
||||
authToken1 := AuthToken{
|
||||
authToken1 := auth.AuthToken{
|
||||
Token: "seekrit-1",
|
||||
DeviceID: "dID",
|
||||
Scope: "*",
|
||||
|
@ -56,8 +30,8 @@ func TestStoreInsertToken(t *testing.T) {
|
|||
|
||||
// Get a token, come back empty
|
||||
gotToken, err := s.GetToken(authToken1.PubKey, authToken1.DeviceID)
|
||||
if gotToken != nil || err != nil {
|
||||
t.Fatalf("Expected no token and no error. token: %+v err: %+v", gotToken, err)
|
||||
if gotToken != nil || err != ErrNoToken {
|
||||
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
|
||||
}
|
||||
|
||||
// Put in a token
|
||||
|
@ -71,7 +45,7 @@ func TestStoreInsertToken(t *testing.T) {
|
|||
t.Fatalf("Unexpected error in GetToken: %+v", err)
|
||||
}
|
||||
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken1DB) {
|
||||
t.Fatalf("token: expected %+v, got: %+v", authToken1DB, gotToken)
|
||||
t.Fatalf("token: \n expected %+v\n got: %+v", authToken1DB, *gotToken)
|
||||
}
|
||||
|
||||
// Try to put a different token, fail becaues we already have one
|
||||
|
@ -94,10 +68,10 @@ func TestStoreInsertToken(t *testing.T) {
|
|||
// Try updateToken with a preexisting token, succeed
|
||||
// Try updateToken again with a new token, succeed
|
||||
func TestStoreUpdateToken(t *testing.T) {
|
||||
s, tmpFile := storeTestInit(t)
|
||||
defer storeTestCleanup(tmpFile)
|
||||
s, tmpFile := StoreTestInit(t)
|
||||
defer StoreTestCleanup(tmpFile)
|
||||
|
||||
authToken1 := AuthToken{
|
||||
authToken1 := auth.AuthToken{
|
||||
Token: "seekrit-1",
|
||||
DeviceID: "dID",
|
||||
Scope: "*",
|
||||
|
@ -112,8 +86,8 @@ func TestStoreUpdateToken(t *testing.T) {
|
|||
|
||||
// Try to get a token, come back empty because we're just starting out
|
||||
gotToken, err := s.GetToken(authToken1.PubKey, authToken1.DeviceID)
|
||||
if gotToken != nil || err != nil {
|
||||
t.Fatalf("Expected no token and no error. token: %+v err: %+v", gotToken, err)
|
||||
if gotToken != nil || err != ErrNoToken {
|
||||
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
|
||||
}
|
||||
|
||||
// Try to update the token - fail because we don't have an entry there in the first place
|
||||
|
@ -123,8 +97,8 @@ func TestStoreUpdateToken(t *testing.T) {
|
|||
|
||||
// Try to get a token, come back empty because the update attempt failed to do anything
|
||||
gotToken, err = s.GetToken(authToken1.PubKey, authToken1.DeviceID)
|
||||
if gotToken != nil || err != nil {
|
||||
t.Fatalf("Expected no token and no error. token: %+v err: %+v", gotToken, err)
|
||||
if gotToken != nil || err != ErrNoToken {
|
||||
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
|
||||
}
|
||||
|
||||
// Put in a token - just so we have something to test updateToken with
|
||||
|
@ -155,11 +129,11 @@ func TestStoreUpdateToken(t *testing.T) {
|
|||
// Put token2-d1 token2-d2
|
||||
// Get token2-d1 token2-d2
|
||||
func TestStoreSaveToken(t *testing.T) {
|
||||
s, tmpFile := storeTestInit(t)
|
||||
defer storeTestCleanup(tmpFile)
|
||||
s, tmpFile := StoreTestInit(t)
|
||||
defer StoreTestCleanup(tmpFile)
|
||||
|
||||
// Version 1 of the token for both devices
|
||||
authToken_d1_1 := AuthToken{
|
||||
authToken_d1_1 := auth.AuthToken{
|
||||
Token: "seekrit-d1-1",
|
||||
DeviceID: "dID-1",
|
||||
Scope: "*",
|
||||
|
@ -179,12 +153,12 @@ func TestStoreSaveToken(t *testing.T) {
|
|||
|
||||
// Try to get the tokens, come back empty because we're just starting out
|
||||
gotToken, err := s.GetToken(authToken_d1_1.PubKey, authToken_d1_1.DeviceID)
|
||||
if gotToken != nil || err != nil {
|
||||
t.Fatalf("Expected no token and no error. token: %+v err: %+v", gotToken, err)
|
||||
if gotToken != nil || err != ErrNoToken {
|
||||
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
|
||||
}
|
||||
gotToken, err = s.GetToken(authToken_d2_1.PubKey, authToken_d2_1.DeviceID)
|
||||
if gotToken != nil || err != nil {
|
||||
t.Fatalf("Expected no token and no error. token: %+v err: %+v", gotToken, err)
|
||||
if gotToken != nil || err != ErrNoToken {
|
||||
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
|
||||
}
|
||||
|
||||
// Save Version 1 tokens for both devices
|
||||
|
@ -264,11 +238,11 @@ func timePtr(t time.Time) *time.Time {
|
|||
// not found for device (one for another device does exist)
|
||||
// expired token not returned
|
||||
func TestStoreGetToken(t *testing.T) {
|
||||
s, tmpFile := storeTestInit(t)
|
||||
defer storeTestCleanup(tmpFile)
|
||||
s, tmpFile := StoreTestInit(t)
|
||||
defer StoreTestCleanup(tmpFile)
|
||||
|
||||
// created for addition to the DB (no expiration attached)
|
||||
authToken := AuthToken{
|
||||
authToken := auth.AuthToken{
|
||||
Token: "seekrit-d1",
|
||||
DeviceID: "dID",
|
||||
Scope: "*",
|
||||
|
@ -281,8 +255,8 @@ func TestStoreGetToken(t *testing.T) {
|
|||
|
||||
// Not found (nothing saved for this pubkey)
|
||||
gotToken, err := s.GetToken(authToken.PubKey, authToken.DeviceID)
|
||||
if gotToken != nil || err != nil {
|
||||
t.Fatalf("Expected no token and no error. token: %+v err: %+v", gotToken, err)
|
||||
if gotToken != nil || err != ErrNoToken {
|
||||
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
|
||||
}
|
||||
|
||||
// Put in a token
|
||||
|
@ -301,8 +275,8 @@ func TestStoreGetToken(t *testing.T) {
|
|||
|
||||
// Fail to get for another device
|
||||
gotToken, err = s.GetToken(authToken.PubKey, "other-device")
|
||||
if gotToken != nil || err != nil {
|
||||
t.Fatalf("Expected no token and no error for nonexistent device. token: %+v err: %+v", gotToken, err)
|
||||
if gotToken != nil || err != ErrNoToken {
|
||||
t.Fatalf("Expected ErrNoToken for nonexistent device. token: %+v err: %+v", gotToken, err)
|
||||
}
|
||||
|
||||
// Update the token to be expired
|
||||
|
@ -313,8 +287,8 @@ func TestStoreGetToken(t *testing.T) {
|
|||
|
||||
// Fail to get the expired token
|
||||
gotToken, err = s.GetToken(authToken.PubKey, authToken.DeviceID)
|
||||
if gotToken != nil || err != nil {
|
||||
t.Fatalf("Expected no token and no error, for expired token. token: %+v err: %+v", gotToken, err)
|
||||
if gotToken != nil || err != ErrNoToken {
|
||||
t.Fatalf("Expected ErrNoToken, for expired token. token: %+v err: %+v", gotToken, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -324,6 +298,67 @@ func TestStoreSanitizeEmptyFields(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestStoreTimeZones(t *testing.T) {
|
||||
// Make sure the tz situation is as we prefer in the DB. Probably just do UTC.
|
||||
// Make sure the tz situation is as we prefer in the DB unless we just do UTC.
|
||||
t.Fatalf("Test me")
|
||||
}
|
||||
|
||||
func TestStoreSetWalletStateSuccess(t *testing.T) {
|
||||
/*
|
||||
Sequence 1 - works via insert
|
||||
Sequence 2 - works via update
|
||||
Sequence 3 - works via update
|
||||
*/
|
||||
t.Fatalf("Test me: WalletState Set successes")
|
||||
}
|
||||
|
||||
func TestStoreSetWalletStateFail(t *testing.T) {
|
||||
/*
|
||||
Sequence 1 - fails via insert - fail by having something there already
|
||||
Sequence 2 - fails via update - fail by not having something there already
|
||||
Sequence 3 - fails via update - fail by having something with wrong sequence number
|
||||
Sequence 4 - fails via update - fail by having something with non-matching device sequence history
|
||||
|
||||
Maybe some of the above gets put off to wallet util
|
||||
*/
|
||||
t.Fatalf("Test me: WalletState Set failures")
|
||||
}
|
||||
|
||||
func TestStoreInsertWalletStateSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: WalletState insert successes")
|
||||
}
|
||||
|
||||
func TestStoreInsertWalletStateFail(t *testing.T) {
|
||||
t.Fatalf("Test me: WalletState insert failures")
|
||||
}
|
||||
|
||||
func TestStoreUpdateWalletStateSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: WalletState update successes")
|
||||
}
|
||||
|
||||
func TestStoreUpdateWalletStateFail(t *testing.T) {
|
||||
t.Fatalf("Test me: WalletState update failures")
|
||||
}
|
||||
|
||||
func TestStoreGetWalletStateSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: WalletState get success")
|
||||
}
|
||||
|
||||
func TestStoreGetWalletStateFail(t *testing.T) {
|
||||
t.Fatalf("Test me: WalletState get failures")
|
||||
}
|
||||
|
||||
func TestStoreSetEmailSuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: Email get success")
|
||||
}
|
||||
|
||||
func TestStoreSetEmailFail(t *testing.T) {
|
||||
t.Fatalf("Test me: Email get failures")
|
||||
}
|
||||
|
||||
func TestStoreGetPublicKeySuccess(t *testing.T) {
|
||||
t.Fatalf("Test me: Public Key get success")
|
||||
}
|
||||
|
||||
func TestStoreGetPublicKeyFail(t *testing.T) {
|
||||
t.Fatalf("Test me: Public Key get failures")
|
||||
}
|
32
store/store_test_utils.go
Normal file
32
store/store_test_utils.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func StoreTestInit(t *testing.T) (s Store, tmpFile *os.File) {
|
||||
s = Store{}
|
||||
|
||||
tmpFile, err := ioutil.TempFile(os.TempDir(), "sqlite-test-")
|
||||
if err != nil {
|
||||
t.Fatalf("DB setup failure: %+v", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.Init(tmpFile.Name())
|
||||
|
||||
err = s.Migrate()
|
||||
if err != nil {
|
||||
t.Fatalf("DB setup failure: %+v", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func StoreTestCleanup(tmpFile *os.File) {
|
||||
if tmpFile != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
}
|
||||
}
|
147
test_client/README.md
Normal file
147
test_client/README.md
Normal file
|
@ -0,0 +1,147 @@
|
|||
# Test Client
|
||||
|
||||
A couple example flows so it's clear how it works.
|
||||
|
||||
## Initial setup and account recovery
|
||||
|
||||
```
|
||||
>>> import test_client
|
||||
>>> c1 = test_client.Client()
|
||||
```
|
||||
|
||||
Create a new wallet locally and authenticate based on the newly created public key (the email and password are not used just yet)
|
||||
|
||||
```
|
||||
>>> c1.new_wallet('email@example.com', '123')
|
||||
>>> c1.get_full_auth_token()
|
||||
Got auth token: 787cefea147f3a7b38e1b9fda49490371b52a3b7077507364854b72c3538f94e
|
||||
```
|
||||
|
||||
Post the wallet along with the downloadKey. The downloadKey is based on the password. It's the same password that will be used (in the full implementation) to encrypt the wallet. This is why we are sending it with the wallet state. We want to keep everything related to the user's password consistent.
|
||||
|
||||
```
|
||||
>>> c1.post_wallet_state()
|
||||
Successfully updated wallet state
|
||||
Got new walletState: {'deviceId': 'e0349bc4-7e7a-48a2-a562-6c530b28a350', 'lastSynced': {'e0349bc4-7e7a-48a2-a562-6c530b28a350': 1}, 'encryptedWallet': ''}
|
||||
```
|
||||
|
||||
Note that every time a client posts, the server sends back the latest wallet state, whether or not the posted wallet state was rejected for being out of sequence. More on this below.
|
||||
|
||||
Send the email address
|
||||
|
||||
```
|
||||
>>> c1.register()
|
||||
Registered
|
||||
```
|
||||
|
||||
Now let's set up a second device
|
||||
|
||||
```
|
||||
>>> c2 = test_client.Client()
|
||||
```
|
||||
|
||||
Gets limited-scope auth token (which includes pubkey) based on email address and downloadKey (which comes from password). This token only allows downloading a wallet state (thus the "downloadKey").
|
||||
|
||||
```
|
||||
>>> c2.get_download_auth_token('email@example.com', '123')
|
||||
Got auth token: fd3f4074e6f1b2401b33e21ce5f69d93255680b37c334b6a4e8ea6385b454b0b
|
||||
Got public key: eeA0FfE5E57E3647524759CA9D7c7Cb1
|
||||
>>>
|
||||
```
|
||||
|
||||
Full auth token requires signature, which requires the wallet, which we don't have yet. (For demo we have a fake signature check, so this restriction is faked by the client)
|
||||
|
||||
```
|
||||
>>> c2.get_full_auth_token()
|
||||
No wallet state, thus no access to private key (or so we pretend for this demo), thus we cannot create a signature
|
||||
```
|
||||
|
||||
Get the wallet state.
|
||||
|
||||
```
|
||||
>>> c2.get_wallet_state()
|
||||
Got latest walletState: {'deviceId': 'e0349bc4-7e7a-48a2-a562-6c530b28a350', 'lastSynced': {'e0349bc4-7e7a-48a2-a562-6c530b28a350': 1}, 'encryptedWallet': ''}
|
||||
```
|
||||
|
||||
The download-only auth token doesn't allow posting a wallet.
|
||||
|
||||
```
|
||||
>>> c2.post_wallet_state()
|
||||
Error 403
|
||||
b'{"error":"Forbidden: Scope"}\n'
|
||||
```
|
||||
|
||||
But, we can get the full auth token now that we downloaded the wallet. In the full implementation, the wallet would be encrypted with the password. This means that somebody who merely intercepts the public key and download key wouldn't be able to do this step.
|
||||
|
||||
```
|
||||
>>> c2.get_full_auth_token()
|
||||
Got auth token: 4b19739a66f55aff5b7e0f1375c42f41d944b5175f5c5d32b35698a360bb0e5b
|
||||
>>> c2.post_wallet_state()
|
||||
Successfully updated wallet state
|
||||
Got new walletState: {'deviceId': '2ede3f32-4e65-4312-8b89-3b6bde0c5d8e', 'lastSynced': {'e0349bc4-7e7a-48a2-a562-6c530b28a350': 1, '2ede3f32-4e65-4312-8b89-3b6bde0c5d8e': 2}, 'encryptedWallet': ''}
|
||||
```
|
||||
|
||||
# Handling conflicts
|
||||
|
||||
Changes here are represented by 4 random characters separated by colons. The sequence of the changes is relevant to the final state of the wallet. Our goal is to make sure that all clients have all of the changes in the same order. This will thus demonstrate how clients can implement a "rebase" behavior when there is a conflict. In a full implementation, there would also be a system to resolve merge conflicts, but that is out of scope here.
|
||||
|
||||
First, create a local change and post it
|
||||
|
||||
```
|
||||
>>> c1.change_encrypted_wallet()
|
||||
>>> c1.cur_encrypted_wallet()
|
||||
':f801'
|
||||
>>> c1.post_wallet_state()
|
||||
Successfully updated wallet state
|
||||
Got new walletState: {'deviceId': 'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 2}, 'encryptedWallet': ':f801'}
|
||||
>>> c1.cur_encrypted_wallet()
|
||||
':f801'
|
||||
```
|
||||
|
||||
The other client gets the update and sees the same thing locally:
|
||||
|
||||
```
|
||||
>>> c2.get_wallet_state()
|
||||
Got latest walletState: {'deviceId': 'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 2}, 'encryptedWallet': ':f801'}
|
||||
>>> c2.cur_encrypted_wallet()
|
||||
':f801'
|
||||
```
|
||||
|
||||
Now, both clients make different local changes and both try to post them
|
||||
|
||||
```
|
||||
>>> c1.change_encrypted_wallet()
|
||||
>>> c2.change_encrypted_wallet()
|
||||
>>> c1.cur_encrypted_wallet()
|
||||
':f801:576b'
|
||||
>>> c2.cur_encrypted_wallet()
|
||||
':f801:dDE7'
|
||||
>>> c1.post_wallet_state()
|
||||
Successfully updated wallet state
|
||||
Got new walletState: {'deviceId': 'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 3}, 'encryptedWallet': ':f801:576b'}
|
||||
|
||||
>>> c2.post_wallet_state()
|
||||
Wallet state out of date. Getting updated wallet state. Try again.
|
||||
Got new walletState: {'deviceId': 'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 3}, 'encryptedWallet': ':f801:576b'}
|
||||
```
|
||||
|
||||
Client 2 gets a conflict, and the server sends it the updated wallet state that was just created by Client 1 (to save an extra request to `getWalletState`).
|
||||
|
||||
Its local change still exists, but now it's on top of client 1's latest change. (In a full implementation, this is where conflict resolution might take place.)
|
||||
|
||||
```
|
||||
>>> c2.cur_encrypted_wallet()
|
||||
':f801:576b:dDE7'
|
||||
```
|
||||
|
||||
Client 2 tries again to post, and it succeeds. Client 1 receives it.
|
||||
|
||||
```
|
||||
>>> c2.post_wallet_state()
|
||||
Successfully updated wallet state
|
||||
Got new walletState: {'deviceId': '127e0045-425c-4dd8-a742-90cd52b9377b', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 3, '127e0045-425c-4dd8-a742-90cd52b9377b': 4}, 'encryptedWallet': ':f801:576b:dDE7'}
|
||||
>>> c1.get_wallet_state()
|
||||
Got latest walletState: {'deviceId': '127e0045-425c-4dd8-a742-90cd52b9377b', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 3, '127e0045-425c-4dd8-a742-90cd52b9377b': 4}, 'encryptedWallet': ':f801:576b:dDE7'}
|
||||
>>> c1.cur_encrypted_wallet()
|
||||
':f801:576b:dDE7'
|
||||
```
|
223
test_client/test_client.py
Executable file
223
test_client/test_client.py
Executable file
|
@ -0,0 +1,223 @@
|
|||
#!/bin/python3
|
||||
import random, string, json, uuid, requests, hashlib, time
|
||||
|
||||
BASE_URL = 'http://localhost:8090'
|
||||
AUTH_FULL_URL = BASE_URL + '/auth/full'
|
||||
AUTH_GET_WALLET_STATE_URL = BASE_URL + '/auth/get-wallet-state'
|
||||
REGISTER_URL = BASE_URL + '/signup'
|
||||
WALLET_STATE_URL = BASE_URL + '/wallet-state'
|
||||
|
||||
def wallet_state_sequence(wallet_state):
|
||||
if 'deviceId' not in wallet_state:
|
||||
return 0
|
||||
return wallet_state['lastSynced'][wallet_state['deviceId']]
|
||||
|
||||
def download_key(password):
|
||||
return hashlib.sha256(password.encode('utf-8')).hexdigest()
|
||||
|
||||
class Client():
|
||||
def _validate_new_wallet_state(self, new_wallet_state):
|
||||
if self.wallet_state is None:
|
||||
# All of the validations here are in reference to what the device already
|
||||
# has. If this device is getting a wallet state for the first time, there
|
||||
# is no basis for comparison.
|
||||
return True
|
||||
|
||||
# Make sure that the new sequence is overall later.
|
||||
if wallet_state_sequence(new_wallet_state) <= wallet_state_sequence(self.wallet_state):
|
||||
return False
|
||||
|
||||
for dev_id in self.wallet_state['lastSynced']:
|
||||
if dev_id == self.device_id:
|
||||
# Check if the new wallet has the latest changes from this device
|
||||
if new_wallet_state['lastSynced'][dev_id] != self.wallet_state['lastSynced'][dev_id]:
|
||||
return False
|
||||
else:
|
||||
# Check if the new wallet somehow regressed on any of the other devices
|
||||
# This most likely means a bug in another client
|
||||
if new_wallet_state['lastSynced'][dev_id] < self.wallet_state['lastSynced'][dev_id]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def __init__(self):
|
||||
# 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.wallet_state = None
|
||||
|
||||
def new_wallet(self, email, password):
|
||||
# Obviously not real behavior
|
||||
self.public_key = ''.join(random.choice(string.hexdigits) for x in range(32))
|
||||
|
||||
# camel-cased to ease json interop
|
||||
self.wallet_state = {'lastSynced': {}, 'encryptedWallet': ''}
|
||||
|
||||
# TODO - actual encryption with password
|
||||
self._encrypted_wallet_local_changes = ''
|
||||
|
||||
self.email = email
|
||||
self.password = password
|
||||
|
||||
def register(self):
|
||||
body = json.dumps({
|
||||
'token': self.auth_token,
|
||||
'publicKey': self.public_key,
|
||||
'deviceId': self.device_id,
|
||||
'email': self.email,
|
||||
})
|
||||
response = requests.post(REGISTER_URL, body)
|
||||
if response.status_code != 201:
|
||||
print ('Error', response.status_code)
|
||||
print (response.content)
|
||||
return
|
||||
print ("Registered")
|
||||
|
||||
def get_download_auth_token(self, email, password):
|
||||
body = json.dumps({
|
||||
'email': email,
|
||||
'downloadKey': download_key(password),
|
||||
'deviceId': self.device_id,
|
||||
})
|
||||
response = requests.post(AUTH_GET_WALLET_STATE_URL, body)
|
||||
if response.status_code != 200:
|
||||
print ('Error', response.status_code)
|
||||
print (response.content)
|
||||
return
|
||||
self.auth_token = json.loads(response.content)['token']
|
||||
self.public_key = json.loads(response.content)['publicKey']
|
||||
print ("Got auth token: ", self.auth_token)
|
||||
print ("Got public key: ", self.public_key)
|
||||
|
||||
self.email = email
|
||||
self.password = password
|
||||
|
||||
def get_full_auth_token(self):
|
||||
if not self.wallet_state:
|
||||
print ("No wallet state, thus no access to private key (or so we pretend for this demo), thus we cannot create a signature")
|
||||
return
|
||||
|
||||
body = json.dumps({
|
||||
'tokenRequestJSON': json.dumps({'deviceId': self.device_id, 'requestTime': int(time.time())}),
|
||||
'publicKey': self.public_key,
|
||||
'signature': 'Good Signature',
|
||||
})
|
||||
response = requests.post(AUTH_FULL_URL, body)
|
||||
if response.status_code != 200:
|
||||
print ('Error', response.status_code)
|
||||
print (response.content)
|
||||
return
|
||||
self.auth_token = json.loads(response.content)['token']
|
||||
print ("Got auth token: ", self.auth_token)
|
||||
|
||||
def get_wallet_state(self):
|
||||
params = {
|
||||
'token': self.auth_token,
|
||||
'publicKey': self.public_key,
|
||||
'deviceId': self.device_id,
|
||||
}
|
||||
response = requests.get(WALLET_STATE_URL, params=params)
|
||||
if response.status_code != 200:
|
||||
print ('Error', response.status_code)
|
||||
print (response.content)
|
||||
return
|
||||
|
||||
if json.loads(response.content)['signature'] != "Good Signature":
|
||||
print ('Error - bad signature on new wallet')
|
||||
print (response.content)
|
||||
return
|
||||
if response.status_code != 200:
|
||||
print ('Error', response.status_code)
|
||||
print (response.content)
|
||||
return
|
||||
|
||||
# In reality, we'd examine, merge, verify, validate etc this new wallet state.
|
||||
new_wallet_state = json.loads(json.loads(response.content)['bodyJSON'])
|
||||
if self.wallet_state != new_wallet_state and not self._validate_new_wallet_state(new_wallet_state):
|
||||
print ('Error - new wallet does not validate')
|
||||
print (response.content)
|
||||
return
|
||||
|
||||
if self.wallet_state is None:
|
||||
# This is if we're getting a wallet_state for the first time. Initialize
|
||||
# the local changes.
|
||||
self._encrypted_wallet_local_changes = ''
|
||||
self.wallet_state = new_wallet_state
|
||||
|
||||
print ("Got latest walletState: ", self.wallet_state)
|
||||
|
||||
def post_wallet_state(self):
|
||||
# Create a *new* wallet state, indicating that it was last updated by this
|
||||
# device, with the updated sequence, and include our local encrypted wallet changes.
|
||||
# Don't set self.wallet_state to this until we know that it's accepted by
|
||||
# the server.
|
||||
if self.wallet_state:
|
||||
submitted_wallet_state = {
|
||||
"deviceId": self.device_id,
|
||||
"lastSynced": dict(self.wallet_state['lastSynced']),
|
||||
"encryptedWallet": self.cur_encrypted_wallet(),
|
||||
}
|
||||
submitted_wallet_state['lastSynced'][self.device_id] = wallet_state_sequence(self.wallet_state) + 1
|
||||
else:
|
||||
# If we have no self.wallet_state, we shouldn't be able to have a full
|
||||
# auth token, so this code path is just to demonstrate an auth failure
|
||||
submitted_wallet_state = {
|
||||
"deviceId": self.device_id,
|
||||
"lastSynced": {self.device_id: 1},
|
||||
"encryptedWallet": self.cur_encrypted_wallet(),
|
||||
}
|
||||
|
||||
body = json.dumps({
|
||||
'token': self.auth_token,
|
||||
'bodyJSON': json.dumps(submitted_wallet_state),
|
||||
'publicKey': self.public_key,
|
||||
'downloadKey': download_key(self.password),
|
||||
'signature': 'Good Signature',
|
||||
})
|
||||
response = requests.post(WALLET_STATE_URL, body)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Our local changes are no longer local, so we reset them
|
||||
self._encrypted_wallet_local_changes = ''
|
||||
print ('Successfully updated wallet state')
|
||||
elif response.status_code == 409:
|
||||
print ('Wallet state out of date. Getting updated wallet state. Try again.')
|
||||
else:
|
||||
print ('Error', response.status_code)
|
||||
print (response.content)
|
||||
return
|
||||
|
||||
if json.loads(response.content)['signature'] != "Good Signature":
|
||||
print ('Error - bad signature on new wallet')
|
||||
print (response.content)
|
||||
return
|
||||
|
||||
# In reality, we'd examine, merge, verify, validate etc this new wallet state.
|
||||
new_wallet_state = json.loads(json.loads(response.content)['bodyJSON'])
|
||||
if submitted_wallet_state != new_wallet_state and not self._validate_new_wallet_state(new_wallet_state):
|
||||
print ('Error - new wallet does not validate')
|
||||
print (response.content)
|
||||
return
|
||||
|
||||
self.wallet_state = new_wallet_state
|
||||
|
||||
print ("Got new walletState: ", self.wallet_state)
|
||||
|
||||
def change_encrypted_wallet(self):
|
||||
if not self.wallet_state:
|
||||
print ("No wallet state, so we can't add to it yet.")
|
||||
return
|
||||
|
||||
self._encrypted_wallet_local_changes += ':' + ''.join(random.choice(string.hexdigits) for x in range(4))
|
||||
|
||||
def cur_encrypted_wallet(self):
|
||||
if not self.wallet_state:
|
||||
print ("No wallet state, so no encrypted wallet.")
|
||||
return
|
||||
|
||||
# The local changes on top of whatever came from the server
|
||||
# If we pull new changes from server, we "rebase" these on top of it
|
||||
# If we push changes, the full "rebased" version gets committed to the server
|
||||
return self.wallet_state['encryptedWallet'] + self._encrypted_wallet_local_changes
|
33
wallet/wallet.go
Normal file
33
wallet/wallet.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
package wallet
|
||||
|
||||
// Currently a small package but given other packages it makes imports easier.
|
||||
// Also this might grow substantially over time
|
||||
|
||||
// For test stubs
|
||||
type WalletUtilInterface interface {
|
||||
ValidateWalletState(walletState *WalletState) bool
|
||||
}
|
||||
|
||||
type WalletUtil struct{}
|
||||
|
||||
type WalletState struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
LastSynced map[string]int `json:"lastSynced"`
|
||||
}
|
||||
|
||||
// TODO - These "validate" functions could/should be methods. Though I think
|
||||
// we'd lose mockability for testing, since the method isn't the
|
||||
// WalletUtilInterface.
|
||||
// Mainly the job of the clients but we may as well short-circuit problems
|
||||
// here before saving them.
|
||||
func (wu *WalletUtil) ValidateWalletState(walletState *WalletState) bool {
|
||||
|
||||
// TODO - nonempty fields, up to date, etc
|
||||
return true
|
||||
}
|
||||
|
||||
// Assumptions: `ws` has been validated
|
||||
// Avoid having to check for error
|
||||
func (ws *WalletState) Sequence() int {
|
||||
return ws.LastSynced[ws.DeviceID]
|
||||
}
|
19
wallet/wallet_test.go
Normal file
19
wallet/wallet_test.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package wallet
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Test stubs for now
|
||||
|
||||
func TestWalletSequence(t *testing.T) {
|
||||
t.Fatalf("Test me: test that walletState.Sequence() == walletState.lastSynced[wallet.DeviceID]")
|
||||
}
|
||||
|
||||
func TestWalletValidateWalletState(t *testing.T) {
|
||||
// walletState.DeviceID in walletState.lastSynced
|
||||
// Sequence for lastSynced all > 1
|
||||
t.Fatalf("Test me: Implement and test validateWalletState.")
|
||||
}
|
||||
|
||||
// TODO - other wallet integrity stuff? particularly related to sequence?
|
Loading…
Reference in a new issue