Spring security with thymeleaf

记录spring boot项目使用spring security的核心配置和相关组件。要点:

  1. 支持自定义页面登录
  2. 支持AJAX登录/登出
  3. 支持RBAC权限控制
  4. 支持增加多种认证方式
  5. 支持集群部署(会话共享redis存储)
  6. 支持SessionId放在Header的X-Auth-Token里

项目依赖 pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>

相关参考:关于redis 关于thymeleaf

Security配置类 SecurityConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import java.util.Arrays;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDecisionManager;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.vote.AuthenticatedVoter;
import org.springframework.security.access.vote.UnanimousBased;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.web.access.expression.WebExpressionVoter;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.session.web.http.HttpSessionIdResolver;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthProviderUsernamePassword authProviderUsernamePassword;
@Autowired
private AuthSuccessHandler authSuccessHandler;
@Autowired
private AuthFailureHandler authFailureHandler;
@Autowired
private ExitSuccessHandler exitSuccessHandler;

@Bean
protected AuthenticationFailureHandler authenticationFailureHandler() {
authFailureHandler.setDefaultFailureUrl("/login?error");
return authFailureHandler;
}

@Bean
protected LogoutSuccessHandler logoutSuccessHandler() {
exitSuccessHandler.setDefaultTargetUrl("/login?logout");
return exitSuccessHandler;
}

private static String[] INGORE_URLS = {"/login", "/error",};

@Override
public void configure(WebSecurity webSecurity) {
webSecurity.ignoring().antMatchers("/static/**");//忽略静态资源
webSecurity.ignoring().antMatchers("/favicon.ico");
}

@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeRequests()
.antMatchers(INGORE_URLS).permitAll()
.anyRequest().authenticated()
.accessDecisionManager(accessDecisionManager())//如果不需要权限验证,去掉这句即可
.and()
.formLogin()
.successHandler(authSuccessHandler)
.failureHandler(authFailureHandler)
.loginPage("/login")//.permitAll()
.and()
.logout()
.logoutSuccessHandler(logoutSuccessHandler())//.permitAll()
//.and().rememberMe()
.and().csrf().disable();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authProviderUsernamePassword);
//auth.authenticationProvider(authProvider2);可以增加多个认证方式,比如码验证等
}

@Bean
protected AccessDecisionManager accessDecisionManager() {
List<AccessDecisionVoter<? extends Object>> decisionVoters = Arrays.asList(
new WebExpressionVoter(),
authDecisionVoter(),//new RoleVoter(),
new AuthenticatedVoter());
return new UnanimousBased(decisionVoters);
}

@Bean
protected AuthDecisionVoter authDecisionVoter() {
return new AuthDecisionVoter();
}

@Bean
public HttpSessionIdResolver httpSessionIdResolver() {
return new HeaderCookieHttpSessionIdResolver();
}
}

登录认证类 AuthProviderUsernamePassword.java

AuthenticationProvider提供用户认证的处理方法。如果有多种认证方式,可以实现多个类一并添加到AuthenticationManagerBuilder里即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;

@Component
public class AuthProviderUsernamePassword implements AuthenticationProvider {
@Autowired
AuthUserService authUserService;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
AuthUser userDetails = authUserService.loadUserByUsername(username);
if(userDetails == null){
throw new BadCredentialsException("账号或密码错误");
}
if (!authUserService.checkPassword(userDetails, password)) {
throw new BadCredentialsException("账号或密码不正确");
}
//认证校验通过后,封装UsernamePasswordAuthenticationToken返回
return new UsernamePasswordAuthenticationToken(userDetails, password, authUserService.fillUserAuthorities(userDetails));
}

@Override
public boolean supports(Class<?> authentication) {
return true;
}
}

登录成功处理 AuthSuccessHandler.java

配置于formLogin().successHandler(),可选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;

