Skip to content

feat(#122 PR C2a): DC customer/admin login plumbing#142

Merged
dfcoffin merged 1 commit into
mainfrom
feature/issue-122-pr-c2a-dc-customer-login
Jun 1, 2026
Merged

feat(#122 PR C2a): DC customer/admin login plumbing#142
dfcoffin merged 1 commit into
mainfrom
feature/issue-122-pr-c2a-dc-customer-login

Conversation

@dfcoffin
Copy link
Copy Markdown
Contributor

@dfcoffin dfcoffin commented Jun 1, 2026

Summary

Brings DC's dormant customer-facing login form online. The RetailCustomerEntity already had every column needed (password, role, enabled, account_locked, etc.) and the schema migration was complete — this PR finishes what was started: re-enables the inert LoginController, wires a third SecurityFilterChain backed by a UserDetailsService that maps the existing role column to Spring Security authorities, and makes the long-existing custodian admin UI (RetailCustomerController + @PreAuthorize("hasRole('ROLE_CUSTODIAN')")) actually reachable for the first time.

Also tightens and revives the security boundary on /espi/1_1/resource/Authorization/** (see "Authorization API" below).

What's in this PR

Customer-login filter chain (openespi-datacustodian/config)

  • CustomerLoginSecurityConfiguration — third SecurityFilterChain @Order(0), between BackchannelSecurityConfiguration (HIGHEST_PRECEDENCE, /internal/**) and the public OAuth2 resource-server chain (@Order(1), /espi/**). Matches /login, /logout, /custodian/**, /oauth/authorize-screen/** (the latter mounted now for PR C2b's Authorization Screen).
  • formLogin with BCryptPasswordEncoder, IF_REQUIRED session, CSRF on via the Spring Security default HttpSessionCsrfTokenRepository (right shape for vanilla form POSTs; CookieCsrfTokenRepository is for SPAs).
  • PathPatternRequestMatcher.withDefaults() — the Spring Security 7.x replacement for the removed AntPathRequestMatcher.
  • SimpleUrlAuthenticationSuccessHandler honors a return_to form parameter (absolute URL or same-origin path) so the AS-issued signed handoff from PR C1 round-trips through login. isSafeReturnTo() rejects open-redirect targets (//evil.example.com) as defense in depth above the codec's own signature verification.

UserDetailsService (openespi-datacustodian/security)

  • RetailCustomerUserDetailsService — looks up by username, returns Spring User with authorities derived from the role column, honors enabled + account_locked. Single-entity role-discriminator model (the codebase had already chosen this path; the greenfield ideal of split entities would be a larger refactor with small benefit at this point).
  • Lives in DC, not common — security adapters belong in the consuming web layer.

Login UI

  • LoginController re-enabled: @Controller stereotype activated, GET accepts ?return_to=<signed-handoff> and exposes it to the template.
  • login.html cleanup: removed dead Google/GitHub OAuth client buttons (ESPI is utility-owned customer identity; social login is wrong architecture and out of scope), removed dead /register link, added hidden return_to input. CSRF token auto-injected by Thymeleaf's th:action.

Admin password handling

  • RetailCustomerController.create POST now BCrypt-hashes the cleartext password before save.
  • Dropped the misplaced @Pattern regex from RetailCustomerEntity.password. The constraint was validating the cleartext-password regex against the bcrypt hash (which contains . and / chars excluded from any sensible cleartext charset), so it rejected every successfully-hashed password at JPA persist time. Cleartext strength belongs on form input via PasswordPolicy, not on the stored hash.

Dev sandbox seed

  • DevSandboxAdminSeedRunner@Profile-gated (dev-mysql, dev-postgresql, local, test, testcontainers) CommandLineRunner that idempotently inserts one admin (username='admin', bcrypt('admin'), role='ROLE_CUSTODIAN') if absent. Out-of-Flyway so production deployments never seed; the cleartext is harmless in source because production deployments don't activate dev profiles.

Authorization API security boundary

Tightened per @donaldcoffin's correction: /espi/1_1/resource/Authorization/** exposes OAuth2 metadata, not customer data. Admin (DC client_credentials) and client (TP client_credentials) tokens both legitimately need it; customer FB-scoped tokens (authorization_code flow output) must never reach it.

Architectural decisions (recap)

  1. Single-entity role-discriminator vs. split ApplicationAdminEntity — kept the existing single-entity model. Greenfield I'd split, but the codebase had already chosen one path; refactor cost > harm here.
  2. Server-rendered Thymeleaf vs. SPA — server-rendered. OAuth2 is fundamentally a redirect flow; every major OAuth2 consent screen (Google, GitHub, Microsoft, Auth0, Keycloak, Okta, Spring Auth Server samples) renders server-side for the same reasons.
  3. Three SecurityFilterChain architecture — back-channel + customer-login + public OAuth2. Canonical Spring Security 7.x multi-audience pattern.
  4. Dev seed via CommandLineRunner, not Flyway — Flyway has no conditional-on-profile mechanism; a seed migration would fire in production too.
  5. Revive @Disabled test instead of delete — disabled tests are technical debt; revive within one release cycle or delete. This one's controller is production-active and the security boundary is worth covering.

Test plan

  • RetailCustomerUserDetailsServiceTest: 6 / 6 unit tests pass.
  • CustomerLoginSecurityConfigurationTest: 7 / 7 MockMvc tests pass (unauthenticated GET, good admin / customer creds, bad creds, CSRF required, return_to honored, open-redirect rejected).
  • AuthorizationControllerTest: 9 / 9 security-boundary tests pass.
  • openespi-datacustodian full suite: 118 / 118 pass, 0 skipped (previously 97 + 1 @Disabled skip → +21 net, +1 skip reclaimed).
  • openespi-common full suite: 866 / 866 pass, 0 failures.
  • CI: 3-DB integration tests (MySQL / PostgreSQL / H2 via TestContainers).
  • CI: Security vulnerability scan + SonarCloud.

Refs

🤖 Generated with Claude Code

Brings DC's dormant customer-facing login form online. The
RetailCustomerEntity already has password / role / enabled / lockout
columns and the schema migration is complete (V2 vendor-specific
tables) — this PR finishes what was started: re-enables the inert
LoginController, wires a SecurityFilterChain backed by a UserDetailsService
that maps the existing `role` column to Spring Security authorities, and
makes the long-existing custodian admin UI (RetailCustomerController +
@PreAuthorize("hasRole('ROLE_CUSTODIAN')")) actually reachable.

Customer-login filter chain (openespi-datacustodian)
- CustomerLoginSecurityConfiguration — third SecurityFilterChain
  @order(0), between BackchannelSecurityConfiguration (HIGHEST_PRECEDENCE
  /internal/**) and the public OAuth2 resource-server chain
  (@order(1) /espi/**). Matches /login, /logout, /custodian/**,
  /oauth/authorize-screen/** (latter mounted now for PR C2b's
  Authorization Screen).
- formLogin with BCrypt, IF_REQUIRED session, CSRF default
  (HttpSessionCsrfTokenRepository — right shape for vanilla form POSTs;
  CookieCsrfTokenRepository is for SPAs).
- PathPatternRequestMatcher.withDefaults() — the Spring Security 7.x
  replacement for the removed AntPathRequestMatcher.
- SimpleUrlAuthenticationSuccessHandler honors a `return_to` form
  parameter (absolute URL or same-origin path) so the AS-issued signed
  handoff from PR C1 round-trips through login. isSafeReturnTo() rejects
  open-redirect targets (`//evil.example.com`) defense-in-depth above
  the codec's own signature verification.

UserDetailsService
- RetailCustomerUserDetailsService (DC, not common — security adapters
  belong in the consuming web layer, not the persistence layer) — looks
  up by username, returns Spring User principal with authorities derived
  from the `role` column, honors `enabled` and `account_locked` flags.
- Single-entity role-discriminator model (the codebase had already
  chosen this path; greenfield would prefer split entities, but the
  refactor cost outweighs the harm).

Login UI
- LoginController re-enabled: @controller stereotype activated, GET
  accepts `?return_to=<signed-handoff>` and exposes it to the template.
- login.html cleanup: removed dead Google/GitHub OAuth client buttons
  (ESPI is utility-owned customer identity; social login is wrong
  architecture and out of scope), removed dead /register link, added
  hidden return_to input. CSRF token auto-injected by Thymeleaf's
  th:action.

Admin password handling
- RetailCustomerController.create POST now BCrypt-hashes the cleartext
  password before save (was previously persisting raw, the @PreAuthorize
  meant this code path was unreachable so it was latent rather than
  exploited).
- Dropped misplaced @pattern regex from RetailCustomerEntity.password
  — the constraint was validating the cleartext-password regex against
  the bcrypt hash (which contains `.` and `/` chars excluded from any
  sensible cleartext charset), so it rejected every successfully-hashed
  password at JPA persist time. Cleartext strength belongs on form
  input via PasswordPolicy, not on the stored hash.

Dev sandbox seed
- DevSandboxAdminSeedRunner — @Profile-gated (dev-mysql, dev-postgresql,
  local, test, testcontainers) CommandLineRunner that idempotently
  inserts one admin (username='admin', bcrypt('admin'),
  role='ROLE_CUSTODIAN') if absent. Out-of-Flyway so production
  deployments never seed; the bcrypt cleartext is harmless in source
  because production deployments don't activate dev profiles.

Authorization API security boundary (revives @disabled test)
- SecurityConfiguration: /espi/1_1/resource/Authorization/** now accepts
  hasAnyAuthority(SCOPE_DataCustodian_Admin_Access,
  SCOPE_ThirdParty_Admin_Access). The endpoint exposes OAuth2 metadata,
  not customer data — admin (DC client_credentials) and client (TP
  client_credentials) tokens both legitimately need it; customer FB-scoped
  tokens (authorization_code flow output) must never reach it.
- AuthorizationController @PreAuthorize on both methods updated to match.
- AuthorizationControllerTest revived (was @disabled since PR #116) and
  rewritten as a security-boundary-only test: 401 unauthenticated, 403 for
  customer FB scopes (SCOPE_FB_15_*, SCOPE_FB_54_*), pass-the-gate for
  both DC_Admin and TP_Admin. Body content is NOT asserted — the stub
  bodies return null and need implementation (#141 filed).

Verification
- RetailCustomerUserDetailsServiceTest: 6 / 6 unit tests pass.
- CustomerLoginSecurityConfigurationTest: 7 / 7 MockMvc tests pass
  (unauthenticated GET, good admin / customer creds, bad creds, CSRF
  required, return_to honored, open-redirect rejected).
- AuthorizationControllerTest: 9 / 9 security-boundary tests pass.
- openespi-datacustodian full suite: 118 / 118 pass, 0 skipped
  (previously 97 + 1 @disabled skip — +21 net, +1 skip reclaimed).
- openespi-common full suite: 866 / 866 pass.

Refs: #122. Builds on PR A (#136), PR B1 (#137), PR B2 (#139), PR C1 (#140).
Follow-up: #141 (AuthorizationController stub bodies + per-TP filtering).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dfcoffin dfcoffin merged commit c17e02c into main Jun 1, 2026
4 checks passed
@dfcoffin dfcoffin deleted the feature/issue-122-pr-c2a-dc-customer-login branch June 1, 2026 13:13
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