1、设置用户状态
在我们的数据库表sys_user中,有一个status字段,用来表示用户的状态,表示:有效或无效等。比如有些用户恶意操作,我们需要对它封号,就需要设置一个状态,这样的话,该用户就无法登录了。根据业务需要,可以设置多个状态字段,Spring Security的User对象也为我们封装一个有关用户状态的构造方法,接下来就来了解下该怎么使用吧
1.1 源码分析
用户认证业务里,我们封装Spring Security的org.springframework.security.core.userdetails.User
对象时,选择了三个构造参数的构造方法,其实还有另一个构造方法:
public class User implements UserDetails, CredentialsContainer {
//......
//三个参数的构造方法
public User(String username, String password, Collection<? extends GrantedAuthority> authorities) {
this(username, password, true, true, true, true, authorities);
}
//多个参数的构造方法,第3个到第6个参数都是状态相关的,必须全部都是true,该对象才能是可用的
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 账户是否锁定
1.2 认证方法修改,添加状态判断
修改UserServiceImpl中的认证方法loadUserByUsername,在返回用户对象给Spring Security的时候,调用7个参数的构造方法。这四个参数必须同时为true认证才可以,为了节省时间,我只用第一个布尔值做个测试,修改认证业务代码:
/**
* Spring Security进行用户认证的方法
* @param username 页面传过来的用户名
* @return UserDetails UserDetails是Spring Security自己的用户对象,如果返回null就表示认证失败
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
//根据用户名从数据库中查询到用户信息
SysUser sysUser = userDao.findByName(username);
if(sysUser == null) {
//若用户名不对,直接返回null,表示认证失败。
return null;
}
//把sysUser封装成Spring Security可以认识的UserDetails对象
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
//得到当前用户的所有角色的集合
List<SysRole> roles = sysUser.getRoles();
//遍历角色(权限),存入到authorities中
for (SysRole role : roles) {
authorities.add(new SimpleGrantedAuthority(role.getRoleName()));
}
/**
* 最终需要返回一个SpringSecurity的UserDetails对象,{noop}表示不加密认证。
* 浏览器输入的密码已经被Spring Security保存了,这里封装之后,会进行比对,如果是相同的就是认证通过,否则认证失败
*/
//UserDetails userDetails = new User(username,sysUser.getPassword(),authorities);
//使用用户状态的认证对象
UserDetails userDetails = new User(username,sysUser.getPassword(),
sysUser.getStatus()==1, //用户状态为1表示可用,0表示不可用,数据库表中定义
true,
true,
true,
authorities);
return userDetails;
}catch (Exception e){
e.printStackTrace();
return null;
}
}
此刻,只有用户状态为1的用户才能成功通过认证!
1.3 测试
可以新增一个用户:
然后使用这个新用户登录系统,因为设置的状态是关闭,status的值是0,那么登录是失败的
2、remember me
2.1 记住我功能原理分析
还记得前面咱们分析认证流程时,提到的记住我功能吗?
2.1.1 AbstractAuthenticationProcessingFilter
用户名密码登录的过滤器是UsernamePasswordAuthenticationFilter,登录成功的逻辑在它的父类中实现:AbstractAuthenticationProcessingFilter的doFilter有登录成功的逻辑:
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 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);
}
}
2.1.2 AbstractRememberMeServices
从上面分析的这句代码:
//这里就是处理记住我的功能,点击进去查看
this.rememberMeServices.loginSuccess(request, response, authResult);
点击进去发现是下面的这个接口:
public interface RememberMeServices {
Authentication autoLogin(HttpServletRequest var1, HttpServletResponse var2);
void loginFail(HttpServletRequest var1, HttpServletResponse var2);
void loginSuccess(HttpServletRequest var1, HttpServletResponse var2, Authentication var3);
}
查看它的实现类:AbstractRememberMeServices的loginSuccess方法:
public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
public static final String SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "remember-me";
private String parameter = "remember-me";
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
// 判断是否勾选记住我 ,rememberMeRequested方法就在下面
// 注意:这里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);
}
}
//这个是抽象方法,所以应该看子类的实现
protected abstract void onLoginSuccess(HttpServletRequest var1, HttpServletResponse var2, Authentication var3);
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方法:
2.1.3 PersistentTokenBasedRememberMeServices
选中了记住我之后,就会走onLoginSuccess方法,我们看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);
}
}
}
2.2 记住我功能实现
2.2.1 记住我功能页面代码
注意name和value属性的值不要写错哦!
<div class="col-xs-8">
<div class="checkbox icheck">
<!-- 注意name和value属性的值不要写错哦 -->
<label>
<input type="checkbox" name="remember-me" value="true"> 记住 下次自动登录 </label>
</div>
</div>
先测试一下,认证通过后,关掉浏览器,再次打开页面,发现还要认证!为什么没有起作用呢?
这是因为remember me功能使用的过滤器RememberMeAuthenticationFilter默认是不开启的!
2.2.2 开启remember me过滤器
在spring-security.xml中添加下面的配置:
<security:http>
<!--省略其余配置-->
<!--开启remember me过滤器,设置token存储时间为60秒-->
<security:remember-me token-validity-seconds="60"/>
</security:http>
说明:RememberMeAuthenticationFilter中功能非常简单,会在打开浏览器时,自动判断是否认证,如果没有则调用autoLogin进行自动认证。
2.3 持久化remember me信息
2.3.1 remember me安全性分析
记住我功能方便是大家看得见的,但是安全性却令人担忧。因为Cookie毕竟是保存在客户端的,很容易盗取,而且cookie的值还与用户名、密码这些敏感数据相关,虽然加密了,但是将敏感信息存在客户端,还是不太安全。那么这就要提醒喜欢使用此功能的,用完网站要及时手动退出登录,清空认证信息。
此外,SpringSecurity还提供了remember me的另一种相对更安全的实现机制 :在客户端的cookie中,仅保存一个无意义的加密串(与用户名、密码等敏感数据无关),然后在db中保存该加密串-用户信息的对应关系,自动登录时,用cookie中的加密串,到db中验证,如果通过,自动登录才算通过。
2.3.2 持久化remember me信息到数据库
1、创建数据库表
创建一张表,注意这张表的名称和字段都是固定的,不要修改:
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
然后将spring-security.xml中 改为:
<security:http>
<!--省略其余配置-->
<!-- 开启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"/>
</security:http>
最后测试发现数据库中自动多了一条记录:
3、显示登录用户名
3.1 前端jsp获取
在header.jsp中找到页面头部最右侧图片处添加如下信息:
<!-- 用户名的显示 -->
<span class="hidden-xs">
<security:authentication property="name" />
<%--或者用下面的--%>
<%--<security:authentication property="principal.username" />--%>
</span>
3.2 后端获取
使用了Spring Security之后,就可以从SecurityContextHolder获取
//获取当前登录的用户名
String name = SecurityContextHolder.getContext().getAuthentication().getName();
//或者通过下面的方式获取
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
User user = (User) principal;
String username = user.getUsername();
4、用户授权
4.1 授权准备工作
为了模拟授权操作,咱们临时编写两个业务功能:
处理器代码:
package com.itheima.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/order")
public class OrderController {
@RequestMapping("/findAll")
public String findAll(){
return "order-list";
}
}
package com.itheima.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@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>
4.2 动态展示菜单
在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">
<!-- 这里省略了很多... -->
<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>
<!-- 订单模块还需要订单角色,注意这里还需再次指定超级管理员 -->
<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>
</li>
</ul>
</section>
<!-- /.sidebar -->
</aside>
我们做个测试,xiaoming这个用户现在只有普通用户角色ROLE_USER,用xiaoming登录后,果然就看不到产品管理和订单管理菜单:
那么问题来了,是不是现在已经授权成功了呢?答案是否定的!你可以试试直接去访问产品的http请求地址:http://localhost:8080/product/findAll ,这个时候能访问到该页面。
总结一句:页面动态菜单的展示只是为了用户体验,并未真正控制权限!
4.3 授权操作
4.3.1 Spring IOC父子容器说明
说明:SpringSecurity可以通过注解的方式来控制类或者方法的访问权限。注解需要对应的注解支持,若注解放在controller类中,对应注解支持应该放在mvc配置文件中,因为controller类是由mvc配置文件扫描并创建的,同理,注解放在service类中,对应注解支持应该放在spring配置文件中。由于我们现在是模拟业务操作,并没有service业务代码(正常情况应该把权限注解配置到service方法上,因为service是父容器管理,不会被http请求访问,会根据的安全),所以就把注解放在controller类中了(由此也就决定了我们要在spring-mvc.xml中开启权限的注解支持)。
可以通过下面的图可以知道Spring IOC父子容器的区别:
1、ServletContext容器是所有的Web容器都有的,是最外层的;
2、当web.xml配置的ContextLoaderListener监听器加载了Spring的主配置文件:applicationContext.xml后,就会在ServletContext中创建一个Spring IOC的父容器,它是注册和管理service,dao相关的Bean,不能被http请求访问,主要是被controller进行调用,并且不能调用controller;
3、当web.xml配置的DispatcherServlet前置处理器加载了SpringMVC的配置文件:spring-mvc.xml,就会创建一个Spring IOC的子容器,它是注册和管理controller,可以被http请求访问,并且能调用service的Bean。
所以:父容器中的对象可以被子容器调用,不能被http请求访问;子容器可以访问父容器的对象,子容器的对象可以被http请求访问。
4.3.2 开启权限注解支持
在spring-mvc.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的名称空间,手动引入即可。
4.3.3 在对应类或者方法上添加权限注解
JSR-250注解
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.annotation.security.RolesAllowed;
@Controller
@RequestMapping("/product")
//表示当前类中所有方法都需要ROLE_ADMIN或者ROLE_PRODUCT才能访问
@RolesAllowed({"ROLE_ADMIN","ROLE_PRODUCT"})//JSR-250注解
public class ProductController {
@RequestMapping("/findAll")
public String findAll(){
return "product-list";
}
}
spring表达式注解
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_ADMIN','ROLE_PRODUCT')")//spring表达式注解
@RequestMapping("/findAll")
public String findAll(){
return "product-list";
}
}
SpringSecurity注解
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_ADMIN","ROLE_PRODUCT"})//SpringSecurity注解
@RequestMapping("/findAll")
public String findAll(){
return "product-list";
}
}
上面的三种注解,任意一种都可以实现。
也可以同样的方式给OrderController添加注解。
4.3.4 测试
现在进去看不到产品的菜单,如果直接浏览器访问:http://localhost:8080/product/findAll 也访问不了了,会出现403的错误。达到了我们的要求
5、权限不足的异常处理
大家也发现了,每次权限不足都出现403页面,着实难堪!体会一下:
这样的错误直接返回给了客户,这是非常不友好的,现在我们立马消灭它!
可以有如下三种方式来处理403异常。
5.1 spring-security.xml配置403错误页面(不推荐)
<security:http>
<!--省略了其他的配置-->
<!--处理403异常-->
<security:access-denied-handler error-page="/403.jsp"/>
</security:http>
5.2 在web.xml配置403错误页面
所有的web工程,在web容器中可以处理相关的异常,跳转到一个友好的页面。在web.xml中添加如下的配置:
<error-page>
<error-code>403</error-code>
<location>/403.jsp</location>
</error-page>
<error-page>
<error-code>404</error-code>
<location>/404.jsp</location>
</error-page>
<error-page>
<error-code>500</error-code>
<location>/500.jsp</location>
</error-page>
5.3 全局异常处理
上面的异常处理都是在web工程中使用的,因为他们都是基于过滤器来处理异常,而过滤器是依赖于Servlet容器的。而目前,微服务很流行,都会是前后端分离的,所以上面的方式就不太适用了。
我们可以基于拦截器的思想来处理,可以自己编写一个拦截器,然后在Spring的配置文件中进行配置,这样当然也是可以的。但是既然用到了Spring的框架,那么就不需要这么麻烦了。
5.3.1 实现HandlerExceptionResolver
Spring给我们提供了一个接口:HandlerExceptionResolver,它就是基于拦截器机制来处理异常的,我们只需要写一个类,实现它里面的一个方法:resolveException,就能处理对应的异常了
package com.itheima.controller.exception;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class HandlerControllerException implements HandlerExceptionResolver {
/**
* @param httpServletRequest
* @param httpServletResponse
* @param o 出现异常的对象
* @param e 出现的异常信息
* @return ModelAndView
*/
@Override
public ModelAndView resolveException(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) {
ModelAndView mv = new ModelAndView();
//将异常信息放入request域中,基本不用
mv.addObject("errorMsg", e.getMessage());
//指定不同异常跳转的页面
if(e instanceof AccessDeniedException){
//如果直接写403.jsp,那么视图解析器会加上前后缀,那么就找不到该页面了,加上redirect:之后,视图解析器就不会解析了
mv.setViewName("redirect:/403.jsp");
}else {
mv.setViewName("redirect:/500.jsp");
}
return mv;
}
}
5.3.2 注解@ControllerAdvice和@ExceptionHandler
当然其实使用了SpringMVC之后,不用上面这样麻烦了,只需要使用@ControllerAdvice和@ExceptionHandler注解就可以了:
package com.itheima.controller.exception;
import org.springframework.security.access.AccessDeniedException;
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(RuntimeException.class)
public String runtimeHandlerException(){
return "redirect:/500.jsp";
}
}
@ControllerAdvice该注解就相当于表明这个类是一个拦截器
r
当然其实使用了SpringMVC之后,不用上面这样麻烦了,只需要使用@ControllerAdvice和@ExceptionHandler注解就可以了:
package com.itheima.controller.exception;
import org.springframework.security.access.AccessDeniedException;
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(RuntimeException.class)
public String runtimeHandlerException(){
return "redirect:/500.jsp";
}
}
@ControllerAdvice该注解就相当于表明这个类是一个拦截器