diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3e05712c..b4abec1a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -184,7 +184,7 @@ contract (column-name keys, `{col}_display`/`{col}_ref` expansion, `__SECRET_SET | DivKit UI | `GET /api/divkit/{shell,home,menu,account,settings}` and `/api/divkit/{catalogs,documents}/{name}[/{id}|/new|/{id}/edit]`, `/api/divkit/registers/{name}` (ui-starter) | | Theme/config | `GET /api/theme`, `GET /api/config`, `GET /api/branding` (ui-starter) | | Events | `GET /api/events` — SSE stream of CRUD/posting changes (ui-starter) | -| Auth | `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me` (auth-starter) | +| Auth | `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`; opt-in magic-link: `POST /api/auth/magic/request`, `GET /api/auth/magic/verify` (auth-starter) | | Import | `POST /api/import/{catalogs,documents}/{name}/csv[/preview]` (import-starter) | | Desktop | `GET /api/desktop/ready`, `GET /api/desktop/manifest` (desktop-starter) | | MCP | `POST /mcp` — streamable-HTTP MCP transport (mcp-starter) | @@ -225,9 +225,19 @@ and `config(key,value)` reference. the token via `onec.auth.oidc.*`; RP-initiated logout. - **`resource-server`** — stateless JWT bearer validation, no session/CSRF. +**Passwordless magic-link** (opt-in, in-memory mode): set `onec.auth.magic-link.enabled=true` and a +user's `email` on `onec.auth.users[*]`. `POST /api/auth/magic/request {email}` emails a single-use +link (via the `onec-mail-starter`); `GET /api/auth/magic/verify?token=…` consumes it, establishes the +session like `/api/auth/login`, and redirects into the app. Tokens are stored hashed in +`onec_auth_magic_link` (needs a `DataSource`) so links validate across nodes; the request endpoint +returns `202` regardless of whether the address is registered (no account enumeration). Delivery and +token storage are pluggable via `MagicLinkSender` / `MagicLinkTokenStore` beans; the login screen +advertises it through `AuthMethods.magicLinkEnabled`. + `/api/**` requires authentication (except the public allowlist: `/error`, `/api/theme`, `/api/config`, `/api/branding`, `/api/auth/login`, `/api/auth/me`, `/api/divkit/login`, -`/api/desktop/**`). **Per-entity RBAC is deny-by-default**: a catalog/document/register is invisible +`/api/auth/magic/request`, `/api/auth/magic/verify`, `/api/desktop/**`). **Per-entity RBAC is +deny-by-default**: a catalog/document/register is invisible and uneditable unless its `@AccessControl` read/write roles grant the caller; the `ADMIN` role is a superuser. Override the whole thing by setting `onec.auth.enabled=false` and supplying your own `SecurityFilterChain`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c67d8490..df0a883c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -52,6 +52,11 @@ except Kafka inbound). Standard Spring keys (`spring.datasource.*`, `spring.mail | --- | --- | --- | --- | | `onec.auth.csrf-ignored-paths` | `List` | — | Request paths exempted from CSRF protection in the cookie-based modes (IN_MEMORY and OIDC). Defaults to just the login endpoint. Add a path here to expose an anonymous, CSRF-free `POST` (e.g. a public lead/intake form) without having to override the whole `SecurityFilterChain` (issue #30). Ant patterns are supported (e.g. `/api/public/**`). Ignored in RESOURCE_SERVER, where CSRF is already disabled. | | `onec.auth.enabled` | `Boolean` | `true` | Master switch for the auth starter. When false, no SecurityFilterChain is contributed and the application can wire its own. | +| `onec.auth.magic-link.base-url` | `String` | — | Absolute base URL used to build the link placed in the email, e.g. `https://app.example.com`. Leave blank to derive it from the incoming request's origin (scheme/host/port) — correct for single-origin deployments, but set it explicitly when the public URL differs from what the app sees behind a proxy/load balancer. | +| `onec.auth.magic-link.enabled` | `Boolean` | `false` | Whether magic-link login is wired and advertised on the login screen. Off by default. | +| `onec.auth.magic-link.redirect-path` | `String` | `/` | Where the browser lands after a link is successfully verified and the session established. A same-origin path (must start with `/`); defaults to the app root. | +| `onec.auth.magic-link.subject` | `String` | `Your sign-in link` | Subject line of the magic-link email. | +| `onec.auth.magic-link.token-validity` | `Duration` | `15m` | How long an emailed link stays valid before it must be re-requested. Single-use regardless: following a link consumes it. Kept short — a link is a bearer credential sitting in an inbox. | | `onec.auth.mode` | `Mode` | `in-memory` | Which authentication backend the starter wires. Selecting a mode only changes how identities are authenticated — the `/api/**`-requires-auth model is the same across all of them. | | `onec.auth.oidc.logout-path` | `String` | `/logout` | Path the SPA navigates to for RP-initiated logout in OIDC mode. A GET here clears the local session and redirects to the IdP's end-session endpoint. Surfaced to the SPA via `/api/auth/me` as `logoutUrl`. | | `onec.auth.oidc.post-logout-redirect-uri` | `String` | `{baseUrl}` | Where the IdP sends the browser after ending its session. `{baseUrl}` expands to the app's own origin (scheme://host:port), so the user lands back on the SPA shell. This value must be registered under the IdP client's valid post-logout redirect URIs. | diff --git a/onec-auth-starter/README.md b/onec-auth-starter/README.md index f84f7e02..1af3ea9b 100644 --- a/onec-auth-starter/README.md +++ b/onec-auth-starter/README.md @@ -56,6 +56,12 @@ to contribute nothing and wire your own security. | `onec.auth.users[*].username` | — | In-memory account username. | | `onec.auth.users[*].password` | — | Plaintext password; BCrypt-encoded at startup. Missing username **or** password fails startup. | | `onec.auth.users[*].roles` | `[]` | Role names (stored as `ROLE_*` authorities by Spring's `User.roles(...)`). | +| `onec.auth.users[*].email` | — | Optional email that identifies this user for magic-link login (opt-in per account). See [magic-link](#passwordless-magic-link-login). | +| `onec.auth.magic-link.enabled` | `false` | Opt-in passwordless email login (in-memory mode). See [magic-link](#passwordless-magic-link-login). | +| `onec.auth.magic-link.token-validity` | `15m` | How long an emailed link stays valid; single-use regardless. | +| `onec.auth.magic-link.base-url` | — | Absolute base URL for the emailed link (e.g. `https://app.example.com`). Blank derives it from the request origin. | +| `onec.auth.magic-link.redirect-path` | `/` | Same-origin path the browser lands on after a link is verified. | +| `onec.auth.magic-link.subject` | `Your sign-in link` | Subject line of the magic-link email. | | `onec.auth.session.timeout` | `8h` | Idle session timeout for the cookie modes (`in-memory`, `oidc`), applied to the servlet container and **overriding** Spring Boot's 30-minute default. Slides on every request. Ignored in `resource-server` (stateless). | | `onec.auth.session.remember-me.enabled` | `true` | In-memory only: issue a persistent remember-me cookie on login and re-authenticate from it after the session lapses. | | `onec.auth.session.remember-me.validity` | `14d` | How long the remember-me cookie stays valid. | @@ -67,7 +73,12 @@ Default `public-paths`: /error /api/theme /api/config +/api/branding /api/auth/login +/api/auth/me +/api/divkit/login +/api/auth/magic/request +/api/auth/magic/verify /api/desktop/ready /api/desktop/manifest ``` @@ -131,6 +142,8 @@ CSRF protection is **enabled** using `CookieCsrfTokenRepository.withHttpOnlyFals | `POST` | `/api/auth/login` | public, CSRF-exempt | `{"username":"...","password":"...","remember":true}` | `200` `{"authenticated":true,"username":"...","roles":["ROLE_ADMIN",...]}`; `401` on bad credentials; `400` if username/password missing. `remember` is optional (default `true`) and, when remember-me is enabled, controls whether the persistent cookie is issued | | `GET` | `/api/auth/me` | public | — | Current user, or `{"authenticated":false,"username":"","roles":[]}` when anonymous | | `POST` | `/api/auth/logout` | authenticated (needs CSRF header) | — | `204 No Content`; cancels the remember-me cookie, clears the security context, and invalidates the session | +| `POST` | `/api/auth/magic/request` | public, CSRF-exempt | `{"email":"..."}` | `202 Accepted` always (no account enumeration). Opt-in — only mapped when `onec.auth.magic-link.enabled=true`. See [magic-link](#passwordless-magic-link-login) | +| `GET` | `/api/auth/magic/verify` | public | `?token=…` | `302` into the app on success (session established) or to `/login?error=link` if invalid/expired/used. Opt-in as above | `roles` are the granted authorities verbatim, so they include the `ROLE_` prefix (a `USER` role appears as `ROLE_USER`). @@ -180,6 +193,64 @@ curl -s -b "$JAR" \ curl -s -b "$JAR" -X POST -H "X-XSRF-TOKEN: $XSRF" "$BASE/api/auth/logout" ``` +## Passwordless magic-link login + +An **opt-in** alternative to the password form for the `in-memory` mode: the user enters their email, +the framework emails a single-use link, and following it establishes the session — no password typed. +It composes with password login (both appear on the login screen); it is **not** used in the `oidc` / +`resource-server` modes, where the IdP owns sign-in. + +The mental model mirrors SSO: just as an OIDC backend keys a user by an external identity, here an +in-memory user *declares its email* as the identity the link looks up. So enabling it is two steps — +flip the switch, and give the accounts that may use it an `email`: + +```yaml +onec: + auth: + magic-link: + enabled: true + # token-validity: 15m # how long a link is valid (single-use regardless) + # base-url: https://app.example.com # else derived from the request origin + # redirect-path: / # where verify lands the browser + users: + - username: admin + password: "s3cret" + roles: [ADMIN] + email: admin@example.com # opt-in: a user with no email can't use magic-link +``` + +**Requirements (fail-fast at startup if missing):** + +- The **`onec-mail-starter`** on the classpath with a configured provider — the default + `MagicLinkSender` emails the link via `MailService`. +- A **`DataSource`** — tokens are persisted (hashed) in `onec_auth_magic_link` so a link issued by one + node validates on any node of a horizontally-scaled deployment. + +**Flow:** + +1. `POST /api/auth/magic/request {"email":"..."}` → if the address maps to a user, a single-use token + is generated, its SHA-256 hash stored with an expiry, and the link emailed. The endpoint returns + `202` **whether or not** an account matched, so it can't be used to enumerate registered addresses. +2. `GET /api/auth/magic/verify?token=…` → validates and consumes the token atomically (single-use, + expiry-checked), establishes the `SecurityContext` in the session exactly like `/api/auth/login`, + and redirects to `redirect-path`. An invalid/expired/used link redirects to `/login?error=link`. + +**Security properties:** 256-bit `SecureRandom` tokens; only their hash is persisted (a store leak +can't be replayed); single-use consumption closes the replay window; short default validity (15 min); +and uniform `202` responses (no enumeration). + +**Extension points** (register your own bean to override the default): + +| SPI | Default | Replace to… | +|-----|---------|-------------| +| `MagicLinkUserLookup` | reads `onec.auth.users` (match `email`, else email-shaped `username`) | resolve emails against your own user directory | +| `MagicLinkTokenStore` | JDBC (`onec_auth_magic_link`) | use a different store (e.g. Redis) | +| `MagicLinkSender` | emails via `MailService` | deliver another way / fully control the message body | + +The login screen advertises the option through `AuthMethods.magicLinkEnabled` (the same SPI that +carries the password flag and SSO providers); the onec UI renders it as the `onec-magic-link` block on +the DivKit login card. + ## OIDC (Keycloak, Zitadel, …) The `oidc` and `resource-server` modes are plain Spring Security OAuth2 — **any** standard OIDC @@ -342,7 +413,8 @@ onec: The available methods are exposed to the UI through the `com.onec.auth.spi.AuthMethodsProvider` bean (contract in `onec-framework`), so the UI module can build the login screen server-side without depending on this module. In the onec UI this drives the DivKit login card (`GET /api/divkit/login`): -the server emits a password form and/or one button per SSO provider, and adding an IdP needs no client +the server emits a password form, the [magic-link](#passwordless-magic-link-login) block (when +`AuthMethods.magicLinkEnabled`), and/or one button per SSO provider, and adding an IdP needs no client change. `AuthMethodsProvider` is the single source of the password flag, mode, and logout URL. To **add** a diff --git a/onec-auth-starter/build.gradle.kts b/onec-auth-starter/build.gradle.kts index 254724c2..c9b68323 100644 --- a/onec-auth-starter/build.gradle.kts +++ b/onec-auth-starter/build.gradle.kts @@ -11,6 +11,11 @@ dependencies { api(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.web) + // Optional: the default magic-link sender emails the link via MailService. compileOnly so apps + // without the mail starter still use the auth starter; the mail-backed sender bean is wired only + // when MailService is on the classpath (and a custom MagicLinkSender can replace it entirely). + compileOnly(project(":onec-mail-starter")) + // OIDC support (Keycloak, Zitadel, …). Only exercised when onec.auth.mode = oidc / // resource-server; the standard Spring Boot auto-config for these stays dormant until // issuer/client properties are set, so in-memory deployments pay nothing but the jars. @@ -22,5 +27,9 @@ dependencies { testImplementation(libs.spring.boot.starter.test) testImplementation(libs.spring.boot.test) + // The JDBC token store is exercised against H2 (the same engine single-node deployments use). + testImplementation(libs.h2) + // Verify the default mail-backed MagicLinkSender against the real MailService/MailMessage types. + testImplementation(project(":onec-mail-starter")) testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthAutoConfiguration.java b/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthAutoConfiguration.java index cc5ccf74..970b8729 100644 --- a/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthAutoConfiguration.java +++ b/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthAutoConfiguration.java @@ -1,12 +1,24 @@ package com.onec.auth; import com.fasterxml.jackson.databind.ObjectMapper; +import com.onec.auth.magic.JdbcMagicLinkTokenStore; +import com.onec.auth.magic.MagicLinkController; +import com.onec.auth.magic.MagicLinkSender; +import com.onec.auth.magic.MagicLinkService; +import com.onec.auth.magic.MagicLinkTokenStore; +import com.onec.auth.magic.MagicLinkUserLookup; +import com.onec.auth.magic.MailMagicLinkSender; +import com.onec.auth.magic.PropertiesMagicLinkUserLookup; import com.onec.auth.spi.AuthMethodsProvider; +import com.onec.mail.MailService; +import org.jdbi.v3.core.Jdbi; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -43,6 +55,7 @@ import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import javax.sql.DataSource; import java.io.IOException; import java.util.Base64; import java.util.Collection; @@ -58,7 +71,14 @@ * authorization model — {@code /api/**} requires authentication, everything else (the SPA shell) * is public — so swapping modes never changes which routes are protected. */ -@AutoConfiguration(before = {SecurityAutoConfiguration.class, UserDetailsServiceAutoConfiguration.class}) +@AutoConfiguration( + // Order after the beans the opt-in magic-link feature consumes via @ConditionalOnBean — the + // DataSource (token store) and, when present, the mail starter's MailService (default sender) — + // so those conditions see the beans instead of evaluating before they're registered. afterName + // keeps the mail starter optional (tolerated when absent). + after = DataSourceAutoConfiguration.class, + afterName = "com.onec.mail.OnecMailAutoConfiguration", + before = {SecurityAutoConfiguration.class, UserDetailsServiceAutoConfiguration.class}) @EnableConfigurationProperties(OnecAuthProperties.class) @ConditionalOnProperty(prefix = "onec.auth", name = "enabled", havingValue = "true", matchIfMissing = true) public class OnecAuthAutoConfiguration { @@ -237,6 +257,75 @@ SecurityFilterChain onecSecurityFilterChain(HttpSecurity http, OnecAuthPropertie .logout(logout -> logout.disable()) .build(); } + + // ------------------------------------------------------------------------------------ + // Opt-in passwordless magic-link login. Nested inside the in-memory config so it only + // activates in that mode (OIDC/resource-server delegate sign-in to the IdP); gated again + // by onec.auth.magic-link.enabled. Tokens are persisted (JDBC) so links validate across a + // horizontally-scaled deployment, and delivery defaults to the mail starter when present. + // ------------------------------------------------------------------------------------ + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(prefix = "onec.auth.magic-link", name = "enabled", havingValue = "true") + static class MagicLinkConfiguration { + + @Bean + @ConditionalOnBean(DataSource.class) + @ConditionalOnMissingBean(MagicLinkTokenStore.class) + MagicLinkTokenStore magicLinkTokenStore(DataSource dataSource) { + JdbcMagicLinkTokenStore store = new JdbcMagicLinkTokenStore(Jdbi.create(dataSource)); + store.initSchema(); + return store; + } + + @Bean + @ConditionalOnMissingBean(MagicLinkUserLookup.class) + MagicLinkUserLookup magicLinkUserLookup(OnecAuthProperties properties) { + return new PropertiesMagicLinkUserLookup(properties); + } + + @Bean + @ConditionalOnMissingBean(MagicLinkService.class) + MagicLinkService magicLinkService(MagicLinkUserLookup userLookup, + ObjectProvider tokenStore, + ObjectProvider sender, + UserDetailsService userDetailsService, + OnecAuthProperties properties) { + MagicLinkTokenStore store = tokenStore.getIfAvailable(); + if (store == null) { + throw new IllegalStateException( + "onec.auth.magic-link.enabled=true needs a DataSource so single-use tokens " + + "can be persisted and validated across nodes. Configure a datasource, " + + "or register your own MagicLinkTokenStore bean."); + } + MagicLinkSender resolvedSender = sender.getIfAvailable(); + if (resolvedSender == null) { + throw new IllegalStateException( + "onec.auth.magic-link.enabled=true needs a way to deliver the link. Add the " + + "onec-mail-starter and configure a mail provider, or register your own " + + "MagicLinkSender bean."); + } + return new MagicLinkService(userLookup, store, resolvedSender, userDetailsService, properties); + } + + @Bean + @ConditionalOnMissingBean(MagicLinkController.class) + MagicLinkController magicLinkController(MagicLinkService service, OnecAuthProperties properties) { + return new MagicLinkController(service, properties); + } + + /** Mail-backed delivery — wired only when the mail starter is on the classpath and active. */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(MailService.class) + static class MailMagicLinkSenderConfiguration { + + @Bean + @ConditionalOnBean(MailService.class) + @ConditionalOnMissingBean(MagicLinkSender.class) + MagicLinkSender mailMagicLinkSender(MailService mailService, OnecAuthProperties properties) { + return new MailMagicLinkSender(mailService, properties); + } + } + } } // ---------------------------------------------------------------------------------------- diff --git a/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthMethodsProvider.java b/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthMethodsProvider.java index a2de7138..8640c8dd 100644 --- a/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthMethodsProvider.java +++ b/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthMethodsProvider.java @@ -29,7 +29,8 @@ class OnecAuthMethodsProvider implements AuthMethodsProvider { @Override public AuthMethods authMethods() { return switch (properties.getMode()) { - case IN_MEMORY -> new AuthMethods(true, List.of(), null, "in-memory"); + case IN_MEMORY -> new AuthMethods(true, properties.getMagicLink().isEnabled(), + List.of(), null, "in-memory"); case RESOURCE_SERVER -> new AuthMethods(false, List.of(), null, "resource-server"); case OIDC -> { OnecAuthProperties.ResolvedOidc oidc = properties.getOidc().resolved(); diff --git a/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthProperties.java b/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthProperties.java index c266c191..5521e2b5 100644 --- a/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthProperties.java +++ b/onec-auth-starter/src/main/java/com/onec/auth/OnecAuthProperties.java @@ -61,6 +61,11 @@ public class OnecAuthProperties { "/api/auth/me", // The server-driven (DivKit) login screen. Public so it can render before sign-in. "/api/divkit/login", + // Passwordless magic-link login. Both must be reachable unauthenticated: request emails + // a single-use link, verify consumes the token and establishes the session. Inert (404) + // unless onec.auth.magic-link.enabled=true wires the controller. + "/api/auth/magic/request", + "/api/auth/magic/verify", // Desktop shell liveness + window manifest. The native shell polls these // before any user can log in, so they must be reachable unauthenticated; // both are non-sensitive (readiness probe + window geometry/title). @@ -75,7 +80,11 @@ public class OnecAuthProperties { * are supported (e.g. {@code /api/public/**}). Ignored in {@link Mode#RESOURCE_SERVER}, where * CSRF is already disabled. */ - private List csrfIgnoredPaths = new ArrayList<>(List.of("/api/auth/login")); + private List csrfIgnoredPaths = new ArrayList<>(List.of( + "/api/auth/login", + // Pre-auth POST from the (public) login screen — no session/token to protect yet, same + // rationale as /api/auth/login. The token itself is the unguessable, single-use credential. + "/api/auth/magic/request")); /** * In-memory user accounts. Empty by default — the consuming app supplies them via @@ -92,6 +101,17 @@ public class OnecAuthProperties { @NestedConfigurationProperty private Session session = new Session(); + /** + * Passwordless "magic link" login for {@link Mode#IN_MEMORY}. Opt-in (disabled by default): the + * user enters their email on the login screen, the framework emails a single-use link, and + * following it establishes a session — no password typed. Requires a configured mail provider + * (the {@code onec-mail-starter}) and a {@code DataSource} (tokens are persisted so links validate + * across every node of a horizontally-scaled deployment). Ignored in the OIDC and resource-server + * modes, where the IdP owns sign-in. + */ + @NestedConfigurationProperty + private MagicLink magicLink = new MagicLink(); + public boolean isEnabled() { return enabled; } @@ -148,6 +168,14 @@ public void setSession(Session session) { this.session = session; } + public MagicLink getMagicLink() { + return magicLink; + } + + public void setMagicLink(MagicLink magicLink) { + this.magicLink = magicLink; + } + public enum Mode { /** Username/password against {@code onec.auth.users}. */ IN_MEMORY, @@ -162,6 +190,16 @@ public static class User { private String password; private List roles = new ArrayList<>(); + /** + * Optional email address that identifies this user for passwordless magic-link login. Opt-in: + * a user with no email cannot use magic-link (the request silently finds no account). The same + * way an OIDC/SSO backend keys a user by its external identity (e.g. a GitHub username), this + * lets an in-memory user declare its email as the identity the magic-link flow looks up. When + * left blank, the {@code username} is treated as the email if it looks like one. Only used in + * {@link Mode#IN_MEMORY} with {@code onec.auth.magic-link.enabled=true}. + */ + private String email; + public String getUsername() { return username; } @@ -185,6 +223,14 @@ public List getRoles() { public void setRoles(List roles) { this.roles = roles; } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } } /** @@ -290,6 +336,78 @@ public void setKey(String key) { } } + /** + * Passwordless email magic-link login (in-memory mode). See {@link OnecAuthProperties#magicLink}. + */ + public static class MagicLink { + + /** Whether magic-link login is wired and advertised on the login screen. Off by default. */ + private boolean enabled = false; + + /** + * How long an emailed link stays valid before it must be re-requested. Single-use regardless: + * following a link consumes it. Kept short — a link is a bearer credential sitting in an inbox. + */ + private Duration tokenValidity = Duration.ofMinutes(15); + + /** + * Absolute base URL used to build the link placed in the email, e.g. + * {@code https://app.example.com}. Leave blank to derive it from the incoming request's + * origin (scheme/host/port) — correct for single-origin deployments, but set it explicitly + * when the public URL differs from what the app sees behind a proxy/load balancer. + */ + private String baseUrl; + + /** + * Where the browser lands after a link is successfully verified and the session established. + * A same-origin path (must start with {@code /}); defaults to the app root. + */ + private String redirectPath = "/"; + + /** Subject line of the magic-link email. */ + private String subject = "Your sign-in link"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Duration getTokenValidity() { + return tokenValidity; + } + + public void setTokenValidity(Duration tokenValidity) { + this.tokenValidity = tokenValidity; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getRedirectPath() { + return redirectPath; + } + + public void setRedirectPath(String redirectPath) { + this.redirectPath = redirectPath; + } + + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + } + /** * OIDC configuration for the {@link Mode#OIDC} and {@link Mode#RESOURCE_SERVER} modes. The * authentication plumbing is plain Spring Security OAuth2 — the only provider-specific concern diff --git a/onec-auth-starter/src/main/java/com/onec/auth/magic/JdbcMagicLinkTokenStore.java b/onec-auth-starter/src/main/java/com/onec/auth/magic/JdbcMagicLinkTokenStore.java new file mode 100644 index 00000000..299aaa82 --- /dev/null +++ b/onec-auth-starter/src/main/java/com/onec/auth/magic/JdbcMagicLinkTokenStore.java @@ -0,0 +1,89 @@ +package com.onec.auth.magic; + +import org.jdbi.v3.core.Jdbi; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Optional; + +/** + * Database-backed {@link MagicLinkTokenStore}. Tokens live in {@code onec_auth_magic_link}, keyed by + * the token hash, so a link issued by one application node is verifiable on any other — the property + * a horizontally-scaled deployment needs. Portable across the two supported engines (H2 for + * single-node dev, PostgreSQL in production); the conditional {@code UPDATE} used for consumption is + * standard SQL and needs no dialect branch. + * + *

Timestamps are stored in UTC ({@link Instant} mapped to a UTC {@link LocalDateTime}) so expiry + * comparisons are independent of the JVM's default time zone. + */ +public class JdbcMagicLinkTokenStore implements MagicLinkTokenStore { + + private static final String DDL = + "CREATE TABLE IF NOT EXISTS onec_auth_magic_link (\n" + + " _token_hash VARCHAR(64) PRIMARY KEY,\n" + + " _username VARCHAR(200) NOT NULL,\n" + + " _expires_at TIMESTAMP NOT NULL,\n" + + " _consumed_at TIMESTAMP,\n" + + " _created_at TIMESTAMP NOT NULL\n" + + ")"; + + private final Jdbi jdbi; + + public JdbcMagicLinkTokenStore(Jdbi jdbi) { + this.jdbi = jdbi; + } + + public void initSchema() { + jdbi.useHandle(h -> h.execute(DDL)); + } + + @Override + public void save(String tokenHash, String username, Instant expiresAt) { + LocalDateTime now = utc(Instant.now()); + jdbi.useHandle(h -> h.createUpdate( + "INSERT INTO onec_auth_magic_link " + + "(_token_hash, _username, _expires_at, _created_at) " + + "VALUES (:hash, :username, :expires, :now)") + .bind("hash", tokenHash) + .bind("username", username) + .bind("expires", utc(expiresAt)) + .bind("now", now) + .execute()); + } + + @Override + public Optional consume(String tokenHash, Instant now) { + LocalDateTime ts = utc(now); + // Flip unconsumed→consumed only while still valid, in one statement: the row count tells us + // whether THIS call won the race. A second concurrent verify (or a replay) updates zero rows + // and gets nothing back, so a token can be redeemed at most once. + return jdbi.inTransaction(h -> { + int updated = h.createUpdate( + "UPDATE onec_auth_magic_link SET _consumed_at = :now " + + "WHERE _token_hash = :hash AND _consumed_at IS NULL AND _expires_at > :now") + .bind("hash", tokenHash) + .bind("now", ts) + .execute(); + if (updated == 0) { + return Optional.empty(); + } + return h.createQuery("SELECT _username FROM onec_auth_magic_link WHERE _token_hash = :hash") + .bind("hash", tokenHash) + .mapTo(String.class) + .findFirst(); + }); + } + + @Override + public int purgeExpired(Instant now) { + return jdbi.withHandle(h -> h.createUpdate( + "DELETE FROM onec_auth_magic_link WHERE _expires_at <= :now") + .bind("now", utc(now)) + .execute()); + } + + private static LocalDateTime utc(Instant instant) { + return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); + } +} diff --git a/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkController.java b/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkController.java new file mode 100644 index 00000000..34afc18c --- /dev/null +++ b/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkController.java @@ -0,0 +1,86 @@ +package com.onec.auth.magic; + +import com.onec.auth.OnecAuthProperties; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.util.Optional; + +/** + * Public endpoints for passwordless magic-link login (in-memory mode): + *

    + *
  • {@code POST /api/auth/magic/request} — body {@code {"email": "..."}}; issues + emails a + * single-use link. Always answers {@code 202} regardless of whether the address is registered, + * so it can't be used to enumerate accounts.
  • + *
  • {@code GET /api/auth/magic/verify?token=...} — consumes the token and, on success, establishes + * the session the same way {@code /api/auth/login} does, then redirects the browser into the app. + * This is a top-level navigation (the link is clicked from an email), so it redirects rather than + * returning JSON.
  • + *
+ */ +@RestController +@RequestMapping("/api/auth/magic") +public class MagicLinkController { + + private final MagicLinkService service; + private final OnecAuthProperties properties; + private final SecurityContextRepository contextRepository = new HttpSessionSecurityContextRepository(); + + public MagicLinkController(MagicLinkService service, OnecAuthProperties properties) { + this.service = service; + this.properties = properties; + } + + @PostMapping("/request") + ResponseEntity request(@RequestBody(required = false) MagicLinkRequest body, + HttpServletRequest request) { + if (body != null && body.email() != null && !body.email().isBlank()) { + String origin = ServletUriComponentsBuilder.fromContextPath(request).build().toUriString(); + service.requestLink(body.email(), origin); + } + // 202 whether or not an account matched (and even for a blank email): no enumeration signal. + return ResponseEntity.accepted().build(); + } + + @GetMapping("/verify") + ResponseEntity verify(@RequestParam(required = false) String token, + HttpServletRequest request, + HttpServletResponse response) { + Optional authentication = service.verify(token); + if (authentication.isEmpty()) { + // Invalid/expired/used link — send the user back to the login screen with a hint. + return redirect("/login?error=link"); + } + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication.get()); + SecurityContextHolder.setContext(context); + contextRepository.saveContext(context, request, response); + + return redirect(properties.getMagicLink().getRedirectPath()); + } + + private static ResponseEntity redirect(String path) { + // Same-origin path only — guard against an open redirect from a tampered config value. + String target = (path != null && path.startsWith("/")) ? path : "/"; + return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(target)).build(); + } + + record MagicLinkRequest(String email) { + } +} diff --git a/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkSender.java b/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkSender.java new file mode 100644 index 00000000..307b4b08 --- /dev/null +++ b/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkSender.java @@ -0,0 +1,20 @@ +package com.onec.auth.magic; + +import java.time.Duration; + +/** + * Delivers a magic-link to a user. The default implementation ({@code MailMagicLinkSender}) sends an + * email through the {@code onec-mail-starter}; an application can register its own bean to deliver the + * link another way (SMS, a push channel, a custom-branded email) or to fully control the message + * body. Keeping delivery behind this seam means the rest of the flow has no dependency on the mail + * module. + */ +public interface MagicLinkSender { + + /** + * @param email the recipient address the link was requested for + * @param link the absolute, single-use verification URL to embed + * @param validity how long the link remains valid, so the message can say so + */ + void send(String email, String link, Duration validity); +} diff --git a/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkService.java b/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkService.java new file mode 100644 index 00000000..39bd284e --- /dev/null +++ b/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkService.java @@ -0,0 +1,145 @@ +package com.onec.auth.magic; + +import com.onec.auth.OnecAuthProperties; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Optional; + +/** + * Orchestrates passwordless magic-link login: issuing + emailing a single-use token on request, and + * validating one on verify. Deliberately self-contained — it owns token generation/hashing and + * delegates the three pluggable concerns (who the email maps to, where tokens live, how the link is + * delivered) to {@link MagicLinkUserLookup}, {@link MagicLinkTokenStore}, and {@link MagicLinkSender}. + * + *

Security properties: tokens are 256 bits of {@link SecureRandom} entropy; only their SHA-256 + * hash is persisted; {@link #verify} consumes single-use and expiry-checked atomically; and + * {@link #requestLink} returns no signal about whether an account matched (no email enumeration). + */ +public class MagicLinkService { + + private static final Logger log = LoggerFactory.getLogger(MagicLinkService.class); + private static final String VERIFY_PATH = "/api/auth/magic/verify"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final MagicLinkUserLookup userLookup; + private final MagicLinkTokenStore tokenStore; + private final MagicLinkSender sender; + private final UserDetailsService userDetailsService; + private final OnecAuthProperties properties; + + public MagicLinkService(MagicLinkUserLookup userLookup, + MagicLinkTokenStore tokenStore, + MagicLinkSender sender, + UserDetailsService userDetailsService, + OnecAuthProperties properties) { + this.userLookup = userLookup; + this.tokenStore = tokenStore; + this.sender = sender; + this.userDetailsService = userDetailsService; + this.properties = properties; + } + + /** + * Issues a link for {@code email} and delivers it — but only if the address maps to an account. + * For an unknown address it does nothing and returns normally, so the caller can respond + * identically either way and never reveal which addresses are registered. + * + * @param email the address entered on the login screen + * @param requestBaseUrl the origin (scheme://host[:port]) of the incoming request, used to + * build the absolute link when {@code onec.auth.magic-link.base-url} is unset + */ + public void requestLink(String email, String requestBaseUrl) { + Optional username = userLookup.usernameForEmail(email); + if (username.isEmpty()) { + log.debug("[magic-link] no account for requested email; nothing sent"); + return; + } + Duration validity = properties.getMagicLink().getTokenValidity(); + String rawToken = newToken(); + Instant expiresAt = Instant.now().plus(validity); + tokenStore.save(hash(rawToken), username.get(), expiresAt); + + String link = buildLink(requestBaseUrl, rawToken); + try { + sender.send(email.trim(), link, validity); + } catch (RuntimeException ex) { + // Don't let a delivery failure change the response shape (which would leak whether the + // address exists). Surface it in logs for operators instead. + log.error("[magic-link] failed to deliver sign-in link", ex); + } + } + + /** + * Validates and consumes a token, returning an {@link Authentication} to establish on success. + * Empty when the token is missing, unknown, already used, expired, or the account it referenced + * no longer exists. + */ + public Optional verify(String rawToken) { + if (rawToken == null || rawToken.isBlank()) { + return Optional.empty(); + } + Optional username = tokenStore.consume(hash(rawToken), Instant.now()); + if (username.isEmpty()) { + return Optional.empty(); + } + try { + UserDetails user = userDetailsService.loadUserByUsername(username.get()); + return Optional.of(UsernamePasswordAuthenticationToken.authenticated( + user, null, user.getAuthorities())); + } catch (UsernameNotFoundException ex) { + // The account was removed between issuing and following the link — treat as invalid. + log.warn("[magic-link] token referenced an unknown user; rejecting"); + return Optional.empty(); + } + } + + private String buildLink(String requestBaseUrl, String rawToken) { + String configured = properties.getMagicLink().getBaseUrl(); + String base = (configured != null && !configured.isBlank()) ? configured.trim() : requestBaseUrl; + if (base != null && base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + String encoded = URLEncode(rawToken); + return (base == null ? "" : base) + VERIFY_PATH + "?token=" + encoded; + } + + private static String newToken() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** Hex-encoded SHA-256 of the raw token — what we persist, so the store never holds the credential. */ + static String hash(String rawToken) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] out = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(out.length * 2); + for (byte b : out) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is required but unavailable", e); + } + } + + private static String URLEncode(String value) { + return java.net.URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkTokenStore.java b/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkTokenStore.java new file mode 100644 index 00000000..564d80b2 --- /dev/null +++ b/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkTokenStore.java @@ -0,0 +1,45 @@ +package com.onec.auth.magic; + +import java.time.Instant; +import java.util.Optional; + +/** + * Persists single-use magic-link tokens and consumes them atomically. The default implementation + * ({@link JdbcMagicLinkTokenStore}) stores tokens in a database table so a link emailed by one node + * validates on any node of a horizontally-scaled deployment; an application can replace it (e.g. with + * a Redis-backed store) by registering its own {@code MagicLinkTokenStore} bean. + * + *

Only the hash of a token is ever stored — the raw token lives only in the email — so a + * leak of the store cannot be replayed as a login. Consumption is single-use and expiry-checked in + * one atomic step to close the replay/double-use window. + */ +public interface MagicLinkTokenStore { + + /** + * Records a freshly-issued token. + * + * @param tokenHash hex-encoded SHA-256 of the raw token (never the raw token itself) + * @param username the account the token signs in + * @param expiresAt the instant after which the token is no longer valid + */ + void save(String tokenHash, String username, Instant expiresAt); + + /** + * Atomically validates and consumes a token: succeeds only if a matching token exists, has not + * already been consumed, and has not expired — and in the same step marks it consumed so it can + * never be used again. + * + * @param tokenHash hex-encoded SHA-256 of the raw token presented by the caller + * @param now the current instant, for the expiry check + * @return the username the token signs in, or empty if the token is unknown, already used, or expired + */ + Optional consume(String tokenHash, Instant now); + + /** + * Deletes tokens that expired before {@code now}. Optional housekeeping — consumption already + * rejects expired tokens, so this only reclaims space. Returns the number of rows removed. + */ + default int purgeExpired(Instant now) { + return 0; + } +} diff --git a/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkUserLookup.java b/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkUserLookup.java new file mode 100644 index 00000000..b550bb5f --- /dev/null +++ b/onec-auth-starter/src/main/java/com/onec/auth/magic/MagicLinkUserLookup.java @@ -0,0 +1,23 @@ +package com.onec.auth.magic; + +import java.util.Optional; + +/** + * Resolves the email a user typed on the login screen to the account it signs in. Kept separate from + * the token store and the credential check so an application can plug its own directory (a database + * of users, an LDAP lookup, …) without touching the rest of the flow — register a + * {@code MagicLinkUserLookup} bean to override the default, which reads {@code onec.auth.users}. + * + *

Returning {@link Optional#empty()} for an unknown email is normal and expected: the controller + * responds identically whether or not an account matched, so the endpoint never reveals which + * addresses are registered. + */ +@FunctionalInterface +public interface MagicLinkUserLookup { + + /** + * @param email the address entered on the login screen (raw; implementations normalize as needed) + * @return the username whose authorities back the session, or empty if no account matches + */ + Optional usernameForEmail(String email); +} diff --git a/onec-auth-starter/src/main/java/com/onec/auth/magic/MailMagicLinkSender.java b/onec-auth-starter/src/main/java/com/onec/auth/magic/MailMagicLinkSender.java new file mode 100644 index 00000000..a28f510c --- /dev/null +++ b/onec-auth-starter/src/main/java/com/onec/auth/magic/MailMagicLinkSender.java @@ -0,0 +1,60 @@ +package com.onec.auth.magic; + +import com.onec.auth.OnecAuthProperties; +import com.onec.mail.MailMessage; +import com.onec.mail.MailService; + +import java.time.Duration; + +/** + * Default {@link MagicLinkSender}: emails the link through the {@code onec-mail-starter}. Wired only + * when a {@link MailService} bean is present (see the auth auto-configuration), so enabling + * magic-link without a mail provider fails fast with a clear message rather than silently dropping + * sign-in emails. The message is built inline (plain text + a minimal HTML part) to avoid requiring + * the host app to register a mail template. + */ +public class MailMagicLinkSender implements MagicLinkSender { + + private final MailService mailService; + private final OnecAuthProperties properties; + + public MailMagicLinkSender(MailService mailService, OnecAuthProperties properties) { + this.mailService = mailService; + this.properties = properties; + } + + @Override + public void send(String email, String link, Duration validity) { + String subject = properties.getMagicLink().getSubject(); + String window = humanize(validity); + String text = "Sign in by following this link (valid for " + window + "):\n\n" + + link + "\n\n" + + "If you didn't request this, you can ignore this email."; + String html = "

Sign in by following this link (valid for " + window + "):

" + + "

Sign in

" + + "

If you didn't request this, you can ignore " + + "this email.

"; + // Synchronous send: the user is waiting on the login screen and the link is time-sensitive, + // so we don't route it through the async outbox relay. + mailService.send(MailMessage.builder() + .to(email) + .subject(subject) + .text(text) + .html(html) + .build()); + } + + /** Renders a short validity window for the email copy, e.g. {@code 15 minutes} or {@code 2 hours}. */ + private static String humanize(Duration validity) { + long minutes = Math.max(1, validity.toMinutes()); + if (minutes % 60 == 0) { + long hours = minutes / 60; + return hours + (hours == 1 ? " hour" : " hours"); + } + return minutes + (minutes == 1 ? " minute" : " minutes"); + } + + private static String escape(String s) { + return s.replace("&", "&").replace("\"", """).replace("<", "<").replace(">", ">"); + } +} diff --git a/onec-auth-starter/src/main/java/com/onec/auth/magic/PropertiesMagicLinkUserLookup.java b/onec-auth-starter/src/main/java/com/onec/auth/magic/PropertiesMagicLinkUserLookup.java new file mode 100644 index 00000000..a9cc0c0a --- /dev/null +++ b/onec-auth-starter/src/main/java/com/onec/auth/magic/PropertiesMagicLinkUserLookup.java @@ -0,0 +1,44 @@ +package com.onec.auth.magic; + +import com.onec.auth.OnecAuthProperties; + +import java.util.Optional; + +/** + * Default {@link MagicLinkUserLookup} over the in-memory accounts in {@code onec.auth.users}. A user + * opts into magic-link by declaring an {@code email}; the lookup matches it case-insensitively. As a + * convenience, a user that sets no email but whose {@code username} already looks like an email + * address is matched on the username, so {@code username: alice@example.com} works with no extra + * config. Users with neither are simply never returned — magic-link is opt-in per account. + */ +public class PropertiesMagicLinkUserLookup implements MagicLinkUserLookup { + + private final OnecAuthProperties properties; + + public PropertiesMagicLinkUserLookup(OnecAuthProperties properties) { + this.properties = properties; + } + + @Override + public Optional usernameForEmail(String email) { + if (email == null || email.isBlank()) { + return Optional.empty(); + } + String normalized = email.trim(); + for (OnecAuthProperties.User user : properties.getUsers()) { + String declared = user.getEmail(); + if (declared != null && !declared.isBlank()) { + if (declared.trim().equalsIgnoreCase(normalized)) { + return Optional.ofNullable(user.getUsername()); + } + continue; + } + // No explicit email: fall back to an email-shaped username so it can be used as-is. + String username = user.getUsername(); + if (username != null && username.contains("@") && username.equalsIgnoreCase(normalized)) { + return Optional.of(username); + } + } + return Optional.empty(); + } +} diff --git a/onec-auth-starter/src/test/java/com/onec/auth/OnecAuthAutoConfigurationTest.java b/onec-auth-starter/src/test/java/com/onec/auth/OnecAuthAutoConfigurationTest.java index 4641c5a3..40be29c0 100644 --- a/onec-auth-starter/src/test/java/com/onec/auth/OnecAuthAutoConfigurationTest.java +++ b/onec-auth-starter/src/test/java/com/onec/auth/OnecAuthAutoConfigurationTest.java @@ -83,11 +83,13 @@ void resourceServerModeSelectsTheStatelessJwtChain() { } @Test - void csrfIgnoredPathsDefaultToLoginOnly() { + void csrfIgnoredPathsDefaultToPublicPreAuthPosts() { runner.run(context -> { assertThat(context).hasNotFailed(); + // The pre-auth login and magic-link-request POSTs are public and carry no session/token + // to protect, so both are exempt by default. assertThat(context.getBean(OnecAuthProperties.class).getCsrfIgnoredPaths()) - .containsExactly("/api/auth/login"); + .containsExactly("/api/auth/login", "/api/auth/magic/request"); }); } diff --git a/onec-auth-starter/src/test/java/com/onec/auth/OnecAuthMagicLinkConfigurationTest.java b/onec-auth-starter/src/test/java/com/onec/auth/OnecAuthMagicLinkConfigurationTest.java new file mode 100644 index 00000000..2c27be34 --- /dev/null +++ b/onec-auth-starter/src/test/java/com/onec/auth/OnecAuthMagicLinkConfigurationTest.java @@ -0,0 +1,132 @@ +package com.onec.auth; + +import com.onec.auth.magic.MagicLinkController; +import com.onec.auth.magic.MagicLinkSender; +import com.onec.auth.magic.MagicLinkService; +import com.onec.auth.magic.MagicLinkTokenStore; +import com.onec.auth.spi.AuthMethodsProvider; + +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; + +import javax.sql.DataSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class OnecAuthMagicLinkConfigurationTest { + + private final WebApplicationContextRunner runner = new WebApplicationContextRunner() + .withPropertyValues("onec.auth.session.remember-me.allow-ephemeral-key=true") + .withConfiguration(AutoConfigurations.of( + WebMvcAutoConfiguration.class, + SecurityAutoConfiguration.class, + OnecAuthAutoConfiguration.class)); + + @Test + void magicLinkIsOffByDefault() { + runner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(MagicLinkController.class); + assertThat(context).doesNotHaveBean(MagicLinkService.class); + assertThat(context.getBean(AuthMethodsProvider.class).authMethods().magicLinkEnabled()) + .isFalse(); + }); + } + + @Test + void enablingWithDataSourceAndSenderWiresEndpointsAndAdvertisesIt() { + runner.withUserConfiguration(DataSourceConfig.class, SenderConfig.class) + .withPropertyValues("onec.auth.magic-link.enabled=true") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(MagicLinkController.class); + assertThat(context).hasSingleBean(MagicLinkService.class); + assertThat(context).hasSingleBean(MagicLinkTokenStore.class); + assertThat(context.getBean(AuthMethodsProvider.class).authMethods().magicLinkEnabled()) + .isTrue(); + }); + } + + @Test + void enablingWithoutADataSourceFailsFastWithGuidance() { + runner.withUserConfiguration(SenderConfig.class) + .withPropertyValues("onec.auth.magic-link.enabled=true") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context).getFailure().hasMessageContaining("DataSource"); + }); + } + + @Test + void enablingWithoutASenderFailsFastWithGuidance() { + runner.withUserConfiguration(DataSourceConfig.class) + .withPropertyValues("onec.auth.magic-link.enabled=true") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context).getFailure().hasMessageContaining("MagicLinkSender"); + }); + } + + @Test + void notWiredInOidcModeEvenWhenEnabled() { + runner.withUserConfiguration(DataSourceConfig.class, SenderConfig.class, ClientRegistrationConfig.class) + .withPropertyValues("onec.auth.mode=oidc", "onec.auth.magic-link.enabled=true") + .run(context -> { + assertThat(context).hasNotFailed(); + // OIDC delegates sign-in to the IdP, so the in-memory-only magic-link flow stays off. + assertThat(context).doesNotHaveBean(MagicLinkController.class); + assertThat(context.getBean(AuthMethodsProvider.class).authMethods().magicLinkEnabled()) + .isFalse(); + }); + } + + @Configuration(proxyBeanMethods = false) + static class DataSourceConfig { + @Bean + DataSource dataSource() { + JdbcDataSource ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:ml-cfg-" + System.nanoTime() + ";DB_CLOSE_DELAY=-1"); + ds.setUser("sa"); + return ds; + } + } + + @Configuration(proxyBeanMethods = false) + static class SenderConfig { + @Bean + MagicLinkSender magicLinkSender() { + return (email, link, validity) -> { + // no-op test sender + }; + } + } + + @Configuration(proxyBeanMethods = false) + static class ClientRegistrationConfig { + @Bean + ClientRegistrationRepository clientRegistrationRepository() { + ClientRegistration keycloak = ClientRegistration.withRegistrationId("keycloak") + .clientId("app") + .clientSecret("secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid") + .authorizationUri("https://keycloak.local/auth") + .tokenUri("https://keycloak.local/token") + .jwkSetUri("https://keycloak.local/certs") + .userNameAttributeName("preferred_username") + .build(); + return new InMemoryClientRegistrationRepository(keycloak); + } + } +} diff --git a/onec-auth-starter/src/test/java/com/onec/auth/OnecAuthMethodsProviderTest.java b/onec-auth-starter/src/test/java/com/onec/auth/OnecAuthMethodsProviderTest.java index 1776e89f..93418146 100644 --- a/onec-auth-starter/src/test/java/com/onec/auth/OnecAuthMethodsProviderTest.java +++ b/onec-auth-starter/src/test/java/com/onec/auth/OnecAuthMethodsProviderTest.java @@ -42,6 +42,7 @@ void inMemoryModeOffersPasswordAndNoProviders() { AuthMethods m = methods(context); assertThat(m.mode()).isEqualTo("in-memory"); assertThat(m.passwordEnabled()).isTrue(); + assertThat(m.magicLinkEnabled()).isFalse(); assertThat(m.providers()).isEmpty(); assertThat(m.logoutUrl()).isNull(); }); diff --git a/onec-auth-starter/src/test/java/com/onec/auth/magic/JdbcMagicLinkTokenStoreTest.java b/onec-auth-starter/src/test/java/com/onec/auth/magic/JdbcMagicLinkTokenStoreTest.java new file mode 100644 index 00000000..520592be --- /dev/null +++ b/onec-auth-starter/src/test/java/com/onec/auth/magic/JdbcMagicLinkTokenStoreTest.java @@ -0,0 +1,56 @@ +package com.onec.auth.magic; + +import org.jdbi.v3.core.Jdbi; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +class JdbcMagicLinkTokenStoreTest { + + /** A fresh H2 in-memory database per test; DB_CLOSE_DELAY keeps the schema across connections. */ + private static JdbcMagicLinkTokenStore store(String name) { + JdbcMagicLinkTokenStore store = new JdbcMagicLinkTokenStore( + Jdbi.create("jdbc:h2:mem:ml-" + name + ";DB_CLOSE_DELAY=-1")); + store.initSchema(); + return store; + } + + @Test + void savesThenConsumesExactlyOnce() { + JdbcMagicLinkTokenStore store = store("once"); + Instant now = Instant.now(); + store.save("hash-1", "alice", now.plus(15, ChronoUnit.MINUTES)); + + assertThat(store.consume("hash-1", now)).contains("alice"); + // A replay (or a concurrent second verify) finds nothing — single use. + assertThat(store.consume("hash-1", now)).isEmpty(); + } + + @Test + void rejectsExpiredToken() { + JdbcMagicLinkTokenStore store = store("expired"); + Instant now = Instant.now(); + store.save("hash-2", "bob", now.minus(1, ChronoUnit.MINUTES)); + + assertThat(store.consume("hash-2", now)).isEmpty(); + } + + @Test + void unknownTokenIsEmpty() { + assertThat(store("unknown").consume("does-not-exist", Instant.now())).isEmpty(); + } + + @Test + void purgeRemovesOnlyExpiredTokens() { + JdbcMagicLinkTokenStore store = store("purge"); + Instant now = Instant.now(); + store.save("old", "a", now.minus(5, ChronoUnit.MINUTES)); + store.save("fresh", "b", now.plus(5, ChronoUnit.MINUTES)); + + assertThat(store.purgeExpired(now)).isEqualTo(1); + assertThat(store.consume("fresh", now)).contains("b"); + } +} diff --git a/onec-auth-starter/src/test/java/com/onec/auth/magic/MagicLinkControllerTest.java b/onec-auth-starter/src/test/java/com/onec/auth/magic/MagicLinkControllerTest.java new file mode 100644 index 00000000..3d41ba33 --- /dev/null +++ b/onec-auth-starter/src/test/java/com/onec/auth/magic/MagicLinkControllerTest.java @@ -0,0 +1,91 @@ +package com.onec.auth.magic; + +import com.onec.auth.OnecAuthProperties; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class MagicLinkControllerTest { + + private MagicLinkService service; + private OnecAuthProperties properties; + private MockMvc mvc; + + @BeforeEach + void setUp() { + service = mock(MagicLinkService.class); + properties = new OnecAuthProperties(); + mvc = MockMvcBuilders.standaloneSetup(new MagicLinkController(service, properties)).build(); + } + + @Test + void requestAlwaysAcceptedAndDelegatesEmail() throws Exception { + mvc.perform(post("/api/auth/magic/request") + .contentType(APPLICATION_JSON) + .content("{\"email\":\"alice@example.com\"}")) + .andExpect(status().isAccepted()); + + verify(service).requestLink(eq("alice@example.com"), anyString()); + } + + @Test + void requestWithBlankEmailIsStillAcceptedAndDoesNotDelegate() throws Exception { + mvc.perform(post("/api/auth/magic/request") + .contentType(APPLICATION_JSON) + .content("{\"email\":\"\"}")) + .andExpect(status().isAccepted()); + + verifyNoInteractions(service); + } + + @Test + void verifyWithInvalidTokenRedirectsToLoginError() throws Exception { + when(service.verify("bad")).thenReturn(Optional.empty()); + + mvc.perform(get("/api/auth/magic/verify").param("token", "bad")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?error=link")); + } + + @Test + void verifyWithValidTokenEstablishesSessionAndRedirectsHome() throws Exception { + Authentication authentication = + UsernamePasswordAuthenticationToken.authenticated("alice", null, List.of()); + when(service.verify("good")).thenReturn(Optional.of(authentication)); + + mvc.perform(get("/api/auth/magic/verify").param("token", "good")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/")); + } + + @Test + void verifyHonoursConfiguredRedirectPath() throws Exception { + properties.getMagicLink().setRedirectPath("/dashboard"); + Authentication authentication = + UsernamePasswordAuthenticationToken.authenticated("alice", null, List.of()); + when(service.verify("good")).thenReturn(Optional.of(authentication)); + + mvc.perform(get("/api/auth/magic/verify").param("token", "good")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/dashboard")); + } +} diff --git a/onec-auth-starter/src/test/java/com/onec/auth/magic/MagicLinkServiceTest.java b/onec-auth-starter/src/test/java/com/onec/auth/magic/MagicLinkServiceTest.java new file mode 100644 index 00000000..769d3779 --- /dev/null +++ b/onec-auth-starter/src/test/java/com/onec/auth/magic/MagicLinkServiceTest.java @@ -0,0 +1,136 @@ +package com.onec.auth.magic; + +import com.onec.auth.OnecAuthProperties; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class MagicLinkServiceTest { + + /** A minimal in-memory token store so the service can be tested without a database. */ + private static final class InMemoryStore implements MagicLinkTokenStore { + private record Entry(String username, Instant expiresAt, boolean consumed) { + } + + private final Map rows = new HashMap<>(); + + @Override + public void save(String tokenHash, String username, Instant expiresAt) { + rows.put(tokenHash, new Entry(username, expiresAt, false)); + } + + @Override + public Optional consume(String tokenHash, Instant now) { + Entry e = rows.get(tokenHash); + if (e == null || e.consumed() || !e.expiresAt().isAfter(now)) { + return Optional.empty(); + } + rows.put(tokenHash, new Entry(e.username(), e.expiresAt(), true)); + return Optional.of(e.username()); + } + } + + private static final class CapturingSender implements MagicLinkSender { + private String email; + private String link; + private Duration validity; + private int calls; + + @Override + public void send(String email, String link, Duration validity) { + this.email = email; + this.link = link; + this.validity = validity; + this.calls++; + } + } + + private static OnecAuthProperties properties() { + OnecAuthProperties p = new OnecAuthProperties(); + OnecAuthProperties.User u = new OnecAuthProperties.User(); + u.setUsername("alice"); + u.setPassword("ignored"); + u.setEmail("alice@example.com"); + u.setRoles(List.of("ADMIN")); + p.setUsers(new ArrayList<>(List.of(u))); + p.getMagicLink().setEnabled(true); + return p; + } + + private static MagicLinkService service(OnecAuthProperties p, MagicLinkTokenStore store, MagicLinkSender sender) { + UserDetailsService uds = new InMemoryUserDetailsManager( + User.withUsername("alice").password("{noop}ignored").roles("ADMIN").build()); + return new MagicLinkService(new PropertiesMagicLinkUserLookup(p), store, sender, uds, p); + } + + @Test + void requestEmailsLinkForKnownAddressThenVerifyRoundTrips() { + OnecAuthProperties p = properties(); + InMemoryStore store = new InMemoryStore(); + CapturingSender sender = new CapturingSender(); + MagicLinkService service = service(p, store, sender); + + service.requestLink("alice@example.com", "https://app.example.com"); + + assertThat(sender.calls).isEqualTo(1); + assertThat(sender.email).isEqualTo("alice@example.com"); + assertThat(sender.validity).isEqualTo(p.getMagicLink().getTokenValidity()); + assertThat(sender.link).startsWith("https://app.example.com/api/auth/magic/verify?token="); + + String token = sender.link.substring(sender.link.indexOf("token=") + "token=".length()); + Optional authentication = service.verify(token); + + assertThat(authentication).isPresent(); + assertThat(authentication.get().getName()).isEqualTo("alice"); + assertThat(authentication.get().getAuthorities()) + .extracting(GrantedAuthority::getAuthority) + .contains("ROLE_ADMIN"); + + // Single use: the same link can't be redeemed twice. + assertThat(service.verify(token)).isEmpty(); + } + + @Test + void requestForUnknownAddressSendsNothingAndStoresNothing() { + InMemoryStore store = new InMemoryStore(); + CapturingSender sender = new CapturingSender(); + + service(properties(), store, sender).requestLink("nobody@example.com", "https://app.example.com"); + + assertThat(sender.calls).isZero(); + assertThat(store.rows).isEmpty(); + } + + @Test + void verifyRejectsBlankAndUnknownTokens() { + MagicLinkService service = service(properties(), new InMemoryStore(), new CapturingSender()); + assertThat(service.verify(null)).isEmpty(); + assertThat(service.verify(" ")).isEmpty(); + assertThat(service.verify("not-a-real-token")).isEmpty(); + } + + @Test + void configuredBaseUrlOverridesRequestOrigin() { + OnecAuthProperties p = properties(); + p.getMagicLink().setBaseUrl("https://public.example.com/"); + CapturingSender sender = new CapturingSender(); + + service(p, new InMemoryStore(), sender).requestLink("alice@example.com", "https://internal:8080"); + + assertThat(sender.link).startsWith("https://public.example.com/api/auth/magic/verify?token="); + } +} diff --git a/onec-auth-starter/src/test/java/com/onec/auth/magic/MailMagicLinkSenderTest.java b/onec-auth-starter/src/test/java/com/onec/auth/magic/MailMagicLinkSenderTest.java new file mode 100644 index 00000000..93ad18fe --- /dev/null +++ b/onec-auth-starter/src/test/java/com/onec/auth/magic/MailMagicLinkSenderTest.java @@ -0,0 +1,51 @@ +package com.onec.auth.magic; + +import com.onec.auth.OnecAuthProperties; +import com.onec.mail.MailMessage; +import com.onec.mail.MailService; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class MailMagicLinkSenderTest { + + @Test + void sendsAnEmailCarryingTheLinkSubjectAndValidity() { + MailService mailService = mock(MailService.class); + OnecAuthProperties properties = new OnecAuthProperties(); + properties.getMagicLink().setSubject("Sign in to Acme"); + + new MailMagicLinkSender(mailService, properties).send( + "alice@example.com", + "https://app.example.com/api/auth/magic/verify?token=abc123", + Duration.ofMinutes(15)); + + ArgumentCaptor sent = ArgumentCaptor.forClass(MailMessage.class); + verify(mailService).send(sent.capture()); + + MailMessage message = sent.getValue(); + assertThat(message.to()).containsExactly("alice@example.com"); + assertThat(message.subject()).isEqualTo("Sign in to Acme"); + assertThat(message.text()).contains("https://app.example.com/api/auth/magic/verify?token=abc123"); + assertThat(message.text()).contains("15 minutes"); + assertThat(message.html()).contains("href=\"https://app.example.com/api/auth/magic/verify?token=abc123\""); + assertThat(message.isHtml()).isTrue(); + } + + @Test + void rendersHourValidityWindow() { + MailService mailService = mock(MailService.class); + new MailMagicLinkSender(mailService, new OnecAuthProperties()) + .send("a@b.c", "https://x/verify?token=t", Duration.ofHours(1)); + + ArgumentCaptor sent = ArgumentCaptor.forClass(MailMessage.class); + verify(mailService).send(sent.capture()); + assertThat(sent.getValue().text()).contains("1 hour"); + } +} diff --git a/onec-auth-starter/src/test/java/com/onec/auth/magic/PropertiesMagicLinkUserLookupTest.java b/onec-auth-starter/src/test/java/com/onec/auth/magic/PropertiesMagicLinkUserLookupTest.java new file mode 100644 index 00000000..186fc6ec --- /dev/null +++ b/onec-auth-starter/src/test/java/com/onec/auth/magic/PropertiesMagicLinkUserLookupTest.java @@ -0,0 +1,54 @@ +package com.onec.auth.magic; + +import com.onec.auth.OnecAuthProperties; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PropertiesMagicLinkUserLookupTest { + + private static OnecAuthProperties.User user(String username, String email) { + OnecAuthProperties.User u = new OnecAuthProperties.User(); + u.setUsername(username); + u.setEmail(email); + return u; + } + + private static PropertiesMagicLinkUserLookup lookup(OnecAuthProperties.User... users) { + OnecAuthProperties properties = new OnecAuthProperties(); + properties.setUsers(new ArrayList<>(List.of(users))); + return new PropertiesMagicLinkUserLookup(properties); + } + + @Test + void matchesDeclaredEmailCaseInsensitivelyAndTrimmed() { + PropertiesMagicLinkUserLookup lookup = lookup(user("alice", "Alice@Example.com")); + assertThat(lookup.usernameForEmail("alice@example.com")).contains("alice"); + assertThat(lookup.usernameForEmail(" ALICE@EXAMPLE.COM ")).contains("alice"); + } + + @Test + void fallsBackToEmailShapedUsernameWhenNoEmailDeclared() { + PropertiesMagicLinkUserLookup lookup = lookup(user("bob@example.com", null)); + assertThat(lookup.usernameForEmail("bob@example.com")).contains("bob@example.com"); + } + + @Test + void plainUsernameWithoutEmailNeverMatches() { + PropertiesMagicLinkUserLookup lookup = lookup(user("carol", null)); + assertThat(lookup.usernameForEmail("carol")).isEmpty(); + assertThat(lookup.usernameForEmail("carol@example.com")).isEmpty(); + } + + @Test + void unknownBlankAndNullAreEmpty() { + PropertiesMagicLinkUserLookup lookup = lookup(user("alice", "alice@example.com")); + assertThat(lookup.usernameForEmail("nobody@example.com")).isEmpty(); + assertThat(lookup.usernameForEmail("")).isEmpty(); + assertThat(lookup.usernameForEmail(null)).isEmpty(); + } +} diff --git a/onec-framework/src/main/java/com/onec/auth/spi/AuthMethods.java b/onec-framework/src/main/java/com/onec/auth/spi/AuthMethods.java index 6d24b253..81d8cc74 100644 --- a/onec-framework/src/main/java/com/onec/auth/spi/AuthMethods.java +++ b/onec-framework/src/main/java/com/onec/auth/spi/AuthMethods.java @@ -10,16 +10,27 @@ *

Deliberately plain data with no Spring / Spring Security types, because it crosses the * {@code onec-framework} seam shared by the auth and UI modules. * - * @param passwordEnabled whether interactive username/password login is available (the in-memory mode) - * @param providers the SSO options to offer, possibly empty - * @param logoutUrl where the client navigates to log out; non-null only when logout requires a - * server round-trip (OIDC RP-initiated logout), null otherwise - * @param mode the active backend: {@code in-memory}, {@code oidc}, or {@code resource-server} + * @param passwordEnabled whether interactive username/password login is available (the in-memory mode) + * @param magicLinkEnabled whether passwordless email "magic link" login is offered (in-memory mode only; + * requires {@code onec.auth.magic-link.enabled} and a configured mail provider) + * @param providers the SSO options to offer, possibly empty + * @param logoutUrl where the client navigates to log out; non-null only when logout requires a + * server round-trip (OIDC RP-initiated logout), null otherwise + * @param mode the active backend: {@code in-memory}, {@code oidc}, or {@code resource-server} */ -public record AuthMethods(boolean passwordEnabled, List providers, +public record AuthMethods(boolean passwordEnabled, boolean magicLinkEnabled, List providers, String logoutUrl, String mode) { public AuthMethods { providers = providers == null ? List.of() : List.copyOf(providers); } + + /** + * Backward-compatible constructor for callers that don't offer magic-link login. Equivalent to + * passing {@code magicLinkEnabled = false}; keeps existing SSO contributors and the password/OIDC + * modes source-compatible after the magic-link flag was added. + */ + public AuthMethods(boolean passwordEnabled, List providers, String logoutUrl, String mode) { + this(passwordEnabled, false, providers, logoutUrl, mode); + } } diff --git a/onec-ui-starter/README.md b/onec-ui-starter/README.md index dc2e8881..662fa3a0 100644 --- a/onec-ui-starter/README.md +++ b/onec-ui-starter/README.md @@ -307,7 +307,9 @@ that matters to an integrator: - **The SPA is public; `/api/**` requires an authenticated session.** Everything outside `/api/**` (the SPA shell and static assets) is permitted anonymously so the login screen can load. `/api/**` is `authenticated()`, except the public endpoints `/api/theme`, `/api/config`, - `/api/auth/login` (and `/error`, plus the desktop probes). Unauthenticated `/api/**` calls get + `/api/branding`, `/api/auth/login`, `/api/divkit/login`, the opt-in magic-link endpoints + `/api/auth/magic/request` and `/api/auth/magic/verify` (and `/error`, plus the desktop probes). + Unauthenticated `/api/**` calls get `401` with `{"error":"unauthenticated"}` — **not** an HTTP Basic challenge (form login and Basic are both disabled). - **Auth is a session cookie, established by a JSON login** — not Basic auth, not a bearer token. @@ -327,6 +329,8 @@ that matters to an integrator: | POST | `/api/auth/login` | Body `{"username","password"}`. On success sets the session cookie and returns `{authenticated, username, roles}`; `401` on bad credentials, `400` on a malformed body. CSRF-exempt. | | POST | `/api/auth/logout` | Invalidates the session. | | GET | `/api/auth/me` | Current user, or `{authenticated:false,...}` when anonymous. | +| POST | `/api/auth/magic/request` | Opt-in passwordless login: emails a single-use sign-in link. Always `202` (no account enumeration). Renders as the `onec-magic-link` block on the DivKit login card when `onec.auth.magic-link.enabled=true`. CSRF-exempt. | +| GET | `/api/auth/magic/verify` | Consumes the token, establishes the session, and redirects into the app. | ### Logging in to call the API diff --git a/onec-ui-starter/src/main/frontend/src/components/magic-link-widget.tsx b/onec-ui-starter/src/main/frontend/src/components/magic-link-widget.tsx new file mode 100644 index 00000000..bc3fac64 --- /dev/null +++ b/onec-ui-starter/src/main/frontend/src/components/magic-link-widget.tsx @@ -0,0 +1,61 @@ +import { FormEvent, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/lib/api"; + +/** + * The React widget behind the DivKit {@code onec-magic-link} custom block — the passwordless + * sign-in option on the server-driven login screen. Like the password sub-form, it needs to read a + * typed value (the email) on submit, which a DivKit action can't do, so it's a real React form. It + * asks the server to email a single-use link, then shows a neutral confirmation. The server answers + * identically whether or not the address is registered, so this UI never reveals which emails exist. + */ +export function MagicLinkWidget() { + const [email, setEmail] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [sent, setSent] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + try { + await api.requestMagicLink(email); + } catch { + // Show the same confirmation even on an unexpected error — never signal whether the address + // matched an account. (Network failures are surfaced separately by the api layer.) + } finally { + setSubmitting(false); + setSent(true); + } + } + + // The login card's container blocks carry pointer-events:none (taps fall through to DivKit + // actions); re-enable them here so the input/button work, and inset from the clipping block edge. + if (sent) { + return ( +

+ If an account matches that address, a sign-in link is on its way — check your inbox. +

+ ); + } + + return ( +
+
+ + setEmail(event.target.value)} + /> +
+ +
+ ); +} diff --git a/onec-ui-starter/src/main/frontend/src/lib/api.ts b/onec-ui-starter/src/main/frontend/src/lib/api.ts index dbdf2711..4c2a00cd 100644 --- a/onec-ui-starter/src/main/frontend/src/lib/api.ts +++ b/onec-ui-starter/src/main/frontend/src/lib/api.ts @@ -199,6 +199,13 @@ export const api = { }), logout: () => fetchJson(`${BASE}/auth/logout`, { method: "POST" }), + // Passwordless login: ask the server to email a single-use sign-in link. Always succeeds (202) + // whether or not the address is registered, so the UI shows the same confirmation either way. + requestMagicLink: (email: string) => + fetchJson(`${BASE}/auth/magic/request`, { + method: "POST", + body: JSON.stringify({ email }), + }), getConfig: () => fetchJson(`${BASE}/config`), getTheme: () => fetchJson>(`${BASE}/theme`), diff --git a/onec-ui-starter/src/main/frontend/src/lib/magic-link-bridge.tsx b/onec-ui-starter/src/main/frontend/src/lib/magic-link-bridge.tsx new file mode 100644 index 00000000..2fc5e483 --- /dev/null +++ b/onec-ui-starter/src/main/frontend/src/lib/magic-link-bridge.tsx @@ -0,0 +1,66 @@ +import { useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; +import { MagicLinkWidget } from "@/components/magic-link-widget"; + +/** + * Bridges DivKit's {@code div-custom} block of type {@code onec-magic-link} to the React + * {@link MagicLinkWidget}. Mirrors {@code login-form-bridge}: the element just marks where the + * passwordless sub-form should mount, so it registers on connect unconditionally, and + * {@link MagicLinkPortals} (mounted on the login route, inside the providers) portals the React form + * into each live element. + */ + +type Mount = { id: number; el: HTMLElement }; + +let mounts: Mount[] = []; +const listeners = new Set<() => void>(); +let seq = 0; + +function emit() { + for (const l of listeners) l(); +} +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} +function getSnapshot(): Mount[] { + return mounts; +} + +class OnecMagicLinkElement extends HTMLElement { + private readonly _id = ++seq; + + connectedCallback() { + if (!mounts.some((m) => m.el === this)) { + mounts = [...mounts, { id: this._id, el: this }]; + emit(); + } + } + disconnectedCallback() { + if (mounts.some((m) => m.el === this)) { + mounts = mounts.filter((m) => m.el !== this); + emit(); + } + } +} + +let defined = false; +export function defineMagicLinkElement() { + if (defined || typeof customElements === "undefined") return; + if (!customElements.get("onec-magic-link")) { + customElements.define("onec-magic-link", OnecMagicLinkElement); + } + defined = true; +} +defineMagicLinkElement(); + +/** The DivKit {@code customComponents} entry: custom_type → element tag. */ +export const MAGIC_LINK_CUSTOM_COMPONENTS = new Map([ + ["onec-magic-link", { element: "onec-magic-link" }], +]); + +/** Portals every live {@code } to its React form. Mount once, on the login route. */ +export function MagicLinkPortals() { + const list = useSyncExternalStore(subscribe, getSnapshot); + return <>{list.map((m) => createPortal(, m.el, String(m.id)))}; +} diff --git a/onec-ui-starter/src/main/frontend/src/views/divkit-content.tsx b/onec-ui-starter/src/main/frontend/src/views/divkit-content.tsx index c4bf1764..975a7a0b 100644 --- a/onec-ui-starter/src/main/frontend/src/views/divkit-content.tsx +++ b/onec-ui-starter/src/main/frontend/src/views/divkit-content.tsx @@ -7,6 +7,7 @@ import { import { WIDGET_CUSTOM_COMPONENTS } from "@/lib/widget-bridge"; import { FORM_CUSTOM_COMPONENTS } from "@/lib/form-bridge"; import { LOGIN_FORM_CUSTOM_COMPONENTS } from "@/lib/login-form-bridge"; +import { MAGIC_LINK_CUSTOM_COMPONENTS } from "@/lib/magic-link-bridge"; import { ICON_CUSTOM_COMPONENTS } from "@/lib/icon-bridge"; import { HINT_CUSTOM_COMPONENTS } from "@/lib/hint-bridge"; import { ACTIONS_MENU_CUSTOM_COMPONENTS } from "@/lib/actions-menu-bridge"; @@ -22,6 +23,7 @@ const CUSTOM_COMPONENTS = new Map([ ...WIDGET_CUSTOM_COMPONENTS, ...FORM_CUSTOM_COMPONENTS, ...LOGIN_FORM_CUSTOM_COMPONENTS, + ...MAGIC_LINK_CUSTOM_COMPONENTS, ...ICON_CUSTOM_COMPONENTS, ...HINT_CUSTOM_COMPONENTS, ...ACTIONS_MENU_CUSTOM_COMPONENTS, diff --git a/onec-ui-starter/src/main/frontend/src/views/login.tsx b/onec-ui-starter/src/main/frontend/src/views/login.tsx index e890c2b3..4bdc418d 100644 --- a/onec-ui-starter/src/main/frontend/src/views/login.tsx +++ b/onec-ui-starter/src/main/frontend/src/views/login.tsx @@ -5,6 +5,7 @@ import { useTheme } from "@/providers/theme-provider"; import { useBranding } from "@/providers/branding-provider"; import { DivKitContent, type ContentAction, type ContentCard } from "@/views/divkit-content"; import { LoginFormPortals } from "@/lib/login-form-bridge"; +import { MagicLinkPortals } from "@/lib/magic-link-bridge"; import { IconPortals } from "@/lib/icon-bridge"; /** @@ -120,6 +121,7 @@ export function LoginView() { {/* DivKit custom blocks on the card mount their React widgets through these portals. */} + ); diff --git a/onec-ui-starter/src/main/java/com/onec/ui/LoginDivController.java b/onec-ui-starter/src/main/java/com/onec/ui/LoginDivController.java index 68d7c92d..4df4d3b0 100644 --- a/onec-ui-starter/src/main/java/com/onec/ui/LoginDivController.java +++ b/onec-ui-starter/src/main/java/com/onec/ui/LoginDivController.java @@ -54,7 +54,8 @@ AuthMethods resolveMethods() { } List providers = new ArrayList<>(base.providers()); providers.addAll(contributed); - return new AuthMethods(base.passwordEnabled(), providers, base.logoutUrl(), base.mode()); + return new AuthMethods(base.passwordEnabled(), base.magicLinkEnabled(), + providers, base.logoutUrl(), base.mode()); } /** diff --git a/onec-ui-starter/src/main/java/com/onec/ui/divkit/LoginDivBuilder.java b/onec-ui-starter/src/main/java/com/onec/ui/divkit/LoginDivBuilder.java index c2be198e..e10ec660 100644 --- a/onec-ui-starter/src/main/java/com/onec/ui/divkit/LoginDivBuilder.java +++ b/onec-ui-starter/src/main/java/com/onec/ui/divkit/LoginDivBuilder.java @@ -49,6 +49,14 @@ public static Map login(AuthMethods methods, Palette p) { items.add(form); } + // Passwordless magic-link sub-form: like the password form it needs to read a typed email on + // submit, so it's a React custom block too. Sits below the password form when both are offered. + if (methods.magicLinkEnabled()) { + Map magic = Div.custom("onec-magic-link", Map.of()); + Div.matchWidth(magic); + items.add(magic); + } + // SSO buttons. The first is primary when no password form competes with it. boolean primary = !methods.passwordEnabled(); for (SsoProvider provider : methods.providers()) {