SpringSecurity(二)

1,103 阅读11分钟

设置用户的状态

源码分析

  • 用户认证业务里面,我们在封装UserDetails对象的时候,选择了三个参数的构造方法,其认证逻辑如下:
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

    SysUser sysUser = userDao.findByName(username);

    if (null == sysUser) {
        //如果用户名不对,直接返回null,表示认证失败
        return null;
    }

    List<SimpleGrantedAuthority> authorities = new ArrayList<>();

    List<SysRole> roles = sysUser.getRoles();

    if (null != roles && roles.size() != 0) {
        roles.forEach(role -> {
            SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role.getRoleName());
            authorities.add(simpleGrantedAuthority);
        });
    }

    //返回UserDetails对象,"{noop}"+密码表示不加密认证
    //封装UserDetails的时候,选择了三个参数的构造方法
    UserDetails userDetails = new User(sysUser.getUsername(), sysUser.getPassword(), authorities);


    return userDetails;
}
  • 其实还有另一个构造方法:
    public User(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
        if (username != null && !"".equals(username) && password != null) {
            this.username = username;
            this.password = password;
            this.enabled = enabled;
            this.accountNonExpired = accountNonExpired;
            this.credentialsNonExpired = credentialsNonExpired;
            this.accountNonLocked = accountNonLocked;
            this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
        } else {
            throw new IllegalArgumentException("Cannot pass null or empty values to constructor");
        }
    }
  • 可以看出,多了四个布尔类型的参数,并且这四个布尔类型的参数都被赋予了true,那么这四个布尔类型的参数的含义是什么?
    • enabled:可用(true,表示可用;false,表示不可用)。
    • accountNonExpired:账户没有过期(true,表示账户没有过期;false,表示账户已经过期)。
    • credentialsNonExpired:凭证(即密码)没有过期(true,表示凭证没有过期;false,表示凭证已经过期)。
    • accountNonLocked:账户没有被锁定(true,表示账户没有被锁定;false,表示账户已经锁定)。

判断认证用户的状态

  • 上面的四个布尔类型的参数必须都为true,认证才通过,为了方便,我使用第一个布尔值做测试,修改的认证业务代码:
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

    SysUser sysUser = userDao.findByName(username);

    if (null == sysUser) {
        //如果用户名不对,直接返回null,表示认证失败
        return null;
    }

    List<SimpleGrantedAuthority> authorities = new ArrayList<>();

    List<SysRole> roles = sysUser.getRoles();

    if (null != roles && roles.size() != 0) {
        roles.forEach(role -> {
            SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role.getRoleName());
            authorities.add(simpleGrantedAuthority);
        });
    }

    //返回UserDetails对象,"{noop}"+密码表示不加密认证
    //判断用户的状态
    UserDetails userDetails = new User(sysUser.getUsername(), sysUser.getPassword(), sysUser.getStatus() == 1, true, true, true, authorities);


    return userDetails;
}
  • 此时用户的状态为1的用户才能成功通过认证。

Remember me(记住我)

记住我功能原理分析

  • 用户认证流程是通过UsernamePasswordAuthenticationFilter这个过滤器实现的,而UsernamePasswordAuthenticationFilter的父类是AbstractAuthenticationProcessingFilter,也是一个过滤器,其源码如下:
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
    

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)req;
        HttpServletResponse response = (HttpServletResponse)res;
        if (!this.requiresAuthentication(request, response)) {
            chain.doFilter(request, response);
        } else {
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Request is to process authentication");
            }

            Authentication authResult;
            try {
                authResult = this.attemptAuthentication(request, response);
                if (authResult == null) {
                    return;
                }

                this.sessionStrategy.onAuthentication(authResult, request, response);
            } catch (InternalAuthenticationServiceException var8) {
                this.logger.error("An internal error occurred while trying to authenticate the user.", var8);
                this.unsuccessfulAuthentication(request, response, var8);
                return;
            } catch (AuthenticationException var9) {
                this.unsuccessfulAuthentication(request, response, var9);
                return;
            }

            if (this.continueChainBeforeSuccessfulAuthentication) {
                chain.doFilter(request, response);
            }

            this.successfulAuthentication(request, response, chain, authResult);
        }
    }

    protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
        return this.requiresAuthenticationRequestMatcher.matches(request);
    }

    public abstract Authentication attemptAuthentication(HttpServletRequest var1, HttpServletResponse var2) throws AuthenticationException, IOException, ServletException;
	//看这里,这是成功认证之后的处理逻辑
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
        }

        SecurityContextHolder.getContext().setAuthentication(authResult);
        //下面的代码实现了记住我的功能
        this.rememberMeServices.loginSuccess(request, response, authResult);
        if (this.eventPublisher != null) {
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }

        this.successHandler.onAuthenticationSuccess(request, response, authResult);
    }

 
}

  • 通过上面的代码我们知道loginSuccess是实现记住我的功能方法,这个方法在RememberServices接口中,其源码如下:
