Spring Security——入门介绍

目录

本文章的笔记整理来自黑马视频https://www.bilibili.com/video/BV1vt4y1i7zA,相关资料可以在该视频的评论区进行获取。该资料包括了提前准备好一个半成品的后台管理系统,而想要完善另一部分,就需要用到SpringSecurity。

1.初识Spring Security

1.1.Spring Security概念

Spring Security是spring采用AOP思想,基于servlet过滤器实现的安全框架。它提供了完善的认证机制和方法级的授权功能。是一款非常优秀的权限管理框架。其官方网址为:https://spring.io/projects/spring-security

1.2.权限管理概念

(1)权限管理,一般指根据系统设置的安全规则或者安全策略,用户可以访问而且只能访问自己被授权的资源。权限管理几乎出现在任何系统里面,前提是需要有用户和密码认证的系统。
(2)在权限管理的概念中,有两个非常重要的名词:

认证通过用户名和密码成功登陆系统后,让系统得到当前用户的角色身份。
授权系统根据当前用户的角色,给其授予对应可以操作的权限资源。

1.3.完成权限管理需要三个对象

用户主要包含用户名,密码和当前用户的角色信息,可实现认证操作。
角色主要包含角色名称,角色描述和当前角色拥有的权限信息,可实现授权操作。
权限权限也可以称为菜单,主要包含当前权限名称,url地址等信息,可实现动态展示菜单。

注:这三个对象中,用户与角色是多对多的关系,角色与权限是多对多的关系,用户与权限没有直接关系,二者是通过角色来建立关联关系的。

2.Spring Security——简单入门

2.1.创建web工程并导入jar包

(1)web工程已事先准备好,不需要再创建。
(2)在pom.xml文件中导入Spring Security的相关依赖

<!--导入Spring Security相关的jar包-->
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-config</artifactId>
    <version>5.1.5.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-taglibs</artifactId>
    <version>5.1.5.RELEASE</version>
</dependency>

2.2.在web.xml中配置Spring Security过滤器链

<!--Spring Security过滤器链,注意过滤器名称必须叫springSecurityFilterChain-->
<filter>
    <filter-name>springSecurityFilterChain</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
    <filter-name>springSecurityFilterChain</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

2.3.编写Spring Security配置文件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: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/security
        http://www.springframework.org/schema/security/spring-security.xsd">

    <!--设置可以用spring的el表达式配置Spring Security并自动生成对应配置组件(过滤器)-->
    <!--
        auto-config="true"  表示自动加载springsecurity的配置文件
        use-expressions="true" 表示使用spring的el表达式来配置springsecurity
    -->
    <security:http auto-config="true" use-expressions="true">
        <!--使用spring的el表达式来指定项目所有资源访问都必须有ROLE_USER或ROLE_ADMIN角色-->
        <security:intercept-url pattern="/**" access="hasAnyRole('ROLE_USER','ROLE_ADMIN')"/>
    </security:http>

    <!--设置Spring Security认证用户信息的来源-->
    <!-- springsecurity默认的认证必须是加密的,加上{noop}表示不加密认证。-->
    <security:authentication-manager>
        <security:authentication-provider>
            <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>
</beans>

2.4.将spring-security.xml配置文件引入到applicationContext.xml中

<!--引入SpringSecurity主配置文件-->
<import resource="classpath:spring-security.xml"/>

2.5.启动项目

(1)此时启动项目后,发现浏览器中的也页面为以下的登录页面(该页面由Spring Security提供),其原因在于spring-security.xml中设置了只有ROLE_USER或ROLE_ADMIN角色才能访问所有资源,而项目启动时默认访问index.jsp页面前却并没有得到这些角色,所以就会跳转到Spring Security事先提供的登录页面。
在这里插入图片描述
(2)不过,此时可以根据spring-security.xml中提前好的认证用户信息来进行登录,即输入用户名user(或者admin),以及密码user(或者admin)就可以登录。
在这里插入图片描述

3.Spring Security——过滤器链

3.1.Spring Security常用过滤器介绍

1org.springframework.security.web.context.SecurityContextPersistenceFilter

非常重要的一个过滤器,SecurityContextPersistenceFilter主要是使用SecurityContextRepository在session中保存或更新一个SecurityContext,并将SecurityContext给以后的过滤器使用,来为后续filter建立所需的上下文。SecurityContext中存储了当前用户的认证以及权限信息。

2org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter

此过滤器用于集成SecurityContext到Spring异步执行机制中的WebAsyncManager

3org.springframework.security.web.header.HeaderWriterFilter

向请求的Header中添加相应的信息,可在http标签内部使用security:headers来控制

4org.springframework.security.web.csrf.CsrfFilter

csrf又称跨域请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的token信息,如果不包含,则报错。起到防止csrf攻击的效果。

5org.springframework.security.web.authentication.logout.LogoutFilter

匹配URL为/logout的请求,实现用户退出,清除认证信息。

6org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

认证操作全靠这个过滤器,默认匹配URL为/login且必须为POST请求。

7org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter

如果没有在配置文件中指定认证页面,则由该过滤器生成一个默认认证页面。

8org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter

由此过滤器可以生产一个默认的退出登录页面

9org.springframework.security.web.authentication.www.BasicAuthenticationFilter

此过滤器会自动解析HTTP请求中头部名字为Authentication,且以Basic开头的头信息。

10org.springframework.security.web.savedrequest.RequestCacheAwareFilter

通过HttpSessionRequestCache内部维护了一个RequestCache,用于缓存HttpServletRequest

11org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter

针对ServletRequest进行了一次包装,使得request具有更加丰富的API

12org.springframework.security.web.authentication.AnonymousAuthenticationFilter

当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存入到SecurityContextHolder中。spring security为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。

13org.springframework.security.web.session.SessionManagementFilter

SecurityContextRepository限制同一用户开启多个会话的数量

14org.springframework.security.web.access.ExceptionTranslationFilter

异常转换过滤器位于整个springSecurityFilterChain的后方,用来转换整个链路中出现的异常

15org.springframework.security.web.access.intercept.FilterSecurityInterceptor

获取所配置资源访问的授权信息,根据SecurityContextHolder中存储的用户信息来决定其是否有权限。

3.2. Spring Security过滤器链加载原理

3.2.1.DelegatingFilterProxy

之前在web.xml中配置了一个名称为springSecurityFilterChain的过滤器DelegatingFilterProxy,接下直接对DelegatingFilterProxy源码里重要代码进行说明,其中删减掉了一些不重要的代码,关注注释部分即可。

public class DelegatingFilterProxy extends GenericFilterBean {
	@Nullable
	private String contextAttribute;
	@Nullable
	private WebApplicationContext webApplicationContext;
	@Nullable
	private String targetBeanName;
	private boolean targetFilterLifecycle;
	@Nullable
	private volatile Filter delegate;//注:这个过滤器才是真正加载的过滤器
	private final Object delegateMonitor;
	
	//注:doFilter才是过滤器的入口,直接从这看!
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain
		filterChain) throws ServletException, IOException {
		Filter delegateToUse = this.delegate;
		if (delegateToUse == null) {
			synchronized(this.delegateMonitor) {
				delegateToUse = this.delegate;
				if (delegateToUse == null) {
					WebApplicationContext wac = this.findWebApplicationContext();
					if (wac == null) {
						throw new IllegalStateException("No WebApplicationContext found: no ContextLoaderListener or DispatcherServlet registered?");
					}
					//第一步:doFilter中最重要的一步,初始化上面私有过滤器属性delegate
					delegateToUse = this.initDelegate(wac);
				}	
				this.delegate = delegateToUse;
			}
		}
		//第三步:执行FilterChainProxy过滤器
		this.invokeDelegate(delegateToUse, request, response, filterChain);
	}
	
	//第二步:直接看最终加载的过滤器到底是谁
	protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
		//debug得知targetBeanName为:springSecurityFilterChain
		String targetBeanName = this.getTargetBeanName();
		Assert.state(targetBeanName != null, "No target bean name set");
		//debug得知delegate对象为:FilterChainProxy
		Filter delegate = (Filter)wac.getBean(targetBeanName, Filter.class);
		if (this.isTargetFilterLifecycle()) {
			delegate.init(this.getFilterConfig());
		}
		return delegate;
	}
	
	protected void invokeDelegate(Filter delegate, ServletRequest request, ServletResponse
		response, FilterChain filterChain) throws ServletException, IOException {
		delegate.doFilter(request, response, filterChain);
	}
}

第二步的debug结果如下:
在这里插入图片描述
由此可知,DelegatingFilterProxy通过springSecurityFilterChain得到了一个FilterChainProxy过滤器,最终在第三步执行了这个过滤器。

3.2.2.FilterChainProxy

public class FilterChainProxy extends GenericFilterBean {
	private static final Log logger = LogFactory.getLog(FilterChainProxy.class);
	private static final String FILTER_APPLIED = FilterChainProxy.class.getName().concat(".APPLIED");
	private List<SecurityFilterChain> filterChains;
	private FilterChainProxy.FilterChainValidator filterChainValidator;
	private HttpFirewall firewall;
	
	//咿!?可以通过一个叫SecurityFilterChain的对象实例化出一个FilterChainProxy对象
	//这FilterChainProxy又是何方神圣?会不会是真正的过滤器链对象呢?先留着这个疑问!
	public FilterChainProxy(SecurityFilterChain chain) {
		this(Arrays.asList(chain));
	}
	//又是SecurityFilterChain这家伙!嫌疑更大了!
	public FilterChainProxy(List<SecurityFilterChain> filterChains) {
		this.filterChainValidator = new FilterChainProxy.NullFilterChainValidator();
		this.firewall = new StrictHttpFirewall();
		this.filterChains = filterChains;
	}
	//注:直接从doFilter看
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
		throws IOException, ServletException {
		boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
		if (clearContext) {
			try {
			request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
				this.doFilterInternal(request, response, chain);
			} finally {
			SecurityContextHolder.clearContext();
				request.removeAttribute(FILTER_APPLIED);
			}
		} else {
		//第一步:具体操作调用下面的doFilterInternal方法了
		this.doFilterInternal(request, response, chain);
		}
	}

	private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) 
		throws IOException, ServletException {
		FirewalledRequest fwRequest =
		this.firewall.getFirewalledRequest((HttpServletRequest)request);
		HttpServletResponse fwResponse =
		this.firewall.getFirewalledResponse((HttpServletResponse)response);
		//第二步:封装要执行的过滤器链,那么多过滤器就在这里被封装进去了!
		List<Filter> filters = this.getFilters((HttpServletRequest)fwRequest);
		if (filters != null && filters.size() != 0) {
			FilterChainProxy.VirtualFilterChain vfc = new
			FilterChainProxy.VirtualFilterChain(fwRequest, chain, filters);
			//第四步:加载过滤器链
			vfc.doFilter(fwRequest, fwResponse);
		} else {
			if (logger.isDebugEnabled()) {
			logger.debug(UrlUtils.buildRequestUrl(fwRequest) + (filters == null ? " has no
			matching filters" : " has an empty filter list"));
			}
			fwRequest.reset();
			chain.doFilter(fwRequest, fwResponse);
		}
	}

	private List<Filter> getFilters(HttpServletRequest request) {
		Iterator var2 = this.filterChains.iterator();
		//第三步:封装过滤器链到SecurityFilterChain中!
		SecurityFilterChain chain;
		do {
			if (!var2.hasNext()) {
				return null;
			}
			chain = (SecurityFilterChain)var2.next();
		} while(!chain.matches(request));
		return chain.getFilters();
	}
}

第二步debug结果如下图所示,十五个过滤器都在这里了!
在这里插入图片描述
再看第三步,这些过滤器都被封装进SecurityFilterChain中了。

3.2.3.FilterChainProxy

最后看SecurityFilterChain,这是个接口,实现类也只有一个,这才是web.xml中配置的过滤器链对象!

//接口
public interface SecurityFilterChain {
	boolean matches(HttpServletRequest var1);
	List<Filter> getFilters();
}
	
//实现类
public final class DefaultSecurityFilterChain implements SecurityFilterChain {
	private static final Log logger = LogFactory.getLog(DefaultSecurityFilterChain.class);
	private final RequestMatcher requestMatcher;
	private final List<Filter> filters;
	
	public DefaultSecurityFilterChain(RequestMatcher requestMatcher, Filter... filters) {
		this(requestMatcher, Arrays.asList(filters));
	}
	
	public DefaultSecurityFilterChain(RequestMatcher requestMatcher, List<Filter> filters) {
		logger.info("Creating filter chain: " + requestMatcher + ", " + filters);
		this.requestMatcher = requestMatcher;
		this.filters = new ArrayList(filters);
	}
	
	public RequestMatcher getRequestMatcher() {
		return this.requestMatcher;
	}
	public List<Filter> getFilters() {
	return this.filters;
	}
	
	public boolean matches(HttpServletRequest request) {
		return this.requestMatcher.matches(request);
	}
	
	public String toString() {
		return "[ " + this.requestMatcher + ", " + this.filters + "]";
	}
}

4.SpringSecurity——使用自定义认证页面

4.1.指定认证页面配置信息

(1)在SpringSecurity主配置文件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: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/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的el表达式配置Spring Security并自动生成对应配置组件(过滤器)-->
    <!--
        auto-config="true"  表示自动加载springsecurity的配置文件
        use-expressions="true" 表示使用spring的el表达式来配置springsecurity
    -->
    <security:http auto-config="true" use-expressions="true">
        <!--让认证页面可以匿名访问-->
        <security:intercept-url pattern="/login.jsp" access="permitAll()"/>
        <!--使用spring的el表达式来指定项目所有资源访问都必须有ROLE_USER或ROLE_ADMIN角色-->
        <security:intercept-url pattern="/**" access="hasAnyRole('ROLE_USER','ROLE_ADMIN')"/>
        <!--配置认证信息-->
        <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"/>
    </security:http>

    <!--设置Spring Security认证用户信息的来源-->
    <!-- springsecurity默认的认证必须是加密的,加上{noop}表示不加密认证。-->
    <security:authentication-manager>
        <security:authentication-provider>
            <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>
</beans>

(2)修改登录认证页面的请求地址,这里有以下两点主要注意:
① 处理登录请求(即/login)的控制器由SpringSecurity提供。
② 登录的请求方式必须是POST。
在这里插入图片描述
(3)启动项目,即可来到自定义的登录认证页面
在这里插入图片描述
(4)但是当输入spring-security.xml中实现配置的认证用户信息时(即输入用户名user/admin,以及密码user/admin),发现出现了以下的界面:
在这里插入图片描述
403异常是SpringSecurity中的权限不足!其实在上面SpringSecurity内置认证页面源码中有一个隐藏的input,其name的属性值为_csrf,问题就出在这里!它是下一节就要讲到SpringSecurity的csrf防护机制。
在这里插入图片描述

4.2.SpringSecurity的csrf防护机制

CSRF(Cross-site request forgery),即跨站请求伪造,是一种难以防范的网络攻击方式。

4.2.1.SpringSecurity中CsrfFilter过滤器说明

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package org.springframework.security.web.csrf;

import java.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.Assert;
import org.springframework.web.filter.OncePerRequestFilter;

public final class CsrfFilter extends OncePerRequestFilter {
    public static final RequestMatcher DEFAULT_CSRF_MATCHER = new CsrfFilter.DefaultRequiresCsrfMatcher();
    private final Log logger = LogFactory.getLog(this.getClass());
    private final CsrfTokenRepository tokenRepository;
    private RequestMatcher requireCsrfProtectionMatcher;
    private AccessDeniedHandler accessDeniedHandler;

    public CsrfFilter(CsrfTokenRepository csrfTokenRepository) {
        this.requireCsrfProtectionMatcher = DEFAULT_CSRF_MATCHER;
        this.accessDeniedHandler = new AccessDeniedHandlerImpl();
        Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");
        this.tokenRepository = csrfTokenRepository;
    }

	//通过这里可以看出SpringSecurity的csrf机制把请求方式分成两类来处理
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        request.setAttribute(HttpServletResponse.class.getName(), response);
        CsrfToken csrfToken = this.tokenRepository.loadToken(request);
        boolean missingToken = csrfToken == null;
        if (missingToken) {
            csrfToken = this.tokenRepository.generateToken(request);
            this.tokenRepository.saveToken(csrfToken, request, response);
        }

        request.setAttribute(CsrfToken.class.getName(), csrfToken);
        request.setAttribute(csrfToken.getParameterName(), csrfToken);
        //第一类:"GET", "HEAD", "TRACE", "OPTIONS"四类请求可以直接通过
        if (!this.requireCsrfProtectionMatcher.matches(request)) {
            filterChain.doFilter(request, response);
        } else {
        	//第二类:除去上面四类,包括POST都要被验证携带token才能通过
            String actualToken = request.getHeader(csrfToken.getHeaderName());
            if (actualToken == null) {
                actualToken = request.getParameter(csrfToken.getParameterName());
            }

            if (!csrfToken.getToken().equals(actualToken)) {
                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request));
                }

                if (missingToken) {
                    this.accessDeniedHandler.handle(request, response, new MissingCsrfTokenException(actualToken));
                } else {
                    this.accessDeniedHandler.handle(request, response, new InvalidCsrfTokenException(csrfToken, actualToken));
                }

            } else {
                filterChain.doFilter(request, response);
            }
        }
    }

    public void setRequireCsrfProtectionMatcher(RequestMatcher requireCsrfProtectionMatcher) {
        Assert.notNull(requireCsrfProtectionMatcher, "requireCsrfProtectionMatcher cannot be null");
        this.requireCsrfProtectionMatcher = requireCsrfProtectionMatcher;
    }

    public void setAccessDeniedHandler(AccessDeniedHandler accessDeniedHandler) {
        Assert.notNull(accessDeniedHandler, "accessDeniedHandler cannot be null");
        this.accessDeniedHandler = accessDeniedHandler;
    }

    private static final class DefaultRequiresCsrfMatcher implements RequestMatcher {
        private final HashSet<String> allowedMethods;

        private DefaultRequiresCsrfMatcher() {
            this.allowedMethods = new HashSet(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));
        }

        public boolean matches(HttpServletRequest request) {
            return !this.allowedMethods.contains(request.getMethod());
        }
    }
}

