Spring security with thymeleaf - zhouted/zhouted.github.io GitHub Wiki

标签: java spring security thymeleaf


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

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

项目依赖 pom.xml

<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

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里即可。

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(),可选。

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(),可选。

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(),可选。

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。

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接口。

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()。

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);
	}
}

模拟用户示例:

{
	"id": 1598515192490,
	"username": "test",
	"password": "test",
	"authorities": [{
			"authority": "P15"
		}, {
			"authority": "P59"
		}, {
			"authority": "P52"
		}
	]
}

认证入口 AuthControll.java

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

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:标签的使用

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

<!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。

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数据,示例:

{
	"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中的字段。

<!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,略。

⚠️ **GitHub.com Fallback** ⚠️