public interface RememberMeServices {
    Authentication autoLogin(HttpServletRequest var1, HttpServletResponse var2);

    void loginFail(HttpServletRequest var1, HttpServletResponse var2);
	//实现了记住我的功能
    void loginSuccess(HttpServletRequest var1, HttpServletResponse var2, Authentication var3);
}
  • RememberMeServices接口有一个抽闲类AbstractRememberMeServices实现了记住我的功能逻辑,其源码如下:
    public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
        //判断是否勾选记住我
        //没有勾选记住我
        if (!this.rememberMeRequested(request, this.parameter)) {
            this.logger.debug("Remember-me login not requested.");
        } else {
            //勾选了记住我,就调用onLoginSuccess方法,而且onLoginSuccess是个抽象方法,需要子类去实现
            this.onLoginSuccess(request, response, successfulAuthentication);
        }
    }
    //这个方法判断请求参数是否含有“remember-me”,并且值是"true"或"on"或"yes"或"1"
    protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
        if (this.alwaysRemember) {
            return true;
        } else {
            String paramValue = request.getParameter(parameter);
            if (paramValue != null && (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on") || paramValue.equalsIgnoreCase("yes") || paramValue.equals("1"))) {
                return true;
            } else {
                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("Did not send remember-me cookie (principal did not set parameter '" + parameter + "')");
                }

                return false;
            }
        }
    }
	protected abstract void onLoginSuccess(HttpServletRequest var1, HttpServletResponse var2, Authentication var3);	
  • PersistentTokenBasedRememberMeServices是AbstractRememberMeServices的子类,重写了onLoginSuccess方法,其源码如下:
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
   
    protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
        String username = successfulAuthentication.getName();
        this.logger.debug("Creating new persistent login for user " + username);
        PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());

        try {
            //将生产的token写到数据库中
            this.tokenRepository.createNewToken(persistentToken);
            //token写到cookie中
            this.addCookie(persistentToken, request, response);
        } catch (Exception var7) {
            this.logger.error("Failed to save persistent token ", var7);
        }

    } 	   
}    

记住我功能页面代码实现

<div class="col-xs-8">
    <div class="checkbox icheck">
        <label><input type="checkbox" name="remember-me" value="true"> 记住 下次自动登录</label>
    </div>
</div>
  • 重启项目,然后测试一下,发现好像不行,因为RememberMeAuthenticationFilter这个过滤器默认是关闭的。

