[JAVA] Create a simple demo site with Spring Security with Spring Boot 2.1

Overview

I wrote an article "Implementing a simple Rest API with Spring Security with Spring Boot 2.0" before, but this time it is simple with a screen. I created a demo site, so I created an article again.

The source code can be found at rubytomato / demo-java12-security.

environment

reference

Main features of the demo site to create

Management of authentication / authorization information

Authentication / authorization information is managed in the following user table.

DROP TABLE IF EXISTS user;
CREATE TABLE user (
  id BIGINT AUTO_INCREMENT                    COMMENT 'User ID',
  name VARCHAR(60) NOT NULL                   COMMENT 'username',
  email VARCHAR(120) NOT NULL                 COMMENT 'mail address',
  password VARCHAR(255) NOT NULL              COMMENT 'password',
  roles VARCHAR(120)                          COMMENT 'roll',
  lock_flag BOOLEAN NOT NULL DEFAULT 0        COMMENT 'Lock flag 1:Lock',
  disable_flag BOOLEAN NOT NULL DEFAULT 0     COMMENT 'Invalid flag 1:Invalid',
  create_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  update_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id)
)
ENGINE = INNODB
DEFAULT CHARSET = UTF8MB4
COMMENT = 'User table';

ALTER TABLE user ADD CONSTRAINT UNIQUE KEY UKEY_user_email (email);

Authentication

Since sign-in uses an email address and password, prepare an email address (email) and password (password) columns to store that information. A UNIQUE KEY is set in the email address column to uniquely identify the account by email address. The password is not plain text and stores the value hashed by the Spring Security password encoder.

Authorization

Access control for some content uses the role of Spring Security. The roles column stores the role strings assigned to the account, separated by commas. Also, the lock flag (lock_flag) is flagged when it freezes temporarily, and the disable flag (disable_flag) is flagged when it freezes permanently. When deleting an account, the data is physically deleted, so there is no deletion flag.

Attributes other than authentication / authorization

In addition, attributes other than authentication / authorization are saved in the user profile table. In this example, the nickname (nick_name) and avatar image (avatar_image) are saved. Avatar images store binary data in BLOB type columns and do not manage the file itself.

DROP TABLE IF EXISTS user_profile;
CREATE TABLE user_profile (
  id BIGINT AUTO_INCREMENT                    COMMENT 'User profile ID',
  user_id BIGINT NOT NULL                     COMMENT 'User ID',
  nick_name VARCHAR(60)                       COMMENT 'nickname',
  avatar_image MEDIUMBLOB                     COMMENT 'Avatar image',
  create_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  update_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id)
)
ENGINE = INNODB
DEFAULT CHARSET = UTF8MB4
COMMENT = 'User profile table';

ALTER TABLE user_profile ADD CONSTRAINT FOREIGN KEY FKEY_user_profile_id_user_id (user_id) REFERENCES user (id);

roll

Some pages have role-based access control. There are two types of rolls to prepare. You can set multiple settings for one account or none at all.

roll Expected use
No roll, authentication is possible without roll
ROLE_USER Roles for general users
ROLE_ADMIN Roles for privileged users

Endpoint list

end point method Authentication roll Figure No Remarks
/ GET no 1 Top page and sign-in page
/menu GET no 2 Menu page
/signup GET no 3 Account registration page
/signup POST no Execution of account registration process, after registration/Redirect to
/signin GET no 4 Sign-in page
/login POST no Sign-in process, endpoint provided by Spring Security, after sign-in/Redirect to
/signout GET yes - 5 Sign out page
/logout POST yes - Sign-out process, endpoints provided by Spring Security, after sign-out/Redirect to
/account/change/password GET yes - 6 Password change page
/account/change/password POST yes - Password change process, after change/Redirect to
/account/change/role GET yes - 7 Role change page
/account/change/role POST yes - Role change processing, after change/Redirect to
/account/change/profile GET yes - 8 Profile change page
/account/change/profile POST yes - Profile change process, after change/Redirect to
/account/delete GET yes - 9 Account deletion page
/account/delete POST yes - Account deletion process, after deletion/Redirect to
/memo GET yes USER, ADMIN USER or ADMIN Role content page
/user GET yes USER USER Role content page
/admin GET yes ADMIN ADMIN Role content page
/error/denied GET no Error page, when access is denied
/error/invalid GET no Error page, session disabled
/error/expired GET no Error page, session expired

Implementation around security

Database access

Use JPA for database access. There is no special implementation related to Spring Security around database access.

entity

The implementation of the entity class corresponding to the user table that manages the authentication / authorization information is as follows.

User


import com.example.demo.auth.UserRolesUtil;
import lombok.Data;
import lombok.ToString;

import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.OneToOne;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import javax.persistence.Table;
import java.time.LocalDateTime;

