Skip to content

feat(server): persist HttpSession across server restarts#154

Open
OGoneZ wants to merge 1 commit intoSathvik-Rao:mainfrom
OGoneZ:pr/persist-session
Open

feat(server): persist HttpSession across server restarts#154
OGoneZ wants to merge 1 commit intoSathvik-Rao:mainfrom
OGoneZ:pr/persist-session

Conversation

@OGoneZ
Copy link
Copy Markdown

@OGoneZ OGoneZ commented May 3, 2026

Summary

Persist HttpSession across server restarts so existing desktop and mobile clients are no longer forced back to the login screen on every redeploy.

Relates to #17 — long-standing self-hoster pain point. From that thread: "A container restart will log out devices because the session keys are removed." This PR fixes that root cause.

What changes

  • pom.xml — add spring-session-jdbc. (spring-session-core was already on the classpath but had no store backend wired in, so persistence was never actually active.)

  • application.properties

    • enable the JDBC store with automatic schema initialization
    • pin server.servlet.session.cookie.name=JSESSIONID so already-deployed clients keep working with no update
    • add WRITE_DELAY=0 to the default H2 URL so session writes are durable across an abrupt docker stop (without it, the most recent session updates can be lost from the file store on shutdown)
  • SecurityConfiguration — swap SessionRegistryImpl for SpringSessionBackedSessionRegistry, so the admin "active devices" view also reads from the persistent store.

  • UserPrincipal / Users — implement Serializable. BruteForceProtectionService is marked transient (it is a Spring bean, not session state); isAccountNonLocked() is null-guarded for the rehydrated path so a restored session doesn't NPE on the now-null service reference. (Brute-force gating still applies to fresh login attempts, which always go through MyUserDetailsService and get a fully wired principal.)

  • SessionServiceSpringSessionBackedSessionRegistry.getAllPrincipals() deliberately throws UnsupportedOperationException (Spring Session doesn't index across all principals). The previous implementation called it from every "log this user out" path:

    • admin force-logoff (ClipCascadeController#486)
    • user-initiated "Log off from all devices" (ClipCascadeController#495)
    • rename username (FacadeUserService#79)
    • change password (FacadeUserService#99)
    • delete user (FacadeUserService#129)

    All five would now return HTTP 500. Replaced with FindByIndexNameSessionRepository.findByPrincipalName() + deleteById() — same end-user semantics (cookie invalidated immediately), indexed lookup, no full scan.

Compatibility

  • Cookie name is unchanged (JSESSIONID); existing desktop and mobile clients continue to work without any client-side update.
  • On first startup the JDBC store auto-creates two new tables in the existing H2 database: SPRING_SESSION and SPRING_SESSION_ATTRIBUTES. Application tables are unaffected.
  • No new env vars introduced. Default behavior changes from "in-memory, lost on restart" to "persistent on disk".

How to verify

docker compose up -d
# Log in from any client; you get a JSESSIONID cookie.
docker compose restart clipcascade
# The same cookie still authenticates — no forced re-login.

Verified locally: login → docker restart → request to /admin/advance with the original cookie returns HTTP 200 and renders the dashboard.

Notes

Happy to split this further if preferred (e.g. Serializable prep first, then the registry swap, then SessionService). Kept it as one PR because the three pieces are individually broken without each other — adding spring-session-jdbc without Serializable on the principal would crash on the first request, and swapping the registry without fixing SessionService would 500 every user-management action.

Replaces the in-memory SessionRegistry with a JDBC-backed Spring Session
store so that a container or process restart no longer evicts every
active session. Without this fix every desktop and mobile client is
forced back to the login screen on each redeploy (see Sathvik-Rao#17 — maintainer
acknowledged: "A container restart will log out devices because the
session keys are removed").

- pom.xml: add spring-session-jdbc (spring-session-core was already on
  the classpath but had no store backend wired in).
- application.properties: enable the JDBC store with automatic schema
  init; pin server.servlet.session.cookie.name=JSESSIONID so existing
  clients stay compatible; add WRITE_DELAY=0 to the H2 URL so session
  writes are durable across an abrupt container stop.
- SecurityConfiguration: swap SessionRegistryImpl for
  SpringSessionBackedSessionRegistry so the admin "active devices" view
  also reads from the persistent store.
- UserPrincipal / Users: implement Serializable.
  BruteForceProtectionService is marked transient (it is a Spring bean,
  not session state); isAccountNonLocked() is null-guarded for the
  rehydrated path.
- SessionService: SpringSessionBackedSessionRegistry.getAllPrincipals()
  deliberately throws UnsupportedOperationException, which would 500
  every "log this user out" path (admin force-logoff, Log off from all
  devices, username/password change, user delete). Replaced with
  FindByIndexNameSessionRepository.findByPrincipalName() + deleteById()
  — same semantics, indexed lookup.

Verified locally: login → docker restart → original cookie still
authenticates and the dashboard renders without re-login.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant