Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,30 @@ the library.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## 2026-08-17

### Changed
- Both Docker stacks (`compose.yaml`, `docker-compose-keycloak.yml`) now run
[Mailpit](https://mailpit.axllent.org/) instead of `docker-mailserver`. Mailpit captures outbound
mail and serves it as a web inbox on http://localhost:8025, so the demo exercises the real
registration-verification and password-reset flows: register, read the message in the browser,
click the link, log in. (Resend-verification is still broken for an unrelated reason; see the
known-limitation note in `docs/CONFIGURATION.md`.)
- Neither stack sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL` any more; both use the base
`user.registration.sendVerificationEmail: true`
- App containers point at `SPRING_MAIL_HOST: mailpit` / `SPRING_MAIL_PORT: 1025`, auth and STARTTLS off

### Fixed
- `page.title.registration-pending-verification` in `messages.properties` was spelled
`registration-ending-verification`, so the pending-verification page rendered
`??page.title.registration-pending-verification_en??` as its title. The page is on the Docker demo's
happy path now that verification is on, which is how the typo surfaced

### Removed
- `mailserver.env`, the `./config/` mail bind mount, the `maildata`/`mailstate`/`maillogs` volumes, the
`NET_ADMIN`/`SYS_PTRACE` capabilities, and the published SMTP ports 25/587. The `SELINUX_LABEL`
variable went with the bind mount, so `docker compose` no longer warns that it is unset

## 2026-08-15

### Added
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,11 @@ docker compose up --build
```

The app image is built from source inside Docker, so the first build takes several minutes. When it is up,
open http://localhost:8080 and register at http://localhost:8080/user/register.html. This stack sets
`USER_REGISTRATION_SENDVERIFICATIONEMAIL=false`, so accounts are enabled at registration and you can log in
immediately. Its `mailserver` container is a relay with no route to real inboxes, so nothing it accepts will
reach an actual mailbox. The stack runs under the `dev` profile and loads no sample events.
open http://localhost:8080 and register at http://localhost:8080/user/register.html. The stack captures every
mail the app sends in [Mailpit](https://mailpit.axllent.org/) instead of delivering it, so the real flows work
end to end: open http://localhost:8025, click the verification link, then log in. Password reset from
http://localhost:8080/user/forgot-password.html arrives in the same inbox. The stack runs under the `dev`
profile and loads no sample events.

Stop it with Ctrl-C, then `docker compose down -v` to remove the containers and their data.

Expand Down
61 changes: 22 additions & 39 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,38 +21,25 @@ services:
timeout: 5s
retries: 3

mailserver:
image: docker.io/mailserver/docker-mailserver:latest
mailpit:
image: axllent/mailpit:v1.30.7
container_name: springuser-mail
Comment thread
Copilot marked this conversation as resolved.
hostname: mailserver
domainname: local
env_file: mailserver.env
ports:
- "25:25"
- "587:587"
volumes:
- maildata:/var/mail
- mailstate:/var/mail-state
- maillogs:/var/log/mail
- ./config/:/tmp/docker-mailserver/${SELINUX_LABEL}
# Web inbox. Open http://localhost:8025 to read the verification and password-reset mail the
# app sends. SMTP (1025) is not published: only the app container needs to reach it.
- "8025:8025"
environment:
PERMIT_DOCKER: connected-networks
ONE_DIR: 1
DMS_DEBUG: 0
SPOOF_PROTECTION: 0
REPORT_RECIPIENT: 1
ENABLE_SPAMASSASSIN: 0
ENABLE_CLAMAV: 0
ENABLE_FAIL2BAN: 1
ENABLE_POSTGREY: 0
SMTP_ONLY: 1
cap_add:
- NET_ADMIN
- SYS_PTRACE
MP_MAX_MESSAGES: 5000
# The app connects without auth (see SPRING_MAIL_* below). These two keep Mailpit accepting the
# message if you point a client at it that insists on AUTH over a plaintext connection.
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "25"]
interval: 30s
timeout: 10s
# No curl in the image (Alpine + busybox, so wget/nc exist but curl doesn't). `mailpit readyz`
# is what the image bakes into its own default HEALTHCHECK; this override just makes it explicit.
test: ["CMD", "/mailpit", "readyz"]
interval: 10s
timeout: 5s
retries: 5

myapp-main:
Expand All @@ -64,7 +51,7 @@ services:
depends_on:
myapp-db:
condition: service_healthy
mailserver:
mailpit:
condition: service_healthy
ports:
- "8080:8080"
Expand All @@ -73,24 +60,20 @@ services:
SPRING_DATASOURCE_USERNAME: springuser
SPRING_DATASOURCE_PASSWORD: springuser
SPRING_PROFILES_ACTIVE: dev
SPRING_MAIL_HOST: mailserver
SPRING_MAIL_PORT: 25
# Mailpit captures everything the app sends and shows it at http://localhost:8025. It speaks
# plain SMTP on 1025 with no auth and no STARTTLS, so verification and password-reset mail is
# readable without any credentials. Registration verification is left at its base default of
# true (application.yml:113): register, open the web inbox, click the link, log in.
SPRING_MAIL_HOST: mailpit
SPRING_MAIL_PORT: 1025
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "false"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "false"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "false"
# The mailserver container is a relay with no route to real inboxes, so a verification link
# would never arrive. With this false the framework enables new accounts immediately and you
# can log in straight after registering. To exercise verification instead, set this to true
# and point the SPRING_MAIL_* values above at a real SMTP server.
USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 5

volumes:
maildata:
mailstate:
maillogs:
userdb:
61 changes: 22 additions & 39 deletions docker-compose-keycloak.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,38 +20,25 @@ services:
timeout: 5s
retries: 3

mailserver:
image: docker.io/mailserver/docker-mailserver:latest
mailpit:
image: axllent/mailpit:v1.30.7
container_name: springuser-mail
Comment thread
Copilot marked this conversation as resolved.
hostname: mailserver
domainname: local
env_file: mailserver.env
ports:
- "25:25"
- "587:587"
volumes:
- maildata:/var/mail
- mailstate:/var/mail-state
- maillogs:/var/log/mail
- ./config/:/tmp/docker-mailserver/${SELINUX_LABEL}
# Web inbox. Open http://localhost:8025 to read the verification and password-reset mail the
# app sends. SMTP (1025) is not published: only the app container needs to reach it.
- "8025:8025"
environment:
PERMIT_DOCKER: connected-networks
ONE_DIR: 1
DMS_DEBUG: 0
SPOOF_PROTECTION: 0
REPORT_RECIPIENT: 1
ENABLE_SPAMASSASSIN: 0
ENABLE_CLAMAV: 0
ENABLE_FAIL2BAN: 1
ENABLE_POSTGREY: 0
SMTP_ONLY: 1
cap_add:
- NET_ADMIN
- SYS_PTRACE
MP_MAX_MESSAGES: 5000
# The app connects without auth (see SPRING_MAIL_* below). These two keep Mailpit accepting the
# message if you point a client at it that insists on AUTH over a plaintext connection.
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "25"]
interval: 30s
timeout: 10s
# No curl in the image (Alpine + busybox, so wget/nc exist but curl doesn't). `mailpit readyz`
# is what the image bakes into its own default HEALTHCHECK; this override just makes it explicit.
test: ["CMD", "/mailpit", "readyz"]
interval: 10s
timeout: 5s
retries: 5