@Entity
@Table(name = "user")
@Data
@ToString(exclude = {"password"})
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  @Column(name = "name", length = 60, nullable = false)
  private String name;
  @Column(name = "email", length = 120, nullable = false, unique = true)
  private String email;
  @Column(name = "password", length = 255, nullable = false)
  private String password;
  @Column(name = "roles", length = 120)
  private String roles;
  @Column(name = "lock_flag", nullable = false)
  private Boolean lockFlag;
  @Column(name = "disable_flag", nullable = false)
  private Boolean disableFlag;
  @Column(name = "create_at", nullable = false)
  private LocalDateTime createAt;
  @Column(name = "update_at", nullable = false)
  private LocalDateTime updateAt;

  @OneToOne(mappedBy = "user", cascade = CascadeType.ALL)
  private UserProfile userProfile;

  public void setUserProfile(UserProfile userProfile) {
    this.userProfile = userProfile;
    userProfile.setUser(this);
  }

  public String getAvatarImageBase64Encode() {
    return this.userProfile.getAvatarImageBase64Encode();
  }

  @PrePersist
  private void prePersist() {
    this.lockFlag = Boolean.FALSE;
    this.disableFlag = Boolean.FALSE;
    this.createAt = LocalDateTime.now();
    this.updateAt = LocalDateTime.now();
  }

  @PreUpdate
  private void preUpdate() {
    this.updateAt = LocalDateTime.now();
  }

  public static User of(String name, String email, String encodedPassword, String[] roles) {
    return User.of(name, email, encodedPassword, roles, new UserProfile());
  }

  public static User of(String name, String email, String encodedPassword, String[] roles, 
                        UserProfile userProfile) {
    User user = new User();
    user.setName(name);
    user.setEmail(email);
    user.setPassword(encodedPassword);
    String joinedRoles = UserRolesUtil.joining(roles);
    user.setRoles(joinedRoles);
    user.setUserProfile(userProfile);
    return user;
  }

}

UserProfile


import lombok.Data;
import lombok.ToString;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.Lob;
import javax.persistence.OneToOne;
import javax.persistence.PostLoad;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import javax.persistence.Table;
import javax.persistence.Transient;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.Base64;

@Entity
@Table(name = "user_profile")
@Data
@ToString(exclude = {"user", "avatarImage", "avatarImageBase64Encode"})
public class UserProfile {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  @Column(name = "nick_name", length = 60)
  private String nickName;
  @Lob
  @Column(name = "avatar_image")
  private byte[] avatarImage;
  @Column(name = "create_at", nullable = false)
  private LocalDateTime createAt;
  @Column(name = "update_at", nullable = false)
  private LocalDateTime updateAt;

  @OneToOne
  @JoinColumn(name = "user_id", nullable = false)
  private User user;

  @Transient
  private String avatarImageBase64Encode;

  public void setAvatarImage(byte[] avatarImage) {
    this.avatarImage = avatarImage;
    this.avatarImageBase64Encode = base64Encode();
  }

  String getAvatarImageBase64Encode() {
    return avatarImageBase64Encode == null ? "" : avatarImageBase64Encode;
  }

  private String base64Encode() {
    return new String(Base64.getEncoder().encode(avatarImage), StandardCharsets.US_ASCII);
  }

  @PostLoad
  private void init() {
    this.avatarImageBase64Encode = base64Encode();
  }

  @PrePersist
  private void prePersist() {
    this.avatarImage = new byte[0];
    this.createAt = LocalDateTime.now();
    this.updateAt = LocalDateTime.now();
  }

  @PreUpdate
  private void preUpdate() {
    this.updateAt = LocalDateTime.now();
  }

}

Repository

Add a method to search by email address.

UserRepository


import com.example.demo.model.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
  Optional<User> findByEmail(String email);
}

Security configuration

Spring Security configurations inherit from the WebSecurityConfigurerAdapter abstract class. There are three main configurations: ʻAuthenticationManager, WebSecurity, and HttpSecurity`.

import com.example.demo.auth.SimpleUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

  private SimpleUserDetailsService simpleUserDetailsService;
  private PasswordEncoder passwordEncoder;

  @Autowired
  public void setSimpleUserDetailsService(SimpleUserDetailsService simpleUserDetailsService) {
    this.simpleUserDetailsService = simpleUserDetailsService;
  }

  @Autowired
  public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
    this.passwordEncoder = passwordEncoder;
  }

  @Bean
  @Override
  public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
  }

  @Bean
  PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
  }

  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    // ...Configuration omitted(Described below)...
  }

  @Override
  public void configure(WebSecurity web) throws Exception {
    // ...Configuration omitted(Described below)...
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    // ...Configuration omitted(Described below)...
  }

}

UserDetails (principal)

Create the user information class used by Spring Security by implementing the ʻUserDetails and CredentialsContainer interfaces. Overriding the ʻequals and hashCode methods also needs to be done properly. If you do not implement this method properly, session management multiple login checks will not work.

SimpleLoginUser


import com.example.demo.model.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.CredentialsContainer;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Objects;
import java.util.Set;

@Slf4j
public class SimpleLoginUser implements UserDetails, CredentialsContainer {
  private static final long serialVersionUID = -888887602572409628L;

  private final String username;
  private String password;
  private final Set<GrantedAuthority> authorities;
  private final boolean accountNonExpired;
  private final boolean accountNonLocked;
  private final boolean credentialsNonExpired;
  private final boolean enabled;
  private final User user;

  public SimpleLoginUser(User user) {
    if ((Objects.isNull(user.getEmail()) || "".equals(user.getEmail())) ||
        (Objects.isNull(user.getPassword()) || "".equals(user.getPassword()))) {
      throw new IllegalArgumentException(
          "Cannot pass null or empty values to constructor");
    }
    this.username = user.getEmail();
    this.password = user.getPassword();
    this.authorities = UserRolesUtil.toSet(user.getRoles());
    this.accountNonExpired = true;
    this.accountNonLocked = !user.getLockFlag();
    this.credentialsNonExpired = true;
    this.enabled = !user.getDisableFlag();
    this.user = user;
  }

  @Override
  public String getUsername() {
    return this.username;
  }

  @Override
  public String getPassword() {
    return this.password;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return this.authorities;
  }

  @Override
  public boolean isAccountNonExpired() {
    return this.accountNonExpired;
  }

  @Override
  public boolean isAccountNonLocked() {
    return this.accountNonLocked;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return this.credentialsNonExpired;
  }

  @Override
  public boolean isEnabled() {
    return this.enabled;
  }

  @Override
  public void eraseCredentials() {
    this.password = null;
  }

  public User getUser() {
    return user;
  }

  @Override
  public boolean equals(Object rhs) {
    if (!(rhs instanceof SimpleLoginUser)) {
      return false;
    }
    return this.username.equals(((SimpleLoginUser)rhs).username);
  }

  @Override
  public int hashCode() {
    return this.username.hashCode();
  }

}

If you do not implement it specially, it is easy to inherit the reference implementation ʻorg.springframework.security.core.userdetails.User`.