通过源码分析,我们可以知道自己的认证页面,请求方式为POST,但却没有携带token,所以才出现了403权限不足的异常。那么处理这个问题一般有以下两种方式(下一小节将详细展开):
方式一:直接禁用csrf(不推荐,因为开启csrf有助于系统的安全性)。
方式二:在认证页面携带token请求。

4.2.2.处理403权限不足的异常

(1)直接禁用csrf(不推荐),即在spring-security.xml文件中添加禁用crsf防护的配置。

<!--禁用csrf防护机制(写在security:http标签中)-->
<security:csrf disabled="true"/>

(2) 在认证页面携带token请求
在这里插入图片描述

4.3.SpringSecurity注销功能

(1)在spring-security.xml中配置退出登录信息(这个之前已经配置好)

<!--配置退出登录信息-->
<security:logout logout-url="/logout"
                 logout-success-url="/login.jsp"/>

(2)在注销相关的前端页面进行修改,即添加SpringSecurity标签库使用POST请求动态携带token(一旦开启了csrf防护功能,logout处理器便只支持POST请求方式了!)。
在这里插入图片描述

5.SpringSecurity——使用数据库数据完成认证

目前数据库中相关的表如下图所示:
在这里插入图片描述

5.1.认证流程分析

想要分析SpringSecurity具体的认证流程,可以从主要负责认证的过滤器UsernamePasswordAuthenticationFilter开始(此处不便详细展开)。

