springsecurity

Spring Security-原始篇(XML配置版)功能优化

2022-05-16  本文已影响0人  石头耳东

前置文章:
Spring Security-原始篇(XML配置版)
结合前置文章加上该文,Spring Security大致的功能其实就都了解了。到使用Spring Boot的注解开发则会更简便一些。

零、本文纲要

  1. 源码分析
  2. 设置用户状态的代码实现
  1. 源码分析
  2. 代码实现
  1. 页面控制-动态展示菜单
  2. 授权操作
  3. 注解介绍
    ① @Secured
    ② @RolesAllowed
    ③ @PreAuthorize

一、设置用户状态

1. 源码分析

在Spring Security当中认证是由 AuthenticationManager 来管理的,但是真正进行认证的是 AuthenticationManager 中定义的 AuthenticationProvider。
我们实际的代码则是通过实现UserDetailsService接口,来编写具体的认证业务逻辑。然后将具体的实现类作为AuthenticationProvider的引用,交给认证框架使用。

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

该接口有一个UserDetails返回值,其也是接口。需要我们使用具体的实现类,以实现具体的认证逻辑。

public interface UserDetails extends Serializable {

    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

框架也给我们提供了其具体实现类User类。

org.springframework.security.core.userdetails.User

Ⅰ 构造方法一

public User(String username, String password,
        Collection<? extends GrantedAuthority> authorities) {
    this(username, password, true, true, true, true, authorities);
}

Ⅱ 构造方法二

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)) {
        throw new IllegalArgumentException(
                "Cannot pass null or empty values to constructor");
    }

    this.username = username;
    this.password = password;
    //是否可用
    this.enabled = enabled;
    //账户是否失效
    this.accountNonExpired = accountNonExpired;
    //凭据(密码)是否失效
    this.credentialsNonExpired = credentialsNonExpired;
    //账户是否锁定
    this.accountNonLocked = accountNonLocked;
    this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
}

2. 设置用户状态的代码实现

修改原有loadUserByUsername实现方法的如下逻辑代码。

UserDetails userDetails = new User(
        sysUser.getUsername(),
        sysUser.getPassword(), 
        sysUser.getStatus() == 1, //boolean enabled 是否可用
        true,
        true,
        true,
        authorities);

其余的部分可以根据实际业务需求,进行相应的代码修改即可。

二、RememberMe功能实现

1. 源码分析

Spring Security整体的实现其实都是依赖过滤器来完成,其中我们重点关注UsernamePasswordAuthenticationFilter。
认证的基础就是依靠该类的attemptAuthentication方法。RememberMe功能实现也离不开该类。
该类继承了一个AbstractAuthenticationProcessingFilter抽象父类,其successfulAuthentication方法内有实现RememberMe的逻辑。

protected void successfulAuthentication(HttpServletRequest request,
        HttpServletResponse response, FilterChain chain, Authentication authResult)
        throws IOException, ServletException {

    if (logger.isDebugEnabled()) {
        logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
                + authResult);
    }

    SecurityContextHolder.getContext().setAuthentication(authResult);
    //【断点】RememberMe入口
    rememberMeServices.loginSuccess(request, response, authResult);

    // Fire event
    if (this.eventPublisher != null) {
        eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
                authResult, this.getClass()));
    }

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

根据如上代码我们可以确定rememberMeServices实现了具体的业务逻辑,所以我们继续跟踪此代码实现。

@Override
public final void loginSuccess(HttpServletRequest request,
        HttpServletResponse response, Authentication successfulAuthentication) {
    //【断点】此处会进行parameter参数的校验
    if (!rememberMeRequested(request, parameter)) {
        logger.debug("Remember-me login not requested.");
        return;
    }
    //【断点】登录成功的业务逻辑,具体吉见PersistentTokenBasedRememberMeServices的分析
    onLoginSuccess(request, response, successfulAuthentication);
}

parameter参数为"remember-me"

private String parameter = DEFAULT_PARAMETER;
public static final String DEFAULT_PARAMETER = "remember-me"

此处我们继续跟踪rememberMeRequested方法

protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
    if (alwaysRemember) {
        return true;
    }
    //【断点】获取请求参数"remember-me"对应的值
    String paramValue = request.getParameter(parameter);

    if (paramValue != null) {
        //其具体值可以是:true、on、yes、1,其中英文部分忽略大小写
        if (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on")
                || paramValue.equalsIgnoreCase("yes") || paramValue.equals("1")) {
            return true;
        }
    }

    if (logger.isDebugEnabled()) {
        logger.debug("Did not send remember-me cookie (principal did not set parameter '"
                + parameter + "')");
    }

    return false;
}

【注意】可以看到,由于框架硬编码parameter的key值为"remember-me",所以我们前端代码传递此值时需要设置为"remember-me"才能被正确识别到。其具体值可以是:true、on、yes、1,其中英文部分忽略大小写。