public class SimpleLoginUser extends User {

  public SimpleLoginUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
    super(username, password, authorities);
  }

  public SimpleLoginUser(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
    super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
  }

}

** Received by the handler method of the controller **

You can receive an object of user information class in the handler method of the endpoint that requires authentication. Annotate the received argument with the @AuthenticationPrincipal annotation.

@PostMapping(value = "change/password")
public String changePassword(@AuthenticationPrincipal SimpleLoginUser loggedinUser,
                             @Validated ChangePasswordForm changePasswordForm, BindingResult result, Model model) {

  User user = loggedinUser.getUser();

  //...abridgement...

}

You can also receive the User entity directly.

@AuthenticationPrincipal(expression = "user") User user

** Access from template (Thymeleaf) **

You can access the user information class (SimpleLoginUser) withgetPrincipal ().

${#authentication.getPrincipal()}

or

${#authentication.principal}

You can also access the User entity class.

${#authentication.getPrincipal().user}

or

${#authentication.principal.user}

UserDetailsService

Create specific code that Spring Security acquires the user information (SimpleLoginUser) required for authentication / authorization by implementing the ʻUserDetailsServiceinterface. The only method that must be overridden isloadUserByUsername ()`, which in this example looks up the User entity by email address and generates user information (SimpleLoginUser) based on the User entity.

SimpleUserDetailsService


import com.example.demo.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Slf4j
public class SimpleUserDetailsService implements UserDetailsService {
  private final UserRepository userRepository;

  public SimpleUserDetailsService(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  @Transactional(readOnly = true)
  @Override
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    log.debug("loadUserByUsername(email):[{}]", email);
    return userRepository.findByEmail(email)
        .map(SimpleLoginUser::new)
        .orElseThrow(() -> new UsernameNotFoundException("User not found by email:[" + email + "]"));
  }

}

AuthenticationManager

Override the default Authentication Manager for configuration. AuthenticationManager delegates the actual authentication process to AuthenticationProvider. There are several implementations of AuthenticationProvider, the implementation of the provider that retrieves user information from the database is DaoAuthenticationProvider.

ʻUserDetailsService ()andpasswordEncoder ()are the configurations forDaoAuthenticationProvider. Set ʻuserDetailsService () to the class (SimpleUserDetailsService) that gets user information that implements the ʻUserDetailsService interface, and setpasswordEncoder ()to the password encoder (default isBCryptPasswordEncoder`).

If ʻeraseCredentials (true) is set, the password of the user information class (SimpleLoginUser`) will be cleared null after authentication.

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
  auth
    .eraseCredentials(true)
    // ### DaoAuthenticationConfigurer
    .userDetailsService(simpleUserDetailsService)
    // ### DaoAuthenticationConfigurer
    .passwordEncoder(passwordEncoder);
}

By the way, in the case of authentication that has user information in memory, the implementation is as follows.

auth
  // ### InMemoryUserDetailsManagerConfigurer
  .inMemoryAuthentication()
  .withUser("user")
    .password(passwordEncoder.encode("passxxx"))
    .roles("USER")
  .and()
  .withUser("admin")
    .password(passwordEncoder.encode("passyyy"))
    .roles("ADMIN");

WebSecurity

WebSecurity


@Override
public void configure(WebSecurity web) throws Exception {
  // @formatter:off
  web
    .debug(false)
    // ### IgnoredRequestConfigurer
    .ignoring()
      .antMatchers("/images/**", "/js/**", "/css/**")
  ;
  // @formatter:on
}

HttpSecurity

Performs the main configuration of Spring Security. Since the amount of code (setting) has increased, I have listed them individually, but the outline is as follows.

HttpSecurity


@Override
protected void configure(HttpSecurity http) throws Exception {
  // @formatter:off
  http
    .authorizeRequests()
      //...Request authorization configuration omitted(Described below)...
      .and()
    .exceptionHandling()
      //...Access denied configuration omitted(Described below)...
      .and()
    .formLogin()
      //...Sign-in configuration omitted(Described below)...
      .and()
    .logout()
      //...Sign out configuration omitted(Described below)...
      .and()
    .csrf()
      //...CSRF configuration omitted(Described below)...
      .and()
    .rememberMe()
      //...Remember-Me configuration omitted(Described below)...
      .and()
    .sessionManagement()
      //...Session management configuration omitted(Described below)...
  ;
  // @formatter:on
}

Request authorization

// ### ExpressionUrlAuthorizationConfigurer
.authorizeRequests()
  .mvcMatchers("/", "/signup", "/menu").permitAll()
  .mvcMatchers("/error/**").permitAll()
  .mvcMatchers("/memo/**").hasAnyRole("USER", "ADMIN")
  .mvcMatchers("/account/**").fullyAuthenticated()
  .mvcMatchers("/admin/**").hasRole("ADMIN")
  .mvcMatchers("/user/**").hasRole("USER")
  .anyRequest().authenticated()

permitAll Anyone, including anonymous, is allowed access.

authenticated Authenticated accounts are granted access.

fullyAuthenticated Accounts that are authenticated by methods other than automatic login (Remember-Me) are granted access.

hasRole/hasAnyRole Accounts with the specified role are granted access.

Access denied handling

Set the transition destination URL when access is denied with ʻaccessDeniedPage ()`. If you access a page that requires authentication in an anonymous state, you will not be denied access, but will move to the sign-in page and if authentication is successful, you will be taken to that page.

// ### ExceptionHandlingConfigurer
.exceptionHandling()

  // #accessDeniedUrl: the URL to the access denied page (i.e. /errors/401)
  .accessDeniedPage("/error/denied")

  // #accessDeniedHandler: the {@link AccessDeniedHandler} to be used
  //.accessDeniedHandler(accessDeniedHandler)

AccessDeniedHandler

You can implement and customize ʻAccessDeniedHandler instead of ʻaccessDeniedPage (). The code below is based on ʻorg.springframework.security.web.access.AccessDeniedHandlerImpl`.

private AccessDeniedHandler accessDeniedHandler = (req, res, accessDeniedException) -> {
  if (res.isCommitted()) {
      log.debug("Response has already been committed. Unable to redirect to ");
      return;
  }
  req.setAttribute(WebAttributes.ACCESS_DENIED_403, accessDeniedException);
  res.setStatus(HttpStatus.FORBIDDEN.value());
  RequestDispatcher dispatcher = req.getRequestDispatcher("/error/denied");
  dispatcher.forward(req, res);
};
Error page when denying access
<div class="row justify-content-center" th:if="${SPRING_SECURITY_403_EXCEPTION != null}">
    <div class="col">
        <div class="alert alert-warning" role="alert">
            <h5 class="alert-heading">Access denied</h5>
            <p class="mb-0" th:text="${SPRING_SECURITY_403_EXCEPTION.message}">message</p>
        </div>
    </div>
</div>

access_denied.png

Sign in

Set the URL of the page with the sign-in form in loginPage (). If authentication is successful and true is set in the second argument ofdefaultSuccessUrl (), it will always move to the URL of the first argument. If false is set, it will move to the URL that was going to transition before authentication. If authentication fails, it will transition to the URL set by failureUrl ().

// ### FormLoginConfigurer
.formLogin()
   // #loginPage: the login page to redirect to if authentication is required (i.e."/login")
  .loginPage("/signin")

   // #loginProcessingUrl: the URL to validate username and password
  .loginProcessingUrl("/login")
  .usernameParameter("email")
  .passwordParameter("password")

  // #defaultSuccessUrl: the default success url
  // #alwaysUse: true if the {@code defaultSuccesUrl} should be used after authentication despite if a protected page had been previously visited
  .defaultSuccessUrl("/", false)

  // #successHandler: the {@link AuthenticationSuccessHandler}.
  //.successHandler(successHandler)

  // #authenticationFailureUrl: the URL to send users if authentication fails (i.e."/login?error").
  .failureUrl("/signin?error")

  // #authenticationFailureHandler: the {@link AuthenticationFailureHandler} to use
  //.failureHandler(failureHandler)

  .permitAll()

Instead of defaultSuccessUrl () and failureUrl (), you can implement and customize ʻAuthenticationSuccessHandler and ʻAuthenticationFailureHandler, respectively.

AuthenticationSuccessHandler

private AuthenticationSuccessHandler successHandler = (req, res, auth) -> {
  //Customized processing
};

AuthenticationFailureHandler

private AuthenticationFailureHandler failureHandler = (req, res, exception) -> {
  //Customized processing
};
Form

The action of the sign-in form will be the URL set in loginProcessingUrl (). In addition to the e-mail address and password required for authentication, there is a check box to select whether to automatically log in with the REMEMBER-ME function.

<form class="text-center" action="#" th:action="@{/login}" method="post">
    <div class="md-form">
        <input type="text" id="email" name="email" class="form-control">
        <label for="email">E-mail</label>
    </div>
    <div class="md-form">
        <input type="password" id="password" name="password" class="form-control">
        <label for="password">Password</label>
    </div>
    <div class="d-flex justify-content-around">
        <div>
            <div class="custom-control custom-checkbox">
                <input type="checkbox" id="remember-me" name="remember-me" value="on" class="custom-control-input">
                <label for="remember-me" class="custom-control-label">Remember me</label>
            </div>
        </div>
        <div>
            <p>Not a member? <a href="/app/signup" th:href="@{/signup}">Register</a></p>
        </div>
    </div>
    <button class="btn indigo accent-4 text-white btn-block my-4" type="submit">Sign in</button>
</form>

signin_form.png

Message display when authentication fails

If authentication fails, redirect to the sign-in page with the parameter ʻerror (/ signin? Error). In addition, Spring Security sets an object of AuthenticationException class (or a class that inherits it, the exception thrown when the authentication information is incorrect is BadCredentialsException) with the name SPRING_SECURITY_LAST_EXCEPTION` in the session attribute.

In the template using Thymeleaf, the message is displayed from these two pieces of information.

<div class="row justify-content-center" th:if="${param['error'] != null && session['SPRING_SECURITY_LAST_EXCEPTION'] != null}">
    <div class="col">
        <div class="alert alert-warning" role="alert">
            <h5 class="alert-heading">certification failed</h5>
            <p class="mb-1" th:text="${session['SPRING_SECURITY_LAST_EXCEPTION'].message}">message</p>
        </div>
    </div>
</div>

signin_form_error.png

Message display when the account is locked

If you try to sign in with 1 set in the lock_flag of the user table, the message shown in the figure will be displayed.

lock.png

Note that these flags are validated at sign-in and are not affected by flagging the logged-in account.

Sign out

Sign out is done using the form on the sign out page (/ signout). This is because if CSRF is enabled, logout (/ logout) must also be requested by POST.

If the sign-out is successful, you will be redirected to the top page (/), and the session will be changed and cookies will be deleted.

// ### LogoutConfigurer
.logout()
  // #logoutUrl: the URL that will invoke logout.
  .logoutUrl("/logout")

  // #logoutSuccessUrl: the URL to redirect to after logout occurred
  //.logoutSuccessUrl("/")

  // #logoutSuccessHandler: the {@link LogoutSuccessHandler} to use after a user
  .logoutSuccessHandler(logoutSuccessHandler)

  // #invalidateHttpSession: true if the {@link HttpSession} should be invalidated (default), or false otherwise.
  .invalidateHttpSession(false)

  // #cookieNamesToClear: the names of cookies to be removed on logout success.
  .deleteCookies("JSESSIONID", "XSRF-TOKEN")

By the way, instead of LogoutSuccessHandler () in the configuration, you can log out with GET by implementing as follows.

//.logoutSuccessHandler(logoutSuccessHandler)
.logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))

LogoutSuccessHandler

Implement the process after successful logout with LogoutSuccessHandler (). In this example, the session ID is changed and redirected to the top page.

private LogoutSuccessHandler logoutSuccessHandler = (req, res, auth) -> {
  if (res.isCommitted()) {
    log.debug("Response has already been committed. Unable to redirect to ");
    return;
  }
  if (req.isRequestedSessionIdValid()) {
    log.debug("requestedSessionIdValid session id:{}", req.getRequestedSessionId());
    req.changeSessionId();
  }
  RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
  redirectStrategy.sendRedirect(req, res, "/");
};

This time I implemented a handler, but you can also specify the transition destination URL after logout with logoutSuccessUrl () in the configuration.

// #logoutSuccessUrl: the URL to redirect to after logout occurred
.logoutSuccessUrl("/")
Form

The action of the signout form will be the URL set in logoutUrl ().

<form class="text-center" action="#" th:action="@{/logout}" method="post">
    <button class="btn indigo accent-4 text-white btn-block my-4" type="submit">Sign out</button>
</form>

signout_form.png

CSRF

When CSRF is enabled, the CSRF token is automatically set hidden in the POST method form. The default save destination of the CSRF token is session, but the following implementation changes the save destination to cookie.

// ### CsrfConfigurer
.csrf()
  .csrfTokenRepository(new CookieCsrfTokenRepository())

Example) The parameter name is _csrf.

<input type="hidden" name="_csrf" value="18331a72-184e-4651-ae0b-e044283a20b3">

Example) The cookie name of the CSRF token is XSRF_TOKEN. By default, it has the HttpOnly attribute.