开启Remember me过滤器

  • 在spring-security.xml中配置即可。
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xmlns:security="http://www.springframework.org/schema/security"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
			    http://www.springframework.org/schema/beans/spring-beans.xsd
			    http://www.springframework.org/schema/context
			    http://www.springframework.org/schema/context/spring-context.xsd
			    http://www.springframework.org/schema/aop
			    http://www.springframework.org/schema/aop/spring-aop.xsd
			    http://www.springframework.org/schema/tx
			    http://www.springframework.org/schema/tx/spring-tx.xsd
			    http://www.springframework.org/schema/mvc
			    http://www.springframework.org/schema/mvc/spring-mvc.xsd
                http://www.springframework.org/schema/security
			    http://www.springframework.org/schema/security/spring-security.xsd">

    <!--直接释放无需经过SpringSecurity过滤器的静态资源-->
    <security:http pattern="/css/**" security="none"/>
    <security:http pattern="/img/**" security="none"/>
    <security:http pattern="/plugins/**" security="none"/>
    <security:http pattern="/failer.jsp" security="none"/>
    <security:http pattern="/favicon.ico" security="none"/>

    <!--
        配置Spring Security
        auto-config="true"表示自定加载spring-security.xml配置文件
        use-expressions="true"表示使用spring的el表达式来配置spring security
    -->
    <security:http auto-config="true" use-expressions="true">

        <!--指定login.jsp页面可以被匿名访问-->
        <security:intercept-url pattern="/login.jsp" access="permitAll()"/>
        <!--   拦截资源     -->
        <!--
            pattern="/**" 表示拦截所有的资源
            access="hasAnyRole('ROLE_USER')" 表示只有ROLE_USER的角色才能访问资源
        -->
        <security:intercept-url pattern="/**" access="hasAnyRole('ROLE_USER','ROLE_ADMIN')"/>
        <!-- 配置认证信息,指定自定义的认证页面 -->
        <!--
            login-page 指定登录页面的地址
            login-processing-url 处理登录的处理器的地址
            default-target-url 登录成功跳转的地址
            authentication-failure-url 登录失败跳转的地址

            默认的用户名是username,密码是password,当然也可以使用username-parameter和password-parameter修改。
        -->
        <security:form-login login-page="/login.jsp"
                             login-processing-url="/login"
                             default-target-url="/index.jsp"
                             authentication-failure-url="/failer.jsp"/>
        <!--
            指定退出登录后跳转的页面
            logout-url 处理退出登录的处理器的地址
            logout-success-url 退出登录成功跳转的地址
        -->
        <security:logout logout-url="/logout" logout-success-url="/login.jsp"/>

        <!-- 禁用csrf防护机制 -->
        <!--        <security:csrf disabled="true"/>-->

        <!--   开启Remember me过滤器,设置token的存储时间为60秒     -->
        <security:remember-me token-validity-seconds="60"/>
    </security:http>

    <!-- 加密对象 -->
    <bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"></bean>

    <!--设置Spring Security认证用户信息的来源-->
    <!--
        Spring Security的认证必须是加密的,{noop}表示不加密认证
    -->
    <security:authentication-manager>
        <security:authentication-provider user-service-ref="userServiceImpl">
            <!--    指定认证使用的加密对象        -->
            <security:password-encoder ref="passwordEncoder"/>
        </security:authentication-provider>
    </security:authentication-manager>


</beans>

RememberMeAuthenticationFilter过滤器的功能很简单,会自动判断是否认证,如果没有认证,就调用autoLogin进行自动认证。

Remember me安全性分析

  • 上面是将生成的token保存在cookie里面,但是cookie是客户端的行为,很容易被盗取,而且cookie的值还和用户名、密码这些敏感的数据相关,虽然加密了,但是将敏感信息保存在客户端,不太安全。那么就要提醒使用此功能的用户,在用完系统之后,要手动退出登录,而不是直接将浏览器关闭。

  • SpringSecurity提供了另一种相对安全的实现机制:在客户端的cookie中,仅仅保存一个无意义的加密串(和用户名、密码等敏感数据无关),然后在db中保存改加密串-用户信息的对应关系,自动登录的时候,用cookie中的加密串,到db中验证,如果通过,自动登录才算通过。

持久化Remember me

  • 创建一张表,表的名称和字段是固定的,不能修改。
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL,
PRIMARY KEY (`series`)
)
  • 在spring-security.xml中改为:
<!--   开启Remember me过滤器,设置token的存储时间为60秒     -->
<!--
    data-source-ref="dataSource" 指定数据库连接池
    token-validity-seconds="60" 设置token的存储时间
    remember-me-parameter="remember-me" 指定记住的参数名,可以省略
-->
<security:remember-me data-source-ref="dataSource"  token-validity-seconds="60" remember-me-parameter="remember-me"/>
  • 最后测试,可以发现数据库中多了一条记录。

Remember me持久化

显示当前认证的用户名

  • 在header.jsp中找到页面头部最右侧的图片处添加如下的信息:
<span class="hidden-xs">
    <security:authentication property="name" />