protected void onLoginSuccess(HttpServletRequest request,
        HttpServletResponse response, Authentication successfulAuthentication) {
    String username = successfulAuthentication.getName();

    logger.debug("Creating new persistent login for user " + username);

    PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
            username, generateSeriesData(), generateTokenData(), new Date());
    try {
        //将Token持久化到数据库
        tokenRepository.createNewToken(persistentToken);
        //将Token写入到浏览器的Cookie当中,注意项目是否支持Cookie
        addCookie(persistentToken, request, response);
    }
    catch (Exception e) {
        logger.error("Failed to save persistent token ", e);
    }
}

JdbcTokenRepositoryImpl#createNewToken方法会执行如下SQL

"insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";

2. 代码实现

<label><input type="checkbox" name="remember-me" value="true"> 记住 下次自动登录</label>

在spring-security.xml配置文件中添加remember me相关配置,如下:

<security:http auto-config="true" use-expressions="true">
    <!--
        注意:此处其余代码不变,这里省略了。
    -->
    <!--开启remember me过滤器,设置token存储时间为60秒-->
    <security:remember-me token-validity-seconds="60"/>
</security:http>

官方持久化表格看这个网址:Persistent Token Approach

此处对应MySQL数据库做了简单调整,如下:

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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

在先前的remember-me配置中,添加数据源

<security:remember-me
        data-source-ref="dataSource"
        token-validity-seconds="60"/>

补充:如果前端真的不是"remember-me",也可以通过remember-me-parameter="remember-me"属性进行覆盖。

三、显示当前用户名

1. 通过SecurityContextHolder获取SecurityContext,进而获取我们所需的user信息。

//从后台获取当前认证通过后的用户名
//对应 <security:authentication property="name" />
String name = SecurityContextHolder.getContext().getAuthentication().getName();
//对应 <security:authentication property="principal.username" />
String username = ((SysUser)(SecurityContextHolder.getContext().getAuthentication().getPrincipal())).getUsername();

具体的封装可以根据实际Result结果类进行操作即可。

四、权限管理

1. 页面控制-动态展示菜单

<%@taglib uri="http://www.springframework.org/security/tags" prefix="security" %>
<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>
    <security:authorize access="hasAnyRole('ROLE_ORDER','ROLE_ADMIN')">
        <li id="system-setting"><a
                href="${pageContext.request.contextPath}/order/findAll">
            <i class="fa fa-circle-o"></i> 订单管理
        </a></li>
    </security:authorize>

注意:此处的设置仅起到显示及隐藏的作用,如果知道对应的请求路径,还是可以正常访问的。

2. 授权操作

注意:本案例是原始版,都是Spring、SpringMVC这些原始的技术,所以部分概念跟Spring Boot不一样。比如此处的父子容器,Spring Boot没有父子容器的概念。

Ⅰ ServletContext容器

Ⅱ SpringIOC容器

a、该容器由Spring的核心监听器ContextLoaderListener加载,为父容器;
b、父容器中的对象可以被子容器调用,如:service对象;
注意:父容器中的对象不能被http请求访问。

Ⅲ SpringIOC子容器

a、该容器由前端控制器DispatcherServlet加载,为子容器;
b、子容器可以访问父容器中的对象,如:controller中调用service对象;
注意:只有子容器中的对象才可以被http请求访问。

通过观察如上特点,就可以理解之前是在applicationContext.xml中<import resource="classpath:spring-security.xml"/>引入SpringSecurity配置文件。
这样的配置更为安全,进对外暴露http请求相关的bean对象。其余的对象放在父容器中,只有我们编辑特定的业务代码才能实现调用。
所以,三层架构中(表现层、业务层、持久层),我们应该将Spring Security相关的配置放在业务层实现。

Ⅰ 在spring-security.xml配置文件中添加如下配置:

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

注意:Security相关注解需要与我们开启注解支持的容器在同一层,不然无法生效。另外,Spring Boot没有父子容器的概念。

Ⅱ 在业务层添加相关注解:

@Override
@Secured(value = {"ROLE_PRODUCT", "ROLE_ADMIN"})
public List<ProductDTO> findAll() {
    ...
}
@Override
@Secured(value = {"ROLE_ORDER", "ROLE_ADMIN"})
public List<OrderDTO> findAll() {
    ...
}

此时如果没有对应权限,即使知道访问路径也会报错org.springframework.security.access.AccessDeniedException: Access is denied

3. 注解介绍

该注解是SpringSecurity提供的,用于限定访问权限

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Secured {
    //填写具体权限,如:ROLE_USER、ROLE_ADMIN
    public String[] value();
}

该注解由jsr250提供,作用与@Secured一致

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface RolesAllowed {
    //填写具体权限,如:ROLE_USER、ROLE_ADMIN
    String[] value();
}

使用方式

@RolesAllowed(value = {"ROLE_ORDER", "ROLE_ADMIN"})
@Override
public String findAll() {
    return "order-list";
}