xsrf-token.png

You can change the cookie name with the code below.

CookieCsrfTokenRepository customCsrfTokenRepository() {
  CookieCsrfTokenRepository cookieCsrfTokenRepository = new CookieCsrfTokenRepository();
  cookieCsrfTokenRepository.setCookieName("NEW-TOKEN-NAME");
  return cookieCsrfTokenRepository;
}

Remember-Me

When you check Remember-Me on the sign-in form and sign in, a cookie named REMEMBER ME will be issued. If you access the demo site with this cookie, you will be automatically authenticated if you are anonymous.

In addition, there are ʻauthenticated and fully Authenticated in the authentication status of Spring Security, and when automatically logged in with Remember-Me, it is identified as ʻauthenticated and RememberMe, and in the case of forms authentication, ʻauthenticated and Identified as fullyAuthenticated`, you can distinguish between automatic login and forms authentication.

// ### RememberMeConfigurer
.rememberMe()
  // #alwaysRemember: set to true to always trigger remember me, false to use the remember-me parameter.
  .alwaysRemember(false)
  // #rememberMeParameter: the HTTP parameter used to indicate to remember the user
  .rememberMeParameter("remember-me")
  // #useSecureCookie: set to {@code true} to always user secure cookies, {@code false} to disable their use.
  .useSecureCookie(true)
  // #rememberMeCookieName: the name of cookie which store the token for remember
  .rememberMeCookieName("REMEMBERME")
  // # Allows specifying how long (in seconds) a token is valid for
  .tokenValiditySeconds(daysToSeconds(3))
  // #key: the key to identify tokens created for remember me authentication
  .key("PgHahck5y6pz7a0Fo#[G)!kt")
Distinguishing between authenticated and fully authenticated

Form authentication is required to access / account / ** if the request authorization configuration is as follows. If you try to access with automatic login by Remember-Me, you will be taken to the sign-in page and you need to explicitly authenticate the form.

.authorizeRequests()
  .mvcMatchers("/account/**").fullyAuthenticated()
  .mvcMatchers("/admin/**").hasRole("ADMIN")
  .mvcMatchers("/user/**").hasRole("USER")
  .anyRequest().authenticated()

In addition, the control that has a certain role and does not allow it unless it is forms authentication is implemented as follows. In this example, access to / admin / ** is allowed if you have the ADMIN role and forms authentication.

  .mvcMatchers("/admin/**").access("hasRole('ADMIN') and isFullyAuthenticated()")

Session management

// ### SessionManagementConfigurer
.sessionManagement()

  .sessionFixation()
    .changeSessionId()

  // #invalidSessionUrl: the URL to redirect to when an invalid session is detected
  .invalidSessionUrl("/error/invalid")

  // ### ConcurrencyControlConfigurer
  // #maximumSessions: the maximum number of sessions for a user
  .maximumSessions(1)
    // #maxSessionsPreventsLogin: true to have an error at time of authentication, else false (default)
    .maxSessionsPreventsLogin(false)
    // #expiredUrl: the URL to redirect to
    .expiredUrl("/error/expired")
Redirect destination when session is invalid

Set the transition destination URL with ʻinvalidSessionUrl ()` when the session is invalid, that is, when the session cookie is deleted, the session ID is tampered with, or there is no session information on the server side.

