diff --git a/api/api.go b/api/api.go index 8ec6217a..1aecf1d7 100644 --- a/api/api.go +++ b/api/api.go @@ -96,7 +96,7 @@ func userRoute(services service.Services, e *echo.Echo) { } func loginRoute(services service.Services, e *echo.Echo) { - handler := handler.NewLogin(e, services.Auth) + handler := handler.NewLogin(e, services.Auth, services.Device) handler.RegisterRoutes(e.Group("/login"), middleware.APIKeyAuth(services.Auth)) } diff --git a/api/handler/login.go b/api/handler/login.go index 17098f97..43b415b3 100644 --- a/api/handler/login.go +++ b/api/handler/login.go @@ -3,10 +3,12 @@ package handler import ( b64 "encoding/base64" "net/http" + "os" "strings" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/service" + "github.com/golang-jwt/jwt" "github.com/labstack/echo/v4" ) @@ -23,11 +25,12 @@ type Login interface { type login struct { Service service.Auth + Device service.Device Group *echo.Group } -func NewLogin(route *echo.Echo, service service.Auth) Login { - return &login{service, nil} +func NewLogin(route *echo.Echo, service service.Auth, device service.Device) Login { + return &login{service, device, nil} } func (l login) NoncePayload(c echo.Context) error { @@ -78,12 +81,22 @@ func (l login) VerifySignature(c echo.Context) error { LogStringError(c, err, "login: verify signature") return BadRequestError(c, "Invalid Payload") } + + // Upsert IP address in user's device + var claims = &service.JWTClaims{} + _, _ = jwt.ParseWithClaims(resp.JWT.Token, claims, func(t *jwt.Token) (interface{}, error) { + return []byte(os.Getenv("JWT_SECRET_KEY")), nil + }) + ip := c.RealIP() + l.Device.UpsertDeviceIP(claims.DeviceId, ip) + // set auth cookies err = SetAuthCookies(c, resp.JWT) if err != nil { LogStringError(c, err, "login: unable to set auth cookies") return InternalError(c) } + return c.JSON(http.StatusOK, resp) } diff --git a/api/handler/transact.go b/api/handler/transact.go index 2a4c56c5..afcefcdf 100644 --- a/api/handler/transact.go +++ b/api/handler/transact.go @@ -38,7 +38,9 @@ func (t transaction) Transact(c echo.Context) error { } userId := c.Get("userId").(string) deviceId := c.Get("deviceId").(string) - res, err := t.Service.Execute(body, userId, deviceId) + ip := c.RealIP() + + res, err := t.Service.Execute(body, userId, deviceId, ip) if err != nil && (strings.Contains(err.Error(), "risk:") || strings.Contains(err.Error(), "payment:")) { LogStringError(c, err, "transact: execute") return Unprocessable(c) diff --git a/pkg/internal/common/util.go b/pkg/internal/common/util.go index 238cfc95..08c388a8 100644 --- a/pkg/internal/common/util.go +++ b/pkg/internal/common/util.go @@ -115,3 +115,12 @@ func BetterStringify(jsonBody any) (betterString string, err error) { return } + +func SliceContains(elems []string, v string) bool { + for _, s := range elems { + if v == s { + return true + } + } + return false +} diff --git a/pkg/internal/unit21/transaction.go b/pkg/internal/unit21/transaction.go index 1dc1cd62..75d268eb 100644 --- a/pkg/internal/unit21/transaction.go +++ b/pkg/internal/unit21/transaction.go @@ -17,9 +17,10 @@ type Transaction interface { } type TransactionRepos struct { - User repository.User - TxLeg repository.TxLeg - Asset repository.Asset + User repository.User + TxLeg repository.TxLeg + Asset repository.Asset + Device repository.Device } type transaction struct { @@ -37,12 +38,18 @@ func (t transaction) Evaluate(transaction model.Transaction) (pass bool, err err return false, common.StringError(err) } + digitalData, err := t.getEventDigitalData(transaction) + if err != nil { + log.Err(err).Msg("Failed to gather Unit21 digital data") + return false, common.StringError(err) + } + url := os.Getenv("UNIT21_RTR_URL") if url == "" { url = "https://rtr.sandbox2.unit21.com/evaluate" } - body, err := u21Post(url, mapToUnit21TransactionEvent(transaction, transactionData)) + body, err := u21Post(url, mapToUnit21TransactionEvent(transaction, transactionData, digitalData)) if err != nil { log.Err(err).Msg("Unit21 Transaction evaluate failed") return false, common.StringError(err) @@ -67,14 +74,19 @@ func (t transaction) Evaluate(transaction model.Transaction) (pass bool, err err func (t transaction) Create(transaction model.Transaction) (unit21Id string, err error) { transactionData, err := t.getTransactionData(transaction) - if err != nil { log.Err(err).Msg("Failed to gather Unit21 transaction source") return "", common.StringError(err) } + digitalData, err := t.getEventDigitalData(transaction) + if err != nil { + log.Err(err).Msg("Failed to gather Unit21 digital data") + return "", common.StringError(err) + } + url := "https://" + os.Getenv("UNIT21_ENV") + ".unit21.com/v1/events/create" - body, err := u21Post(url, mapToUnit21TransactionEvent(transaction, transactionData)) + body, err := u21Post(url, mapToUnit21TransactionEvent(transaction, transactionData, digitalData)) if err != nil { log.Err(err).Msg("Unit21 Transaction create failed") return "", common.StringError(err) @@ -98,9 +110,15 @@ func (t transaction) Update(transaction model.Transaction) (unit21Id string, err return "", common.StringError(err) } + digitalData, err := t.getEventDigitalData(transaction) + if err != nil { + log.Err(err).Msg("Failed to gather Unit21 digital data") + return "", common.StringError(err) + } + orgName := os.Getenv("UNIT21_ORG_NAME") url := "https://" + os.Getenv("UNIT21_ENV") + ".unit21.com/v1/" + orgName + "/events/" + transaction.ID + "/update" - body, err := u21Put(url, mapToUnit21TransactionEvent(transaction, transactionData)) + body, err := u21Put(url, mapToUnit21TransactionEvent(transaction, transactionData, digitalData)) if err != nil { log.Err(err).Msg("Unit21 Transaction create failed:") @@ -213,7 +231,26 @@ func (t transaction) getTransactionData(transaction model.Transaction) (txData t return } -func mapToUnit21TransactionEvent(transaction model.Transaction, transactionData transactionData) *u21Event { +func (t transaction) getEventDigitalData(transaction model.Transaction) (digitalData eventDigitalData, err error) { + if transaction.DeviceID == "" { + return + } + + device, err := t.repos.Device.GetById(transaction.DeviceID) + if err != nil { + log.Err(err).Msg("Failed to get transaction device") + err = common.StringError(err) + return + } + + digitalData = eventDigitalData{ + IPAddress: transaction.IPAddress, + ClientFingerprint: device.Fingerprint, + } + return +} + +func mapToUnit21TransactionEvent(transaction model.Transaction, transactionData transactionData, digitalData eventDigitalData) *u21Event { var transactionTagArr []string if transaction.Tags != nil { for key, value := range transaction.Tags { @@ -233,11 +270,9 @@ func mapToUnit21TransactionEvent(transaction model.Transaction, transactionData }, TransactionData: &transactionData, ActionData: nil, - DigitalData: &eventDigitalData{ - IPAddress: transaction.IPAddress, - }, - LocationData: nil, - CustomData: nil, + DigitalData: &digitalData, + LocationData: nil, + CustomData: nil, } return jsonBody diff --git a/pkg/internal/unit21/types.go b/pkg/internal/unit21/types.go index 072e2d9b..7bcf7888 100644 --- a/pkg/internal/unit21/types.go +++ b/pkg/internal/unit21/types.go @@ -170,7 +170,8 @@ type actionData struct { } type eventDigitalData struct { - IPAddress string `json:"ip_address,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + ClientFingerprint string `json:"client_fingerprint,omitempty"` } type eventCustomData struct { diff --git a/pkg/model/request.go b/pkg/model/request.go index f4701046..4fe3cec4 100644 --- a/pkg/model/request.go +++ b/pkg/model/request.go @@ -128,7 +128,8 @@ type NetworkUpdates struct { } type DeviceUpdates struct { - ValidatedAt *time.Time `json:"validatedAt" db:"validated_at"` + ValidatedAt *time.Time `json:"validatedAt" db:"validated_at"` + IpAddresses *pq.StringArray `json:"ipAddresses" db:"ip_addresses"` } type RefreshTokenPayload struct { diff --git a/pkg/repository/transaction.go b/pkg/repository/transaction.go index d2c257c8..71e89e48 100644 --- a/pkg/repository/transaction.go +++ b/pkg/repository/transaction.go @@ -25,8 +25,8 @@ func (t transaction[T]) Create(insert model.Transaction) (model.Transaction, err m := model.Transaction{} // TODO: Add platform_id once it becomes available rows, err := t.store.NamedQuery(` - INSERT INTO transaction (status, network_id, device_id, platform_id) - VALUES(:status, :network_id, :device_id, :platform_id) RETURNING id`, insert) + INSERT INTO transaction (status, network_id, device_id, platform_id, ip_address) + VALUES(:status, :network_id, :device_id, :platform_id, :ip_address) RETURNING id`, insert) if err != nil { return m, common.StringError(err) } diff --git a/pkg/service/device.go b/pkg/service/device.go index b4b5d87f..ad92126f 100644 --- a/pkg/service/device.go +++ b/pkg/service/device.go @@ -13,6 +13,7 @@ import ( type Device interface { VerifyDevice(encrypted string) error + UpsertDeviceIP(deviceId string, Ip string) (err error) CreateDeviceIfNeeded(userID, visitorID, requestID string) (model.Device, error) CreateUnknownDevice(userID string) (model.Device, error) InvalidateUnknownDevice(device model.Device) error @@ -27,30 +28,36 @@ func NewDevice(repos repository.Repositories, f Fingerprint) Device { return &device{repos, f} } -func (d device) createDevice(userID string, visitor FPVisitor, description string) (model.Device, error) { - addresses := pq.StringArray{} - if visitor.IPAddress.String != "" { - addresses = pq.StringArray{visitor.IPAddress.String} +func (d device) VerifyDevice(encrypted string) error { + key := os.Getenv("STRING_ENCRYPTION_KEY") + received, err := common.Decrypt[DeviceVerification](encrypted, key) + if err != nil { + return common.StringError(err) } - return d.repos.Device.Create(model.Device{ - UserID: userID, - Fingerprint: visitor.VisitorID, - Type: visitor.Type, - IpAddresses: addresses, - Description: description, - LastUsedAt: time.Now(), - }) + now := time.Now() + if now.Unix()-received.Timestamp > (60 * 15) { + return common.StringError(errors.New("link expired")) + } + err = d.repos.Device.Update(received.DeviceID, model.DeviceUpdates{ValidatedAt: &now}) + return err } -func (d device) CreateUnknownDevice(userID string) (model.Device, error) { - visitor := FPVisitor{ - VisitorID: "unknown", - Type: "unknown", - UserAgent: "unknown", +func (d device) UpsertDeviceIP(deviceId string, ip string) (err error) { + device, err := d.repos.Device.GetById(deviceId) + if err != nil { + return } - device, err := d.createDevice(userID, visitor, "an unknown device") - return device, common.StringError(err) + contains := common.SliceContains(device.IpAddresses, ip) + if !contains { + ipAddresses := append(device.IpAddresses, ip) + updates := &model.DeviceUpdates{IpAddresses: &ipAddresses} + err = d.repos.Device.Update(deviceId, updates) + if err != nil { + return + } + } + return } func (d device) CreateDeviceIfNeeded(userID, visitorID, requestID string) (model.Device, error) { @@ -88,19 +95,39 @@ func (d device) CreateDeviceIfNeeded(userID, visitorID, requestID string) (model } } -func (d device) VerifyDevice(encrypted string) error { - key := os.Getenv("STRING_ENCRYPTION_KEY") - received, err := common.Decrypt[DeviceVerification](encrypted, key) - if err != nil { - return common.StringError(err) +func (d device) CreateUnknownDevice(userID string) (model.Device, error) { + visitor := FPVisitor{ + VisitorID: "unknown", + Type: "unknown", + UserAgent: "unknown", } + device, err := d.createDevice(userID, visitor, "an unknown device") + return device, common.StringError(err) +} - now := time.Now() - if now.Unix()-received.Timestamp > (60 * 15) { - return common.StringError(errors.New("link expired")) +func (d device) InvalidateUnknownDevice(device model.Device) error { + if device.Fingerprint != "unknown" { + return nil // only unknown devices can be invalidated } - err = d.repos.Device.Update(received.DeviceID, model.DeviceUpdates{ValidatedAt: &now}) - return err + + device.ValidatedAt = &time.Time{} // Zero time to set it to nil + return d.repos.Device.Update(device.ID, device) +} + +func (d device) createDevice(userID string, visitor FPVisitor, description string) (model.Device, error) { + addresses := pq.StringArray{} + if visitor.IPAddress.String != "" { + addresses = pq.StringArray{visitor.IPAddress.String} + } + + return d.repos.Device.Create(model.Device{ + UserID: userID, + Fingerprint: visitor.VisitorID, + Type: visitor.Type, + IpAddresses: addresses, + Description: description, + LastUsedAt: time.Now(), + }) } func (d device) getOrCreateUnknownDevice(userId, visitorId string) (model.Device, error) { @@ -123,12 +150,3 @@ func (d device) getOrCreateUnknownDevice(userId, visitorId string) (model.Device func isDeviceValidated(device model.Device) bool { return device.ValidatedAt != nil && !device.ValidatedAt.IsZero() } - -func (d device) InvalidateUnknownDevice(device model.Device) error { - if device.Fingerprint != "unknown" { - return nil // only unknown devices can be invalidated - } - - device.ValidatedAt = &time.Time{} // Zero time to set it to nil - return d.repos.Device.Update(device.ID, device) -} diff --git a/pkg/service/transaction.go b/pkg/service/transaction.go index c63cb90c..48dc3bb2 100644 --- a/pkg/service/transaction.go +++ b/pkg/service/transaction.go @@ -21,7 +21,7 @@ import ( type Transaction interface { Quote(d model.TransactionRequest) (model.ExecutionRequest, error) - Execute(e model.ExecutionRequest, userId string, deviceId string) (model.TransactionReceipt, error) + Execute(e model.ExecutionRequest, userId string, deviceId string, ip string) (model.TransactionReceipt, error) } type TransactionRepos struct { @@ -57,6 +57,7 @@ func NewTransaction(repos repository.Repositories, redis store.RedisStore, unit2 type transactionProcessingData struct { userId *string deviceId *string + ip *string executor *Executor processingFeeAsset *model.Asset transactionModel *model.Transaction @@ -106,10 +107,9 @@ func (t transaction) Quote(d model.TransactionRequest) (model.ExecutionRequest, return res, nil } -func (t transaction) Execute(e model.ExecutionRequest, userId string, deviceId string) (res model.TransactionReceipt, err error) { +func (t transaction) Execute(e model.ExecutionRequest, userId string, deviceId string, ip string) (res model.TransactionReceipt, err error) { t.getStringInstrumentsAndUserId() - - p := transactionProcessingData{executionRequest: &e, userId: &userId, deviceId: &deviceId} + p := transactionProcessingData{executionRequest: &e, userId: &userId, deviceId: &deviceId, ip: &ip} // Pre-flight transaction setup p, err = t.transactionSetup(p) @@ -275,11 +275,11 @@ func (t transaction) transactionSetup(p transactionProcessingData) (transactionP } // Create new Tx in repository, populate it with known info - transactionModel, err := t.repos.Transaction.Create(model.Transaction{Status: "Created", NetworkID: chain.UUID, DeviceID: *p.deviceId, PlatformID: t.ids.StringPlatformId}) - p.transactionModel = &transactionModel + transactionModel, err := t.repos.Transaction.Create(model.Transaction{Status: "Created", NetworkID: chain.UUID, DeviceID: *p.deviceId, IPAddress: *p.ip, PlatformID: t.ids.StringPlatformId}) if err != nil { return p, common.StringError(err) } + p.transactionModel = &transactionModel updateDB := &model.TransactionUpdates{} processingFeeAsset, err := t.populateInitialTxModelData(*p.executionRequest, updateDB) diff --git a/pkg/service/unit21.go b/pkg/service/unit21.go index 01c3d831..a4e4577c 100644 --- a/pkg/service/unit21.go +++ b/pkg/service/unit21.go @@ -18,7 +18,7 @@ func NewUnit21(repos repository.Repositories) Unit21 { entity := unit21.NewEntity(entityRepos) instrumentRepos := unit21.InstrumentRepos{User: repos.User, Device: repos.Device, Location: repos.Location} instrument := unit21.NewInstrument(instrumentRepos, action) - transactionRepos := unit21.TransactionRepos{User: repos.User, TxLeg: repos.TxLeg, Asset: repos.Asset} + transactionRepos := unit21.TransactionRepos{User: repos.User, TxLeg: repos.TxLeg, Asset: repos.Asset, Device: repos.Device} transaction := unit21.NewTransaction(transactionRepos) return Unit21{ Action: action,