From 783cddd523edb3d334e0a5d72282bd9488b1f695 Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Sun, 16 Aug 2020 06:23:31 +0200 Subject: [PATCH] Ported system user service to new store --- store/rdbms/credentials.go | 8 + store/rdbms/roles.go | 43 +++- store/rdbms/users.go | 10 + store/tests/users_test.go | 38 ++- system/commands/users.go | 8 +- system/rest/auth.go | 30 +-- system/rest/auth_external.go | 36 +-- system/rest/auth_internal.go | 51 +++-- system/service/auth.go | 432 +++++++++++++++++------------------ system/service/auth_test.go | 295 ++++++++++++++---------- system/service/service.go | 23 +- system/service/user.go | 19 +- 12 files changed, 577 insertions(+), 416 deletions(-) diff --git a/store/rdbms/credentials.go b/store/rdbms/credentials.go index f66b2da2e..d7bb637d0 100644 --- a/store/rdbms/credentials.go +++ b/store/rdbms/credentials.go @@ -12,5 +12,13 @@ func (s Store) convertCredentialsFilter(f types.CredentialsFilter) (query squirr query = query.Where(squirrel.Eq{"crd.kind": f.Kind}) } + if f.Credentials != "" { + query = query.Where(squirrel.Eq{"crd.credentials": f.Credentials}) + } + + if f.OwnerID > 0 { + query = query.Where(squirrel.Eq{"crd.rel_owner": f.OwnerID}) + } + return } diff --git a/store/rdbms/roles.go b/store/rdbms/roles.go index 4148c733a..3c25d0dc9 100644 --- a/store/rdbms/roles.go +++ b/store/rdbms/roles.go @@ -1,9 +1,12 @@ package rdbms import ( + "context" "github.com/Masterminds/squirrel" "github.com/cortezaproject/corteza-server/pkg/rh" + "github.com/cortezaproject/corteza-server/store" "github.com/cortezaproject/corteza-server/system/types" + "github.com/jmoiron/sqlx" ) func (s Store) convertRoleFilter(f types.RoleFilter) (query squirrel.SelectBuilder, err error) { @@ -54,8 +57,13 @@ func (s Store) convertRoleFilter(f types.RoleFilter) (query squirrel.SelectBuild return } -func (s Store) roleMemberTable() string { - return "sys_role_member" +func (s Store) RoleMemberTable(aa ...string) string { + var alias string + if len(aa) > 0 { + alias = " AS " + aa[0] + } + + return "sys_role_member" + alias } //func (s *Store) MembershipsFindByUserID(roleID uint64) (mm []*types.RoleMember, err error) { @@ -69,15 +77,28 @@ func (s Store) roleMemberTable() string { // sql := "SELECT * FROM " + rl.tableMember() + " WHERE rel_role = ?" // return rval, rl.db().Select(&rval, sql, roleID) //} -// -//func (s *Store) MemberAddByID(roleID, userID uint64) error { -// mod := &types.RoleMember{ -// RoleID: roleID, -// UserID: userID, -// } -// return rl.db().Replace(rl.tableMember(), mod) -//} -// + +func (s *Store) AddRoleMembersByID(ctx context.Context, roleID uint64, IDs ...uint64) error { + if len(IDs) == 0 { + return nil + } + + var ( + p = store.Payload{"rel_role": roleID, "rel_user": 0} + ) + + return Tx(ctx, s.db, s.config, nil, func(db *sqlx.Tx) (err error) { + for _, p["rel_user"] = range IDs { + err = ExecuteSqlizer(ctx, s.DB(), s.Insert(s.RoleMemberTable()).SetMap(p)) + if err != nil { + return err + } + } + + return nil + }) +} + //func (s *Store) MemberRemoveByID(roleID, userID uint64) error { // mod := &types.RoleMember{ // RoleID: roleID, diff --git a/store/rdbms/users.go b/store/rdbms/users.go index 1067bcc71..5837cfd15 100644 --- a/store/rdbms/users.go +++ b/store/rdbms/users.go @@ -1,6 +1,8 @@ package rdbms import ( + "context" + "fmt" "github.com/Masterminds/squirrel" "github.com/cortezaproject/corteza-server/pkg/permissions" "github.com/cortezaproject/corteza-server/pkg/rh" @@ -79,3 +81,11 @@ func (s Store) convertUserFilter(f types.UserFilter) (query squirrel.SelectBuild query = query.OrderBy(orderBy...) return } + +func (s Store) CountUsers(ctx context.Context, f types.UserFilter) (uint, error) { + if q, err := s.convertUserFilter(f); err != nil { + return 0, fmt.Errorf("could not count users: %w", err) + } else { + return Count(ctx, s.db, q) + } +} diff --git a/store/tests/users_test.go b/store/tests/users_test.go index d2bbccec3..6bd136a27 100644 --- a/store/tests/users_test.go +++ b/store/tests/users_test.go @@ -2,6 +2,8 @@ package tests import ( "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/id" "github.com/cortezaproject/corteza-server/system/types" _ "github.com/joho/godotenv/autoload" "github.com/stretchr/testify/require" @@ -9,13 +11,22 @@ import ( "time" ) -func testUsers(t *testing.T, s usersStore) { +type ( + usersStoreAdt interface { + usersStore + CountUsers(ctx context.Context, f types.UserFilter) (uint, error) + } +) + +func testUsers(t *testing.T, tmp interface{}) { var ( ctx = context.Background() req = require.New(t) //err error user *types.User + + s = tmp.(usersStoreAdt) ) t.Run("create", func(t *testing.T) { @@ -154,4 +165,29 @@ func testUsers(t *testing.T, s usersStore) { t.Run("ordered search", func(t *testing.T) { t.Skip("not implemented") }) + + t.Run("count", func(t *testing.T) { + var ( + f = types.UserFilter{} + c1, c2 uint + err error + user = &types.User{ID: id.Next(), CreatedAt: time.Now(), Email: fmt.Sprintf("user-crud+%s@crust.test", time.Now().String())} + ) + + c1, err = s.CountUsers(ctx, f) + req.NoError(err) + + req.NoError(s.CreateUser(ctx, user)) + + c2, err = s.CountUsers(ctx, f) + req.NoError(err) + req.Equal(c1+1, c2) + + req.NoError(s.RemoveUserByID(ctx, user.ID)) + + c2, err = s.CountUsers(ctx, f) + req.NoError(err) + req.Equal(c1, c2) + }) + } diff --git a/system/commands/users.go b/system/commands/users.go index 32b57b1b6..469212bc7 100644 --- a/system/commands/users.go +++ b/system/commands/users.go @@ -101,7 +101,7 @@ func Users(app serviceInitializer) *cobra.Command { db = factory.Database.MustGet() userRepo = repository.User(ctx, db) - authSvc = service.Auth(ctx) + authSvc = service.Auth() // @todo email validation user = &types.User{Email: args[0]} @@ -135,7 +135,7 @@ func Users(app serviceInitializer) *cobra.Command { return } - if err = authSvc.SetPassword(user.ID, string(password)); err != nil { + if err = authSvc.SetPassword(ctx, user.ID, string(password)); err != nil { cli.HandleError(err) } } @@ -159,7 +159,7 @@ func Users(app serviceInitializer) *cobra.Command { db = factory.Database.MustGet() userRepo = repository.User(ctx, db) - authSvc = service.Auth(ctx) + authSvc = service.Auth() user *types.User err error @@ -183,7 +183,7 @@ func Users(app serviceInitializer) *cobra.Command { return } - if err = authSvc.SetPassword(user.ID, string(password)); err != nil { + if err = authSvc.SetPassword(ctx, user.ID, string(password)); err != nil { cli.HandleError(err) } }, diff --git a/system/rest/auth.go b/system/rest/auth.go index f535e7ffa..0daa74967 100644 --- a/system/rest/auth.go +++ b/system/rest/auth.go @@ -2,17 +2,15 @@ package rest import ( "context" - "net/http" - - "github.com/pkg/errors" - "github.com/titpetric/factory/resputil" - "github.com/cortezaproject/corteza-server/pkg/auth" "github.com/cortezaproject/corteza-server/pkg/payload" "github.com/cortezaproject/corteza-server/pkg/payload/outgoing" "github.com/cortezaproject/corteza-server/system/rest/request" "github.com/cortezaproject/corteza-server/system/service" "github.com/cortezaproject/corteza-server/system/types" + "github.com/pkg/errors" + "github.com/titpetric/factory/resputil" + "net/http" ) var _ = errors.Wrap @@ -21,7 +19,7 @@ type ( Auth struct { tokenEncoder auth.TokenEncoder settings *types.AppSettings - authSvc service.AuthService + authSvc authUserService } authUserResponse struct { @@ -33,6 +31,13 @@ type ( *outgoing.User Roles []string `json:"roles"` } + + authUserService interface { + Impersonate(ctx context.Context, userID uint64) (*types.User, error) + ValidateAuthRequestToken(ctx context.Context, token string) (user *types.User, err error) + CanRegister(ctx context.Context) error + LoadRoleMemberships(ctx context.Context, user *types.User) error + } ) func (Auth) New() *Auth { @@ -46,7 +51,7 @@ func (Auth) New() *Auth { func (ctrl *Auth) Check(ctx context.Context, r *request.AuthCheck) (interface{}, error) { return func(w http.ResponseWriter, r *http.Request) { if identity := auth.GetIdentityFromContext(ctx); identity != nil && identity.Valid() { - if user, err := service.DefaultUser.With(ctx).FindByID(identity.Identity()); err == nil { + if user, err := service.DefaultUser.FindByID(identity.Identity()); err == nil { var p *authUserResponse if p, err = ctrl.makePayload(ctx, user); err != nil { @@ -72,7 +77,7 @@ func (ctrl *Auth) Logout(ctx context.Context, r *request.AuthLogout) (interface{ // // This is experimental and internals will most likely change in the future: func (ctrl *Auth) Impersonate(ctx context.Context, r *request.AuthImpersonate) (interface{}, error) { - u, err := ctrl.authSvc.With(ctx).Impersonate(r.UserID) + u, err := ctrl.authSvc.Impersonate(ctx, r.UserID) if err != nil { return nil, err } @@ -96,7 +101,7 @@ func (ctrl *Auth) Settings(ctx context.Context, r *request.AuthSettings) (interf } ) - if err := ctrl.authSvc.With(ctx).CanRegister(); err != nil { + if err := ctrl.authSvc.CanRegister(ctx); err != nil { // f["internalSignUpEnabled"] = false out["signUpDisabled"] = err.Error() } @@ -105,9 +110,7 @@ func (ctrl *Auth) Settings(ctx context.Context, r *request.AuthSettings) (interf } func (ctrl *Auth) ExchangeAuthToken(ctx context.Context, r *request.AuthExchangeAuthToken) (interface{}, error) { - var svc = ctrl.authSvc.With(ctx) - - user, err := svc.ValidateAuthRequestToken(r.Token) + user, err := ctrl.authSvc.ValidateAuthRequestToken(ctx, r.Token) if err != nil { return nil, err } @@ -116,8 +119,7 @@ func (ctrl *Auth) ExchangeAuthToken(ctx context.Context, r *request.AuthExchange } func (ctrl *Auth) makePayload(ctx context.Context, user *types.User) (*authUserResponse, error) { - var svc = ctrl.authSvc.With(ctx) - if err := svc.LoadRoleMemberships(user); err != nil { + if err := ctrl.authSvc.LoadRoleMemberships(ctx, user); err != nil { return nil, err } diff --git a/system/rest/auth_external.go b/system/rest/auth_external.go index e6d9420f4..4f7060135 100644 --- a/system/rest/auth_external.go +++ b/system/rest/auth_external.go @@ -23,9 +23,15 @@ import ( type ( ExternalAuth struct { - auth service.AuthService + auth externalAuthService jwtEncoder auth.TokenEncoder } + + externalAuthService interface { + External(ctx context.Context, profile goth.User) (*types.User, error) + FrontendRedirectURL() string + IssueAuthRequestToken(ctx context.Context, user *types.User) (token string, err error) + } ) const ( @@ -113,28 +119,24 @@ func (ctrl *ExternalAuth) handleFailedCallback(w http.ResponseWriter, r *http.Re // 2) use `auth.frontend.url.redirect` setting // 3) use current url func (ctrl *ExternalAuth) handleSuccessfulAuth(w http.ResponseWriter, r *http.Request, cred goth.User) { - ctrl.log(r.Context(), zap.String("provider", cred.Provider)).Info("external login successful") - - svc := ctrl.auth.With(r.Context()) - - var ( - u *types.User - err error - ) - - // Try to login/sign-up external user - if u, err = svc.External(cred); err != nil { - resputil.JSON(w, err) - return - } var ( + u *types.User + err error ctx = r.Context() token string redirUrl *url.URL c *http.Cookie ) + ctrl.log(ctx, zap.String("provider", cred.Provider)).Info("external login successful") + + // Try to login/sign-up external user + if u, err = ctrl.auth.External(ctx, cred); err != nil { + resputil.JSON(w, err) + return + } + if c, err = r.Cookie(redirCookieName); err != nil && err != http.ErrNoCookie { ctrl.log(ctx, zap.Error(err)).Warn("error reading cookies") } @@ -151,7 +153,7 @@ func (ctrl *ExternalAuth) handleSuccessfulAuth(w http.ResponseWriter, r *http.Re if redirUrl == nil { // Try with frontend redirect URL - if fru := svc.FrontendRedirectURL(); fru != "" { + if fru := ctrl.auth.FrontendRedirectURL(); fru != "" { redirUrl, err = url.Parse(fru) if redirUrl == nil { @@ -178,7 +180,7 @@ func (ctrl *ExternalAuth) handleSuccessfulAuth(w http.ResponseWriter, r *http.Re if u != nil { // Append auth request token to the URL // This token is used by the client and exchanged for JWT - if token, err = svc.IssueAuthRequestToken(u); err == nil { + if token, err = ctrl.auth.IssueAuthRequestToken(ctx, u); err == nil { q.Set("token", token) } } diff --git a/system/rest/auth_internal.go b/system/rest/auth_internal.go index 8c84f9965..44abf408e 100644 --- a/system/rest/auth_internal.go +++ b/system/rest/auth_internal.go @@ -28,7 +28,19 @@ type ( AuthInternal struct { tokenEncoder auth.TokenEncoder - authSvc service.AuthService + authSvc authInternalAuthService + } + + authInternalAuthService interface { + InternalSignUp(ctx context.Context, input *types.User, password string) (*types.User, error) + InternalLogin(ctx context.Context, email string, password string) (*types.User, error) + SetPassword(ctx context.Context, userID uint64, AuthActionPassword string) error + ChangePassword(ctx context.Context, userID uint64, oldPassword, AuthActionPassword string) error + LoadRoleMemberships(ctx context.Context, user *types.User) error + ValidateEmailConfirmationToken(ctx context.Context, token string) (user *types.User, err error) + ExchangePasswordResetToken(ctx context.Context, token string) (user *types.User, exchangedToken string, err error) + ValidatePasswordResetToken(ctx context.Context, token string) (user *types.User, err error) + SendPasswordResetToken(ctx context.Context, email string) (err error) } ) @@ -40,18 +52,15 @@ func (AuthInternal) New() *AuthInternal { } func (ctrl *AuthInternal) Login(ctx context.Context, r *request.AuthInternalLogin) (interface{}, error) { - var svc = ctrl.authSvc.With(ctx) - u, err := svc.InternalLogin(r.Email, r.Password) + u, err := ctrl.authSvc.InternalLogin(ctx, r.Email, r.Password) if err != nil { return nil, err } - return ctrl.authInternalValidUserResponse(svc, u) + return ctrl.authInternalValidUserResponse(ctx, u) } func (ctrl *AuthInternal) Signup(ctx context.Context, r *request.AuthInternalSignup) (interface{}, error) { - var svc = ctrl.authSvc.With(ctx) - newUser := &types.User{ Email: r.Email, Handle: r.Handle, @@ -59,7 +68,7 @@ func (ctrl *AuthInternal) Signup(ctx context.Context, r *request.AuthInternalSig Name: r.Name, } - u, err := svc.InternalSignUp(newUser, r.Password) + u, err := ctrl.authSvc.InternalSignUp(ctx, newUser, r.Password) if err != nil { return nil, err } @@ -69,20 +78,19 @@ func (ctrl *AuthInternal) Signup(ctx context.Context, r *request.AuthInternalSig return authInternalValidUserResponse{User: &authUserPayload{User: payload.User(u)}}, nil } - if err = svc.LoadRoleMemberships(u); err != nil { + if err = ctrl.authSvc.LoadRoleMemberships(ctx, u); err != nil { return nil, err } - return ctrl.authInternalValidUserResponse(svc, u) + return ctrl.authInternalValidUserResponse(ctx, u) } func (ctrl *AuthInternal) RequestPasswordReset(ctx context.Context, r *request.AuthInternalRequestPasswordReset) (interface{}, error) { - return true, ctrl.authSvc.With(ctx).SendPasswordResetToken(r.Email) + return true, ctrl.authSvc.SendPasswordResetToken(ctx, r.Email) } func (ctrl *AuthInternal) ExchangePasswordResetToken(ctx context.Context, r *request.AuthInternalExchangePasswordResetToken) (interface{}, error) { - var svc = ctrl.authSvc.With(ctx) - u, token, err := svc.ExchangePasswordResetToken(r.Token) + u, token, err := ctrl.authSvc.ExchangePasswordResetToken(ctx, r.Token) if err != nil { return nil, err } @@ -94,39 +102,36 @@ func (ctrl *AuthInternal) ExchangePasswordResetToken(ctx context.Context, r *req } func (ctrl *AuthInternal) ResetPassword(ctx context.Context, r *request.AuthInternalResetPassword) (interface{}, error) { - var svc = ctrl.authSvc.With(ctx) - var u, err = svc.ValidatePasswordResetToken(r.Token) + var u, err = ctrl.authSvc.ValidatePasswordResetToken(ctx, r.Token) if err != nil { return nil, err } - err = svc.SetPassword(u.ID, r.Password) + err = ctrl.authSvc.SetPassword(ctx, u.ID, r.Password) if err != nil { return nil, err } - return ctrl.authInternalValidUserResponse(svc, u) + return ctrl.authInternalValidUserResponse(ctx, u) } func (ctrl *AuthInternal) ConfirmEmail(ctx context.Context, r *request.AuthInternalConfirmEmail) (interface{}, error) { - var svc = ctrl.authSvc.With(ctx) - var u, err = svc.ValidateEmailConfirmationToken(r.Token) + var u, err = ctrl.authSvc.ValidateEmailConfirmationToken(ctx, r.Token) if err != nil { return nil, err } - return ctrl.authInternalValidUserResponse(svc, u) + return ctrl.authInternalValidUserResponse(ctx, u) } func (ctrl *AuthInternal) ChangePassword(ctx context.Context, r *request.AuthInternalChangePassword) (interface{}, error) { - var svc = ctrl.authSvc.With(ctx) var identity = auth.GetIdentityFromContext(ctx) if !identity.Valid() { return nil, errors.New("invalid user (not authenticated)") } - err := svc.ChangePassword(identity.Identity(), r.OldPassword, r.NewPassword) + err := ctrl.authSvc.ChangePassword(ctx, identity.Identity(), r.OldPassword, r.NewPassword) if err != nil { return nil, err } else { @@ -134,8 +139,8 @@ func (ctrl *AuthInternal) ChangePassword(ctx context.Context, r *request.AuthInt } } -func (ctrl AuthInternal) authInternalValidUserResponse(svc interface{ LoadRoleMemberships(*types.User) error }, u *types.User) (*authInternalValidUserResponse, error) { - if err := svc.LoadRoleMemberships(u); err != nil { +func (ctrl AuthInternal) authInternalValidUserResponse(ctx context.Context, u *types.User) (*authInternalValidUserResponse, error) { + if err := ctrl.authSvc.LoadRoleMemberships(ctx, u); err != nil { return nil, err } diff --git a/system/service/auth.go b/system/service/auth.go index 8f1cec83c..d302badf1 100644 --- a/system/service/auth.go +++ b/system/service/auth.go @@ -2,7 +2,10 @@ package service import ( "context" + "errors" "fmt" + "github.com/cortezaproject/corteza-server/pkg/id" + "github.com/cortezaproject/corteza-server/store" "regexp" "strconv" "time" @@ -23,50 +26,16 @@ import ( type ( auth struct { - db db - ctx context.Context - actionlog actionlog.Recorder ac authAccessController eventbus eventDispatcher subscription authSubscriptionChecker - credentials repository.CredentialsRepository - users repository.UserRepository - roles repository.RoleRepository + store authStore settings *types.AppSettings notifications AuthNotificationService providerValidator func(string) error - now func() *time.Time - } - - AuthService interface { - With(ctx context.Context) AuthService - - External(profile goth.User) (*types.User, error) - FrontendRedirectURL() string - - InternalSignUp(input *types.User, password string) (*types.User, error) - InternalLogin(email string, password string) (*types.User, error) - SetPassword(userID uint64, AuthActionPassword string) error - ChangePassword(userID uint64, oldPassword, AuthActionPassword string) error - Impersonate(userID uint64) (*types.User, error) - - IssueAuthRequestToken(user *types.User) (token string, err error) - ValidateAuthRequestToken(token string) (user *types.User, err error) - ValidateEmailConfirmationToken(token string) (user *types.User, err error) - ExchangePasswordResetToken(token string) (user *types.User, exchangedToken string, err error) - ValidatePasswordResetToken(token string) (user *types.User, err error) - SendEmailAddressConfirmationToken(email string) (err error) - SendPasswordResetToken(email string) (err error) - - CanRegister() error - - LoadRoleMemberships(*types.User) error - - checkPasswordStrength(string) bool - changePassword(uint64, string) error } authAccessController interface { @@ -76,6 +45,15 @@ type ( authSubscriptionChecker interface { CanRegister(uint) error } + + authStore interface { + usersStore + rolesStore + credentialsStore + + AddRoleMembersByID(context.Context, uint64, ...uint64) error + CountUsers(context.Context, types.UserFilter) (uint, error) + } ) const ( @@ -97,8 +75,8 @@ func defaultProviderValidator(provider string) error { return err } -func Auth(ctx context.Context) AuthService { - return (&auth{ +func Auth() *auth { + return &auth{ eventbus: eventbus.Service(), ac: DefaultAccessControl, subscription: CurrentSubscription, @@ -108,36 +86,6 @@ func Auth(ctx context.Context) AuthService { actionlog: DefaultActionlog, providerValidator: defaultProviderValidator, - - now: func() *time.Time { - var now = time.Now() - return &now - }, - }).With(ctx) -} - -// With returns copy of service with new context -// obsolete approach, will be removed ASAP -func (svc auth) With(ctx context.Context) AuthService { - db := repository.DB(ctx) - return &auth{ - db: db, - ctx: ctx, - - credentials: repository.Credentials(ctx, db), - users: repository.User(ctx, db), - roles: repository.Role(ctx, db), - - ac: svc.ac, - subscription: svc.subscription, - settings: svc.settings, - notifications: svc.notifications, - eventbus: svc.eventbus, - providerValidator: svc.providerValidator, - - actionlog: svc.actionlog, - - now: svc.now, } } @@ -156,7 +104,9 @@ func (svc auth) With(ctx context.Context) AuthService { // 2.2. create user on-the-fly if it does not exist // 2.3. create credentials for that social login // -func (svc auth) External(profile goth.User) (u *types.User, err error) { +// External login/signup does not: +// - validate provider on profile, only uses it for matching credentials +func (svc auth) External(ctx context.Context, profile goth.User) (u *types.User, err error) { var ( authProvider = &types.AuthProvider{Provider: profile.Provider} @@ -180,7 +130,8 @@ func (svc auth) External(profile goth.User) (u *types.User, err error) { return AuthErrProfileWithoutValidEmail(aam) } - if cc, err := svc.credentials.FindByCredentials(profile.Provider, profile.UserID); err == nil { + f := types.CredentialsFilter{Kind: profile.Provider, Credentials: profile.UserID} + if cc, _, err := svc.store.SearchCredentials(ctx, f); err == nil { // Credentials found, load user for _, c := range cc { if !c.Valid() { @@ -190,36 +141,37 @@ func (svc auth) External(profile goth.User) (u *types.User, err error) { // Add credentials ID for audit log aam.setCredentials(c) - if u, err = svc.users.FindByID(c.OwnerID); err != nil { - if repository.ErrUserNotFound.Eq(err) { + if u, err = svc.store.LookupUserByID(ctx, c.OwnerID); err != nil { + if errors.Is(err, store.ErrNotFound) { // Orphaned credentials (no owner) // try to auto-fix this by removing credentials and recreating user - if err = svc.credentials.DeleteByID(c.ID); err != nil { + if err = svc.store.RemoveCredentialsByID(ctx, c.ID); err != nil { return err } else { goto findByEmail } } + return err } // Add user ID for audit log aam.setUser(u) - svc.ctx = internalAuth.SetIdentityToContext(svc.ctx, u) + ctx = internalAuth.SetIdentityToContext(ctx, u) - if err = svc.eventbus.WaitFor(svc.ctx, event.AuthBeforeLogin(u, authProvider)); err != nil { + if err = svc.eventbus.WaitFor(ctx, event.AuthBeforeLogin(u, authProvider)); err != nil { return err } if u.Valid() { // Valid user, Bingo! - c.LastUsedAt = svc.now() - if c, err = svc.credentials.Update(c); err != nil { + c.LastUsedAt = nowPtr() + if err = svc.store.UpdateCredentials(ctx, c); err != nil { return err } - _ = svc.eventbus.WaitFor(svc.ctx, event.AuthAfterLogin(u, authProvider)) - return svc.recordAction(svc.ctx, aam, AuthActionUpdateCredentials, nil) + _ = svc.eventbus.WaitFor(ctx, event.AuthAfterLogin(u, authProvider)) + return svc.recordAction(ctx, aam, AuthActionUpdateCredentials, nil) } else { // Scenario: linked to an invalid user if len(cc) > 1 { @@ -248,8 +200,11 @@ func (svc auth) External(profile goth.User) (u *types.User, err error) { setUser(nil) // Find user via his email - if u, err = svc.users.FindByEmail(profile.Email); repository.ErrUserNotFound.Eq(err) { + if u, err = svc.store.LookupUserByEmail(ctx, profile.Email); errors.Is(err, store.ErrNotFound) { // @todo check if it is ok to auto-create a user here + if err = svc.CanRegister(ctx); err != nil { + return AuthErrSubscription(aam).Wrap(err) + } // In case we do not have this email, create a new user u = &types.User{ @@ -262,31 +217,31 @@ func (svc auth) External(profile goth.User) (u *types.User, err error) { u.Handle = profile.NickName } - if err = svc.CanRegister(); err != nil { - return AuthErrSubscription(aam).Wrap(err) - } - - if err = svc.eventbus.WaitFor(svc.ctx, event.AuthBeforeSignup(u, authProvider)); err != nil { + if err = svc.eventbus.WaitFor(ctx, event.AuthBeforeSignup(u, authProvider)); err != nil { return err } if u.Handle == "" { - createHandle(svc.users, u) + createHandle(ctx, svc.store, u) } - if u, err = svc.users.Create(u); err != nil { + u.ID = id.Next() + u.CreatedAt = now() + if err = svc.store.CreateUser(ctx, u); err != nil { return err } aam.setUser(nil) - svc.ctx = internalAuth.SetIdentityToContext(svc.ctx, u) + ctx = internalAuth.SetIdentityToContext(ctx, u) - _ = svc.eventbus.WaitFor(svc.ctx, event.AuthAfterSignup(u, authProvider)) + _ = svc.eventbus.WaitFor(ctx, event.AuthAfterSignup(u, authProvider)) - svc.recordAction(svc.ctx, aam, AuthActionExternalSignup, nil) + if err = svc.recordAction(ctx, aam, AuthActionExternalSignup, nil); err != nil { + return err + } // Auto-promote first user - if err = svc.autoPromote(u); err != nil { + if err = svc.autoPromote(ctx, u); err != nil { return err } } else if err != nil { @@ -294,13 +249,13 @@ func (svc auth) External(profile goth.User) (u *types.User, err error) { } else { // User found aam.setUser(u) - svc.ctx = internalAuth.SetIdentityToContext(svc.ctx, u) + ctx = internalAuth.SetIdentityToContext(ctx, u) - if err = svc.eventbus.WaitFor(svc.ctx, event.AuthBeforeLogin(u, authProvider)); err != nil { + if err = svc.eventbus.WaitFor(ctx, event.AuthBeforeLogin(u, authProvider)); err != nil { return err } - _ = svc.eventbus.WaitFor(svc.ctx, event.AuthAfterLogin(u, authProvider)) + _ = svc.eventbus.WaitFor(ctx, event.AuthAfterLogin(u, authProvider)) // If user if !u.Valid() { @@ -311,24 +266,26 @@ func (svc auth) External(profile goth.User) (u *types.User, err error) { // If we got to this point, assume that user is authenticated // but credentials need to be stored c := &types.Credentials{ + ID: id.Next(), + CreatedAt: now(), Kind: profile.Provider, OwnerID: u.ID, Credentials: profile.UserID, - LastUsedAt: svc.now(), + LastUsedAt: nowPtr(), } - if c, err = svc.credentials.Create(c); err != nil { + if err = svc.store.CreateCredentials(ctx, c); err != nil { return err } aam.setCredentials(c) - svc.recordAction(svc.ctx, aam, AuthActionCreateCredentials, nil) + svc.recordAction(ctx, aam, AuthActionCreateCredentials, nil) // Owner loaded, carry on. return nil }() - return u, svc.recordAction(svc.ctx, aam, AuthActionAuthenticate, err) + return u, svc.recordAction(ctx, aam, AuthActionAuthenticate, err) } // FrontendRedirectURL - a proxy to frontend redirect url setting @@ -341,7 +298,7 @@ func (svc auth) FrontendRedirectURL() string { // Forgiving but strict: valid existing users get notified // // We're accepting the whole user object here and copy all we need to the new user -func (svc auth) InternalSignUp(input *types.User, password string) (u *types.User, err error) { +func (svc auth) InternalSignUp(ctx context.Context, input *types.User, password string) (u *types.User, err error) { var ( authProvider = &types.AuthProvider{Provider: credentialsTypePassword} @@ -370,10 +327,13 @@ func (svc auth) InternalSignUp(input *types.User, password string) (u *types.Use } var eUser *types.User - eUser, err = svc.users.FindByEmail(input.Email) + eUser, err = svc.store.LookupUserByEmail(ctx, input.Email) if err == nil && eUser.Valid() { - var cc types.CredentialsSet - cc, err = svc.credentials.FindByKind(eUser.ID, credentialsTypePassword) + var ( + cc types.CredentialsSet + f = types.CredentialsFilter{OwnerID: eUser.ID, Kind: credentialsTypePassword} + ) + cc, _, err = svc.store.SearchCredentials(ctx, f) if err != nil { return err } @@ -382,28 +342,29 @@ func (svc auth) InternalSignUp(input *types.User, password string) (u *types.Use return AuthErrInvalidCredentials(aam) } else { // Update last-used-by timestamp on matching credentials - c.LastUsedAt = svc.now() + c.LastUsedAt = nowPtr() + c.UpdatedAt = nowPtr() aam.setCredentials(c) - if _, err = svc.credentials.Update(c); err != nil { + if err = svc.store.UpdateCredentials(ctx, c); err != nil { return err } } // We're not actually doing sign-up here - user exists, // password is a match, so lets trigger before/after user login events - if err = svc.eventbus.WaitFor(svc.ctx, event.AuthBeforeLogin(eUser, authProvider)); err != nil { + if err = svc.eventbus.WaitFor(ctx, event.AuthBeforeLogin(eUser, authProvider)); err != nil { return err } if !eUser.EmailConfirmed { - err = svc.sendEmailAddressConfirmationToken(eUser) + err = svc.sendEmailAddressConfirmationToken(ctx, eUser) if err != nil { return err } } - _ = svc.eventbus.WaitFor(svc.ctx, event.AuthAfterLogin(eUser, authProvider)) + _ = svc.eventbus.WaitFor(ctx, event.AuthAfterLogin(eUser, authProvider)) u = eUser return nil @@ -418,15 +379,16 @@ func (svc auth) InternalSignUp(input *types.User, password string) (u *types.Use // } // // return nil,nil - } else if !repository.ErrUserNotFound.Eq(err) { + } else if !errors.Is(err, store.ErrNotFound) { return err } - if err = svc.CanRegister(); err != nil { + if err = svc.CanRegister(ctx); err != nil { return err } var nUser = &types.User{ + ID: id.Next(), Email: input.Email, Name: input.Name, Username: input.Username, @@ -436,54 +398,54 @@ func (svc auth) InternalSignUp(input *types.User, password string) (u *types.Use EmailConfirmed: !svc.settings.Auth.Internal.Signup.EmailConfirmationRequired, } - if err = svc.eventbus.WaitFor(svc.ctx, event.AuthBeforeSignup(nUser, authProvider)); err != nil { + if err = svc.eventbus.WaitFor(ctx, event.AuthBeforeSignup(nUser, authProvider)); err != nil { return err } if input.Handle == "" { - createHandle(svc.users, input) + createHandle(ctx, svc.store, input) } // Whitelisted user data to copy - u, err = svc.users.Create(nUser) + err = svc.store.CreateUser(ctx, nUser) if err != nil { return err } aam.setUser(u) - _ = svc.eventbus.WaitFor(svc.ctx, event.AuthAfterSignup(u, authProvider)) + _ = svc.eventbus.WaitFor(ctx, event.AuthAfterSignup(u, authProvider)) - if err = svc.autoPromote(u); err != nil { + if err = svc.autoPromote(ctx, u); err != nil { return err } if len(password) > 0 { - err = svc.changePassword(u.ID, password) + err = svc.SetPassword(ctx, u.ID, password) if err != nil { return err } } if !u.EmailConfirmed { - err = svc.sendEmailAddressConfirmationToken(u) + err = svc.sendEmailAddressConfirmationToken(ctx, u) if err != nil { return err } - return svc.recordAction(svc.ctx, aam, AuthActionSendEmailConfirmationToken, nil) + return svc.recordAction(ctx, aam, AuthActionSendEmailConfirmationToken, nil) } return nil }() - return u, svc.recordAction(svc.ctx, aam, AuthActionInternalSignup, err) + return u, svc.recordAction(ctx, aam, AuthActionInternalSignup, err) } // InternalLogin verifies username/password combination in the internal credentials table // // Expects plain text password as an input -func (svc auth) InternalLogin(email string, password string) (u *types.User, err error) { +func (svc auth) InternalLogin(ctx context.Context, email string, password string) (u *types.User, err error) { var ( authProvider = &types.AuthProvider{Provider: credentialsTypePassword} @@ -511,8 +473,8 @@ func (svc auth) InternalLogin(email string, password string) (u *types.User, err cc types.CredentialsSet ) - u, err = svc.users.FindByEmail(email) - if repository.ErrUserNotFound.Eq(err) { + u, err = svc.store.LookupUserByEmail(ctx, email) + if errors.Is(err, store.ErrNotFound) { return AuthErrFailedForUnknownUser() } @@ -521,9 +483,9 @@ func (svc auth) InternalLogin(email string, password string) (u *types.User, err } // Update audit meta with found user - svc.ctx = internalAuth.SetIdentityToContext(svc.ctx, u) + ctx = internalAuth.SetIdentityToContext(ctx, u) - cc, err = svc.credentials.FindByKind(u.ID, credentialsTypePassword) + cc, _, err = svc.store.SearchCredentials(ctx, types.CredentialsFilter{OwnerID: u.ID, Kind: credentialsTypePassword}) if err != nil { return err } @@ -532,15 +494,16 @@ func (svc auth) InternalLogin(email string, password string) (u *types.User, err return AuthErrInvalidCredentials(aam) } else { // Update last-used-by timestamp on matching credentials - c.LastUsedAt = svc.now() + c.UpdatedAt = nowPtr() + c.LastUsedAt = nowPtr() aam.setCredentials(c) - if _, err = svc.credentials.Update(c); err != nil { + if err = svc.store.UpdateCredentials(ctx, c); err != nil { return err } } - if err = svc.eventbus.WaitFor(svc.ctx, event.AuthBeforeLogin(u, authProvider)); err != nil { + if err = svc.eventbus.WaitFor(ctx, event.AuthBeforeLogin(u, authProvider)); err != nil { return err } @@ -549,18 +512,18 @@ func (svc auth) InternalLogin(email string, password string) (u *types.User, err } if !u.EmailConfirmed { - if err = svc.sendEmailAddressConfirmationToken(u); err != nil { + if err = svc.sendEmailAddressConfirmationToken(ctx, u); err != nil { return err } return AuthErrFailedUnconfirmedEmail() } - _ = svc.eventbus.WaitFor(svc.ctx, event.AuthAfterLogin(u, authProvider)) + _ = svc.eventbus.WaitFor(ctx, event.AuthAfterLogin(u, authProvider)) return nil }() - return u, svc.recordAction(svc.ctx, aam, AuthActionAuthenticate, err) + return u, svc.recordAction(ctx, aam, AuthActionAuthenticate, err) } // checkPassword returns true if given (encrypted) password matches any of the credentials @@ -569,7 +532,9 @@ func (svc auth) checkPassword(password string, cc types.CredentialsSet) bool { } // SetPassword sets new password for a user -func (svc auth) SetPassword(userID uint64, password string) (err error) { +// +// This function also records an action +func (svc auth) SetPassword(ctx context.Context, userID uint64, password string) (err error) { var ( u *types.User @@ -584,50 +549,50 @@ func (svc auth) SetPassword(userID uint64, password string) (err error) { return AuthErrInteralLoginDisabledByConfig(aam) } - if !svc.checkPasswordStrength(password) { + if !svc.CheckPasswordStrength(password) { return AuthErrPasswordNotSecure(aam) } - u, err = svc.users.FindByID(userID) - if repository.ErrUserNotFound.Eq(err) { + u, err = svc.store.LookupUserByID(ctx, userID) + if errors.Is(err, store.ErrNotFound) { return AuthErrPasswordChangeFailedForUnknownUser(aam) } - if err != svc.changePassword(userID, password) { + if err != svc.SetPasswordCredentials(ctx, userID, password) { return err } return nil }() - return svc.recordAction(svc.ctx, aam, AuthActionChangePassword, err) + return svc.recordAction(ctx, aam, AuthActionChangePassword, err) } // Impersonate verifies if user can impersonate another user and returns that user // // For now, it's the caller's responsibility to generate the auth token -func (svc auth) Impersonate(userID uint64) (u *types.User, err error) { +func (svc auth) Impersonate(ctx context.Context, userID uint64) (u *types.User, err error) { var ( aam = &authActionProps{user: u} ) err = func() error { - if u, err = svc.users.FindByID(userID); err != nil { + if u, err = svc.store.LookupUserByID(ctx, userID); err != nil { return err } - if !svc.ac.CanImpersonateUser(svc.ctx, u) { + if !svc.ac.CanImpersonateUser(ctx, u) { return AuthErrNotAllowedToImpersonate() } return err }() - return u, svc.recordAction(svc.ctx, aam, AuthActionImpersonate, err) + return u, svc.recordAction(ctx, aam, AuthActionImpersonate, err) } // ChangePassword validates old password and changes it with new -func (svc auth) ChangePassword(userID uint64, oldPassword, AuthActionPassword string) (err error) { +func (svc auth) ChangePassword(ctx context.Context, userID uint64, oldPassword, AuthActionPassword string) (err error) { var ( u *types.User cc types.CredentialsSet @@ -647,16 +612,16 @@ func (svc auth) ChangePassword(userID uint64, oldPassword, AuthActionPassword st return AuthErrPasswordNotSecure(aam) } - if !svc.checkPasswordStrength(AuthActionPassword) { + if !svc.CheckPasswordStrength(AuthActionPassword) { return AuthErrPasswordNotSecure(aam) } - u, err = svc.users.FindByID(userID) - if repository.ErrUserNotFound.Eq(err) { + u, err = svc.store.LookupUserByID(ctx, userID) + if errors.Is(err, store.ErrNotFound) { return AuthErrPasswordChangeFailedForUnknownUser(aam) } - cc, err = svc.credentials.FindByKind(userID, credentialsTypePassword) + cc, _, err = svc.store.SearchCredentials(ctx, types.CredentialsFilter{Kind: credentialsTypePassword, OwnerID: userID}) if err != nil { return err } @@ -665,21 +630,21 @@ func (svc auth) ChangePassword(userID uint64, oldPassword, AuthActionPassword st return AuthErrPasswodResetFailedOldPasswordCheckFailed(aam) } - if err != svc.changePassword(userID, AuthActionPassword) { + if err != svc.SetPasswordCredentials(ctx, userID, AuthActionPassword) { return err } return nil }() - return svc.recordAction(svc.ctx, aam, AuthActionChangePassword, err) + return svc.recordAction(ctx, aam, AuthActionChangePassword, err) } func (svc auth) hashPassword(password string) (hash []byte, err error) { return bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) } -func (svc auth) checkPasswordStrength(password string) bool { +func (svc auth) CheckPasswordStrength(password string) bool { if len(password) <= 4 { return false } @@ -687,35 +652,57 @@ func (svc auth) checkPasswordStrength(password string) bool { return true } -// ChangePassword (soft) deletes old password entry and creates a new one +// SetPasswordCredentials (soft) deletes old password entry and creates a new entry with new password on every change // -// Expects hashed password as an input -func (svc auth) changePassword(userID uint64, password string) (err error) { - var hash []byte +// This way we can implement more strict password-change policies in the future +// +// This method is used by auth and user procedures to unify password hashing and updating +// credentials +func (svc auth) SetPasswordCredentials(ctx context.Context, userID uint64, password string) (err error) { + var ( + hash []byte + cc types.CredentialsSet + f = types.CredentialsFilter{Kind: credentialsTypePassword, OwnerID: userID} + ) + if hash, err = svc.hashPassword(password); err != nil { return } - if err = svc.credentials.DeleteByKind(userID, credentialsTypePassword); err != nil { + if cc, _, err = svc.store.SearchCredentials(ctx, f); err != nil { + return nil + } + + // Mark all credentials as deleted + _ = cc.Walk(func(c *types.Credentials) error { + c.DeletedAt = nowPtr() + return nil + }) + + // Do a partial update and soft-delete all + if err = svc.store.PartialUpdateCredentials(ctx, []string{"deleted_at"}, cc...); err != nil { return } - _, err = svc.credentials.Create(&types.Credentials{ + // Add new credentials with new password + c := &types.Credentials{ + ID: id.Next(), + CreatedAt: now(), OwnerID: userID, Kind: credentialsTypePassword, Credentials: string(hash), - }) + } - return err + return svc.store.CreateCredentials(ctx, c) } // IssueAuthRequestToken returns token that can be used for authentication -func (svc auth) IssueAuthRequestToken(user *types.User) (token string, err error) { - return svc.createUserToken(user, credentialsTypeAuthToken) +func (svc auth) IssueAuthRequestToken(ctx context.Context, user *types.User) (token string, err error) { + return svc.createUserToken(ctx, user, credentialsTypeAuthToken) } // ValidateAuthRequestToken returns user that requested auth token -func (svc auth) ValidateAuthRequestToken(token string) (u *types.User, err error) { +func (svc auth) ValidateAuthRequestToken(ctx context.Context, token string) (u *types.User, err error) { var ( aam = &authActionProps{ credentials: &types.Credentials{Kind: credentialsTypeAuthToken}, @@ -723,29 +710,29 @@ func (svc auth) ValidateAuthRequestToken(token string) (u *types.User, err error ) err = func() error { - u, err = svc.loadUserFromToken(token, credentialsTypeAuthToken) + u, err = svc.loadUserFromToken(ctx, token, credentialsTypeAuthToken) if err != nil && u != nil { aam.setUser(u) - svc.ctx = internalAuth.SetIdentityToContext(svc.ctx, u) + ctx = internalAuth.SetIdentityToContext(ctx, u) } return err }() - return u, svc.recordAction(svc.ctx, aam, AuthActionValidateToken, err) + return u, svc.recordAction(ctx, aam, AuthActionValidateToken, err) } // ValidateEmailConfirmationToken issues a validation token that can be used for -func (svc auth) ValidateEmailConfirmationToken(token string) (user *types.User, err error) { - return svc.loadFromTokenAndConfirmEmail(token, credentialsTypeEmailAuthToken) +func (svc auth) ValidateEmailConfirmationToken(ctx context.Context, token string) (user *types.User, err error) { + return svc.loadFromTokenAndConfirmEmail(ctx, token, credentialsTypeEmailAuthToken) } // ValidatePasswordResetToken validates password reset token -func (svc auth) ValidatePasswordResetToken(token string) (user *types.User, err error) { - return svc.loadFromTokenAndConfirmEmail(token, credentialsTypeEmailAuthToken) +func (svc auth) ValidatePasswordResetToken(ctx context.Context, token string) (user *types.User, err error) { + return svc.loadFromTokenAndConfirmEmail(ctx, token, credentialsTypeEmailAuthToken) } // loadFromTokenAndConfirmEmail loads token, confirms user's -func (svc auth) loadFromTokenAndConfirmEmail(token, tokenType string) (u *types.User, err error) { +func (svc auth) loadFromTokenAndConfirmEmail(ctx context.Context, token, tokenType string) (u *types.User, err error) { var ( aam = &authActionProps{ user: u, @@ -758,31 +745,32 @@ func (svc auth) loadFromTokenAndConfirmEmail(token, tokenType string) (u *types. return AuthErrInternalSignupDisabledByConfig(aam) } - u, err = svc.loadUserFromToken(token, tokenType) + u, err = svc.loadUserFromToken(ctx, token, tokenType) if err != nil { return err } aam.setUser(u) - svc.ctx = internalAuth.SetIdentityToContext(svc.ctx, u) + ctx = internalAuth.SetIdentityToContext(ctx, u) if u.EmailConfirmed { return nil } u.EmailConfirmed = true - if u, err = svc.users.Update(u); err != nil { + u.UpdatedAt = nowPtr() + if err = svc.store.UpdateUser(ctx, u); err != nil { return err } return nil }() - return u, svc.recordAction(svc.ctx, aam, AuthActionConfirmEmail, err) + return u, svc.recordAction(ctx, aam, AuthActionConfirmEmail, err) } // ExchangePasswordResetToken exchanges reset password token for a new one and returns it with user info -func (svc auth) ExchangePasswordResetToken(token string) (u *types.User, t string, err error) { +func (svc auth) ExchangePasswordResetToken(ctx context.Context, token string) (u *types.User, t string, err error) { var ( aam = &authActionProps{ user: u, @@ -795,15 +783,15 @@ func (svc auth) ExchangePasswordResetToken(token string) (u *types.User, t strin return AuthErrPasswordResetDisabledByConfig(aam) } - u, err = svc.loadUserFromToken(token, credentialsTypeResetPasswordToken) + u, err = svc.loadUserFromToken(ctx, token, credentialsTypeResetPasswordToken) if err != nil { return AuthErrInvalidToken(aam).Wrap(err) } aam.setUser(u) - svc.ctx = internalAuth.SetIdentityToContext(svc.ctx, u) + ctx = internalAuth.SetIdentityToContext(ctx, u) - t, err = svc.createUserToken(u, credentialsTypeResetPasswordTokenExchanged) + t, err = svc.createUserToken(ctx, u, credentialsTypeResetPasswordTokenExchanged) if err != nil { u = nil t = "" @@ -813,11 +801,11 @@ func (svc auth) ExchangePasswordResetToken(token string) (u *types.User, t strin return nil }() - return u, t, svc.recordAction(svc.ctx, aam, AuthActionExchangePasswordResetToken, err) + return u, t, svc.recordAction(ctx, aam, AuthActionExchangePasswordResetToken, err) } // SendEmailAddressConfirmationToken sends email with email address confirmation token -func (svc auth) SendEmailAddressConfirmationToken(email string) (err error) { +func (svc auth) SendEmailAddressConfirmationToken(ctx context.Context, email string) (err error) { var ( aam = &authActionProps{ email: email, @@ -829,18 +817,18 @@ func (svc auth) SendEmailAddressConfirmationToken(email string) (err error) { return AuthErrPasswordResetDisabledByConfig(aam) } - u, err := svc.users.FindByEmail(email) + u, err := svc.store.LookupUserByEmail(ctx, email) if err != nil { return AuthErrInvalidToken(aam) } - return svc.sendEmailAddressConfirmationToken(u) + return svc.sendEmailAddressConfirmationToken(ctx, u) }() - return svc.recordAction(svc.ctx, aam, AuthActionSendEmailConfirmationToken, err) + return svc.recordAction(ctx, aam, AuthActionSendEmailConfirmationToken, err) } -func (svc auth) sendEmailAddressConfirmationToken(u *types.User) (err error) { +func (svc auth) sendEmailAddressConfirmationToken(ctx context.Context, u *types.User) (err error) { var ( notificationLang = "en" token string @@ -851,19 +839,19 @@ func (svc auth) sendEmailAddressConfirmationToken(u *types.User) (err error) { } ) - if token, err = svc.createUserToken(u, credentialsTypeEmailAuthToken); err != nil { + if token, err = svc.createUserToken(ctx, u, credentialsTypeEmailAuthToken); err != nil { return } - if err = svc.notifications.EmailConfirmation(svc.ctx, notificationLang, u.Email, token); err != nil { + if err = svc.notifications.EmailConfirmation(ctx, notificationLang, u.Email, token); err != nil { return } - return svc.recordAction(svc.ctx, aam, AuthActionSendEmailConfirmationToken, err) + return svc.recordAction(ctx, aam, AuthActionSendEmailConfirmationToken, err) } // SendPasswordResetToken sends password reset token to email -func (svc auth) SendPasswordResetToken(email string) (err error) { +func (svc auth) SendPasswordResetToken(ctx context.Context, email string) (err error) { var ( u *types.User @@ -878,48 +866,53 @@ func (svc auth) SendPasswordResetToken(email string) (err error) { return AuthErrPasswordResetDisabledByConfig(aam) } - if u, err = svc.users.FindByEmail(email); err != nil { + if u, err = svc.store.LookupUserByEmail(ctx, email); err != nil { return err } - svc.ctx = internalAuth.SetIdentityToContext(svc.ctx, u) + ctx = internalAuth.SetIdentityToContext(ctx, u) - if err = svc.sendPasswordResetToken(u); err != nil { + if err = svc.sendPasswordResetToken(ctx, u); err != nil { return err } return nil }() - return svc.recordAction(svc.ctx, aam, AuthActionSendPasswordResetToken, err) + return svc.recordAction(ctx, aam, AuthActionSendPasswordResetToken, err) } // CanRegister verifies if user can register -func (svc auth) CanRegister() error { +func (svc auth) CanRegister(ctx context.Context) error { if svc.subscription != nil { + c, err := svc.store.CountUsers(ctx, types.UserFilter{}) + if err != nil { + return fmt.Errorf("can not check if user can register: %w", err) + } + // When we have an active subscription, we need to check // if users can register or did this deployment hit // it's user-limit - return svc.subscription.CanRegister(svc.users.Total()) + return svc.subscription.CanRegister(c) } return nil } -func (svc auth) sendPasswordResetToken(u *types.User) (err error) { +func (svc auth) sendPasswordResetToken(ctx context.Context, u *types.User) (err error) { var ( notificationLang = "en" ) - token, err := svc.createUserToken(u, credentialsTypeResetPasswordToken) + token, err := svc.createUserToken(ctx, u, credentialsTypeResetPasswordToken) if err != nil { return err } - return svc.notifications.PasswordReset(svc.ctx, notificationLang, u.Email, token) + return svc.notifications.PasswordReset(ctx, notificationLang, u.Email, token) } -func (svc auth) loadUserFromToken(token, kind string) (u *types.User, err error) { +func (svc auth) loadUserFromToken(ctx context.Context, token, kind string) (u *types.User, err error) { var ( aam = &authActionProps{ credentials: &types.Credentials{Kind: kind}, @@ -931,7 +924,7 @@ func (svc auth) loadUserFromToken(token, kind string) (u *types.User, err error) return nil, AuthErrInvalidToken(aam) } - c, err := svc.credentials.FindByID(credentialsID) + c, err := svc.store.LookupCredentialsByID(ctx, credentialsID) if err == repository.ErrCredentialsNotFound { return nil, AuthErrInvalidToken(aam) } @@ -942,7 +935,7 @@ func (svc auth) loadUserFromToken(token, kind string) (u *types.User, err error) return } - if err = svc.credentials.DeleteByID(c.ID); err != nil { + if err = svc.store.RemoveCredentialsByID(ctx, c.ID); err != nil { return } @@ -950,7 +943,7 @@ func (svc auth) loadUserFromToken(token, kind string) (u *types.User, err error) return nil, AuthErrInvalidToken(aam) } - u, err = svc.users.FindByID(c.OwnerID) + u, err = svc.store.LookupUserByID(ctx, c.OwnerID) if err != nil { return nil, err } @@ -984,7 +977,7 @@ func (svc auth) validateToken(token string) (ID uint64, credentials string) { // Generates & stores user token // it returns combined value of token + token ID to help with the lookups -func (svc auth) createUserToken(u *types.User, kind string) (token string, err error) { +func (svc auth) createUserToken(ctx context.Context, u *types.User, kind string) (token string, err error) { var ( expiresAt time.Time aam = &authActionProps{ @@ -997,18 +990,22 @@ func (svc auth) createUserToken(u *types.User, kind string) (token string, err e switch kind { case credentialsTypeAuthToken: // 15 sec expiration for all tokens that are part of redirection - expiresAt = svc.now().Add(time.Second * 15) + expiresAt = nowPtr().Add(time.Second * 15) default: // 1h expiration for all tokens send via email - expiresAt = svc.now().Add(time.Minute * 60) + expiresAt = nowPtr().Add(time.Minute * 60) } - c, err := svc.credentials.Create(&types.Credentials{ + c := &types.Credentials{ + ID: id.Next(), + CreatedAt: now(), OwnerID: u.ID, Kind: kind, Credentials: string(rand.Bytes(credentialsTokenLength)), ExpiresAt: &expiresAt, - }) + } + + err := svc.store.CreateCredentials(ctx, c) if err != nil { return err @@ -1018,32 +1015,35 @@ func (svc auth) createUserToken(u *types.User, kind string) (token string, err e return nil }() - return token, svc.recordAction(svc.ctx, aam, AuthActionIssueToken, err) + return token, svc.recordAction(ctx, aam, AuthActionIssueToken, err) } // Automatically promotes user to administrator if it is the first user in the database -func (svc auth) autoPromote(u *types.User) (err error) { - if svc.users.Total() > 1 || u.ID == 0 { - return nil - } - - if svc.roles == nil { - // no role repository; auto-promotion disabled - return nil - } - +func (svc auth) autoPromote(ctx context.Context, u *types.User) (err error) { var ( + c uint roleID = permissions.AdminsRoleID aam = &authActionProps{user: u, role: &types.Role{ID: roleID}} ) - err = svc.roles.MemberAddByID(roleID, u.ID) - return svc.recordAction(svc.ctx, aam, AuthActionAutoPromote, err) + err = func() error { + if c, err = svc.store.CountUsers(ctx, types.UserFilter{}); err != nil { + return err + } + + if c > 1 || u.ID == 0 { + return nil + } + + return svc.store.AddRoleMembersByID(ctx, roleID, u.ID) + }() + + return svc.recordAction(ctx, aam, AuthActionAutoPromote, err) } // LoadRoleMemberships loads membership info -func (svc auth) LoadRoleMemberships(u *types.User) error { - rr, _, err := svc.roles.Find(types.RoleFilter{MemberID: u.ID}) +func (svc auth) LoadRoleMemberships(ctx context.Context, u *types.User) error { + rr, _, err := svc.store.SearchRoles(ctx, types.RoleFilter{MemberID: u.ID}) if err != nil { return err } diff --git a/system/service/auth_test.go b/system/service/auth_test.go index dfd8ae533..f7ffcd101 100644 --- a/system/service/auth_test.go +++ b/system/service/auth_test.go @@ -1,159 +1,216 @@ package service import ( + "context" + "fmt" + "github.com/cortezaproject/corteza-server/pkg/id" + "github.com/cortezaproject/corteza-server/store/sqlite" + "github.com/davecgh/go-spew/spew" + "github.com/markbates/goth" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" "testing" "time" - "github.com/golang/mock/gomock" - "github.com/markbates/goth" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/bcrypt" - "github.com/cortezaproject/corteza-server/pkg/eventbus" - "github.com/cortezaproject/corteza-server/system/repository" - repomock "github.com/cortezaproject/corteza-server/system/repository/mocks" "github.com/cortezaproject/corteza-server/system/types" ) -// @todo this mockDB will be probably be used by other tests, move it to some common place -type mockDB struct{} - -func (mockDB) Transaction(callback func() error) error { return callback() } - // Mock auth service with nil for current time, dummy provider validator and mock db -func makeMockAuthService(u repository.UserRepository, c repository.CredentialsRepository) *auth { - return &auth{ - db: &mockDB{}, - users: u, - credentials: c, +func makeMockAuthService() *auth { + var ( + ctx = context.Background() - providerValidator: func(s string) error { - // All providers are valid. - return nil - }, + mem, err = sqlite.NewInMemory(ctx) - settings: &types.Settings{}, + svc = &auth{ + providerValidator: func(s string) error { + // All providers are valid. + return nil + }, - eventbus: eventbus.New(), + settings: &types.AppSettings{}, + eventbus: eventbus.New(), + } + ) - now: func() *time.Time { - return nil - }, + if err != nil { + panic(err) } + + if err = mem.Upgrade(ctx, zap.NewNop()); err != nil { + panic(err) + } + + svc.store = mem + + return svc } -func TestAuth_External_Existing(t *testing.T) { - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() +func TestAuth_External(t *testing.T) { + var ( + req = require.New(t) + ctx = context.Background() - // Create some virtual user and credentials - var u = &types.User{ID: 300000, Email: "foo@example.tld"} - var c = &types.Credentials{ID: 200000, OwnerID: u.ID} + // Create some virtual user and credentials + validUser = &types.User{Email: "valid@test.cortezaproject.org", ID: id.Next(), CreatedAt: now()} + suspendedUser = &types.User{Email: "suspended@test.cortezaproject.org", ID: id.Next(), CreatedAt: now(), SuspendedAt: nowPtr()} - // Profile to be used. make sure email matches - var p = goth.User{UserID: "some-profile-id", Provider: "google", Email: u.Email} + freshProfileID = func() string { + return fmt.Sprintf("fresh-profile-id-%d", id.Next()) + } - crdRpoMock := repomock.NewMockCredentialsRepository(mockCtrl) - crdRpoMock.EXPECT(). - FindByCredentials("google", p.UserID). - Times(1). - Return(types.CredentialsSet{c}, nil) + fooCredentials = &types.Credentials{ + ID: id.Next(), + OwnerID: validUser.ID, + Label: "credentials for foo provider", + Kind: "foo", + Credentials: freshProfileID(), + CreatedAt: time.Time{}, + } - crdRpoMock.EXPECT(). - Update(gomock.Any()). - Times(1). - Return(c, nil) + barCredentials = &types.Credentials{ + ID: id.Next(), + OwnerID: validUser.ID, + Label: "credentials for bar provider", + Kind: "bar", + Credentials: freshProfileID(), + CreatedAt: time.Time{}, + } - usrRpoMock := repomock.NewMockUserRepository(mockCtrl) - usrRpoMock.EXPECT().FindByID(u.ID).Times(1).Return(u, nil) + cases = []struct { + name string + profile goth.User + user *types.User + err error + }{ + { + "matching by user email", + goth.User{UserID: freshProfileID(), Provider: "-", Email: validUser.Email}, + validUser, + nil}, + { + "unknown profile", + goth.User{UserID: freshProfileID(), Provider: "-", Email: "fresh-from-foo@test.cortezaproject.org"}, + &types.User{Email: "fresh-from-foo@test.cortezaproject.org"}, + nil}, + { + "profile match by provider ID", + goth.User{UserID: fooCredentials.Credentials, Provider: fooCredentials.Kind, Email: "valid+2nd+email@test.cortezaproject.org"}, + validUser, + nil}, + } + ) - svc := makeMockAuthService(usrRpoMock, crdRpoMock) + _ = spew.Dump + + svc := makeMockAuthService() svc.settings.Auth.External.Enabled = true + req.NoError(svc.store.TruncateUsers(ctx)) + req.NoError(svc.store.TruncateCredentials(ctx)) + req.NoError(svc.store.CreateUser(ctx, validUser, suspendedUser)) + req.NoError(svc.store.CreateCredentials(ctx, fooCredentials, barCredentials)) - { - auser, err := svc.External(p) - require.NoError(t, err) - require.True(t, auser.ID == u.ID, "Did not receive expected user") + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req = require.New(t) + + var ( + ru, rerr = svc.External(ctx, c.profile) + ) + + if c.err != nil { + req.EqualError(unwrapGeneric(rerr), c.err.Error()) + return + } + + req.NoError(unwrapGeneric(rerr)) + req.NotNil(ru) + + if c.user == nil { + panic("invalid test case, user should not be nil") + } + + req.Equal(c.user.Email, ru.Email) + }) } } -func TestAuth_External_NonExisting(t *testing.T) { - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() - - var u = &types.User{ID: 300000, Email: "foo@example.tld"} - var c = &types.Credentials{ID: 200000, OwnerID: u.ID} - var p = goth.User{UserID: "some-profile-id", Provider: "google", Email: u.Email} - - crdRpoMock := repomock.NewMockCredentialsRepository(mockCtrl) - crdRpoMock.EXPECT(). - FindByCredentials("google", p.UserID). - Times(1). - Return(types.CredentialsSet{}, nil) - - crdRpoMock.EXPECT(). - Create(&types.Credentials{Kind: "google", OwnerID: u.ID, Credentials: p.UserID}). - Times(1). - Return(c, nil) - - usrRpoMock := repomock.NewMockUserRepository(mockCtrl) - usrRpoMock.EXPECT(). - FindByHandle("foo"). - Times(1). - Return(nil, repository.ErrUserNotFound) - - usrRpoMock.EXPECT(). - FindByEmail(u.Email). - Times(1). - Return(nil, repository.ErrUserNotFound) - - usrRpoMock.EXPECT(). - Create(&types.User{Email: "foo@example.tld", Handle: "foo"}). - Times(1). - Return(u, nil) - - usrRpoMock.EXPECT(). - Total(). - Times(1). - Return(uint(0)) - - svc := makeMockAuthService(usrRpoMock, crdRpoMock) - svc.settings.Auth.External.Enabled = true - - { - auser, err := svc.External(p) - require.NoError(t, err) - require.True(t, auser.ID == u.ID, "Did not receive expected user") - } +func TestAuth_InternalSignU(t *testing.T) { + t.Skip("pending implementation") } -func Test_auth_validateInternalLogin(t *testing.T) { - type args struct { - email string - password string - } - tests := []struct { - name string - args args - wantErr bool - }{ - {name: "no email", args: args{"", ""}, wantErr: true}, - {name: "bad email", args: args{"test", ""}, wantErr: true}, - {name: "no pass", args: args{"test@domain.tld", ""}, wantErr: true}, - //{name: "all good", args: args{"test@domain.tld", "password"}, wantErr: false}, +func TestAuth_InternalLogin(t *testing.T) { + var ( + req = require.New(t) + ctx = context.Background() - // until we get proper mocking, DI for unit testing in place - // this will have to do - } + validPass = "this is a valid password !! 42" + validUser = &types.User{Email: "valid@test.cortezaproject.org", ID: id.Next(), CreatedAt: now(), EmailConfirmed: true} + suspendedUser = &types.User{Email: "suspended@test.cortezaproject.org", ID: id.Next(), CreatedAt: now(), SuspendedAt: nowPtr()} - svc := makeMockAuthService(nil, nil) + tests = []struct { + name string + email string + password string + err error + }{ + { + "with no email", + "", + "", + fmt.Errorf("invalid email")}, + { + "with bad email", + "test", + "", + fmt.Errorf("invalid email")}, + { + "with empty password", + "test@domain.tld", + "", + fmt.Errorf("invalid username and password combination")}, + { + "with valid credentials", + validUser.Email, + validPass, + nil}, + { + "with invalid password", + validUser.Email, + "invalid password", + fmt.Errorf("invalid username and password combination")}, + { + "with suspended user", + suspendedUser.Email, + validPass, + fmt.Errorf("invalid username and password combination")}, + } + ) + + svc := makeMockAuthService() svc.settings.Auth.Internal.Enabled = true + req.NoError(svc.store.TruncateUsers(ctx)) + req.NoError(svc.store.TruncateCredentials(ctx)) + req.NoError(svc.store.CreateUser(ctx, validUser, suspendedUser)) + req.NoError(svc.SetPasswordCredentials(ctx, validUser.ID, validPass)) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if _, err := svc.InternalLogin(tt.args.email, tt.args.password); (err != nil) != tt.wantErr { - t.Errorf("auth.validateInternalLogin() error = %v, wantErr %v", err, tt.wantErr) + req = require.New(t) + + var ( + usr, err = svc.InternalLogin(ctx, tt.email, tt.password) + ) + + if tt.err == nil { + req.NoError(err) + req.NotNil(usr) + } else { + req.EqualError(err, tt.err.Error()) } + }) } } @@ -206,7 +263,7 @@ func Test_auth_checkPassword(t *testing.T) { } svc := auth{ - settings: &types.Settings{}, + settings: &types.AppSettings{}, } for _, tt := range tests { diff --git a/system/service/service.go b/system/service/service.go index 53d8191a3..b21d21387 100644 --- a/system/service/service.go +++ b/system/service/service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "github.com/cortezaproject/corteza-server/pkg/healthcheck" "github.com/cortezaproject/corteza-server/pkg/id" @@ -85,7 +86,7 @@ var ( DefaultSink *sink - DefaultAuth AuthService + DefaultAuth *auth DefaultUser UserService DefaultRole RoleService DefaultOrganisation OrganisationService @@ -188,7 +189,7 @@ func Initialize(ctx context.Context, log *zap.Logger, s interface{}, c Config) ( hcd.Add(store.Healthcheck(DefaultStore), "Store/System") DefaultAuthNotification = AuthNotification(CurrentSettings) - DefaultAuth = Auth(ctx) + DefaultAuth = Auth() DefaultUser = User(ctx) DefaultRole = Role(ctx) DefaultOrganisation = Organisation(ctx) @@ -215,3 +216,21 @@ func Watchers(ctx context.Context) { // Reloading permissions on change DefaultPermissions.Watch(ctx) } + +// isGeneric returns true if given error is generic +func isGeneric(err error) bool { + g, ok := err.(interface{ IsGeneric() bool }) + return ok && g != nil && g.IsGeneric() +} + +// unwrapGeneric unwraps error if error is generic (and wrapped) +func unwrapGeneric(err error) error { + for { + if isGeneric(err) { + err = errors.Unwrap(err) + continue + } + + return err + } +} diff --git a/system/service/user.go b/system/service/user.go index a5c089715..0db1fe396 100644 --- a/system/service/user.go +++ b/system/service/user.go @@ -2,6 +2,7 @@ package service import ( "context" + "github.com/cortezaproject/corteza-server/store" "io" "net/mail" "regexp" @@ -47,8 +48,8 @@ type ( } userAuth interface { - checkPasswordStrength(string) bool - changePassword(uint64, string) error + CheckPasswordStrength(string) bool + SetPasswordCredentials(context.Context, uint64, string) error } userSubscriptionChecker interface { @@ -328,7 +329,7 @@ func (svc user) Create(new *types.User) (u *types.User, err error) { } if new.Handle == "" { - createHandle(svc.user, new) + createHandle(svc.ctx, DefaultNgStore, new) } if err = svc.UniqueCheck(new); err != nil { @@ -623,11 +624,11 @@ func (svc user) SetPassword(userID uint64, newPassword string) (err error) { return UserErrNotAllowedToUpdate() } - if !svc.auth.checkPasswordStrength(newPassword) { + if !svc.auth.CheckPasswordStrength(newPassword) { return UserErrPasswordNotSecure() } - if err := svc.auth.changePassword(userID, newPassword); err != nil { + if err := svc.auth.SetPasswordCredentials(svc.ctx, userID, newPassword); err != nil { return err } @@ -699,13 +700,13 @@ rangeLoop: return uu.Walk(s) } -func createHandle(r repository.UserRepository, u *types.User) { +func createHandle(ctx context.Context, s usersStore, u *types.User) { if u.Handle == "" { u.Handle, _ = handle.Cast( // Must not exist before - func(s string) bool { - e, err := r.FindByHandle(s) - return err == repository.ErrUserNotFound && (e == nil || e.ID == u.ID) + func(lookup string) bool { + e, err := s.LookupUserByHandle(ctx, lookup) + return err == store.ErrNotFound && (e == nil || e.ID == u.ID) }, // use name or username u.Name,