fix: a password change or ban ends authorized device sessions

The device hand-over now refuses a session authorized before the user's
tokens_valid_after, and every AccessRevokedEvent revokes the user's
pending and confirmed device sessions inside the write's transaction
(classes.RevokeDeviceSessions).
This commit is contained in:
2026-10-26 16:08:20 +01:00
parent 20e7b175f8
commit 557c03c595
5 changed files with 132 additions and 6 deletions

View File

@@ -1038,6 +1038,79 @@ func TestDeviceRevokeAfterRefresh(t *testing.T) {
})
}
// TestDeviceAccessRevocation pins that a password change or a ban ends the
// device sessions a user authorized before it: AccessRevokedEvent revokes
// the user's pending and confirmed sessions in the write's transaction (a
// used session keeps its status), and a confirmed session authorized
// before the user's tokens_valid_after hands nothing over even when no
// event fired (an application calling RevokeUserAccess alone).
func TestDeviceAccessRevocation(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
t.Run("password_change_revokes_confirmed_sessions", func(t *testing.T) {
user, session := envSession(t, env, "dar-change")
if err := env.gdb.Model(&models.User{}).Where("id = ?", user.ID).Update("has_self_set_password", true).Error; err != nil {
t.Fatal(err)
}
dev := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody)
_, _, usedID := envDevice(t, env, session, "Used desk")
bystander, bystanderSession := envSession(t, env, "dar-bystander")
other := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, bystanderSession, map[string]string{"token": other.Token}), http.StatusOK, deviceAuthorizedBody)
env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/change-password", session, map[string]string{
"current_password": "correct-horse", "password": "dar-changed-password", "password_confirmation": "dar-changed-password",
})
if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusRevoked {
t.Fatalf("the confirmed session survived the password change: %+v", row)
}
wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("revoked"))
var used models.DeviceAuthSession
if err := env.gdb.Where("id = ?", usedID).Take(&used).Error; err != nil {
t.Fatal(err)
}
if used.Status != models.DeviceStatusUsed {
t.Fatalf("the used session changed status: %+v", used)
}
// Another user's confirmed session is untouched and still hands over.
if row := envDeviceRow(t, env, other.Token); row.Status != models.DeviceStatusConfirmed || row.UserID == nil || *row.UserID != bystander.ID {
t.Fatalf("a bystander's session changed: %+v", row)
}
if rec := envPoll(t, env, other.Token); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"token"`) {
t.Fatalf("the bystander's device got no token: %d %s", rec.Code, rec.Body.String())
}
})
t.Run("cutoff_after_the_authorization_hands_nothing_over", func(t *testing.T) {
user, session := envSession(t, env, "dar-cutoff")
dev := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody)
row := envDeviceRow(t, env, dev.Token)
// RevokeUserAccess without the event: only tokens_valid_after moves.
cutoff := row.ConfirmedAt.Truncate(time.Second).Add(time.Second)
if err := env.gdb.Model(&models.User{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
t.Fatal(err)
}
wantBody(t, envPoll(t, env, dev.Token), http.StatusOK, deviceStatusBody("revoked"))
if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusRevoked || row.JWTJTI != nil {
t.Fatalf("row = %+v", row)
}
// A device authorized after the cutoff signs in.
if err := env.gdb.Model(&models.User{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(-time.Minute).Truncate(time.Second)).Error; err != nil {
t.Fatal(err)
}
later := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": later.Token}), http.StatusOK, deviceAuthorizedBody)
if rec := envPoll(t, env, later.Token); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"token"`) {
t.Fatalf("a device authorized after the cutoff got no token: %d %s", rec.Code, rec.Body.String())
}
})
}
// TestDeviceRevokeRacesRefresh pins that a revoke and a refresh of the same
// device cannot leave the device signed in, whichever commits first. A
// refresh whose device session was revoked after the token passed the