Session expiration date

You can set the session expiration date in the application configuration file. If you set as below, the session will be invalid if there is no operation for 30 minutes or more, and if you operate after that, you will be redirected to the URL set by ʻinvalidSessionUrl ()`.

server:
  servlet:
    session:
      timeout: 30m

If both ʻinvalidSessionUrl () and ʻexpiredUrl () are set, ʻinvalidSessionUrl ()` seems to take precedence.

Control of multiple logins

If you want to control multiple logins with the same account, you can control the number of simultaneous sessions with maximumSessions () and maxSessionsPreventsLogin ().

Use maximumSessions () to set the number of sessions that can be logged in at the same time, and maxSessionsPreventsLogin () to set the control behavior.

maxSessionsPreventsLogin behavior
true You cannot log in beyond maximum Sessions as long as the session you logged in to first is valid.
false (default) Sessions logged in later become valid, and sessions that exceed maximum Sessions among the sessions logged in earlier become invalid

Implementation of account registration and deletion

Account registration and deletion cannot be set in the Spring Security configuration, so it will be implemented from scratch.

account registration

The implementation of this demo site creates an account as soon as you register for an account. Normally, it is necessary to take the step of temporarily registering without registering suddenly, sending an activation email to the email address at the time of temporary registration, and then performing the main registration by the user activating.