@Component
public class AuthSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private RequestCache requestCache = new HttpSessionRequestCache();

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws ServletException, IOException {
//登录成功处理,比如记录登录日志
String ip = request.getRemoteAddr();
String targetUrl = "";
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) {
targetUrl = savedRequest.getRedirectUrl();
}
AuthUser aUser = (AuthUser) authentication.getPrincipal();
System.out.printf("User %s login, ip: %s, url: ", aUser.getUsername(), ip, targetUrl);

if (WebUtils.isAjaxReq(request)) {//ajax登录
response.sendError(200, "success");
return;
}
super.onAuthenticationSuccess(request, response, authentication);
}
}

登录成功处理 AuthFailureHandler.java

配置于formLogin().failureHandler(),可选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;

@Component
public class AuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String uaSummary = WebUtils.getUserAgentSummary(request);
String ip = request.getRemoteAddr();
String username = request.getParameter("username");
System.out.printf("User %s login failed, ip: %s, ua: %s", username, ip, uaSummary);
super.saveException(request, exception);
if (WebUtils.isAjaxReq(request)) {//ajax登录
//为什么用sendError会导致302重定向到login页面?
//--When you invoke sendError it will dispatch the request to /error (it the error handling code registered by Spring Boot. However, Spring Security will intercept /error and see that you are not authenticated and thus redirect you to a log in form.
response.sendError(403, exception.getMessage());
return;
}
response.sendRedirect("login?error");
}
}

登出成功处理 ExitSuccessHandler.java

配置于logout().logoutSuccessHandler(),可选。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import org.springframework.stereotype.Component;

@Component
public class ExitSuccessHandler extends SimpleUrlLogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
if (WebUtils.isAjaxReq(request)) {//ajax登录
response.sendError(200, "success");
return;
}
super.onLogoutSuccess(request, response, authentication);
}
}

解析SessionId的类 HeaderCookieHttpSessionIdResolver.java

增加优先从Header里找X-Auth-Token作为SessionId,以适应不支持Cookie的情况。
这个类就是把CookieHttpSessionIdResolver和HeaderHttpSessionIdResolver柔和在一起而已。
对应配置@Bean httpSessionIdResolver。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.session.web.http.CookieHttpSessionIdResolver;
import org.springframework.session.web.http.HeaderHttpSessionIdResolver;
import org.springframework.session.web.http.HttpSessionIdResolver;

public class HeaderCookieHttpSessionIdResolver implements HttpSessionIdResolver {
protected HeaderHttpSessionIdResolver headerResolver = HeaderHttpSessionIdResolver.xAuthToken();
protected CookieHttpSessionIdResolver cookieResolver = new CookieHttpSessionIdResolver();

@Override
public List<String> resolveSessionIds(HttpServletRequest request) {
List<String> sessionIds = headerResolver.resolveSessionIds(request);
if (sessionIds.isEmpty()) {
sessionIds = cookieResolver.resolveSessionIds(request);
}
return sessionIds;
}

@Override
public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) {
headerResolver.setSessionId(request, response, sessionId);
cookieResolver.setSessionId(request, response, sessionId);
}

@Override
public void expireSession(HttpServletRequest request, HttpServletResponse response) {
headerResolver.expireSession(request, response);
cookieResolver.expireSession(request, response);
}
}

认证用户类 AuthUser.java

用户实体类,实现UserDetails接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
import javax.persistence.Id;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import lombok.Data;

@Data
public class AuthUser implements UserDetails, Serializable {
private static final long serialVersionUID = -1572872798317304041L;

@Id
private Long id;
private String username;
private String password;

private Collection<? extends GrantedAuthority> authorities;

public Collection<? extends GrantedAuthority> fillPerms(List<String> perms) {
String authorityString = StringUtils.collectionToCommaDelimitedString(perms);
authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(authorityString);
return authorities;
}

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

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}

认证用户服务类 AuthUserService.java

