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

用户权限模型,分别对应:用户表、角色表、权限表、用户角色关系表,角色权限对应表。
Spring Security 有两个关键字 Authentication(认证) 和 Authorization(授权)
准备工作
| 12
 3
 4
 5
 
 | <dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-security</artifactId>
 </dependency>
 
 | 
httpBasic
SecurityConfig.java
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 
 | @Configurationpublic class SecurityConfig extends WebSecurityConfigurerAdapter {
 
 
 @Override
 protected void configure(HttpSecurity http) throws Exception {
 
 
 
 
 
 
 
 http.httpBasic()
 .and()
 .authorizeRequests()
 .anyRequest()
 .authenticated();
 }
 }
 
 | 
可以被破解,不安全。
SecurityConfig.java
| 12
 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
 
 | @Configurationpublic 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/**");
 }
 }
 
 | 
| 12
 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 框架作为用户唯一标识
| 12
 3
 4
 
 | <dependency><groupId>io.jsonwebtoken</groupId>
 <artifactId>jjwt</artifactId>
 </dependency>
 
 | 
登录步骤
首先配置文件如下:
| 12
 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
 
 | @Configurationpublic 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 分别为过滤器,用来做验证码认证和登录认证。
| 12
 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
 
 | @Componentpublic 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);
 }
 }
 
 | 
| 12
 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
 
 | @Componentpublic 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);
 }
 }
 
 | 
配置文件和过滤器准备好后,先来说一下思路吧:
- 用户执行登录操作,如果验证码、密码、用户名输入都正确会来到下面登陆成功后的处理器
| 12
 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
 
 | @Componentpublic 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 即可。
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 
 | @Componentpublic 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 字段即可。
| 12
 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
 
 | @Componentpublic 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)
| 12
 3
 
 | @Configuration@EnableGlobalMethodSecurity(prePostEnabled = true)
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
 
 | 
- @PreAuthorize
- @PreFilter
- @PostAuthorize
- @PostFilter
| 12
 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() {
 
 }
 
 |