AuthController.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.controller;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apereo.cas.client.util.CommonUtils;
import org.entrystore.rest.springboot.configuration.CasCustomConfiguration;
import org.entrystore.rest.springboot.filter.CacheControlFilter;
import org.entrystore.rest.springboot.model.api.ConfirmRequestBody;
import org.entrystore.rest.springboot.model.api.PwResetRequestBody;
import org.entrystore.rest.springboot.model.api.SignupRequestBody;
import org.entrystore.rest.springboot.model.auth.ConfirmationResult;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.EntityNotFoundException;
import org.entrystore.rest.springboot.security.RefreshableRelyingPartyRegistrationRepository;
import org.entrystore.rest.springboot.service.AuthService;
import org.entrystore.rest.springboot.service.OidcAuthService;
import org.entrystore.rest.springboot.service.SamlAuthService;
import org.entrystore.rest.springboot.service.auth.RecaptchaSettings;
import org.entrystore.rest.springboot.util.HttpUtil;
import org.entrystore.rest.springboot.util.RequestBodyValidator;
import org.entrystore.rest.springboot.util.WebResourceUrls;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.cas.ServiceProperties;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
@Slf4j
@Controller
@RequiredArgsConstructor
public class AuthController {
private final WebResourceUrls webResourceUrls;
private final RecaptchaSettings recaptchaSettings;
private static final int MAX_REQUEST_SIZE = 32 * 1024;
private static final String SIGNUP_TITLE = "Sign-up";
private static final String PASSWORD_RESET_TITLE = "Password reset";
/** Per-flow attributes for the shared {@code confirm_form} template (new confirmation mode). */
private record ConfirmForm(String title, String action, String passwordLabel, String intro) {
}
private static final ConfirmForm SIGNUP_FORM = new ConfirmForm(SIGNUP_TITLE, "/auth/signup/confirm", "Password",
"To confirm your sign-up, re-enter the email address and the password you chose.");
private static final ConfirmForm PWRESET_FORM = new ConfirmForm(PASSWORD_RESET_TITLE, "/auth/pwreset/confirm", "New password",
"To reset your password, enter your email address and choose a new password.");
/**
* Exposed to every view this controller renders. A bean reference cannot be used from the
* templates directly: Thymeleaf forbids SpEL bean access in the restricted context that
* attribute expressions are evaluated in.
*/
@ModelAttribute("stylesheetPath")
String stylesheetPath() {
return webResourceUrls.getStylesheetPath();
}
/** The site key the sign-up and password-reset forms render the widget with; null hides the widget. */
@ModelAttribute("recaptchaSiteKey")
String recaptchaSiteKey() {
return recaptchaSettings.getSiteKey();
}
@Value("${entrystore.auth.saml.enabled:false}")
private boolean isSamlAuthEnabled;
@Value("${entrystore.auth.oidc.enabled:false}")
private boolean isOidcAuthEnabled;
private final AuthService authService;
private final SamlAuthService samlAuthService;
private final OidcAuthService oidcAuthService;
private final CasCustomConfiguration casConfiguration;
private final Optional<ServiceProperties> casServiceProperties;
// Optional: the bean exists only when spring.security.oauth2.client registrations are configured.
private final Optional<ClientRegistrationRepository> clientRegistrationRepository;
// Optional: the bean exists only when SAML is enabled.
private final Optional<RefreshableRelyingPartyRegistrationRepository> samlRegistrationRepository;
private final RequestBodyValidator requestBodyValidator;
@Operation(summary = "Starts a CAS login by redirecting to the CAS server's login page. Answers 404 when CAS " +
"is disabled.")
@GetMapping("/auth/cas")
public void startCasLogin(HttpServletResponse response) throws IOException {
if (!casConfiguration.enabled()) {
throw new EntityNotFoundException("Not Found");
}
String serviceUrl = casServiceProperties.orElseThrow(() -> new IllegalStateException(
"CAS is enabled but ServiceProperties bean is missing — check CasConfig.")).getService();
String loginUrl = casConfiguration.server().resolvedLoginUrl();
// CommonUtils.constructRedirectUrl handles query-string parsing and URL encoding,
// so it works correctly even when loginUrl already contains a query string.
response.sendRedirect(CommonUtils.constructRedirectUrl(loginUrl, "service", serviceUrl, false, false));
}
/**
* A configured IdP without a usable registration (e.g. its metadata was unreachable) goes to the failure URL, as
* a failed SAML login does; Spring's initiation endpoint would answer 404.
*/
@Operation(summary = "Starts a SAML login by redirecting to the identity provider chosen from the 'username' " +
"domain, else 'idp', else the configured default IdP. Redirects to the failure URL when that IdP is " +
"unavailable, e.g. its metadata could not be fetched. Answers 404 when SAML is disabled.")
@GetMapping("/auth/saml")
public String startSamlLogin(@RequestParam(required = false) String username,
@RequestParam(required = false) String idp,
@RequestParam(name = "successurl", required = false) String successUrl,
@RequestParam(name = "failureurl", required = false) String failureUrl,
RedirectAttributes redirectAttributes) {
if (!isSamlAuthEnabled) {
throw new EntityNotFoundException("Not Found");
}
String idpId = samlAuthService.findIdpIdForRequest(username, idp);
if (samlRegistrationRepository.map(repository -> repository.isConfiguredButUnavailable(idpId)).orElse(false)) {
log.debug("SAML login with IdP '{}' cannot start: no usable registration", idpId);
return "redirect:" + samlAuthService.failureRedirectUrl(failureUrl);
}
if (successUrl != null && samlAuthService.isValidRedirectUrl(successUrl)) {
redirectAttributes.addAttribute("successurl", successUrl);
}
if (failureUrl != null && samlAuthService.isValidRedirectUrl(failureUrl)) {
redirectAttributes.addAttribute("failureurl", failureUrl);
}
redirectAttributes.addAttribute("idpId", idpId);
return "redirect:/saml2/authenticate/{idpId}";
}
/**
* Initiates OIDC authentication by redirecting to the selected provider's authorization
* endpoint. An unknown registration id would otherwise surface deep inside Spring Security's
* {@code OAuth2AuthorizationRequestRedirectFilter} as an HTML 500 that bypasses
* {@code AppExceptionHandler}, so the guard below fails early. Once a provider id has been
* selected, a missing registration repository is config-shaped (500). An unknown id is then
* classified by the selection's provenance: a bogus {@code ?provider=} parameter is the caller's
* fault (400); a config-derived id — a {@code default-provider} typo, or an
* {@code entrystore.auth.oidc.provider.{id}} entry whose map key has no registration (domain
* routing returns that key, never the {@code domains} values) — is a server misconfiguration
* surfaced as a 500 that {@code AppExceptionHandler} logs at error.
* {@code OidcProviderRegistrationValidator} rejects both config-derived cases at startup; the
* guard is the runtime backstop.
*/
@Operation(summary = "Starts an OIDC login by redirecting to the provider chosen from the 'username' domain, " +
"else 'provider', else the configured default provider. Answers 404 when OIDC is disabled.")
@GetMapping("/auth/oidc")
public String startOidcLogin(@RequestParam(required = false) String username,
@RequestParam(required = false) String provider,
@RequestParam(name = "successurl", required = false) String successUrl,
@RequestParam(name = "failureurl", required = false) String failureUrl,
RedirectAttributes redirectAttributes) {
if (!isOidcAuthEnabled) {
throw new EntityNotFoundException("Not Found");
}
if (successUrl != null && oidcAuthService.isValidRedirectUrl(successUrl)) {
redirectAttributes.addAttribute("successurl", successUrl);
}
if (failureUrl != null && oidcAuthService.isValidRedirectUrl(failureUrl)) {
redirectAttributes.addAttribute("failureurl", failureUrl);
}
var selection = oidcAuthService.findProviderIdForRequest(username, provider);
// With a provider id selected, no repository bean is config-shaped (see method Javadoc).
var registrations = clientRegistrationRepository.orElseThrow(
() -> new IllegalStateException("OIDC is enabled but no spring.security.oauth2.client.registration "
+ "entries are configured — no provider can complete a login"));
if (registrations.findByRegistrationId(selection.id()) == null) {
if (selection.fromRequestParameter()) {
// sanitizeForLog: the raw ?provider= value is reflected into the message, which is
// copied into the JSON body and the handler's debug log.
throw new BadRequestException("Unknown OIDC provider: " + HttpUtil.sanitizeForLog(selection.id()));
}
throw new IllegalStateException("Configured OIDC provider id '" + selection.id()
+ "' has no matching spring.security.oauth2.client.registration entry — check "
+ "entrystore.auth.oidc.default-provider and the entrystore.auth.oidc.provider.{id} keys");
}
redirectAttributes.addAttribute("providerId", selection.id());
// Spring Security's OAuth2AuthorizationRequestRedirectFilter serves this path;
// OidcAuthorizationRequestResolver re-validates and caches the redirect URLs there.
return "redirect:/oauth2/authorization/{providerId}";
}
@Operation(
summary = "Returns a basic HTML-form for basic login",
description = "It is recommended to use any of the other login-resources. Performs a cookie-based login and " +
"should only be used for testing purposes, does not really belong to the API.")
@GetMapping(path = "/auth/login")
public String getLoginPage() {
// Uses Thymeleaf templating engine - returns login.html template from /resources/templates
return "login";
}
@Operation(summary = "Generates new link for password change confirmation and sends an email to the User.")
@PostMapping(path = "/auth/pwreset",
consumes = {MediaType.APPLICATION_JSON_VALUE}
)
public String resetPassword(
HttpServletRequest request,
Model model,
@RequestBody PwResetRequestBody pwResetRequestBody) {
HttpUtil.checkRequestSize(request, MAX_REQUEST_SIZE);
String message = authService.pwReset(request, pwResetRequestBody, PASSWORD_RESET_TITLE);
model.addAttribute("title", PASSWORD_RESET_TITLE);
model.addAttribute("message", message);
return "auth";
}
@Operation(summary = "Generates new link for password change confirmation and sends an email to the User. Request is an html form.")
@PostMapping(path = "/auth/pwreset",
consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE}
)
public String resetPasswordViaForm(
HttpServletRequest request,
Model model,
@RequestParam MultiValueMap<String, String> paramMap) {
HttpUtil.checkRequestSize(request, MAX_REQUEST_SIZE);
PwResetRequestBody pwResetRequestBody = new PwResetRequestBody(
paramMap.getFirst("email"),
paramMap.getFirst("password"),
paramMap.getFirst("urlsuccess"),
paramMap.getFirst("urlfailure"),
paramMap.getFirst("g-recaptcha-response"));
String message = authService.pwReset(request, pwResetRequestBody, PASSWORD_RESET_TITLE);
model.addAttribute("title", PASSWORD_RESET_TITLE);
model.addAttribute("message", message);
return "auth";
}
@Operation(summary = "Legacy mode: confirms the password change. New mode: renders the confirmation form for a valid token.")
@GetMapping(path = "/auth/pwreset")
public String confirmPasswordReset(
Model model,
HttpServletResponse response,
@RequestParam(required = false) String confirm
) {
if (confirm == null || confirm.isEmpty()) {
return "pwreset_form";
}
if (authService.isLegacyConfirmationMode()) {
String message = authService.confirmPasswordLegacy(confirm, PASSWORD_RESET_TITLE);
model.addAttribute("title", PASSWORD_RESET_TITLE);
model.addAttribute("message", message);
return "auth";
}
authService.assertPasswordResetTokenValid(confirm, PASSWORD_RESET_TITLE);
addConfirmFormAttributes(model, response, PWRESET_FORM, confirm, null);
return "confirm_form";
}
@Operation(summary = "Confirms a password change after the user re-enters the email and chosen password.")
@PostMapping(path = "/auth/pwreset/confirm", consumes = {MediaType.APPLICATION_JSON_VALUE})
public String confirmPasswordResetSubmit(
HttpServletRequest request,
HttpServletResponse response,
Model model,
@RequestBody ConfirmRequestBody body) {
return submitPasswordResetConfirmation(request, response, model, body.confirm(), body.email(), body.password());
}
@Operation(summary = "Confirms a password change after the user re-enters the email and chosen password. Request is an html form.")
@PostMapping(path = "/auth/pwreset/confirm", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE})
public String confirmPasswordResetSubmitViaForm(
HttpServletRequest request,
HttpServletResponse response,
Model model,
@RequestParam MultiValueMap<String, String> paramMap) {
return submitPasswordResetConfirmation(request, response, model, paramMap.getFirst("confirm"),
paramMap.getFirst("email"), paramMap.getFirst("password"));
}
@Operation(summary = "Generates new link for user sign-up confirmation and sends an email to provided email address.")
@PostMapping(path = "/auth/signup",
consumes = {MediaType.APPLICATION_JSON_VALUE}
)
public String signup(
HttpServletRequest request,
HttpServletResponse response,
Model model,
@RequestBody SignupRequestBody signupRequestBody) {
HttpUtil.checkRequestSize(request, MAX_REQUEST_SIZE);
response.setContentType(MediaType.TEXT_HTML_VALUE);
// Validated here rather than with @Valid so the size limit above still decides first. @Valid runs
// during argument resolution, which precedes the method body, so an oversized body whose fields
// are also invalid would answer 400 instead of 413 — and an oversized sign-up body almost always
// is invalid, since the bulk has to go in one of these fields.
requestBodyValidator.assertValid(signupRequestBody, SIGNUP_TITLE);
String message = authService.signup(request, signupRequestBody, SIGNUP_TITLE);
model.addAttribute("title", SIGNUP_TITLE);
model.addAttribute("message", message);
return "auth";
}
@Operation(summary = "Generates new link for user sign-up confirmation and sends an email to provided email address. Request is an html form.")
@PostMapping(path = "/auth/signup",
consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE}
)
public String signupViaForm(
HttpServletRequest request,
Model model,
@RequestParam Map<String, String> parameters) {
HttpUtil.checkRequestSize(request, MAX_REQUEST_SIZE);
// Not @Valid @ModelAttribute: the form spells the captcha field g-recaptcha-response, which is
// not a legal record component name, so Spring cannot bind it. The body is assembled here and
// validated against the same constraints, so both content types answer with the same message.
SignupRequestBody signupRequestBody = SignupRequestBody.fromFormParameters(parameters);
requestBodyValidator.assertValid(signupRequestBody, SIGNUP_TITLE);
String message = authService.signup(request, signupRequestBody, SIGNUP_TITLE);
model.addAttribute("title", SIGNUP_TITLE);
model.addAttribute("message", message);
return "auth";
}
@Operation(summary = "Legacy mode: confirms new user creation. New mode: renders the confirmation form for a valid token.")
@GetMapping(path = "/auth/signup")
public String confirmSignup(
Model model,
HttpServletResponse response,
@RequestParam(required = false) String confirm
) {
if (confirm == null || confirm.isEmpty()) {
return "signup_form";
}
if (authService.isLegacyConfirmationMode()) {
String message = authService.confirmSignupLegacy(confirm, SIGNUP_TITLE);
model.addAttribute("title", SIGNUP_TITLE);
model.addAttribute("message", message);
response.setStatus(HttpStatus.CREATED.value());
return "auth";
}
authService.assertSignupTokenValid(confirm, SIGNUP_TITLE);
addConfirmFormAttributes(model, response, SIGNUP_FORM, confirm, null);
return "confirm_form";
}
@Operation(summary = "Confirms new user creation after the user re-enters the email and chosen password.")
@PostMapping(path = "/auth/signup/confirm", consumes = {MediaType.APPLICATION_JSON_VALUE})
public String confirmSignupSubmit(
HttpServletRequest request,
HttpServletResponse response,
Model model,
@RequestBody ConfirmRequestBody body) {
return submitSignupConfirmation(request, response, model, body.confirm(), body.email(), body.password());
}
@Operation(summary = "Confirms new user creation after the user re-enters the email and chosen password. Request is an html form.")
@PostMapping(path = "/auth/signup/confirm", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE})
public String confirmSignupSubmitViaForm(
HttpServletRequest request,
HttpServletResponse response,
Model model,
@RequestParam MultiValueMap<String, String> paramMap) {
return submitSignupConfirmation(request, response, model, paramMap.getFirst("confirm"),
paramMap.getFirst("email"), paramMap.getFirst("password"));
}
private String submitSignupConfirmation(HttpServletRequest request, HttpServletResponse response, Model model,
String confirm, String email, String password) {
HttpUtil.checkRequestSize(request, MAX_REQUEST_SIZE);
ConfirmationResult result = authService.confirmSignup(request, confirm, email, password, SIGNUP_TITLE);
return renderConfirmation(model, response, result, SIGNUP_FORM, confirm, HttpStatus.CREATED.value());
}
private String submitPasswordResetConfirmation(HttpServletRequest request, HttpServletResponse response, Model model,
String confirm, String email, String password) {
HttpUtil.checkRequestSize(request, MAX_REQUEST_SIZE);
ConfirmationResult result = authService.confirmPassword(request, confirm, email, password, PASSWORD_RESET_TITLE);
return renderConfirmation(model, response, result, PWRESET_FORM, confirm, HttpStatus.OK.value());
}
private String renderConfirmation(Model model, HttpServletResponse response, ConfirmationResult result,
ConfirmForm form, String token, int successStatus) {
if (result.success()) {
model.addAttribute("title", form.title());
model.addAttribute("message", result.message());
response.setStatus(successStatus);
return "auth";
}
String error = "The information you entered is incorrect. " + result.remainingAttempts() + " attempt(s) remaining.";
addConfirmFormAttributes(model, response, form, token, error);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return "confirm_form";
}
/** Renders the token into the form, so no cache may store the response. */
private void addConfirmFormAttributes(Model model, HttpServletResponse response, ConfirmForm form, String token,
String error) {
response.setHeader(HttpHeaders.CACHE_CONTROL, CacheControlFilter.CACHE_CONTROL_CREDENTIALS);
model.addAttribute("title", form.title());
model.addAttribute("action", form.action());
model.addAttribute("passwordLabel", form.passwordLabel());
model.addAttribute("intro", form.intro());
model.addAttribute("token", token);
model.addAttribute("error", error);
}
}