提供根据用户名获取用户的方法loadUserByUsername();提供用户的权限fillUserAuthorities()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.joda.time.LocalDateTime;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class AuthUserService implements UserDetailsService {
@Override
public AuthUser loadUserByUsername(String username) throws UsernameNotFoundException {
//读取用户,一般是从数据库读取,这里随便new一个
AuthUser user = new AuthUser();// userDao.findByUsername(username);
user.setId(System.currentTimeMillis());
user.setUsername(username);
user.setPassword(username);
return user;
}

public boolean checkPassword(AuthUser user, String pwd) {
//判断用户密码,这里简单判断相等
if (pwd != null && pwd.equals(user.getPassword())) {
return true;
}
return false;
}

public Collection<? extends GrantedAuthority> fillUserAuthorities(AuthUser aUser) {
//获取用户权限,一般从数据库读取,并缓存。这里随便拼凑
List<String> perms = new ArrayList<>(); //permDao.findPermByUserId(aUser.getId());
LocalDateTime now = LocalDateTime.now();
perms.add("P"+now.getHourOfDay());
perms.add("P"+now.getMinuteOfHour());
perms.add("P"+now.getSecondOfMinute());
return aUser.fillPerms(perms);
}
}

模拟用户示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"id": 1598515192490,
"username": "test",
"password": "test",
"authorities": [{
"authority": "P15"
}, {
"authority": "P59"
}, {
"authority": "P52"
}
]
}

认证入口 AuthControll.java

这里提供loginPage配置的路径”/login”。如果暂不想自定义登录界面,去掉loginPage配置即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class AuthController {
@RequestMapping("/login")//登录入口
String login(String username, Model model) {
model.addAttribute("username", username);
return "login";
}

@RequestMapping("/")//主页
@ResponseBody
Object home(@AuthenticationPrincipal AuthUser currentUser) {
return currentUser;
}

@RequestMapping("/{path}")//测试用
@ResponseBody
Object url1(@PathVariable String path) {
if (path.contains("0")) {//模拟错误
path = String.valueOf(1/0);
}
return path;
}
}

权限验证类 AuthDecisionVoter.java

配置AccessDecisionManager用于自定义权限验证投票器。验证的前提是获取待访问资源(url)相关的权限(getPermissionsByUrl)。验证的方法是,看用户所拥有的权限是否能够匹配url的权限。

Spring security另一种常用的权限控制方式是配置@EnableGlobalMethodSecurity(prePostEnabled = true),在方法上使用@PreAuthorize(“hasPermission(‘PXX’)”)。但用这种方法注解的url,不支持用在thymeleaf模板的sec:authorize-url中。

ps1.thymeleaf 提供了前端判断权限的扩展,参见 thymeleaf-extras-springsecurity & thymeleaf sec:标签的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.SecurityConfig;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.FilterInvocation;
import org.springframework.util.StringUtils;

public class RbacDecisionVoter implements AccessDecisionVoter<Object> {
static final String permitAll = "permitAll";

@Override
public boolean supports(ConfigAttribute attribute) {
return true;
}

@Override
public boolean supports(Class<?> clazz) {
return true;
}

@Override
public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
if (authentication == null) {
return ACCESS_DENIED;
}

if (attributes != null) {
for (ConfigAttribute attribute : attributes) {
if (permitAll.equals(attribute.toString())) {// skip permitAll
return ACCESS_ABSTAIN;
}
}
}

String requestUrl = ((FilterInvocation) object).getRequestUrl();// 当前请求的URL
Collection<ConfigAttribute> urlPerms = getPermissionsByUrl(requestUrl);// 能访问URL的权限
if (urlPerms == null || urlPerms.isEmpty()) {
return ACCESS_ABSTAIN;
}

int result = ACCESS_ABSTAIN;
Collection<? extends GrantedAuthority> userAuthorities = authentication.getAuthorities(); // 当前用户的权限
for (ConfigAttribute attribute : urlPerms) {
String urlPerm = attribute.getAttribute();
if (StringUtils.isEmpty(urlPerm)) {
continue;
}

result = ACCESS_DENIED;
// Attempt to find a matching granted authority
for (GrantedAuthority authority : userAuthorities) {
if (urlPerm.equals(authority.getAuthority())) {
return ACCESS_GRANTED;
}
}
}
return result;
}

