diff --git a/system/service/auth.go b/system/service/auth.go index 6eaa1f8b0..f422d8b85 100644 --- a/system/service/auth.go +++ b/system/service/auth.go @@ -2,27 +2,19 @@ package service import ( "context" - "fmt" - "math" - rand2 "math/rand" "regexp" - "strconv" "time" "github.com/cortezaproject/corteza-server/pkg/actionlog" internalAuth "github.com/cortezaproject/corteza-server/pkg/auth" "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/pkg/eventbus" - "github.com/cortezaproject/corteza-server/pkg/filter" "github.com/cortezaproject/corteza-server/pkg/handle" "github.com/cortezaproject/corteza-server/pkg/payload" - "github.com/cortezaproject/corteza-server/pkg/rand" "github.com/cortezaproject/corteza-server/store" "github.com/cortezaproject/corteza-server/system/service/event" "github.com/cortezaproject/corteza-server/system/types" - "github.com/dgryski/dgoogauth" "github.com/markbates/goth" - "golang.org/x/crypto/bcrypt" ) type ( @@ -42,6 +34,11 @@ type ( AuthOptions struct { LimitUsers int + + // how fresh can a password be before we consider it + // reused? + // @todo make this configurable someday + PasswordReuseTimeWindow time.Duration } authAccessController interface { @@ -50,31 +47,8 @@ type ( } ) -const ( - credentialsTypePassword = "password" - credentialsTypePersistentSession = "persistent-session" - credentialsTypeEmailAuthToken = "email-authentication-token" - credentialsTypeResetPasswordToken = "password-reset-token" - credentialsTypeResetPasswordTokenExchanged = "password-reset-token-exchanged" - credentialsTypeCreatePasswordToken = "password-create-token" - credentialsTypeMfaTotpSecret = "mfa-totp-secret" - credentialsTypeMFAEmailOTP = "mfa-email-otp" - - credentialsTokenLength = 32 - - passwordMinLength = 8 - passwordMaxLength = 256 - - tokenReqMaxCount = 5 - tokenReqMaxWindow = time.Minute * 15 -) - var ( reEmail = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") - - oneTokenPerUser = map[string]bool{ - credentialsTypeResetPasswordToken: true, - } ) func defaultProviderValidator(provider string) error { @@ -120,7 +94,7 @@ func Auth(opt AuthOptions) *auth { // // External login/signup does not: // - validate provider on profile, only uses it for matching credentials -func (svc auth) External(ctx context.Context, profile types.ExternalAuthUser) (u *types.User, err error) { +func (svc *auth) External(ctx context.Context, profile types.ExternalAuthUser) (u *types.User, err error) { var ( authProvider = &types.AuthProvider{Provider: profile.Provider} @@ -312,7 +286,7 @@ func (svc auth) External(ctx context.Context, profile types.ExternalAuthUser) (u // 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(ctx context.Context, 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} @@ -336,6 +310,8 @@ func (svc auth) InternalSignUp(ctx context.Context, input *types.User, password } if len(password) == 0 { + // making sure password is not empty + // proper strength check is done a bit lower, after user existance check return AuthErrPasswordNotSecure(aam) } @@ -352,7 +328,8 @@ func (svc auth) InternalSignUp(ctx context.Context, input *types.User, password return err } - if c = cc.CompareHashAndPassword(password, true); c == nil { + // does password match any of the valid credentials? + if !isValidPassword(cc, password) { return AuthErrInvalidCredentials(aam) } @@ -377,9 +354,6 @@ func (svc auth) InternalSignUp(ctx context.Context, input *types.User, password // // return nil,nil - // @note moved the password check higher up so we can terminate the proc - // sooner. - // // The check must be after the login fallback so that we still allow // logins with old passwords in case the policy has changed since then. if !svc.CheckPasswordStrength(password) { @@ -458,7 +432,7 @@ func (svc auth) InternalSignUp(ctx context.Context, input *types.User, password // InternalLogin verifies username/password combination in the internal credentials table // // Expects plain text password as an input -func (svc auth) InternalLogin(ctx context.Context, 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} @@ -467,6 +441,8 @@ func (svc auth) InternalLogin(ctx context.Context, email string, password string credentials: &types.Credentials{Kind: credentialsTypePassword}, user: u, } + + c *types.Credentials ) err = func() error { @@ -479,6 +455,9 @@ func (svc auth) InternalLogin(ctx context.Context, email string, password string } if len(password) == 0 { + // making sure password is not empty + // we're not checking for strength here, users might + // use weak passwords from before the policy was introduced return AuthErrInvalidCredentials() } @@ -500,8 +479,8 @@ func (svc auth) InternalLogin(ctx context.Context, email string, password string return err } - c := cc.CompareHashAndPassword(password, true) - if c == nil { + // find 1st valid credentials that match the hashed password + if !isValidPassword(cc, password) { return AuthErrInvalidCredentials(aam) } @@ -514,73 +493,10 @@ func (svc auth) InternalLogin(ctx context.Context, email string, password string return u, svc.recordAction(ctx, aam, AuthActionAuthenticate, err) } -// checkPassword returns true if given (encrypted) password matches any of the credentials -func (svc auth) CheckPassword(password string, validOnly bool, cc types.CredentialsSet) bool { - return cc.CompareHashAndPassword(password, validOnly) != nil -} - -// SetPassword sets new password for a user -// -// This function also records an action -func (svc auth) SetPassword(ctx context.Context, userID uint64, password string) (err error) { - var ( - u *types.User - cc types.CredentialsSet - - aam = &authActionProps{ - user: u, - credentials: &types.Credentials{Kind: credentialsTypePassword}, - } - ) - - err = func() error { - if !svc.settings.Auth.Internal.Enabled { - return AuthErrInternalLoginDisabledByConfig(aam) - } - - if !svc.CheckPasswordStrength(password) { - return AuthErrPasswordNotSecure(aam) - } - - u, err = store.LookupUserByID(ctx, svc.store, userID) - if errors.IsNotFound(err) { - return AuthErrPasswordChangeFailedForUnknownUser(aam) - } - - aam.setUser(u) - ctx = internalAuth.SetIdentityToContext(ctx, u) - - cc, _, err = store.SearchCredentials(ctx, svc.store, types.CredentialsFilter{ - Kind: credentialsTypePassword, - OwnerID: userID, - Deleted: filter.StateInclusive}) - - if err != nil { - return err - } - - if svc.CheckPassword(password, true, cc) { - return AuthErrPasswordResetFailedOldPasswordCheckFailed(aam) - } - - if svc.CheckPassword(password, false, cc) { - return AuthErrPasswordSetFailedReusedPasswordCheckFailed(aam) - } - - if err != svc.SetPasswordCredentials(ctx, userID, password) { - return err - } - - return nil - }() - - 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(ctx context.Context, 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} ) @@ -600,390 +516,8 @@ func (svc auth) Impersonate(ctx context.Context, userID uint64) (u *types.User, return u, svc.recordAction(ctx, aam, AuthActionImpersonate, err) } -// ChangePassword validates old password and changes it with new -func (svc auth) ChangePassword(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) { - var ( - u *types.User - cc types.CredentialsSet - - aam = &authActionProps{ - user: u, - credentials: &types.Credentials{Kind: credentialsTypePassword}, - } - ) - - err = func() error { - if !svc.settings.Auth.Internal.Enabled { - return AuthErrInternalLoginDisabledByConfig(aam) - } - - if len(oldPassword) == 0 { - return AuthErrPasswordNotSecure(aam) - } - - if !svc.CheckPasswordStrength(newPassword) { - return AuthErrPasswordNotSecure(aam) - } - - u, err = store.LookupUserByID(ctx, svc.store, userID) - if errors.IsNotFound(err) { - return AuthErrPasswordChangeFailedForUnknownUser(aam) - } - - aam.setUser(u) - ctx = internalAuth.SetIdentityToContext(ctx, u) - - cc, _, err = store.SearchCredentials(ctx, svc.store, types.CredentialsFilter{ - Kind: credentialsTypePassword, - OwnerID: userID, - Deleted: filter.StateInclusive}) - - if err != nil { - return err - } - - if !svc.CheckPassword(oldPassword, true, cc) { - return AuthErrPasswordResetFailedOldPasswordCheckFailed(aam) - } - - if svc.CheckPassword(newPassword, false, cc) { - return AuthErrPasswordSetFailedReusedPasswordCheckFailed(aam) - } - - if err != svc.SetPasswordCredentials(ctx, userID, newPassword) { - return err - } - - if err = svc.RemoveAccessTokens(ctx, u); err != nil { - return err - } - - return nil - }() - - 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 { - pwdL := len(password) - - // Ignore defined password constraints - if !svc.settings.Auth.Internal.PasswordConstraints.PasswordSecurity { - return true - } - - // Check the password length - minL := math.Max(float64(passwordMinLength), float64(svc.settings.Auth.Internal.PasswordConstraints.MinLength)) - if pwdL < int(minL) || pwdL > passwordMaxLength { - return false - } - - // Check special constraints - // - numeric characters - count := svc.settings.Auth.Internal.PasswordConstraints.MinNumCount - if count > 0 { - rr := regexp.MustCompile("[0-9]") - if uint(len(rr.FindAllStringIndex(password, -1))) < count { - return false - } - } - - // - special characters - count = svc.settings.Auth.Internal.PasswordConstraints.MinSpecialCount - if count > 0 { - rr := regexp.MustCompile("[^0-9a-zA-Z]") - if uint(len(rr.FindAllStringIndex(password, -1))) < count { - return false - } - } - - return true -} - -// SetPasswordCredentials (soft) deletes old password entry and creates a new entry with new password on every change -// -// 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 - ) - - if hash, err = svc.hashPassword(password); err != nil { - return - } - - if err = svc.removePasswordCredentials(ctx, userID); err != nil { - return - } - - // Add new credentials with new password - c := &types.Credentials{ - ID: nextID(), - CreatedAt: *now(), - OwnerID: userID, - Kind: credentialsTypePassword, - Credentials: string(hash), - } - - return store.CreateCredentials(ctx, svc.store, c) -} - -// RemovePasswordCredentials (soft) deletes old password entry -func (svc auth) RemovePasswordCredentials(ctx context.Context, userID uint64) (err error) { - // Do a partial update and soft-delete all - return svc.removePasswordCredentials(ctx, userID) -} - -// RemovePasswordCredentials (soft) deletes old password entry -func (svc auth) removePasswordCredentials(ctx context.Context, userID uint64) (err error) { - var ( - cc types.CredentialsSet - f = types.CredentialsFilter{Kind: credentialsTypePassword, OwnerID: userID} - ) - - if cc, _, err = store.SearchCredentials(ctx, svc.store, f); err != nil { - return nil - } - - // Mark all credentials as deleted - _ = cc.Walk(func(c *types.Credentials) error { - c.DeletedAt = now() - return nil - }) - - // Do a partial update and soft-delete all - return store.UpdateCredentials(ctx, svc.store, cc...) -} - -// ValidateEmailConfirmationToken issues a validation token that can be used for -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(ctx context.Context, token string) (user *types.User, err error) { - return svc.loadFromTokenAndConfirmEmail(ctx, token, credentialsTypeResetPasswordToken) -} - -// ValidatePasswordCreateToken validates password create token -func (svc auth) ValidatePasswordCreateToken(ctx context.Context, token string) (user *types.User, err error) { - return svc.loadFromTokenAndConfirmEmail(ctx, token, credentialsTypeCreatePasswordToken) -} - -// PasswordSet checks and returns true if user's password is set -// False is also returned in case user does not exist. -func (svc *auth) PasswordSet(ctx context.Context, email string) (is bool) { - - //svc.settings.Auth.External.Enabled - u, err := store.LookupUserByEmail(ctx, svc.store, email) - if err != nil { - return - } - - cc, _, err := store.SearchCredentials(ctx, svc.store, types.CredentialsFilter{ - OwnerID: u.ID, - Kind: credentialsTypePassword, - }) - if err != nil { - return - } - - if len(cc) > 0 && svc.settings.Auth.Internal.Enabled { - return true - } - - return -} - -// loadFromTokenAndConfirmEmail loads token, confirms user's -func (svc auth) loadFromTokenAndConfirmEmail(ctx context.Context, token, tokenType string) (u *types.User, err error) { - var ( - aam = &authActionProps{ - user: u, - credentials: &types.Credentials{Kind: tokenType}, - } - ) - - err = func() error { - if !svc.settings.Auth.Internal.Enabled { - return AuthErrInternalSignupDisabledByConfig(aam) - } - - u, err = svc.loadUserFromToken(ctx, token, tokenType) - if err != nil { - return err - } - - aam.setUser(u) - ctx = internalAuth.SetIdentityToContext(ctx, u) - - if !u.EmailConfirmed { - // User's email is not confirmed but going through password reset flow - // we can confirm it - u.EmailConfirmed = true - u.UpdatedAt = now() - if err = store.UpdateUser(ctx, svc.store, u); err != nil { - return err - } - } - - if err = svc.LoadRoleMemberships(ctx, u); err != nil { - return err - } - - return nil - }() - - 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(ctx context.Context, token string) (u *types.User, t string, err error) { - var ( - aam = &authActionProps{ - user: u, - credentials: &types.Credentials{Kind: credentialsTypeResetPasswordToken}, - } - ) - - err = func() error { - if !svc.settings.Auth.Internal.Enabled || !svc.settings.Auth.Internal.PasswordReset.Enabled { - return AuthErrPasswordResetDisabledByConfig(aam) - } - - u, err = svc.loadUserFromToken(ctx, token, credentialsTypeResetPasswordToken) - if err != nil { - return AuthErrInvalidToken(aam).Wrap(err) - } - - aam.setUser(u) - ctx = internalAuth.SetIdentityToContext(ctx, u) - - t, err = svc.createUserToken(ctx, u, credentialsTypeResetPasswordTokenExchanged) - if err != nil { - u = nil - t = "" - return AuthErrInvalidToken(aam).Wrap(err) - } - - return nil - }() - - return u, t, svc.recordAction(ctx, aam, AuthActionExchangePasswordResetToken, err) -} - -func (svc auth) SendEmailAddressConfirmationToken(ctx context.Context, u *types.User) (err error) { - var ( - token string - - aam = &authActionProps{ - user: u, - credentials: &types.Credentials{Kind: credentialsTypeEmailAuthToken}, - } - ) - - if token, err = svc.createUserToken(ctx, u, credentialsTypeEmailAuthToken); err != nil { - return - } - - if err = svc.notifications.EmailConfirmation(ctx, u.Email, token); err != nil { - return - } - - return svc.recordAction(ctx, aam, AuthActionSendEmailConfirmationToken, err) -} - -// SendPasswordResetToken sends password reset token to email -func (svc auth) SendPasswordResetToken(ctx context.Context, email string) (err error) { - var ( - u *types.User - - aam = &authActionProps{ - user: u, - email: email, - } - ) - - err = func() error { - if !svc.settings.Auth.Internal.Enabled || !svc.settings.Auth.Internal.PasswordReset.Enabled { - return AuthErrPasswordResetDisabledByConfig(aam) - } - - if u, err = store.LookupUserByEmail(ctx, svc.store, email); err != nil { - return err - } - - ctx = internalAuth.SetIdentityToContext(ctx, u) - - if err = svc.sendPasswordResetToken(ctx, u); err != nil { - return err - } - - return nil - }() - - return svc.recordAction(ctx, aam, AuthActionSendPasswordResetToken, err) -} - -func (svc auth) sendPasswordResetToken(ctx context.Context, u *types.User) (err error) { - token, err := svc.createUserToken(ctx, u, credentialsTypeResetPasswordToken) - if err != nil { - return err - } - - return svc.notifications.PasswordReset(ctx, u.Email, token) -} - -// GeneratePasswordCreateToken generates password create token -func (svc auth) GeneratePasswordCreateToken(ctx context.Context, email string) (url string, err error) { - var ( - u *types.User - - aam = &authActionProps{ - user: u, - email: email, - } - ) - - err = func() error { - if !svc.settings.Auth.Internal.Enabled || !svc.settings.Auth.Internal.PasswordCreate.Enabled { - return AuthErrPasswordCreateDisabledByConfig(aam) - } - - if u, err = store.LookupUserByEmail(ctx, svc.store, email); err != nil { - return err - } - - ctx = internalAuth.SetIdentityToContext(ctx, u) - - if url, err = svc.sendPasswordCreateToken(ctx, u); err != nil { - return err - } - - return nil - }() - - return url, svc.recordAction(ctx, aam, AuthActionGeneratePasswordCreateToken, err) -} - -func (svc auth) sendPasswordCreateToken(ctx context.Context, u *types.User) (url string, err error) { - token, err := svc.createUserToken(ctx, u, credentialsTypeCreatePasswordToken) - if err != nil { - return - } - - return svc.notifications.PasswordCreate(token) -} - // procLogin fn performs standard validation, credentials-update tasks and triggers events -func (svc auth) procLogin(ctx context.Context, s store.Storer, u *types.User, c *types.Credentials, p *types.AuthProvider) (err error) { +func (svc *auth) procLogin(ctx context.Context, s store.Storer, u *types.User, c *types.Credentials, p *types.AuthProvider) (err error) { if err = svc.eventbus.WaitFor(ctx, event.AuthBeforeLogin(u, p)); err != nil { return err } @@ -1049,223 +583,8 @@ func (svc auth) procLogin(ctx context.Context, s store.Storer, u *types.User, c return nil } -// Loads user from token and removes that token right after -func (svc auth) loadUserFromToken(ctx context.Context, token, kind string) (u *types.User, _ error) { - var ( - aam = &authActionProps{ - credentials: &types.Credentials{Kind: kind}, - } - ) - - return u, svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) (err error) { - credentialsID, credentials := validateToken(token) - if credentialsID == 0 { - return AuthErrInvalidToken(aam) - } - - c, err := store.LookupCredentialsByID(ctx, s, credentialsID) - if errors.IsNotFound(err) { - return AuthErrInvalidToken(aam) - } - - aam.setCredentials(c) - - if err != nil { - return - } - - if err = store.DeleteCredentialsByID(ctx, s, c.ID); err != nil { - return - } - - if !c.Valid() || c.Credentials != credentials { - return AuthErrInvalidToken(aam) - } - - u, err = store.LookupUserByID(ctx, s, c.OwnerID) - if err != nil { - return err - } - - aam.setUser(u) - - // context will be updated with new identity - // in the caller fn - - if !u.Valid() { - return AuthErrInvalidCredentials(aam) - } - - return nil - }) -} - -// Generates & stores user token -// it returns combined value of token + token ID to help with the lookups -func (svc auth) createUserToken(ctx context.Context, u *types.User, kind string) (token string, err error) { - var ( - expiresAt time.Time - aam = &authActionProps{ - user: u, - credentials: &types.Credentials{Kind: kind}, - } - ) - - err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) (err error) { - if u == nil || u.ID == 0 { - return AuthErrGeneric() - } - - // Rate limit requests - cc, _, err := store.SearchCredentials(ctx, s, types.CredentialsFilter{ - OwnerID: u.ID, - Kind: kind, - - // we want to count deleted tokens as well - Deleted: filter.StateInclusive, - }) - - if err != nil { - return err - } - - // gt/eq since this current request is not yet stored - if err = svc.checkTokenRate(cc, tokenReqMaxWindow, tokenReqMaxCount); err != nil { - return - } - - // removes expired and soft-deleted tokens - // and enforces one-token-per-user rule - if err = svc.cleanupCredentials(ctx, s, cc); err != nil { - return - } - - switch kind { - case credentialsTypeMFAEmailOTP: - expSec := svc.settings.Auth.MultiFactor.EmailOTP.Expires - if expSec == 0 { - expSec = 60 - } - - expiresAt = now().Add(time.Second * time.Duration(expSec)) - - // random number, 6 chars - token = fmt.Sprintf("%06d", rand2.Int())[0:6] - case credentialsTypeCreatePasswordToken: - expSec := svc.settings.Auth.Internal.PasswordCreate.Expires - if expSec == 0 { - expSec = 24 - } - - expiresAt = now().Add(time.Hour * time.Duration(expSec)) - - // random password string, "3i[g0|)z" - token = fmt.Sprintf("%s", rand.Password(credentialsTokenLength)) - default: - // 1h expiration for all tokens send via email - expiresAt = now().Add(time.Minute * 60) - token = string(rand.Bytes(credentialsTokenLength)) - } - - c := &types.Credentials{ - ID: nextID(), - CreatedAt: *now(), - OwnerID: u.ID, - Kind: kind, - Credentials: token, - ExpiresAt: &expiresAt, - } - - err = store.CreateCredentials(ctx, s, c) - - if err != nil { - return err - } - - switch kind { - case credentialsTypeMFAEmailOTP: - // do not alter the final token - default: - // suffixing tokens with credentials ID - // this will help us with token lookups - token = fmt.Sprintf("%s%d", token, c.ID) - } - - return nil - }) - - return token, svc.recordAction(ctx, aam, AuthActionIssueToken, err) -} - -// checks existing tokens and ensure that the creation rate is within limits -func (svc auth) checkTokenRate(cc types.CredentialsSet, window time.Duration, max int) error { - if len(cc) == 0 || window == 0 || max == 0 { - return nil - } - - var ( - cutoff = now().Add(window * -1) - count = 0 - ) - - for _, c := range cc { - if c.CreatedAt.Before(cutoff) { - // skip tokens created before cutoff - continue - } - - count++ - - if count > max { - break - } - } - - if count > max { - return AuthErrRateLimitExceeded() - } - - return nil -} - -func (svc auth) cleanupCredentials(ctx context.Context, s store.Credentials, cc types.CredentialsSet) (err error) { - var ( - update types.CredentialsSet - remove types.CredentialsSet - ) - - for _, c := range cc { - switch { - case oneTokenPerUser[c.Kind]: - // if token type is shortlisted in one-token-per-user - // mark all existing tokens as deleted if to - // - // only want to mark them as deleted ad - c.DeletedAt = now() - update = append(update, c) - - case false, // just a placeholder - (c.DeletedAt != nil && c.DeletedAt.Add(tokenReqMaxWindow).Before(*now())), - (c.ExpiresAt != nil && c.ExpiresAt.Before(*now())): - // schedule all soft-deleted and expired token - // for removal - remove = append(remove, c) - } - } - - if err = store.UpdateCredentials(ctx, s, update...); err != nil { - return - } - - if err = store.DeleteCredentials(ctx, s, remove...); err != nil { - return - } - - return -} - // Automatically promotes user to super-administrator if it is the first non-system user in the database -func (svc auth) autoPromote(ctx context.Context, u *types.User) (err error) { +func (svc *auth) autoPromote(ctx context.Context, u *types.User) (err error) { var ( c uint aam = &authActionProps{user: u, role: &types.Role{}} @@ -1292,341 +611,10 @@ func (svc auth) autoPromote(ctx context.Context, u *types.User) (err error) { return nil } -// ValidateTOTP checks given code with the current secret -// Fn fails if no secret is set -func (svc auth) ValidateTOTP(ctx context.Context, code string) (err error) { - var ( - c *types.Credentials - u *types.User - kind = credentialsTypeMfaTotpSecret - aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} - i = internalAuth.GetIdentityFromContext(ctx) - ) - - err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) error { - if !svc.settings.Auth.MultiFactor.TOTP.Enabled { - return AuthErrDisabledMFAWithTOTP() - } - - u, err = store.LookupUserByID(ctx, svc.store, i.Identity()) - if errors.IsNotFound(err) { - return AuthErrFailedForUnknownUser(aam) - } - - aam.setUser(u) - - if !u.Meta.SecurityPolicy.MFA.EnforcedTOTP { - return AuthErrUnconfiguredTOTP() - } - - if c, err = svc.getTOTPSecret(ctx, s, u.ID); err != nil { - return err - } else if err = svc.validateTOTP(c.Credentials, code); err != nil { - return err - } else { - c.LastUsedAt = now() - return store.UpdateCredentials(ctx, s, c) - } - }) - - return svc.recordAction(ctx, aam, AuthActionTotpValidate, err) -} - -// ConfigureTOTP stores totp secret in user's credentials -// -// It returns the user with security policy changes -func (svc auth) ConfigureTOTP(ctx context.Context, secret string, code string) (u *types.User, err error) { - var ( - kind = credentialsTypeMfaTotpSecret - aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} - i = internalAuth.GetIdentityFromContext(ctx) - ) - - err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) error { - if !svc.settings.Auth.MultiFactor.TOTP.Enabled { - return AuthErrDisabledMFAWithTOTP() - } - - if err = svc.validateTOTP(secret, code); err != nil { - return err - } - - u, err = store.LookupUserByID(ctx, svc.store, i.Identity()) - if errors.IsNotFound(err) { - return AuthErrFailedForUnknownUser(aam) - } - - aam.setUser(u) - - if i == nil || u.Meta.SecurityPolicy.MFA.EnforcedTOTP { - // TOTP is already enforced on the user, - // this means that we cannot just allow the change - return AuthErrNotAllowedToConfigureTOTP() - } - - // revoke (soft-delete) all existing secrets - if err = svc.revokeAllTOTP(ctx, s, u.ID); err != nil { - return err - } - - cred := &types.Credentials{ - ID: nextID(), - CreatedAt: *now(), - OwnerID: u.ID, - Kind: kind, - Credentials: secret, - } - - if err = store.CreateCredentials(ctx, s, cred); err != nil { - return err - } - - u.Meta.SecurityPolicy.MFA.EnforcedTOTP = true - return store.UpdateUser(ctx, s, u) - }) - - return u, svc.recordAction(ctx, aam, AuthActionTotpConfigure, err) -} - -// RemoveTOTP removes TOTP secret from user's credentials -// -// If user is removing own TOTP code is required -// When removing TOTP for another user, remover shou -// -// It returns the user with security policy changes -func (svc auth) RemoveTOTP(ctx context.Context, userID uint64, code string) (u *types.User, err error) { - var ( - c *types.Credentials - kind = credentialsTypeMfaTotpSecret - aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} - i = internalAuth.GetIdentityFromContext(ctx) - self = i != nil && i.Identity() == userID - ) - - err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) error { - if !svc.settings.Auth.MultiFactor.TOTP.Enabled { - return AuthErrDisabledMFAWithTOTP() - } - if svc.settings.Auth.MultiFactor.TOTP.Enforced { - return AuthErrEnforcedMFAWithTOTP() - } - - u, err = store.LookupUserByID(ctx, svc.store, userID) - if errors.IsNotFound(err) { - return AuthErrFailedForUnknownUser(aam) - } - - aam.setUser(u) - - if i != nil && u != nil && self { - if c, err = svc.getTOTPSecret(ctx, s, u.ID); err != nil { - return err - } - - if err = svc.validateTOTP(c.Credentials, code); err != nil { - return err - } - } else if !svc.ac.CanUpdateUser(ctx, u) { - return AuthErrNotAllowedToRemoveTOTP() - } - - if err = svc.revokeAllTOTP(ctx, s, u.ID); err != nil { - return err - } - - u.Meta.SecurityPolicy.MFA.EnforcedTOTP = false - return store.UpdateUser(ctx, s, u) - - }) - - return u, svc.recordAction(ctx, aam, AuthActionTotpConfigure, err) -} - -// Searches for all valid TOTP secret credentials -func (svc auth) getTOTPSecret(ctx context.Context, s store.Credentials, userID uint64) (*types.Credentials, error) { - cc, _, err := store.SearchCredentials(ctx, s, types.CredentialsFilter{ - OwnerID: userID, - Kind: credentialsTypeMfaTotpSecret, - Deleted: filter.StateExcluded, - }) - - if err != nil { - return nil, err - } - - if len(cc) != 1 { - return nil, AuthErrInvalidTOTP() - } - - return cc[0], nil -} - -// Verifies TOTP code and secret -func (auth) validateTOTP(secret string, code string) error { - // removes all non-numeric characters - code = regexp.MustCompile(`[^0-9]`).ReplaceAllString(code, "") - if len(code) != 6 { - return AuthErrInvalidTOTP() - } - - otpc := &dgoogauth.OTPConfig{ - Secret: secret, - WindowSize: 5, - } - - if ok, err := otpc.Authenticate(code); err != nil { - return AuthErrInvalidTOTP().Wrap(err) - } else if !ok { - return AuthErrInvalidTOTP() - } - - return nil -} - -// Revokes all existing user's TOTPs -func (auth) revokeAllTOTP(ctx context.Context, s store.Credentials, userID uint64) error { - // revoke (soft-delete) all existing secrets - cc, _, err := store.SearchCredentials(ctx, s, types.CredentialsFilter{ - OwnerID: userID, - Kind: credentialsTypeMfaTotpSecret, - Deleted: filter.StateExcluded, - }) - - if err != nil { - return err - } - - return cc.Walk(func(c *types.Credentials) error { - c.DeletedAt = now() - return store.UpdateCredentials(ctx, s, c) - }) -} - -func (svc auth) SendEmailOTP(ctx context.Context) (err error) { - var ( - otp string - u *types.User - kind = credentialsTypeMFAEmailOTP - aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} - i = internalAuth.GetIdentityFromContext(ctx) - ) - - err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) (err error) { - if !svc.settings.Auth.MultiFactor.EmailOTP.Enabled { - return AuthErrDisabledMFAWithEmailOTP() - } - - u, err = store.LookupUserByID(ctx, svc.store, i.Identity()) - if errors.IsNotFound(err) { - return AuthErrFailedForUnknownUser(aam) - } - - aam.setUser(u) - - if otp, err = svc.createUserToken(ctx, u, kind); err != nil { - return - } - - if err = svc.notifications.EmailOTP(ctx, u.Email, otp); err != nil { - return - } - - return - }) - - return svc.recordAction(ctx, aam, AuthActionSendEmailConfirmationToken, err) -} - -func (svc auth) ConfigureEmailOTP(ctx context.Context, userID uint64, enable bool) (u *types.User, err error) { - var ( - kind = credentialsTypeMFAEmailOTP - aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} - ) - - err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) (err error) { - if !svc.settings.Auth.MultiFactor.EmailOTP.Enabled { - return AuthErrDisabledMFAWithEmailOTP() - } - - if svc.settings.Auth.MultiFactor.EmailOTP.Enforced && !enable { - return AuthErrEnforcedMFAWithEmailOTP() - } - - u, err = store.LookupUserByID(ctx, svc.store, userID) - if errors.IsNotFound(err) { - return AuthErrFailedForUnknownUser(aam) - } - - aam.setUser(u) - u.Meta.SecurityPolicy.MFA.EnforcedEmailOTP = enable - - return store.UpdateUser(ctx, s, u) - }) - - return u, svc.recordAction(ctx, aam, AuthActionSendEmailConfirmationToken, err) -} - -// ValidateEmailOTP issues a validation OTP -func (svc auth) ValidateEmailOTP(ctx context.Context, code string) (err error) { - var ( - cc types.CredentialsSet - u *types.User - kind = credentialsTypeMFAEmailOTP - aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} - i = internalAuth.GetIdentityFromContext(ctx) - ) - - err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) error { - if !svc.settings.Auth.MultiFactor.EmailOTP.Enabled { - return AuthErrDisabledMFAWithEmailOTP() - } - - u, err = store.LookupUserByID(ctx, svc.store, i.Identity()) - if errors.IsNotFound(err) { - return AuthErrFailedForUnknownUser(aam) - } - - aam.setUser(u) - - // removes all non-numeric characters - code = regexp.MustCompile(`[^0-9]`).ReplaceAllString(code, "") - if len(code) != 6 { - return AuthErrInvalidEmailOTP() - } - - cc, _, err = store.SearchCredentials(ctx, s, types.CredentialsFilter{ - OwnerID: u.ID, - Kind: kind, - Deleted: filter.StateExcluded, - }) - - if err != nil { - return err - } - - for _, c := range cc { - if c.ExpiresAt.Before(*now()) { - continue - } - - if c.Credentials != code { - continue - } - - // Credentials found, remove it - return store.DeleteCredentials(ctx, s, c) - } - - return AuthErrInvalidEmailOTP() - }) - - return svc.recordAction(ctx, aam, AuthActionEmailOtpVerify, err) -} - // LoadRoleMemberships loads membership info // // @todo move this to role service -func (svc auth) LoadRoleMemberships(ctx context.Context, u *types.User) error { +func (svc *auth) LoadRoleMemberships(ctx context.Context, u *types.User) error { rr, _, err := store.SearchRoles(ctx, svc.store, types.RoleFilter{MemberID: u.ID}) if err != nil { return err @@ -1636,11 +624,11 @@ func (svc auth) LoadRoleMemberships(ctx context.Context, u *types.User) error { return nil } -func (svc auth) GetProviders() types.ExternalAuthProviderSet { +func (svc *auth) GetProviders() types.ExternalAuthProviderSet { return CurrentSettings.Auth.External.Providers } -func (svc auth) checkLimits(ctx context.Context) error { +func (svc *auth) checkLimits(ctx context.Context) error { if svc.opt.LimitUsers == 0 { return nil } @@ -1653,29 +641,3 @@ func (svc auth) checkLimits(ctx context.Context) error { return nil } - -// RemoveAccessTokens removes all user's access tokens when suspended, -// deleted or security context changes -func (svc auth) RemoveAccessTokens(ctx context.Context, user *types.User) error { - return svc.recordAction( - ctx, - &authActionProps{user: user}, - AuthActionAccessTokensRemoved, - svc.store.DeleteAuthOA2TokenByUserID(ctx, user.ID), - ) -} - -func validateToken(token string) (ID uint64, credentials string) { - // Token = <32 random chars> - if len(token) <= credentialsTokenLength { - return - } - - ID, _ = strconv.ParseUint(token[credentialsTokenLength:], 10, 64) - if ID == 0 { - return - } - - credentials = token[:credentialsTokenLength] - return -} diff --git a/system/service/auth_credentials.go b/system/service/auth_credentials.go new file mode 100644 index 000000000..6306ec93f --- /dev/null +++ b/system/service/auth_credentials.go @@ -0,0 +1,1150 @@ +package service + +// part of auth service +// collection of functions that handle password checking, setting, resetting, changing +// +// general credential handling functions should still be part of auth.go + +import ( + "context" + "fmt" + internalAuth "github.com/cortezaproject/corteza-server/pkg/auth" + "github.com/cortezaproject/corteza-server/pkg/errors" + "github.com/cortezaproject/corteza-server/pkg/filter" + "github.com/cortezaproject/corteza-server/pkg/rand" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/system/types" + "github.com/dgryski/dgoogauth" + "golang.org/x/crypto/bcrypt" + "math" + rand2 "math/rand" + "regexp" + "sort" + "strconv" + "time" +) + +const ( + credentialsTypePassword = "password" + credentialsTypePersistentSession = "persistent-session" + credentialsTypeEmailAuthToken = "email-authentication-token" + credentialsTypeResetPasswordToken = "password-reset-token" + credentialsTypeResetPasswordTokenExchanged = "password-reset-token-exchanged" + credentialsTypeCreatePasswordToken = "password-create-token" + credentialsTypeMfaTotpSecret = "mfa-totp-secret" + credentialsTypeMFAEmailOTP = "mfa-email-otp" + + credentialsTokenLength = 32 + + tokenReqMaxCount = 5 + tokenReqMaxWindow = time.Minute * 15 + + passwordMinLength = 8 + passwordMaxLength = 256 +) + +var ( + oneTokenPerUser = map[string]bool{ + credentialsTypeResetPasswordToken: true, + } +) + +// ValidateEmailConfirmationToken issues a validation token that can be used for +func (svc *auth) ValidateEmailConfirmationToken(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(ctx context.Context, token, tokenType string) (u *types.User, err error) { + var ( + aam = &authActionProps{ + user: u, + credentials: &types.Credentials{Kind: tokenType}, + } + ) + + err = func() error { + if !svc.settings.Auth.Internal.Enabled { + return AuthErrInternalSignupDisabledByConfig(aam) + } + + u, err = svc.loadUserFromToken(ctx, token, tokenType) + if err != nil { + return err + } + + aam.setUser(u) + ctx = internalAuth.SetIdentityToContext(ctx, u) + + if !u.EmailConfirmed { + // User's email is not confirmed but going through password reset flow + // we can confirm it + u.EmailConfirmed = true + u.UpdatedAt = now() + if err = store.UpdateUser(ctx, svc.store, u); err != nil { + return err + } + } + + if err = svc.LoadRoleMemberships(ctx, u); err != nil { + return err + } + + return nil + }() + + return u, svc.recordAction(ctx, aam, AuthActionConfirmEmail, err) +} + +func (svc *auth) SendEmailAddressConfirmationToken(ctx context.Context, u *types.User) (err error) { + var ( + token string + + aam = &authActionProps{ + user: u, + credentials: &types.Credentials{Kind: credentialsTypeEmailAuthToken}, + } + ) + + if token, err = svc.createUserToken(ctx, u, credentialsTypeEmailAuthToken); err != nil { + return + } + + if err = svc.notifications.EmailConfirmation(ctx, u.Email, token); err != nil { + return + } + + return svc.recordAction(ctx, aam, AuthActionSendEmailConfirmationToken, err) +} + +// Loads user from token and removes that token right after +func (svc *auth) loadUserFromToken(ctx context.Context, token, kind string) (u *types.User, _ error) { + var ( + aam = &authActionProps{ + credentials: &types.Credentials{Kind: kind}, + } + ) + + return u, svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) (err error) { + credentialsID, credentials := validateToken(token) + if credentialsID == 0 { + return AuthErrInvalidToken(aam) + } + + c, err := store.LookupCredentialsByID(ctx, s, credentialsID) + if errors.IsNotFound(err) { + return AuthErrInvalidToken(aam) + } + + aam.setCredentials(c) + + if err != nil { + return + } + + if err = store.DeleteCredentialsByID(ctx, s, c.ID); err != nil { + return + } + + if !c.Valid() || c.Credentials != credentials { + return AuthErrInvalidToken(aam) + } + + u, err = store.LookupUserByID(ctx, s, c.OwnerID) + if err != nil { + return err + } + + aam.setUser(u) + + // context will be updated with new identity + // in the caller fn + + if !u.Valid() { + return AuthErrInvalidCredentials(aam) + } + + return nil + }) +} + +// Generates & stores user token +// it returns combined value of token + token ID to help with the lookups +func (svc *auth) createUserToken(ctx context.Context, u *types.User, kind string) (token string, err error) { + var ( + expiresAt time.Time + aam = &authActionProps{ + user: u, + credentials: &types.Credentials{Kind: kind}, + } + ) + + err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) (err error) { + if u == nil || u.ID == 0 { + return AuthErrGeneric() + } + + // Rate limit requests + cc, _, err := store.SearchCredentials(ctx, s, types.CredentialsFilter{ + OwnerID: u.ID, + Kind: kind, + + // we want to count deleted tokens as well + Deleted: filter.StateInclusive, + }) + + if err != nil { + return err + } + + // gt/eq since this current request is not yet stored + if err = svc.checkTokenRate(cc, tokenReqMaxWindow, tokenReqMaxCount); err != nil { + return + } + + // removes expired and soft-deleted tokens + // and enforces one-token-per-user rule + if err = svc.cleanupCredentials(ctx, s, cc); err != nil { + return + } + + switch kind { + case credentialsTypeMFAEmailOTP: + expSec := svc.settings.Auth.MultiFactor.EmailOTP.Expires + if expSec == 0 { + expSec = 60 + } + + expiresAt = now().Add(time.Second * time.Duration(expSec)) + + // random number, 6 chars + token = fmt.Sprintf("%06d", rand2.Int())[0:6] + case credentialsTypeCreatePasswordToken: + expSec := svc.settings.Auth.Internal.PasswordCreate.Expires + if expSec == 0 { + expSec = 24 + } + + expiresAt = now().Add(time.Hour * time.Duration(expSec)) + + // random password string, "3i[g0|)z" + token = fmt.Sprintf("%s", rand.Password(credentialsTokenLength)) + default: + // 1h expiration for all tokens send via email + expiresAt = now().Add(time.Minute * 60) + token = string(rand.Bytes(credentialsTokenLength)) + } + + c := &types.Credentials{ + ID: nextID(), + CreatedAt: *now(), + OwnerID: u.ID, + Kind: kind, + Credentials: token, + ExpiresAt: &expiresAt, + } + + err = store.CreateCredentials(ctx, s, c) + + if err != nil { + return err + } + + switch kind { + case credentialsTypeMFAEmailOTP: + // do not alter the final token + default: + // suffixing tokens with credentials ID + // this will help us with token lookups + token = fmt.Sprintf("%s%d", token, c.ID) + } + + return nil + }) + + return token, svc.recordAction(ctx, aam, AuthActionIssueToken, err) +} + +// checks existing tokens and ensure that the creation rate is within limits +func (svc *auth) checkTokenRate(cc types.CredentialsSet, window time.Duration, max int) error { + if len(cc) == 0 || window == 0 || max == 0 { + return nil + } + + var ( + cutoff = now().Add(window * -1) + count = 0 + ) + + for _, c := range cc { + if c.CreatedAt.Before(cutoff) { + // skip tokens created before cutoff + continue + } + + count++ + + if count > max { + break + } + } + + if count > max { + return AuthErrRateLimitExceeded() + } + + return nil +} + +func (svc *auth) cleanupCredentials(ctx context.Context, s store.Credentials, cc types.CredentialsSet) (err error) { + var ( + update types.CredentialsSet + remove types.CredentialsSet + ) + + for _, c := range cc { + switch { + case oneTokenPerUser[c.Kind]: + // if token type is shortlisted in one-token-per-user + // mark all existing tokens as deleted if to + // + // only want to mark them as deleted ad + c.DeletedAt = now() + update = append(update, c) + + case false, // just a placeholder + (c.DeletedAt != nil && c.DeletedAt.Add(tokenReqMaxWindow).Before(*now())), + (c.ExpiresAt != nil && c.ExpiresAt.Before(*now())): + // schedule all soft-deleted and expired token + // for removal + remove = append(remove, c) + } + } + + if err = store.UpdateCredentials(ctx, s, update...); err != nil { + return + } + + if err = store.DeleteCredentials(ctx, s, remove...); err != nil { + return + } + + return +} + +// SendPasswordResetToken sends password reset token to email +func (svc *auth) SendPasswordResetToken(ctx context.Context, email string) (err error) { + var ( + u *types.User + + aam = &authActionProps{ + user: u, + email: email, + } + ) + + err = func() error { + if !svc.settings.Auth.Internal.Enabled || !svc.settings.Auth.Internal.PasswordReset.Enabled { + return AuthErrPasswordResetDisabledByConfig(aam) + } + + if u, err = store.LookupUserByEmail(ctx, svc.store, email); err != nil { + return err + } + + ctx = internalAuth.SetIdentityToContext(ctx, u) + + if err = svc.sendPasswordResetToken(ctx, u); err != nil { + return err + } + + return nil + }() + + return svc.recordAction(ctx, aam, AuthActionSendPasswordResetToken, err) +} + +func (svc *auth) sendPasswordResetToken(ctx context.Context, u *types.User) (err error) { + token, err := svc.createUserToken(ctx, u, credentialsTypeResetPasswordToken) + if err != nil { + return err + } + + return svc.notifications.PasswordReset(ctx, u.Email, token) +} + +// GeneratePasswordCreateToken generates password create token +func (svc *auth) GeneratePasswordCreateToken(ctx context.Context, email string) (url string, err error) { + var ( + u *types.User + + aam = &authActionProps{ + user: u, + email: email, + } + ) + + err = func() error { + if !svc.settings.Auth.Internal.Enabled || !svc.settings.Auth.Internal.PasswordCreate.Enabled { + return AuthErrPasswordCreateDisabledByConfig(aam) + } + + if u, err = store.LookupUserByEmail(ctx, svc.store, email); err != nil { + return err + } + + ctx = internalAuth.SetIdentityToContext(ctx, u) + + if url, err = svc.sendPasswordCreateToken(ctx, u); err != nil { + return err + } + + return nil + }() + + return url, svc.recordAction(ctx, aam, AuthActionGeneratePasswordCreateToken, err) +} + +func (svc *auth) sendPasswordCreateToken(ctx context.Context, u *types.User) (url string, err error) { + token, err := svc.createUserToken(ctx, u, credentialsTypeCreatePasswordToken) + if err != nil { + return + } + + return svc.notifications.PasswordCreate(token) +} + +// ValidatePasswordResetToken validates password reset token +func (svc *auth) ValidatePasswordResetToken(ctx context.Context, token string) (user *types.User, err error) { + return svc.loadFromTokenAndConfirmEmail(ctx, token, credentialsTypeResetPasswordToken) +} + +// ValidatePasswordCreateToken validates password create token +func (svc *auth) ValidatePasswordCreateToken(ctx context.Context, token string) (user *types.User, err error) { + return svc.loadFromTokenAndConfirmEmail(ctx, token, credentialsTypeCreatePasswordToken) +} + +// ExchangePasswordResetToken exchanges reset password token for a new one and returns it with user info +func (svc *auth) ExchangePasswordResetToken(ctx context.Context, token string) (u *types.User, t string, err error) { + var ( + aam = &authActionProps{ + user: u, + credentials: &types.Credentials{Kind: credentialsTypeResetPasswordToken}, + } + ) + + err = func() error { + if !svc.settings.Auth.Internal.Enabled || !svc.settings.Auth.Internal.PasswordReset.Enabled { + return AuthErrPasswordResetDisabledByConfig(aam) + } + + u, err = svc.loadUserFromToken(ctx, token, credentialsTypeResetPasswordToken) + if err != nil { + return AuthErrInvalidToken(aam).Wrap(err) + } + + aam.setUser(u) + ctx = internalAuth.SetIdentityToContext(ctx, u) + + t, err = svc.createUserToken(ctx, u, credentialsTypeResetPasswordTokenExchanged) + if err != nil { + u = nil + t = "" + return AuthErrInvalidToken(aam).Wrap(err) + } + + return nil + }() + + return u, t, svc.recordAction(ctx, aam, AuthActionExchangePasswordResetToken, err) +} + +// ChangePassword validates old password and changes it with new +func (svc *auth) ChangePassword(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) { + var ( + u *types.User + cc types.CredentialsSet + + aam = &authActionProps{ + user: u, + credentials: &types.Credentials{Kind: credentialsTypePassword}, + } + ) + + err = func() error { + if !svc.settings.Auth.Internal.Enabled { + return AuthErrInternalLoginDisabledByConfig(aam) + } + + if len(oldPassword) == 0 { + return AuthErrPasswordNotSecure(aam) + } + + if !svc.CheckPasswordStrength(newPassword) { + return AuthErrPasswordNotSecure(aam) + } + + u, err = store.LookupUserByID(ctx, svc.store, userID) + if errors.IsNotFound(err) { + return AuthErrPasswordChangeFailedForUnknownUser(aam) + } + + aam.setUser(u) + ctx = internalAuth.SetIdentityToContext(ctx, u) + + cc, _, err = store.SearchCredentials(ctx, svc.store, types.CredentialsFilter{ + Kind: credentialsTypePassword, + OwnerID: userID, + Deleted: filter.StateInclusive}) + + if err != nil { + return err + } + + if !isValidPassword(cc, oldPassword) { + return AuthErrPasswordResetFailedOldPasswordCheckFailed(aam) + } + + if isPasswordReused(cc, newPassword, svc.opt.PasswordReuseTimeWindow) { + return AuthErrPasswordSetFailedReusedPasswordCheckFailed(aam) + } + + if err != svc.SetPasswordCredentials(ctx, userID, newPassword) { + return err + } + + if err = svc.RemoveAccessTokens(ctx, u); err != nil { + return err + } + + return nil + }() + + 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 { + pwdL := len(password) + + // Ignore defined password constraints + if !svc.settings.Auth.Internal.PasswordConstraints.PasswordSecurity { + return true + } + + // Check the password length + minL := math.Max(float64(passwordMinLength), float64(svc.settings.Auth.Internal.PasswordConstraints.MinLength)) + if pwdL < int(minL) || pwdL > passwordMaxLength { + return false + } + + // Check special constraints + // - numeric characters + count := svc.settings.Auth.Internal.PasswordConstraints.MinNumCount + if count > 0 { + rr := regexp.MustCompile("[0-9]") + if uint(len(rr.FindAllStringIndex(password, -1))) < count { + return false + } + } + + // - special characters + count = svc.settings.Auth.Internal.PasswordConstraints.MinSpecialCount + if count > 0 { + rr := regexp.MustCompile("[^0-9a-zA-Z]") + if uint(len(rr.FindAllStringIndex(password, -1))) < count { + return false + } + } + + return true +} + +// SetPasswordCredentials (soft) deletes old password entry and creates a new entry with new password on every change +// +// 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 + ) + + if hash, err = svc.hashPassword(password); err != nil { + return + } + + if err = svc.removePasswordCredentials(ctx, userID); err != nil { + return + } + + // Add new credentials with new password + c := &types.Credentials{ + ID: nextID(), + CreatedAt: *now(), + OwnerID: userID, + Kind: credentialsTypePassword, + Credentials: string(hash), + } + + return store.CreateCredentials(ctx, svc.store, c) +} + +//CheckPassword verifies if password matches any of the valid credentials +//func (svc *auth) CheckPassword(cc types.CredentialsSet, password string) bool { +// return findMatchingCredentials(cc, password, true) != nil +//} + +// SetPassword sets new password for a user +// +// This function also records an action +// +// this method is used in 2 scenarios: +// +// SELF: +// user forgot the password and needs to reset it +// there should be protocols prior to this point that +// authenticate and validate users +// +// USER MANAGEMENT: +// administrator is resetting password for another user +// +func (svc *auth) SetPassword(ctx context.Context, userID uint64, password string) (err error) { + var ( + u *types.User + cc types.CredentialsSet + + aam = &authActionProps{ + user: u, + credentials: &types.Credentials{Kind: credentialsTypePassword}, + } + ) + + err = func() error { + if !svc.settings.Auth.Internal.Enabled { + return AuthErrInternalLoginDisabledByConfig(aam) + } + + if !svc.CheckPasswordStrength(password) { + return AuthErrPasswordNotSecure(aam) + } + + u, err = store.LookupUserByID(ctx, svc.store, userID) + if errors.IsNotFound(err) { + return AuthErrPasswordChangeFailedForUnknownUser(aam) + } + + aam.setUser(u) + ctx = internalAuth.SetIdentityToContext(ctx, u) + + cc, _, err = store.SearchCredentials(ctx, svc.store, types.CredentialsFilter{ + Kind: credentialsTypePassword, + OwnerID: userID, + Deleted: filter.StateInclusive}) + + if err != nil { + return err + } + + if isPasswordReused(cc, password, svc.opt.PasswordReuseTimeWindow) { + return AuthErrPasswordSetFailedReusedPasswordCheckFailed(aam) + } + + if err != svc.SetPasswordCredentials(ctx, userID, password) { + return err + } + + return nil + }() + + return svc.recordAction(ctx, aam, AuthActionChangePassword, err) +} + +// PasswordSet checks and returns true if user's password is set +// False is also returned in case user does not exist. +func (svc *auth) PasswordSet(ctx context.Context, email string) (is bool) { + //svc.settings.Auth.External.Enabled + u, err := store.LookupUserByEmail(ctx, svc.store, email) + if err != nil { + return + } + + cc, _, err := store.SearchCredentials(ctx, svc.store, types.CredentialsFilter{ + OwnerID: u.ID, + Kind: credentialsTypePassword, + }) + if err != nil { + return + } + + if len(cc) > 0 && svc.settings.Auth.Internal.Enabled { + return true + } + + return +} + +// RemovePasswordCredentials (soft) deletes old password entry +func (svc *auth) RemovePasswordCredentials(ctx context.Context, userID uint64) (err error) { + // Do a partial update and soft-delete all + return svc.removePasswordCredentials(ctx, userID) +} + +// RemovePasswordCredentials (soft) deletes old password entry +func (svc *auth) removePasswordCredentials(ctx context.Context, userID uint64) (err error) { + var ( + cc types.CredentialsSet + f = types.CredentialsFilter{Kind: credentialsTypePassword, OwnerID: userID} + ) + + if cc, _, err = store.SearchCredentials(ctx, svc.store, f); err != nil { + return nil + } + + // Mark all credentials as deleted + _ = cc.Walk(func(c *types.Credentials) error { + c.DeletedAt = now() + return nil + }) + + // Do a partial update and soft-delete all + return store.UpdateCredentials(ctx, svc.store, cc...) +} + +// RemoveAccessTokens removes all user's access tokens when suspended, +// deleted or security context changes +func (svc *auth) RemoveAccessTokens(ctx context.Context, user *types.User) error { + return svc.recordAction( + ctx, + &authActionProps{user: user}, + AuthActionAccessTokensRemoved, + svc.store.DeleteAuthOA2TokenByUserID(ctx, user.ID), + ) +} + +// ValidateTOTP checks given code with the current secret +// Fn fails if no secret is set +func (svc *auth) ValidateTOTP(ctx context.Context, code string) (err error) { + var ( + c *types.Credentials + u *types.User + kind = credentialsTypeMfaTotpSecret + aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} + i = internalAuth.GetIdentityFromContext(ctx) + ) + + err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) error { + if !svc.settings.Auth.MultiFactor.TOTP.Enabled { + return AuthErrDisabledMFAWithTOTP() + } + + u, err = store.LookupUserByID(ctx, svc.store, i.Identity()) + if errors.IsNotFound(err) { + return AuthErrFailedForUnknownUser(aam) + } + + aam.setUser(u) + + if !u.Meta.SecurityPolicy.MFA.EnforcedTOTP { + return AuthErrUnconfiguredTOTP() + } + + if c, err = svc.getTOTPSecret(ctx, s, u.ID); err != nil { + return err + } else if err = svc.validateTOTP(c.Credentials, code); err != nil { + return err + } else { + c.LastUsedAt = now() + return store.UpdateCredentials(ctx, s, c) + } + }) + + return svc.recordAction(ctx, aam, AuthActionTotpValidate, err) +} + +// ConfigureTOTP stores totp secret in user's credentials +// +// It returns the user with security policy changes +func (svc *auth) ConfigureTOTP(ctx context.Context, secret string, code string) (u *types.User, err error) { + var ( + kind = credentialsTypeMfaTotpSecret + aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} + i = internalAuth.GetIdentityFromContext(ctx) + ) + + err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) error { + if !svc.settings.Auth.MultiFactor.TOTP.Enabled { + return AuthErrDisabledMFAWithTOTP() + } + + if err = svc.validateTOTP(secret, code); err != nil { + return err + } + + u, err = store.LookupUserByID(ctx, svc.store, i.Identity()) + if errors.IsNotFound(err) { + return AuthErrFailedForUnknownUser(aam) + } + + aam.setUser(u) + + if i == nil || u.Meta.SecurityPolicy.MFA.EnforcedTOTP { + // TOTP is already enforced on the user, + // this means that we cannot just allow the change + return AuthErrNotAllowedToConfigureTOTP() + } + + // revoke (soft-delete) all existing secrets + if err = svc.revokeAllTOTP(ctx, s, u.ID); err != nil { + return err + } + + cred := &types.Credentials{ + ID: nextID(), + CreatedAt: *now(), + OwnerID: u.ID, + Kind: kind, + Credentials: secret, + } + + if err = store.CreateCredentials(ctx, s, cred); err != nil { + return err + } + + u.Meta.SecurityPolicy.MFA.EnforcedTOTP = true + return store.UpdateUser(ctx, s, u) + }) + + return u, svc.recordAction(ctx, aam, AuthActionTotpConfigure, err) +} + +// RemoveTOTP removes TOTP secret from user's credentials +// +// If user is removing own TOTP code is required +// When removing TOTP for another user, remover shou +// +// It returns the user with security policy changes +func (svc *auth) RemoveTOTP(ctx context.Context, userID uint64, code string) (u *types.User, err error) { + var ( + c *types.Credentials + kind = credentialsTypeMfaTotpSecret + aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} + i = internalAuth.GetIdentityFromContext(ctx) + self = i != nil && i.Identity() == userID + ) + + err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) error { + if !svc.settings.Auth.MultiFactor.TOTP.Enabled { + return AuthErrDisabledMFAWithTOTP() + } + if svc.settings.Auth.MultiFactor.TOTP.Enforced { + return AuthErrEnforcedMFAWithTOTP() + } + + u, err = store.LookupUserByID(ctx, svc.store, userID) + if errors.IsNotFound(err) { + return AuthErrFailedForUnknownUser(aam) + } + + aam.setUser(u) + + if i != nil && u != nil && self { + if c, err = svc.getTOTPSecret(ctx, s, u.ID); err != nil { + return err + } + + if err = svc.validateTOTP(c.Credentials, code); err != nil { + return err + } + } else if !svc.ac.CanUpdateUser(ctx, u) { + return AuthErrNotAllowedToRemoveTOTP() + } + + if err = svc.revokeAllTOTP(ctx, s, u.ID); err != nil { + return err + } + + u.Meta.SecurityPolicy.MFA.EnforcedTOTP = false + return store.UpdateUser(ctx, s, u) + + }) + + return u, svc.recordAction(ctx, aam, AuthActionTotpConfigure, err) +} + +// Searches for all valid TOTP secret credentials +func (svc *auth) getTOTPSecret(ctx context.Context, s store.Credentials, userID uint64) (*types.Credentials, error) { + cc, _, err := store.SearchCredentials(ctx, s, types.CredentialsFilter{ + OwnerID: userID, + Kind: credentialsTypeMfaTotpSecret, + Deleted: filter.StateExcluded, + }) + + if err != nil { + return nil, err + } + + if len(cc) != 1 { + return nil, AuthErrInvalidTOTP() + } + + return cc[0], nil +} + +// Verifies TOTP code and secret +func (auth) validateTOTP(secret string, code string) error { + // removes all non-numeric characters + code = regexp.MustCompile(`[^0-9]`).ReplaceAllString(code, "") + if len(code) != 6 { + return AuthErrInvalidTOTP() + } + + otpc := &dgoogauth.OTPConfig{ + Secret: secret, + WindowSize: 5, + } + + if ok, err := otpc.Authenticate(code); err != nil { + return AuthErrInvalidTOTP().Wrap(err) + } else if !ok { + return AuthErrInvalidTOTP() + } + + return nil +} + +// Revokes all existing user's TOTPs +func (auth) revokeAllTOTP(ctx context.Context, s store.Credentials, userID uint64) error { + // revoke (soft-delete) all existing secrets + cc, _, err := store.SearchCredentials(ctx, s, types.CredentialsFilter{ + OwnerID: userID, + Kind: credentialsTypeMfaTotpSecret, + Deleted: filter.StateExcluded, + }) + + if err != nil { + return err + } + + return cc.Walk(func(c *types.Credentials) error { + c.DeletedAt = now() + return store.UpdateCredentials(ctx, s, c) + }) +} + +func (svc *auth) SendEmailOTP(ctx context.Context) (err error) { + var ( + otp string + u *types.User + kind = credentialsTypeMFAEmailOTP + aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} + i = internalAuth.GetIdentityFromContext(ctx) + ) + + err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) (err error) { + if !svc.settings.Auth.MultiFactor.EmailOTP.Enabled { + return AuthErrDisabledMFAWithEmailOTP() + } + + u, err = store.LookupUserByID(ctx, svc.store, i.Identity()) + if errors.IsNotFound(err) { + return AuthErrFailedForUnknownUser(aam) + } + + aam.setUser(u) + + if otp, err = svc.createUserToken(ctx, u, kind); err != nil { + return + } + + if err = svc.notifications.EmailOTP(ctx, u.Email, otp); err != nil { + return + } + + return + }) + + return svc.recordAction(ctx, aam, AuthActionSendEmailConfirmationToken, err) +} + +func (svc *auth) ConfigureEmailOTP(ctx context.Context, userID uint64, enable bool) (u *types.User, err error) { + var ( + kind = credentialsTypeMFAEmailOTP + aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} + ) + + err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) (err error) { + if !svc.settings.Auth.MultiFactor.EmailOTP.Enabled { + return AuthErrDisabledMFAWithEmailOTP() + } + + if svc.settings.Auth.MultiFactor.EmailOTP.Enforced && !enable { + return AuthErrEnforcedMFAWithEmailOTP() + } + + u, err = store.LookupUserByID(ctx, svc.store, userID) + if errors.IsNotFound(err) { + return AuthErrFailedForUnknownUser(aam) + } + + aam.setUser(u) + u.Meta.SecurityPolicy.MFA.EnforcedEmailOTP = enable + + return store.UpdateUser(ctx, s, u) + }) + + return u, svc.recordAction(ctx, aam, AuthActionSendEmailConfirmationToken, err) +} + +// ValidateEmailOTP issues a validation OTP +func (svc *auth) ValidateEmailOTP(ctx context.Context, code string) (err error) { + var ( + cc types.CredentialsSet + u *types.User + kind = credentialsTypeMFAEmailOTP + aam = &authActionProps{credentials: &types.Credentials{Kind: kind}} + i = internalAuth.GetIdentityFromContext(ctx) + ) + + err = svc.store.Tx(ctx, func(ctx context.Context, s store.Storer) error { + if !svc.settings.Auth.MultiFactor.EmailOTP.Enabled { + return AuthErrDisabledMFAWithEmailOTP() + } + + u, err = store.LookupUserByID(ctx, svc.store, i.Identity()) + if errors.IsNotFound(err) { + return AuthErrFailedForUnknownUser(aam) + } + + aam.setUser(u) + + // removes all non-numeric characters + code = regexp.MustCompile(`[^0-9]`).ReplaceAllString(code, "") + if len(code) != 6 { + return AuthErrInvalidEmailOTP() + } + + cc, _, err = store.SearchCredentials(ctx, s, types.CredentialsFilter{ + OwnerID: u.ID, + Kind: kind, + Deleted: filter.StateExcluded, + }) + + if err != nil { + return err + } + + for _, c := range cc { + if c.ExpiresAt.Before(*now()) { + continue + } + + if c.Credentials != code { + continue + } + + // Credentials found, remove it + return store.DeleteCredentials(ctx, s, c) + } + + return AuthErrInvalidEmailOTP() + }) + + return svc.recordAction(ctx, aam, AuthActionEmailOtpVerify, err) +} + +func validateToken(token string) (ID uint64, credentials string) { + // Token = <32 random chars> + if len(token) <= credentialsTokenLength { + return + } + + ID, _ = strconv.ParseUint(token[credentialsTokenLength:], 10, 64) + if ID == 0 { + return + } + + credentials = token[:credentialsTokenLength] + return +} + +// returns true if (hashed version of a) password is found in the +// list of (valid) credentials +// +// should be used as a parameter for credentialsFilter fn +func isValidPassword(cc []*types.Credentials, password string) bool { + return len(credentialsFilter(cc, 1, skipInvalid, compareHashedCredentials(password))) > 0 +} + +// returns true if (hashed version of a) password is found in the +// list of given credentials +// +// should be used as a parameter for credentialsFilter fn +func isPasswordReused(cc []*types.Credentials, password string, reuseWindow time.Duration) bool { + return len(credentialsFilter(cc, -1, compareHashedCredentials(password), skipNewerCredentials(reuseWindow))) > 0 +} + +// skips all invalid credentials +// +// should be used as a parameter for credentialsFilter fn +func skipInvalid(c *types.Credentials) bool { + return c.Valid() +} + +// +func compareHashedCredentials(password string) func(c *types.Credentials) bool { + var ( + p = []byte(password) + ) + + return func(c *types.Credentials) bool { + return bcrypt.CompareHashAndPassword([]byte(c.Credentials), p) == nil + } +} + +func skipNewerCredentials(cutoff time.Duration) func(c *types.Credentials) bool { + var ( + t = now().Add(cutoff * -1) + ) + + return func(c *types.Credentials) bool { + return c.CreatedAt.Before(t) + } +} + +// CompareHashAndPassword returns first valid credentials with matching hash +func credentialsFilter(cc []*types.Credentials, limit int, mm ...func(*types.Credentials) bool) (out []*types.Credentials) { + // sort credentials by ID (and effectively from newest to oldest) + sort.Slice(cc, func(i, j int) bool { + return cc[i].ID > cc[j].ID + }) + + for _, c := range cc { + if len(c.Credentials) == 0 { + continue + } + + next := false + for _, m := range mm { + if !m(c) { + next = true + break + } + } + + if next { + continue + } + + out = append(out, c) + if limit == len(out) { + break + } + } + + return +} diff --git a/system/service/auth_credentials_test.go b/system/service/auth_credentials_test.go new file mode 100644 index 000000000..bda3c3124 --- /dev/null +++ b/system/service/auth_credentials_test.go @@ -0,0 +1,198 @@ +package service + +import ( + "github.com/cortezaproject/corteza-server/system/types" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "testing" + "time" +) + +func Test_isValidPassword(t *testing.T) { + var ( + pwdPlain = " ... plain password ... " + pwdHashedB, _ = bcrypt.GenerateFromPassword([]byte(pwdPlain), bcrypt.DefaultCost) + pwdHashed = string(pwdHashedB) + pwdUnknown = "$2a$10$8sOZxfZinxnu3bAtpkqEx.wBBwOfci6aG1szgUyxm5.BL2WiLu.ni" + ) + + cases := []struct { + name string + password string + cc types.CredentialsSet + rval bool + }{ + { + name: "empty set", + rval: false, + }, + { + name: "bad pwd", + rval: false, + password: " foo ", + cc: types.CredentialsSet{&types.Credentials{ID: 1, Credentials: pwdHashed}}, + }, + { + name: "invalid credentials", + rval: false, + password: " foo ", + cc: types.CredentialsSet{&types.Credentials{ID: 0, Credentials: pwdHashed}}, + }, + { + name: "ok", + rval: true, + password: pwdPlain, + cc: types.CredentialsSet{&types.Credentials{ID: 1, Credentials: pwdHashed}}, + }, + { + name: "multipass", + rval: true, + password: pwdPlain, + cc: types.CredentialsSet{ + &types.Credentials{ID: 0, Credentials: pwdHashed}, + &types.Credentials{ID: 1, Credentials: pwdUnknown}, + &types.Credentials{ID: 2, Credentials: pwdHashed}, + &types.Credentials{ID: 3, Credentials: ""}, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var ( + req = require.New(t) + rsp = isValidPassword(c.cc, c.password) + ) + + if c.rval { + req.True(rsp) + } else { + req.False(rsp) + } + }) + } +} + +func Test_isPasswordReused(t *testing.T) { + var ( + pwdPlain = " ... plain password ... " + pwdHashedB, _ = bcrypt.GenerateFromPassword([]byte(pwdPlain), bcrypt.DefaultCost) + pwdHashed = string(pwdHashedB) + pwdUnknown = "$2a$10$8sOZxfZinxnu3bAtpkqEx.wBBwOfci6aG1szgUyxm5.BL2WiLu.ni" + ) + + cases := []struct { + name string + password string + window time.Duration + cc types.CredentialsSet + rval bool + }{ + { + name: "no credentials, not reused", + rval: false, + password: pwdPlain, + cc: types.CredentialsSet{}, + }, + { + name: "not reused", + rval: false, + password: pwdPlain, + cc: types.CredentialsSet{ + &types.Credentials{ID: 1, Credentials: pwdUnknown}, + &types.Credentials{ID: 2, Credentials: ""}, + }, + }, + { + name: "present, valid, first", + rval: true, + password: pwdPlain, + cc: types.CredentialsSet{ + &types.Credentials{ID: 1, Credentials: pwdHashed}, + &types.Credentials{ID: 2, Credentials: pwdUnknown}, + &types.Credentials{ID: 3, Credentials: ""}, + }, + }, + { + name: "present, but within time window", + rval: false, + password: pwdPlain, + window: 5 * time.Minute, + cc: types.CredentialsSet{ + &types.Credentials{ID: 1, Credentials: pwdHashed, CreatedAt: *now()}, + &types.Credentials{ID: 2, Credentials: pwdUnknown}, + &types.Credentials{ID: 3, Credentials: ""}, + }, + }, + { + name: "present, invalid, last", + rval: true, + password: pwdPlain, + cc: types.CredentialsSet{ + &types.Credentials{ID: 2, Credentials: pwdUnknown}, + &types.Credentials{ID: 3, Credentials: ""}, + &types.Credentials{ID: 1, Credentials: pwdHashed, DeletedAt: now()}, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var ( + req = require.New(t) + rsp = isPasswordReused(c.cc, c.password, c.window) + ) + + if c.rval { + req.True(rsp) + } else { + req.False(rsp) + } + }) + } +} + +func TestValidateToken(t *testing.T) { + type args struct { + token string + } + tests := []struct { + name string + args args + wantID uint64 + wantCredentials string + }{ + { + name: "empty", + wantID: 0, + wantCredentials: "", + args: args{token: ""}}, + { + name: "foo", + wantID: 0, + wantCredentials: "", + args: args{token: "foo1"}}, + { + name: "semivalid", + wantID: 0, + wantCredentials: "", + args: args{token: "foofoofoofoofoofoofoofoofoofoofo0"}}, + { + name: "valid", + wantID: 1, + wantCredentials: "foofoofoofoofoofoofoofoofoofoofo", + args: args{token: "foofoofoofoofoofoofoofoofoofoofo1"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID, gotCredentials := validateToken(tt.args.token) + + if gotID != tt.wantID { + t.Errorf("auth.validateToken() gotID = %v, want %v", gotID, tt.wantID) + } + if gotCredentials != tt.wantCredentials { + t.Errorf("auth.validateToken() gotCredentials = %v, want %v", gotCredentials, tt.wantCredentials) + } + }) + } +} diff --git a/system/service/auth_test.go b/system/service/auth_test.go index bdab026cc..d1f4be77f 100644 --- a/system/service/auth_test.go +++ b/system/service/auth_test.go @@ -14,7 +14,6 @@ import ( "github.com/markbates/goth" "github.com/stretchr/testify/require" "go.uber.org/zap" - "golang.org/x/crypto/bcrypt" ) // Mock auth service with nil for current time, dummy provider validator and mock db @@ -392,108 +391,3 @@ func TestAuth_multiCreateUserTokenForPasswordReset(t *testing.T) { } } - -func Test_auth_checkPassword(t *testing.T) { - plainPassword := " ... plain password ... " - hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost) - type args struct { - password string - cc types.CredentialsSet - } - tests := []struct { - name string - args args - rval bool - }{ - { - name: "empty set", - rval: false, - args: args{}}, - { - name: "bad pwd", - rval: false, - args: args{ - password: " foo ", - cc: types.CredentialsSet{&types.Credentials{ID: 1, Credentials: string(hashedPassword)}}}}, - { - name: "invalid credentials", - rval: false, - args: args{ - password: " foo ", - cc: types.CredentialsSet{&types.Credentials{ID: 0, Credentials: string(hashedPassword)}}}}, - { - name: "ok", - rval: true, - args: args{ - password: plainPassword, - cc: types.CredentialsSet{&types.Credentials{ID: 1, Credentials: string(hashedPassword)}}}}, - { - name: "multipass", - rval: true, - args: args{ - password: plainPassword, - cc: types.CredentialsSet{ - &types.Credentials{ID: 0, Credentials: string(hashedPassword)}, - &types.Credentials{ID: 1, Credentials: "$2a$10$8sOZxfZinxnu3bAtpkqEx.wBBwOfci6aG1szgUyxm5.BL2WiLu.ni"}, - &types.Credentials{ID: 2, Credentials: string(hashedPassword)}, - &types.Credentials{ID: 3, Credentials: ""}, - }}}, - } - - svc := auth{ - settings: &types.AppSettings{}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.rval != svc.CheckPassword(tt.args.password, true, tt.args.cc) { - t.Errorf("auth.checkPassword() expecting rval to be %v", tt.rval) - } - }) - } -} - -func TestValidateToken(t *testing.T) { - type args struct { - token string - } - tests := []struct { - name string - args args - wantID uint64 - wantCredentials string - }{ - { - name: "empty", - wantID: 0, - wantCredentials: "", - args: args{token: ""}}, - { - name: "foo", - wantID: 0, - wantCredentials: "", - args: args{token: "foo1"}}, - { - name: "semivalid", - wantID: 0, - wantCredentials: "", - args: args{token: "foofoofoofoofoofoofoofoofoofoofo0"}}, - { - name: "valid", - wantID: 1, - wantCredentials: "foofoofoofoofoofoofoofoofoofoofo", - args: args{token: "foofoofoofoofoofoofoofoofoofoofo1"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotID, gotCredentials := validateToken(tt.args.token) - - if gotID != tt.wantID { - t.Errorf("auth.validateToken() gotID = %v, want %v", gotID, tt.wantID) - } - if gotCredentials != tt.wantCredentials { - t.Errorf("auth.validateToken() gotCredentials = %v, want %v", gotCredentials, tt.wantCredentials) - } - }) - } -} diff --git a/system/service/user.go b/system/service/user.go index 774394bfc..0a05106fc 100644 --- a/system/service/user.go +++ b/system/service/user.go @@ -52,7 +52,6 @@ type ( userAuth interface { CheckPasswordStrength(string) bool - CheckPassword(string, bool, types.CredentialsSet) bool SetPasswordCredentials(context.Context, uint64, string) error RemovePasswordCredentials(context.Context, uint64) error RemoveAccessTokens(context.Context, *types.User) error @@ -687,8 +686,7 @@ func (svc user) Unsuspend(ctx context.Context, userID uint64) (err error) { // Expecting setter to have permissions to update users func (svc user) SetPassword(ctx context.Context, userID uint64, newPassword string) (err error) { var ( - u *types.User - cc types.CredentialsSet + u *types.User uaProps = &userActionProps{user: &types.User{ID: userID}} a = UserActionSetPassword @@ -718,18 +716,13 @@ func (svc user) SetPassword(ctx context.Context, userID uint64, newPassword stri return svc.auth.RemovePasswordCredentials(ctx, userID) } - cc, _, err = store.SearchCredentials(ctx, svc.store, types.CredentialsFilter{ - Kind: credentialsTypePassword, - OwnerID: userID, - Deleted: filter.StateInclusive}) - - if err != nil { - return err - } - - if svc.auth.CheckPassword(newPassword, false, cc) { - return AuthErrPasswordSetFailedReusedPasswordCheckFailed() - } + // note on password reuse: + // + // we do not really care if user is setting same password + // to someone else (or to self for that matter) + // + // he has rights to update the user and is doing so + // through general user management API if !svc.auth.CheckPasswordStrength(newPassword) { return UserErrPasswordNotSecure() diff --git a/system/types/credentials.go b/system/types/credentials.go index f2ea485e1..361090535 100644 --- a/system/types/credentials.go +++ b/system/types/credentials.go @@ -5,7 +5,6 @@ import ( "github.com/cortezaproject/corteza-server/pkg/filter" "github.com/jmoiron/sqlx/types" - "golang.org/x/crypto/bcrypt" ) type ( @@ -34,22 +33,3 @@ type ( func (u *Credentials) Valid() bool { return u.ID > 0 && (u.ExpiresAt == nil || u.ExpiresAt.After(time.Now())) && u.DeletedAt == nil } - -// CompareHashAndPassword returns first valid credentials with matching hash -func (cc CredentialsSet) CompareHashAndPassword(password string, validOnly bool) *Credentials { - for _, c := range cc { - if validOnly && !c.Valid() { - continue - } - - if len(c.Credentials) == 0 { - continue - } - - if bcrypt.CompareHashAndPassword([]byte(c.Credentials), []byte(password)) == nil { - return c - } - } - - return nil -}