@TedZhou
2020-11-04T09:56:20.000000Z
字数 20133
阅读 764
java spring security thymeleaf
记录spring boot项目使用spring security的核心配置和相关组件。要点:
1. 支持自定义页面登录
2. 支持AJAX登录/登出
3. 支持RBAC权限控制
4. 支持增加多种认证方式
5. 支持集群部署(会话共享redis存储)
6. 支持SessionId放在Header的X-Auth-Token里
<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
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 {@Autowiredprivate AuthProviderUsernamePassword authProviderUsernamePassword;@Autowiredprivate AuthSuccessHandler authSuccessHandler;@Autowiredprivate AuthFailureHandler authFailureHandler;@Autowiredprivate ExitSuccessHandler exitSuccessHandler;@Beanprotected AuthenticationFailureHandler authenticationFailureHandler() {authFailureHandler.setDefaultFailureUrl("/login?error");return authFailureHandler;}@Beanprotected LogoutSuccessHandler logoutSuccessHandler() {exitSuccessHandler.setDefaultTargetUrl("/login?logout");return exitSuccessHandler;}private static String[] INGORE_URLS = {"/login", "/error",};@Overridepublic void configure(WebSecurity webSecurity) {webSecurity.ignoring().antMatchers("/static/**");//忽略静态资源webSecurity.ignoring().antMatchers("/favicon.ico");}@Overrideprotected 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();}@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.authenticationProvider(authProviderUsernamePassword);//auth.authenticationProvider(authProvider2);可以增加多个认证方式,比如码验证等}@Beanprotected AccessDecisionManager accessDecisionManager() {List<AccessDecisionVoter<? extends Object>> decisionVoters = Arrays.asList(new WebExpressionVoter(),authDecisionVoter(),//new RoleVoter(),new AuthenticatedVoter());return new UnanimousBased(decisionVoters);}@Beanprotected AuthDecisionVoter authDecisionVoter() {return new AuthDecisionVoter();}@Beanpublic HttpSessionIdResolver httpSessionIdResolver() {return new HeaderCookieHttpSessionIdResolver();}}
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;@Componentpublic class AuthProviderUsernamePassword implements AuthenticationProvider {@AutowiredAuthUserService authUserService;@Overridepublic 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));}@Overridepublic boolean supports(Class<?> authentication) {return true;}}
配置于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;@Componentpublic class AuthSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {private RequestCache requestCache = new HttpSessionRequestCache();@Overridepublic 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登录HttpSession session = request.getSession();String sessionId = new Base64().encodeToString(session.getId().getBytes("UTF-8"));response.sendError(200, "success!SESSION="+sessionId);return;}super.onAuthenticationSuccess(request, response, authentication);}}
配置于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;@Componentpublic class AuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {@Overridepublic 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");}}
配置于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;@Componentpublic class ExitSuccessHandler extends SimpleUrlLogoutSuccessHandler {@Overridepublic 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);}}
增加优先从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();@Overridepublic List<String> resolveSessionIds(HttpServletRequest request) {List<String> sessionIds = headerResolver.resolveSessionIds(request);if (sessionIds.isEmpty()) {sessionIds = cookieResolver.resolveSessionIds(request);}return sessionIds;}@Overridepublic void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) {headerResolver.setSessionId(request, response, sessionId);cookieResolver.setSessionId(request, response, sessionId);}@Overridepublic void expireSession(HttpServletRequest request, HttpServletResponse response) {headerResolver.expireSession(request, response);cookieResolver.expireSession(request, response);}}
用户实体类,实现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;@Datapublic class AuthUser implements UserDetails, Serializable {private static final long serialVersionUID = -1572872798317304041L;@Idprivate 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;}@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {return authorities;}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return true;}}
提供根据用户名获取用户的方法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;@Servicepublic class AuthUserService implements UserDetailsService {@Overridepublic 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"}]}
这里提供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;@Controllerpublic class AuthController {@RequestMapping("/login")//登录入口String login(String username, Model model) {model.addAttribute("username", username);return "login";}@RequestMapping("/")//主页@ResponseBodyObject home(@AuthenticationPrincipal AuthUser currentUser) {return currentUser;}@RequestMapping("/{path}")//测试用@ResponseBodyObject url1(@PathVariable String path) {if (path.contains("0")) {//模拟错误path = String.valueOf(1/0);}return path;}}
配置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";@Overridepublic boolean supports(ConfigAttribute attribute) {return true;}@Overridepublic boolean supports(Class<?> clazz) {return true;}@Overridepublic 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 permitAllreturn ACCESS_ABSTAIN;}}}String requestUrl = ((FilterInvocation) object).getRequestUrl();// 当前请求的URLCollection<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 authorityfor (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);}}
<!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>
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;@Componentpublic class CustomErrorAttributes extends DefaultErrorAttributes {@Overridepublic 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页面。
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>
类似5xx.html,略。