diff --git a/internal/api/api_auth.go b/internal/api/api_auth.go index 7ccb89e21..032df780f 100644 --- a/internal/api/api_auth.go +++ b/internal/api/api_auth.go @@ -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). diff --git a/internal/api/api_auth_test.go b/internal/api/api_auth_test.go index 62275c7df..6965fb419 100644 --- a/internal/api/api_auth_test.go +++ b/internal/api/api_auth_test.go @@ -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) diff --git a/internal/api/users_passcode.go b/internal/api/users_passcode.go index fac30d7c7..5afe8eb56 100644 --- a/internal/api/users_passcode.go +++ b/internal/api/users_passcode.go @@ -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() diff --git a/internal/api/users_password.go b/internal/api/users_password.go index 5bd6ceb9c..e78e3ac40 100644 --- a/internal/api/users_password.go +++ b/internal/api/users_password.go @@ -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)) diff --git a/internal/api/users_update.go b/internal/api/users_update.go index 9ffcdf8e0..3889f4e25 100644 --- a/internal/api/users_update.go +++ b/internal/api/users_update.go @@ -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. diff --git a/internal/entity/auth_user.go b/internal/entity/auth_user.go index 40540b929..bebcd7a43 100644 --- a/internal/entity/auth_user.go +++ b/internal/entity/auth_user.go @@ -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") } diff --git a/internal/entity/auth_user_cli.go b/internal/entity/auth_user_cli.go index aa9ad36ad..a7f6e6790 100644 --- a/internal/entity/auth_user_cli.go +++ b/internal/entity/auth_user_cli.go @@ -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 diff --git a/internal/entity/auth_user_test.go b/internal/entity/auth_user_test.go index e8e83af6f..ec8f539d4 100644 --- a/internal/entity/auth_user_test.go +++ b/internal/entity/auth_user_test.go @@ -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()) }) } diff --git a/internal/server/webdav_auth.go b/internal/server/webdav_auth.go index c6a04a494..2c73ed73e 100644 --- a/internal/server/webdav_auth.go +++ b/internal/server/webdav_auth.go @@ -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{} diff --git a/pkg/authn/revoke.go b/pkg/authn/revoke.go new file mode 100644 index 000000000..73fe2ae92 --- /dev/null +++ b/pkg/authn/revoke.go @@ -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 +) diff --git a/pkg/log/status/const.go b/pkg/log/status/const.go index 38970ca05..bfe3d56c0 100644 --- a/pkg/log/status/const.go +++ b/pkg/log/status/const.go @@ -4,6 +4,7 @@ package status const ( Failed = "failed" Denied = "denied" + Disabled = "disabled" Granted = "granted" Added = "added" Updated = "updated"