Spring Security
如果你只是想实现一个简单的 web 应用,shiro 更加的轻量级,学习成本也更低,如果你正在开发一个分布式的、微服务的、或者与 Spring Cloud 系列框架深度集成的项目,建议使用 Spring Security。

用户权限模型,分别对应:用户表、角色表、权限表、用户角色关系表,角色权限对应表。
Spring Security 有两个关键字 Authentication(认证) 和 Authorization(授权)
准备工作
1 2 3 4 5
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
|
httpBasic
SecurityConfig.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override protected void configure(HttpSecurity http) throws Exception {
http.httpBasic() .and() .authorizeRequests() .anyRequest() .authenticated(); } }
|
可以被破解,不安全。
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
| @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable().formLogin() .loginPage("/login.html")
.loginProcessingUrl("/login") .defaultSuccessUrl("/index") .and().authorizeRequests() .antMatchers("/login.html", "/login", "/captcha").permitAll()
.anyRequest() .authenticated(); .and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .invalidSessionUrl("/login.html") .sessionFixation().migrateSession();
; }
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("user") .password(passwordEncoder().encode("123456")) .roles("user") .and() .withUser(AdminRole.ADMIN) .password(passwordEncoder().encode("123456")) .roles(AdminRole.ADMIN)
.and() .passwordEncoder(passwordEncoder()); }
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Override public void configure(WebSecurity web) throws Exception { web.ignoring() .antMatchers("/css/**", "/fonts/**", "/images/**", "/js/**", "/layui/**"); } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <form class="layui-form" id="loginForm" action="/admin/login" method="post"> <div class="layui-form-item"> <input type="text" name="username" required lay-verify="title" placeholder="用户名" autocomplete="off" class="layui-input"> </div> <div class="layui-form-item"> <input type="password" name="password" required lay-verify="password" placeholder="密码" autocomplete="off" class="layui-input"> </div> <div class="layui-form-item"> <div class="layui-inline"> <input type="text" name="captchaCode" required placeholder="验证码" autocomplete="off" class="layui-input"> </div> <div class="layui-inline"> <img class="verifyImg" src="/admin/captcha" onclick="captchaCodeResult()"/> </div> </div> <div class="layui-form-item m-login-btn"> <div class="layui-inline"> <button class="layui-btn layui-btn-normal submitBtn" type="submit">登录</button> </div> <div class="layui-inline"> <button type="reset" class="layui-btn layui-btn-primary">重置</button> </div> </div> </form>
|
SpringSecurity基本原理

基于过滤器 filter,核心是FilterChains
绿色部分一系列过滤器来认证 http 请求,哪个过滤器可以通过就会放行。请求时,会把认证通过的认证主体和请求存入 SecurityContext 中,在把 SecurityContext 存入 session 中,在下一次请求的时候,就会直接从 session 中取出认证主体,不会再次进行认证。
上图只是初步了解主体过程。下面是源码

- 请求到达 AuthenticationFilter
- 用用户名和密码构建一个令牌 Token
- AuthenticationManager 拿到 Token,
- 通过对应的 AuthenticationProvider 列表中的某个 provider 认证,一般是用 DaoAuthenticationProvider
- 将 Token 中的 password 加密。
- 通过 UserDetailsService 从数据库中查询出 User
- 78910进行认证通过 FilterChains 将 认证主体 Authentication(Principal + Authorities) 保存到 SecurityContext 中,在把 SecurityContext 存入 session 中。
AuthenticaionProvider
AuthenticaionProvider 接口是用于认证的,可以通过实现这个接口指定我们自己的认证逻辑,它的实现类有很多,默认是 JaasAuthenticationProvider
Java Authentication and Authorization Service (JAAS)
AccessDecisionManager
AccessDecisionManager 用于访问控制, 它决定用户是否可以访问某个资源,实现这个接口定制我们自己的授权逻辑。
AccessDecisionVoter
AccessDecisionVoter 投票器,在授权的时候通过投票的方式来决定用户是否可以访问,投票规则。
UserDetailsService
UserDetailsService 用于加载特定用户信息的,只有一个接口通过制定的用户名去查询。
UserDetails
UserDetails代表用户信息,即主体,相当于Shiro中的Subject。User是它的一个实现。
Security 在非分布式应用中的使用
需求:结合前端 cookie、session 开发一个具有记住我、同一用户只有一个客户端有效登录的 admin 管理平台。下图为登录验证请求的流程

