diff --git a/internal/api/context.go b/internal/api/context.go index e2714cd5de..501ab49f2e 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -195,16 +195,6 @@ func getExternalReferrer(ctx context.Context) string { return obj.(string) } -// getFunctionHooks reads the request ID from the context. -func getFunctionHooks(ctx context.Context) map[string][]string { - obj := ctx.Value(functionHooksKey) - if obj == nil { - return map[string][]string{} - } - - return obj.(map[string][]string) -} - // withAdminUser adds the admin user to the context. func withAdminUser(ctx context.Context, u *models.User) context.Context { return context.WithValue(ctx, adminUserKey, u) diff --git a/internal/api/external.go b/internal/api/external.go index a20e3ba597..a47d201dc2 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -375,10 +375,6 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http. }); terr != nil { return nil, terr } - if terr = triggerEventHooks(ctx, tx, SignupEvent, user, config); terr != nil { - return nil, terr - } - // fall through to auto-confirm and issue token if terr = user.Confirm(tx); terr != nil { return nil, internalServerError("Error updating user").WithInternalError(terr) @@ -410,16 +406,12 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http. }); terr != nil { return nil, terr } - if terr = triggerEventHooks(ctx, tx, LoginEvent, user, config); terr != nil { - return nil, terr - } } return user, nil } func (a *API) processInvite(r *http.Request, ctx context.Context, tx *storage.Connection, userData *provider.UserProvidedData, inviteToken, providerType string) (*models.User, error) { - config := a.config user, err := models.FindUserByConfirmationToken(tx, inviteToken) if err != nil { if models.IsNotFoundError(err) { @@ -467,9 +459,6 @@ func (a *API) processInvite(r *http.Request, ctx context.Context, tx *storage.Co }); err != nil { return nil, err } - if err := triggerEventHooks(ctx, tx, SignupEvent, user, config); err != nil { - return nil, err - } // an account with a previously unconfirmed email + password // combination or phone may exist. so now that there is an diff --git a/internal/api/hook_test.go b/internal/api/hook_test.go deleted file mode 100644 index a5d573db09..0000000000 --- a/internal/api/hook_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/supabase/auth/internal/conf" - "github.com/supabase/auth/internal/models" - "github.com/supabase/auth/internal/storage/test" -) - -// withFunctionHooks adds the provided function hooks to the context. -func withFunctionHooks(ctx context.Context, hooks map[string][]string) context.Context { - return context.WithValue(ctx, functionHooksKey, hooks) -} - -func TestSignupHookSendInstanceID(t *testing.T) { - globalConfig, err := conf.LoadGlobal(apiTestConfig) - require.NoError(t, err) - - conn, err := test.SetupDBConnection(globalConfig) - require.NoError(t, err) - - user, err := models.NewUser("81234567", "test@truth.com", "thisisapassword", "", nil) - require.NoError(t, err) - - var callCount int - svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount++ - defer squash(r.Body.Close) - raw, err := io.ReadAll(r.Body) - require.NoError(t, err) - - data := map[string]interface{}{} - require.NoError(t, json.Unmarshal(raw, &data)) - - assert.Len(t, data, 3) - w.WriteHeader(http.StatusOK) - })) - defer svr.Close() - - config := &conf.GlobalConfiguration{ - Webhook: conf.WebhookConfig{ - URL: svr.URL, - Events: []string{SignupEvent}, - }, - } - - require.NoError(t, triggerEventHooks(context.Background(), conn, SignupEvent, user, config)) - - assert.Equal(t, 1, callCount) -} - -func TestSignupHookFromClaims(t *testing.T) { - globalConfig, err := conf.LoadGlobal(apiTestConfig) - require.NoError(t, err) - - conn, err := test.SetupDBConnection(globalConfig) - require.NoError(t, err) - - user, err := models.NewUser("", "test@truth.com", "thisisapassword", "", nil) - require.NoError(t, err) - - var callCount int - svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount++ - defer squash(r.Body.Close) - raw, err := io.ReadAll(r.Body) - require.NoError(t, err) - - data := map[string]interface{}{} - require.NoError(t, json.Unmarshal(raw, &data)) - - assert.Len(t, data, 3) - w.WriteHeader(http.StatusOK) - })) - defer svr.Close() - - config := &conf.GlobalConfiguration{ - Webhook: conf.WebhookConfig{ - Events: []string{"signup"}, - }, - } - - ctx := context.Background() - ctx = withFunctionHooks(ctx, map[string][]string{ - "signup": {svr.URL}, - }) - - require.NoError(t, triggerEventHooks(ctx, conn, SignupEvent, user, config)) - - assert.Equal(t, 1, callCount) -} - -func TestHookRetry(t *testing.T) { - var callCount int - svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount++ - assert.EqualValues(t, 0, r.ContentLength) - if callCount == 3 { - w.WriteHeader(http.StatusOK) - } else { - w.WriteHeader(http.StatusBadRequest) - } - })) - defer svr.Close() - - config := &conf.WebhookConfig{ - URL: svr.URL, - Retries: 3, - } - w := Webhook{ - WebhookConfig: config, - } - b, err := w.trigger() - defer func() { - if b != nil { - b.Close() - } - }() - require.NoError(t, err) - - assert.Equal(t, 3, callCount) -} - -func TestHookTimeout(t *testing.T) { - realTimeout := defaultTimeout - defer func() { - defaultTimeout = realTimeout - }() - defaultTimeout = time.Millisecond * 10 - - var mu sync.Mutex - var callCount int - svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - callCount++ - mu.Unlock() - time.Sleep(20 * time.Millisecond) - })) - - config := &conf.WebhookConfig{ - URL: svr.URL, - Retries: 3, - } - w := Webhook{ - WebhookConfig: config, - } - _, err := w.trigger() - require.Error(t, err) - herr, ok := err.(*HTTPError) - require.True(t, ok) - assert.Equal(t, http.StatusGatewayTimeout, herr.Code) - - svr.Close() - assert.Equal(t, 3, callCount) -} - -func squash(f func() error) { _ = f } diff --git a/internal/api/hooks.go b/internal/api/hooks.go index 2fd31983e2..b5a8e5bc74 100644 --- a/internal/api/hooks.go +++ b/internal/api/hooks.go @@ -1,295 +1,16 @@ package api import ( - "bytes" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" - "io" - "net" "net/http" - "net/http/httptrace" - "net/url" - "time" - - "github.com/gofrs/uuid" - jwt "github.com/golang-jwt/jwt" - "github.com/pkg/errors" - "github.com/sirupsen/logrus" + "github.com/supabase/auth/internal/hooks" - "github.com/supabase/auth/internal/conf" - "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" - "github.com/supabase/auth/internal/utilities" -) - -type HookEvent string - -const ( - headerHookSignature = "x-webhook-signature" - defaultHookRetries = 3 - gotrueIssuer = "gotrue" - ValidateEvent = "validate" - SignupEvent = "signup" - EmailChangeEvent = "email_change" - LoginEvent = "login" ) -var defaultTimeout = time.Second * 5 - -type webhookClaims struct { - jwt.StandardClaims - SHA256 string `json:"sha256"` -} - -type Webhook struct { - *conf.WebhookConfig - - jwtSecret string - claims jwt.Claims - payload []byte -} - -type WebhookResponse struct { - AppMetaData map[string]interface{} `json:"app_metadata,omitempty"` - UserMetaData map[string]interface{} `json:"user_metadata,omitempty"` -} - -func (w *Webhook) trigger() (io.ReadCloser, error) { - timeout := defaultTimeout - if w.TimeoutSec > 0 { - timeout = time.Duration(w.TimeoutSec) * time.Second - } - - if w.Retries == 0 { - w.Retries = defaultHookRetries - } - - hooklog := logrus.WithFields(logrus.Fields{ - "component": "webhook", - "url": w.URL, - "signed": w.jwtSecret != "", - "instance_id": uuid.Nil.String(), - }) - client := http.Client{ - Timeout: timeout, - } - - for i := 0; i < w.Retries; i++ { - hooklog = hooklog.WithField("attempt", i+1) - hooklog.Info("Starting to perform signup hook request") - - req, err := http.NewRequest(http.MethodPost, w.URL, bytes.NewBuffer(w.payload)) - if err != nil { - return nil, internalServerError("Failed to make request object").WithInternalError(err) - } - req.Header.Set("Content-Type", "application/json") - watcher, req := watchForConnection(req) - - if w.jwtSecret != "" { - header, jwtErr := w.generateSignature() - if jwtErr != nil { - return nil, jwtErr - } - req.Header.Set(headerHookSignature, header) - } - - start := time.Now() - rsp, err := client.Do(req) - if err != nil { - if terr, ok := err.(net.Error); ok && terr.Timeout() { - // timed out - try again? - if i == w.Retries-1 { - closeBody(rsp) - return nil, httpError(http.StatusGatewayTimeout, "Failed to perform webhook in time frame (%v seconds)", timeout.Seconds()) - } - hooklog.Info("Request timed out") - continue - } else if watcher.gotConn { - closeBody(rsp) - return nil, internalServerError("Failed to trigger webhook to %s", w.URL).WithInternalError(err) - } else { - closeBody(rsp) - return nil, httpError(http.StatusBadGateway, "Failed to connect to %s", w.URL) - } - } - dur := time.Since(start) - rspLog := hooklog.WithFields(logrus.Fields{ - "status_code": rsp.StatusCode, - "dur": dur.Nanoseconds(), - }) - switch rsp.StatusCode { - case http.StatusOK, http.StatusNoContent, http.StatusAccepted: - rspLog.Infof("Finished processing webhook in %s", dur) - var body io.ReadCloser - if rsp.ContentLength > 0 { - body = rsp.Body - } - return body, nil - default: - rspLog.Infof("Bad response for webhook %d in %s", rsp.StatusCode, dur) - } - } - - hooklog.Infof("Failed to process webhook for %s after %d attempts", w.URL, w.Retries) - return nil, unprocessableEntityError("Failed to handle signup webhook") -} - -func (w *Webhook) generateSignature() (string, error) { - token := jwt.NewWithClaims(jwt.SigningMethodHS256, w.claims) - tokenString, err := token.SignedString([]byte(w.jwtSecret)) - if err != nil { - return "", internalServerError("Failed build signing string").WithInternalError(err) - } - return tokenString, nil -} - -func closeBody(rsp *http.Response) { - if rsp != nil && rsp.Body != nil { - if err := rsp.Body.Close(); err != nil { - logrus.WithError(err).Warn("body close in hooks failed") - } - } -} - -func triggerEventHooks(ctx context.Context, conn *storage.Connection, event HookEvent, user *models.User, config *conf.GlobalConfiguration) error { - if config.Webhook.URL != "" { - hookURL, err := url.Parse(config.Webhook.URL) - if err != nil { - return errors.Wrapf(err, "Failed to parse Webhook URL") - } - if !config.Webhook.HasEvent(string(event)) { - return nil - } - return triggerHook(ctx, hookURL, config.Webhook.Secret, conn, event, user, config) - } - - fun := getFunctionHooks(ctx) - if fun == nil { - return nil - } - - for _, eventHookURL := range fun[string(event)] { - hookURL, err := url.Parse(eventHookURL) - if err != nil { - return errors.Wrapf(err, "Failed to parse Event Function Hook URL") - } - err = triggerHook(ctx, hookURL, config.JWT.Secret, conn, event, user, config) - if err != nil { - return err - } - } - return nil -} - -func triggerHook(ctx context.Context, hookURL *url.URL, secret string, conn *storage.Connection, event HookEvent, user *models.User, config *conf.GlobalConfiguration) error { - if !hookURL.IsAbs() { - siteURL, err := url.Parse(config.SiteURL) - if err != nil { - return errors.Wrapf(err, "Failed to parse Site URL") - } - hookURL.Scheme = siteURL.Scheme - hookURL.Host = siteURL.Host - hookURL.User = siteURL.User - } - - payload := struct { - Event HookEvent `json:"event"` - InstanceID uuid.UUID `json:"instance_id,omitempty"` - User *models.User `json:"user"` - }{ - Event: event, - InstanceID: uuid.Nil, - User: user, - } - data, err := json.Marshal(&payload) - if err != nil { - return internalServerError("Failed to serialize the data for signup webhook").WithInternalError(err) - } - - sha, err := checksum(data) - if err != nil { - return internalServerError("Failed to checksum the data for signup webhook").WithInternalError(err) - } - - claims := webhookClaims{ - StandardClaims: jwt.StandardClaims{ - IssuedAt: time.Now().Unix(), - Subject: uuid.Nil.String(), - Issuer: gotrueIssuer, - }, - SHA256: sha, - } - - w := Webhook{ - WebhookConfig: &config.Webhook, - jwtSecret: secret, - claims: claims, - payload: data, - } - - w.URL = hookURL.String() - - body, err := w.trigger() - if body != nil { - defer utilities.SafeClose(body) - } - if err == nil && body != nil { - webhookRsp := &WebhookResponse{} - decoder := json.NewDecoder(body) - if err = decoder.Decode(webhookRsp); err != nil { - return internalServerError("Webhook returned malformed JSON: %v", err).WithInternalError(err) - } - return conn.Transaction(func(tx *storage.Connection) error { - if webhookRsp.UserMetaData != nil { - user.UserMetaData = nil - if terr := user.UpdateUserMetaData(tx, webhookRsp.UserMetaData); terr != nil { - return terr - } - } - if webhookRsp.AppMetaData != nil { - user.AppMetaData = nil - if terr := user.UpdateAppMetaData(tx, webhookRsp.AppMetaData); terr != nil { - return terr - } - } - return nil - }) - } - return err -} - -func watchForConnection(req *http.Request) (*connectionWatcher, *http.Request) { - w := new(connectionWatcher) - t := &httptrace.ClientTrace{ - GotConn: w.GotConn, - } - - req = req.WithContext(httptrace.WithClientTrace(req.Context(), t)) - return w, req -} - -func checksum(data []byte) (string, error) { - sha := sha256.New() - _, err := sha.Write(data) - if err != nil { - return "", err - } - - return hex.EncodeToString(sha.Sum(nil)), nil -} - -type connectionWatcher struct { - gotConn bool -} - -func (c *connectionWatcher) GotConn(_ httptrace.GotConnInfo) { - c.gotConn = true -} - func (a *API) runHook(ctx context.Context, name string, input, output any) ([]byte, error) { db := a.db.WithContext(ctx) diff --git a/internal/api/signup.go b/internal/api/signup.go index 8b84ffd30d..d7f2899015 100644 --- a/internal/api/signup.go +++ b/internal/api/signup.go @@ -216,9 +216,6 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { }); terr != nil { return terr } - if terr = triggerEventHooks(ctx, tx, SignupEvent, user, config); terr != nil { - return terr - } if terr = user.Confirm(tx); terr != nil { return internalServerError("Database error updating user").WithInternalError(terr) } @@ -253,9 +250,6 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { }); terr != nil { return terr } - if terr = triggerEventHooks(ctx, tx, SignupEvent, user, config); terr != nil { - return terr - } if terr = user.ConfirmPhone(tx); terr != nil { return internalServerError("Database error updating user").WithInternalError(terr) } @@ -316,9 +310,6 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error { }); terr != nil { return terr } - if terr = triggerEventHooks(ctx, tx, LoginEvent, user, config); terr != nil { - return terr - } token, terr = a.issueRefreshToken(ctx, tx, user, models.PasswordGrant, grantParams) if terr != nil { @@ -388,9 +379,6 @@ func (a *API) signupNewUser(ctx context.Context, conn *storage.Connection, user if terr = user.SetRole(tx, config.JWT.DefaultGroupName); terr != nil { return internalServerError("Database error updating user").WithInternalError(terr) } - if terr = triggerEventHooks(ctx, tx, ValidateEvent, user, config); terr != nil { - return terr - } return nil }) if err != nil { diff --git a/internal/api/signup_test.go b/internal/api/signup_test.go index 3f114ba1f1..eb69dd6a63 100644 --- a/internal/api/signup_test.go +++ b/internal/api/signup_test.go @@ -4,15 +4,12 @@ import ( "bytes" "encoding/json" "fmt" - "io" "net/http" "net/http/httptest" "net/url" "testing" "time" - "github.com/gofrs/uuid" - jwt "github.com/golang-jwt/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -76,107 +73,6 @@ func (ts *SignupTestSuite) TestSignup() { assert.Equal(ts.T(), []interface{}{"email"}, data.AppMetaData["providers"]) } -func (ts *SignupTestSuite) TestWebhookTriggered() { - var callCount int - require := ts.Require() - assert := ts.Assert() - - svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount++ - assert.Equal("application/json", r.Header.Get("Content-Type")) - - // verify the signature - signature := r.Header.Get("x-webhook-signature") - p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} - claims := new(jwt.StandardClaims) - token, _ := p.ParseWithClaims(signature, claims, func(token *jwt.Token) (interface{}, error) { - return []byte(ts.Config.Webhook.Secret), nil - }) - assert.True(token.Valid) - assert.Equal(uuid.Nil.String(), claims.Subject) // not configured for multitenancy - assert.Equal("gotrue", claims.Issuer) - assert.WithinDuration(time.Now(), time.Unix(claims.IssuedAt, 0), 5*time.Second) - - // verify the contents - - defer squash(r.Body.Close) - raw, err := io.ReadAll(r.Body) - require.NoError(err) - data := map[string]interface{}{} - require.NoError(json.Unmarshal(raw, &data)) - - assert.Equal(3, len(data)) - assert.Equal("validate", data["event"]) - - u, ok := data["user"].(map[string]interface{}) - require.True(ok) - assert.Equal("authenticated", u["aud"]) - assert.Equal("authenticated", u["role"]) - assert.Equal("test@example.com", u["email"]) - - appmeta, ok := u["app_metadata"].(map[string]interface{}) - require.True(ok) - assert.Len(appmeta, 2) - assert.EqualValues("email", appmeta["provider"]) - assert.EqualValues([]interface{}{"email"}, appmeta["providers"]) - - usermeta, ok := u["user_metadata"].(map[string]interface{}) - require.True(ok) - assert.Len(usermeta, 1) - assert.EqualValues(1, usermeta["a"]) - })) - defer svr.Close() - - ts.Config.Webhook = conf.WebhookConfig{ - URL: svr.URL, - Retries: 1, - TimeoutSec: 1, - Secret: "top-secret", - Events: []string{"validate"}, - } - var buffer bytes.Buffer - require.NoError(json.NewEncoder(&buffer).Encode(map[string]interface{}{ - "email": "test@example.com", - "password": "test123", - "data": map[string]interface{}{ - "a": 1, - }, - })) - req := httptest.NewRequest(http.MethodPost, "http://localhost/signup", &buffer) - req.Header.Set("Content-Type", "application/json") - - w := httptest.NewRecorder() - ts.API.handler.ServeHTTP(w, req) - assert.Equal(http.StatusOK, w.Code) - assert.Equal(1, callCount) -} - -func (ts *SignupTestSuite) TestFailingWebhook() { - ts.Config.Webhook = conf.WebhookConfig{ - URL: "http://notaplace.localhost", - Retries: 1, - TimeoutSec: 1, - Events: []string{"validate", "signup"}, - } - var buffer bytes.Buffer - require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ - "email": "test@example.com", - "password": "test123", - "data": map[string]interface{}{ - "a": 1, - }, - })) - req := httptest.NewRequest(http.MethodPost, "http://localhost/signup", &buffer) - req.Header.Set("Content-Type", "application/json") - - // Setup response recorder - w := httptest.NewRecorder() - - ts.API.handler.ServeHTTP(w, req) - - require.Equal(ts.T(), http.StatusBadGateway, w.Code) -} - // TestSignupTwice checks to make sure the same email cannot be registered twice func (ts *SignupTestSuite) TestSignupTwice() { // Request body diff --git a/internal/api/token.go b/internal/api/token.go index 75d2e50878..7f084609af 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -204,9 +204,6 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri }); terr != nil { return terr } - if terr = triggerEventHooks(ctx, tx, LoginEvent, user, config); terr != nil { - return terr - } token, terr = a.issueRefreshToken(ctx, tx, user, models.PasswordGrant, grantParams) if terr != nil { return terr diff --git a/internal/api/verify.go b/internal/api/verify.go index ecacc1e9ac..dcfe73c96a 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -291,8 +291,6 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP } func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) { - config := a.config - if user.EncryptedPassword == "" && user.InvitedAt != nil { // sign them up with temporary password, and require application // to present the user with a password set form @@ -318,10 +316,6 @@ func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.C return terr } - if terr = triggerEventHooks(ctx, tx, SignupEvent, user, config); terr != nil { - return terr - } - if terr = user.Confirm(tx); terr != nil { return internalServerError("Error confirming user").WithInternalError(terr) } @@ -334,8 +328,6 @@ func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.C } func (a *API) recoverVerify(r *http.Request, ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) { - config := a.config - err := conn.Transaction(func(tx *storage.Connection) error { var terr error if terr = user.Recover(tx); terr != nil { @@ -346,9 +338,6 @@ func (a *API) recoverVerify(r *http.Request, ctx context.Context, conn *storage. return terr } - if terr = triggerEventHooks(ctx, tx, SignupEvent, user, config); terr != nil { - return terr - } if terr = user.Confirm(tx); terr != nil { return terr } @@ -356,9 +345,6 @@ func (a *API) recoverVerify(r *http.Request, ctx context.Context, conn *storage. if terr = models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", nil); terr != nil { return terr } - if terr = triggerEventHooks(ctx, tx, LoginEvent, user, config); terr != nil { - return terr - } } return nil }) @@ -370,12 +356,8 @@ func (a *API) recoverVerify(r *http.Request, ctx context.Context, conn *storage. } func (a *API) smsVerify(r *http.Request, ctx context.Context, conn *storage.Connection, user *models.User, params *VerifyParams) (*models.User, error) { - config := a.config err := conn.Transaction(func(tx *storage.Connection) error { - if terr := triggerEventHooks(ctx, tx, SignupEvent, user, config); terr != nil { - return terr - } if params.Type == smsVerification { if terr := models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", nil); terr != nil { @@ -509,10 +491,6 @@ func (a *API) emailChangeVerify(r *http.Request, ctx context.Context, conn *stor return terr } - if terr := triggerEventHooks(ctx, tx, EmailChangeEvent, user, config); terr != nil { - return terr - } - if identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "email"); terr != nil { if !models.IsNotFoundError(terr) { return terr