一. 简介
Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架。它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IoC,DI(控制反转Inversion of Control ,DI:Dependency Injection 依赖注入)和AOP(面向切面编程)功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。
什么是ACL和RBAC
-
ACL: Access Control List 访问控制列表
-
以前盛行的一种权限设计,它的核心在于用户直接和权限挂钩
-
优点:简单易用,开发便捷
-
缺点:用户和权限直接挂钩,导致在授予时的复杂性,比较分散,不便于管理
-
例子:常见的文件系统权限设计, 直接给用户加权限
-
-
RBAC: Role Based Access Control
-
基于角色的访问控制系统。权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限
-
优点:简化了用户与权限的管理,通过对用户进行分类,使得角色与权限关联起来
-
缺点:开发对比ACL相对复杂
-
例子:基于RBAC模型的权限验证框架与应用 Apache Shiro、spring Security
-
-
BAT企业 ACL,一般是对报表系统,阿里的ODPS
二. 入门案例
2.1 添加依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.6.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.17</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-web</artifactId>
<version>1.1.6.RELEASE</version>
</dependency>
</dependencies>
2.2 请求
我们任意编写一个接口,然后进行访问,会直接跳转到一个登录页面
三. 自定义用户登录处理
3.1 安全配置
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() //采用表单登录
.and()
.authorizeRequests() //请求认证
.anyRequest() //对于任何请求都需要认证
.authenticated(); //认证通过了才能访问
}
}
3.2 自定义用户认证
@Component
public class UserAuthentication implements UserDetailsService {
@Resource
private SysUserRepository sysUserRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = sysUserRepository.findByNickyName(username);
if (null == sysUser) {
return new User(username, null, null);
}else {
return new User(username, sysUser.getPassword(),
AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
}
在实际的应用过程中,当我们发起请求的时候,springSecurity处理用户登录的过滤器是UsernamePasswordAuthenticationFilter这个过滤器,而这个过滤器会将用户提交的用户名和密码交由UserDetailsService的实现类来处理。具体的处理流程如下图所示:
3.3 密码加密校验
密码的加密校验需要实现PasswordEncoder这个接口,接口中有两个方法,
@Component
public class CustomizePasswordEncoder implements PasswordEncoder {
// 注册的时候使用, 人为的去调用
@Override
public String encode(CharSequence rawPassword) {
return null;
}
// 当在返回UserDetails,会自动的去实现校验
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return false;
}
}
实际工作中我们可以直接使用spring security中默认的密码处理方式就完全可以满足日常的开发。
3.4 自定义登录页面
spring security中定义的登录页面有可能不满足需求,需要自己来实现一个登录页面,处理的方式为只需要在3.1节方法中 formLogin() 方法的后面加上loginPage()方法即可,如下代码所示:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() //采用表单登录
.loginPage("/login.html")
.and()
.authorizeRequests() //请求认证
.anyRequest() //对于任何请求都需要认真
.authenticated(); //认证通过了才能访问
}
这样配置会发现报如下的错误:
这个错误是很多的初学者容易犯的一个错误,原因是因为对于任何的页面都需要认证,所以就在这里无限循环下去了。我们需要接着调整代码,如下所示:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() //采用表单登录
.loginPage("/login.html")
.loginProcessingUrl("/authentication/form") //登录页面提交的地址
.and()
.authorizeRequests() //请求认证
.antMatchers("/login.html").permitAll() //如果是登录页面直接让其访问
.anyRequest() //对于任何请求都需要认真
.authenticated(); //认证通过了才能访问
}
3.5 编写自己的登录页面
<form action="/authentication/form" method="post">
Username: <input name="username"> <br>
Password: <input name="password" type="password"> <br>
<button>提交</button>
</form>
当我们实现了自己的登录页面后发现还是无法登录,原因在于我们没有加上csrf(跨站请求伪造),我们暂时先将其禁用,代码如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() //采用表单登录
.loginPage("/login.html")
.loginProcessingUrl("/authentication/form") //登录页面提交的地址
.and()
.authorizeRequests() //请求认证
.antMatchers("/login.html").permitAll() //如果是登录页面直接让其访问
.anyRequest() //对于任何请求都需要认真
.authenticated() //认证通过了才能访问
.and()
.csrf().disable(); //关闭跨站请求伪造功能
}
四. 登录成功与失败处理
4.1 登录成功处理
在spring security中,当我们登录成功后默认是跳转到用户登录之前的请求,这个在当今SPA(Single Page Application)应用流行的今天,肯定是不适用的,我们需要的是异步的请求,返回登录成功的信息。
要实现用户登录成功处理,需要实现AuthenticationSuccessHandler这个接口,然后实现接口中的方法:
4.2 登录失败处理
通过上面的演示我们能看到每次登录,还是回到登录页面,在异步请求下这种是无法满足我们的需求的,所以需要自定义登录失败处理。要实现AuthenticationFailureHandler这个接口,如下所示:
安全配置代码如下:
五. 记住我
5.1 基本原理
在前端页面的请求参数必须叫remember-me
5.2 功能实现
5.3 安全配置
六. 图片验证码
在实际的应用过程中,为了防止用户的恶意请求,我们通常都会设置图片验证码功能,而springsecurity并没有提供现有的实现,需要开发人员自行的实现。
6.1 封装验证码类
public class ImageCode {
private BufferedImage bufferedImage;
// code是随机字母,需要存储在session中
private String code;
// 过期时间
private LocalDateTime expireTime;
// 第三个参数为过期的时间
public ImageCode(BufferedImage bufferedImage, String code, int seconds) {
this.bufferedImage = bufferedImage;
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(seconds); //设置过期的时间点
}
// 验证码是否过期
public boolean isExpire() {
return LocalDateTime.now().isAfter(expireTime); //当前时间是否在过期时间之后
}
// setters、getters、other constructors
}
6.2 请求控制类
6.3 过滤器的编写
public class ValidataCodeFilter extends OncePerRequestFilter {
// 所有登录失败都交由该类来处理
private CustomerAuthenticationFailHandler customerAuthenticationFailHandler;
public void setCustomerAuthenticationFailHandler(CustomerAuthenticationFailHandler customerAuthenticationFailHandler) {
this.customerAuthenticationFailHandler = customerAuthenticationFailHandler;
}
private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();
6.4 登录异常处理
springSecurity中处理用户登录异常都应该由AuthenticationException这个异常来处理,所以我们需要自定义验证码校验失败的异常类:
public class ValidateException extends AuthenticationException {
public ValidateException(String msg) {
super(msg);
}
}
七. 手机号登录
手机号登录与用户名密码登录逻辑相同,所以我们在使用手机号登录系统的时候可以完全拷贝用户名密码登录的逻辑,那么前提是我们必须得搞懂用户名密码登录的逻辑。
7.1 编写Token
编写手机号认证Token, 模仿UsernamePasswordAuthenticationToken这个类来实现。
/**
* 短信验证码Token, 用于封装用户使用手机登录的相关信息。
*/
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
// principal在未登录之前封装用户的手机号,登录之后封装用户的信息
private final Object principal;
// ~ Constructors
/**
* This constructor can be safely used by any code that wishes to create a
* <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
* will return <code>false</code>.
*/
public SmsAuthenticationToken(Object principal) {
super(null);
this.principal = principal;
setAuthenticated(false);
}
/**
* This constructor should only be used by <code>AuthenticationManager</code> or
* <code>AuthenticationProvider</code> implementations that are satisfied with
* producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
* authentication token.
*
* @param principal
* @param authorities
*/
public SmsAuthenticationToken(Object principal,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}
7.2 编写Filter
手机号的过滤器可以模仿 UsernamePasswordAuthenticationFilter 来实现。
public class SmsAuthenticationFilter extends
AbstractAuthenticationProcessingFilter {
// ~ Static fields/initializers
public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";
private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
private boolean postOnly = true;
public SmsAuthenticationFilter() {
super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
}
// ~ Methods
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String mobile = obtainMobile(request);
if (mobile == null) {
mobile = "";
}
mobile = mobile.trim();
SmsAuthenticationToken authRequest = new SmsAuthenticationToken(mobile);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
}
/**
* Provided so that subclasses may configure what is put into the authentication
* request's details property.
*
* @param request that an authentication request is being created for
* @param authRequest the authenticatio request object that should have its details
*/
protected void setDetails(HttpServletRequest request,
SmsAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
/**
* Sets the parameter name which will be used to obtain the username from the login
* request.
*/
public void setMobileParameter(String mobileParameter) {
Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
this.mobileParameter = mobileParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
}
7.3 Provider
Provider的作用是用来处理对应的Token,校验用户名密码使用的Provider为DaoAuthenticationProvider, 在实现我们自己的Provider的时候,我们去实现AuthenticationProvider。
public class SmsCredentialsProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
public UserDetailsService getUserDetailsService() {
return userDetailsService;
}
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
7.4 发送短信实现
接口的实现
public interface SmsCodeSender {
//发送手机短信验证码的
void send(String code, String mobile);
}
实现类
public class DefaultSmsCodeSender implements SmsCodeSender {
7.5 配置Filter以及Provider
7.6 安全配置
短信验证码的过滤器和图片验证码的逻辑是相同,故在此不作处理。
7.7 页面的实现
八. session管理
8.1 session并发控制
session的失效时间默认为30min,可以通过 server.servlet.session.timeout类配置。在很多的业务场景下,我们只允许一台设备登录到服务端。
安全配置
session失效处理逻辑
/**
* 同时多设备登录处理
*/
public class MultipleSessionHandler implements SessionInformationExpiredStrategy {
8.2 session集群管理
当我们在集群环境下,用户每次的请求我们并不能保证每次都是到达同一台服务器,可能会导致session存在于不同的服务器上,而让用户重新进行登录,所以必须要采用一个中间件来存储用户的session信息,企业中使用最多的就是redis.
依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-core</artifactId>
<version>2.1.9.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>2.1.9.RELEASE</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.7.0</version>
</dependency>
applicatoin.yml配置
spring
九. 退出登录
.logout() //
.logoutSuccessUrl("/login.html") //退出后跳转的页面
.and()
十. 权限管理
权限是大部分的后台管理系统都需要实现的功能,用户控制不同的角色能够进行的不同的操作。Spring Security的可以进行用户的角色权限控制,也可以进行用户的操作权限控制。在之前的代码实现上,我们仅仅只是实现用户的登录,在用户信息验证的时候使用UserDetailsService,但是却一直忽略了用户的权限。
10.1 启动类配置
/**
* 开启方法的注解安全校验。
* securedEnabled @Secured("ROLE_abc") 该注解是Spring security提供的
* jsr250Enabled @RolesAllowed("admin") 该注解是 JSR250 支持的注解形式
* prePostEnabled
*/
10.2 基于角色的权限控制
用户权限的查询
我们在构建SimpleGrantedAuthority对象的时候,用户的角色必须是以 ROLE_
开头,例如 ROLE_admin
、ROLE_manager
控制器角色控制
在控制器上进行用户访问控制的时候,基于角色有两种书写方式:
方式一:@RolesAllowed
/**
* @RolesAllowed 中的值可以写成 "admin", 例如 @RolesAllowed("admin")
* @RolesAllowed 中的值还可以写成 "ROLE_admin",例如 @RolesAllowed("ROLE_admin")
*/
方式二:
/**
* @Secured 中的值必须为 "ROLE_admin",例如 @Secured("ROLE_admin"),ROLE_不能省略
*/
10.3 基于操作的权限控制
当然我们也可以使用基于操作的权限控制,这个功能稍显得有点累赘,因为在实际的项目开发过程中我们都是基于角色的权限控制。
用户权限查询
控制器访问控制(针对角色)
/**
* @PreAuthorize 中的值可以为 "ROLE_admin", "admin",
* 例如 @PreAuthorize("hasRole('admin')") 或者为
* @PreAuthorize("hasRole('ROLE_admin')")
*/
控制器访问控制(针对操作)
10.4 访问无权限处理
.and()
.exceptionHandling()
.accessDeniedHandler(customizeAccessDeniedHandler) //无权限访问处理
.and()