mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
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:
parent
aff25636b5
commit
c45ee6e54f
11 changed files with 334 additions and 55 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
16
pkg/authn/revoke.go
Normal 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
|
||||
)
|
||||
|
|
@ -4,6 +4,7 @@ package status
|
|||
const (
|
||||
Failed = "failed"
|
||||
Denied = "denied"
|
||||
Disabled = "disabled"
|
||||
Granted = "granted"
|
||||
Added = "added"
|
||||
Updated = "updated"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue