diff --git a/backend/go.mod b/backend/go.mod index 1eeeb0f44..f12feed71 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -24,6 +24,7 @@ require ( github.com/go-redis/redis/v8 v8.11.5 github.com/gobitfly/eth-rewards v0.1.2-0.20230403064929-411ddc40a5f7 github.com/gobitfly/eth.store v0.0.0-20240312111708-b43f13990280 + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang/protobuf v1.5.3 github.com/gomodule/redigo v1.9.2 @@ -70,6 +71,7 @@ require ( golang.org/x/text v0.14.0 golang.org/x/tools v0.18.0 google.golang.org/api v0.164.0 + google.golang.org/appengine v1.6.8 google.golang.org/protobuf v1.32.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -233,7 +235,6 @@ require ( golang.org/x/sys v0.17.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect - google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20240125205218-1f4bbc51befe // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240125205218-1f4bbc51befe // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240205150955-31a09d347014 // indirect diff --git a/backend/go.sum b/backend/go.sum index d6357316d..97c31bc89 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -305,6 +305,8 @@ github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14j github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= diff --git a/backend/pkg/api/data_access/app.go b/backend/pkg/api/data_access/app.go new file mode 100644 index 000000000..e3c29d621 --- /dev/null +++ b/backend/pkg/api/data_access/app.go @@ -0,0 +1,107 @@ +package dataaccess + +import ( + "database/sql" + "fmt" + "time" + + t "github.com/gobitfly/beaconchain/pkg/api/types" + "github.com/gobitfly/beaconchain/pkg/commons/utils" + "github.com/gobitfly/beaconchain/pkg/userservice" + "github.com/pkg/errors" +) + +type AppRepository interface { + GetUserIdByRefreshToken(claimUserID, claimAppID, claimDeviceID uint64, hashedRefreshToken string) (uint64, error) + MigrateMobileSession(oldHashedRefreshToken, newHashedRefreshToken, deviceID, deviceName string) error + AddUserDevice(userID uint64, hashedRefreshToken string, deviceID, deviceName string, appID uint64) error + GetAppDataFromRedirectUri(callback string) (*t.OAuthAppData, error) + AddMobileNotificationToken(userID uint64, deviceID, notifyToken string) error + GetAppSubscriptionCount(userID uint64) (uint64, error) + AddMobilePurchase(tx *sql.Tx, userID uint64, paymentDetails t.MobileSubscription, verifyResponse *userservice.VerifyResponse, extSubscriptionId string) error +} + +// GetUserIdByRefreshToken basically used to confirm the claimed user id with the refresh token. Returns the userId if successful +func (d *DataAccessService) GetUserIdByRefreshToken(claimUserID, claimAppID, claimDeviceID uint64, hashedRefreshToken string) (uint64, error) { + if hashedRefreshToken == "" { // sanity + return 0, errors.New("empty refresh token") + } + var userID uint64 + err := d.userWriter.Get(&userID, + `SELECT user_id FROM users_devices WHERE user_id = $1 AND + refresh_token = $2 AND app_id = $3 AND id = $4 AND active = true`, claimUserID, hashedRefreshToken, claimAppID, claimDeviceID) + if errors.Is(err, sql.ErrNoRows) { + return userID, fmt.Errorf("%w: user not found via refresh token", ErrNotFound) + } + return userID, err +} + +func (d *DataAccessService) MigrateMobileSession(oldHashedRefreshToken, newHashedRefreshToken, deviceID, deviceName string) error { + result, err := d.userWriter.Exec("UPDATE users_devices SET refresh_token = $2, device_identifier = $3, device_name = $4 WHERE refresh_token = $1", oldHashedRefreshToken, newHashedRefreshToken, deviceID, deviceName) + if err != nil { + return errors.Wrap(err, "Error updating refresh token") + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return errors.Wrap(err, "Error getting rows affected") + } + + if rowsAffected != 1 { + return errors.New(fmt.Sprintf("illegal number of rows affected, expected 1 got %d", rowsAffected)) + } + + return err +} + +func (d *DataAccessService) GetAppDataFromRedirectUri(callback string) (*t.OAuthAppData, error) { + data := t.OAuthAppData{} + err := d.userWriter.Get(&data, "SELECT id, app_name, redirect_uri, active, owner_id FROM oauth_apps WHERE active = true AND redirect_uri = $1", callback) + return &data, err +} + +func (d *DataAccessService) AddUserDevice(userID uint64, hashedRefreshToken string, deviceID, deviceName string, appID uint64) error { + _, err := d.userWriter.Exec("INSERT INTO users_devices (user_id, refresh_token, device_identifier, device_name, app_id, created_ts) VALUES($1, $2, $3, $4, $5, 'NOW()') ON CONFLICT DO NOTHING", + userID, hashedRefreshToken, deviceID, deviceName, appID, + ) + return err +} + +func (d *DataAccessService) AddMobileNotificationToken(userID uint64, deviceID, notifyToken string) error { + _, err := d.userWriter.Exec("UPDATE users_devices SET notification_token = $1 WHERE user_id = $2 AND device_identifier = $3;", + notifyToken, userID, deviceID, + ) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: user mobile device not found", ErrNotFound) + } + return err +} + +func (d *DataAccessService) GetAppSubscriptionCount(userID uint64) (uint64, error) { + var count uint64 + err := d.userReader.Get(&count, "SELECT COUNT(receipt) FROM users_app_subscriptions WHERE user_id = $1", userID) + return count, err +} + +func (d *DataAccessService) AddMobilePurchase(tx *sql.Tx, userID uint64, paymentDetails t.MobileSubscription, verifyResponse *userservice.VerifyResponse, extSubscriptionId string) error { + now := time.Now() + nowTs := now.Unix() + receiptHash := utils.HashAndEncode(verifyResponse.Receipt) + + query := `INSERT INTO users_app_subscriptions + (user_id, product_id, price_micros, currency, created_at, updated_at, validate_remotely, active, store, receipt, expires_at, reject_reason, receipt_hash, subscription_id) + VALUES($1, $2, $3, $4, TO_TIMESTAMP($5), TO_TIMESTAMP($6), $7, $8, $9, $10, TO_TIMESTAMP($11), $12, $13, $14) + ON CONFLICT(receipt_hash) DO UPDATE SET product_id = $2, active = $7, updated_at = TO_TIMESTAMP($5);` + var err error + if tx == nil { + _, err = d.userWriter.Exec(query, + userID, verifyResponse.ProductID, paymentDetails.PriceMicros, paymentDetails.Currency, nowTs, nowTs, verifyResponse.Valid, verifyResponse.Valid, paymentDetails.Transaction.Type, verifyResponse.Receipt, verifyResponse.ExpirationDate, verifyResponse.RejectReason, receiptHash, extSubscriptionId, + ) + } else { + _, err = tx.Exec(query, + userID, verifyResponse.ProductID, paymentDetails.PriceMicros, paymentDetails.Currency, nowTs, nowTs, verifyResponse.Valid, verifyResponse.Valid, paymentDetails.Transaction.Type, verifyResponse.Receipt, verifyResponse.ExpirationDate, verifyResponse.RejectReason, receiptHash, extSubscriptionId, + ) + } + + return err +} diff --git a/backend/pkg/api/data_access/data_access.go b/backend/pkg/api/data_access/data_access.go index 8e101e0ad..7441d860e 100644 --- a/backend/pkg/api/data_access/data_access.go +++ b/backend/pkg/api/data_access/data_access.go @@ -23,12 +23,14 @@ type DataAccessor interface { SearchRepository NetworkRepository UserRepository + AppRepository NotificationsRepository AdminRepository BlockRepository Close() + GetLatestFinalizedEpoch() (uint64, error) GetLatestSlot() (uint64, error) GetLatestBlock() (uint64, error) GetBlockHeightAt(slot uint64) (uint64, error) diff --git a/backend/pkg/api/data_access/dummy.go b/backend/pkg/api/data_access/dummy.go index 15676b5a6..fd906d816 100644 --- a/backend/pkg/api/data_access/dummy.go +++ b/backend/pkg/api/data_access/dummy.go @@ -2,6 +2,7 @@ package dataaccess import ( "context" + "database/sql" "fmt" "math/rand/v2" "reflect" @@ -10,7 +11,9 @@ import ( "github.com/go-faker/faker/v4" "github.com/go-faker/faker/v4/pkg/options" "github.com/gobitfly/beaconchain/pkg/api/enums" + "github.com/gobitfly/beaconchain/pkg/api/types" t "github.com/gobitfly/beaconchain/pkg/api/types" + "github.com/gobitfly/beaconchain/pkg/userservice" "github.com/shopspring/decimal" ) @@ -63,6 +66,12 @@ func (d *DummyService) GetLatestSlot() (uint64, error) { return r, err } +func (d *DummyService) GetLatestFinalizedEpoch() (uint64, error) { + r := uint64(0) + err := commonFakeData(&r) + return r, err +} + func (d *DummyService) GetLatestBlock() (uint64, error) { r := uint64(0) err := commonFakeData(&r) @@ -674,12 +683,40 @@ func (d *DummyService) RemoveAdConfiguration(ctx context.Context, key string) er return nil } -func (d *DummyService) GetBlock(ctx context.Context, chainId, block uint64) (*t.BlockSummary, error) { - r := t.BlockSummary{} +func (d *DummyService) GetUserIdByRefreshToken(claimUserID, claimAppID, claimDeviceID uint64, hashedRefreshToken string) (uint64, error) { + r := uint64(0) + err := commonFakeData(&r) + return r, err +} + +func (d *DummyService) MigrateMobileSession(oldHashedRefreshToken, newHashedRefreshToken, deviceID, deviceName string) error { + return nil +} + +func (d *DummyService) GetAppDataFromRedirectUri(callback string) (*t.OAuthAppData, error) { + r := t.OAuthAppData{} err := commonFakeData(&r) return &r, err } +func (d *DummyService) AddUserDevice(userID uint64, hashedRefreshToken string, deviceID, deviceName string, appID uint64) error { + return nil +} + +func (d *DummyService) AddMobileNotificationToken(userID uint64, deviceID, notifyToken string) error { + return nil +} + +func (d *DummyService) GetAppSubscriptionCount(userID uint64) (uint64, error) { + r := uint64(0) + err := commonFakeData(&r) + return r, err +} + +func (d *DummyService) AddMobilePurchase(tx *sql.Tx, userID uint64, paymentDetails types.MobileSubscription, verifyResponse *userservice.VerifyResponse, extSubscriptionId string) error { + return nil +} + func (d *DummyService) GetBlockOverview(ctx context.Context, chainId, block uint64) (*t.BlockOverview, error) { r := t.BlockOverview{} err := commonFakeData(&r) @@ -692,6 +729,12 @@ func (d *DummyService) GetBlockTransactions(ctx context.Context, chainId, block return r, err } +func (d *DummyService) GetBlock(ctx context.Context, chainId, block uint64) (*t.BlockSummary, error) { + r := t.BlockSummary{} + err := commonFakeData(&r) + return &r, err +} + func (d *DummyService) GetBlockVotes(ctx context.Context, chainId, block uint64) ([]t.BlockVoteTableRow, error) { r := []t.BlockVoteTableRow{} err := commonFakeData(&r) diff --git a/backend/pkg/api/data_access/header.go b/backend/pkg/api/data_access/header.go index 9363483bb..5658c9157 100644 --- a/backend/pkg/api/data_access/header.go +++ b/backend/pkg/api/data_access/header.go @@ -11,6 +11,11 @@ func (d *DataAccessService) GetLatestSlot() (uint64, error) { return latestSlot, nil } +func (d *DataAccessService) GetLatestFinalizedEpoch() (uint64, error) { + finalizedEpoch := cache.LatestFinalizedEpoch.Get() + return finalizedEpoch, nil +} + func (d *DataAccessService) GetLatestBlock() (uint64, error) { // @DATA-ACCESS implement return d.dummy.GetLatestBlock() diff --git a/backend/pkg/api/data_access/user.go b/backend/pkg/api/data_access/user.go index 712ad7d34..f79844ba2 100644 --- a/backend/pkg/api/data_access/user.go +++ b/backend/pkg/api/data_access/user.go @@ -160,6 +160,8 @@ func (d *DataAccessService) GetUserInfo(ctx context.Context, userId uint64) (*t. return nil, fmt.Errorf("error getting userEmail: %w", err) } + userInfo.Email = utils.CensorEmail(userInfo.Email) + err = d.userReader.SelectContext(ctx, &userInfo.ApiKeys, `SELECT api_key FROM api_keys WHERE user_id = $1`, userId) if err != nil && err != sql.ErrNoRows { return nil, fmt.Errorf("error getting userApiKeys: %w", err) diff --git a/backend/pkg/api/handlers/auth.go b/backend/pkg/api/handlers/auth.go index bb798ec0e..7a3bd9096 100644 --- a/backend/pkg/api/handlers/auth.go +++ b/backend/pkg/api/handlers/auth.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "html" "net/http" "strconv" "strings" @@ -13,8 +14,9 @@ import ( "github.com/gobitfly/beaconchain/pkg/api/types" "github.com/gobitfly/beaconchain/pkg/commons/log" "github.com/gobitfly/beaconchain/pkg/commons/mail" - commontsTypes "github.com/gobitfly/beaconchain/pkg/commons/types" + commonTypes "github.com/gobitfly/beaconchain/pkg/commons/types" "github.com/gobitfly/beaconchain/pkg/commons/utils" + "github.com/gobitfly/beaconchain/pkg/userservice" "github.com/gorilla/mux" "golang.org/x/crypto/bcrypt" ) @@ -24,6 +26,7 @@ const ( userIdKey = "user_id" subscriptionKey = "subscription" userGroupKey = "user_group" + mobileAuthKey = "mobile_auth" ) const authConfirmEmailRateLimit = time.Minute * 2 @@ -33,6 +36,8 @@ type ctxKet string const ctxUserIdKey ctxKet = "user_id" +var errBadCredentials = newUnauthorizedErr("invalid email or password") + func (h *HandlerService) getUserBySession(r *http.Request) (types.UserCredentialInfo, error) { authenticated := h.scs.GetBool(r.Context(), authenticatedKey) if !authenticated { @@ -80,7 +85,7 @@ Best regards, %[1]s `, utils.Config.Frontend.SiteDomain, confirmationHash) - err = mail.SendTextMail(email, subject, msg, []commontsTypes.EmailAttachment{}) + err = mail.SendTextMail(email, subject, msg, []commonTypes.EmailAttachment{}) if err != nil { return errors.New("error sending confirmation email, try again later") } @@ -255,7 +260,6 @@ func (h *HandlerService) InternalPostLogin(w http.ResponseWriter, r *http.Reques return } - badCredentialsErr := newUnauthorizedErr("invalid email or password") // fetch user userId, err := h.dai.GetUserByEmail(r.Context(), email) if err != nil { @@ -265,7 +269,7 @@ func (h *HandlerService) InternalPostLogin(w http.ResponseWriter, r *http.Reques user, err := h.dai.GetUserCredentialInfo(r.Context(), userId) if err != nil { if errors.Is(err, dataaccess.ErrNotFound) { - err = badCredentialsErr + err = errBadCredentials } handleErr(w, err) return @@ -278,7 +282,7 @@ func (h *HandlerService) InternalPostLogin(w http.ResponseWriter, r *http.Reques // validate password err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) if err != nil { - handleErr(w, badCredentialsErr) + handleErr(w, errBadCredentials) return } @@ -297,6 +301,251 @@ func (h *HandlerService) InternalPostLogin(w http.ResponseWriter, r *http.Reques returnOk(w, nil) } +// Can be used to login on mobile, requires an authenticated session +// Response must conform to OAuth spec +func (h *HandlerService) InternalPostMobileAuthorize(w http.ResponseWriter, r *http.Request) { + req := struct { + DeviceIDAndName string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + State string `json:"state"` + }{} + + // Retrieve parameters from GET request + req.DeviceIDAndName = r.URL.Query().Get("client_id") + req.RedirectURI = r.URL.Query().Get("redirect_uri") + req.State = r.URL.Query().Get("state") + + // To be compliant with OAuth 2 Spec, we include client_name in client_id instead of adding an additional param + // Split req.DeviceID on ":", first one is the client id and second one the client name + deviceIDParts := strings.Split(req.DeviceIDAndName, ":") + var clientID, clientName string + if len(deviceIDParts) != 2 { + clientID = req.DeviceIDAndName + clientName = "Unknown" + } else { + clientID = deviceIDParts[0] + clientName = deviceIDParts[1] + } + + state := "" + if req.State != "" { + state = "&state=" + req.State + } + + // check if user has a session + userInfo, err := h.getUserBySession(r) + if err != nil { + callback := req.RedirectURI + "?error=invalid_request&error_description=unauthorized_client" + state + http.Redirect(w, r, callback, http.StatusSeeOther) + return + } + + // check if oauth app exists to validate whether redirect uri is valid + appInfo, err := h.dai.GetAppDataFromRedirectUri(req.RedirectURI) + if err != nil { + callback := req.RedirectURI + "?error=invalid_request&error_description=missing_redirect_uri" + state + http.Redirect(w, r, callback, http.StatusSeeOther) + return + } + + // renew session and pass to callback + err = h.scs.RenewToken(r.Context()) + if err != nil { + callback := req.RedirectURI + "?error=invalid_request&error_description=server_error" + state + http.Redirect(w, r, callback, http.StatusSeeOther) + return + } + session := h.scs.Token(r.Context()) + + sanitizedDeviceName := html.EscapeString(clientName) + err = h.dai.AddUserDevice(userInfo.Id, utils.HashAndEncode(session+session), clientID, sanitizedDeviceName, appInfo.ID) + if err != nil { + log.Warnf("Error adding user device: %v", err) + callback := req.RedirectURI + "?error=invalid_request&error_description=server_error" + state + http.Redirect(w, r, callback, http.StatusSeeOther) + return + } + + // pass via redirect to app oauth callback handler + callback := req.RedirectURI + "?access_token=" + session + "&token_type=bearer" + state // prefixed session + http.Redirect(w, r, callback, http.StatusFound) +} + +// Abstract: One time Transitions old v1 app sessions to new v2 sessions so users stay signed in +// Can be used to exchange a legacy mobile auth access_token & refresh_token pair for a session +// Refresh token is consumed and can no longer be used after this +func (h *HandlerService) InternalPostMobileEquivalentExchange(w http.ResponseWriter, r *http.Request) { + var v validationError + req := struct { + DeviceName string `json:"client_name"` + RefreshToken string `json:"refresh_token"` + DeviceID string `json:"client_id"` + }{} + if err := v.checkBody(&req, r); err != nil { + handleErr(w, err) + return + } + if v.hasErrors() { + handleErr(w, v) + return + } + + // get user id by refresh token + userID, refreshTokenHashed, err := h.getTokenByRefresh(r, req.RefreshToken) + if err != nil { + handleErr(w, err) + return + } + + // Get user info + user, err := h.dai.GetUserCredentialInfo(r.Context(), userID) + if err != nil { + if errors.Is(err, dataaccess.ErrNotFound) { + err = errBadCredentials + } + handleErr(w, err) + return + } + if !user.EmailConfirmed { + handleErr(w, newUnauthorizedErr("email not confirmed")) + return + } + + // create new session + err = h.scs.RenewToken(r.Context()) + if err != nil { + handleErr(w, errors.New("error creating session")) + return + } + session := h.scs.Token(r.Context()) + + // invalidate old refresh token and replace with hashed session id + sanitizedDeviceName := html.EscapeString(req.DeviceName) + err = h.dai.MigrateMobileSession(refreshTokenHashed, utils.HashAndEncode(session+session), req.DeviceID, sanitizedDeviceName) // salted with session + if err != nil { + handleErr(w, err) + return + } + + // set fields of session after invalidating refresh token + h.scs.Put(r.Context(), authenticatedKey, true) + h.scs.Put(r.Context(), userIdKey, userID) + h.scs.Put(r.Context(), subscriptionKey, user.ProductId) + h.scs.Put(r.Context(), userGroupKey, user.UserGroup) + h.scs.Put(r.Context(), mobileAuthKey, true) + + returnOk(w, struct { + Session string + }{ + Session: session, + }) +} + +func (h *HandlerService) InternalPostUsersMeNotificationSettingsPairedDevicesToken(w http.ResponseWriter, r *http.Request) { + deviceID := mux.Vars(r)["client_id"] + var v validationError + req := struct { + Token string `json:"token"` + }{} + if err := v.checkBody(&req, r); err != nil { + handleErr(w, err) + return + } + if v.hasErrors() { + handleErr(w, v) + return + } + + user, err := h.getUserBySession(r) + if err != nil { + handleErr(w, err) + return + } + + err = h.dai.AddMobileNotificationToken(user.Id, deviceID, req.Token) + if err != nil { + handleErr(w, err) + return + } + + returnOk(w, nil) +} + +const USER_SUBSCRIPTION_LIMIT = 8 + +func (h *HandlerService) InternalHandleMobilePurchase(w http.ResponseWriter, r *http.Request) { + var v validationError + req := types.MobileSubscription{} + if err := v.checkBody(&req, r); err != nil { + handleErr(w, err) + return + } + if v.hasErrors() { + handleErr(w, v) + return + } + + user, err := h.getUserBySession(r) + if err != nil { + handleErr(w, err) + return + } + + if req.ProductIDUnverified == "plankton" { + handleErr(w, newForbiddenErr("plankton subscription has been discontinued")) + return + } + + // Only allow ios and android purchases to be registered via this endpoint + if req.Transaction.Type != "ios-appstore" && req.Transaction.Type != "android-playstore" { + handleErr(w, newForbiddenErr("only ios-appstore and android-playstore purchases are allowed")) + return + } + + subscriptionCount, err := h.dai.GetAppSubscriptionCount(user.Id) + if err != nil { + handleErr(w, err) + return + } + if subscriptionCount >= USER_SUBSCRIPTION_LIMIT { + handleErr(w, newForbiddenErr("user has reached the subscription limit")) + return + } + + // Verify subscription with apple/google + verifyPackage := &commonTypes.PremiumData{ + ID: 0, + Receipt: req.Transaction.Receipt, + Store: req.Transaction.Type, + Active: false, + ProductID: req.ProductIDUnverified, + ExpiresAt: time.Now(), + } + + validationResult, err := userservice.VerifyReceipt(nil, nil, verifyPackage) + if err != nil { + log.Warn(err, "could not verify receipt %v", 0, map[string]interface{}{"receipt": verifyPackage.Receipt}) + if errors.Is(err, userservice.ErrClientInit) { + log.Error(err, "Apple or Google client is NOT initialized. Did you provide their configuration?", 0, nil) + handleErr(w, err) + return + } + } + + err = h.dai.AddMobilePurchase(nil, user.Id, req, validationResult, "") + if err != nil { + handleErr(w, err) + return + } + + if !validationResult.Valid { + handleErr(w, newForbiddenErr("receipt is not valid")) + return + } + + returnOk(w, nil) +} + func (h *HandlerService) InternalPostLogout(w http.ResponseWriter, r *http.Request) { err := h.scs.Destroy(r.Context()) if err != nil { diff --git a/backend/pkg/api/handlers/backward_compat.go b/backend/pkg/api/handlers/backward_compat.go new file mode 100644 index 000000000..f02161a00 --- /dev/null +++ b/backend/pkg/api/handlers/backward_compat.go @@ -0,0 +1,116 @@ +package handlers + +import ( + "database/sql" + "encoding/hex" + "fmt" + "net/http" + "strings" + + "github.com/pkg/errors" + + dataaccess "github.com/gobitfly/beaconchain/pkg/api/data_access" + "github.com/gobitfly/beaconchain/pkg/commons/log" + "github.com/gobitfly/beaconchain/pkg/commons/utils" + "github.com/golang-jwt/jwt" +) + +var signingMethod = jwt.SigningMethodHS256 + +type CustomClaims struct { + UserID uint64 `json:"userID"` + AppID uint64 `json:"appID"` + DeviceID uint64 `json:"deviceID"` + Package string `json:"package"` + Theme string `json:"theme"` + jwt.StandardClaims +} + +func (h *HandlerService) getTokenByRefresh(r *http.Request, refreshToken string) (uint64, string, error) { + accessToken := r.Header.Get("Authorization") + + // hash refreshtoken + refreshTokenHashed := utils.HashAndEncode(refreshToken) + + // Extract userId from JWT. Note that this is just an unvalidated claim! + // Do not use userIDClaim as userID until confirmed by refreshToken validation + unsafeClaims, err := UnsafeGetClaims(accessToken) + if err != nil { + log.Warnf("Error getting claims from access token: %v", err) + return 0, "", newUnauthorizedErr("invalid token") + } + + log.Infof("refresh token: %v, claims: %v, hashed refresh: %v", refreshToken, unsafeClaims, refreshTokenHashed) + + // confirm all claims via db lookup and refreshtoken check + userID, err := h.dai.GetUserIdByRefreshToken(unsafeClaims.UserID, unsafeClaims.AppID, unsafeClaims.DeviceID, refreshTokenHashed) + if err != nil { + if err == sql.ErrNoRows { + return 0, "", dataaccess.ErrNotFound + } + return 0, "", errors.Wrap(err, "Error getting user by refresh token") + } + + return userID, refreshTokenHashed, nil +} + +// UnsafeGetClaims this method returns the userID of a given jwt token WITHOUT VALIDATION +// DO NOT USE THIS METHOD AS RELIABLE SOURCE FOR USERID +func UnsafeGetClaims(tokenString string) (*CustomClaims, error) { + return accessTokenGetClaims(tokenString, false) +} + +func stripOffBearerFromToken(tokenString string) string { + if len(tokenString) > 6 && strings.ToUpper(tokenString[0:6]) == "BEARER" { + return tokenString[7:] + } + return tokenString //"", errors.New("Only bearer tokens are supported, got: " + tokenString) +} + +func accessTokenGetClaims(tokenStringFull string, validate bool) (*CustomClaims, error) { + tokenString := stripOffBearerFromToken(tokenStringFull) + + token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return getSignKey() + }) + + if err != nil && validate { + if !strings.Contains(err.Error(), "token is expired") && token != nil { + log.Warnf("Error parsing token: %v", err) + } + + return nil, err + } + + if token == nil { + return nil, fmt.Errorf("error token is not defined %v", tokenStringFull) + } + + // Make sure header hasnt been tampered with + if token.Method != signingMethod { + return nil, errors.New("only SHA256hmac as signature method is allowed") + } + + claims, ok := token.Claims.(*CustomClaims) + + // Check issuer claim + if claims.Issuer != utils.Config.Frontend.JwtIssuer { + return nil, errors.New("invalid issuer claim") + } + + valid := ok && token.Valid + + if valid || !validate { + return claims, nil + } + + return nil, errors.New("token validity or claims cannot be verified") +} + +func getSignKey() ([]byte, error) { + signSecret, err := hex.DecodeString(utils.Config.Frontend.JwtSigningSecret) + if err != nil { + return nil, errors.Wrap(err, "Error decoding jwtSecretKey, not in hex format or missing from config?") + } + return signSecret, nil +} diff --git a/backend/pkg/api/handlers/common.go b/backend/pkg/api/handlers/common.go index 4c585443a..dd2a6aa7a 100644 --- a/backend/pkg/api/handlers/common.go +++ b/backend/pkg/api/handlers/common.go @@ -773,6 +773,8 @@ func errWithMsg(err error, format string, args ...interface{}) error { return fmt.Errorf("%w: %s", err, fmt.Sprintf(format, args...)) } +//nolint:nolintlint +//nolint:unparam func newBadRequestErr(format string, args ...interface{}) error { return errWithMsg(errBadRequest, format, args...) } @@ -782,14 +784,19 @@ func newUnauthorizedErr(format string, args ...interface{}) error { return errWithMsg(errUnauthorized, format, args...) } +//nolint:unparam func newForbiddenErr(format string, args ...interface{}) error { return errWithMsg(errForbidden, format, args...) } +//nolint:nolintlint +//nolint:unparam func newConflictErr(format string, args ...interface{}) error { return errWithMsg(errConflict, format, args...) } +//nolint:nolintlint +//nolint:unparam func newNotFoundErr(format string, args ...interface{}) error { return errWithMsg(dataaccess.ErrNotFound, format, args...) } diff --git a/backend/pkg/api/handlers/internal.go b/backend/pkg/api/handlers/internal.go index 033dee854..ef1086575 100644 --- a/backend/pkg/api/handlers/internal.go +++ b/backend/pkg/api/handlers/internal.go @@ -39,14 +39,21 @@ func (h *HandlerService) InternalGetLatestState(w http.ResponseWriter, r *http.R return } + finalizedEpoch, err := h.dai.GetLatestFinalizedEpoch() + if err != nil { + handleErr(w, err) + return + } + exchangeRates, err := h.dai.GetLatestExchangeRates() if err != nil { handleErr(w, err) return } data := types.LatestStateData{ - LatestSlot: latestSlot, - ExchangeRates: exchangeRates, + LatestSlot: latestSlot, + FinalizedEpoch: finalizedEpoch, + ExchangeRates: exchangeRates, } response := types.InternalGetLatestStateResponse{ diff --git a/backend/pkg/api/handlers/public.go b/backend/pkg/api/handlers/public.go index 8f652d80c..5779d65d9 100644 --- a/backend/pkg/api/handlers/public.go +++ b/backend/pkg/api/handlers/public.go @@ -22,10 +22,6 @@ func (h *HandlerService) PublicGetHealthzLoadbalancer(w http.ResponseWriter, r * returnOk(w, nil) } -func (h *HandlerService) PublicPostOauthToken(w http.ResponseWriter, r *http.Request) { - returnOk(w, nil) -} - func (h *HandlerService) PublicGetUserDashboards(w http.ResponseWriter, r *http.Request) { userId, err := h.GetUserIdByApiKey(r) if err != nil { diff --git a/backend/pkg/api/router.go b/backend/pkg/api/router.go index 8b4cc6037..5a76f976b 100644 --- a/backend/pkg/api/router.go +++ b/backend/pkg/api/router.go @@ -28,8 +28,7 @@ func NewApiRouter(dataAccessor dataaccess.DataAccessor, cfg *types.Config) *mux. sessionManager := newSessionManager(cfg) internalRouter.Use(sessionManager.LoadAndSave) - debug := cfg.Frontend.Debug - if !debug { + if !cfg.Frontend.CsrfInsecure { internalRouter.Use(getCsrfProtectionMiddleware(cfg), csrfInjecterMiddleware) } handlerService := handlers.NewHandlerService(dataAccessor, sessionManager) @@ -86,9 +85,12 @@ func addRoutes(hs *handlers.HandlerService, publicRouter, internalRouter *mux.Ro {http.MethodGet, "/healthz-loadbalancer", hs.PublicGetHealthzLoadbalancer, nil}, {http.MethodPost, "/login", nil, hs.InternalPostLogin}, - {http.MethodPost, "/logout", nil, hs.InternalPostLogout}, - {http.MethodPost, "/oauth/token", hs.PublicPostOauthToken, nil}, + {http.MethodGet, "/mobile/authorize", nil, hs.InternalPostMobileAuthorize}, + {http.MethodPost, "/mobile/equivalent-exchange", nil, hs.InternalPostMobileEquivalentExchange}, + {http.MethodPost, "/mobile/purchase", nil, hs.InternalHandleMobilePurchase}, + + {http.MethodPost, "/logout", nil, hs.InternalPostLogout}, {http.MethodGet, "/latest-state", nil, hs.InternalGetLatestState}, @@ -107,6 +109,7 @@ func addRoutes(hs *handlers.HandlerService, publicRouter, internalRouter *mux.Ro {http.MethodPut, "/users/me/password", nil, hs.InternalPutUserPassword}, // TODO reset password {http.MethodGet, "/users/me/dashboards", hs.PublicGetUserDashboards, hs.InternalGetUserDashboards}, + {http.MethodPut, "/users/me/notifications/settings/paired-devices/{client_id}/token", nil, hs.InternalPostUsersMeNotificationSettingsPairedDevicesToken}, {http.MethodPost, "/search", nil, hs.InternalPostSearch}, @@ -235,7 +238,7 @@ func addValidatorDashboardRoutes(hs *handlers.HandlerService, publicRouter, inte // add middleware to check if user has access to dashboard if !cfg.Frontend.Debug { publicDashboardRouter.Use(hs.GetVDBAuthMiddleware(hs.GetUserIdByApiKey), hs.ManageViaApiCheckMiddleware) - internalDashboardRouter.Use(hs.GetVDBAuthMiddleware(hs.GetUserIdBySession), GetAuthMiddleware(cfg.ApiKeySecret)) + internalDashboardRouter.Use(hs.GetVDBAuthMiddleware(hs.GetUserIdBySession)) } endpoints := []endpoint{ diff --git a/backend/pkg/api/types/data_access.go b/backend/pkg/api/types/data_access.go index 318eeccc3..e9d716bf3 100644 --- a/backend/pkg/api/types/data_access.go +++ b/backend/pkg/api/types/data_access.go @@ -183,6 +183,20 @@ type VDBProtocolModes struct { RocketPool bool } +type MobileSubscription struct { + ProductIDUnverified string `json:"id"` + PriceMicros uint64 `json:"priceMicros"` + Currency string `json:"currency"` + Transaction MobileSubscriptionTransactionGeneric `json:"transaction"` + ValidUnverified bool `json:"valid"` +} + +type MobileSubscriptionTransactionGeneric struct { + Type string `json:"type"` + Receipt string `json:"receipt"` + ID string `json:"id"` +} + type VDBValidatorSummaryChartRow struct { Timestamp time.Time `db:"ts"` GroupId int64 `db:"group_id"` diff --git a/backend/pkg/api/types/latest_state.go b/backend/pkg/api/types/latest_state.go index f21ee28dc..3e38fa925 100644 --- a/backend/pkg/api/types/latest_state.go +++ b/backend/pkg/api/types/latest_state.go @@ -12,8 +12,9 @@ type EthConversionRate struct { } type LatestStateData struct { - LatestSlot uint64 `json:"current_slot"` - ExchangeRates []EthConversionRate `json:"exchange_rates" faker:"slice_len=3"` + LatestSlot uint64 `json:"current_slot"` + FinalizedEpoch uint64 `json:"finalized_epoch"` + ExchangeRates []EthConversionRate `json:"exchange_rates" faker:"slice_len=3"` } type InternalGetLatestStateResponse ApiDataResponse[LatestStateData] diff --git a/backend/pkg/api/types/user.go b/backend/pkg/api/types/user.go index 36f485031..82ff55442 100644 --- a/backend/pkg/api/types/user.go +++ b/backend/pkg/api/types/user.go @@ -142,3 +142,11 @@ type StripeCreateCheckoutSession struct { type StripeCustomerPortal struct { Url string `json:"url"` } + +type OAuthAppData struct { + ID uint64 `db:"id"` + Owner uint64 `db:"owner_id"` + AppName string `db:"app_name"` + RedirectURI string `db:"redirect_uri"` + Active bool `db:"active"` +} diff --git a/backend/pkg/commons/utils/user.go b/backend/pkg/commons/utils/user.go index b995792ef..52aafa36f 100644 --- a/backend/pkg/commons/utils/user.go +++ b/backend/pkg/commons/utils/user.go @@ -4,6 +4,8 @@ import ( securerand "crypto/rand" "encoding/base64" "math/big" + "slices" + "strings" ) // GenerateAPIKey generates an API key for a user @@ -44,3 +46,31 @@ func GenerateRandomBytesSecure(n int) ([]byte, error) { return b, nil } + +// As a safety precaution we don't want to expose the full email address via the API +// We can rest assured that even if a user session ever leaks, no personal data is provided via api that could link the users addresses or validators to them +func CensorEmail(mail string) string { + parts := strings.Split(mail, "@") + if len(parts) != 2 { // invalid mail, should not happen + return mail + } + username := parts[0] + domain := parts[1] + + if len(username) > 2 { + username = string(username[0]) + "***" + string(username[len(username)-1]) + } + + // Also censor domain part for not well known domains as they could be used to identify the user if it's a niche domain + domainParts := strings.Split(domain, ".") + if len(parts) == 2 { + // https://email-verify.my-addr.com/list-of-most-popular-email-domains.php + wellKnownDomains := []string{"gmail", "hotmail", "yahoo", "apple", "aol", "outlook", "gmx", "live", "comcast", "msn"} + + if !slices.Contains(wellKnownDomains, domainParts[0]) && len(domainParts[0]) > 2 { + domain = string(domainParts[0][0]) + "***" + string(domainParts[0][len(domainParts[0])-1]) + "." + domainParts[1] + } + } + + return username + "@" + domain +} diff --git a/backend/pkg/userservice/appsubscription_oracle.go b/backend/pkg/userservice/appsubscription_oracle.go index 7012646f3..0b078674e 100644 --- a/backend/pkg/userservice/appsubscription_oracle.go +++ b/backend/pkg/userservice/appsubscription_oracle.go @@ -22,6 +22,8 @@ import ( "github.com/pkg/errors" ) +var ErrClientInit = errors.New("client init exception") + var duplicateOrderMap map[string]uint64 = make(map[string]uint64) func CheckMobileSubscriptions() { @@ -95,22 +97,27 @@ func verifyManuall(receipt *types.PremiumData) (*VerifyResponse, error) { Valid: valid, ExpirationDate: receipt.ExpiresAt.Unix(), RejectReason: rejectReason(valid), + ProductID: receipt.ProductID, + Receipt: receipt.Receipt, }, nil } // Does not verify stripe or ethpool payments as those are handled differently func VerifyReceipt(googleClient *playstore.Client, appleClient *api.StoreClient, receipt *types.PremiumData) (*VerifyResponse, error) { - if receipt.Store == "ios-appstore" { + switch receipt.Store { + case "ios-appstore": return verifyApple(appleClient, receipt) - } else if receipt.Store == "android-playstore" { + case "android-playstore": return verifyGoogle(googleClient, receipt) - } else if receipt.Store == "manuall" { + case "manuall": return verifyManuall(receipt) - } else { + default: return &VerifyResponse{ Valid: false, ExpirationDate: 0, RejectReason: "invalid_store", + ProductID: receipt.ProductID, + Receipt: receipt.Receipt, }, nil } } @@ -165,6 +172,8 @@ func verifyGoogle(client *playstore.Client, receipt *types.PremiumData) (*Verify Valid: false, ExpirationDate: 0, RejectReason: "", + ProductID: receipt.ProductID, + Receipt: receipt.Receipt, } if client == nil { @@ -172,7 +181,7 @@ func verifyGoogle(client *playstore.Client, receipt *types.PremiumData) (*Verify client, err = initGoogle() if err != nil { response.RejectReason = "gclient_init_exception" - return response, errors.New("google client can't be initialized") + return response, errors.Wrap(ErrClientInit, "google client can't be initialized") } } @@ -229,6 +238,8 @@ func verifyApple(apple *api.StoreClient, receipt *types.PremiumData) (*VerifyRes Valid: false, ExpirationDate: 0, RejectReason: "", + ProductID: receipt.ProductID, // may be changed by this function to be different than receipt.ProductID + Receipt: receipt.Receipt, // may be changed by this function to be different than receipt.Receipt } if apple == nil { @@ -236,23 +247,25 @@ func verifyApple(apple *api.StoreClient, receipt *types.PremiumData) (*VerifyRes apple, err = initApple() if err != nil { response.RejectReason = "aclient_init_exception" - return response, errors.New("apple client can't be initialized") + return response, errors.Wrap(ErrClientInit, "apple client can't be initialized") } } + receiptToken := receipt.Receipt // legacy resolver for old receipts, can be removed at some point - if len(receipt.Receipt) > 100 { - transactionID, err := getLegacyAppstoreTransactionIDByReceipt(receipt.Receipt, receipt.ProductID) + if len(receiptToken) > 100 { + transactionID, err := getLegacyAppstoreTransactionIDByReceipt(receiptToken, receipt.ProductID) if err != nil { log.Error(err, "error resolving legacy appstore receipt", 0, nil) response.RejectReason = "exception_legresolve" return response, err } - receipt.Receipt = transactionID + receiptToken = transactionID time.Sleep(50 * time.Millisecond) // avoid rate limiting } + response.Receipt = receiptToken // update response to reflect the resolved receipt - res, err := apple.GetALLSubscriptionStatuses(context.Background(), receipt.Receipt, nil) + res, err := apple.GetALLSubscriptionStatuses(context.Background(), receiptToken, nil) if err != nil { response.RejectReason = "exception" return response, err @@ -287,7 +300,7 @@ func verifyApple(apple *api.StoreClient, receipt *types.PremiumData) (*VerifyRes response.RejectReason = "invalid_product_id" return response, nil } - receipt.ProductID = productId + response.ProductID = productId // update response to reflect the resolved product id expiresDateFloat, ok := claims["expiresDate"].(float64) if !ok { @@ -377,4 +390,6 @@ type VerifyResponse struct { Valid bool ExpirationDate int64 RejectReason string + ProductID string + Receipt string } diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue index f0ed60da8..9a4289a60 100644 --- a/frontend/pages/login.vue +++ b/frontend/pages/login.vue @@ -3,10 +3,12 @@ import { object as yupObject } from 'yup' import { useForm } from 'vee-validate' import { useUserStore } from '~/stores/useUserStore' import { Target } from '~/types/links' +import { provideMobileAuthParams, handleMobileAuth } from '~/utils/mobileAuth' const { t: $t } = useI18n() const { doLogin } = useUserStore() const toast = useBcToast() +const route = useRoute() useBcSeo('login_and_register.title_login') @@ -23,6 +25,11 @@ const [password, passwordAttrs] = defineField('password') const onSubmit = handleSubmit(async (values) => { try { await doLogin(values.email, values.password) + + if (handleMobileAuth(route.query)) { + return + } + await navigateTo('/') } catch (error) { password.value = '' @@ -31,6 +38,11 @@ const onSubmit = handleSubmit(async (values) => { }) const canSubmit = computed(() => email.value && password.value && !Object.keys(errors.value).length) + +const registerLink = computed(() => { + return provideMobileAuthParams(route.query, '/register') +}) +