</span>
或
<span class="hidden-xs">
    <security:authentication property="principal.username" />
</span>

授权准备工作

  • 为了模拟授权操作,临时编写了两个业务功能:

    • 控制器代码:
    @Controller
    @RequestMapping("/order")
    public class OrderController {
        @RequestMapping("/findAll")
        public String findAll(){
            return "order-list";
        }
    }
    
    @Controller
    @RequestMapping("/product")
    public class ProductController {
        @RequestMapping("/findAll")
        public String findAll(){
            return "product-list";
        }
    }
    
    • aside.jsp页面
    <ul class="treeview-menu">
        <li id="system-setting"><a
                href="${pageContext.request.contextPath}/product/findAll">
            <i class="fa fa-circle-o"></i> 产品管理
        </a></li>
        <li id="system-setting"><a
                href="${pageContext.request.contextPath}/order/findAll">
            <i class="fa fa-circle-o"></i> 订单管理
        </a></li>
    </ul>
    

动态展示菜单

  • 在aside.jsp页面中对每个菜单通过SpringSecurity标签库指定访问所需要的角色:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="security" uri="http://www.springframework.org/security/tags" %>
<aside class="main-sidebar">
    <!-- sidebar: style can be found in sidebar.less -->
    <section class="sidebar">
        <!-- Sidebar user panel -->
        <div class="user-panel">
            <div class="pull-left image">
                <img src="${pageContext.request.contextPath}/img/user2-160x160.jpg"
                     class="img-circle" alt="User Image">
            </div>
            <div class="pull-left info">
                <p>
                    <security:authentication property="principal.username"/>
                </p>
                <a href="#"><i class="fa fa-circle text-success"></i> 在线</a>
            </div>
        </div>

        <!-- sidebar menu: : style can be found in sidebar.less -->
        <ul class="sidebar-menu">
            <li class="header">菜单</li>
            <li id="admin-index"><a
                    href="${pageContext.request.contextPath}/pages/main.jsp"><i
                    class="fa fa-dashboard"></i> <span>首页</span></a></li>

            <li class="treeview"><a href="#"> <i class="fa fa-cogs"></i>
                <span>系统管理</span> <span class="pull-right-container"> <i
                        class="fa fa-angle-left pull-right"></i>
				</span>
            </a>
                <ul class="treeview-menu">

                    <li id="system-setting"><a
                            href="${pageContext.request.contextPath}/user/findAll"> <i
                            class="fa fa-circle-o"></i> 用户管理
                    </a></li>
                    <li id="system-setting"><a
                            href="${pageContext.request.contextPath}/role/findAll"> <i
                            class="fa fa-circle-o"></i> 角色管理
                    </a></li>
                    <li id="system-setting"><a
                            href="${pageContext.request.contextPath}/pages/permission-list.jsp">
                        <i class="fa fa-circle-o"></i> 权限管理
                    </a></li>
                </ul>
            </li>

            <li class="treeview"><a href="#"> <i class="fa fa-cube"></i>
                <span>基础数据</span> <span class="pull-right-container"> <i
                        class="fa fa-angle-left pull-right"></i>
				</span>
            </a>
                <ul class="treeview-menu">
                    <security:authorize access="hasAnyRole('ROLE_PRODUCT','ROLE_ADMIN')">
                        <li id="system-setting"><a
                                href="${pageContext.request.contextPath}/product/findAll">
                            <i class="fa fa-circle-o"></i> 产品管理
                        </a></li>
                    </security:authorize>
                    <%--     订单模块 普通用户就可以访问了               --%>
                    <li id="system-setting"><a
                            href="${pageContext.request.contextPath}/order/findAll">
                        <i class="fa fa-circle-o"></i> 订单管理
                    </a></li>

                </ul>
            </li>
        </ul>
    </section>
    <!-- /.sidebar -->
</aside>
  • 现在使用xiaoming这个账号登录,xiaoming账号是普通用户的角色,只看到订单管理。

xiaoming只看到订单管理

  • 但是,如果在浏览器输入http://localhost:8080/product/findAll的地址,会出现如下的界面:

