Spring Security-原始篇(XML配置版)功能优化
前置文章:
Spring Security-原始篇(XML配置版)
结合前置文章加上该文,Spring Security大致的功能其实就都了解了。到使用Spring Boot的注解开发则会更简便一些。
零、本文纲要
- 一、设置用户状态
- 源码分析
- 设置用户状态的代码实现
- 二、RememberMe功能实现
- 源码分析
- 代码实现
- 三、显示当前用户名
- 四、权限管理
- 页面控制-动态展示菜单
- 授权操作
- 注解介绍
① @Secured
② @RolesAllowed
③ @PreAuthorize
- 五、异常处理
一、设置用户状态
1. 源码分析
- ① UserDetailsService接口
在Spring Security当中认证是由 AuthenticationManager 来管理的,但是真正进行认证的是 AuthenticationManager 中定义的 AuthenticationProvider。
我们实际的代码则是通过实现UserDetailsService接口,来编写具体的认证业务逻辑。然后将具体的实现类作为AuthenticationProvider的引用,交给认证框架使用。
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
该接口有一个UserDetails返回值,其也是接口。需要我们使用具体的实现类,以实现具体的认证逻辑。
- ② UserDetails接口
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
框架也给我们提供了其具体实现类User类。
- ③ 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. 源码分析
- ① UsernamePasswordAuthenticationFilter
Spring Security整体的实现其实都是依赖过滤器来完成,其中我们重点关注UsernamePasswordAuthenticationFilter。
认证的基础就是依靠该类的attemptAuthentication方法。RememberMe功能实现也离不开该类。
该类继承了一个AbstractAuthenticationProcessingFilter抽象父类,其successfulAuthentication方法内有实现RememberMe的逻辑。
- ② AbstractAuthenticationProcessingFilter
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实现了具体的业务逻辑,所以我们继续跟踪此代码实现。
- ③ AbstractRememberMeServices
@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,其中英文部分忽略大小写。
- ④ PersistentTokenBasedRememberMeServices
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>
- ② 开启remember me过滤器
在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" %>
- ② 添加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. 注解介绍
- ① @Secured
该注解是SpringSecurity提供的,用于限定访问权限
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Secured {
//填写具体权限,如:ROLE_USER、ROLE_ADMIN
public String[] value();
}
- ② @RolesAllowed
该注解由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";
}
- ③ @PreAuthorize
该注解是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>