myapp-main:
Expand All @@ -63,7 +50,7 @@ services:
depends_on:
myapp-db:
condition: service_healthy
mailserver:
mailpit:
condition: service_healthy
# Not strictly required to boot, but the demo is useless until Keycloak answers, and this makes
# `up` report the stack as ready only when a Keycloak login can actually be attempted.
Expand All @@ -77,16 +64,15 @@ services:
SPRING_DATASOURCE_USERNAME: springuser
SPRING_DATASOURCE_PASSWORD: springuser
SPRING_PROFILES_ACTIVE: docker-keycloak
SPRING_MAIL_HOST: mailserver
SPRING_MAIL_PORT: 25
# Mailpit captures everything the app sends and shows it at http://localhost:8025. It speaks
# plain SMTP on 1025 with no auth and no STARTTLS, so verification and password-reset mail is
# readable without any credentials. Registration verification is left at its base default of
# true (application.yml:113): register, open the web inbox, click the link, log in.
SPRING_MAIL_HOST: mailpit
SPRING_MAIL_PORT: 1025
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "false"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "false"
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "false"
# The mailserver container is a relay with no route to real inboxes, so a verification link
# would never arrive. With this false the framework enables new accounts immediately and you
# can log in straight after registering. To exercise verification instead, set this to true
# and point the SPRING_MAIL_* values above at a real SMTP server.
USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"]
interval: 30s
Expand Down Expand Up @@ -133,8 +119,5 @@ services:
condition: service_healthy

volumes:
maildata:
mailstate:
maillogs:
userdb:
keycloak:
24 changes: 15 additions & 9 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,20 @@ failed logins and lockouts land in the audit log.
[`register.js`](../src/main/resources/static/js/user/register.js)), which posts JSON to
`POST /user/registration`, not form data.
2. What happens next depends on `user.registration.sendVerificationEmail`:
- `true` (base `application.yml:113`): the account is created disabled, a verification email is sent,
and the browser lands on `/user/registration-pending-verification.html`. The emailed link is
- `true` (base `application.yml:113`, and what both Docker stacks run): the account is created
disabled, a verification email is sent, and the browser lands on
`/user/registration-pending-verification.html`. The emailed link is
`GET /user/registrationConfirm?token=...`, which enables the account. Lost it? Request another at
`/user/request-new-verification-email.html`
([`resend-verification.js`](../src/main/resources/static/js/user/resend-verification.js) posts
`POST /user/resendRegistrationToken`).
`POST /user/resendRegistrationToken`). Under `docker compose up` the message is captured by Mailpit
rather than delivered: open <http://localhost:8025> and click the link there. The resend page itself
is currently broken against `ds-spring-user-framework:5.3.0`: the endpoint binds the full
registration `UserDto`, whose `firstName`/`lastName`/`password`/`matchingPassword` are `@NotBlank`,
so the email-only payload the page posts is rejected with HTTP 400 and no mail goes out.
- `false`: the account is created enabled, the framework logs the user straight in, and the browser
lands on `/user/registration-complete.html`. `application-local.yml-example:131` sets it false and
the Docker demo stack sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"` (`compose.yaml:85`),
so neither documented run path needs a working SMTP server.
lands on `/user/registration-complete.html`. `application-local.yml-example:131` sets it false, so
the Gradle `local` run path needs no SMTP server at all.
3. Log in at `/user/login.html` ([`login.js`](../src/main/resources/static/js/user/login.js)). The form
posts to `/user/login`; success redirects to `/index.html?messageKey=message.login.success`. Ten
failed attempts lock the account for 30 minutes (`application.yml:146-147`).
Expand All @@ -36,7 +40,9 @@ failed logins and lockouts land in the audit log.
`/user/forgot-password-change.html`, which posts `POST /user/savePassword`
([`forgot-password.js`](../src/main/resources/static/js/user/forgot-password.js),
[`reset-password.js`](../src/main/resources/static/js/user/reset-password.js)). Both reset steps need
real mail, unlike registration. To change a password you know, `/user/update-password.html` posts
working mail in every profile: the Docker stacks capture it in Mailpit at <http://localhost:8025>,
and `playwright-test` turns the email off and reads the token through the test API instead
(`application-playwright-test.yml:8`). To change a password you know, `/user/update-password.html` posts
`POST /user/updatePassword`.

