Files
team/server/channels/app/session.go
ae56750319 [MM-69561] Add ability to rotate (regenerate) Personal Access Tokens (#37295)
* MM-69561: Add ability to rotate (regenerate) Personal Access Tokens

- Add UpdateTokenRotate to UserAccessTokenStore interface and implement
  in sqlstore: deletes sessions on the old secret, then updates the
  token row with the new secret and expiry in one transaction
- Regenerate store retrylayer, timerlayer, and mocks
- Add RotateUserAccessToken app method: validates expiry (bot-exempt),
  captures old session for cache eviction, generates new secret, and
  sends a notification email
- Register POST /api/v4/users/tokens/rotate handler with full permission
  checks (create_user_access_token + edit_other_users + manage_system
  for sysadmin targets); rejects OAuth sessions and disabled tokens
- Add RotateUserAccessToken to the Go client (client4.go)
- Add storetest covering secret rotation and old-session cleanup
- Add API4 tests: happy path, permission denials, OAuth rejection, and
  max-lifetime enforcement

* MM-69561: Add dedicated rotate email and i18n strings

- Add SendUserAccessTokenRotatedEmail (subject/body distinct from the
  'added' email so users aren't confused by a rotation event)
- Add SendUserAccessTokenRotatedEmail to ServiceInterface + mock
- Add en.json strings for the rotate email and the two new error ids
  (rotate.app_error, disabled_token.app_error)
- Switch RotateUserAccessToken to call SendUserAccessTokenRotatedEmail

* MM-69561: Add mmctl token rotate subcommand

- Add RotateUserAccessToken to the mmctl Client interface and mock
- Add 'mmctl token rotate <token-id> [--expires-in <duration>]' command
  reusing the existing resolveTokenExpiry/parseExpiresIn helpers from
  'generate'; prints the new secret once on success
- Add unit tests: happy path, --expires-in passed through, server error,
  invalid --expires-in

* MM-69561: Regenerate mmctl docs for token rotate

* MM-69561: Add API docs for POST /users/tokens/rotate

* MM-69561: Fix i18n string ordering after extract

* MM-69561: Add missing API4 test cases for token rotate

Cover the three untested access-control branches flagged by the test
analysis bot:
- Rotating a disabled token returns 400
- Non-system-admin rotating a sysadmin's token returns 403
- Rotating a remote user's token returns 403

* MM-69561: Address review comments

- Fix handler authorization order: check SessionHasPermissionToUserOrBot
  and manage_system before IsRemote/IsActive to avoid leaking token state
  to unauthorized callers; matches revokeUserAccessToken/disableUserAccessToken
- Fix API docs minimum server version: 10.8 -> 10.10

* MM-69561: Restore i18n strings accidentally deleted by extract

The earlier i18n-extract run stripped ~164 unrelated translation keys
(mostly enterprise-only strings like app.pap.* and api.ldap.*) because
the enterprise codebase isn't present in this checkout, so the
extractor treated them as unused. Restore them while keeping the 5
new keys added for token rotation.

* comment

* [MM-69561] Add webapp Regenerate option for Personal Access Tokens

Adds a "Regenerate" link to Account Settings > Security > Personal
Access Tokens that calls the POST /users/tokens/rotate endpoint added
in the server-side rotate PAT work. Regenerating shows a confirmation
modal naming the token, then reveals the new secret via the existing
one-time-copy flow used for token creation.

- webapp Client4.rotateUserAccessToken
- mattermost-redux rotateUserAccessToken action
- Regenerate link/confirm modal/reveal flow in user_access_token_section
- i18n strings
- Playwright e2e coverage

* Fix regenerate PAT e2e test: confirm modal is not nested in the Profile dialog

ConfirmModal renders via react-bootstrap's Modal, which portals to
document.body as a sibling of the Profile dialog rather than a
descendant, so it must be located via #confirmModal on the page
instead of scoped to the Profile dialog locator.

* Let users pick a new expiry when regenerating a Personal Access Token

Previously, regenerating a token always called rotateUserAccessToken
with no expiresAt, so the rotated secret never expired even if the
original token did. The Regenerate confirmation modal now includes the
same expiry picker used by token creation (extracted into a shared
renderExpiryPicker helper), enforces MaximumPersonalAccessTokenLifetimeDays
the same way, and disables the confirm button until a valid expiry is
selected.

* Scope the red background in the Regenerate modal to the warning text only

The confirmation question, expiry picker, and its hints were sitting
inside the same alert-danger box as the warning, making the whole
modal read as an error. Only the warning paragraph keeps the red
background now.

* Move the regenerate confirmation question below the expiry picker

* Align rotate-token wording with UI: use 'regenerate/regenerated'

* [MM-69561] rename pat_expiry_notify job to notify_expiring_access_tokens

Unifies naming with the sibling cleanup_expired_access_tokens job and
fixes the "expiry" vs "expiring" ambiguity: this job warns about tokens
approaching expiry, not ones that have already expired. Safe to rename
outright since the job hasn't shipped yet.

* [MM-69561] remove dead session lookup from EnableUserAccessToken

The GetSessionContext call and its result were never used: both branches
returned nil regardless. Leftover from mirroring DisableUserAccessToken's
shape, which does use its session (to revoke it) unlike Enable.

* [MM-69561] rename remaining PAT identifiers to match AccessToken convention

The job-level rename (pat_expiry_notify -> notify_expiring_access_tokens)
left the app-layer function and its helpers using the old PAT/
PersonalAccessToken naming. Rename them to match:

- NotifyPersonalAccessTokensExpiring -> NotifyExpiringAccessTokens
- patExpiryBucket -> accessTokenExpiryBucket
- sendPATExpiryNotification -> sendAccessTokenExpiryNotification
- patExpiryNotifyBatchLimit -> expiringAccessTokenBatchLimit
- patExpiryThresholds -> expiringAccessTokenThresholds
- maxPersonalAccessTokenExpiry -> maxUserAccessTokenExpiry

Also reword the package doc on notify_expiring_access_tokens, which no
longer needs to explain a PAT/UserAccessToken naming split now that the
app-layer method matches the job name.

Pure rename, no behavior change.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-10 23:08:19 +02:00

933 lines
36 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"crypto/subtle"
"errors"
"math"
"net/http"
"os"
"github.com/golang-jwt/jwt/v5"
"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/platform"
"github.com/mattermost/mattermost/server/v8/channels/app/users"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// maxSessionsLimit prevents a potential DOS caused by creating an unbounded number of sessions; MM-55320
const maxSessionsLimit = 500
func (a *App) CreateSession(rctx request.CTX, session *model.Session) (*model.Session, *model.AppError) {
if appErr := a.limitNumberOfSessions(rctx, session.UserId); appErr != nil {
return nil, appErr
}
// remote/synthetic users cannot create sessions. This lookup will already be cached.
// Some unit tests rely on sessions being created for users that don't exist, therefore
// missing users are allowed.
user, appErr := a.GetUser(session.UserId)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
return nil, appErr
}
if user != nil && user.IsRemote() {
return nil, model.NewAppError("login", "api.user.login.remote_users.login.error", nil, "", http.StatusUnauthorized)
}
session, err := a.ch.srv.platform.CreateSession(rctx, session)
if err != nil {
var invErr *store.ErrInvalidInput
switch {
case errors.As(err, &invErr):
return nil, model.NewAppError("CreateSession", "app.session.save.existing.app_error", nil, "", http.StatusBadRequest).Wrap(err)
default:
return nil, model.NewAppError("CreateSession", "app.session.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return session, nil
}
func (a *App) GetCloudSession(token string) (*model.Session, *model.AppError) {
apiKey := os.Getenv("MM_CLOUD_API_KEY")
if apiKey != "" && subtle.ConstantTimeCompare([]byte(apiKey), []byte(token)) == 1 {
// Need a bare-bones session object for later checks
session := &model.Session{
Token: token,
IsOAuth: false,
}
session.AddProp(model.SessionPropType, model.SessionTypeCloudKey)
return session, nil
}
return nil, model.NewAppError("GetCloudSession", "api.context.invalid_token.error", map[string]any{"Token": token, "Error": ""}, "The provided token is invalid", http.StatusUnauthorized)
}
func (a *App) GetRemoteClusterSession(token string, remoteId string) (*model.Session, *model.AppError) {
rc, appErr := a.GetRemoteCluster(remoteId, false)
if appErr == nil && subtle.ConstantTimeCompare([]byte(rc.Token), []byte(token)) == 1 {
// Need a bare-bones session object for later checks
session := &model.Session{
Token: token,
IsOAuth: false,
}
session.AddProp(model.SessionPropType, model.SessionTypeRemoteclusterToken)
return session, nil
}
return nil, model.NewAppError("GetRemoteClusterSession", "api.context.invalid_token.error", map[string]any{"Token": token, "Error": ""}, "The provided token is invalid", http.StatusUnauthorized)
}
func (a *App) GetSession(token string) (*model.Session, *model.AppError) {
// Create a context as GetSession is used in a lot of places where no context is current present.
// Once more of the codebase is migrated to use a context, GetSession should accept one.
rctx := request.EmptyContext(a.Log())
var session *model.Session
// We intentionally skip the error check here, we only want to check if the token is valid.
// If we don't have the session we are going to create one with the token eventually.
if session, _ = a.ch.srv.platform.GetSession(rctx, token); session != nil {
if session.Token != token {
return nil, model.NewAppError("GetSession", "api.context.invalid_token.error", map[string]any{"Token": token, "Error": ""}, "session token is different from the one in DB", http.StatusUnauthorized)
}
if !session.IsExpired() {
if err := a.ch.srv.platform.AddSessionToCache(session); err != nil {
rctx.Logger().Error("Failed to add session to cache", mlog.Err(err))
}
}
}
var appErr *model.AppError
if session == nil || session.Id == "" {
session, appErr = a.createSessionForUserAccessToken(rctx, token)
if appErr != nil {
return nil, model.NewAppError("GetSession", "api.context.invalid_token.error", map[string]any{"Token": token}, "", appErr.StatusCode).Wrap(appErr)
}
}
if session.Id == "" || session.IsExpired() {
return nil, model.NewAppError("GetSession", "api.context.invalid_token.error", map[string]any{"Token": token, "Error": ""}, "session is either nil or expired", http.StatusUnauthorized)
}
if *a.Config().ServiceSettings.SessionIdleTimeoutInMinutes > 0 &&
!session.IsOAuth && !session.IsMobileApp() &&
session.Props[model.SessionPropType] != model.SessionTypeUserAccessToken &&
!*a.Config().ServiceSettings.ExtendSessionLengthWithActivity {
timeout := int64(*a.Config().ServiceSettings.SessionIdleTimeoutInMinutes) * 1000 * 60
if (model.GetMillis() - session.LastActivityAt) > timeout {
// Revoking the session is an asynchronous task anyways since we are not checking
// for the return value of the call before returning the error.
// So moving this to a goroutine has 2 advantages:
// 1. We are treating this as a proper asynchronous task.
// 2. This also fixes a race condition in the web hub, where GetSession
// gets called from (*WebConn).isMemberOfTeam and revoking a session involves
// clearing the webconn cache, which needs the hub again.
a.Srv().Go(func() {
err := a.RevokeSessionById(rctx, session.Id)
if err != nil {
rctx.Logger().Warn("Error while revoking session", mlog.Err(err))
}
})
return nil, model.NewAppError("GetSession", "api.context.invalid_token.error", map[string]any{"Token": token, "Error": ""}, "idle timeout", http.StatusUnauthorized)
}
}
return session, nil
}
func (a *App) GetSessions(rctx request.CTX, userID string) ([]*model.Session, *model.AppError) {
sessions, err := a.ch.srv.platform.GetSessions(rctx, userID)
if err != nil {
return nil, model.NewAppError("GetSessions", "app.session.get_sessions.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return sessions, nil
}
// limitNumberOfSessions revokes userId's least recently used sessions to keep the number below
// maxSessionsLimit; MM-55320
func (a *App) limitNumberOfSessions(rctx request.CTX, userId string) *model.AppError {
const returnLimit = 100
sessions, appErr := a.GetLRUSessions(rctx, userId, returnLimit, maxSessionsLimit-1)
if appErr != nil {
return model.NewAppError("limitNumberOfSessions", "app.session.save.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
}
// Revoke any sessions over the limit to make room for new sessions
for _, sess := range sessions {
if err := a.RevokeSession(rctx, sess); err != nil {
return model.NewAppError("limitNumberOfSessions", "app.session.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
rctx.Logger().Debug("Session revoked; user's number of sessions were over the maxSessionsLimit",
mlog.String("user_id", userId),
mlog.String("session_id", sess.Id))
}
return nil
}
// GetLRUSessions returns the Least Recently Used sessions for userID, skipping over the newest 'offset'
// number of sessions. E.g., if userID has 100 sessions, offset 98 will return the oldest 2 sessions.
func (a *App) GetLRUSessions(rctx request.CTX, userID string, limit uint64, offset uint64) ([]*model.Session, *model.AppError) {
sessions, err := a.ch.srv.platform.GetLRUSessions(rctx, userID, limit, offset)
if err != nil {
return nil, model.NewAppError("GetLRUSessions", "app.session.get_lru_sessions.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return sessions, nil
}
func (a *App) sendMobileWipeSignal(rctx request.CTX, sessions ...*model.Session) {
// detach from request context and runs async after the caller returns
rctx = rctx.WithContext(context.Background())
if !model.SafeDereference(a.Config().MobileEphemeralModeSettings.Enable) {
return
}
if !a.canSendPushNotifications() {
rctx.Logger().Warn("Cannot send mobile wipe signal because push notifications are disabled")
return
}
for _, session := range sessions {
if session.DeviceId == "" || session.DeviceId == session.Props[model.SessionPropLastRemovedDeviceId] {
continue
}
// Send an empty push notification of type Session that will cause apps to terminate sessions and wipe data.
// ContentAvailable and SoundNone are set to trigger a silent notification that wakes up
// the app in the background without alerting the user.
msg := &model.PushNotification{
Version: model.PushMessageV2,
Type: model.PushTypeSession,
ContentAvailable: 1,
Sound: model.PushSoundNone,
}
msg.SetDeviceIdAndPlatform(session.DeviceId)
msg.AckId = model.NewId()
signature, signErr := jwt.NewWithClaims(jwt.SigningMethodES256, pushJWTClaims{
AckId: msg.AckId,
DeviceId: msg.DeviceId,
}).SignedString(a.AsymmetricSigningKey())
if signErr != nil {
rctx.Logger().Warn("Failed to sign session wipe push", mlog.String("session_id", session.Id), mlog.Err(signErr))
continue
}
msg.Signature = signature
msg.ServerId = a.ServerId()
pushResponse, sendErr := a.rawSendToPushProxy(msg)
reason := model.NotificationReasonPushProxySendError
switch pushResponse[model.PushStatus] {
case model.PushStatusRemove:
reason = model.NotificationReasonPushProxyRemoveDevice
sendErr = errors.New(notificationErrorRemoveDevice)
case model.PushStatusFail:
sendErr = errors.New(pushResponse[model.PushStatusErrorMsg])
}
if sendErr != nil {
a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypePush, reason, msg.Platform)
rctx.Logger().Warn("Failed to send session wipe push",
mlog.String("session_id", session.Id),
mlog.String("reason", reason),
mlog.Err(sendErr))
continue
}
if a.Metrics() != nil {
a.Metrics().IncrementPostSentPush()
}
}
}
func (a *App) RevokeAllSessions(rctx request.CTX, userID string) *model.AppError {
sessions, err := a.ch.srv.platform.RevokeAllSessions(rctx, userID)
if err != nil {
switch {
case errors.Is(err, platform.GetSessionError):
return model.NewAppError("RevokeAllSessions", "app.session.get_sessions.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
case errors.Is(err, platform.DeleteSessionError):
return model.NewAppError("RevokeAllSessions", "app.session.remove.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
default:
return model.NewAppError("RevokeAllSessions", "app.session.remove.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
a.Srv().Go(func() {
a.sendMobileWipeSignal(rctx, sessions...)
})
return nil
}
func (a *App) AddSessionToCache(session *model.Session) {
if err := a.ch.srv.platform.AddSessionToCache(session); err != nil {
a.Srv().Platform().Log().Error("Failed to add session to cache", mlog.String("session_id", session.Id), mlog.String("user_id", session.UserId), mlog.Err(err))
}
}
// RevokeSessionsFromAllUsers will go through all the sessions active
// in the server and revoke them
func (a *App) RevokeSessionsFromAllUsers(rctx request.CTX) *model.AppError {
// When Mobile Ephemeral Mode is enabled, fetch sessions with active device ids
// before revoking them, so we can send wipe signals to the correct devices.
var sessionsWithActiveDevices []*model.Session
if model.SafeDereference(a.Config().MobileEphemeralModeSettings.Enable) {
var err error
// Sessions created between this fetch and the deletion below are revoked but won't receive a wipe signal.
sessionsWithActiveDevices, err = a.Srv().Store().Session().GetAllSessionsWithActiveDeviceIds()
if err != nil {
return model.NewAppError("RevokeSessionsFromAllUsers", "app.session.remove_all_sessions_for_team.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
if err := a.ch.srv.platform.RevokeSessionsFromAllUsers(); err != nil {
switch {
case errors.Is(err, users.DeleteAllAccessDataError):
return model.NewAppError("RevokeSessionsFromAllUsers", "app.oauth.remove_access_data.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
default:
return model.NewAppError("RevokeSessionsFromAllUsers", "app.session.remove_all_sessions_for_team.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
a.Srv().Go(func() {
a.sendMobileWipeSignal(rctx, sessionsWithActiveDevices...)
})
return nil
}
func (a *App) ClearSessionCacheForUser(userID string) {
a.ch.srv.platform.ClearUserSessionCache(userID)
}
func (a *App) ClearSessionCacheForAllUsers() {
if err := a.ch.srv.platform.ClearAllUsersSessionCache(); err != nil {
a.Srv().Platform().Log().Error("Failed to clear session cache for all users", mlog.Err(err))
}
}
func (a *App) ClearSessionCacheForUserSkipClusterSend(userID string) {
a.Srv().Platform().ClearSessionCacheForUserSkipClusterSend(userID)
}
func (a *App) ClearSessionCacheForAllUsersSkipClusterSend() {
if err := a.Srv().Platform().ClearSessionCacheForAllUsersSkipClusterSend(); err != nil {
a.Srv().Platform().Log().Error("Failed to clear session cache for all users", mlog.Err(err))
}
}
func (a *App) RevokeOtherSessionsForDeviceId(rctx request.CTX, userID string, deviceId string, currentSessionId string) *model.AppError {
if err := a.ch.srv.platform.RevokeOtherSessionsForDeviceId(rctx, userID, deviceId, currentSessionId); err != nil {
if errors.Is(err, platform.ErrEmptyDeviceId) {
return model.NewAppError("RevokeOtherSessionsForDeviceId", "app.session.revoke_other_sessions.empty_device_id.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
return model.NewAppError("RevokeOtherSessionsForDeviceId", "app.session.get_sessions.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func (a *App) RevokeOtherSessionsForVoIPDeviceId(rctx request.CTX, userID string, voIPDeviceId string, currentSessionId string) *model.AppError {
if err := a.ch.srv.platform.RevokeOtherSessionsForVoIPDeviceId(rctx, userID, voIPDeviceId, currentSessionId); err != nil {
if errors.Is(err, platform.ErrEmptyVoIPDeviceId) {
return model.NewAppError("RevokeOtherSessionsForVoIPDeviceId", "app.session.revoke_other_sessions.empty_voip_device_id.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
return model.NewAppError("RevokeOtherSessionsForVoIPDeviceId", "app.session.get_sessions.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func (a *App) GetSessionById(rctx request.CTX, sessionID string) (*model.Session, *model.AppError) {
session, err := a.ch.srv.platform.GetSessionByID(rctx, sessionID)
if err != nil {
return nil, model.NewAppError("GetSessionById", "app.session.get.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
return session, nil
}
func (a *App) RevokeSessionById(rctx request.CTX, sessionID string) *model.AppError {
session, err := a.GetSessionById(rctx, sessionID)
if err != nil {
return model.NewAppError("RevokeSessionById", "app.session.get.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
return a.RevokeSession(rctx, session)
}
func (a *App) RevokeSession(rctx request.CTX, session *model.Session) *model.AppError {
if err := a.ch.srv.platform.RevokeSession(rctx, session); err != nil {
switch {
case errors.Is(err, platform.DeleteSessionError):
return model.NewAppError("RevokeSession", "app.session.remove.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
default:
return model.NewAppError("RevokeSession", "app.session.remove.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
a.Srv().Go(func() {
a.sendMobileWipeSignal(rctx, session)
})
return nil
}
func (a *App) AttachDeviceId(sessionID string, deviceId string, voIPDeviceId string, expiresAt int64) *model.AppError {
if err := a.Srv().Store().Session().UpdateDeviceId(sessionID, deviceId, voIPDeviceId, expiresAt); err != nil {
return model.NewAppError("AttachDeviceId", "app.session.update_device_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func (a *App) SetExtraSessionProps(session *model.Session, newProps map[string]string) *model.AppError {
changed := false
for k, v := range newProps {
if session.Props[k] == v {
continue
}
session.AddProp(k, v)
changed = true
}
if !changed {
return nil
}
err := a.Srv().Store().Session().UpdateProps(session)
if err != nil {
return model.NewAppError("SetExtraSessionProps", "app.session.set_extra_session_prop.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// ExtendSessionExpiryIfNeeded extends Session.ExpiresAt based on session lengths in config.
// A new ExpiresAt is only written if enough time has elapsed since last update.
// Returns true only if the session was extended.
func (a *App) ExtendSessionExpiryIfNeeded(rctx request.CTX, session *model.Session) bool {
if !*a.Config().ServiceSettings.ExtendSessionLengthWithActivity {
return false
}
if session == nil || session.IsExpired() {
return false
}
sessionLength := a.GetSessionLengthInMillis(session)
// Only extend the expiry if the lessor of 1% or 1 day has elapsed within the
// current session duration.
threshold := max(
int64(math.Min(float64(sessionLength)*0.01, float64(model.DayInMilliseconds))),
// Minimum session length is 1 day as of this writing, therefore a minimum ~14 minutes threshold.
// However we'll add a sanity check here in case that changes. Minimum 5 minute threshold,
// meaning we won't write a new expiry more than every 5 minutes.
5*60*1000,
)
now := model.GetMillis()
elapsed := now - (session.ExpiresAt - sessionLength)
if elapsed < threshold {
return false
}
auditRec := a.MakeAuditRecord(rctx, model.AuditEventExtendSessionExpiry, model.AuditStatusFail)
defer a.LogAuditRec(rctx, auditRec, nil)
auditRec.AddEventPriorState(session)
newExpiry := now + sessionLength
if err := a.ch.srv.platform.ExtendSessionExpiry(session, newExpiry); err != nil {
rctx.Logger().Error("Failed to update ExpiresAt", mlog.String("user_id", session.UserId), mlog.String("session_id", session.Id), mlog.Err(err))
auditRec.AddMeta("err", err.Error())
return false
}
rctx.Logger().Debug("Session extended",
mlog.String("user_id", session.UserId),
mlog.String("session_id", session.Id),
mlog.Int("newExpiry", newExpiry),
mlog.Int("session_length", sessionLength),
)
auditRec.Success()
auditRec.AddEventResultState(session)
return true
}
// GetSessionLengthInMillis returns the session length, in milliseconds,
// based on the type of session (Mobile, SSO, Web/LDAP).
func (a *App) GetSessionLengthInMillis(session *model.Session) int64 {
if session == nil {
return 0
}
// For PAT sessions with a fixed expiry, return the remaining lifetime so
// that ExtendSessionExpiryIfNeeded never pushes ExpiresAt past the token's
// own expiry. The elapsed threshold check collapses to zero, so extension
// is effectively a no-op for these sessions (correct: the expiry is fixed).
// PAT sessions with ExpiresAt == 0 (non-expiring) fall through to normal
// web-session behavior.
if session.Props[model.SessionPropType] == model.SessionTypeUserAccessToken && session.ExpiresAt > 0 {
remaining := session.ExpiresAt - model.GetMillis()
if remaining < 0 {
return 0
}
return remaining
}
var hours int
if session.IsMobileApp() {
hours = *a.Config().ServiceSettings.SessionLengthMobileInHours
} else if session.IsSSOLogin() {
hours = *a.Config().ServiceSettings.SessionLengthSSOInHours
} else {
hours = *a.Config().ServiceSettings.SessionLengthWebInHours
}
return int64(hours * 60 * 60 * 1000)
}
// SetSessionExpireInHours sets the session's expiry the specified number of hours
// relative to either the session creation date or the current time, depending
// on the `ExtendSessionOnActivity` config setting.
func (a *App) SetSessionExpireInHours(session *model.Session, hours int) {
a.ch.srv.platform.SetSessionExpireInHours(session, hours)
}
// validateUserAccessTokenExpiry checks a token's ExpiresAt against
// ServiceSettings.MaximumPersonalAccessTokenLifetimeDays. 0 means no policy:
// never-expiring tokens are allowed. A value > 0 requires the token to expire
// within that many days, so ExpiresAt == 0 or an expiry beyond the cap is
// rejected. Only newly created tokens are checked; existing tokens are not
// re-validated.
func (a *App) validateUserAccessTokenExpiry(token *model.UserAccessToken) *model.AppError {
cfg := a.Config().ServiceSettings
maxDays := int64(0)
if cfg.MaximumPersonalAccessTokenLifetimeDays != nil {
maxDays = int64(*cfg.MaximumPersonalAccessTokenLifetimeDays)
}
if token.ExpiresAt == 0 {
// A configured maximum lifetime implies tokens must expire; never-expiring
// tokens are only allowed when no maximum is set.
if maxDays > 0 {
return model.NewAppError("CreateUserAccessToken", "app.user_access_token.expires_at_required.app_error", nil, "", http.StatusBadRequest)
}
return nil
}
if token.ExpiresAt <= model.GetMillis() {
return model.NewAppError("CreateUserAccessToken", "app.user_access_token.expires_at_in_past.app_error", nil, "", http.StatusBadRequest)
}
if maxDays > 0 {
maxExpiry := model.GetMillis() + maxDays*24*60*60*1000
if token.ExpiresAt > maxExpiry {
return model.NewAppError(
"CreateUserAccessToken",
"app.user_access_token.expires_at_too_far.app_error",
map[string]any{"Days": maxDays},
"",
http.StatusBadRequest,
)
}
}
return nil
}
func (a *App) CreateUserAccessToken(rctx request.CTX, token *model.UserAccessToken) (*model.UserAccessToken, *model.AppError) {
user, nErr := a.ch.srv.userService.GetUser(token.UserId)
if nErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(nErr, &nfErr):
return nil, model.NewAppError("CreateUserAccessToken", MissingAccountError, nil, "", http.StatusNotFound).Wrap(nErr)
default:
return nil, model.NewAppError("CreateUserAccessToken", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
if !*a.Config().ServiceSettings.EnableUserAccessTokens && !user.IsBot {
return nil, model.NewAppError("CreateUserAccessToken", "app.user_access_token.disabled", nil, "", http.StatusNotImplemented)
}
// Bot accounts are exempt from the PAT expiry policy, matching the existing
// EnableUserAccessTokens bypass above: bots are programmatic clients that
// typically need long-lived credentials, and integrations that provision
// them would otherwise break the moment an admin enables enforcement.
if !user.IsBot {
if err := a.validateUserAccessTokenExpiry(token); err != nil {
return nil, err
}
}
token.Token = model.NewId()
token, nErr = a.Srv().Store().UserAccessToken().Save(token)
if nErr != nil {
var appErr *model.AppError
switch {
case errors.As(nErr, &appErr):
return nil, appErr
default:
return nil, model.NewAppError("CreateUserAccessToken", "app.user_access_token.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
// Don't send emails to bot users.
if !user.IsBot {
if err := a.Srv().EmailService.SendUserAccessTokenAddedEmail(user.Email, user.Locale, a.GetSiteURL()); err != nil {
rctx.Logger().Error("Unable to send user access token added email", mlog.Err(err), mlog.String("user_id", user.Id))
}
}
return token, nil
}
func (a *App) createSessionForUserAccessToken(rctx request.CTX, tokenString string) (*model.Session, *model.AppError) {
token, nErr := a.Srv().Store().UserAccessToken().GetByToken(tokenString)
if nErr != nil {
return nil, model.NewAppError("createSessionForUserAccessToken", "app.user_access_token.invalid_or_missing", nil, "", http.StatusUnauthorized).Wrap(nErr)
}
if !token.IsActive {
return nil, model.NewAppError("createSessionForUserAccessToken", "app.user_access_token.invalid_or_missing", nil, "inactive_token", http.StatusUnauthorized)
}
user, nErr := a.Srv().Store().User().Get(rctx.Context(), token.UserId)
if nErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(nErr, &nfErr):
return nil, model.NewAppError("createSessionForUserAccessToken", MissingAccountError, nil, "", http.StatusNotFound).Wrap(nErr)
default:
return nil, model.NewAppError("createSessionForUserAccessToken", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
if !*a.Config().ServiceSettings.EnableUserAccessTokens && !user.IsBot {
return nil, model.NewAppError("createSessionForUserAccessToken", "app.user_access_token.invalid_or_missing", nil, "EnableUserAccessTokens=false", http.StatusUnauthorized)
}
if token.IsExpired() {
auditRec := a.MakeAuditRecord(rctx, model.AuditEventRejectExpiredUserAccessToken, model.AuditStatusFail)
auditRec.AddMeta("token_id", token.Id)
auditRec.AddMeta("user_id", token.UserId)
auditRec.AddMeta("expires_at", token.ExpiresAt)
a.LogAuditRec(rctx, auditRec, nil)
return nil, model.NewAppError("createSessionForUserAccessToken", "app.user_access_token.expired", nil, "expired_token", http.StatusUnauthorized)
}
if user.DeleteAt != 0 {
return nil, model.NewAppError("createSessionForUserAccessToken", "app.user_access_token.invalid_or_missing", nil, "inactive_user_id="+user.Id, http.StatusUnauthorized)
}
if appErr := a.limitNumberOfSessions(rctx, user.Id); appErr != nil {
return nil, appErr
}
session := &model.Session{
Token: token.Token,
UserId: user.Id,
Roles: user.GetRawRoles(),
IsOAuth: false,
}
session.AddProp(model.SessionPropUserAccessTokenId, token.Id)
session.AddProp(model.SessionPropType, model.SessionTypeUserAccessToken)
if user.IsBot {
session.AddProp(model.SessionPropIsBot, model.SessionPropIsBotValue)
}
if user.IsGuest() {
session.AddProp(model.SessionPropIsGuest, "true")
} else {
session.AddProp(model.SessionPropIsGuest, "false")
}
a.ch.srv.platform.SetSessionExpireInHours(session, model.SessionUserAccessTokenExpiryHours)
// If the underlying PAT has a non-zero expiry, clamp the session expiry to
// the token's ExpiresAt so that cached sessions honor PAT expiry as well.
if token.ExpiresAt > 0 && (session.ExpiresAt == 0 || token.ExpiresAt < session.ExpiresAt) {
session.ExpiresAt = token.ExpiresAt
}
session, nErr = a.Srv().Store().Session().Save(rctx, session)
if nErr != nil {
var invErr *store.ErrInvalidInput
switch {
case errors.As(nErr, &invErr):
return nil, model.NewAppError("CreateSession", "app.session.save.existing.app_error", nil, "", http.StatusBadRequest).Wrap(nErr)
default:
return nil, model.NewAppError("CreateSession", "app.session.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
if err := a.ch.srv.platform.AddSessionToCache(session); err != nil {
a.ch.srv.Log().Error("Failed to add session to cache", mlog.String("session_id", session.Id), mlog.Err(err))
}
return session, nil
}
func (a *App) RevokeUserAccessToken(rctx request.CTX, token *model.UserAccessToken) *model.AppError {
var session *model.Session
session, _ = a.ch.srv.platform.GetSessionContext(rctx, token.Token)
if err := a.Srv().Store().UserAccessToken().Delete(token.Id); err != nil {
return model.NewAppError("RevokeUserAccessToken", "app.user_access_token.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if session == nil {
return nil
}
return a.RevokeSession(rctx, session)
}
// revokeNonCompliantBatchLimit bounds both the number of rows fetched by
// GetNonCompliantExpiry and the corresponding DeleteByIds call, keeping the
// transaction footprint bounded even when a large number of tokens are
// non-compliant. revokeNonCompliantMaxBatches caps the iterations of a single
// revoke call so a runaway loop can't develop.
const (
revokeNonCompliantBatchLimit = 1000
revokeNonCompliantMaxBatches = 1000
)
// maxUserAccessTokenExpiry returns the latest ExpiresAt a token may carry to
// comply with the current ServiceSettings.MaximumPersonalAccessTokenLifetimeDays
// policy, along with whether a policy is in effect. When no maximum is
// configured (0), no policy applies and every token is compliant.
func (a *App) maxUserAccessTokenExpiry() (maxExpiresAt int64, enabled bool) {
cfg := a.Config().ServiceSettings
maxDays := int64(0)
if cfg.MaximumPersonalAccessTokenLifetimeDays != nil {
maxDays = int64(*cfg.MaximumPersonalAccessTokenLifetimeDays)
}
if maxDays <= 0 {
return 0, false
}
return model.GetMillis() + maxDays*24*60*60*1000, true
}
// CountNonCompliantUserAccessTokens returns the number of active, non-bot
// personal access tokens that violate the current maximum lifetime policy. It
// lets an admin preview the blast radius before revoking. When no policy is in
// effect it returns 0 — nothing is non-compliant.
func (a *App) CountNonCompliantUserAccessTokens() (int64, *model.AppError) {
maxExpiresAt, enabled := a.maxUserAccessTokenExpiry()
if !enabled {
return 0, nil
}
count, err := a.Srv().Store().UserAccessToken().CountNonCompliantExpiry(maxExpiresAt)
if err != nil {
return 0, model.NewAppError("CountNonCompliantUserAccessTokens", "app.user_access_token.count_non_compliant.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return count, nil
}
// RevokeNonCompliantUserAccessTokens hard-deletes every active, non-bot personal
// access token that violates the current maximum lifetime policy, along with any
// sessions minted from them. It returns the number of tokens actually deleted.
// Work is done in bounded batches to keep transactions small, and the per-user
// session cache is cleared so stale sessions aren't served from memory. When no
// policy is in effect the call is refused — there is nothing to revoke and a
// caller reaching this path likely has a stale view of the config. Auditing is
// the caller's responsibility, matching RevokeUserAccessToken.
func (a *App) RevokeNonCompliantUserAccessTokens(rctx request.CTX) (int64, *model.AppError) {
maxExpiresAt, enabled := a.maxUserAccessTokenExpiry()
if !enabled {
return 0, model.NewAppError("RevokeNonCompliantUserAccessTokens", "app.user_access_token.revoke_non_compliant.no_policy.app_error", nil, "", http.StatusBadRequest)
}
var totalRevoked int64
allRevoked := false
for range revokeNonCompliantMaxBatches {
userIDs, err := a.Srv().Store().UserAccessToken().DeleteNonCompliantExpiry(maxExpiresAt, revokeNonCompliantBatchLimit)
if err != nil {
return totalRevoked, model.NewAppError("RevokeNonCompliantUserAccessTokens", "app.user_access_token.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if len(userIDs) == 0 {
allRevoked = true
break
}
totalRevoked += int64(len(userIDs))
seen := make(map[string]struct{}, len(userIDs))
for _, userID := range userIDs {
if _, ok := seen[userID]; !ok {
seen[userID] = struct{}{}
a.ClearSessionCacheForUser(userID)
}
}
if len(userIDs) < revokeNonCompliantBatchLimit {
allRevoked = true
break
}
}
if !allRevoked {
return totalRevoked, model.NewAppError("RevokeNonCompliantUserAccessTokens", "app.user_access_token.revoke_non_compliant.partial.app_error", nil, "", http.StatusInternalServerError)
}
return totalRevoked, nil
}
func (a *App) DisableUserAccessToken(rctx request.CTX, token *model.UserAccessToken) *model.AppError {
var session *model.Session
session, _ = a.ch.srv.platform.GetSessionContext(rctx, token.Token)
if err := a.Srv().Store().UserAccessToken().UpdateTokenDisable(token.Id); err != nil {
return model.NewAppError("DisableUserAccessToken", "app.user_access_token.update_token_disable.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if session == nil {
return nil
}
return a.RevokeSession(rctx, session)
}
func (a *App) EnableUserAccessToken(rctx request.CTX, token *model.UserAccessToken) *model.AppError {
if err := a.Srv().Store().UserAccessToken().UpdateTokenEnable(token.Id); err != nil {
return model.NewAppError("EnableUserAccessToken", "app.user_access_token.update_token_enable.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// RotateUserAccessToken generates a new secret for the token, sets a fresh
// expiry, and immediately invalidates the old secret and its sessions. The
// returned token carries the new secret (shown once, like CreateUserAccessToken).
func (a *App) RotateUserAccessToken(rctx request.CTX, token *model.UserAccessToken, expiresAt int64) (*model.UserAccessToken, *model.AppError) {
user, nErr := a.ch.srv.userService.GetUser(token.UserId)
if nErr != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(nErr, &nfErr):
return nil, model.NewAppError("RotateUserAccessToken", MissingAccountError, nil, "", http.StatusNotFound).Wrap(nErr)
default:
return nil, model.NewAppError("RotateUserAccessToken", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr)
}
}
if !*a.Config().ServiceSettings.EnableUserAccessTokens && !user.IsBot {
return nil, model.NewAppError("RotateUserAccessToken", "app.user_access_token.disabled", nil, "", http.StatusNotImplemented)
}
if !user.IsBot {
// Validate against the proposed expiry using a throwaway copy so token
// isn't mutated unless the rotation actually succeeds.
rotated := &model.UserAccessToken{
Id: token.Id,
UserId: token.UserId,
ExpiresAt: expiresAt,
}
if err := a.validateUserAccessTokenExpiry(rotated); err != nil {
return nil, err
}
}
// Capture the old session before the store update so we can evict it from
// the cache after the secret changes.
oldSession, _ := a.ch.srv.platform.GetSessionContext(rctx, token.Token)
newSecret := model.NewId()
if err := a.Srv().Store().UserAccessToken().UpdateTokenRotate(token.Id, newSecret, expiresAt); err != nil {
return nil, model.NewAppError("RotateUserAccessToken", "app.user_access_token.rotate.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if oldSession != nil {
if err := a.RevokeSession(rctx, oldSession); err != nil {
rctx.Logger().Warn("Failed to revoke old session after token rotate", mlog.String("token_id", token.Id), mlog.Err(err))
}
}
token.Token = newSecret
token.ExpiresAt = expiresAt
if !user.IsBot {
if err := a.Srv().EmailService.SendUserAccessTokenRotatedEmail(user.Email, user.Locale, a.GetSiteURL()); err != nil {
rctx.Logger().Error("Unable to send user access token rotated email", mlog.Err(err), mlog.String("user_id", user.Id))
}
}
return token, nil
}
func (a *App) GetUserAccessTokens(page, perPage int) ([]*model.UserAccessToken, *model.AppError) {
tokens, err := a.Srv().Store().UserAccessToken().GetAll(page*perPage, perPage)
if err != nil {
return nil, model.NewAppError("GetUserAccessTokens", "app.user_access_token.get_all.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, token := range tokens {
token.Token = ""
}
return tokens, nil
}
func (a *App) GetUserAccessTokensForUser(userID string, page, perPage int) ([]*model.UserAccessToken, *model.AppError) {
tokens, err := a.Srv().Store().UserAccessToken().GetByUser(userID, page*perPage, perPage)
if err != nil {
return nil, model.NewAppError("GetUserAccessTokensForUser", "app.user_access_token.get_by_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, token := range tokens {
token.Token = ""
}
return tokens, nil
}
func (a *App) GetUserAccessToken(tokenID string, sanitize bool) (*model.UserAccessToken, *model.AppError) {
token, err := a.Srv().Store().UserAccessToken().Get(tokenID)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("GetUserAccessToken", "app.user_access_token.get_by_user.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("GetUserAccessToken", "app.user_access_token.get_by_user.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
if sanitize {
token.Token = ""
}
return token, nil
}
func (a *App) SearchUserAccessTokens(term string) ([]*model.UserAccessToken, *model.AppError) {
tokens, err := a.Srv().Store().UserAccessToken().Search(term)
if err != nil {
return nil, model.NewAppError("SearchUserAccessTokens", "app.user_access_token.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, token := range tokens {
token.Token = ""
}
return tokens, nil
}