Auth: Revoke derived sessions & gate app passwords by login state #5647

- Add scoped session revocation (RevokeSessions / RevokeDerivedSessions)
  shared by the user-update API and the CLI: a privilege-level change now
  revokes logins and app-password-derived sessions while keeping the app
  passwords themselves, so configured devices keep working.
- Identify app-password-derived sessions by auth_method "session".
- Deny app passwords on the REST API when the account cannot log in
  (DenyLogIn); WebDAV access stays governed by CanUseWebDAV.
- Reduce the WebDAV auth cache expiration to one minute.
- Add IsSystemOrInvalid; allow deleting the initial admin account and skip
  re-initializing a deactivated or deleted admin in InitAccount.
- Move session-revocation scopes to pkg/authn and extend tests.
This commit is contained in:
Michael Mayer 2026-06-15 23:41:01 +00:00
parent aff25636b5
commit c45ee6e54f
11 changed files with 334 additions and 55 deletions

View file

@ -55,13 +55,22 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
// Set client IP.
s.SetClientIP(clientIp)
// Reject app passwords when the feature is disabled, so tokens minted before the
// flag was turned off stop working. Identified by the session's auth provider
// (IsApplication), which covers every grant type used to mint one.
if s.IsApplication() && get.Config().DisableAppPasswords() {
event.AuditWarn([]string{clientIp, "session %s", "%s %s with app passwords disabled", status.Denied}, s.RefID, perms.String(), string(resource))
return entity.SessionStatusForbidden()
} else if s.IsClient() {
// Enforce restrictions for app password sessions, identified by the "application" auth provider.
if s.IsApplication() {
// Reject app passwords when the feature is disabled.
if get.Config().DisableAppPasswords() {
event.AuditWarn([]string{clientIp, "session %s", "%s %s with app password", status.Disabled}, s.RefID, perms.String(), string(resource))
return entity.SessionStatusForbidden()
}
// Reject app passwords when the user is denied access to the Web UI/API.
if u := s.GetUser(); u.DenyLogIn() {
event.AuditWarn([]string{clientIp, "session %s", "%s %s with app password", status.Denied}, s.RefID, perms.String(), string(resource))
return entity.SessionStatusForbidden()
}
}
if s.IsClient() {
// If the request is from a client application, check its authorization based
// on the allowed scope, the ACL, and the user account it belongs to (if any).

View file

@ -144,6 +144,117 @@ func TestAuthAny_AppPasswordsDisabled(t *testing.T) {
}
}
func TestAuthAny_AppPasswordWebLoginDisabled(t *testing.T) {
conf := config.TestConfig()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
// Use a non-super-admin account; super admins keep web login regardless of CanLogin.
user := entity.FindUserByName("bob")
require.NotNil(t, user)
require.False(t, user.SuperAdmin)
sess, err := entity.AddClientSession("bob-app-pw", conf.SessionMaxAge(), "*", authn.GrantPassword, user)
require.NoError(t, err)
require.True(t, sess.IsApplication())
token := sess.AuthToken()
authPhotos := func() *entity.Session {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/photos", nil)
header.SetAuthorization(req, token)
req.RemoteAddr = "10.9.8.7:4321"
c.Request = req
return AuthAny(c, acl.ResourcePhotos, acl.Permissions{acl.ActionView})
}
// Restore the fixture's web login state after the test.
defer func() {
if m := entity.FindLocalUser("bob"); m != nil {
m.CanLogin = true
_ = m.Save()
}
entity.FlushSessionCache()
}()
// Web login enabled: the app password authorizes within its scope.
s := authPhotos()
require.NotNil(t, s)
assert.Equal(t, http.StatusOK, s.HttpStatus())
// Web login disabled: the same app password is rejected on the REST API. WebDAV
// access stays governed by CanUseWebDAV (verified in the entity tests).
m := entity.FindLocalUser("bob")
require.NotNil(t, m)
m.CanLogin = false
require.NoError(t, m.Save())
entity.FlushSessionCache()
s2 := authPhotos()
require.NotNil(t, s2)
assert.Equal(t, http.StatusForbidden, s2.HttpStatus())
}
func TestAuthAny_AppPasswordDeactivated(t *testing.T) {
conf := config.TestConfig()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
user := entity.FindUserByName("bob")
require.NotNil(t, user)
sess, err := entity.AddClientSession("bob-app-deact", conf.SessionMaxAge(), "*", authn.GrantPassword, user)
require.NoError(t, err)
require.True(t, sess.IsApplication())
token := sess.AuthToken()
authPhotos := func() *entity.Session {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/photos", nil)
header.SetAuthorization(req, token)
req.RemoteAddr = "10.9.8.7:4321"
c.Request = req
return AuthAny(c, acl.ResourcePhotos, acl.Permissions{acl.ActionView})
}
// Restore the fixture's auth provider after the test.
defer func() {
if m := entity.FindLocalUser("bob"); m != nil {
m.SetProvider(authn.ProviderLocal)
m.CanLogin = true
_ = m.Save()
}
entity.FlushSessionCache()
}()
// Active account: the app password authorizes within its scope.
s := authPhotos()
require.NotNil(t, s)
assert.Equal(t, http.StatusOK, s.HttpStatus())
// Deactivated (auth provider set to none): the app password is rejected on the REST
// API by the per-request DenyLogIn gate, even though the record is not revoked.
m := entity.FindLocalUser("bob")
require.NotNil(t, m)
m.SetProvider(authn.ProviderNone)
require.NoError(t, m.Save())
entity.FlushSessionCache()
s2 := authPhotos()
require.NotNil(t, s2)
assert.Equal(t, http.StatusForbidden, s2.HttpStatus())
// The app password record itself is preserved, so reactivating the account restores
// access without reconfiguring devices.
rec, err := entity.FindSession(sess.ID)
require.NoError(t, err)
assert.NotNil(t, rec)
}
func TestAuthToken(t *testing.T) {
t.Run("None", func(t *testing.T) {
gin.SetMode(gin.TestMode)

View file

@ -175,10 +175,10 @@ func ActivateUserPasscode(router *gin.RouterGroup) {
// Log event.
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, authn.Passcode, status.Activated}, s.RefID)
// Invalidate any other user sessions to protect the account:
// https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, "invalidated %s"}, s.RefID,
english.Plural(user.DeleteSessions([]string{s.ID}), authn.Session, authn.Sessions))
// Revoke other user sessions after a privilege level change,
// except for app passwords and client access tokens.
event.AuditInfo([]string{ClientIP(c), "session %s", authn.Users, user.UserName, "revoked %s"}, s.RefID,
english.Plural(user.RevokeDerivedSessions([]string{s.ID}), authn.Session, authn.Sessions))
// Clear session cache.
s.ClearCache()

View file

@ -117,10 +117,10 @@ func UpdateUserPassword(router *gin.RouterGroup) {
// Log event.
event.AuditInfo([]string{ClientIP(c), "session %s", "users", u.UserName, "password", "changed"}, s.RefID)
// Invalidate any other user sessions to protect the account:
// https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
event.AuditInfo([]string{ClientIP(c), "session %s", "users", u.UserName, "invalidated %s"}, s.RefID,
english.Plural(u.DeleteSessions([]string{s.ID}), "session", "sessions"))
// Revoke other user sessions after a privilege level change,
// except for app passwords and client access tokens.
event.AuditInfo([]string{ClientIP(c), "session %s", "users", u.UserName, "revoked %s"}, s.RefID,
english.Plural(u.RevokeDerivedSessions([]string{s.ID}), "session", "sessions"))
AddTokenHeaders(c, s)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPasswordChanged))

View file

@ -146,14 +146,12 @@ func UpdateUser(router *gin.RouterGroup) {
// Log event.
event.AuditInfo([]string{ClientIP(c), "session %s", "users", m.UserName, "updated"}, s.RefID)
// Delete user sessions after a privilege level change.
// see https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#renew-the-session-id-after-any-privilege-level-change
// Revoke other user sessions after a privilege level change,
// except for app passwords and client access tokens.
if privilegeLevelChange {
// Prevent the current session from being deleted.
deleted := m.DeleteSessions([]string{s.ID})
// Delete active user sessions.
event.AuditInfo([]string{ClientIP(c), "session %s", "users", m.UserName, "invalidated %s"}, s.RefID,
english.Plural(deleted, "session", "sessions"))
revoked := m.RevokeDerivedSessions([]string{s.ID})
event.AuditInfo([]string{ClientIP(c), "session %s", "users", m.UserName, "revoked %s"}, s.RefID,
english.Plural(revoked, "session", "sessions"))
}
// Flush session cache.

View file

@ -272,6 +272,15 @@ func (m *User) InvalidUID() bool {
return !m.HasUID()
}
// IsSystemOrInvalid checks whether the user is a system user or has an invalid ID.
func (m *User) IsSystemOrInvalid() bool {
if m == nil {
return true
}
return m.ID <= 0 || m.InvalidUID()
}
// SameUID checks if the given uid matches the own uid.
func (m *User) SameUID(uid string) bool {
if m == nil {
@ -283,14 +292,17 @@ func (m *User) SameUID(uid string) bool {
return m.UserUID == uid
}
// InitAccount sets the name and password of the initial admin account.
// InitAccount sets the name and password of the initial super admin account.
func (m *User) InitAccount(initName, initPasswd, scope string) (updated bool) {
// User must exist and the password must not be empty.
initPasswd = strings.TrimSpace(initPasswd)
if rnd.InvalidUID(m.UserUID, UserUID) || initPasswd == "" {
if m.InvalidUID() || initPasswd == "" {
return false
} else if m.IsDeleted() || m.HasProvider(authn.ProviderNone) {
event.SystemDebug([]string{"config", "init", "admin account", status.Disabled})
return false
} else if !m.CanLogIn() {
log.Warnf("users: %s account is not allowed to log in", m.String())
event.SystemWarn([]string{"config", "init", "admin account", status.Disabled})
}
// Abort if user has a password.
@ -352,7 +364,7 @@ func (m *User) Save() (err error) {
// Delete marks the entity as deleted.
func (m *User) Delete() (err error) {
if m.ID <= 1 {
if m.IsSystemOrInvalid() {
return fmt.Errorf("cannot delete system user")
} else if m.UserUID == "" {
return fmt.Errorf("uid is required to delete user")
@ -371,7 +383,9 @@ func (m *User) Delete() (err error) {
// IsDeleted checks if the user account has been deleted.
func (m *User) IsDeleted() bool {
if m.DeletedAt == nil {
if m == nil {
return true
} else if m.DeletedAt == nil {
return false
}
@ -475,13 +489,13 @@ func (m *User) UpdateLoginTime() *time.Time {
return timeStamp
}
// CanLogIn checks if the user is allowed to log in and use the web UI.
// CanLogIn checks if the user is allowed to log in and use the Web UI/API.
func (m *User) CanLogIn() bool {
if m == nil {
return false
} else if m.IsDeleted() || m.HasProvider(authn.ProviderNone) {
} else if m.IsSystemOrInvalid() || m.IsDeleted() || m.HasProvider(authn.ProviderNone) {
return false
} else if !m.CanLogin && !m.SuperAdmin || m.ID <= 0 || m.UserName == "" {
} else if !m.CanLogin && !m.SuperAdmin || m.UserName == "" {
return false
} else if m.IsDisabled() || m.IsUnknown() || !m.IsRegistered() {
return false
@ -490,12 +504,17 @@ func (m *User) CanLogIn() bool {
}
}
// DenyLogIn checks if the user should be denied access to the web UI/API
func (m *User) DenyLogIn() bool {
return !m.CanLogIn()
}
// CanUseWebDAV checks whether the user is allowed to use WebDAV to synchronize files.
func (m *User) CanUseWebDAV() bool {
if m == nil {
// Abort check if user is nil for any reason.
return false
} else if !m.WebDAV || m.ID <= 0 || m.IsDisabled() || m.IsUnknown() || !m.IsRegistered() || m.HasProvider(authn.ProviderNone) {
} else if !m.WebDAV || m.IsSystemOrInvalid() || m.IsDisabled() || m.IsUnknown() || !m.IsRegistered() || m.HasProvider(authn.ProviderNone) {
// Deny WebDAV access if WebDAV is disabled, the user does not have a
// regular, registered account, or the account has been deactivated.
return false
@ -998,26 +1017,39 @@ func (m *User) IsUnknown() bool {
return m.InvalidUID() || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID || m.HasRole(acl.RoleNone)
}
// DeleteSessions deletes all active user sessions except those passed as argument.
func (m *User) DeleteSessions(omit []string) (deleted int) {
// RevokeDerivedSessions deletes user login sessions, including those created using app passwords.
func (m *User) RevokeDerivedSessions(omit []string) (deleted int) {
return m.RevokeSessions(omit, authn.RevokeDerivedSessions)
}
// RevokeSessions deletes all user sessions depending on the scope, except for the ones specified to omit.
func (m *User) RevokeSessions(omit []string, scope authn.SessionScope) (deleted int) {
if m.UserUID == "" {
return 0
}
// Compose update statement.
stmt := Db()
// Limit deletion to the user's own sessions, excluding the ids passed as argument.
stmt := Db().Where("user_uid = ?", m.UserUID)
// Find all user sessions except the session ids passed as argument.
if len(omit) == 0 {
stmt = stmt.Where("user_uid = ?", m.UserUID)
} else {
stmt = stmt.Where("user_uid = ? AND id NOT IN (?)", m.UserUID, omit)
if len(omit) > 0 {
stmt = stmt.Where("id NOT IN (?)", omit)
}
// Exclude client access tokens.
stmt = stmt.Where("auth_provider NOT IN (?)", authn.ClientProviders)
// Restrict the session types removed based on the revocation scope.
switch scope {
case authn.RevokeLoginSessions:
// Keep app passwords, client access tokens, and sessions derived from app passwords.
stmt = stmt.Where("auth_provider NOT IN (?)", authn.ClientProviders)
case authn.RevokeDerivedSessions:
// Keep app passwords and client access tokens, but delete regular
// login sessions and those derived from app passwords.
stmt = stmt.Where("auth_provider NOT IN (?) OR auth_method = ?",
authn.ClientProviders, authn.MethodSession.String())
case authn.RevokeAllSessions:
// Remove all sessions, including app passwords, client access tokens, and derived sessions.
}
// Fetch sessions from database.
// Fetch matching sessions from the database.
sess := Sessions{}
if err := stmt.Find(&sess).Error; err != nil {
@ -1409,7 +1441,7 @@ func (m *User) PrivilegeLevelChange(frm form.User) bool {
// cluster service principal that has no end-user identity of its own and so
// cannot satisfy u.IsAdmin() despite being authorized for user management.
func (m *User) SaveForm(frm form.User, u *User, byAdmin bool) error {
if m.UserName == "" || m.ID <= 0 {
if m.UserName == "" || m.IsSystemOrInvalid() {
return fmt.Errorf("system users cannot be modified")
} else if frm.SuperAdmin && !acl.IsAdminRole(acl.Role(frm.Role())) {
// Super admins must keep an admin-level role. cluster_admin is the
@ -1552,7 +1584,7 @@ func (m *User) HasAvatar() bool {
// SetAvatar updates the user avatar image.
func (m *User) SetAvatar(thumb, thumbSrc string) error {
if m.UserName == "" || m.ID <= 0 {
if m.UserName == "" || m.IsSystemOrInvalid() {
return fmt.Errorf("system user avatars cannot be changed")
}

View file

@ -109,8 +109,9 @@ func (m *User) SetValuesFromCli(ctx *cli.Context) error {
// Invalid.
return err
} else if privilegeLevelChange {
// Delete sessions after privilege level change.
m.DeleteSessions(nil)
// Revoke all user sessions after a privilege level change,
// except for app passwords and client access tokens.
m.RevokeDerivedSessions(nil)
}
return nil

View file

@ -868,9 +868,9 @@ func TestUser_SetPassword(t *testing.T) {
func TestUser_InitAccount(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
p := User{UserUID: "u000000000000009", UserName: "Hanna", DisplayName: "", CanLogin: true}
p := User{ID: 9, UserUID: "u000000000000009", UserName: "Hanna", DisplayName: "", UserRole: acl.RoleAdmin.String(), AuthProvider: authn.ProviderLocal.String(), CanLogin: true}
assert.Nil(t, FindPassword("u000000000000009"))
assert.True(t, p.InitAccount("admin", "insecure", ""))
assert.True(t, p.InitAccount("Hanna", "insecure", ""))
m := FindPassword("u000000000000009")
if m == nil {
@ -1335,6 +1335,14 @@ func TestUser_CanUseWebDAV(t *testing.T) {
assert.False(t, UserFixtures.Pointer("deleted").CanUseWebDAV())
assert.False(t, UserFixtures.Pointer("friend").CanUseWebDAV())
// Disabling web login must not affect WebDAV access: the API gate denies app
// passwords when CanLogin is off, while WebDAV stays governed by CanUseWebDAV.
// bob is a non-super-admin with WebDAV enabled (super admins always keep login).
webdavUser := UserFixtures.Get("bob")
webdavUser.CanLogin = false
assert.False(t, webdavUser.CanLogIn())
assert.True(t, webdavUser.CanUseWebDAV())
}
func TestUser_CanUpload(t *testing.T) {
@ -2390,7 +2398,7 @@ func TestUser_Equal(t *testing.T) {
assert.False(t, Admin.Equal(&Visitor))
}
func TestUser_DeleteSessions(t *testing.T) {
func TestUser_RevokeDerivedSessions(t *testing.T) {
t.Run("EmptyUid", func(t *testing.T) {
u := User{
ID: 1234567,
@ -2399,13 +2407,115 @@ func TestUser_DeleteSessions(t *testing.T) {
UserRole: "user",
}
assert.Equal(t, 0, u.DeleteSessions([]string{}))
assert.Equal(t, 0, u.RevokeDerivedSessions([]string{}))
})
t.Run("Alice", func(t *testing.T) {
m := FindLocalUser("alice")
assert.Equal(t, 0, m.DeleteSessions([]string{rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")}))
assert.Equal(t, 1, m.DeleteSessions([]string{}))
assert.Equal(t, 0, m.RevokeDerivedSessions([]string{rnd.SessionID("69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0")}))
assert.Equal(t, 1, m.RevokeDerivedSessions([]string{}))
})
}
// revokeTestSession creates a persisted session of the given type for a synthetic user.
func revokeTestSession(t *testing.T, uid string, provider authn.ProviderType, method authn.MethodType, authID string) *Session {
s := NewSession(86400, 0)
s.UserUID = uid
s.UserName = "revoke-test"
s.SetProvider(provider)
s.SetMethod(method)
if authID != "" {
s.SetAuthID(authID, uid)
}
require.NoError(t, s.Create())
return s
}
// countUserSessions returns the number of sessions stored for the given user uid.
func countUserSessions(t *testing.T, uid string) int {
var sess Sessions
require.NoError(t, Db().Where("user_uid = ?", uid).Find(&sess).Error)
return len(sess)
}
// newRevokeTestUser creates a synthetic user with one regular login, a parent app
// password, a derived child app session, and a client access token.
func newRevokeTestUser(t *testing.T) *User {
uid := rnd.GenerateUID(UserUID)
revokeTestSession(t, uid, authn.ProviderLocal, authn.MethodDefault, "")
parent := revokeTestSession(t, uid, authn.ProviderApplication, authn.MethodDefault, "")
revokeTestSession(t, uid, authn.ProviderApplication, authn.MethodSession, parent.ID)
revokeTestSession(t, uid, authn.ProviderClient, authn.MethodOAuth2, "")
return &User{ID: 100, UserUID: uid, UserName: "revoke-test", UserRole: "admin", AuthProvider: authn.ProviderLocal.String(), RefID: "usrevoke0001"}
}
func TestUser_RevokeSessions(t *testing.T) {
t.Run("EmptyUid", func(t *testing.T) {
u := &User{ID: 1234567, UserUID: "", UserName: "test", UserRole: "user"}
assert.Equal(t, 0, u.RevokeSessions(nil, authn.RevokeAllSessions))
})
t.Run("LoginSessions", func(t *testing.T) {
u := newRevokeTestUser(t)
assert.Equal(t, 4, countUserSessions(t, u.UserUID))
assert.Equal(t, 1, u.RevokeSessions(nil, authn.RevokeLoginSessions))
assert.Equal(t, 3, countUserSessions(t, u.UserUID))
})
t.Run("DerivedSessions", func(t *testing.T) {
u := newRevokeTestUser(t)
assert.Equal(t, 2, u.RevokeSessions(nil, authn.RevokeDerivedSessions))
assert.Equal(t, 2, countUserSessions(t, u.UserUID))
})
t.Run("AllSessions", func(t *testing.T) {
u := newRevokeTestUser(t)
assert.Equal(t, 4, u.RevokeSessions(nil, authn.RevokeAllSessions))
assert.Equal(t, 0, countUserSessions(t, u.UserUID))
})
t.Run("OmitKeepsSession", func(t *testing.T) {
u := newRevokeTestUser(t)
var login Sessions
require.NoError(t, Db().Where("user_uid = ? AND auth_provider = ?", u.UserUID, authn.ProviderLocal.String()).Find(&login).Error)
require.Len(t, login, 1)
assert.Equal(t, 0, u.RevokeSessions([]string{login[0].ID}, authn.RevokeLoginSessions))
assert.Equal(t, 4, countUserSessions(t, u.UserUID))
})
t.Run("CrossUserIsolation", func(t *testing.T) {
// The `auth_method = 'session'` clause must stay scoped to the target user, so
// revoking one user's derived sessions must never touch another user's sessions.
uidA := rnd.GenerateUID(UserUID)
uidB := rnd.GenerateUID(UserUID)
parentA := revokeTestSession(t, uidA, authn.ProviderApplication, authn.MethodDefault, "")
revokeTestSession(t, uidA, authn.ProviderApplication, authn.MethodSession, parentA.ID)
parentB := revokeTestSession(t, uidB, authn.ProviderApplication, authn.MethodDefault, "")
derivedB := revokeTestSession(t, uidB, authn.ProviderApplication, authn.MethodSession, parentB.ID)
uA := &User{ID: 101, UserUID: uidA, UserName: "iso-a", RefID: "usiso0000a01"}
assert.Equal(t, 1, uA.RevokeDerivedSessions(nil))
assert.Equal(t, 1, countUserSessions(t, uidA)) // parent A kept
assert.Equal(t, 2, countUserSessions(t, uidB)) // user B untouched
sB, err := FindSession(derivedB.ID)
require.NoError(t, err)
require.NotNil(t, sB)
})
}
func TestUser_DenyLogIn(t *testing.T) {
t.Run("Active", func(t *testing.T) {
assert.False(t, UserFixtures.Pointer("alice").DenyLogIn())
})
t.Run("ProviderNone", func(t *testing.T) {
u := UserFixtures.Get("bob")
u.SetProvider(authn.ProviderNone)
assert.True(t, u.DenyLogIn())
})
t.Run("WebLoginDisabled", func(t *testing.T) {
u := UserFixtures.Get("bob")
u.CanLogin = false
assert.True(t, u.DenyLogIn())
})
t.Run("Nil", func(t *testing.T) {
var u *User
assert.True(t, u.DenyLogIn())
})
}

View file

@ -25,8 +25,9 @@ import (
"github.com/photoprism/photoprism/pkg/rnd"
)
// Use auth cache to improve WebDAV performance. It has a standard expiration time of about 5 minutes.
var webdavAuthExpiration = 5 * time.Minute
// Use auth cache to improve WebDAV performance. The short expiration time bounds how long
// a credential keeps working after the account is changed or its WebDAV access is revoked.
var webdavAuthExpiration = 1 * time.Minute
var webdavAuthCache = gc.New(webdavAuthExpiration, webdavAuthExpiration)
var webdavAuthMutex = sync.Mutex{}

16
pkg/authn/revoke.go Normal file
View file

@ -0,0 +1,16 @@
package authn
// SessionScope represents a session revocation target.
type SessionScope uint8
const (
// RevokeLoginSessions removes interactive login sessions while keeping app
// passwords, client access tokens, and sessions derived from app passwords.
RevokeLoginSessions SessionScope = iota
// RevokeDerivedSessions additionally removes sessions derived from an app
// password while keeping the app passwords themselves, so configured devices keep working.
RevokeDerivedSessions
// RevokeAllSessions removes every session, including app password records,
// client access tokens, and sessions derived from them.
RevokeAllSessions
)

View file

@ -4,6 +4,7 @@ package status
const (
Failed = "failed"
Denied = "denied"
Disabled = "disabled"
Granted = "granted"
Added = "added"
Updated = "updated"