Get in and get out of protected areas

Description

User login is performed using a username and a password that have been stored in the database. The user password is by default encrypted. A root user can be configured in order to perform administration operations on other users created at runtime.

The application can have a full login page or a login modal that opens on any page. It is nice to have both for different use cases. For example the full page can be nicer when you want all users to perform login before proceeding, while the modal is convenient when you want to let anonymous users into the application and let them login at a later step without losing their current state.

The login process is composed of these steps:

  • the user clicks on a login link or on a link to a protected page

  • the login form is presented

    • the login form can either be on a normal page or on a modal loaded via ajax

  • the user fills the form and submits it, either with a normal or ajax request

  • the server may respond with validation errors returning the same login form back

    • username not found

    • invalid password

    • too many failed login attempts

  • or the final page will be shown

    • if the user clicked on a login link, a preconfigured page is shown

    • if the user clicked on a link to a protected page, the requested page is shown

Setup

There are two functions in HomeController that define the ajax login page and the normal login page: login() and loginAjax(). Either is called when login fails, depending on the login POST type (normal/ajax). Only the first one is called after clicking on a link to a protected page, be it a normal or an ajax link. In order to change the url values, that by default are "/login" and "/ajaxLogin", the SecurityConfig bean must override the relevant variables in the constructor:

public SecurityConfig() {
	loginUrl = "/myloginpage";
	loginUrlAjax = "/myloginmodal";
}

The "/openLogin" request mapping, implemented in HomeController.openLogin(), should be called on the link that users click to perform an explicit login. This method resets any saved request that may still be stored from previous aborted access attempts thus ensuring that the target page after login is the correct one. The @RequestMapping value can be changed to anything as long as the endpoint is not protected.

The landing page after an explicit successful login can be configured in SecurityConfig too. The default values are "/" (the home page) for normal requests and "/yadaLoginSuccess" for ajax requests. The landing page after logout can also be configured this way. This is an example:

public class SecurityConfig extends YadaSecurityConfig {
 	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http.authorizeHttpRequests(authorize -> authorize
			.requestMatchers(new AntPathRequestMatcher("/admin/**")).hasRole("ADMIN")
			.requestMatchers(new AntPathRequestMatcher("/user/**")).hasRole("USER")
		);
		super.configure(http);
		super.successHandler.setDefaultTargetUrlNormalRequest("/my/area");
		super.successHandler.setDefaultTargetUrlAjaxRequest("/yadaLoginSuccess?targetUrl=/my/area");
		super.logoutSuccessHandler.setDefaultTargetUrl("/my/area");
		http.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());
		return http.build();
	}
}

The syntax /yadaLoginSuccess?targetUrl=/my/area ensures that after an ajax login request a full page at "/my/area" will be loaded.

"/yadaLoginSuccess" is handled by YadaLoginController and returns either YadaViews.AJAX_SUCCESS or a redirect to the target url when specified.

Password Reset

Whenever there is a login with user and password there should also be a way to recover a forgotten password. The Yada Framework by default encrypts passwords on database so retrieval is not possible. As an alternative, password reset must be used.

The steps are as follows:

  • Provide a link to the "Password reset" page near the login form

  • Implement a password reset page with a form where users type their email address

  • Receive the reset request and send an email with a password reset link

  • Validate the password reset link and show a form where the new password can be inserted

    • Show any validation errors e.g. link expired or invalid

  • Update the user password

The link to the password reset page could call a "/passwordReset" endpoint passing any email address the user might have typed in the login user field. This is an example implementation that uses javascript to copy the field value to the form behind the link:

<form th:action="@{/passwordReset}" role="form" method="post" id="pwdRecoverForm">
	<input type="hidden" name="email" th:value="${username}" />
	<a class="link link--black fs-6" href="#" th:text="#{view.link.forgotPassword}">Forgot my password</a>
</form>
<script>
    $('#pwdRecoverForm a').click(function(e) {
    	e.preventDefault();
    	var email=$('#loginForm input[name="username"]').val();
    	$('#pwdRecoverForm input[name="email"]').val(email);
    	$('#pwdRecoverForm').submit();
    	return false;
    });
</script>

The controller can then open the page that contains the password reset form:

@RequestMapping("/passwordReset")
public String passwordReset(String email, YadaRegistrationRequest yadaRegistrationRequest) {
	yadaRegistrationRequest.setEmail(email);
	yadaRegistrationRequest.setRegistrationType(YadaRegistrationType.PASSWORD_RECOVERY);
	return "/passwordReset";
}

The password reset form only required field is the email address. It should also display any error messages:

<form role="form" th:action="@{/yadaPasswordResetPost}" th:object="${yadaRegistrationRequest}" method="post">
	<fieldset class="has-feedback">
		<label for="email">E-Mail</label>
		<div th:with="hasError=${#fields.hasErrors('email')}" >
			<input type="email" id="email" name="email" th:value="*{email}" class="form-control" maxlength="64"
				th:classappend="${hasError?'is-invalid':''}" required="required" autofocus="autofocus" />
			<small th:each="err : ${#fields.errors('email')}" class="invalid-feedback" th:text="${err}">Error</small>
		</div>
	</fieldset>
	<button class="btn btn-lg btn-primary w-100" type="submit">Reset Password</button>
