1 Spring Security 实现认证和授权的原理
1.1 过滤器链
Spring Security
对Servlet
的安全认证是基于包含一系列的过滤器对请求进行层层拦截处理实现的,多个过滤器组成过滤器链。处理单个http
请求的过滤链角色示意图如下所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-otcXhyuJ-1604841699378)(D:\markdown撰写文档\images\filterChain.png)]
每个Filter
的作用在于:
-
阻止处于过滤器链中当前
Filter
下游Filter和Servlet
方法的调用,写响应给客户端的HttpServletResponse
-
修改用于下游
Filter
和Servlet
的HttpServletRequest
和HttpServletResponse
FilterChain
的使用如下,也就是完成过滤器的doFilter
方法中的逻辑
public void doFilter(ServletRequest request, ServletResponse response, FilterChain
chain) {
// do something before the rest of the application
chain.doFilter(request, response); // invoke the rest of the application
// do something after the rest of the application
}
因为每个过滤器只会影响到它下游的Filter
和Servlet
,因此每个Filter
的执行顺序非常重要。对于每一个请求URL,Spring Security
过滤器链中只会执行第一个匹配上的过滤器,后面的过滤器即便匹配上了也不会再执行。
为了对请求进行拦截, Spring Security
提供了过滤器 DelegatingFilterProxy
类给予开发者配置。
1.2 处理安全异常
Spring Security
提供了一个 ExceptionTranslationFilter
用于处理安全异常。ExceptionTranslationFilter
也是作为一个安全过滤器加入到 FilterChainProxy
中的,它允许将AccessDeniedException
(访问拒绝异常)和 AuthenticationException
(认证异常) 信息写进 HttpResponse
中。
ExceptionTranslationFilter
` 拦截请求的流程图如下:
图 1 spring security在认证过程发生异常时的过异常转换处理过滤器处理流程
(1) 第一步,ExceptionTranslationFilter
执行 FilterChain.doFilter(request, response)
方法通过则进入控制器请求方法执行正常逻辑
(2)如果登录用户没有认证或者发送认证异常,则开始认证。此时会发生以下几件事情:
SecurityContextHolder
被清除HttpRequest
信息保存在RequestCache
中,当用户认证成功则RequestCache
会响应客户端的原始请求AuthenticationEntryPoint
用来从客户端请求凭据。例如,它会重定向到一个登录页面或者发送一个WWW-Authenticate
请求头
(3) 如果发生 AccessDeniedException
,代表访问被拒绝,则会执行 AccessDeniedHandler
中的方法。
ExceptionTranslationFilter
中的伪代码如下所示:
try {
//过滤请求
filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
//没有认证或者发送认证异常则开始认证
startAuthentication();
} else {
//访问被拒
accessDenied();
}
}
2 用户/密码认证
认证登录用户最常用的一种方式就是通过验证用户名和密码认证用户。基于此,spring security
对使用用户名和密码的方式提供了全面的支持。
2.1读取用户名和密码
spring security
提供了以下几种方式从HttpServletRequest
中读取用户名和密码:
- 表单登录
- Basic 认证
- 签名认证
2.2 存储认证信息机制
spring security
支持以下几种方式存储用户认证信息,上面每种读取用户名和密码的方式都可以利用下面任何一种存储认证信息的方式实现对访问用户的认证
- 使用
In-Memory Authentication
存储在内存中 - 使用
JDBC Authentication
认证存储在关系型数据库中 - 使用
UserDetailsService
存储在自定义数据库中 - 使用
LDAP Authentication
存储在LDAP
服务器中
限于篇幅,本文只演示基于内存存储的认证方式
2.3 实现自定义认证和授权
spring security
提供了一个抽象类WebSecurityConfigurerAdapter
实现了默认的认证和授权,我们可以自定义WebSecurityConfig
类继承WebSecurityConfigurerAdapter
类并重写其中的3个configure
实现自定义的认证和授权。
protected void configure(AuthenticationManagerBuilder auth) throws Exception{......}
public void configure(WebSecurity web) throws Exception {......}
protected void configure(HttpSecurity http) throws Exception {......}
3 实现表单登录实战
本文主要利用内存存储和自定义UserDetailsService
实现基于内存存储的登录表单认证
3.1 在SpringBoot web
项目中加入Spring Security
的依赖
在本人之前的boot-demo
项目的pom.xml
文件中引入spring-boot-starter-security
起步依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
而在 Spring Boot 中,只要 加入了Spring security
的起步依赖,直接启动spring Boot
的应用也会启用 Spring Security ,这样就可以 看到如下打印随机生成密码的日志(请注意,需要保证你的日志级别为INFO
或者其以下才能看到)
2020-10-19 23:26:37.390 INFO 12808 --- [ main] .s.s.UserDetailsServiceAutoConfiguration :
Using generated security password: 089ae129-bb01-472f-9919-4bb529b64153
3.2 与用户认证信息有关的自动配置类
开启Spring Security
的默认配置就会完成以下事项
- 创建一个命名为
springSecurityFilterChain
的Servlet
过滤器bean
,这个bean
负责保护应用的整个安全,包括保护请求的URL、认证提交的用户名和密码和重定向到登录表单等。 - 创建一个
UserDetailsService
类的bean
,该类有一个user
属性,user
由username
字段和一个随机生成并打印到控制台上的password
字段组成。 - 在
Servlet
容器中注册一个命名为springSecurityFilterChain
的过滤器bean
对每一次请求进行过滤。
通过IDEA中搜索UserDetailServiceAutoConfiguration类可进入UserDetailServiceAutoConfiguration
配置类的源码。
UserDetailServiceAutoConfiguration
配置类的源码中getOrDeducePassword
方法会判断代码是否自动生成,如果是则打印生成的密码。然后进入SecurityProperties.User
类中查看源码会发现:系统自动生成随机密码是就是一个UUID
,而一旦用户配置了密码则passwordGenerated
标识符变成了false
,使用开发者配置的密码。
SecurityProperties配置类中的静态内部User类源码
如下:
public static class User {
private String name = "user";
private String password = UUID.randomUUID().toString();
private List<String> roles = new ArrayList();
private boolean passwordGenerated = true;
public User() {
}
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public String getPassword() {
return this.password;
}
public void setPassword(String password) {
if (StringUtils.hasLength(password)) {
this.passwordGenerated = false;
this.password = password;
}
}
public List<String> getRoles() {
return this.roles;
}
public void setRoles(List<String> roles) {
this.roles = new ArrayList(roles);
}
public boolean isPasswordGenerated() {
return this.passwordGenerated;
}
}
User
类中包含了username、password 和roles 等信息
3.3 使用Spring Security
默认的表单登录
在boot-demo 项目com.example.bootdemo.controller
包下面新建一个IndexController
的控制器,并增加一个index
方法,代码如下:
@RestController
@RequestMapping("/index")
public class IndexController {
@GetMapping("/")
public String index(){
return "欢迎学习 Spring Security!";
}
}
启动项目后在浏览器中输入http://localhost:8088/apiBoot/index/,然后回车。因为用户一开始没有登录认证,所有会被spring security
拦截到登录界面让用户先登录。
图 2 spring security 默认的表单登录认证拦截界面
因为我们没有自定义登录界面,所以默认会使用 DefaultLoginPageGeneratingFilter 类,生成上述界面。
默认情况下,Spring Boot UserDetailsServiceAutoConfiguration 自动化配置类,会创建一个内存级别的 InMemoryUserDetailsManager Bean 对象,提供认证的用户信息。
输入user
的用户和应用控制台中打印的登陆密码(32位UUID
)登录成功后浏览器页面会出现下面的内容:
欢迎学习 Spring Security!
说明请求进入了IndexController
的index
方法并成功返回。
如果认证失败,则无法跳转到相应的请求方法里去,默认会一直停留在登录界面,但是可以通过配置使路由跳转认证失败的页面。
通常情况下,我们会在application.properties
或者application.yaml
文件中配置用户名、登录密码和角色等信息,而不是每次拿着一个随机生成的UUID
作为密码去登录
spring.security.user.name=user
spring.security.user.password=user123
spring.security.user.roles=user
UserDetailsServiceAutoConfiguration
会基于配置的信息创建一个用户 User 在内存中
此时,我们重启服务器后重新登录输入用户名user
和配置的密码user123
就能登录成功了
3.4 自定义继承自继承WebSecurityConfigurerAdapter
类的WebSecurityConfig
配置类
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth //使用内存存储
.inMemoryAuthentication()
//使用BCrypt密码编码器
.passwordEncoder( passwordEncoder())
//配置user用户、密码和角色,此处配置的user用户密码会覆盖系统随机生成的uuID密码
// 密文在控制台使用springboot-cli指令 spring encodepassword user得到
.withUser("user").password("$2a$10$bVicNl2vVT0H70APYQYmde9bauRRaENu0HN7HpzByJCtLy0FU0ubu")
.roles("USER")
.and()
//配置admin用户、密码和角色
.withUser("admin")
//密文获取方式同user用户
.password("$2a$10$DHtuK1bibHqbAwoGgLi4zOiNjULuHQ2qhIs/ziCw/9T2fqF320cJu")
.roles("ADMIN","USER");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
//使用ant风格拦截请求
//限制index/user路径对应的接口接口只有USER或ADMIN角色用户可以访问
.antMatchers("/index/user").hasAnyRole("USER","ADMIN")
//限制index/admin路径对应的接口只有ADMIN角色用户可以访问
.antMatchers("/index/admin").hasRole("ADMIN")
.anyRequest().authenticated()
//登录接口对所有用户开发权限
.antMatchers("/login").permitAll()
//使用spring security默认的登录接口
//自定义不同路径的认证接口时在登录时报302错误且笔者一时没有找到有效的解决办法
.and().formLogin().loginProcessingUrl("/login").
usernameParameter("username").passwordParameter("password")
//配置登录成功处理器
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication auth) throws IOException, ServletException {
//从Authentication实例中拿到当前用户的认证信息
Object principal = auth.getPrincipal();
//设置响应体内容为json格式
response.setContentType("application/json;charset=utf-8");
response.setStatus(200);
PrintWriter writer = response.getWriter();
Map<String,Object> map = new HashMap<>();
map.put("status",200);
map.put("msg","login success");
map.put("data",principal);
ObjectMapper objectMapper = new ObjectMapper();
//借助ObjectMappe对象将返回数据写到响应体的打印流中
//这样就能渲染到客户端浏览器页面,也利于前后端发分离项目
//前端跳转页面可以使用vue实现
writer.write(objectMapper.writeValueAsString(map));
writer.flush();
writer.close();
}
}).failureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) throws IOException, ServletException {
response.setContentType("application/json ;charset=utf-8");
PrintWriter writer = response.getWriter();
response.setStatus(401);
Map<String,Object> map = new HashMap<>();
map.put("status",401);
//根据异常类型判断具体的认证失败信息
if(ex instanceof LockedException){
map.put("msg","账号被锁定,登录失败");
}else if(ex instanceof BadCredentialsException){
map.put("msg","账号或密码输入错误,登录失败");
}else if(ex instanceof DisabledException){
map.put("msg","账户被禁用,登录失败");
}else if(ex instanceof CredentialsExpiredException){
map.put("msg","密码过期,登录失败");
}else{
map.put("msg","登录失败");
}
ObjectMapper objectMapper = new ObjectMapper();
writer.write(objectMapper.writeValueAsString(map));
writer.flush();
writer.close();
}
}) //表单登录对所有用户放开权限
.permitAll()
.and();
}
//配置BCrypt密码编码器
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
3.5 IndexController
中添加对应限制角色访问的方法
@GetMapping("/")
@GetMapping("/user")
public String user(){
return "普通用户或管理员用户能看到我!";
}
@GetMapping("/admin")
public String admin(){
return "只有管理员用户能看到我!";
}
4 效果测试
在IDEA
中启动项目成功后就可以测试效果了
4.1 测试登录接口
在浏览你器中输入 http://localhost:8088/apiBoot/login
然后回车就可以看到和之前一样登录界面
然后在输入框中输入用户名 (user) 和 密码 (user) ,点击 Sign in
登录成功后会返回如下响应信息说明登录成功
{"msg":"login success","data":{"password":null,"username":"admin","authorities":[{"authority":"ROLE_ADMIN"},{"authority":"ROLE_USER"}],"accountNonExpired":true,"accountNonLocked":true,"credentialsNonExpired":true,"enabled":true},"status":200}
响应体的 data字段中会有用户的信息,包含username、password 和 authorities
等字段,其中password字段为null,说明用户认证信息里面没有存储用户的密码,也是为了防止密码泄露。这里要注意Spring Security
会给后台配置的用户角色会加上一个ROLE_
前缀。
4.2 测试 /index/user
接口和/index/admin
接口
(1)使用user用户登录成功后在浏览器中输入 http://localhost:8088/apiBoot/index/user
后回车后浏览器中会得到如下响应信息:
普通用户或管理员用户能看到我!
(2) 继续在浏览器中输入 http://localhost:8088/apiBoot/index/admin
后回车,浏览器会得到下面的响应信息,状态码为403说明当前用户没有权限访问
Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.
Sun Oct 25 20:57:47 GMT+08:00 2020
There was an unexpected error (type=Forbidden, status=403).
Forbidden
(3) 然后输入http://localhost:8088/apiBoot/login
再次进入登录界面使用admin
账户登录,密码为admin
, 登录成功后再在浏览器种调用http://localhost:8088/apiBoot/index/admin
接口后浏览器种可以看到调用成功的响应信息,说明admin
用户能够成功访问index/admin
接口
只有管理员用户能看到我!
由于用户的注册信息存在内存中,数据量一旦大起来的话对服务的运行会是一个很大的负担,因此实际的生产环境一般是存储在数据库中的,或者在服务启动成功后开始作为热点数据加载到redis
缓存中方便认证用户。 下一篇文章,笔者会尽快推出基于数据库认证的方式实战,敬请期待!
5 参考文章
[1] 《spring-security-reference》 chaper 10 Autherization
[2] 王松著《spring boot2.0 + Vue 全栈开发实战》
文章首发个人微信公众号,第一次阅读笔者文章的小伙伴欢迎扫描下方二维码关注笔者的个人微信公众号,作者会不定期分析前后端技术干货以及一些已拿到大厂offer的同行面试题和求职经验。