Collection<ConfigAttribute> getPermissionsByUrl(String url) {
// 获取url的访问权限,一般从数据库读取,并缓存。这里随便拼凑
if ("/".equals(url)) {
return null;//根路径不限权
}
String n1 = url.substring(url.length()-1);
String n2 = url.substring(url.length()-2);
return SecurityConfig.createList("P"+n1, "P"+n2);
}
}

自定义登录界面 login.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!DOCTYPE html>
<html lang="zh" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<title>登录</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"/>
<link rel="stylesheet" href="//cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css"/>
<style type="text/css">
body{padding-top:40px; padding-bottom:40px; background-color:#eee;}
.form-signin{max-width:330px; padding:15px; margin:0 auto;}
</style>
</head>
<body>
<div id="root" class="container">
<form class="form-signin" method="post" th:action="@{/login}">
<h2 class="form-signin-heading">请登录</h2>
<div th:if="${param.logout}" class="alert alert-success" role="alert"><span>您已退出登录</span></div>
<div th:if="${param.error}" class="alert alert-danger" role="alert"><span th:utext="${session['SPRING_SECURITY_LAST_EXCEPTION'].message}">密码错误</span></div>
<p>
<label for="username" class="sr-only">用户账号:</label>
<input type="text" id="username" name="username" class="form-control" placeholder="请输入账号" required autofocus>
</p>
<p>
<label for="password" class="sr-only">用户密码:</label>
<input type="password" name="password" class="form-control" placeholder="请输入密码" required>
</p>
<button class="btn btn-lg btn-primary btn-block" type="submit">确定</button>
</form>
</div>
</body>
</html>

自定义错误信息 CustomErrorAttributes.java

403-没有权限、404-找不到页面等所有错误和异常,都会被SpringBoot默认的BasicErrorController处理。如果有需要,可定制ErrorAttributes。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.Map;
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.WebRequest;

@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(WebRequest webRequest, boolean includeStackTrace) {
Map<String, Object> errorAttributes = super.getErrorAttributes(webRequest, includeStackTrace);
errorAttributes.put("code", errorAttributes.getOrDefault("status", 0));//自定义code属性
Throwable error = super.getError(webRequest);
if (error != null && error.getMessage() != null) {
String message = (String)errorAttributes.getOrDefault("message", "");
if (!message.equals(error.getMessage())) {
errorAttributes.put("message", message+" "+error.getMessage());//增强message属性
}
}
return errorAttributes;
}
}

非浏览器访问(produces=”text/html”)出错时,返回json数据,示例:

1
2
3
4
5
6
7
8
{
"timestamp": "2020-08-27T09:05:11.178+0000",
"status": 500,
"error": "Internal Server Error",
"message": "/ by zero",
"path": "/demo/015",
"code": 500
}

浏览器访问(produces=”text/html”)出错时,返回html页面。

自定义错误页面 error/4xx.html

SpringBoot默认的Whitelabel Error Page需要定制,只要把错误页面模板放在error路径下即可。模板中可使用上述ErrorAttributes中的字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html lang="zh" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=no"/>
<link rel="stylesheet" href="//cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css"/>
</head>
<body>
<div id="root" class="container">
<div class="main">
<br/><h2 class="text-center"><span th:text="${status}">404</span>-<span th:text="${error}">Not Found</span></h2><br/>
<p class="text-center" th:if="${message}"><span th:text="${message}"></span></p>
<p class="text-center" th:if="${exception}"><span th:text="${exception}"></span></p>
<p class="text-center"><a class="btn btn-primary" th:href="@{'/'}">Home</a></p>
</div>
</div>
</body>
</html>

自定义错误页面 error/5xx.html

类似5xx.html,略。