5.2.初步实现认证功能

5.2.1.自定义的UserService接口继承UserDetailsService

UserDetailsService是SpringSecurity内部的,要想让SpringSecurity认识自定义的UserService,那么只需让UserService接口继承UserDetailsService,并重写相应的方法即可。

package com.itheima.service;

import com.itheima.domain.SysUser;
import org.springframework.security.core.userdetails.UserDetailsService;

import java.util.List;
import java.util.Map;

//自定义的UserService继承SpringSecurity内部的UserDetailsService
public interface UserService extends UserDetailsService {

    public void save(SysUser user);

    public List<SysUser> findAll();

    public Map<String, Object> toAddRolePage(Integer id);

    public void addRoleToUser(Integer userId, Integer[] ids);
}

5.2.2.编写loadUserByUsername业务

UserService接口继承UserDetailsService之后,在UserServiceImpl中重写loadUserByUsername方法。

/**
 * 认证业务
 * @param username 用户在浏览器输入的用户名
 * @return UserDetails 是springsecurity自己的用户对象
 * @throws UsernameNotFoundException
 */
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    try {
        //根据用户名做查询
        SysUser sysUser = userDao.findByName(username);
        if(sysUser==null){
            return null;
        }
        //authorities:当前用户所拥有的权限
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        //获取当前拥有的角色
        List<SysRole> roles = sysUser.getRoles();
        for (SysRole role : roles) {
            authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
        }
        /*
            1.{noop}后面的密码,springsecurity会认为是原文。
            2.浏览器此时已经有了用户输入的密码,然后将其与"{noop}"+sysUser.getPassword()进行比较即可
         */
        UserDetails userDetails = new User(sysUser.getUsername(), "{noop}"+sysUser.getPassword(), authorities);
        return userDetails;
    }catch (Exception e){
        e.printStackTrace();
        //返回null时,SpringSecurity会认为认证失败!
        return null;
    }
}

5.2.3.在spring-security.xml中指定认证使用的业务对象

<!--
	1.设置Spring Security认证用户信息的来源
	2.由于UserServiceImpl已经被注册到Spring容器中,那么在引用其对象名时,默认为类名的首字母小写
-->
<security:authentication-manager>
    <security:authentication-provider user-service-ref="userServiceImpl">
    </security:authentication-provider>
</security:authentication-manager>

最后来进行测试:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
需要注意的是,如果要修改用户的角色信息,也需要在相应页面添加SpringSecurity标签库使用POST请求动态携带token

5.3.加密认证

5.3.1.在spring-security.xml提供加密对象

<!--把加密对象放到IoC容器中-->
<bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"></bean>
<!--设置Spring Security认证用户信息的来源-->
<security:authentication-manager>
    <security:authentication-provider user-service-ref="userServiceImpl">
        <security:password-encoder ref="passwordEncoder"/>
    </security:authentication-provider>
