mattermost/server/channels/app/authentication.go
Alejandro García Montoro 238867e247
MM-68732: Remove global mutex for login attempts in favour of database serialization (#36515)
* 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>
2026-05-18 10:16:32 +00:00

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
}