Form

This is a form for registering an account.

<form class="text-center" action="#" th:action="@{/signup}" th:object="${signupForm}" method="post">
    <div class="md-form">
        <input id="username" class="form-control" type="text" name="username" th:field="*{username}">
        <label for="username">username</label>
        <div th:if="${#fields.hasErrors('username')}" th:errors="*{username}" class="text-danger">error</div>
    </div>
    <div class="md-form">
        <input id="email" class="form-control" type="text" name="email" th:field="*{email}">
        <label for="email">email</label>
        <div th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="text-danger">error</div>
    </div>
    <div class="md-form">
        <input id="password" class="form-control" type="text" name="password" th:field="*{password}">
        <label for="password">password</label>
        <div th:if="${#fields.hasErrors('password')}" th:errors="*{password}" class="text-danger">
            error
        </div>
    </div>
    <div class="md-form">
        <input id="repassword" class="form-control" type="text" name="repassword" th:field="*{repassword}">
        <label for="repassword">(re) password</label>
        <div th:if="${#fields.hasErrors('repassword')}" th:errors="*{repassword}" class="text-danger">error</div>
    </div>
    <div class="text-left justify-content-start">
        <p>roles</p>
        <div class="custom-control custom-checkbox">
            <input id="roles_1" class="custom-control-input" type="checkbox" name="roles" value="ROLE_USER" th:field="*{roles}">
            <label for="roles_1" class="custom-control-label">ROLE_USER</label>
        </div>
        <div class="custom-control custom-checkbox">
            <input id="roles_2" class="custom-control-input" type="checkbox" name="roles" value="ROLE_ADMIN" th:field="*{roles}">
            <label for="roles_2" class="custom-control-label">ROLE_ADMIN</label>
        </div>
        <div th:if="${#fields.hasErrors('roles')}" th:errors="*{roles}" class="text-danger">error</div>
    </div>
    <button class="btn indigo accent-4 text-white btn-block my-4" type="submit">Sign up</button>