过滤器在认证之后,对认证结果进行审查,如果认证成功,会执行 AuthenticationSuccessHandler,如果失败会执行 AuthenticationFailureHandler 接口。所以我们需要自定义类 实现这两个接口。
这里我采用了 jsonwebtoken 框架作为用户唯一标识
1 2 3 4
| <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> </dependency>
|
登录步骤
首先配置文件如下:
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
| @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired private UserDetailsService userDetailsService; @Autowired private LogoutSuccessHandler logoutSuccessHandler; @Autowired private AuthenticationFailureHandler authenticationFailureHandler; @Autowired private AuthenticationSuccessHandler authenticationSuccessHandler; @Autowired private AuthenticationEntryPoint authenticationEntryPoint;
@Autowired private CaptchaCodeFilter captchaCodeFilter; @Autowired private AuthenticationTokenFilter tokenFilter; @Autowired private DataSource dataSource;
@Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .headers().frameOptions().sameOrigin().and() .formLogin().loginPage("/login.html").successHandler(authenticationSuccessHandler).failureHandler(authenticationFailureHandler).and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED).and() .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint).and() .authorizeRequests()
.antMatchers("/*.html", "/captchaCode").permitAll() .anyRequest() .authenticated().and() .logout().logoutSuccessHandler(logoutSuccessHandler); ;
http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(captchaCodeFilter, AuthenticationTokenFilter.class); http.headers().cacheControl(); }
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService).passwordEncoder(new PasswordEncoder() { @Override public String encode(CharSequence charSequence) { return DigestUtils.md5DigestAsHex(charSequence.toString().getBytes()); }
@Override public boolean matches(CharSequence charSequence, String s) { String encode = DigestUtils.md5DigestAsHex(charSequence.toString().getBytes()); boolean res = s.equals(encode); return res; } }); }
@Override public void configure(WebSecurity web) throws Exception { web.ignoring() .antMatchers("/css/**", "/fonts/**", "/images/**", "/js/**", "/layui/**"); } }
|
如果你的应用是无状态的前后端分离应用,可以把 sessionCreationPolicy
设置成 STATELESS。captchaCodeFilter 和 tokenFilter 分别为过滤器,用来做验证码认证和登录认证。
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
| @Component public class CaptchaCodeFilter extends OncePerRequestFilter {
@Autowired AdminAuthenticationFailureHandler failureHandler;
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { if ("/admin/login.html".equalsIgnoreCase(request.getRequestURI()) && "post".equalsIgnoreCase(request.getMethod())) {
try { checkCaptchaCode(request, response); } catch (AdminAuthenticationException e) { failureHandler.onAuthenticationFailure(request, response, e); return; } } chain.doFilter(request, response); }
private void checkCaptchaCode(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException, AdminAuthenticationException {
String randomCode = request.getParameter("captchaCode"); if (randomCode == null) return;
CaptchaCode captchaCode = (CaptchaCode) SessionUtil.getSessionAttribute(SessionKey.CAPTCHACODE, request);
if (captchaCode == null || captchaCode.getCode() == null) throw new AdminAuthenticationException(AMError.CAPTCHACODE_NOT_EXIST);
if (captchaCode.isExpired()) { SessionUtil.removeSessionAttribute(SessionKey.CAPTCHACODE, request); throw new AdminAuthenticationException(AMError.CAPTCHACODE_IS_EXPIRED); } if (StringUtil.isEmpty(randomCode)) throw new AdminAuthenticationException(AMError.CAPTCHACODE_IS_EMPTY);
if (!captchaCode.getCode().equalsIgnoreCase(randomCode)) throw new AdminAuthenticationException(AMError.CAPTCHACODE_IS_WRONG); } }
|
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
| @Component public class AuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired private AdminUserDetailsService userDetailsService; @Autowired AdminAuthenticationFailureHandler failureHandler; @Autowired private TokenUtil tokenUtil; @Autowired private AdminUserMapper userMapper;
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String spath = request.getServletPath(); String[] urls = {"/login", ".html", "/layui/", "/js/", "/css/", "/fonts/", "/images/", "/captchaCode"}; boolean staticPath = StringUtil.isStaticPath(urls, spath); if (staticPath) { chain.doFilter(request, response); return; }
String token = request.getHeader(Const.HEADER_STRING); if (StringUtil.isEmpty(token)) { chain.doFilter(request, response); return; }
String username = tokenUtil.getUsernameFromToken(token); if (username == null) { chain.doFilter(request, response); return; }
try { AdminUser user = userMapper.selectByAccountRoleNull(username); if (StringUtil.isNotEmpty(user.getToken())) { if (!user.getToken().equals(token)) throw new AdminAuthenticationException(AMError.ACCOUNT_DISABLE_LOGIN); Integer minDiff = DateUtil.minDiff(DateUtil.str2Time(user.getLoginAt()), new Date()); if (minDiff >= Const.LOGIN_EXPIRED_TIME) throw new AdminAuthenticationException(AMError.ACCOUNT_EXPIRE_LOGIN); } } catch (AdminAuthenticationException e) { failureHandler.onAuthenticationFailure(request, response, e); return; }
if (SecurityContextHolder.getContext().getAuthentication() != null) { chain.doFilter(request, response); return; }
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); if (tokenUtil.validateToken(token, userDetails)) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } chain.doFilter(request, response); } }
|
配置文件和过滤器准备好后,先来说一下思路吧:
- 用户执行登录操作,如果验证码、密码、用户名输入都正确会来到下面登陆成功后的处理器
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
| @Component public class AdminAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Value("${phoneacce.admin.loginType}") private String loginType; @Autowired private AdminUserMapper userMapper; @Autowired private TokenUtil tokenUtil;
@Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
AdminUserDetails principal = (AdminUserDetails) authentication.getPrincipal(); String token = tokenUtil.generateToken((UserDetails) principal); String parameter = request.getParameter("remember-me");
AdminUser updateUser = new AdminUser(); AdminUser user = principal.getAdminUser();
if (parameter != null && parameter.equals("on")) { updateUser.setId(user.getId()); updateUser.setToken(token); CookieUtil.addCookie(response, "token", token); } else { updateUser.setId(user.getId()); updateUser.setToken(""); } updateUser.setLoginAt(DateUtil.getCurDateStrMiao_()); userMapper.update(updateUser); JsonResult jsonResult = new JsonResult(); response.setContentType("application/json; charset=UTF-8"); response.getWriter().print(JSON.toJSONString(jsonResult.success(token, "登录成功"))); }
|
当然 security 框架帮我们做了登录认证。此时把用户登录凭证 token 返回给前端,存入 sessionStorage,如果选择了记住密码,会把 token 也存入 cookie 中,设置一个过期时间,同时把 token 存入数据库,如果不记住密码,就将 token 清空。不记住我的模式下,用户的免登录状态认证周期是从打开浏览器到关闭浏览器。
前端执行逻辑:访问接口时,先从本地的 sessionStorage 中查找 token,如果 token 为空,则说明是第一次登录,则再去查看 cookie 中是否有值。如果 cookie 中有 token ,说明用户选择了记住我选项,
此时,当用户访问其他接口时,会来到 AuthenticationTokenFilter。执行校验。具体步骤见 AuthenticationTokenFilter 中的注释。
解决访问需要认证的资源时直接报302错误页面不跳转登录
这里需要提一下的是,这种配置在前后端不分的登录中是没有问题的,在前后端分离的登录中,这种配置就有问题了。我举个简单的例子,例如我想访问 /hello 接口,但是这个接口需要登录之后才能访问,我现在没有登录就直接去访问这个接口了,那么系统会给我返回 302,让我去登录页面,在前后端分离中,我的后端一般是没有登录页面的,也就是说,当我没有登录直接去访问 /hello 这个接口的时候,我会看到上面这段 JSON 字符串。在前后端分离开发中,这个看起来没问题(后端不再做页面跳转,无论发生什么都是返回 JSON)。
但是问题就出在这里,系统默认的跳转是一个重定向,就是说当你访问 /hello 的时候,服务端会给浏览器返回 302,同时响应头中有一个 Location 字段,它的值为 http://localhost:8081/login ,也就是告诉浏览器你去访问 http://localhost:8081/login 地址吧。
浏览器收到指令之后,就会直接去访问 http://localhost:8081/login 地址,如果此时是开发环境并且请求还是 Ajax 请求,就会发生跨域。因为前后端分离开发中,前端我们一般在 NodeJS 上启动,然后前端的所有请求通过 NodeJS 做请求转发,现在服务端直接把请求地址告诉浏览器了,浏览器就会直接去访问 http://localhost:8081/login 了,而不会做请求转发了,因此就发生了跨域问题。
如果我们的 Spring Security 在用户未认证时访问一个认证后才能访问的资源,不给用户重定向,而是直接返回一个 Json,告诉用户这个请求认证后才能发起。需要自定义 AuthencationEntryPoint 类。
从注释中看出,这个类的作用是决定到底是重定向还是要 forward,默认是重定向。所以重写这个方法,返回 Json 即可。
1 2 3 4 5 6 7 8 9 10 11
| @Component public class AdminAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { response.setContentType("application/json; charset=UTF-8"); JsonResult jsonResult = new JsonResult(); response.getWriter().print(JSON.toJSONString(jsonResult.failed(AMError.ACCOUNT_NOT_LOGIN))); } }
|
在执行登出操作时,需要清空数据库 token, 浏览器的 cookie 字段即可。
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
| @Component public class AdminLogoutSuccessHandler implements LogoutSuccessHandler {
@Autowired private AdminUserMapper userMapper; @Autowired private TokenUtil tokenUtil;
@Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.setContentType("application/json; charset=UTF-8"); String token = request.getHeader(Const.HEADER_STRING); JsonResult jsonResult = new JsonResult();
CookieUtil.deleteCookie("token", request, response);
String username = tokenUtil.getUsernameFromToken(token); if (StringUtil.isEmpty(username)) { response.getWriter().print(JSON.toJSONString(jsonResult.failed("您的浏览器可能遭到劫持"))); return; }
AdminUser updateUser = new AdminUser(); updateUser.setAccount(username); updateUser.setToken(""); userMapper.updateByAccount(updateUser);
response.getWriter().print(JSON.toJSONString(jsonResult.success("退出成功"))); } }
|
# 常用的权限表达式
表达式函数 |
描述 |
hasRole([role]) |
用户拥有指定的角色时返回 true(Spring security 默认会带有 ROLE_前缀),去除前缀参考 Remove the ROLE_ |
hasAnyRole(role1, role2) |
用户拥有任意一个指定的角色时返回 true |
hasAuthority([authority]) |
拥有某资源的访问权限时返回 ture |
hasAnyAuthority([auth1, auth2]) |
拥有某些资源其中部分资源的访问权限时返回 true |
permitAll |
永远返回 true |
denyAll |
永远返回 false |
anonymous |
当前用户时 anonymous 时返回 true |
rememberMe |
当前用户是 rememberMe 用户返回 true |
authentication |
当前登录用户的 authentication 对象 |
fullAuthenticated |
当前用户既不是 anonymous 也不是 rememberMe 用户时返回 true |
hasIpAddress(‘192.168.1.0/24’) |
请求发送的 IP 匹配时返回 true |
方法级别的安全控制
开启开关:@EnableGlobalMethodSecurity(prePostEnabled = true)
1 2 3
| @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
@PreAuthorize
@PreFilter
@PostAuthorize
@PostFilter
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @PreAuthorize("hasRole('admin')") public List<permission> findAll() {}
@PostAuthorize("returnObject.name == authentication.name") public PersonDemo findOne() { return new Persion(name) }
@PreFilter(filterTarget="ids", value="filterObject % 2 == 0") public void delete(List<Integer> ids, List<String> usernames) { }
@PostFilter("filterObject.name == authentication.name") public List<PersionDemo> findAll() {
}
|