mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
* Add atomic login-attempt counter primitives to UserStore
Two new store methods back the upcoming switch from a global
per-node mutex to per-user atomic slot claiming:
TryIncrementFailedPasswordAttempts(userID, maxAttempts) (bool, error)
UPDATE Users SET FailedAttempts = FailedAttempts + 1
WHERE Id = ? AND FailedAttempts < maxAttempts
Returns true when a slot was claimed (rows affected == 1) and
false when the cap was already reached. The conditional UPDATE
serialises concurrent attempts on the same user via the row
lock, so the cap is enforced without any application-level
locking and without serialising attempts across users.
DecrementFailedPasswordAttempts(userID) error
UPDATE Users SET FailedAttempts = FailedAttempts - 1
WHERE Id = ? AND FailedAttempts > 0
Releases a slot previously claimed by TryIncrement when the
in-flight authentication turns out not to be a credential
failure. The conditional UPDATE means concurrent decrements
cannot underflow.
Storetest covers both primitives: claim-below-cap, reject-at-cap,
reject-above-cap, no-op for unknown user, and a 50-goroutine
concurrent test with a start barrier asserting exactly
maxAttempts slots are ever claimed and that decrement clamps at
zero under contention.
The testify mock is regenerated here so the storetest package
that returns *mocks.UserStore as a store.UserStore still satisfies
the interface; the wrapper layers are regenerated in the next
commit.
------
AI assisted commit
* Regenerate store layers for the new primitives
Pick up TryIncrementFailedPasswordAttempts and
DecrementFailedPasswordAttempts in every generated wrapper:
- retrylayer: retry on repeatable errors using the standard
three-attempt loop.
- timerlayer: record store-method duration metrics under
UserStore.TryIncrementFailedPasswordAttempts and
UserStore.DecrementFailedPasswordAttempts.
- localcachelayer: invalidate the profile cache only after the
underlying conditional UPDATE actually changes a row; an
at-cap no-op return on TryIncrement no longer produces
unnecessary cluster invalidation traffic.
------
AI assisted commit
* Drop login-attempt mutex; use per-user slot claiming
Replace the global per-node mutex that serialised every login
attempt with the database-side atomic slot machine added on the
Users row. Each of the three authentication entry points now
pre-claims a slot via TryIncrementFailedPasswordAttempts before
running the expensive password / LDAP / MFA check, and releases
the slot when the failure path is not a real credential mismatch:
- CheckPasswordAndAllCriteria (email/password): refunds the
slot on backend errors during the password check (malformed
stored hash, hasher misc failure, password-migration write
failure) so a transient infra issue cannot ratchet
FailedAttempts to a lockout for a user with valid credentials;
refunds on the MFA pre-flight probe (empty mfaToken on an
MFA-enabled user) so the probe is not counted as a real
attempt.
- DoubleCheckPassword: same backend-error refund predicate.
- checkLdapUserPasswordAndAllCriteria: pre-claims only for
existing users (first-time LDAP users have no local row to
claim against); refunds non-credential DoLogin errors (server
unreachable, transient) so an LDAP outage cannot lock out
everyone; refunds the MFA pre-flight probe; for first-time
users, explicitly bumps the counter via UpdateFailedPasswordAttempts
on a real bad-password or bad-MFA attempt, matching the
pre-refactor counting behaviour.
If the refund itself fails the underlying authentication error is
preserved and returned to the caller (the failure is logged); a
leaked slot is annoying, but masking the real failure with a
generic store 500 would be a clear observability regression.
Cluster-wide behaviour also changes: the previous design honoured
MaximumLoginAttempts per node, so an n-node cluster effectively
permitted n * MaximumLoginAttempts attempts. The cap is now
enforced globally.
------
AI assisted commit
* Cover app-layer behaviors of the new login slot machine
The store-layer tests already exercise TryIncrement and Decrement
under concurrency and at the cap boundary. The new behavioural
contracts at the app layer were not covered, so a regression that
flipped a refund predicate, a probe condition, or a first-time
LDAP path would have slipped through type checking and existing
unit tests.
Add tests around the three callers of the new path:
- CheckPasswordAndAllCriteria: an MFA pre-flight probe (empty
token) does not consume a slot; a real attempt with a wrong
non-empty token does; a backend error during the password
check (malformed stored hash) refunds the slot; the happy
path also asserts FailedAttempts resets to zero.
- DoubleCheckPassword: gets its first test coverage, covering
the happy path, rate-limit rejection once max attempts is
reached, and the backend-error refund path.
- checkLdapUserPasswordAndAllCriteria: covers paths the table
loop did not exercise, first-time LDAP user with a bad
password (uses GetUserByAuth to reach the freshly created
row), first-time LDAP user with a wrong MFA token, existing
LDAP user with a non-credential DoLogin error (slot
refunded), and the existing LDAP user MFA pre-flight probe
(slot refunded).
------
AI assisted commit
* Address coderabbit review
------
AI assisted commit
* Fix race in first-time LDAP failed-attempt counter
For first-time LDAP users we have no local row to pre-claim, so
the bad-password and bad-MFA branches fell back to an absolute
UpdateFailedPasswordAttempts(id, ldapUser.FailedAttempts+1) based
on a snapshot from GetUserByAuth. Concurrent first-attempt
requests for the same user could all read FailedAttempts == 0 and
all write 1, losing increments. As a secondary issue the absolute
set did not enforce MaximumLoginAttempts, so the counter could
also drift past the cap.
Switch both branches to TryIncrementFailedPasswordAttempts, the
atomic conditional UPDATE already used on every other path. The
row lock serialises concurrent increments and the predicate caps
at MaximumLoginAttempts.
A new concurrent storetest-style subtest runs
3 * maxFailedLoginAttempts goroutines through the first-time
bad-password path against the same fresh LDAP row and asserts
FailedAttempts lands at exactly maxFailedLoginAttempts. Against
the previous absolute-set implementation the test fails (observed
FailedAttempts = 4 with maxFailedLoginAttempts = 3, either a lost
increment or a cap overshoot).
The first-time bad-password branch also switches from a wrapped
500 return on store error to log-and-continue, matching the rest
of the file's refund/probe error handling: the underlying LDAP
authentication failure is the more useful error for the caller.
------
AI assisted commit
* Address review comments
------
AI assisted commit
---------
Co-authored-by: Mattermost Build <build@mattermost.com>
534 lines
20 KiB
Go
534 lines
20 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
"github.com/mattermost/mattermost/server/public/shared/request"
|
|
"github.com/mattermost/mattermost/server/v8/channels/app/password/hashers"
|
|
"github.com/mattermost/mattermost/server/v8/channels/app/users"
|
|
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
|
"github.com/mattermost/mattermost/server/v8/platform/shared/mfa"
|
|
)
|
|
|
|
type TokenLocation int
|
|
|
|
const (
|
|
TokenLocationNotFound TokenLocation = iota
|
|
TokenLocationHeader
|
|
TokenLocationCookie
|
|
TokenLocationQueryString
|
|
TokenLocationCloudHeader
|
|
TokenLocationRemoteClusterHeader
|
|
)
|
|
|
|
func (tl TokenLocation) String() string {
|
|
switch tl {
|
|
case TokenLocationNotFound:
|
|
return "Not Found"
|
|
case TokenLocationHeader:
|
|
return "Header"
|
|
case TokenLocationCookie:
|
|
return "Cookie"
|
|
case TokenLocationQueryString:
|
|
return "QueryString"
|
|
case TokenLocationCloudHeader:
|
|
return "CloudHeader"
|
|
case TokenLocationRemoteClusterHeader:
|
|
return "RemoteClusterHeader"
|
|
default:
|
|
return "Unknown"
|
|
}
|
|
}
|
|
|
|
func (a *App) IsPasswordValid(rctx request.CTX, password string) *model.AppError {
|
|
if err := users.IsPasswordValidWithSettings(password, &a.Config().PasswordSettings); err != nil {
|
|
var invErr *users.ErrInvalidPassword
|
|
switch {
|
|
case errors.As(err, &invErr):
|
|
return model.NewAppError("User.IsValid", invErr.Id(), map[string]any{"Min": *a.Config().PasswordSettings.MinimumLength}, "", http.StatusBadRequest).Wrap(err)
|
|
default:
|
|
return model.NewAppError("User.IsValid", "app.valid_password_generic.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) checkUserPassword(user *model.User, password string) *model.AppError {
|
|
if user.Password == "" || password == "" {
|
|
return model.NewAppError("checkUserPassword", "api.user.check_user_password.invalid.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
|
|
}
|
|
|
|
// Get the hasher and parsed PHC
|
|
hasher, phc, err := hashers.GetHasherFromPHCString(user.Password)
|
|
if err != nil {
|
|
return model.NewAppError("checkUserPassword", "api.user.check_user_password.invalid_hash.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
|
|
}
|
|
|
|
// Compare the password using the hasher that generated it
|
|
err = hasher.CompareHashAndPassword(phc, password)
|
|
if err != nil && errors.Is(err, hashers.ErrMismatchedHashAndPassword) {
|
|
return model.NewAppError("checkUserPassword", "api.user.check_user_password.invalid.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized).Wrap(err)
|
|
} else if err != nil {
|
|
return model.NewAppError("checkUserPassword", "app.valid_password_generic.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
// Migrate the password if needed
|
|
if !hashers.IsLatestHasher(hasher) {
|
|
return a.migratePassword(user, password)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// migratePassword updates the database with the user's password hashed with the
|
|
// latest hashing method. It assumes that the password has been already validated.
|
|
func (a *App) migratePassword(user *model.User, password string) *model.AppError {
|
|
// Compute the new hash with the latest hashing method
|
|
newHash, err := hashers.Hash(password)
|
|
if err != nil {
|
|
return model.NewAppError("migratePassword", "app.user.check_user_password.failed_migration", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
// Update the password
|
|
if err := a.Srv().Store().User().UpdatePassword(user.Id, newHash); err != nil {
|
|
return model.NewAppError("migratePassword", "app.user.check_user_password.failed_update", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
a.InvalidateCacheForUser(user.Id)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) CheckPasswordAndAllCriteria(rctx request.CTX, userID string, password string, mfaToken string) *model.AppError {
|
|
user, err := a.GetUser(userID)
|
|
if err != nil {
|
|
if err.Id != MissingAccountError {
|
|
err.StatusCode = http.StatusInternalServerError
|
|
return err
|
|
}
|
|
err.StatusCode = http.StatusBadRequest
|
|
return err
|
|
}
|
|
|
|
if err := a.CheckUserPreflightAuthenticationCriteria(rctx, user, mfaToken); err != nil {
|
|
return err
|
|
}
|
|
|
|
maxAttempts := *a.Config().ServiceSettings.MaximumLoginAttempts
|
|
claimed, claimErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(user.Id, maxAttempts)
|
|
if claimErr != nil {
|
|
return model.NewAppError("CheckPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(claimErr)
|
|
}
|
|
if !claimed {
|
|
return model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
|
|
}
|
|
|
|
if err := a.checkUserPassword(user, password); err != nil {
|
|
// Only keep the claimed slot when the failure is an actual
|
|
// credential mismatch; backend errors (hasher failures, migration
|
|
// failures, malformed stored hash) must not consume a slot or a
|
|
// transient infra issue could lock out a user with valid creds.
|
|
if err.Id != "api.user.check_user_password.invalid.app_error" {
|
|
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
|
|
rctx.Logger().Warn("failed to refund login attempt slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
if err := a.CheckUserMfa(rctx, user, mfaToken); err != nil {
|
|
// The slot we claimed already counts this as a failed attempt;
|
|
// the only special case is when no mfaToken was provided, which
|
|
// is treated as a pre-flight MFA-state probe rather than a real
|
|
// attempt — refund the slot so the probe is not counted.
|
|
if mfaToken == "" {
|
|
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
|
|
rctx.Logger().Warn("failed to refund MFA probe slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, 0); passErr != nil {
|
|
return model.NewAppError("CheckPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
|
|
}
|
|
|
|
if err := a.CheckUserPostflightAuthenticationCriteria(rctx, user); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// This to be used for places we check the users password when they are already logged in
|
|
func (a *App) DoubleCheckPassword(rctx request.CTX, user *model.User, password string) *model.AppError {
|
|
maxAttempts := *a.Config().ServiceSettings.MaximumLoginAttempts
|
|
claimed, claimErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(user.Id, maxAttempts)
|
|
if claimErr != nil {
|
|
return model.NewAppError("DoubleCheckPassword", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(claimErr)
|
|
}
|
|
if !claimed {
|
|
return model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
|
|
}
|
|
|
|
if err := a.checkUserPassword(user, password); err != nil {
|
|
if err.Id != "api.user.check_user_password.invalid.app_error" {
|
|
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
|
|
rctx.Logger().Warn("failed to refund login attempt slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(user.Id, 0); passErr != nil {
|
|
return model.NewAppError("DoubleCheckPassword", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
|
|
}
|
|
|
|
a.InvalidateCacheForUser(user.Id)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) checkLdapUserPasswordAndAllCriteria(rctx request.CTX, user *model.User, password, mfaToken string) (*model.User, *model.AppError) {
|
|
// We need to get the latest value of the user from the database. user.Id is empty for first-time LDAP users.
|
|
if user.Id != "" {
|
|
var err *model.AppError
|
|
user, err = a.GetUser(user.Id)
|
|
if err != nil {
|
|
if err.Id != MissingAccountError {
|
|
err.StatusCode = http.StatusInternalServerError
|
|
return nil, err
|
|
}
|
|
err.StatusCode = http.StatusBadRequest
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
ldapID := user.AuthData
|
|
|
|
if a.Ldap() == nil || ldapID == nil {
|
|
err := model.NewAppError("doLdapAuthentication", "api.user.login_ldap.not_available.app_error", nil, "", http.StatusNotImplemented)
|
|
return nil, err
|
|
}
|
|
|
|
maxAttempts := *a.Config().LdapSettings.MaximumLoginAttempts
|
|
|
|
// First-time LDAP users have no local row yet to pre-claim against.
|
|
if user.Id != "" {
|
|
claimed, claimErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(user.Id, maxAttempts)
|
|
if claimErr != nil {
|
|
return nil, model.NewAppError("checkLdapUserPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(claimErr)
|
|
}
|
|
if !claimed {
|
|
return nil, model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many_ldap.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
ldapUser, err := a.Ldap().DoLogin(rctx, *ldapID, password)
|
|
if err != nil {
|
|
// If this is a new LDAP user, we need to get the user from the database because DoLogin will have created the user.
|
|
if user.Id == "" {
|
|
var getUserByAuthErr *model.AppError
|
|
ldapUser, getUserByAuthErr = a.GetUserByAuth(ldapID, model.UserAuthServiceLdap)
|
|
if getUserByAuthErr != nil {
|
|
return nil, getUserByAuthErr
|
|
}
|
|
} else {
|
|
ldapUser = user
|
|
}
|
|
|
|
// Log a info to make it easier to admin to spot that a user tried to log in with a legitimate user name.
|
|
if err.Id == "ent.ldap.do_login.invalid_password.app_error" {
|
|
rctx.Logger().LogM(mlog.MlvlLDAPInfo, "A user tried to sign in, which matched an LDAP account, but the password was incorrect.", mlog.String("ldap_id", *ldapID))
|
|
|
|
// For existing users we already claimed the slot above, so the
|
|
// counter has already been bumped. For first-time users (the
|
|
// row was just created by DoLogin) we still need to count the
|
|
// failed attempt explicitly, using the atomic primitive so
|
|
// concurrent first-attempt requests cannot overwrite each
|
|
// other's increments.
|
|
if user.Id == "" {
|
|
if _, passErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(ldapUser.Id, maxAttempts); passErr != nil {
|
|
rctx.Logger().Warn("failed to record failed attempt for first-time LDAP user", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
|
|
}
|
|
}
|
|
} else if user.Id != "" {
|
|
// Non-credential failure (LDAP unreachable, transient error,
|
|
// etc.) on an existing user must not consume the slot we
|
|
// pre-claimed, or an LDAP outage could lock out everyone.
|
|
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(user.Id); passErr != nil {
|
|
rctx.Logger().Warn("failed to refund LDAP login attempt slot", mlog.String("user_id", user.Id), mlog.Err(passErr))
|
|
}
|
|
}
|
|
|
|
err.StatusCode = http.StatusUnauthorized
|
|
return nil, err
|
|
}
|
|
|
|
if err = a.CheckUserMfa(rctx, ldapUser, mfaToken); err != nil {
|
|
// For existing LDAP users we pre-claimed a slot, so it already
|
|
// counts as a failed attempt. The only special case is when no
|
|
// mfaToken was provided, which is treated as a pre-flight
|
|
// MFA-state probe rather than a real attempt — refund the slot
|
|
// so the probe is not counted.
|
|
//
|
|
// For first-time LDAP users we did not pre-claim (no row to
|
|
// claim against), so a real MFA attempt with a non-empty token
|
|
// still needs to be counted explicitly against the freshly
|
|
// created row.
|
|
switch {
|
|
case user.Id == "" && mfaToken != "":
|
|
if _, passErr := a.Srv().Store().User().TryIncrementFailedPasswordAttempts(ldapUser.Id, maxAttempts); passErr != nil {
|
|
rctx.Logger().Warn("failed to record failed MFA attempt for first-time LDAP user", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
|
|
}
|
|
case user.Id != "" && mfaToken == "":
|
|
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(ldapUser.Id); passErr != nil {
|
|
rctx.Logger().Warn("failed to refund LDAP MFA probe slot", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if err = checkUserNotDisabled(ldapUser); err != nil {
|
|
// Existing LDAP users had a slot pre-claimed; a disabled-account
|
|
// rejection is not a credential failure, so refund the slot so a
|
|
// reactivated user is not immediately rate-limited.
|
|
if user.Id != "" {
|
|
if passErr := a.Srv().Store().User().DecrementFailedPasswordAttempts(ldapUser.Id); passErr != nil {
|
|
rctx.Logger().Warn("failed to refund disabled LDAP login attempt slot", mlog.String("user_id", ldapUser.Id), mlog.Err(passErr))
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if passErr := a.Srv().Store().User().UpdateFailedPasswordAttempts(ldapUser.Id, 0); passErr != nil {
|
|
return nil, model.NewAppError("checkLdapUserPasswordAndAllCriteria", "app.user.update_failed_pwd_attempts.app_error", nil, "", http.StatusInternalServerError).Wrap(passErr)
|
|
}
|
|
|
|
// user successfully authenticated
|
|
return ldapUser, nil
|
|
}
|
|
|
|
func (a *App) CheckUserAllAuthenticationCriteria(rctx request.CTX, user *model.User, mfaToken string) *model.AppError {
|
|
if err := a.CheckUserPreflightAuthenticationCriteria(rctx, user, mfaToken); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := a.CheckUserPostflightAuthenticationCriteria(rctx, user); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) CheckUserPreflightAuthenticationCriteria(rctx request.CTX, user *model.User, mfaToken string) *model.AppError {
|
|
if err := checkUserNotDisabled(user); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := checkUserNotBot(user); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := checkUserLoginAttempts(user, *a.Config().ServiceSettings.MaximumLoginAttempts); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) CheckUserPostflightAuthenticationCriteria(rctx request.CTX, user *model.User) *model.AppError {
|
|
if !user.EmailVerified && *a.Config().EmailSettings.RequireEmailVerification {
|
|
return model.NewAppError("Login", "api.user.login.not_verified.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) CheckUserMfa(rctx request.CTX, user *model.User, token string) *model.AppError {
|
|
if !user.MfaActive || !*a.Config().ServiceSettings.EnableMultifactorAuthentication {
|
|
return nil
|
|
}
|
|
|
|
if !*a.Config().ServiceSettings.EnableMultifactorAuthentication {
|
|
return model.NewAppError("CheckUserMfa", "mfa.mfa_disabled.app_error", nil, "", http.StatusNotImplemented)
|
|
}
|
|
|
|
ok, err := mfa.New(a.Srv().Store().User()).ValidateToken(user, token)
|
|
if err != nil {
|
|
return model.NewAppError("CheckUserMfa", "mfa.validate_token.authenticate.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
|
}
|
|
|
|
if !ok {
|
|
return model.NewAppError("checkUserMfa", "api.user.check_user_mfa.bad_code.app_error", nil, "", http.StatusUnauthorized)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) MFARequired(rctx request.CTX) *model.AppError {
|
|
if license := a.Channels().License(); license == nil || !*license.Features.MFA || !*a.Config().ServiceSettings.EnableMultifactorAuthentication || !*a.Config().ServiceSettings.EnforceMultifactorAuthentication {
|
|
return nil
|
|
}
|
|
|
|
session := rctx.Session()
|
|
// Session cannot be nil or empty if MFA is to be enforced.
|
|
if session == nil || session.Id == "" {
|
|
return model.NewAppError("MfaRequired", "api.context.get_session.app_error", nil, "", http.StatusUnauthorized)
|
|
}
|
|
|
|
// OAuth integrations are excepted
|
|
if session.IsOAuth {
|
|
return nil
|
|
}
|
|
|
|
user, err := a.GetUser(session.UserId)
|
|
if err != nil {
|
|
return model.NewAppError("MfaRequired", "api.context.get_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
if user.IsGuest() && !*a.Config().GuestAccountsSettings.EnforceMultifactorAuthentication {
|
|
return nil
|
|
}
|
|
// Only required for email and ldap accounts
|
|
if user.AuthService != "" &&
|
|
user.AuthService != model.UserAuthServiceEmail &&
|
|
user.AuthService != model.UserAuthServiceLdap {
|
|
return nil
|
|
}
|
|
|
|
// Special case to let user get themself
|
|
subpath, _ := utils.GetSubpathFromConfig(a.Config())
|
|
if rctx.Path() == path.Join(subpath, "/api/v4/users/me") {
|
|
return nil
|
|
}
|
|
|
|
// Bots are exempt
|
|
if user.IsBot {
|
|
return nil
|
|
}
|
|
|
|
if !user.MfaActive {
|
|
return model.NewAppError("MfaRequired", "api.context.mfa_required.app_error", nil, "", http.StatusForbidden)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func checkUserLoginAttempts(user *model.User, maxAttempts int) *model.AppError {
|
|
if user.FailedAttempts >= maxAttempts {
|
|
if user.AuthService == model.UserAuthServiceLdap {
|
|
return model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many_ldap.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
|
|
}
|
|
return model.NewAppError("checkUserLoginAttempts", "api.user.check_user_login_attempts.too_many.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func checkUserNotDisabled(user *model.User) *model.AppError {
|
|
if user.DeleteAt > 0 {
|
|
return model.NewAppError("Login", "api.user.login.inactive.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkUserNotBot(user *model.User) *model.AppError {
|
|
if user.IsBot {
|
|
return model.NewAppError("Login", "api.user.login.bot_login_forbidden.app_error", nil, "user_id="+user.Id, http.StatusUnauthorized)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) authenticateUser(rctx request.CTX, user *model.User, password, mfaToken string) (*model.User, *model.AppError) {
|
|
license := a.Srv().License()
|
|
ldapAvailable := *a.Config().LdapSettings.Enable && a.Ldap() != nil && license != nil && *license.Features.LDAP
|
|
|
|
if user.AuthService == model.UserAuthServiceLdap {
|
|
if !ldapAvailable {
|
|
err := model.NewAppError("login", "api.user.login_ldap.not_available.app_error", nil, "", http.StatusNotImplemented)
|
|
return user, err
|
|
}
|
|
|
|
ldapUser, err := a.checkLdapUserPasswordAndAllCriteria(rctx, user, password, mfaToken)
|
|
if err != nil {
|
|
err.StatusCode = http.StatusUnauthorized
|
|
return user, err
|
|
}
|
|
|
|
// slightly redundant to get the user again, but we need to get it from the LDAP server
|
|
return ldapUser, nil
|
|
}
|
|
|
|
if user.AuthService != "" {
|
|
authService := user.AuthService
|
|
if authService == model.UserAuthServiceSaml {
|
|
authService = strings.ToUpper(authService)
|
|
}
|
|
err := model.NewAppError("login", "api.user.login.use_auth_service.app_error", map[string]any{"AuthService": authService}, "", http.StatusBadRequest)
|
|
return user, err
|
|
}
|
|
|
|
if err := a.CheckPasswordAndAllCriteria(rctx, user.Id, password, mfaToken); err != nil {
|
|
if err.Id == "api.user.check_user_password.invalid.app_error" {
|
|
rctx.Logger().LogM(mlog.MlvlLDAPInfo, "A user tried to sign in, which matched a Mattermost account, but the password was incorrect.", mlog.String("username", user.Username))
|
|
}
|
|
|
|
err.StatusCode = http.StatusUnauthorized
|
|
return user, err
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func ParseAuthTokenFromRequest(r *http.Request) (token string, loc TokenLocation) {
|
|
defer func() {
|
|
// Stripping off tokens of large sizes
|
|
// to prevent logging a large string.
|
|
if len(token) > 50 {
|
|
token = token[:50]
|
|
}
|
|
}()
|
|
|
|
authHeader := r.Header.Get(model.HeaderAuth)
|
|
|
|
// Attempt to parse the token from the cookie
|
|
if cookie, err := r.Cookie(model.SessionCookieToken); err == nil {
|
|
return cookie.Value, TokenLocationCookie
|
|
}
|
|
|
|
// Parse the token from the header
|
|
if len(authHeader) > 6 && strings.ToUpper(authHeader[0:6]) == model.HeaderBearer {
|
|
// Default session token
|
|
return authHeader[7:], TokenLocationHeader
|
|
}
|
|
|
|
if len(authHeader) > 5 && strings.ToLower(authHeader[0:5]) == model.HeaderToken {
|
|
// OAuth token
|
|
return authHeader[6:], TokenLocationHeader
|
|
}
|
|
|
|
// Attempt to parse token out of the query string
|
|
if token := r.URL.Query().Get("access_token"); token != "" {
|
|
return token, TokenLocationQueryString
|
|
}
|
|
|
|
if token := r.Header.Get(model.HeaderCloudToken); token != "" {
|
|
return token, TokenLocationCloudHeader
|
|
}
|
|
|
|
if token := r.Header.Get(model.HeaderRemoteclusterToken); token != "" {
|
|
return token, TokenLocationRemoteClusterHeader
|
|
}
|
|
|
|
return "", TokenLocationNotFound
|
|
}
|