</security:authentication-manager>

5.3.2.修改认证方法

即去掉loadUserByUsername()中的{noop}

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    try {
        //根据用户名做查询
        SysUser sysUser = userDao.findByName(username);
        if(sysUser==null){
            return null;
        }
        //authorities:当前用户所拥有的权限
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        //获取当前拥有的角色
        List<SysRole> roles = sysUser.getRoles();
        for (SysRole role : roles) {
            authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
        }
        /*
            1.{noop}后面的密码,springsecurity会认为是原文。
            2.浏览器此时已经有了用户输入的密码,然后将其与"{noop}"+sysUser.getPassword()进行比较即可
         */
        UserDetails userDetails = new User(sysUser.getUsername(), sysUser.getPassword(), authorities);
        return userDetails;
    }catch (Exception e){
        e.printStackTrace();
        //认证失败!
        return null;
    }
}

5.3.3.修改添加用户的操作

即对用户密码进行加密操作后,再存入数据库

//注入加密对象
@Autowired
private BCryptPasswordEncoder passwordEncoder;

@Override
public void save(SysUser user) {
    //对用户密码进行加密操作
    user.setPassword(passwordEncoder.encode(user.getPassword()));
    userDao.save(user);
}

5.3.4.手动将数据库中用户密码改为加密后的密文

可以编写一个测试用例,获取明文密码加密后的密文,最后再将其写入数据库。
在这里插入图片描述

6.设置用户状态

6.1.源码分析

在用户认证业务里,SpringSecurity封装User对象时,选择了三个构造参数的构造方法,其实还有另一个构造方法:

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,这四个布尔值的具体含义如下:

boolean enabled是否可用
boolean accountNonExpired账户是否失效
boolean credentialsNonExpired秘密是否失效
boolean accountNonLocked账户是否锁定

6.2.判断认证用户的状态

上面的四个参数必须同时为true时,认证才通过,为了节省时间,这里只用第一个布尔值做测试,修改后的认证业务代码如下:

/**
* 认证业务
 * @param username 用户在浏览器输入的用户名
 * @return UserDetails 是springsecurity自己的用户对象
 * @throws UsernameNotFoundException
 */
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    try {
        //根据用户名做查询
        SysUser sysUser = userDao.findByName(username);
        if(sysUser==null){
            return null;
        }
        //authorities:当前用户所拥有的权限
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        //获取当前拥有的角色
        List<SysRole> roles = sysUser.getRoles();
        for (SysRole role : roles) {
            authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
        }
        /*
            1.{noop}后面的密码,springsecurity会认为是原文。
            2.浏览器此时已经有了用户输入的密码,然后将其与"{noop}"+sysUser.getPassword()进行比较即可
         */
        UserDetails userDetails = new User(sysUser.getUsername(),
                sysUser.getPassword(),
                sysUser.getStatus()==1,
                true,
                true,
                true,
                authorities);
        return userDetails;
    }catch (Exception e){
        e.printStackTrace();
        //认证失败!
        return null;
    }
}

此刻,只有用户状态为1的用户才能成功通过认证!(用户表结构如下图所示)
在这里插入图片描述

7.remember me

7.1.记住我功能原理分析

public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler { 
	public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { 
		// 判断是否勾选记住我 
		// 注意:这里this.parameter点进去是上面的private String parameter = "remember-me"; 
		if (!this.rememberMeRequested(request, this.parameter)) { 
			this.logger.debug("Remember-me login not requested."); } 
		else { 
			//若勾选就调用onLoginSuccess方法 
			this.onLoginSuccess(request, response, successfulAuthentication); 
		} 
	} 
}

再点进去上面if判断中的rememberMeRequested方法,还在当前类中:

protected boolean rememberMeRequested(HttpServletRequest request, String parameter) { 
	if (this.alwaysRemember) {
		return true; 
	} else { 
		// 从上面的字parameter的值为"remember-me" 
		// 也就是说,此功能提交的属性名必须为"remember-me" 
		String paramValue = request.getParameter(parameter); 
		// 这里我们看到属性值可以为:true,on,yes,1。 
		if (paramValue != null && (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on") || paramValue.equalsIgnoreCase("yes") || paramValue.equals("1"))) { 
		//满足上面条件才能返回true 
		return true; 
		} else { 
		if (this.logger.isDebugEnabled()) { 
			this.logger.debug("Did not send remember-me cookie (principal did not set parameter '" + parameter + "')"); }
			return false; 
		} 
	} 
}

如果上面方法返回true,就表示页面勾选了记住我选项了。继续顺着调用的方法找到PersistentTokenBasedRememberMeServices的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);
        //创建记住我的token
        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);
        }
    }
}