该注解是Spring提供的,作用与@Secured一致

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface PreAuthorize {
    //填写具体权限,如:ROLE_USER、ROLE_ADMIN
    //是使用EL表达式填写的
    String value();
}

EL表达式可填写的选项:
Ⅰ hasAuthority、hasAnyAuthority、hasAnyAuthorityName
Ⅱ hasRole、hasAnyRole
Ⅲ hasPermission
Ⅳ isAuthenticated、isFullyAuthenticated

使用方式

@PreAuthorize(value = "hasAnyRole('ROLE_PRODUCT','ROLE_ADMIN')")
@Override
public String findAll() {
    return "product-list";
}

五、异常处理

1. 方式一(不推荐)

在spring-security.xml配置文件中添加异常处理,如下:

<security:http auto-config="true" use-expressions="true">
    <!--注意:此处仅是省略了其余内容的展示,并不是没有!-->
    <!--处理403异常-->
    <security:access-denied-handler error-page="403.jsp"/>
</security:http>

注意:此种方法仅能处理403 org.springframework.security.access.AccessDeniedException: Access is denied 异常。

2. 方式二(不推荐)

在web.xml配置文件中配置异常处理,如下:

<!--处理403异常-->
<error-page>
    <error-code>403</error-code>
    <location>/403.jsp</location>
</error-page>

web工程可以考虑使用这种处理方案,但是前后端分离的情形下也不推荐。

3. 方式三(不推荐)

自定义异常处理类实现HandlerExceptionResolver接口,如下:

@Component
public class ControllerExceptionHandler implements HandlerExceptionResolver {
    /**
     * 统一异常处理
     * @param request 请求对象
     * @param response 相应对象
     * @param handler 出现异常的对象
     * @param ex 异常对象
     * @return mav
     */
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        ModelAndView modelAndView = new ModelAndView();
        //将异常信息放入Request域中,基本不用
        modelAndView.addObject("errorMsg", ex.getMessage());
        //指定不同异常跳转的页面
        if (ex instanceof AccessDeniedException) {
            //modelAndView.setViewName("forward:/403.jsp");
            modelAndView.setViewName("redirect:/403.jsp");
        } else {
            modelAndView.setViewName("redirect:/500.jsp");
        }
        return modelAndView;
    }
}

4. 方式四(推荐)

使用@ControllerAdvice + @ExceptionHandler注解,如下:

@ControllerAdvice
public class HandlerControllerAdvice {

    @ExceptionHandler({AccessDeniedException.class})
    public String handlerAccessDeniedException(){
        return "redirect:/403.jsp";
    }

    @ExceptionHandler({Throwable.class})
    public String handlerRemainingException(){
        return "redirect:/500.jsp";
    }
}

六、结尾

以上即为功能增强篇的全部内容,感谢阅读。

附件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: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/security
                http://www.springframework.org/schema/security/spring-security.xsd">
    <!--释放静态资源-->
    <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"/>
    <!--配置SpringSecurity-->
    <!--
        auto-config="true" 表示自动加载SpringSecurity的配置文件
        use-expressions="true" 表示使用Spring的EL表达式来配置
    -->
    <security:http auto-config="true" use-expressions="true">
        <!--让认证页面可以匿名访问-->
        <security:intercept-url pattern="/login.jsp" access="permitAll()"/>
        <!--拦截资源-->
        <!--
            pattern="/**" 表示拦截所有资源
            access="hasAnyRole('ROLE_USER') 表示只有ROLE_USER角色才能访问资源
        -->
        <security:intercept-url pattern="/**" access="hasAnyRole('ROLE_USER')"/>
        <!--配置认证信息-->
        <security:form-login login-page="/login.jsp"
                             login-processing-url="/login"
                             default-target-url="/index.jsp"
                             authentication-failure-url="/failer.jsp"/>
        <!--配置退出登录信息-->
        <security:logout logout-url="/logout"
                         logout-success-url="/login.jsp"/>
        <!--禁用CSRF拦截的过滤器-->
        <!--<security:csrf disabled="true"/>-->

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

        <!--处理403异常-->
        <!--<security:access-denied-handler error-page="403.jsp"/>-->
    </security:http>

    <!--把加密对象放入IOC容器中-->
    <bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>

    <!--设置SpringSecurity认证用户的来源-->
    <!--
        SpringSecurity默认是时加密认证的
        加上{noop}表示不加密认证
    -->
    <security:authentication-manager>
        <security:authentication-provider user-service-ref="userServiceImpl">
            <security:password-encoder ref="passwordEncoder"/>
            <!--如下配置是固定了数据来源-->
            <!--<security:user-service>
                <security:user name="user" password="{noop}user"
                               authorities="ROLE_USER" />
                <security:user name="admin" password="{noop}admin"
                               authorities="ROLE_ADMIN" />
            </security:user-service>-->
        </security:authentication-provider>
    </security:authentication-manager>

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

猜你喜欢

热点阅读