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
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);
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.
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.
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);
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 |
no
are allowed to access anonymously (Anonymous).yes
are allowed access only to authenticated accounts. If a role is specified, access is permitted if the account to be accessed is granted the role.-
is authenticated, access is permitted.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 |
Use JPA for database access. There is no special implementation related to Spring Security around database access.
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();
}
}
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);
}
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 is
loadUserByUsername ()`, 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 ()and
passwordEncoder ()are the configurations for
DaoAuthenticationProvider. Set ʻuserDetailsService ()
to the class (SimpleUserDetailsService
) that gets user information that implements the ʻUserDetailsService interface, and set
passwordEncoder ()to the password encoder (default is
BCryptPasswordEncoder`).
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
}
// ### 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.
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);
};
<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>
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
};
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>
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>
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.
Note that these flags are validated at sign-in and are not affected by flagging the logged-in account.
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("/")
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>
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.
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")
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()")
// ### 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")
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.
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.
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 |
Account registration and deletion cannot be set in the Spring Security configuration, so it will be implemented from scratch.
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.
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>
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 the
credentials 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() + "]");
}
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>
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:/";
}
<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
).
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>
I used Bootstrap 4.3.1 and Material Design for Bootstrap 4.8.10 as the CSS framework.
** No.1 Top **
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 **
** No.3 sign up **
** No.4 sign-in **
** No.5 Sign Out **
** No.6 Password Change **
** No.7 role change **
** No.8 Profile change **
** No.9 Account Delete **
Recommended Posts