</form>

signup_form.png

registration process

An implementation of the account service class. DI the repository for database access, PasswordEncoder for password hashing, and AuthenticationManager for authentication in the constructor.

@Service
@Slf4j
public class AccountServiceImpl implements AccountService {
  private final UserRepository userRepository;
  private final PasswordEncoder passwordEncoder;
  private final AuthenticationManager authenticationManager;

  public AccountServiceImpl(UserRepository userRepository, PasswordEncoder passwordEncoder, AuthenticationManager authenticationManager) {
    this.userRepository = userRepository;
    this.passwordEncoder = passwordEncoder;
    this.authenticationManager = authenticationManager;
  }

  @Transactional
  @Override
  public void register(String name, String email, String rawPassword, String[] roles) {
    log.info("user register name:{}, email:{}, roles:{}", name, email, roles);
    String encodedPassword = passwordEncoder.encode(rawPassword);
    User storedUser = userRepository.saveAndFlush(User.of(name, email, encodedPassword, roles));
    authentication(storedUser, rawPassword);
  }

  private void authentication(User user, String rawPassword) {
    log.info("authenticate user:{}", user);
    SimpleLoginUser loginUser = new SimpleLoginUser(user);
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, rawPassword, loginUser.getAuthorities());
    authenticationManager.authenticate(authenticationToken);
    if (authenticationToken.isAuthenticated()) {
      SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    } else {
      throw new RuntimeException("login failure");
    }
  }

}

** Password hashing ** The password uses the value hashed by the Spring Security password encoder.

String encodedPassword = passwordEncoder.encode(rawPassword);

** Entity persistence ** Account data persistence uses repositories. The User entity to be persisted will be used in the subsequent authentication process.

User storedUser = userRepository.saveAndFlush(User.of(name, email, encodedPassword, roles));

** Authentication ** Programmatically authenticate with the registered account information. Create an object of user information (SimpleLoginUser) used by Spring Security from the User entity to be persisted. In addition, it will generate an authentication token from that user information object.

The first argument of the constructor for ʻUsernamePasswordAuthenticationToken is the principalobject (ie user information), the second argument is thecredentials object (ie password), and the third argument is ʻauthorities.

SimpleLoginUser loginUser = new SimpleLoginUser(user);
UsernamePasswordAuthenticationToken authenticationToken =
  new UsernamePasswordAuthenticationToken(loginUser, rawPassword, loginUser.getAuthorities());

Authenticate with the generated authentication token, and if successful, set the authentication token in SecurityContextHolder. This completes account registration and authentication, and the subsequent operations are the same as when the form is authenticated.

authenticationManager.authenticate(authenticationToken);
if (authenticationToken.isAuthenticated()) {
  SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} else {
  throw new RuntimeException("authenticate failure user:[" + user.toString() + "]");
}

Account deletion

Form

Since there is no information to enter from the form, it will be a form with only a submit button. (CSRF tokens are set automatically.)

<form class="text-center" action="#" th:action="@{/account/delete}" th:object="${deleteForm}" method="post">
    <button class="btn indigo accent-4 text-white btn-block my-4" type="submit">Delete</button>
</form>

delete_form.png

Delete process

In the account service class, the process is just to delete the User entity, and the authentication information is destroyed on the calling controller side.

@Service
@Slf4j
public class AccountServiceImpl implements AccountService {
  //...abridgement...

  @Transactional
  @Override
  public void delete(final User user) {
    log.info("delete user:{}", user);
    userRepository.findById(user.getId())
        .ifPresentOrElse(findUser -> {
              userRepository.delete(findUser);
              userRepository.flush();
            },
            () -> {
              log.error("user not found:{}", user);
            });
  }

}

Authentication information is destroyed (session is destroyed, cookie is deleted) by performing logout processing.

@PostMapping(value = "delete")
public String delete(@AuthenticationPrincipal SimpleLoginUser user, HttpServletRequest request,
                     DeleteForm deleteForm) {
  log.info("delete form user:{}", user.getUser());
  accountService.delete(user.getUser());
  try {
    request.logout();
  } catch (ServletException e) {
    throw new RuntimeException("delete and logout failure", e);
  }
  return "redirect:/";
}

Supplement

Spring Security extension of thymeleaf

<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

namespace

<html lang="ja"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

Anonymous user judgment

<p sec:authorize="isAnonymous()">anonymous user</p>
<p sec:authorize="!isAnonymous()">non anonymous user</p>

Authentication user judgment

<p sec:authorize="isAuthenticated()">authenticated user</p>
<p sec:authorize="!isAuthenticated()">non authenticated user</p>

Judgment of users with roles

<p sec:authorize="hasRole('USER')">USER role authenticated user</p>

Output user information

<p sec:authentication="name">name</p>

Or

<p sec:authentication="principal.username">username</p>

