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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user