前言
做后端开发有一个绕不过去的问题就是鉴权和登录, 而 Spring Security 框架恰恰是我们集中解决这个问题的好帮手, 但是 Spring Security 在我看来还算是学习路上比较难的一个框架。写这篇博客前也学过 Spring Security 但是只是会用,并不了解它的很多配置和操作的原理。但是做项目的时候也不能老是被 Spring Security 约束住,不敢随便的修改里面的配置吧。正好有时间和意图,我搭建实验环境并且追进源码好好的解读了下 Spring Security 框架在底层做的逻辑。 看完这篇文章你一定会对 Srping Security 有更加深刻的了解,以后做项目集成安全框架的时候也可以做到按照需求来指定防护逻辑(夹带私货,玩出一点花样
搭建实验环境
实验环境搭建步骤
新建一个maven项目 (实验环境 jdk1.8)
导入SpringSecurity依赖和SpringMVC的web依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
...
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--Security安全工具-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
为了方便测试我们加入日志相关依赖
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
application.yaml
server:
port: 8081
Start
启动项目
现象
启动项目 我们发现这样一行日志
这就是 Spring Security 为默认用户 user 生成的临时密码,是一个 UUID 字符串。
我们访问 localhost:8081 被重定向到了Spring Security 的默认登录页面
默认的username是user
我们随便输入登录,发现它是有错误处理的
使用日志打印出来的临时密码登录
没有报出特殊错误,说明登录成功
源码解释
上面security打印的默认密码生成是在UserDetailsServiceAutoConfiguration里面
追进getPassord()方法,进入
SecurityProperties发现有一个静态内部类User
我们发现到了默认的用户名和密码的随机uuid生成,并且默认情况下passwordGenderated也是true
配置自己的用户
使用配置文件方式
security的默认用户密码是随机的,很不方便
但是我们也可以配置自己的用户!
还是注意到上面说的SecurityProperties
上面有行注解
@ConfigurationProperties(
prefix = "spring.security"
)
学springboot的同学都知道,这个注解可以将配置文件的对应内容给写到类的成员变量中。
我们在配置文件中写入
spring:
security:
user:
name: pixel-revolve
password: 123456
同样的访问,登录
用配置里面的用户名和密码进去
我们发现password是set进去的,并且发现
passwordGenerated已经设置为false了,所以日志也不会再打印了
使用配置类方式
除了上面的配置文件这种方式之外,我们也可以在配置类中配置用户名/密码。
在配置类中配置,我们就要指定 PasswordEncoder 了。
使用过security框架的小伙伴一定很熟悉这个类。并且知道它用于密码加密。
为什么需要加密密码?
密码如果是明文存储在数据库中,一旦数据库泄露就会造成用户密码的泄露。很多用户都是很多网站用一个密码的,这就给造成了极大的安全隐患。所以我们需要加密密码。
PasswordEncoder接口
- encode 方法用来对明文密码进行加密,返回加密之后的密文。
- matches 方法是一个密码校对方法,在用户登录的时候,将用户传来的明文密码和数据库中保存的密文密码作为参数,传入到这个方法中去,根据返回的 Boolean 值判断用户密码是否输入正确。
- upgradeEncoding 是否还要进行再次加密,这个一般来说就不用了。 它拥有14个实现类
具体的配置
/**
* @author pixel-revolve
* @date 2022/3/24
*/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("foo")
.password("123").roles("admin");
}
}
SecurityConfig继承自WebSecurityConfigurerAdapter,重写里边的configure方法。- 首先我们提供了一个
PasswordEncoder的实例,因为目前的案例还比较简单,因此我暂时先不给密码进行加密,所以返回NoOpPasswordEncoder的实例即可。
- configure 方法中,我们通过
inMemoryAuthentication来开启在内存中定义用户,withUser中是用户名,password 中则是用户密码,roles 中是用户角色。 - 如果需要配置多个用户,用 and 相连。
为什么使用 and 相连?
在没有 Spring Boot 的时候,我们都是 SSM 中使用 Spring Security,
这种时候都是在 XML 文件中配置 Spring Security,既然是 XML 文件,
标签就有开始有结束,现在的 and 符号相当于就是 XML 标签的结束符,表示结束当前标签,
这是个时候上下文会回到 inMemoryAuthentication 方法中,然后开启新用户的配置。
自定义表单登录页
Security核心配置类
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/js/**", "/css/**","/images/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.permitAll()
.and()
.csrf().disable();
}
- web.ignoring() 用来配置忽略掉的 URL 地址,一般对于静态文件,我们可以采用此操作。
- 如果我们使用 XML 来配置 Spring Security ,里边会有一个重要的标签
<http>,HttpSecurity 提供的配置方法 都对应了该标签。 - authorizeRequests 对应了
<intercept-url>。 - formLogin 对应了
<formlogin>。 - and 方法表示结束当前标签,上下文回到HttpSecurity,开启新一轮的配置。
- permitAll 表示登录相关的页面/接口不要被拦截。
- 最后记得关闭 csrf。
当我们定义了登录页面为 /login.html 的时候,Spring Security 也会帮我们自动注册一个 /login.html 的接口,这个接口是 POST 请求,用来处理登录逻辑。
我们使用别的大大做的login页面来帮助实验(比默认的login要好看太多了)
这里贴出html代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<div class="login-page">
<div class="form">
<form class="register-form">
<input type="text" placeholder="name"/>
<input type="password" placeholder="password"/>
<input type="text" placeholder="email address"/>
<button>create</button>
<p class="message">Already registered? <a href="#">Sign In</a></p>
</form>
<form class="login-form">
<input type="text" placeholder="username"/>
<input type="password" placeholder="password"/>
<button>login</button>
<p class="message">Not registered? <a href="#">Create an account</a></p>
</form>
</div>
</div>
</body>
</html>
大家可以使用自己喜欢的页面来进行实验。
再次启动项目!
发现页面果然给替换成了我们设置的前端页面。
在SpringSecurity中默认的登录接口是
/login.
且默认存在两个接口
如果是 GET 请求表示你想访问登录页面,如果是 POST 请求,表示你想提交登录数据。
.and()
.formLogin()
.loginPage("/login.html")
.permitAll()
.and()
当我们配置了 loginPage 为 /login.html 之后,这个配置从字面上理解,就是设置登录页面的地址为 /login.html。
在前端页面给form标签设置action属性的时候像idea这样的IDE就会提示我们接口了。/login.html确实设计成了Security的登录接口。
实际上它还有一个隐藏的操作,就是登录接口地址也设置成
/login.html 了。换句话说,新的登录页面和登录接口地址都是 /login.html,现在存在如下两个请求:
前面的 GET 请求用来获取登录页面,后面的 POST 请求用来提交登录数据。
但是我们一般的需求都是登录页面和登录接口是分开来的,我们可以通过 loginProcessingUrl 方法来指定登录接口地址:
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/doLogin")
.permitAll()
.and()
我们再修改 form 的 action 属性,和 method 属性
<form class="login-form" action="/doLogin" method="post">
<input type="text" placeholder="username"/>
<input type="password" placeholder="password"/>
<button>login</button>
<p class="message">Not registered? <a href="#">Create an account</a></p>
</form>
那么为什么默认情况下两个配置地址是一样的呢?
源码分析
先告诉结论,form 表单的相关配置在 FormLoginConfigurer 中,该类继承自 AbstractAuthenticationFilterConfigurer ,所以当 FormLoginConfigurer 初始化的时候,AbstractAuthenticationFilterConfigurer 也会初始化,在 AbstractAuthenticationFilterConfigurer 的构造方法中,我们可以看到:
这个就是默认配置的loginPage,set进了本类对象的成员变量上。
另一方面,
FormLoginConfigurer 的初始化方法 init 方法中也调用了父类的 init 方法:
AbstractAuthenticationFilterConfigurer的init方法
在这个方法中还调用了
updateAuthenticationDefaults 方法名言简意赅
我们追进去看下
从这个方法的逻辑中我们就可以看到,如果用户没有给
loginProcessingUrl 设置值的话,默认就使用 loginPage 作为 loginProcessingUrl。
如果我们设置了loginPage,updateAuthenticationDefaults方法被调用,如果没有设置loginProcessUrl,loginProcessUrl值是null,然后使用我们新配置的loginPage构造出来。
这就完成了配置对默认登录页面的覆盖!
再追的更底一点看下?
还是看回到FormLoginConfigurer类中,它的构造方法中我们看到了两个配置用户名密码的方法:
我们猜想是与我们表单验证
POST方式请求接口传参有关的参数。
我们追进这两个方法看一下 usernameParameter:
注意到
getAuthenticationFilter方法,我们发现它是父类AbstractAuthenticationFilterConfigurer
为了获取到该类对象的
authFilter属性
authFilter属性的初始化是在构造函数中完成的。
关于这个构造方法的被调用我们回到
FormLoginConfigurer的构造方法我们传递过去的是new出来的UsernamePasswordAuthenticationFilter实例
追进
UsernamePasswordAuthenticationFilter的构造方法,我们看到该类设置的默认的请求匹配器
同样父类
AbstractAuthenticationProcessingFilter的构造方法
果然有相应的属性被赋值了!
对
UsernamePasswordAuthenticationFilter的追击我们先到这为止。(深度优先回溯bushi
我们回到usernameParameter方法,我们已经取得了authFilter属性(UsernamePasswordAuthenticationFilter的实例)
然后调用
UsernamepasswordAuthenticationFilter的实例的set方法
很显然是对属性的初始化or覆盖
答案是覆盖,覆盖了原来的用户名参数的值
这里的设计到底和我们的上层建筑有什么关联呢?
注意到本类还有这两个方法
它们的功能就是使用这两个参数去取得请求体的参数,名与
usernameParameter相同。
有mvc基础的小伙伴一下就想到了过滤器类型的类的超类基本上都有一个方法就是doFilter,它是请求的入口,拦在后台接口之前。
我们跳到AbstractAuthenticationProcessingFilter检索,果然发现了这个方法。
那么目的就很清楚了,我们的配置是抢先在过滤器实际运作之前的。所以这里我们就是让用配置来修改我们所需要的前端传递的参数。
默认是username,和password
回到Security的核心配置类
智能提示已经为我们指明方向了!
添加这两个参数的配置来覆盖默认值
同时对前端进行修改,和服务端进行对应。
启动服务,登录 and 通过!
tips 这里其实埋了一个坑,不过我们先不解决,等后面让问题暴露出来再做解析
登录成功的回调
在 Spring Security 中,和登录成功重定向 URL 相关的方法有两个:
- defaultSuccessUrl
- successForwardUrl
这两个咋看没什么区别,实际上内藏乾坤。
首先我们在配置的时候,defaultSuccessUrl 和 successForwardUrl 只需要配置一个即可,具体配置哪个,则要看你的需求,两个的区别如下:
defaultSuccessUrl有一个重载的方法,我们先说一个参数的defaultSuccessUrl方法。如果我们在defaultSuccessUrl中指定登录成功的跳转页面为/index,此时分两种情况,如果你是直接在浏览器中输入的登录地址,登录成功后,就直接跳转到/index,如果你是在浏览器中输入了其他地址,例如http://localhost:8081/hello,结果因为没有登录,又重定向到登录页面,此时登录成功后,就不会来到/index,而是来到/hello页面。defaultSuccessUrl还有一个重载的方法,第二个参数如果不设置默认为false,也就是我们上面的的情况,如果手动设置第二个参数为 true,则defaultSuccessUrl的效果和successForwardUrl一致。successForwardUrl表示不管你是从哪里来的,登录后一律跳转到successForwardUrl指定的地址。
successForwardUrl()方法追到下面发现这个方法,说明路径不能为"/",不能为空,且必须是post请求!
而
defaultSuccessUrl(),设置跳转路径后需要加true,不然默认是false,不会生效!
相关配置如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/doLogin")
.passwordParameter("userName")
.usernameParameter("passWord")
.successForwardUrl("/index")
.permitAll()
.and()
.csrf().disable();
}
我们实际操作的时候这两个只要配置了一个就可以了。
这里测出来结果是这样
在认证成功后,会要继续跳转至刚刚你需要访问的(需要授权)路径,在我在login.html页面上认证成功后,SpringSecurity继续访问/index,所以会出现302。
总的来说就是还没有授权! 不止如此,这个问题还和我们故意埋坑有关。
不过不怕,我们的目的就是完全的解剖它,之后再收拾它也不迟!
登录失败的回调
与登录成功相似,登录失败也是有两个方法:
failureForwardUrlfailureUrl
这两个方法在设置的时候也是设置一个即可。failureForwardUrl 是登录失败之后会发生服务端跳转,failureUrl 则在登录失败之后,会发生重定向。
登出
注销登录的配置:
- 默认注销的 URL 是
/logout,是一个 GET 请求,我们可以通过logoutUrl方法来修改默认的注销 URL。 logoutRequestMatcher方法不仅可以修改注销 URL,还可以修改请求方式 ,实际项目中,这个方法和logoutUrl任意设置一个即可。logoutSuccessUrl表示注销成功后要跳转的页面。deleteCookies用来清除 cookie。clearAuthentication和invalidateHttpSession分别表示清除认证信息和使HttpSession失效,默认可以不用配置,默认就会清除。
登录成功的处理逻辑
successHandler
之前我们配置登录成功的处理的逻辑是通过如下两个方法来配置的:
defaultSuccessUrlsuccessForwardUrl
这两个都是配置跳转地址的,适用于前后端不分的开发。而我们前后端分离的项目一般都是使用 successHandler。
successHandler 的功能十分强大,囊括了 defaultSuccessUrl 和 successForwardUrl 的功能。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
//指定认证方式为表单认证
.formLogin()
//需要校验登录至
.loginPage("/login.html")
//登录认证的post接口
.passwordParameter("userName")
.usernameParameter("passWord")
.loginProcessingUrl("/doLogin").permitAll()
.successHandler((req, resp, authentication) -> {
Object principal = authentication.getPrincipal();
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(principal));
out.flush();
out.close();
})
.failureHandler((req, resp, e) -> {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write(e.getMessage());
out.flush();
out.close();
})
// 认证成功后的跳转逻辑
// .successForwardUrl("/index")
.permitAll()
.and()
.logout()
.logoutUrl("/logout")
.logoutRequestMatcher(new AntPathRequestMatcher("/logout","POST"))
// .logoutSuccessUrl("/index")
.deleteCookies()
.clearAuthentication(true)
.invalidateHttpSession(true)
.permitAll()
.and()
.csrf().disable();
}
successHandler 方法的参数是一个 AuthenticationSuccessHandler 对象,这个对象中我们要实现的方法是 onAuthenticationSuccess。
onAuthenticationSuccess 方法有三个参数,分别是:
HttpServletRequestHttpServletResponseAuthentication
利用 HttpServletRequest 和 HttpServletResponse 我们可以随心所欲的控制请求,当然,也可以返回 JSON 数据。
Authentication 参数则保存了我们刚刚登录成功的用户信息。
配置完成后,我们再去登录,就可以看到登录成功的用户信息通过 JSON 返回到前端了。
登录失败的回调
failureHandler
.failureHandler((req, resp, e) -> {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write(e.getMessage());
out.flush();
out.close();
})
失败的回调也是三个参数,前两个就不用说了,第三个是一个 Exception,对于登录失败,会有不同的原因,Exception 中则保存了登录失败的原因,我们可以将之通过 JSON 返回到前端。报错提示也更加直观一点
封装一个model
Result:
/**
* @author pixel-revolve
* @date 2022/3/25
*/
@Data
public class Result {
private String message;
private String errorMessage;
Result(String message){this.message=message;}
Result(String errorMessage,String message){
this.message=message;
this.errorMessage=errorMessage;
}
public static Result error(String errorMessage){
return new Result(errorMessage,null);
}
}
在核心配置类配置一下failHandler
.failureHandler((req, resp, e) -> {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
Result result = Result.error(e.getMessage());
if (e instanceof LockedException) {
result.setMessage("账户被锁定,请联系管理员!");
} else if (e instanceof CredentialsExpiredException) {
result.setMessage("密码过期,请联系管理员!");
} else if (e instanceof AccountExpiredException) {
result.setMessage("账户过期,请联系管理员!");
} else if (e instanceof DisabledException) {
result.setMessage("账户被禁用,请联系管理员!");
} else if (e instanceof BadCredentialsException) {
result.setMessage("用户名或者密码输入错误,请重新输入!");
}
out.write(new ObjectMapper().writeValueAsString(result));
out.flush();
out.close();
})
我们请求登录一下,这下报错直观多了!
在 Spring Security 中,用户名查找失败对应的异常是:
UsernameNotFoundException
密码匹配失败对应的异常是:
BadCredentialsException
但是我们在登录失败的回调中,却总是看不到 UsernameNotFoundException 异常,无论用户名还是密码输入错误,抛出的异常都是 BadCredentialsException。
(而且我们现在的报错问题就是 Bad credentials)
我们回忆到登录的时候有一个很重要的一步:
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
try {
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found");
if (hideUserNotFoundExceptions) {
thrownew BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
else {
throw notFound;
}
}
}
从这段代码中,我们看出,在查找用户时,如果抛出了 UsernameNotFoundException,这个异常会被捕获,捕获之后,如果 hideUserNotFoundExceptions 属性的值为 true,就抛出一个 BadCredentialsException。相当于将 UsernameNotFoundException 异常隐藏了,而默认情况下,hideUserNotFoundExceptions 的值就为 true。
看到这里大家就明白了为什么无论用户还是密码写错,你收到的都是 BadCredentialsException 异常。
一般来说这个配置是不需要修改的,如果你一定要区别出来 UsernameNotFoundException 和 BadCredentialsException,有三种解决思路:
- 自己定义
DaoAuthenticationProvider代替系统默认的,在定义时将hideUserNotFoundExceptions属性设置为 false。 - 当用户名查找失败时,不抛出
UsernameNotFoundException异常,而是抛出一个自定义异常,这样自定义异常就不会被隐藏,进而在登录失败的回调中根据自定义异常信息给前端用户一个提示。 - 当用户名查找失败时,直接抛出
BadCredentialsException,但是异常信息为 “用户名不存在”。 但是也不用硬要区分开来,模糊的报错提示其实是有好处的。
未认证处理
有小伙伴说,那还不简单,没有认证就访问数据,直接重定向到登录页面就行了,这没错,系统默认的行为也是这样。
但是在前后端分离中,这个逻辑明显是有问题的,如果用户没有登录就访问一个需要认证后才能访问的页面,这个时候,我们不应该让用户重定向到登录页面,而是给用户一个尚未登录的提示,前端收到提示之后,再自行决定页面跳转。
要解决这个问题,就涉及到 Spring Security 中的一个接口 AuthenticationEntryPoint ,该接口有一个实现类:LoginUrlAuthenticationEntryPoint ,该类中有一个方法 commence,如下:
这里就能看出来它的逻辑是想让你转发(forward)还是重定向(redirect)!
我们找到成员变量useForward,发现默认是不适用转发的而是使用重定向
那么我们解决问题的思路很简单,直接重写这个方法,在方法中返回 JSON 即可,不再做重定向操作,具体配置如下:
在 Spring Security 的配置中加上自定义的
AuthenticationEntryPoint 处理方法,该方法中直接返回相应的 JSON 提示即可。这样,如果用户再去直接访问一个需要认证之后才可以访问的请求,就不会发生重定向操作了,服务端会直接给浏览器一个 JSON 提示,浏览器收到 JSON 之后,该干嘛干嘛。
我们在未登录前请求/index接口
注销登录处理
注销登录我们前面说过,按照前面的配置,注销登录之后,系统自动跳转到登录页面,这也是不合适的,如果是前后端分离项目,注销登录成功后返回 JSON 即可,配置如下:
.and()
.logout()
.logoutUrl("/logout")
// .logoutRequestMatcher(new AntPathRequestMatcher("/logout","POST"))
// .logoutSuccessUrl("/index")
.logoutSuccessHandler((req, resp, authentication) -> {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write("注销成功");
out.flush();
out.close();
})
.deleteCookies()
.clearAuthentication(true)
.invalidateHttpSession(true)
.permitAll()
.and()
这样,注销成功之后,前端收到的也是 JSON 了:
登录逻辑
Authentication
玩过 Spring Security 的小伙伴都知道,在 Spring Security 中有一个非常重要的对象叫做 Authentication,我们可以在任何地方注入 Authentication 进而获取到当前登录用户信息,Authentication 本身是一个接口,它有很多实现类:
在这众多的实现类中,我们最常用的就是
UsernamePasswordAuthenticationToken 了,但是当我们打开这个类的源码后,却发现这个类平平无奇,他只有两个属性、两个构造方法以及若干个 get/set 方法;当然,他还有更多属性在它的父类上。
但是从它仅有的这两个属性中,我们也能大致看出,这个类就保存了我们登录用户的基本信息。那么我们的登录信息是如何存到这两个对象中的?这就要来梳理一下登录流程了。
登录流程
在 Spring Security 中,认证与授权的相关校验都是在一系列的过滤器链中完成的,在这一系列的过滤器链中,和认证相关的过滤器就是 UsernamePasswordAuthenticationFilter先贴出源码:
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login", "POST");
private String usernameParameter = "username";
private String passwordParameter = "password";
private boolean postOnly = true;
public UsernamePasswordAuthenticationFilter() {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
}
public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
}
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String username = this.obtainUsername(request);
username = username != null ? username : "";
username = username.trim();
String password = this.obtainPassword(request);
password = password != null ? password : "";
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
@Nullable
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(this.passwordParameter);
}
@Nullable
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(this.usernameParameter);
}
protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
public void setUsernameParameter(String usernameParameter) {
Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
this.usernameParameter = usernameParameter;
}
public void setPasswordParameter(String passwordParameter) {
Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
this.passwordParameter = passwordParameter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getUsernameParameter() {
return this.usernameParameter;
}
public final String getPasswordParameter() {
return this.passwordParameter;
}
}
注意到 attemptAuthentication 方法
- 首先从
Authentication提取出登录用户名。 - 然后通过拿着 username 去调用
retrieveUser方法去获取当前用户对象,这一步会调用我们自己在登录时候的写的loadUserByUsername方法,所以这里返回的 user 其实就是你的登录对象。 - 接下来调用
preAuthenticationChecks.check方法去检验 user 中的各个账户状态属性是否正常,例如账户是否被禁用、账户是否被锁定、账户是否过期等等。 additionalAuthenticationChecks方法则是做密码比对的,好多小伙伴好奇 Spring Security 的密码加密之后,是如何进行比较的,看这里就懂了,因为比较的逻辑很简单,我这里就不贴代码出来了。- 最后在
postAuthenticationChecks.check方法中检查密码是否过期。 - 接下来有一个
forcePrincipalAsString属性,这个是是否强制将Authentication中的principal属性设置为字符串,这个属性我们一开始在UsernamePasswordAuthenticationFilter类中其实就是设置为字符串的(即 username),但是默认情况下,当用户登录成功之后, 这个属性的值就变成当前用户这个对象了。之所以会这样,就是因为forcePrincipalAsString默认为 false,不过这块其实不用改,就用 false,这样在后期获取当前用户信息的时候反而方便很多。 - 最后,通过
createSuccessAuthentication方法构建一个新的UsernamePasswordAuthenticationToken。
简单来说就是找认证服务的提供者(AuthenticationProvider的具体实现类)(服务提供者可能不止一个)看是否支持这个认证(authentication)要是支持就使用它的authenticate方法
登录的整个流程就是
UsernamePasswordAuthenticationFilter--attemptAuthentication()-->AuthenticationManager--authenticate()-->AuthenticationProvider--authenticate()-->Authentication
Authentication作为底层返回值回到最上层UsernamePasswordAuthenticationFilter开启方法调用链的方法
用户名密码登录的过滤器最后得到的就是用户名密码认证Token。
登录用户的信息?
要去找登录的用户信息,我们得先来解决一个问题,就是上面我们说了这么多,这一切是从哪里开始被触发的?
我们来到 UsernamePasswordAuthenticationFilter 的父类 AbstractAuthenticationProcessingFilter 中,这个类我们经常会见到,因为很多时候当我们想要在 Spring Security 自定义一个登录验证码或者将登录参数改为 JSON 的时候,我们都需自定义过滤器继承自 AbstractAuthenticationProcessingFilter ,毫无疑问,UsernamePasswordAuthenticationFilter#attemptAuthentication 方法就是在 AbstractAuthenticationProcessingFilter 类的 doFilter 方法中被触发的。(这个我上面也说了。。。)
从上面的代码中,我们可以看到,当
attemptAuthentication 方法被调用时,实际上就是触发了 UsernamePasswordAuthenticationFilter#attemptAuthentication 方法,当登录抛出异常的时候,unsuccessfulAuthentication 方法会被调用,而当登录成功的时候,successfulAuthentication 方法则会被调用,那我们就来看一看 successfulAuthentication 方法:
成功认证了以后会生成
SecurityContext(上下文),这个context中包含了我们过滤完成后得到的Authentication,而context又是被放在SecurityContextHolder里面的
也就是说我们的用户信息被封装了好几层,而最外层的
SecurityContextHolder可以在随处获取到用户的登录信息!
授权
实验环境搭建
基于上面的实验环境
准备测试用户
配置类的方式:
因为我们现在还没有连接数据库,所以测试用户还是基于内存来配置。
基于内存配置测试用户,我们使用配置类的配置方式,如下:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("pixel-revolve")
.password(passwordEncoder().encode("123456")).roles("admin")
.and()
.withUser("海绵宝宝")
.password(passwordEncoder().encode("123456")).roles("user");
}
tips
AuthenticationManagerBuilder这个类也很有意思,可以追进去看下
UserDetailService:
由于 Spring Security 支持多种数据源,例如内存、数据库、LDAP 等,这些不同来源的数据被共同封装成了一个 UserDetailService 接口,任何实现了该接口的对象都可以作为认证数据源。
因此我们还可以通过重写 WebSecurityConfigurerAdapter 中的 userDetailsService 方法来提供一个 UserDetailService 实例进而配置多个用户:
@Bean
protected UserDetailsService userDetailsService() {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("pixel-revolve").password("123456").roles("admin").build());
manager.createUser(User.withUsername("海绵宝宝").password("123456").roles("user").build());
return manager;
}
准备好测试接口
/admin/**下的接口只有拥有admin角色的用户能访问 准备好测试接口
/admin/**下的接口只有拥有admin角色的用户能访问
/**
* @author pixel-revolve
* @date 2022/3/25
*/
@Slf4j
@RestController
@RequestMapping("/admin")
public class AdminController {
@GetMapping("/index")
public String indexController(){
log.info("请求/admin/index接口");
return "index in admin";
}
}
/user/**下的接口只有拥有user角色的用户能访问
/**
* @author pixel-revolve
* @date 2022/3/24
*/
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/index")
public String indexController(){
log.info("请求/user/index接口");
return "index in user";
}
}
/base/**下的接口所有用户都能访问
/**
* @author pixel-revolve
* @date 2022/3/25
*/
@Slf4j
@RestController
@RequestMapping("/base")
public class BaseController {
@GetMapping("/index")
public String indexController(){
log.info("请求/user/base接口");
return "index in base";
}
}
SpringSecurity核心配置类配置鉴权逻辑
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.anyRequest().authenticated()
.and()
这里的匹配规则我们采用了 Ant 风格的路径匹配符,Ant 风格的路径匹配符在 Spring 家族中使用非常广泛,它的匹配规则也非常简单: \
| 通配符 | 含义 |
|---|---|
| ** | 匹配多层路径 |
| * | 匹配一层路径 |
| ? | 匹配任意单个字符 |
上面配置的含义是:
- 如果请求路径满足
/admin/**格式,则用户需要具备 admin 角色。 - 如果请求路径满足
/user/**格式,则用户需要具备 user 角色。 - 剩余的其他格式的请求路径,只需要认证(登录)后就可以访问。
如果上面的配置你把anyRequest().authenticated()放到.antMatchers上面的话会报错
这从语义上很好理解,anyRequest 已经包含了其他请求了,在它之后如果还配置其他请求也没有任何意义。
从语义上理解,anyRequest 应该放在最后,表示除了前面拦截规则之外,剩下的请求要如何处理。
在拦截规则的配置类 AbstractRequestMatcherRegistry 中,我们可以看到如下一些代码(部分源码):
从这段源码中,我们可以看到,在任何拦截规则之前(包括 anyRequest 自身),都会先判断 anyRequest 是否已经配置,如果已经配置,则会抛出异常,系统启动失败。
这样大家就理解了为什么 anyRequest 一定要放在最后。
前面埋下的坑解决
好了在梳理完登录逻辑并且我们的环境已经搭建的差不多了,现在可以暴露我们的问题并且着手解决了!
请求/doLogin接口
中间翻了车了,还是前面的问题,不过有了先前的基础我们可以尝试debug解决一下,正好检验一下我们的实力!
登录认证出错了所以才会返回这段json,所以我们直接把断点打在AbstractUserDetailsAuthenticationProvider的authenticate方法里面,进去之后发现问题来了,username和password搞反了。。。难怪一直错误
从上层开始检查
发现两个parameter写反了。。。
很简单的错误,但是不知道如何定位的话还是要花很多时间才能发现这个bug的导致原因是这样简单。在有理论支撑后我们直接在能暴露问题的位置打上断点就好了!
再请求,得到结果,意料之中。淡定
测试
我们试着使用role为user的海绵宝宝请求/user/index
再请求/admin下的资源
发现角色鉴权确实有效果!
角色继承
我们使用角色为 admin 的pixel-revolve用户登录并测试上面的几个接口。
请求/admin/**下的资源
请求/user/**下的资源
发现使用admin的时候无法请求到/user/**的资源,而我们设计的逻辑一般都是admin拥有比user更高的权限,user能够访问到的资源admin都能访问到。
这个需求当然是可以在antMatcher的配置阶段完成的。但是显然不够优雅。
我们这里则是推出一种角色继承的方式来帮助完成这一需求:
上级可能具备下级的所有权限,如果使用角色继承,这个功能就很好实现,我们只需要在 SecurityConfig 中添加如下代码来配置角色继承关系即可:
@Bean
RoleHierarchy roleHierarchy() {
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
hierarchy.setHierarchy("ROLE_admin > ROLE_user");
return hierarchy;
}
注意配置时,需要给角色手动加上ROLE_前缀。上面的配置表示 ROLE_admin 自动具备ROLE_user 的权限。
我们重新启动项目,再次用pixel-revolve角色请求/user/**下的资源。
发现现在admin角色的用户已经有权限访问user下的资源了。
用户存进数据库
不过,Spring Security 也给我们提供了一个它自己设计好的权限数据库,这里我们先来看看这是怎么回事!先来学这个简单的,然后我们再去看复杂的。
UserDetailService
Spring Security 支持多种不同的数据源,这些不同的数据源最终都将被封装成 UserDetailsService 的实例,Spring security项目中我们一般要自己来创建一个类实现 UserDetailsService 接口,除了自己封装,我们也可以使用系统默认提供的 UserDetailsService 实例,还有基于内存的InMemoryUserDetailsManager 。
我们来看下 UserDetailsService 都有哪些实现类:
可以看到,在几个能直接使用的实现类中,除了 InMemoryUserDetailsManager 之外,还有一个 JdbcUserDetailsManager,使用 JdbcUserDetailsManager 可以让我们通过 JDBC 的方式将数据库和 Spring Security 连接起来。
JdbcUserDetailManager
JdbcUserDetailsManager 自己提供了一个数据库模型,这个数据库模型保存在如下位置:
org/springframework/security/core/userdetails/jdbc/users.ddl
这里存储的脚本内容如下:
create table users(username varchar_ignorecase(50) not null primary key,password varchar_ignorecase(500) not null,enabled boolean not null);
create table authorities (username varchar_ignorecase(50) not null,authority varchar_ignorecase(50) not null,constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);
可以看到,脚本中有一种数据类型 varchar_ignorecase,这个其实是针对 HSQLDB 数据库创建的,而我们使用的 MySQL 并不支持这种数据类型,所以这里需要大家手动调整一下数据类型,将 varchar_ignorecase 改为 varchar 即可。
create database `security_study`;
use security_study;
create table users(username varchar(50) not null primary key,password varchar(500) not null,enabled boolean not null);
create table authorities (username varchar(50) not null,authority varchar(50) not null,constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);
修改完成后,创建数据库,执行完成后的脚本。
执行完 SQL 脚本后,我们可以看到一共创建了两张表:users 和 authorities。
- users 表中保存用户的基本信息,包括用户名、用户密码以及账户是否可用。
- authorities 中保存了用户的角色。
- authorities 和 users 通过 username 关联起来。
配置完成后,接下来,我们将上篇文章中通过 InMemoryUserDetailsManager 提供的用户数据用 JdbcUserDetailsManager 代替掉,如下:
@Resource
DataSource dataSource;
@Bean
@Override
protected UserDetailsService userDetailsService() {
JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);
if (!manager.userExists("pixel-revolve")) {
manager.createUser(User.withUsername("pixel-revolve").password("123456").roles("admin").build());
}
if (!manager.userExists("海绵宝宝")) {
manager.createUser(User.withUsername("海绵宝宝").password("123456").roles("user").build());
}
return manager;
}
这段配置的含义如下:
- 首先构建一个 JdbcUserDetailsManager 实例。
- 给 JdbcUserDetailsManager 实例添加一个 DataSource 对象。
- 调用 userExists 方法判断用户是否存在,如果不存在,就创建一个新的用户出来(因为每次项目启动时这段代码都会执行,所以加一个判断,避免重复创建用户)。
- 用户的创建方法和我们之前 InMemoryUserDetailsManager 中的创建方法基本一致。
这里的 createUser 或者 userExists 方法其实都是调用写好的 SQL 去判断的,我们从它的源码里就能看出来:
数据库支持
将数据库依赖导入
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
在application.yaml中配置数据库连接:
server:
port: 8081
#spring:
# security:
# user:
# name: pixel-revolve
# password: 123456
spring:
web:
resources:
static-locations: classpath:/static,classpath:/templates/,classpath:/public,classpath:/resources,classpath:/META-INF/resources
datasource:
url: jdbc:mysql://localhost:3306/security_study?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2b8
username: root
password: 123456
driver‐class‐name: com.mysql.cj.jdbc.Driver
启动项目
我们发现数据库中自动的导入了两个用户
接下来大家就自行的对两个用户进行测试吧!
在测试的过程中,如果在数据库中将用户的 enabled 属性设置为 false,表示禁用该账户,此时再使用该账户登录就会登录失败。
更加真实的开发场景?
我们先导入mybatis-plus依赖(大家可以选择自己喜欢的框架搭建)
<!--mybatis-plus依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
创建三张表
use security_study;
# select *
# from users u
# left join authorities a using (username);
# drop table security_study.users;
# drop table security_study.authorities;
create table `t_role`(
id bigint primary key not null auto_increment,
name varchar(20) not null
)engine innodb;
create table `t_user`(
id bigint primary key not null auto_increment,
username varchar(30) not null ,
password varchar(30) not null ,
account_non_expired tinyint,
account_non_locked tinyint,
credentials_non_expired tinyint,
enabled tinyint
)engine innodb;
CREATE TABLE `t_user_role` (
`user_id` bigint NOT NULL,
`role_id` bigint NOT NULL,
PRIMARY KEY (`user_id`,`role_id`),
FOREIGN KEY (`user_id`) REFERENCES `t_user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (`role_id`) REFERENCES `t_role` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4;
创建两个实体类 MyUser:
@Data
@TableName("t_user")
public class MyUser implements UserDetails {
private Long id;
private String username;
private String password;
private boolean accountNonExpired;
private boolean accountNonLocked;
private boolean credentialsNonExpired;
private boolean enabled;
private List<Role> roles;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (Role role : getRoles()) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
}
Role:
@Data
@TableName("t_role")
public class Role implements Serializable {
private Long id;
private String name;
}
UserRole:
@TableName("t_user_role")
@Data
@AllArgsConstructor
public class MyUserRole {
Long userId;
Long roleId;
}
对应的dao层类
User:
@Mapper
public interface UserMapper extends BaseMapper<MyUser> {
MyUser selectByUsername(String username);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.dyh.mapper.UserMapper">
<resultMap type="com.dyh.pojo.MyUser" id="MyUserMap">
<id property="id" column="id" jdbcType="BIGINT"/>
<result property="username" column="username" jdbcType="VARCHAR"/>
<result property="password" column="password" jdbcType="VARCHAR"/>
<result property="accountNonExpired" column="account_non_expired" jdbcType="TINYINT"/>
<result property="accountNonLocked" column="account_non_locked" jdbcType="TINYINT"/>
<result property="credentialsNonExpired" column="credentials_non_expired" jdbcType="TINYINT"/>
<result property="enabled" column="enabled" jdbcType="TINYINT"/>
<collection property="roles" ofType="com.dyh.pojo.Role" fetchType="lazy">
<id property="id" column="id" jdbcType="BIGINT"/>
<id property="name" column="name" jdbcType="VARCHAR"/>
</collection>
</resultMap>
<select id="selectByUsername" resultMap="MyUserMap">
select *
from security_study.t_user u
left join security_study.t_user_role ur
on u.id = ur.user_id
left join security_study.t_role r
on ur.role_id = r.id
where username=#{username}
</select>
</mapper>
Role:
@Mapper
public interface RoleMapper extends BaseMapper<Role> {
Role selectByRoleName(String name);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.dyh.mapper.RoleMapper">
<resultMap type="com.dyh.pojo.Role" id="RoleMap">
<id property="id" column="id" jdbcType="BIGINT"/>
<result property="name" column="name" jdbcType="VARCHAR"/>
</resultMap>
<select id="selectByRoleName" resultMap="RoleMap">
select *
from security_study.t_role r
where name=#{name}
</select>
</mapper>
UserRole:
@Mapper
public interface UserRoleMapper extends BaseMapper<MyUserRole> {
}
先请注释掉刚才配置
自定义 UserService 继承 UserDetailsService
/**
* @author pixel-revolve
* @date 2022/3/26
*/
@Service
public class UserService implements UserDetailsService {
@Resource
UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// Map<String,Object> conditions=new HashMap<>();
// conditions.put("username",username);
// List<MyUser> users = userMapper.selectByMap(conditions);
//这里我们默认用户名是unique的
// MyUser user=users.get(0);
MyUser user=userMapper.selectByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
return user;
}
}
我们自己定义的 UserService 需要实现 UserDetailsService 接口,实现该接口,就要实现接口中的方法,也就是 loadUserByUsername ,这个方法的参数就是用户在登录的时候传入的用户名,根据用户名去查询用户信息(查出来之后,系统会自动进行密码比对)。
配置完成之后我们还需要在 Spring Security 中稍作配置
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}
大家注意,还是重写 configure 方法,只不过这次我们不是基于内存,也不是基于 JdbcUserDetailsManager,而是使用自定义的 UserService,就这样配置就 OK 了。
@Resource
UserService userService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}
完成以后再进行测试 插入数据
/**
* @author pixel-revolve
* @date 2022/3/26
*/
@SpringBootTest(classes = {SpringSecurityDemoApplication.class})
@RunWith(SpringRunner.class)
public class CRUDTest {
@Resource
UserMapper userMapper;
@Resource
RoleMapper roleMapper;
@Resource
UserRoleMapper userRoleMapper;
@Test
public void testMapper(){
// MyUser user1 = new MyUser();
// user1.setUsername("pixel-revolve");
// user1.setPassword("123456");
// user1.setAccountNonExpired(true);
// user1.setAccountNonLocked(true);
// user1.setCredentialsNonExpired(true);
// user1.setEnabled(true);
// List<Role> roles1 = new ArrayList<>();
//
// Role r1 = new Role();
// r1.setName("ROLE_admin");
// roleMapper.insert(r1);
// roles1.add(r1);
//
// user1.setRoles(roles1);
// userMapper.insert(user1);
//
// user1=userMapper.selectByUsername(user1.getUsername());
// r1= roleMapper.selectByRoleName(r1.getName());
// MyUserRole myUserRole=new MyUserRole(user1.getId(),r1.getId());
// userRoleMapper.insert(myUserRole);
MyUser user1 = new MyUser();
user1.setUsername("海绵宝宝");
user1.setPassword("123456");
user1.setAccountNonExpired(true);
user1.setAccountNonLocked(true);
user1.setCredentialsNonExpired(true);
user1.setEnabled(true);
List<Role> roles1 = new ArrayList<>();
Role r1 = new Role();
r1.setName("ROLE_user");
roleMapper.insert(r1);
roles1.add(r1);
user1.setRoles(roles1);
userMapper.insert(user1);
user1=userMapper.selectByUsername(user1.getUsername());
r1= roleMapper.selectByRoleName(r1.getName());
MyUserRole myUserRole=new MyUserRole(user1.getId(),r1.getId());
userRoleMapper.insert(myUserRole);
}
}
查询一下
select *
from t_user
left join t_user_role tur
on t_user.id = tur.user_id
left join t_role tr
on tur.role_id = tr.id
两个用户已经成功的存入数据库中了!
接下来就是接口测试了!
启动项目 按照之前的逻辑测试接口
完成收工~
tips
我们走到loadByUsername后进入了DaoAuthenticationProvider的retrieveUser方法(取回用户)。说明这里的通过配置UserService的认证提供者是DaoAuthenticationProvider
总结
其实我们发现 Spring Security 说到底,实现的逻辑也还是依靠底层的几个接口。
- Authentication
- AuthenticationFilter
- UserDetailsService
- UserDetailManager
- UserDetailManagerBuilder
- AuthenticationFilterConfigurer
- ... 跟着源码一点一点也加深了对它们的印象。
这篇文章比较完整的解释了授权的源码和鉴权的自定义配置和部分源码。也花了一段时间。不过相信读完的小伙伴应该能有所收获!
本文参考: