记录spring boot项目使用spring security的核心配置和相关组件。要点:
支持自定义页面登录
支持AJAX登录/登出
支持RBAC权限控制
支持增加多种认证方式
支持集群部署(会话共享redis存储)
支持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" ) .and() .logout() .logoutSuccessHandler(logoutSuccessHandler()) .and().csrf().disable(); } @Override protected void configure (AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(authProviderUsernamePassword); } @Bean protected AccessDecisionManager accessDecisionManager () { List<AccessDecisionVoter<? extends Object >> decisionVoters = Arrays.asList( new WebExpressionVoter (), authDecisionVoter(), 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 ("账号或密码不正确" ); } 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)) { 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)) { 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)) { response.sendError(200 , "success" ); return ; } super .onLogoutSuccess(request, response, authentication); } }
增加优先从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 { AuthUser user = new AuthUser (); 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 <>(); 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())) { return ACCESS_ABSTAIN; } } } String requestUrl = ((FilterInvocation) object).getRequestUrl(); Collection<ConfigAttribute> urlPerms = getPermissionsByUrl(requestUrl); 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; for (GrantedAuthority authority : userAuthorities) { if (urlPerm.equals(authority.getAuthority())) { return ACCESS_GRANTED; } } } return result; } Collection<ConfigAttribute> getPermissionsByUrl (String 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 )); 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()); } } 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,略。