7.2.记住我功能页面代码

<form action="${pageContext.request.contextPath}/login" method="post">
	<security:csrfInput/>
	<div class="form-group has-feedback">
		<input type="text" name="username" class="form-control"
			placeholder="用户名"> <span
			class="glyphicon glyphicon-envelope form-control-feedback"></span>
	</div>
	<div class="form-group has-feedback">
		<input type="password" name="password" class="form-control"
			placeholder="密码"> <span
			class="glyphicon glyphicon-lock form-control-feedback"></span>
	</div>
	<div class="row">
		<div class="col-xs-8">
			<div class="checkbox icheck">
				<!--
					1.name属性的值必须为remember-me
					2.value属性的值必须为true、on、yes或1中的一种
				-->
				<label><input type="checkbox" name="remember-me" value="true"> 记住 下次自动登录</label>
			</div>
		</div>
		<!-- /.col -->
		<div class="col-xs-4">
			<button type="submit" class="btn btn-primary btn-block btn-flat">登录</button>
		</div>
		<!-- /.col -->
	</div>
</form>

7.3.开启remember me过滤器

在spring-security.xml中开启remember me过滤器:

<!--开启remember me过滤器,设置token存储时间为60秒(位于security:http标签中)-->
<security:remember-me token-validity-seconds="60"/>

说明:RememberMeAuthenticationFilter中功能非常简单,会在打开浏览器时,自动判断是否认证,如果没有则调用autoLogin进行自动认证。
在这里插入图片描述
在这里插入图片描述

在登录时选中记住复选框,那么在token的有效存储时间内,如果关闭浏览器并再次打开时,就可以不用登录,直接输入后台主页面地址进行访问。

7.4.remember me安全性分析

(1)记住我功能方便是大家看得见的,但是安全性却令人担忧。因为Cookie毕竟是保存在客户端的,很容易盗取,而且cookie的值还与用户名、密码这些敏感数据相关,虽然加密了,但是将敏感信息存在客户端,还是不太安全。那么这就要提醒喜欢使用此功能的,用完网站要及时手动退出登录,清空认证信息。
(2)此外,SpringSecurity还提供了remember me的另一种相对更安全的实现机制 ,即在客户端的cookie中,仅保存一个无意义的加密串(与用户名、密码等敏感数据无关),然后在数据库中保存该加密串-用户信息的对应关系,自动登录时,用cookie中的加密串,到数据库中验证,如果通过,自动登录才算通过。

7.5.持久化remember me信息

(1)创建一张名为persistent_logins表,注意这张表的名称和字段都是官方指定的,不要修改。

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

(2)在spring-security.xml中对之前的配置进行修改:

<!-- 
	开启remember me过滤器 
	data-source-ref="dataSource" 指定数据库连接池 
	token-validity-seconds="60" 设置token存储时间为60秒 可省略
 	remember-me-parameter="remember-me" 指定记住的参数名 可省略 
--> 
<security:remember-me data-source-ref="dataSource" 
						token-validity-seconds="60" 
						remember-me-parameter="remember-me"/>

(3)再次测试后,发现该表中多了一条记录,即remember me的相关信息被持久化在数据库中,相对来说更加安全。
在这里插入图片描述

7.5.显示当前认证用户名

在header.jsp(先要添加Spring Security标签库)中找到页面头部最右侧图片处添加如下信息:

<span class="hidden-xs"> 
	<security:authentication property="name" /> 
</span> 
<!--
	或者
	<span class="hidden-xs"> 
		<security:authentication property="principal.username" /> 
	</span>
-->

8.授权操作

此处的授权操作是指对于拥有不同权限的用户,进入后台管理系统所能进行的操作(或者说到的页面)是不一样的。例如现在在数据库表sys_user中有两个模拟用户xiaoming和xiaoma,xiaoming在后台能进行产品管理,但不能进行订单管理,而xiaoma则正好相反,现在就要实现不同的用户进入后台系统所能看到的功能菜单不一样。

8.1.前期准备工作

为了模拟授权操作,现临时编写两个业务功能,其处理器代码如下:

//ProductController 
@Controller 
@RequestMapping("/product") 
public class ProductController { 
	@RequestMapping("/findAll") 
	public String findAll(){ 
		return "product-list"; 
	} 
}
	