## Passkeys
Expand Down Expand Up @@ -160,8 +166,8 @@ Open http://localhost:8080/user/login.html, click "Login with Keycloak", sign in
First login creates a local account with provider `KEYCLOAK` and email `demo@example.com`. The realm is
`demo`, not `master`, so every OIDC URL is `/realms/demo/...`; `admin` / `admin` is a master realm
account and cannot sign in to the demo app. The app's own registration form still works in this stack, and
like the `compose.yaml` stack it sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"`, so an account
registered there is enabled immediately rather than waiting on mail the bundled relay cannot deliver.
like the `compose.yaml` stack it sends the verification email through the bundled Mailpit container: open
<http://localhost:8025>, click the link in the message, and log in with the form.
The compose file sets `SPRING_PROFILES_ACTIVE: docker-keycloak`, and every value in
[`application-docker-keycloak.yml`](../src/main/resources/application-docker-keycloak.yml) comes from an
environment variable in [`keycloak.env`](../keycloak.env): `DS_SPRING_USER_KEYCLOAK_CLIENT_ID`,
Expand Down
11 changes: 5 additions & 6 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,20 +63,19 @@ Recognized elsewhere (fall back to a demo default when unset):
| `DATABASE_URL`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | Production datasource (`application-prd.yml:10-12`). |
| `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_NAME`, `WEBAUTHN_ALLOWED_ORIGINS` | WebAuthn relying-party identity in `prd` (`application-prd.yml:41-43`). |
| `DS_SPRING_USER_KEYCLOAK_CLIENT_ID`, `_CLIENT_SECRET`, `_PROVIDER_AUTHORIZATION_URI`, `_PROVIDER_TOKEN_URI`, `_PROVIDER_USER_INFO_URI`, `_PROVIDER_JWK_SET_URI` | Keycloak OAuth2 client and provider endpoints for `docker-keycloak`, consumed in `application-docker-keycloak.yml:19-20,35,48-50`; supplied by [`keycloak.env`](../keycloak.env) when you run `docker-compose-keycloak.yml`. There is no `_PROVIDER_ISSUER_URI`: `issuer-uri` is deliberately left unset, see [`keycloak/README.md`](../keycloak/README.md). |
| `SELINUX_LABEL` | Suffix on the mailserver's bind-mounted config path in `compose.yaml`/`docker-compose-keycloak.yml`. Unset by default; Docker Compose prints a harmless warning about it. |

Any framework property can also be set through Spring's relaxed binding (`SCREAMING_SNAKE_CASE` of
the dotted key). The Docker demo stack (`compose.yaml`) does this for the app container:
`SPRING_DATASOURCE_URL`/`_USERNAME`/`_PASSWORD` (→ `spring.datasource.*`), `SPRING_PROFILES_ACTIVE`,
`SPRING_MAIL_HOST`/`_PORT` and the `SPRING_MAIL_PROPERTIES_MAIL_SMTP_*` keys (→ `spring.mail.*`), and
`USER_REGISTRATION_SENDVERIFICATIONEMAIL` (→ `user.registration.sendVerificationEmail`). The same
pattern works for any other key, e.g. `USER_SECURITY_BCRYPTSTRENGTH` for `user.security.bcryptStrength`.
`SPRING_DATASOURCE_URL`/`_USERNAME`/`_PASSWORD` (→ `spring.datasource.*`), `SPRING_PROFILES_ACTIVE`, and
`SPRING_MAIL_HOST`/`_PORT` plus the `SPRING_MAIL_PROPERTIES_MAIL_SMTP_*` keys (→ `spring.mail.*`). The same
pattern works for any other key, e.g. `USER_SECURITY_BCRYPTSTRENGTH` for `user.security.bcryptStrength` or
`USER_REGISTRATION_SENDVERIFICATIONEMAIL` for `user.registration.sendVerificationEmail`.