通过手动输入URL来访问产品管理

  • xiaoming没有产品管理的权限,却通过手动输入地址的方式看到了产品管理的功能,很不合理,当然,实际开发中,不可能这么干的。实际开发中的页面都是动态从后台数据库读取的。

授权操作

SpringSecurity可以通过注解的方式来控制类或者方法的访问权限。

开启授权的注解支持

  • 在spring-mvc中开启授权的注解支持:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xmlns:tx="http://www.springframework.org/schema/tx"
       xmlns:mvc="http://www.springframework.org/schema/mvc"
       xmlns:security="http://www.springframework.org/schema/security"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
			    http://www.springframework.org/schema/beans/spring-beans.xsd
			    http://www.springframework.org/schema/context
			    http://www.springframework.org/schema/context/spring-context.xsd
			    http://www.springframework.org/schema/aop
			    http://www.springframework.org/schema/aop/spring-aop.xsd
			    http://www.springframework.org/schema/tx
			    http://www.springframework.org/schema/tx/spring-tx.xsd
			    http://www.springframework.org/schema/mvc
			    http://www.springframework.org/schema/mvc/spring-mvc.xsd
                http://www.springframework.org/schema/security
			    http://www.springframework.org/schema/security/spring-security.xsd">

    <context:component-scan base-package="com.weiwei.xu.controller"/>

    <mvc:annotation-driven/>

    <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
        <property name="prefix" value="/pages/"/>
        <property name="suffix" value=".jsp"/>
    </bean>

    <mvc:default-servlet-handler/>

    <!--
      开启Security的注解支持
      secured-annotations="enabled" SpringSecurity内部的权限控制注解开关
      pre-post-annotations="enabled" Spring注解的权限控制开关
      jsr250-annotations="enabled" 表示支持jsr250-api的注解,需要jsr250-api的jar包
   -->
    <security:global-method-security
            secured-annotations="enabled"
            jsr250-annotations="enabled"
            pre-post-annotations="enabled"/>

</beans>

在对应的类或方法上添加注解

  • SpringSecurity内部的权限控制注解:
package com.weiwei.xu.controller;

import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/product")
public class ProductController {

    @Secured({"ROLE_PRODUCT","ROLE_ADMIN"})
    @RequestMapping("/findAll")
    public String findAll(){
        return "product-list";
    }
}

  • JSR205控制注解:
package com.weiwei.xu.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.annotation.security.RolesAllowed;

@Controller
@RequestMapping("/product")
public class ProductController {

    @RolesAllowed({"ROLE_PRODUCT","ROLE_ADMIN"})
    @RequestMapping("/findAll")
    public String findAll(){
        return "product-list";
    }
}
  • Spring的权限控制注解:
package com.weiwei.xu.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/product")
public class ProductController {
    
    @PreAuthorize("hasAnyRole('ROLE_PRODUCT','ROLE_ADMIN')")
    @RequestMapping("/findAll")
    public String findAll(){
        return "product-list";
    }
}

权限不足异常

  • 每次权限不足的时候,总会出现如下的界面:

权限不足异常

  • 后台的日志:

权限不足后台日志异常信息

  • 这种体验也太差了,不友好,需要处理这种异常。

    • 在spring-security.xml配置文件中处理。
        <!--
            配置Spring Security
            auto-config="true"表示自定加载spring-security.xml配置文件
            use-expressions="true"表示使用spring的el表达式来配置spring security
        -->
        <security:http auto-config="true" use-expressions="true">
    
       		<!-- 其他配置省略 -->
    
            <!-- 403异常处理 -->
            <security:access-denied-handler error-page="/403.jsp"/>
        </security:http>
    
    • 在web.xml中配置。
    <error-page>
         <error-code>403</error-code>
         <location>/403.jsp</location>
    </error-page>
    
    • 编写异常处理器。
    package com.itheima.controller.advice;
    
    import org.springframework.security.access.AccessDeniedException;
    import org.springframework.web.bind.annotation.ControllerAdvice;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    
    @ControllerAdvice
    public class ControllerExceptionAdvice {
    
        //只有出现AccessDeniedException异常才调转403.jsp页面
        @ExceptionHandler(AccessDeniedException.class)
        public String exceptionAdvice(){
            return "forward:/403.jsp";
        }
    }