//OrderController 
@Controller 
@RequestMapping("/order") 
public class OrderController {
	@RequestMapping("/findAll") 
	public String findAll(){ 
		return "order-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>

8.2.动态展示菜单

(1)先在数据库中为用户xiaoming和xiaoma准备好相关的角色
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
(2)先在aside.jsp中添加Spring Security的标签库,然后对产品管理、订单管理这两个功能菜单进行如下修改:

<ul class="treeview-menu">
    <%--只有拥有ROLE_PRODUCT或ROLE_ADMIN角色的用户才能看见产品管理这一功能菜单--%>
    <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>

    <%--只有拥有ROLE_ORDER或ROLE_ADMIN角色的用户才能看见订单管理这一功能菜单--%>
    <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>
</ul>

(3)分别使用xiaoming和xiaoma作为用户名进行登录
在这里插入图片描述
在这里插入图片描述
虽然从功能菜单的显示效果上看起来,好像已经对xiaoming和xiaoma授权成功了,但是实际上并没有。例如可以试试直接去访问产品的http请求地址,此时发现原本不能进行产品管理的xiaoma却能直接操作!
在这里插入图片描述
所以说现xiaoma其实是可以操作产品模块的,只是系统没有把产品功能展示给xiaoma而已,即页面动态菜单的展示只是为了用户体验,并未真正控制权限!

8.3.真正的授权操作

说明:SpringSecurity可以通过注解的方式来控制类或者方法的访问权限。注解需要对应的注解支持,若注解放在controller类中,对应注解支持应该放在mvc配置文件中,因为controller类是有mvc配置文件扫描并创建的,同理,注解放在service类中,对应注解支持应该放在spring配置文件中。由于我们现在是模拟业务操作,并没有service业务代码,所以就把注解放在controller类中了。
在这里插入图片描述
(1)开启授权的注解支持
在SpringMVC的配置文件spring-mvc.xml中开启授权的注解支持

<!--
    开启权限的注解支持:
    secured-annotations="enabled":SpringSecurity内部的权限控制注解开关
    pre-post-annotations="enabled":Spring指定的权限控制的注解开关
    jsr250-annotations="enabled":开启java250注解支持
-->
<security:global-method-security
        secured-annotations="enabled"
        pre-post-annotations="enabled"
        jsr250-annotations="enabled"/>

(2)在注解支持对应类或者方法上添加注解

//ProductController
//表示当前用户需要ROLE_PRODUCT或者ROLE_ADMIN才能访问该方法
//@Secured({"ROLE_PRODUCT","ROLE_ADMIN"})//springSecurity内部制定的注解
//@RolesAllowed({"ROLE_PRODUCT","ROLE_ADMIN"})//jsr250注解
@PreAuthorize("hasAnyAuthority('ROLE_PRODUCT','ROLE_ADMIN')")//spring的el表达式注解
@RequestMapping("/findAll")
public String findAll(){
    return "product-list";
}

//OrderController
//表示当前用户需要ROLE_ORDER或者ROLE_ADMIN角色才能访问该方法
@Secured({"ROLE_ORDER","ROLE_ADMIN"})
@RequestMapping("/findAll")
public String findAll(){
    return "order-list";
}

此时用xiaoma作为用户名进行登录,并且再直接去访问产品的http请求地址,会出现权限不足的提示!这说明授权真正地成功了。
在这里插入图片描述

8.4.权限不足异常处理

通过上面的例子,大家也发现了每次权限不足时都出现了403页面,这对用户的观感体验是非常不友好的,所以应该想办法处理一下异常,即跳转到一个相对来说对用户友好易懂的页面。
在这里插入图片描述
(1)异常处理流程图:
在这里插入图片描述
(2)三种常用的处理异常的方式
方式一:在spring-security.xml配置文件中处理

<!--出现403权限不足的异常时,跳转到403.jsp页面(写在security:http标签中)-->
<security:access-denied-handler error-page="/403.jsp"/>

该方式的缺点在于只能处理403权限不足的这种异常,而不能处理其它类型的异常!(不推荐使用)
在这里插入图片描述
方式二:在web.xml中进行处理

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

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

方式三:编写异常处理器(推荐使用)

package com.itheima.controller.advice;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.acls.model.NotFoundException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class HandlerControllerAdvice{
    
    //针对不同的异常类型,跳转到不同的页面
    @ExceptionHandler(AccessDeniedException.class)
    public String handlerException(){
        return "redirect:/403.jsp";
    }
    
    @ExceptionHandler(NotFoundException.class)
    public String notFoundException(){
        return "redirect:/404.jsp";
    }
    
    @ExceptionHandler(RuntimeException.class)
    public String runtimeHandlerException(){
        return "redirect:/500.jsp";
    }
}
  • 5
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

代码星辰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值