diff --git a/.env.example b/.env.example index 9f1692c9..e4ba1e23 100644 --- a/.env.example +++ b/.env.example @@ -26,4 +26,6 @@ UNIT21_URL=https://sandbox2-api.unit21.com/v1/ TWILIO_ACCOUNT_SID=AC034879a536d54325687e48544403cb4d TWILIO_AUTH_TOKEN= TWILIO_SMS_SID=MG367a4f51ea6f67a28db4d126eefc734f -DEV_PHONE_NUMBERS=+14088675309,+14155555555 \ No newline at end of file +DEV_PHONE_NUMBERS=+14088675309,+14155555555 +STRING_ENCRYPTION_KEY=secret_encryption_key_0123456789 +SENDGRID_API_KEY= \ No newline at end of file diff --git a/api/api.go b/api/api.go index 296a2561..67c37e5a 100644 --- a/api/api.go +++ b/api/api.go @@ -31,6 +31,8 @@ func Start(config APIConfig) { authService := authRoute(config, e) platformRoute(config, e) transactRoute(config, authService, e) + userRoute(config, authService, e) + verificationRoute(config, e) e.Logger.Fatal(e.Start(":" + config.Port)) } @@ -74,3 +76,25 @@ func transactRoute(config APIConfig, auth service.Auth, e *echo.Echo) { handler := handler.NewTransaction(e, service) handler.RegisterRoutes(e.Group("/transact"), middleware.APIKeyAuth(auth), middleware.BearerAuth()) } + +func userRoute(config APIConfig, auth service.Auth, e *echo.Echo) { + repos := service.UserRepos{ + User: repository.NewUser(config.DB), + Contact: repository.NewContact(config.DB), + Instrument: repository.NewInstrument(config.DB), + } + service := service.NewUser(repos) + handler := handler.NewUser(e, service) + handler.RegisterRoutes(e.Group("/user"), middleware.APIKeyAuth(auth), middleware.BearerAuth()) +} + +func verificationRoute(config APIConfig, e *echo.Echo) { + repos := service.UserRepos{ + User: repository.NewUser(config.DB), + Contact: repository.NewContact(config.DB), + Instrument: repository.NewInstrument(config.DB), + } + service := service.NewUser(repos) + handler := handler.NewUser(e, service) + handler.RegisterRoutes(e.Group("/verification")) +} diff --git a/api/handler/user.go b/api/handler/user.go new file mode 100644 index 00000000..65bd7ad1 --- /dev/null +++ b/api/handler/user.go @@ -0,0 +1,122 @@ +package handler + +import ( + "net/http" + + "github.com/String-xyz/string-api/pkg/model" + "github.com/String-xyz/string-api/pkg/service" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog" +) + +type User interface { + GetStatus(c echo.Context) error // If wallet addr is associated with user, return current state of their onboarding + Create(c echo.Context) error // Create new user using wallet addr, optionally mark as validated if signature is provided + Sign(c echo.Context) error // Takes in a signed timestamp from user, validating their wallet + Authenticate(c echo.Context) error // Takes e-mail and wallet addr of user, validates email with twilio + Name(c echo.Context) error // Takes name and wallet addr of user, associates name with wallet addr + RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) +} + +type ResultMessage struct { + Status string +} + +type user struct { + Service service.User + Group *echo.Group +} + +func NewUser(route *echo.Echo, service service.User) User { + return &user{service, nil} +} + +func (u user) GetStatus(c echo.Context) error { + lg := c.Get("logger").(*zerolog.Logger) + var body model.UserRequest + err := c.Bind(&body) + if err != nil { + return c.String(http.StatusBadRequest, "Bad Request") + } + res, err := u.Service.GetStatus(body) + if err != nil { + lg.Err(err).Msg("user getstatus") + return c.String(http.StatusNotFound, "User Not Found") + } + return c.JSON(http.StatusOK, res) +} + +func (u user) Create(c echo.Context) error { + lg := c.Get("logger").(*zerolog.Logger) + var body model.UserRequest + err := c.Bind(&body) + if err != nil { + return c.String(http.StatusBadRequest, "Bad Request") + } + err = u.Service.Create(body) + if err != nil { + lg.Err(err).Msg("user create") + return c.String(http.StatusOK, "User Service Failed") + } + return c.JSON(http.StatusOK, ResultMessage{Status: "User Created"}) +} + +func (u user) Sign(c echo.Context) error { + lg := c.Get("logger").(*zerolog.Logger) + var body model.UserRequest + err := c.Bind(&body) + if err != nil { + return c.String(http.StatusBadRequest, "Bad Request") + } + err = u.Service.Sign(body) + if err != nil { + lg.Err(err).Msg("user sign") + return c.String(http.StatusBadRequest, "Signing Wallet Failed") + } + return c.JSON(http.StatusOK, ResultMessage{Status: "Wallet Signed"}) +} + +func (u user) Authenticate(c echo.Context) error { + lg := c.Get("logger").(*zerolog.Logger) + var body model.UserRequest + err := c.Bind(&body) + if err != nil { + return c.String(http.StatusBadRequest, "Bad Request") + } + + // User needs a token + err = u.Service.Authenticate(body) + if err != nil { + lg.Err(err).Msg("user authenticate") + return c.String(http.StatusBadRequest, "Could Not Send Email Verification") + } + return c.JSON(http.StatusOK, ResultMessage{Status: "Email Validation Sent"}) +} + +func (u user) Name(c echo.Context) error { + lg := c.Get("logger").(*zerolog.Logger) + var body model.UserRequest + err := c.Bind(&body) + if err != nil { + return c.String(http.StatusBadRequest, "Bad Request") + } + err = u.Service.Name(body) + if err != nil { + lg.Err(err).Msg("user name") + return c.String(http.StatusBadRequest, "Could Not Update Name") + } + return c.JSON(http.StatusOK, ResultMessage{Status: "Name Updated Successfully"}) +} + +func (u user) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { + if g == nil { + panic("No group attached to the User Handler") + } + u.Group = g + g.Use(ms...) + g.GET("", u.GetStatus) + g.POST("", u.Create) + g.PUT("", u.Sign) + g.POST("/email", u.Authenticate) + g.POST("/name", u.Name) +} diff --git a/api/handler/verification.go b/api/handler/verification.go new file mode 100644 index 00000000..b38c0b83 --- /dev/null +++ b/api/handler/verification.go @@ -0,0 +1,44 @@ +package handler + +import ( + "net/http" + + "github.com/String-xyz/string-api/pkg/service" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog" +) + +type Verification interface { + Authenticate(c echo.Context) error // Takes e-mail and wallet addr of user, validates email with twilio + RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) +} + +type verification struct { + Service service.User + Group *echo.Group +} + +func NewVerification(route *echo.Echo, service service.User) Verification { + return &verification{service, nil} +} + +func (v verification) Authenticate(c echo.Context) error { + lg := c.Get("logger").(*zerolog.Logger) + // Token was provided + token := c.QueryParam("token") + err := v.Service.ReceiveEmailAuthentication(token) + if err != nil { + lg.Err(err).Msg("user authenticate") + return c.String(http.StatusBadRequest, "Invalid Token") + } + return c.JSON(http.StatusOK, ResultMessage{Status: "Email Validated Successfully"}) +} + +func (v verification) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { + if g == nil { + panic("No group attached to the User Handler") + } + v.Group = g + g.Use(ms...) + g.POST("/email", v.Authenticate) +} diff --git a/go.mod b/go.mod index ae406a62..382c093d 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,8 @@ require ( github.com/philhofer/fwd v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/tsdb v0.7.1 // indirect + github.com/sendgrid/rest v2.6.9+incompatible // indirect + github.com/sendgrid/sendgrid-go v3.12.0+incompatible // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/tinylib/msgp v1.1.2 // indirect diff --git a/go.sum b/go.sum index 43e1b9d4..f2808c16 100644 --- a/go.sum +++ b/go.sum @@ -193,6 +193,10 @@ github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= +github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= +github.com/sendgrid/sendgrid-go v3.12.0+incompatible h1:/N2vx18Fg1KmQOh6zESc5FJB8pYwt5QFBDflYPh1KVg= +github.com/sendgrid/sendgrid-go v3.12.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= diff --git a/pkg/internal/common/base64.go b/pkg/internal/common/base64.go new file mode 100644 index 00000000..8477b6b7 --- /dev/null +++ b/pkg/internal/common/base64.go @@ -0,0 +1,27 @@ +package common + +import ( + "encoding/base64" + "encoding/json" +) + +func EncodeToBase64(object interface{}) (string, error) { + buffer, err := json.Marshal(object) + if err != nil { + return "", StringError(err) + } + return base64.StdEncoding.EncodeToString(buffer), nil +} + +func DecodeFromBase64[T any](from string) (T, error) { + var result *T = new(T) + buffer, err := base64.StdEncoding.DecodeString(from) + if err != nil { + return *result, StringError(err) + } + err = json.Unmarshal(buffer, &result) + if err != nil { + return *result, StringError(err) + } + return *result, nil +} diff --git a/pkg/internal/common/crypt.go b/pkg/internal/common/crypt.go new file mode 100644 index 00000000..b507de92 --- /dev/null +++ b/pkg/internal/common/crypt.go @@ -0,0 +1,66 @@ +package common + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" +) + +func Encrypt(object interface{}, secret string) (string, error) { + buffer, err := json.Marshal(object) + if err != nil { + return "", StringError(err) + } + return EncryptString(string(buffer), secret) +} + +func Decrypt[T any](from string, secret string) (T, error) { + var result *T = new(T) + decrypted, err := DecryptString(from, secret) + if err != nil { + return *result, StringError(err) + } + err = json.Unmarshal([]byte(decrypted), &result) + if err != nil { + return *result, StringError(err) + } + return *result, nil +} + +func EncryptString(data string, secret string) (string, error) { + block, err := aes.NewCipher([]byte(secret)) + if err != nil { + return "", StringError(err) + } + plainText := []byte(data) + cipherText := make([]byte, aes.BlockSize+len(plainText)) + iv := cipherText[:aes.BlockSize] + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", StringError(err) + } + cfb := cipher.NewCFBEncrypter(block, iv) + cfb.XORKeyStream(cipherText[aes.BlockSize:], plainText) + return base64.StdEncoding.EncodeToString(cipherText), nil +} + +func DecryptString(data string, secret string) (string, error) { + block, err := aes.NewCipher([]byte(secret)) + if err != nil { + return "", StringError(err) + } + cipherText, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return "", StringError(err) + } + iv := cipherText[:aes.BlockSize] + + cipherText = cipherText[aes.BlockSize:] + + cfb := cipher.NewCFBDecrypter(block, iv) + plainText := make([]byte, len(cipherText)) + cfb.XORKeyStream(plainText, cipherText) + return string(plainText), nil +} diff --git a/pkg/internal/common/crypt_test.go b/pkg/internal/common/crypt_test.go new file mode 100644 index 00000000..be87e432 --- /dev/null +++ b/pkg/internal/common/crypt_test.go @@ -0,0 +1,76 @@ +package common + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type randomObject1 struct { + Timestamp int64 `json:"timestamp"` + Email string `json:"email"` + Address string `json:"address"` +} + +func TestEncodeDecodeString(t *testing.T) { + obj1 := "this is a test" + + obj1Encoded, err := EncodeToBase64(obj1) + assert.NoError(t, err) + + obj1Decoded, err := DecodeFromBase64[string](obj1Encoded) + assert.NoError(t, err) + assert.Equal(t, obj1, obj1Decoded) +} + +func TestEncodeDecodeObject(t *testing.T) { + obj2 := randomObject1{Timestamp: time.Now().Unix(), Email: "test@test.com", Address: "0xdecafbabe"} + + obj2Encoded, err := EncodeToBase64(obj2) + assert.NoError(t, err) + + obj2Decoded, err := DecodeFromBase64[randomObject1](obj2Encoded) + assert.NoError(t, err) + assert.Equal(t, obj2, obj2Decoded) +} + +func TestEncryptDecryptString(t *testing.T) { + str := "this is a string" + + strEncrypted, err := EncryptString(str, "secret_encryption_key_0123456789") + assert.NoError(t, err) + + strDecrypted, err := DecryptString(strEncrypted, "secret_encryption_key_0123456789") + assert.NoError(t, err) + + assert.Equal(t, str, strDecrypted) +} + +func TestEncryptDecryptObject(t *testing.T) { + obj := randomObject1{Timestamp: time.Now().Unix(), Email: "test@test.com", Address: "0xdecafbabe"} + + objEncoded, err := EncodeToBase64(obj) + assert.NoError(t, err) + + objEncrypted, err := EncryptString(objEncoded, "secret_encryption_key_0123456789") + assert.NoError(t, err) + + objDecrypted, err := DecryptString(objEncrypted, "secret_encryption_key_0123456789") + assert.NoError(t, err) + + objDecoded, err := DecodeFromBase64[randomObject1](objDecrypted) + assert.NoError(t, err) + assert.Equal(t, obj, objDecoded) +} + +func TestEncryptDecryptUnencoded(t *testing.T) { + obj := randomObject1{Timestamp: time.Now().Unix(), Email: "test@test.com", Address: "0xdecafbabe"} + + objEncrypted, err := Encrypt(obj, "secret_encryption_key_0123456789") + assert.NoError(t, err) + + objDecrypted, err := Decrypt[randomObject1](objEncrypted, "secret_encryption_key_0123456789") + assert.NoError(t, err) + assert.Equal(t, obj, objDecrypted) +} diff --git a/pkg/internal/common/evm.go b/pkg/internal/common/evm.go index e90460d3..15835ddb 100644 --- a/pkg/internal/common/evm.go +++ b/pkg/internal/common/evm.go @@ -1,13 +1,18 @@ package common import ( + "context" "errors" "math/big" + "regexp" "strconv" "strings" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/params" "github.com/lmittmann/w3" + "golang.org/x/crypto/sha3" ) func ParseEncoding(function *w3.Func, signature string, params []string) ([]byte, error) { @@ -76,3 +81,48 @@ func WeiToEther(wei *big.Int) float64 { eth64, _ := ethBig.Float64() // OK to reduce precision? return eth64 } + +// TODO: Eventually make sure we support smart contract wallets +func IsWallet(addr string) bool { + RPC := "https://rpc.ankr.com/eth" // temporarily just use ETH mainnet + geth, _ := ethclient.Dial(RPC) + + if !validAddress(addr) { + return false + } + if !validChecksum(addr) { + return false + } + + address := common.HexToAddress(addr) + bytecode, err := geth.CodeAt(context.Background(), address, nil) + if err != nil { + return false + } + isContract := len(bytecode) > 0 + return !isContract +} + +func validChecksum(addr string) bool { + lowerCase := strings.ToLower(addr)[2:] + hash := sha3.NewLegacyKeccak256() + hash.Write([]byte(lowerCase)) + hashBytes := hash.Sum(nil) + + valid := "0x" + for i, b := range lowerCase { + c := string(b) + if b < '0' || b > '9' { + if hashBytes[i/2]&byte(128-i%2*120) != 0 { + c = string(b - 32) + } + } + valid += c + } + return addr == valid +} + +func validAddress(addr string) bool { + re := regexp.MustCompile("^0x[0-9a-fA-F]{40}$") + return re.MatchString(addr) +} diff --git a/pkg/internal/common/sign.go b/pkg/internal/common/sign.go index 911499ee..f0568a82 100644 --- a/pkg/internal/common/sign.go +++ b/pkg/internal/common/sign.go @@ -50,8 +50,7 @@ func ValidateEVMSignature(signature string, data interface{}) (bool, error) { if err != nil { return false, StringError(err) } - sigBytes = sigBytes[:len(sigBytes)-1] // last byte is a recovery ID - verified := crypto.VerifySignature(pkBytes, hash.Bytes(), sigBytes) + verified := crypto.VerifySignature(pkBytes, hash.Bytes(), sigBytes[:len(sigBytes)-1]) // last byte of signature is recovery ID return verified, nil } @@ -66,14 +65,18 @@ func ValidateExternalEVMSignature(signature string, address string, data interfa if err != nil { return false, StringError(err) } - sigBytes = sigBytes[:len(sigBytes)-1] // last byte is a recovery ID - addrBytes, err := hexutil.Decode(address) + sigPKECDSA, err := crypto.SigToPub(hash.Bytes(), sigBytes) if err != nil { return false, StringError(err) } - addrBytes = addrBytes[:len(addrBytes)-1] // last byte is a recovery ID + sigPKBytes := crypto.FromECDSAPub(sigPKECDSA) - verified := crypto.VerifySignature(addrBytes, hash.Bytes(), sigBytes) + SIGPKString := crypto.PubkeyToAddress(*sigPKECDSA).String() + if address != SIGPKString { + return false, nil + } + + verified := crypto.VerifySignature(sigPKBytes, hash.Bytes(), sigBytes[:len(sigBytes)-1]) // last byte of signature is recovery ID return verified, nil } diff --git a/pkg/internal/common/sign_test.go b/pkg/internal/common/sign_test.go new file mode 100644 index 00000000..7dae1933 --- /dev/null +++ b/pkg/internal/common/sign_test.go @@ -0,0 +1,22 @@ +package common + +import ( + "testing" + + "github.com/joho/godotenv" + "github.com/stretchr/testify/assert" +) + +func SignAndValidateString(t *testing.T) { + err := godotenv.Load("../../.env") + assert.NoError(t, err) + + obj1 := "test string" + + obj1Signed, err := EVMSign(obj1) + assert.NoError(t, err) + + valid, err := ValidateEVMSignature(obj1Signed, obj1) + assert.NoError(t, err) + assert.Equal(t, false, valid) +} diff --git a/pkg/internal/unit21/entities_mapper.go b/pkg/internal/unit21/entities_mapper.go index dcfdf9dc..688e2fbb 100644 --- a/pkg/internal/unit21/entities_mapper.go +++ b/pkg/internal/unit21/entities_mapper.go @@ -4,11 +4,12 @@ import "github.com/String-xyz/string-api/pkg/model" func MapUserToEntity(user model.User) *u21entity { var userTagArr []string - if user.Tags != nil { - for key, value := range user.Tags { - userTagArr = append(userTagArr, key+":"+value) - } - } + // Temporarily disabled + // if user.Tags != nil { + // for key, value := range user.Tags { + // userTagArr = append(userTagArr, key+":"+value) + // } + // } jsonBody := &u21entity{ GeneralData: &general{ diff --git a/pkg/model/custom.go b/pkg/model/custom.go new file mode 100644 index 00000000..e2a4326c --- /dev/null +++ b/pkg/model/custom.go @@ -0,0 +1,26 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "errors" + + "github.com/lib/pq" +) + +type StringMap map[string]string +type StringArray pq.StringArray + +func (sm StringMap) Value() (driver.Value, error) { + return json.Marshal(sm) +} + +func (sm *StringMap) Scan(src interface{}) error { + switch t := src.(type) { + case string: + return json.Unmarshal([]byte(t), sm) + case []byte: + return json.Unmarshal([]byte(t), sm) + } + return errors.New("unknown type") +} diff --git a/pkg/model/entity.go b/pkg/model/entity.go index 55de9a94..37241b36 100644 --- a/pkg/model/entity.go +++ b/pkg/model/entity.go @@ -4,22 +4,20 @@ import ( "database/sql" "encoding/json" "time" - - "github.com/jmoiron/sqlx/types" ) // See STRING_USER in Migrations 0001 type User struct { - ID string `json:"id" db:"id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt" db:"deactivated_at"` - Type string `json:"type" db:"type"` - Status string `json:"status" db:"status"` - Tags map[string]string `json:"tags" db:"tags"` - FirstName string `json:"firstName" db:"first_name"` - MiddleName string `json:"middleName" db:"middle_name"` - LastName string `json:"lastName" db:"last_name"` + ID string `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + DeactivatedAt *time.Time `json:"deactivatedAt" db:"deactivated_at"` + Type string `json:"type" db:"type"` + Status string `json:"status" db:"status"` + Tags StringMap `json:"tags" db:"tags"` + FirstName string `json:"firstName" db:"first_name"` + MiddleName string `json:"middleName" db:"middle_name"` + LastName string `json:"lastName" db:"last_name"` } // See PLATFORM in Migrations 0001 @@ -63,17 +61,17 @@ type Asset struct { // See DEVICE in Migrations 0002 type Device struct { - ID string `json:"id" db:"id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - LastUsedAt time.Time `json:"lastUsedAt" db:"last_used_at"` - ValidatedAt time.Time `json:"validatedAt" db:"validated_at"` - DeactivatedAt time.Time `json:"deactivatedAt" db:"deactivated_at"` - Type string `json:"type" db:"type"` - Description string `json:"description" db:"description"` - Fingerprint string `json:"fingerprint" db:"fingerprint"` - IpAddresses types.JSONText `json:"ipAddresses" db:"ip_addresses"` - UserID string `json:"userId" db:"user_id"` + ID string `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + LastUsedAt time.Time `json:"lastUsedAt" db:"last_used_at"` + ValidatedAt time.Time `json:"validatedAt" db:"validated_at"` + DeactivatedAt time.Time `json:"deactivatedAt" db:"deactivated_at"` + Type string `json:"type" db:"type"` + Description string `json:"description" db:"description"` + Fingerprint string `json:"fingerprint" db:"fingerprint"` + IpAddresses StringArray `json:"ipAddresses" db:"ip_addresses"` + UserID string `json:"userId" db:"user_id"` } // See CONTACT in Migrations 0002 @@ -91,36 +89,36 @@ type Contact struct { // See LOCATION in Migrations 0002 type Location struct { - ID string `json:"id" db:"id"` - UserID string `json:"userId" db:"user_id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - Type string `json:"type" db:"type"` - Status string `json:"status" db:"status"` - Tags map[string]string `json:"tags" db:"tags"` - BuildingNumber string `json:"buildingNumber" db:"building_number"` - UnitNumber string `json:"unitNumber" db:"unit_number"` - StreetName string `json:"streetName" db:"street_name"` - City string `json:"city" db:"city"` - State string `json:"state" db:"state"` - PostalCode string `json:"postalCode" db:"postal_code"` - Country string `json:"country" db:"country"` + ID string `json:"id" db:"id"` + UserID string `json:"userId" db:"user_id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + Type string `json:"type" db:"type"` + Status string `json:"status" db:"status"` + Tags StringMap `json:"tags" db:"tags"` + BuildingNumber string `json:"buildingNumber" db:"building_number"` + UnitNumber string `json:"unitNumber" db:"unit_number"` + StreetName string `json:"streetName" db:"street_name"` + City string `json:"city" db:"city"` + State string `json:"state" db:"state"` + PostalCode string `json:"postalCode" db:"postal_code"` + Country string `json:"country" db:"country"` } // See INSTRUMENT in Migrations 0002 type Instrument struct { - ID string `json:"id" db:"id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt" db:"deactivated_at"` - Type string `json:"type" db:"type"` - Status string `json:"status" db:"status"` - Tags map[string]string `json:"tags" db:"tags"` - Network string `json:"network" db:"network"` - PublicKey string `json:"publicKey" db:"public_key"` - Last4 string `json:"last4" db:"last_4"` - UserID string `json:"userId" db:"user_id"` - LocationID string `json:"locationId" db:"location_id"` + ID string `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + DeactivatedAt *time.Time `json:"deactivatedAt" db:"deactivated_at"` + Type string `json:"type" db:"type"` + Status string `json:"status" db:"status"` + Tags StringMap `json:"tags" db:"tags"` + Network string `json:"network" db:"network"` + PublicKey string `json:"publicKey" db:"public_key"` + Last4 string `json:"last4" db:"last_4"` + UserID string `json:"userId" db:"user_id"` + LocationID sql.NullString `json:"locationId" db:"location_id"` } // See CONTACT_PLATFORM in Migrations 0003 @@ -158,28 +156,28 @@ type TxLeg struct { // See TRANSACTION in Migrations 0003 type Transaction struct { - ID string `json:"id" db:"id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - Type string `json:"type" db:"type"` - Status string `json:"status" db:"status"` - Tags types.JSONText `json:"tags" db:"tags"` // TODO: Fix this alongside Unit21 integration - DeviceID string `json:"deviceId" db:"device_id"` - IPAddress string `json:"ipAddress" db:"ip_address"` - PlatformID string `json:"platformId" db:"platform_id"` - TransactionHash string `json:"transactionHash" db:"transaction_hash"` - NetworkID string `json:"networkId" db:"network_id"` - NetworkFee string `json:"networkFee" db:"network_fee"` - ContractParams types.JSONText `json:"contractParameters" db:"contract_params"` - ContractFunc string `json:"contractFunc" db:"contract_func"` - TransactionAmount string `json:"transactionAmount" db:"transaction_amount"` - OriginTXLegID string `json:"originTXLegId" db:"origin_tx_leg_id"` - ReceiptTXLegID string `json:"receiptTXLegId" db:"receipt_tx_leg_id"` - ResponseTXLegID string `json:"responseTXLegId" db:"response_tx_leg_id"` - DestinationTXLegID string `json:"destinationTXLegId" db:"destination_tx_leg_id"` - ProcessingFee string `json:"processingFee" db:"processing_fee"` - ProcessingFeeAsset string `json:"processingFeeAsset" db:"processing_fee_asset"` - StringFee string `json:"stringFee" db:"string_fee"` + ID string `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + Type string `json:"type" db:"type"` + Status string `json:"status" db:"status"` + Tags StringMap `json:"tags" db:"tags"` // TODO: Fix this alongside Unit21 integration + DeviceID string `json:"deviceId" db:"device_id"` + IPAddress string `json:"ipAddress" db:"ip_address"` + PlatformID string `json:"platformId" db:"platform_id"` + TransactionHash string `json:"transactionHash" db:"transaction_hash"` + NetworkID string `json:"networkId" db:"network_id"` + NetworkFee string `json:"networkFee" db:"network_fee"` + ContractParams StringArray `json:"contractParameters" db:"contract_params"` + ContractFunc string `json:"contractFunc" db:"contract_func"` + TransactionAmount string `json:"transactionAmount" db:"transaction_amount"` + OriginTXLegID string `json:"originTXLegId" db:"origin_tx_leg_id"` + ReceiptTXLegID string `json:"receiptTXLegId" db:"receipt_tx_leg_id"` + ResponseTXLegID string `json:"responseTXLegId" db:"response_tx_leg_id"` + DestinationTXLegID string `json:"destinationTXLegId" db:"destination_tx_leg_id"` + ProcessingFee string `json:"processingFee" db:"processing_fee"` + ProcessingFeeAsset string `json:"processingFeeAsset" db:"processing_fee_asset"` + StringFee string `json:"stringFee" db:"string_fee"` } type AuthStrategy struct { diff --git a/pkg/model/request.go b/pkg/model/request.go index a0501ee9..c2b5b467 100644 --- a/pkg/model/request.go +++ b/pkg/model/request.go @@ -76,5 +76,25 @@ type UserPKLogin struct { Nonce string `json:"nonce"` } +type UserRequest struct { + WalletAddress string `json:"walletAddress"` + EmailAddress string `json:"emailAddress"` + FirstName string `json:"firstName"` + MiddleName string `json:"middleName"` + LastName string `json:"lastName"` + Signature string `json:"signature"` +} + +// Can be used for user, instrument, etc +type UpdateStatus struct { + Status *string `json:"status" db:"status"` +} + +type UpdateUserName struct { + FirstName string `json:"firstName" db:"first_name"` + MiddleName string `json:"middleName" db:"middle_name"` + LastName string `json:"lastName" db:"last_name"` +} + type EntityType string type AuthType string diff --git a/pkg/model/user.go b/pkg/model/user.go new file mode 100644 index 00000000..0281a95c --- /dev/null +++ b/pkg/model/user.go @@ -0,0 +1,5 @@ +package model + +type UserOnboardingStatus struct { + Status string `json:"status"` +} diff --git a/pkg/repository/contact.go b/pkg/repository/contact.go new file mode 100644 index 00000000..0fb0347d --- /dev/null +++ b/pkg/repository/contact.go @@ -0,0 +1,42 @@ +package repository + +import ( + "github.com/String-xyz/string-api/pkg/internal/common" + "github.com/String-xyz/string-api/pkg/model" + "github.com/jmoiron/sqlx" +) + +type Contact interface { + Transactable + Readable + Create(model.Contact) (model.Contact, error) + GetID(ID string) (model.Contact, error) + Update(ID string, updates any) error +} + +type contact[T any] struct { + base[T] +} + +func NewContact(db *sqlx.DB) Contact { + return &contact[model.Contact]{base[model.Contact]{store: db, table: "contact"}} +} + +func (c contact[T]) Create(insert model.Contact) (model.Contact, error) { + m := model.Contact{} + rows, err := c.store.NamedQuery(` + INSERT INTO contact (type, user_id, status) + VALUES(:type, :user_id, :status) RETURNING *`, insert) + if err != nil { + return m, common.StringError(err) + } + for rows.Next() { + err = rows.StructScan(&m) + if err != nil { + return m, common.StringError(err) + } + } + + defer rows.Close() + return m, nil +} diff --git a/pkg/repository/instrument.go b/pkg/repository/instrument.go index c7a2b32d..80a0cc80 100644 --- a/pkg/repository/instrument.go +++ b/pkg/repository/instrument.go @@ -1,6 +1,9 @@ package repository import ( + "database/sql" + "fmt" + "github.com/String-xyz/string-api/pkg/internal/common" "github.com/String-xyz/string-api/pkg/model" "github.com/jmoiron/sqlx" @@ -10,6 +13,7 @@ type Instrument interface { Transactable Create(model.Instrument) (model.Instrument, error) GetID(id string) (model.Instrument, error) + GetWallet(addr string) (model.Instrument, error) Update(ID string, updates any) error } @@ -24,8 +28,8 @@ func NewInstrument(db *sqlx.DB) Instrument { func (i instrument[T]) Create(insert model.Instrument) (model.Instrument, error) { m := model.Instrument{} rows, err := i.store.NamedQuery(` - INSERT INTO instrument (name) - VALUES(:name) RETURNING *`, insert) + INSERT INTO instrument (type, status, network, public_key, user_id) + VALUES(:type, :status, :network, :public_key, :user_id) RETURNING *`, insert) if err != nil { return m, common.StringError(err) } @@ -39,3 +43,12 @@ func (i instrument[T]) Create(insert model.Instrument) (model.Instrument, error) defer rows.Close() return m, nil } + +func (i instrument[T]) GetWallet(addr string) (model.Instrument, error) { + m := model.Instrument{} + err := i.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE public_key = $1", i.table), addr) + if err != nil && err == sql.ErrNoRows { + return m, common.StringError(ErrNotFound) + } + return m, nil +} diff --git a/pkg/repository/user.go b/pkg/repository/user.go index cb776e19..a0c714b7 100644 --- a/pkg/repository/user.go +++ b/pkg/repository/user.go @@ -26,8 +26,8 @@ func NewUser(db *sqlx.DB) User { func (u user[T]) Create(insert model.User) (model.User, error) { m := model.User{} rows, err := u.store.NamedQuery(` - INSERT INTO string_user (first_name, last_name, type, status) - VALUES(:first_name,:last_name, :type, :status) RETURNING *`, insert) + INSERT INTO string_user (type, status) + VALUES(:type, :status) RETURNING *`, insert) if err != nil { return m, common.StringError(err) } diff --git a/pkg/service/auth.go b/pkg/service/auth.go index d6d4aac1..1d66cb4e 100644 --- a/pkg/service/auth.go +++ b/pkg/service/auth.go @@ -8,7 +8,6 @@ import ( "time" "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/String-xyz/string-api/pkg/internal/unit21" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" "github.com/golang-jwt/jwt/v4" @@ -99,12 +98,12 @@ func (a auth) Register(m UserRegister) (JWT, error) { return JWT{}, common.StringError(err) } - // Share with Unit21 - entityRepo := unit21.NewEntity(a.userRepo, a.contactRepo) - _, err = entityRepo.Create(user) // Discard Unit21 ID - if err != nil { - return JWT{}, common.StringError(err) - } + // // Share with Unit21 + // entityRepo := unit21.NewEntity(a.userRepo, a.contactRepo) + // _, err = entityRepo.Create(user) // Discard Unit21 ID + // if err != nil { + // return JWT{}, common.StringError(err) + // } return a.GenerateJWT(user) } diff --git a/pkg/service/user.go b/pkg/service/user.go new file mode 100644 index 00000000..58f1d79c --- /dev/null +++ b/pkg/service/user.go @@ -0,0 +1,249 @@ +package service + +import ( + netMail "net/mail" + "os" + "strings" + "time" + + "github.com/String-xyz/string-api/pkg/internal/common" + "github.com/String-xyz/string-api/pkg/model" + "github.com/String-xyz/string-api/pkg/repository" + "github.com/pkg/errors" + "github.com/sendgrid/sendgrid-go" + "github.com/sendgrid/sendgrid-go/helpers/mail" +) + +type UserRequest = model.UserRequest + +type EmailVerification struct { + Timestamp int64 + Email string + UserID string +} + +type UserRepos struct { + User repository.User + Contact repository.Contact + Instrument repository.Instrument +} + +type User interface { + GetStatus(request UserRequest) (model.UserOnboardingStatus, error) // If wallet addr is associated with user, return current state of their onboarding + Create(request UserRequest) error // Create new user using wallet addr, optionally mark as validated if signature is provided + Sign(request UserRequest) error // Takes in a signed timestamp from user, validating their wallet + Authenticate(request UserRequest) error // Takes e-mail and wallet addr of user, validates email with twilio + ReceiveEmailAuthentication(encrypted string) error // Decrypts query and validates e-mail address of user + Name(request UserRequest) error // Takes name and wallet addr of user, associates name with wallet addr +} + +type user struct { + repos UserRepos +} + +func NewUser(repos UserRepos) User { + return &user{repos: repos} +} + +func (u user) GetStatus(request UserRequest) (model.UserOnboardingStatus, error) { + res := model.UserOnboardingStatus{Status: "not found"} + addr := request.WalletAddress + if addr == "" { + return res, common.StringError(errors.New("no wallet address provided")) + } + instrument, err := u.repos.Instrument.GetWallet(addr) + if err != nil { + return res, common.StringError(err) + } + associatedUser, err := u.repos.User.GetID(instrument.UserID) + if err != nil { + return res, common.StringError(err) + } + if associatedUser.Status != "" { + res.Status = associatedUser.Status + return res, nil + } + return res, common.StringError(errors.New("not found")) +} + +func (u user) Create(request UserRequest) error { + addr := request.WalletAddress + if addr == "" { + return common.StringError(errors.New("no wallet address provided")) + } + + // Make sure wallet does not already exist + // TODO: Revisit this logic. Parsing error string for "not found" is bad. + instrument, err := u.repos.Instrument.GetWallet(addr) + if err != nil && !strings.Contains(err.Error(), "not found") { // because we are wrapping error and care about its value + return common.StringError(err) + } else if err == nil && instrument.UserID != "" { + return common.StringError(errors.New("wallet already associated with user")) + } else if err == nil && instrument.PublicKey == addr { + return common.StringError(errors.New("wallet already exists")) + } + + // Make sure address is a wallet and not a smart contract + if !common.IsWallet(addr) { + return common.StringError(errors.New("address provided is not a valid wallet")) + } + + // Optionally verify signature + status := "unverified" + if request.Signature != "" { + valid, err := common.ValidateExternalEVMSignature(request.Signature, addr, addr) // they signed their own address. + // it's like writing your name on your hand and then xeroxing it + if err != nil { + return common.StringError(err) + } + if !valid { + return common.StringError(errors.New("signature invalid")) + } + status = "validated" + } + + // Initialize a new user + user := model.User{Type: "string-user", Status: "unverified"} // Validated status pertains to specific instrument + user, err = u.repos.User.Create(user) + if err != nil { + return common.StringError(err) + } + + // Create a new wallet instrument and associate it with the new user + instrument = model.Instrument{Type: "crypto-wallet", Status: status, Network: "EVM", PublicKey: addr, UserID: user.ID} + instrument, err = u.repos.Instrument.Create(instrument) + if err != nil { + return common.StringError(err) + } + return nil +} + +func (u user) Sign(request UserRequest) error { + addr := request.WalletAddress + // Make sure wallet exists + instrument, err := u.repos.Instrument.GetWallet(addr) + if err != nil { + return common.StringError(err) + } + // Make sure wallet is associated with a user + if instrument.UserID == "" { + return common.StringError(errors.New("wallet not associated with user")) + } + _, err = u.repos.User.GetID(instrument.UserID) // Don't need to update user, just verify they exist + if err != nil { + return common.StringError(err) + } + if instrument.Status == "validated" { + return common.StringError(errors.New("wallet already validated")) + } + + // @dev Uncomment these lines to sign payload without using front end + // signed, _ := common.EVMSign(addr) + // fmt.Printf("\n\nSECRET SIGNATURE=%+v", signed) + + // Verify signature + valid, err := common.ValidateExternalEVMSignature(request.Signature, addr, addr) + if err != nil { + return common.StringError(err) + } + if !valid { + return common.StringError(errors.New("signature invalid")) + } + validated := "validated" + status := model.UpdateStatus{Status: &validated} + err = u.repos.Instrument.Update(instrument.ID, status) + if err != nil { + return common.StringError(err) + } + return nil +} + +func (u user) Authenticate(request UserRequest) error { + addr := request.WalletAddress + email := request.EmailAddress + // TODO: Use go-playground/validator + if addr == "" || email == "" { + return common.StringError(errors.New("missing wallet/email")) + } + _, err := netMail.ParseAddress(email) + if err != nil { + return common.StringError(err) + } + + // Make sure wallet exists + instrument, err := u.repos.Instrument.GetWallet(addr) + if err != nil { + return common.StringError(err) + } + // Make sure wallet is associated with a user + if instrument.UserID == "" { + return common.StringError(errors.New("wallet not associated with user")) + } + // Encrypt required data to Base64 string and insert it in an email hyperlink + key := os.Getenv("STRING_ENCRYPTION_KEY") + code, err := common.Encrypt(EmailVerification{Timestamp: time.Now().Unix(), Email: email, UserID: instrument.UserID}, key) + if err != nil { + return common.StringError(err) + } + baseURL := "http://localhost:5555/" + env := os.Getenv("ENV") + if env == "dev" { + baseURL = "https://app.dev.string-api.xyz/" + } else if env == "local" { + baseURL = "http://localhost:5555/" + } else { + baseURL = "https://app.string-api.xyz/" + } + + from := mail.NewEmail("String Authentication", "auth@string.xyz") + subject := "String Email Authentication" + to := mail.NewEmail("New String User", email) + textContent := "Click the link below to complete your e-mail authentication!" + htmlContent := "
" + message := mail.NewSingleEmail(from, subject, to, textContent, htmlContent) + client := sendgrid.NewSendClient(os.Getenv("SENDGRID_API_KEY")) + _, err = client.Send(message) + if err != nil { + return common.StringError(err) + } + // success + return nil +} + +func (u user) ReceiveEmailAuthentication(encrypted string) error { + key := os.Getenv("STRING_ENCRYPTION_KEY") + received, err := common.Decrypt[EmailVerification](encrypted, key) + if err != nil { + return common.StringError(err) + } + + // Wait for up to 15 minutes, final timeout TBD + now := time.Now().Unix() + if now-received.Timestamp > (60 * 15) { + return common.StringError(errors.New("link expired")) + } + contact := model.Contact{UserID: received.UserID, Type: "email", Status: "validated", Data: received.Email} + contact, _ = u.repos.Contact.Create(contact) + return nil +} + +func (u user) Name(request UserRequest) error { + addr := request.WalletAddress + instrument, err := u.repos.Instrument.GetWallet(addr) + if err != nil { + return common.StringError(err) + } + if instrument.UserID == "" { + return common.StringError(errors.New("wallet not associated with user")) + } + user, err := u.repos.User.GetID(instrument.UserID) + if err != nil { + return common.StringError(err) + } + updates := model.UpdateUserName{FirstName: request.FirstName, MiddleName: request.MiddleName, LastName: request.LastName} + err = u.repos.User.Update(user.ID, updates) + if err != nil { + return common.StringError(err) + } + return nil +}