## Mail

- `spring.mail.username`, `spring.mail.password`, `spring.mail.host`, `spring.mail.port` (`application.yml:2-6`) configure the SMTP transport used for verification, password-reset, and notification email. The base file's `host` is a placeholder SES endpoint; set real credentials in your profile.
- `user.registration.sendVerificationEmail` (`application.yml:113`) controls whether a new account must click a verification link before it can log in. `false` enables the account immediately at registration.
- The Docker demo stack's `mailserver` service (`compose.yaml`) is a relay only: `SMTP_ONLY: 1` (`compose.yaml:48`) with no route to real inboxes. That stack sets `USER_REGISTRATION_SENDVERIFICATIONEMAIL: "false"` (`compose.yaml:85`) so registered accounts activate immediately instead of waiting on mail nothing will deliver.
- Both Docker stacks (`compose.yaml` and `docker-compose-keycloak.yml`) run a `mailpit` service ([Mailpit](https://mailpit.axllent.org/)) that captures outbound mail instead of delivering it, and serves it as a web inbox on <http://localhost:8025>. The app container reaches it over plain SMTP with `SPRING_MAIL_HOST: mailpit` and `SPRING_MAIL_PORT: 1025`, auth and STARTTLS off. Neither stack overrides `user.registration.sendVerificationEmail`, so registration verification and password reset run their real email flows: read the message in the web inbox and click the link. Nothing leaves the machine, and Mailpit keeps no volume, so `docker compose down` discards the captured mail. The resend-verification page does not work against `ds-spring-user-framework:5.3.0`: `UserAPI.resendRegistrationToken` binds the full registration `UserDto`, whose `firstName`, `lastName`, `password`, and `matchingPassword` are `@NotBlank`, so the email-only payload that `resend-verification.js` posts is rejected with HTTP 400 and no mail is sent. That is a framework-side bug, not a stack setting.
- `user.mail.fromAddress` sets the `From` address on outbound mail; it is set per profile (e.g. `application-local.yml-example:144`), not in the base file.

## Security settings this demo sets
Expand Down
9 changes: 5 additions & 4 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,13 @@ that automate the tunnel + proxy pair for the first case. See:
- [`compose.dev.yaml`](../compose.dev.yaml): database only. Started automatically by `bootRun`'s
Docker Compose integration; not meant to be run directly, though `docker compose -f compose.dev.yaml
up -d` works if you want the database without the app.
- [`compose.yaml`](../compose.yaml): the full demo stack: app + MariaDB + a relay-only mail
container. `docker compose up -d` builds the app image (multi-stage [`Dockerfile`](../Dockerfile):
- [`compose.yaml`](../compose.yaml): the full demo stack: app + MariaDB + a Mailpit mail catcher
whose web inbox is on <http://localhost:8025>. `docker compose up -d` builds the app image
(multi-stage [`Dockerfile`](../Dockerfile):
a JDK-21 build stage runs `./gradlew --no-daemon bootJar -x test` (`Dockerfile:14`), a JRE-21 stage runs the resulting jar as a non-root
user) and runs all three. The app container's healthcheck polls `GET /actuator/health`
(`compose.yaml:86-90`), the only actuator endpoint left unauthenticated.
- [`docker-compose-keycloak.yml`](../docker-compose-keycloak.yml): the same app + MariaDB + mail
(`compose.yaml:72-76`), the only actuator endpoint left unauthenticated.
- [`docker-compose-keycloak.yml`](../docker-compose-keycloak.yml): the same app + MariaDB + Mailpit
setup plus a Keycloak container, for testing OIDC login. Start with
`docker compose -f docker-compose-keycloak.yml up -d --build --wait`. See
[`keycloak/README.md`](../keycloak/README.md) for ports, credentials, and the login walkthrough, and
Expand Down
Loading
Loading