The principal in this case is an instance of the user information class (SimpleLoginUser).

Another way of writing

In addition to the above description method, you can also use the expression utility objects #authorization and #authentication.

#authorization

A utility object that checks the authentication status.

<p th:if=${#authorization.expression('isAnonymous()')}></p>

#authentication

Represents a Spring Security authentication object.

<p th:text="${#authentication.name}">name</p>

Page screenshot

I used Bootstrap 4.3.1 and Material Design for Bootstrap 4.8.10 as the CSS framework.

** No.1 Top ** 1_top.png

Debug information is output on the top page. In particular, the status is displayed with the following code so that the user accessing the top page can check which authentication status it is in.

<tr>
    <th scope="row">Anonymous</th>
    <th:block th:switch="${#authorization.expression('isAnonymous()')}">
        <td th:case="${true}"><span class="badge badge-info">yes</span></td>
        <td th:case="${false}">no</td>
    </th:block>
</tr>
<tr>
    <th>Authenticated</th>
    <th:block th:switch="${#authorization.expression('isAuthenticated()')}">
        <td th:case="${true}"><span class="badge badge-info">yes</span></td>
        <td th:case="${false}">no</td>
    </th:block>
</tr>
<tr>
    <th>FullyAuthenticated</th>
    <th:block th:switch="${#authorization.expression('isFullyAuthenticated()')}">
        <td th:case="${true}"><span class="badge badge-info">yes</span></td>
        <td th:case="${false}">no</td>
    </th:block>
</tr>
<tr>
    <th>RememberMe</th>
    <th:block th:switch="${#authorization.expression('isRememberMe()')}">
        <td th:case="${true}"><span class="badge badge-info">yes</span></td>
        <td th:case="${false}">no</td>
    </th:block>
</tr>

** No.2 menu ** 2_menu.png

** No.3 sign up ** 3_signup.png

** No.4 sign-in ** 4_signin.png

** No.5 Sign Out ** 5_signout.png

** No.6 Password Change ** 6_password_change.png

** No.7 role change ** 7_role_change.png

** No.8 Profile change ** 8_profile_change.png

** No.9 Account Delete ** 9_delete.png

Recommended Posts

Create a simple demo site with Spring Security with Spring Boot 2.1
Create a simple search app with Spring Boot
Implement a simple Rest API with Spring Security & JWT with Spring Boot 2.0
Create a simple on-demand batch with Spring Batch
Create a website with Spring Boot + Gradle (jdk1.8.x)
Create a web api server with spring boot
Create a Spring Boot development environment with docker
Create Spring Cloud Config Server with security with Spring Boot 2.0
Create microservices with Spring Boot
Create an app with Spring Boot 2
Create an app with Spring Boot
Let's make a simple API with EC2 + RDS + Spring boot ①
Implement a simple Web REST API server with Spring Boot + MySQL
Create a simple web application with Dropwizard
Create a simple bar chart with MPAndroidChart
I made a simple search form with Spring Boot + GitHub Search API.
Achieve BASIC authentication with Spring Boot + Spring Security
Create a Spring Boot app development project with the cURL + tar command
Steps to create a simple camel app using Apache Camel Spring Boot starters
Hash passwords with Spring Boot + Spring Security (with salt, with stretching)
Create a simple bulletin board with Java + MySQL
Create a Spring Boot application using IntelliJ IDEA
Create CRUD apps with Spring Boot 2 + Thymeleaf + MyBatis
Create your own Utility with Thymeleaf with Spring Boot
Create Spring Boot environment with Windows + VS Code
[Introduction to Spring Boot] Authentication function with Spring Security
Download with Spring Boot
Create a portfolio app using Java and Spring Boot
Create an EC site with Rails 5 ⑨ ~ Create a cart function ~
How to create a Spring Boot project in IntelliJ
[Spring Boot] How to create a project (for beginners)
Spring Boot with Spring Security Filter settings and addictive points
[JUnit 5] Write a validation test with Spring Boot! [Parameterization test]
A memorandum when creating a REST service with Spring Boot
Create Restapi with Spring Boot ((1) Until Run of App)
I wrote a test with Spring Boot + JUnit 5 now
I tried to create a shopping site administrator function / screen with Java and Spring
Generate barcode with Spring Boot
Hello World with Spring Boot
Implement GraphQL with Spring Boot
Get started with Spring boot
Run LIFF with Spring Boot
SNS login with Spring Boot
File upload with Spring Boot
Spring Boot starting with copy
Create a playground with Xcode 12
Login function with Spring Security
Hello World with Spring Boot
Set cookies with Spring Boot
Use Spring JDBC with Spring Boot
Add module with Spring Boot
Getting Started with Spring Boot
Try using Spring Boot Security
Send email with spring boot
Create a simple CRUD with SpringBoot + JPA + Thymeleaf ③ ~ Add Validation ~
A story packed with the basics of Spring Boot (solved)
Create a Hello World web app with Spring framework + Jetty
I made a simple MVC sample system using Spring Boot
Create a simple DRUD application with Java + SpringBoot + Gradle + thymeleaf (1)
A new employee tried to create an authentication / authorization function from scratch with Spring Security
Create a simple web server with the Java standard library com.sun.net.httpserver