</form>

The default form handler is implemented in YadaRegistrationController.java. If the user does not exist, it returns an error, otherwise it sends a recovery email to the supplied address.

The email template is in /src/main/resources/template/email/passwordRecovery.html. When the email can not be sent, an error is returned to the form, otherwise a successful notification is shown after redirection to the page configured with config/security/passwordReset/passwordResetSent (defaults to home).

These are the localisation keys for the whole process:

yada.passwordrecover.username.notfound = ...
email.subject.passwordRecovery = ...
yada.email.send.failed = ...
yada.email.passwordrecover.title = ...
yada.email.passwordrecover.message =  ...
if you need higher security, do not acknowledge the validity of the supplied email address but just return the same message whether the email exists or not

The recovery email contains a link to the "/passwordReset/{token}" handler. This handler is application-specific but can use the yadaRegistrationController.passwordResetForm() method to do all the needed work.

@RequestMapping("/passwordReset/{token}")
public String passwordResetPost(@PathVariable String token, Model model, HttpServletRequest request, RedirectAttributes redirectAttributes, Locale locale) {
	// Everything is done in the yada class.
	boolean done = yadaRegistrationController.passwordResetForm(token, model, redirectAttributes);
	if (!done) {
		yadaNotify.titleKey(redirectAttributes, locale, "pwdreset.invalidlink.title").error().messageKey("pwdreset.invalidlink.message").add();
		return yadaWebUtil.redirectString("/passwordReset", locale); // Moved temporarily
	}
	// Don't do a redirect here because you'll lose the Model
	return homeController.home(request, model, locale);
}

Login Process

The login page can either be a full page or a modal, and must contain the login form.

The login form must have a username and a password field. The form must be submitted to the configured loginProcessingUrl endpoint (see SecurityConfig.java) via ajax or not depending on how it was opened (see later).

An example login form/page can be found in the modalLogin.html file created during project initialization. It is a Bootstrap 5 modal but it can also be embedded in a normal page by including the appropriate fragment, for example with

<div th:replace="~{/modalLogin :: #loginForm}">Login form here</div>

The above file shows how to:

  • perform login

  • show a generic login error message

  • show a lockout message when the maximum number of login attempts has been tried

  • show an error message below username or password

  • reveal the typed password by clicking on a button

  • provide a link to the password reset page

The <security> section of the configuration file contains some useful parameters like the required password length or the number of attempts before the user is locked out.

- force password change after login

The login page/modal can either be opened by clicking on a login link or by requesting a protected url: in the latter case the request (ajax or not) will be saved and replayed after successful login.

A POST to a protected url that triggers the login process will be replayed as a GET (this is by Spring Security design) without the original payload. Never allow a post to a protected url unless the user is logged in already.

The login link can either be ajax or normal and could open either a full page or a modal:

  • Opening a login page with a normal request is trivial

  • Opening a login modal with an ajax request is also trivial

  • Opening a modal with a normal request requires landing on some page (usually the home) with a model parameter that triggers the opening of the embedded login modal, e.g. ${login}

  • Opening a normal login page with an ajax request requires that some element in the page (not the body) has the yadafullPage class.

To make things simpler, a normal request should open a normal login page and an ajax request should open a modal. This reflects a common use case.

The login page/modal is automatically opened when a protected url is requested. The request type (ajax/normal) that opens the login page/modal is the same of the initial request, that is saved for later. The provided HomeController.login() method handles both request types and adds a "login" model attribute before returning the home page so that the login modal can be opened via javascript.

The login link must be shown only when the user is not logged in and replaced with the logout link otherwise:

<header th:with="loggedIn=${@yadaSecurityUtil.loggedIn()}">
	<a th:unless="${loggedIn}" th:href="@{/loginForm}" class="yadaAjax">
		Login
	</a>
	<a th:if="${loggedIn}" th:href="@{/logout}">
		Logout
	</a>

The login form must post using the same method (ajax or not) used to load it. This is because if the login process is triggered by accessing a protected url in a normal request, the login form must use a normal POST otherwise the saved request would be redirected to via ajax and may not be shown correctly. If, on the contrary, the process is triggered when the initial request is ajax, the login form must use ajax to POST otherwise the saved request would be loaded non-ajax and shown as a full page.

To achieve this, use the yadaIsAjaxResponse model attribute that is always present when returning from an ajax call:

<form th:action="@{/loginPost}" th:classappend="${yadaIsAjaxResponse}?yadaAjax" ... >

After successful login, the login modal should be closed and any dynamic parts of the page that differ when a user is logged in should be replaced with the correct version: for example the login link should become a logout link.

The easiest way to do so is to reload the entire page, but this can only be done when there is no unsaved data that needs to be kept. The yada.reload() function can do the trick. Otherwise, some javascript should fetch the new page parts from the server and replace them at the correct position. In both cases this can be done in a yada:successHandler of the login form:

<form th:action="@{/loginPost}" yada:successHandler="postLogin" (1)
	th:classappend="${yadaIsAjaxResponse}?yadaAjax"
	role="form" method="post">
...
<script th:inline="javascript">
    function postLogin() {
    	const headerUrl = /*[[@{/justTheHeader}]]*/ "unset";
    	yada.ajax(headerUrl, null, function(responseText, $responseHtml) {
    		$("header").replaceWith($("header", $responseHtml)); (2)
    		$("#myLoginModal").modal("hide"); (3)
    	});
    }
1 postLogin will be called after successful form submission
2 the current page header is replaced by the header as seen by logged-in users
3 the login modal is closed

Another option would be, after login, to redirect to some other page. This can be done by configuring the DefaultTargetUrlAjaxRequest with "/yadaLoginSuccess?targetUrl=/myOtherUrl/" as seen earlier.

As said above, a public page should not contain a form that posts to a protected endpoint. Such form should be shown only to logged-in users. This can be done either by conditionally showing the form or by placing it on a modal that is opened by clicking on a protected link. For example, a "save icon" could be an ajax link that returns a protected modal containing the save form. By clicking on the save icon, the user would first trigger the login process then the save form would be shown in the modal to the now logged-in user.

When using the previous tip, never open from a public page a modal containing a form to a protected page using javascript, because there won’t be a chance to trigger the login process. Call the backend instead as explained.

Login POST

The login POST is handled by YadaUserDetailsService.loadUserByUsername(). Check the source code to see what happens.

Login Success

When the provided credentials are valid and the user is not forbidden from entering, execution goes through YadaAuthenticationFailureHandler that performs some tasks like setting the user login timestamp, resetting the failed attempts counter, setting the user timezone and fixing the saved url (if any) by adding the language path (if configured).

In case the saved request was ajax, the YadaAuthenticationSuccessHandler.AJAX_LOGGEDIN_PARAM is added to it. The name of the request parameter is yadaAjaxJustLoggedIn and can be used in a @Controller. See the ajax documentation for an example. The YadaAuthenticationSuccessHandler.AJAX_LOGGEDIN_HEADER header is also set on the response and can eventually be used in javascript.

Login Failure

When the login POST can’t authenticate the user or throws an exception, execution goes through YadaAuthenticationFailureHandler that sets the following request attributes:

username

string

name typed in the login form, this is always set

loginErrorFlag

boolean

generic login error, this is always set

passwordError

boolean

login failed for a wrong password

userDisabled

boolean

user is disabled regardless of password. This happens when YadaUserCredentials.enabled is false.

credentialsExpiredException

boolean

password is expired regardless of what password is used. This happens when YadaUserCredentials.changePassword is true. Execution is forwarded to "/pwdChange"

lockoutMinutes

long

the number of minutes before a locked account is allowed again

usernameNotFound, password

boolean, string

user not found in database, value of password typed

loginError

boolean

login failed for some other reason

The "boolean" values actually have the value of their name, e.g. ?loginErrorFlag=loginErrorFlag

The first two parameters are always set, the others are mutually exclusive but one is always provided. When the reason for failure is not credentialsExpiredException, execution is forwarded to the endpoint set in YadaSecurityConfig, which is by default the @Controller that opens the login page. This page can then use the above request attributes to display the appropriate message, for example using them in a conditional expression in HTML:

th:if="${loginErrorFlag!=null}"

The credentialsExpiredException flag is set if the user entered valid credentials but its YadaUserCredentials.changePassword flag is true, which can happen if a password reset has been forced by an administrator via a dashboard. In such case, execution is forwarded to "/pwdChange" where different steps can be taken, usually showing the login form. As the originating request is still active, any request parameter can be used, for example "username":

@Autowired RegistrationController registrationController;

@RequestMapping("/pwdChange")
public String pwdChange(String username, Model model, Locale locale) {
	// The user is not logged in yet, and we open the password reset page
	YadaRegistrationRequest yadaRegistrationRequest = new YadaRegistrationRequest();
	return registrationController.passwordReset(username, yadaRegistrationRequest);
}

Credentials Change

Logged-in users should have an option to change their password or even their email address. Changing password is straightforward while changing email address requires sending a confirmation email similar to the password recovery step.

Change password

TODO - see modalPasswordChange.html

Change email

A logged-in user can ask for it email to be changed. A form should be provided for this purpose in a protected user area. When the form is submitted, a confirmation email will be sent to the new email address, containing a confirmation link. When this link is clicked, the new old email address will be replaced with the new one. For extra security you may ask for confirmation by password after the confirmation link is clicked. The link controller should not be protected or the user might be confused about which email address to use for login.

TODO

- checking session expiration
- autologin
- yadaLoginSuccess?targetUrl: why use it when you have a login successHandler that can do that?
- using YadaAuthenticationSuccessHandler.setTargetUrlParameter() to specify the landing page dynamically in the login form
- impersonating users