一、Security数据库登录流程分析
2、被spring security的filter过滤器拦截(里面有16个Filter);
3、由于没有登录过,所以spring security就跳转到登录页(登录页是框架生成的)
4、我们在登录页输入账号和密码去登录提交;(账号和密码是数据库的账号密码)
5、spring security里面的UsernamePasswordAuthenticationFilter接收账号和密码;
6、第5步的这个filter会调用loadUserByUsername(String username)方法去数据库查询用户;
7、从数据库查询到用户后,把用户组装成UserDetails对象,然后返回给SpringSecurity框架;
8、第7步返回后,再回到框架的filter里面进行用户状态的判断,用户对象中默认有4个状态字段,如果这4个状态字段的值都是true,该用户才能登录,否则就是提示用户状态不正常,不能登录的(框架中实际上只判断3个状态值,那个密码是否过期没有做判断);
9、第7步返回后,再回到框架的filter里面进行密码的匹配,如果密码匹配上了,就登录成功,否则失败;
10、比较密码代码:
AbstractUserDetailsAuthenticationProvider.java
java
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
});
String username = this.determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
} catch (UsernameNotFoundException var6) {
UsernameNotFoundException ex = var6;
this.logger.debug(LogMessage.format("Failed to find user '%s'", username));
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
// 是在自定义的UserDetailsService实现中用来执行额外的身份验证检查的
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
} catch (AuthenticationException var7) {
AuthenticationException ex = var7;
if (!cacheWasUsed) {
throw ex;
}
cacheWasUsed = false;
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return this.createSuccessAuthentication(principalToReturn, authentication, user);
}
上述源码中additionalAuthenticationChecks方法是在自定义的UserDetailsService实现中用来执行额外的身份验证检查的。这个方法允许开发者根据业务需求对用户信息进行额外的验证。
通过当前方法定位到 protected abstract void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException;抽象方法
DaoAuthenticationProvider.java
java
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
} else {
String presentedPassword = authentication.getCredentials().toString();
// 这里是验证密码正确的关键步骤 判断密码是否正确,如果不正确则抛出异常
if (!((PasswordEncoder)this.passwordEncoder.get()).matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}
解析:
该代码块就是进行前端传过来的密码 和 数据库的查询的密码 进行匹配,如果匹配失败,意味着你输入的密码与系统中存储的密码不匹配.
二、自定义登录页
如果以后我们想要在登录页上加一些其他的东西去登录,比如验证码该怎么办呢?对吧,因此我们想能不能自己定义登录也做这个事情,及有所求,必有所应。
我们需要在Security的配置类中再注册一个Bean(SecurityFilterChain),安全过滤器链实现,在容器生成该Bean的时候呢,加入我们自己的业务逻辑就可以了。
看以下代码:
SecurityConfig.java
java
@Configuration
public class SecurityConfig {
// 配置加密器
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
// 注册一个Bean(安全过滤器链), 定义一些我们自己的过滤逻辑(认证登录的时候去访问我们自定义的登录页面)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
// 发送/toLogin请求 跳转到登录页面 ==> 页面的选择: jsp(淘汰),vue(暂时不考虑) html(thymeleaf暂时使用) 需要引入依赖
.formLogin(formLogin -> formLogin.loginPage("/toLogin"))
.build(); // 如果只写一个build也只是空对象,因此在创建的时候加一些属性
}
}
源码分析:

在Spring Security中,FormLoginConfigurer 是一个用于配置表单登录的类。它允许你自定义登录表单的行为,比如登录页面URL、登录处理URL、登录成功或失败后的行为等。

通过源码分析可知Customizer接口上有一个注解@FunctionalInterface,表名当前接口属于函数式接口,函数式是Java中一种特殊的接口类型,有且仅有一个抽象方法,用于支持函数式编程和Lambda表达式简化代码实现。 所以可以在return httpSecurity.formLogin()方法中编写Lambda表达式用来生成登录页面。
接下来我们去编写我们的login.html页面去,前提是先添加thymeleaf依赖:
XML
<!--thymeleaf依赖 模版依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>3.5.3</version>
</dependency>
在resources目录下创建 templates 目录,定义 login.html 登录页
login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<form method="post" action="/login">
<p>
username: <input name = "username">
</p>
<p>
password: <input type="password" name="password"/>
</p>
<p>
<input type="submit" value="登录"/>
</p>
</form>
</body>
</html>
接下来为了能访问到我们自定义的登录页,去编写一个去登录页面的controller
UserController.java
java
@Controller
public class UserController {
@Autowired
private UserService userService;
/**
* 跳转登录页面
* @return
*/
@GetMapping("/toLogin")
public String toLogin(){
return "login";
}
/**
* 用户登录功能
* @param username
* @param password
* @return
*/
@GetMapping("/login")
@ResponseBody
public String login(String username, String password){
return "we are comming login";
}
@GetMapping("/add")
@ResponseBody
public String add(String username, String password){
return "hello add...";
}
}
特别注意:这个时候不能让该方法返回json,因为我们这里返回的这个字符串其实是代表的是去的页面,因此应该将 注解改为@Controller,在需要返回json的方法上添加ResponseBody注解就行。
来我们测试一下:发送toLogin请求能否去登录页面呢? ==> 肯定没问题

访问我们之前的 http://localhost:8080/add 路径,看看需不需要登录

此时发现不需要登录也能去访问添加功能,这样就没有了Security的效果了吗
因为现在走的是我们自己定义的过滤器链,而我们这里没有定义验证,框架定义了.
我们这里需要定义下就好了

再试一试

此时发现网页进不去了
我们来找原因:

拦截是所有请求都要验证是否认证,但是我们还没有登录认证,所以我们就会一直在/toLogin这里转悠。
知道原因就好解决了。怎么办呢?
我们只需要将 登录页面的请求 不让它认证不就可以了吗? 如何实现呢? 如下:

来吧,再验证一下吧:
先去访问/add请求,看会不会去验证来

但是,我们输入正确的用户名和密码之后还是不行:

这个是为啥呢?我们去看一下原来Security帮我们生成的页面,我们先把我们注册的那个安全过滤器链先注释一下看看:
右键查看源代码,我们发现登录页面上除了用用户名和密码外还有一个隐藏域:

我们只需要把这个字段加上去和相关的值也赋值过去就好了,值是Security框架生成的,通过thymeleaf语法获取一下就好了。
如图:

测试发现还是不行
当发送请求时,因为过滤器是自己写的,我们要告诉Spring Security安全框架,让我们自定义页面中的请求地址走安全框架进行认证。


此时发现可以进去了
三、获取当前登录用户信息
如果我们登录成功,完成认证后,如何获取用户的信息呢,对吧!
这个呢,我们也有相应的方式去实现,首先,在controller中定义一个成功登陆后的一个行为,比如:
UserController.java
java
@Controller
public class UserController {
@RequestMapping("/index")
@ResponseBody
public Principal index(Principal principal){
return principal;
}
}
Principal:源码注释内容:
This interface represents the abstract notion of a principal, which can be used to represent any entity, such as an individual, a corporation, and a login id. 啥意思呢?
该接口表示主体的抽象概念,可用于表示任何实体,例如个人、公司和登录 ID。
注意:这里的请求映射要用RequestMapping或者PostMapping,为什么呢?一会揭晓。
但是我们怎么让 security 知道我们认证成功后要发送/index请求呢?
那么就需要我们在security的配置类中做一个定制的配置就好了,告诉Security,怎么做呢?

这个代码的意思就是认证成功后转发的路径。
运行项目的结果:虽然能够返回用户信息,但是有效信息不多。

我们发现以上的结果包含了sessionid,用户名,和权限信息(目前是空权限,因为我们配置的就是空权限).
还有4个值,分别表示:
- 账户没有过期
- 账户没有被锁
- 凭证没有过期
- 可以使用
但是关于用户的信息,除了用户名之外什么都没有了。
但是我需要用户的完整信息应该怎么获取呢?
我们发现返回的信息标注的内容很眼熟,哪里见过呢?UserDetails的内容看看:

其实就是这个UserDetail的内容,但是我们发现也不包含我们想要的信息。那怎么办呢?
开动一下脑筋,异想天开一下:我们的User实体能不能去实现UserDetails接口呢?
答案是显而易见的,这样我们只需要重写接口中的抽象方法就好了,当然默认方法想重写也不是不可以。
那么就开始改造吧:(为了方便操作,我们把我们自己的User改个名字叫Tuser,其他名字也可以)

TUser.java
java
/**
* 用户表 user
*/
@Data
public class TUser implements UserDetails, Serializable {
/**
* 用户ID
*/
private Integer id;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 真实姓名
*/
private String realName;
/**
* 手机号
*/
private String phone;
/**
* 状态:0-禁用,1-正常
*/
private Byte status;
/**
* 创建时间
*/
private Date createTime;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
}
重写的东西是关于权限的内容,我们这里暂时先不动,实现了不报错就行。

那么紧接着我们自定义的UserService那里就可以改一下了

这里我们是不是返回我们自己的TUser就可以了呢?
因为TUser是UserDetails的实现类。因此可以直接返回TUser对象即可
那么就可以这么改了:

那么重新测试看看

四、用户信息存储组件修改
其实Controller中的Principal也可以修改,因为它是一个接口,那么我们来看看它的关系:

快捷键: ctrl + h 可以获取当前Principal接口的子接口



那么Principal也可以换成Authentication/UsernamePasswordAuthenticationToken都可以获取到用户信息。
如下: 这里先改成标记的方式请求映射,方便测试

此时发现以上两种情况都是可以获取到当前用户的详细信息:

或者通过框架上下文的方式也可以获取

这个时候在回头看我们之前说的security框架中的组件就能理解它们的意思了

4.1 获取用户信息优化

上图方法中的SecurityContextHolder.getContext().getAuthentication();内容比较长。
我们可不可以去写一个工具类呢?
应该可以的:添加 utils 包 / LoginInfoUtils
LoginInfoUtils.java
java
public class LoginInfoUtils {
public static TUser currentLoginUser(){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
TUser tuser = (TUser) authentication.getPrincipal();
return tuser;
}
}
这样是否可行呢?
那么在Controller中就可以这么用了(不需要依赖注入的方式了):
UserController.java
java
@Controller
public class UserController {
@RequestMapping ("/index4")
@ResponseBody
public TUser index4(){
return LoginInfoUtils.currentLoginUser();
}
}
测试结果如下:

4.2 JSON日期处理扩展
小扩展:我们需要注意,密码不应该返回的,对吧,那么我们的处理方案有:写一个BO/VO,
这里说一个Json的注解,如果没有BO/VO可以使用如下的注解:

json日期格式化:记得加时区,不然数据库中的时间和返回的时间不一致,默认是0时区,我们这里是东8区。

测试结果如下:

但是你想一下,如果以后其他类中也有多个日期的属性,你都要这样一个一个去加格式化日期,这样方便吗?
肯定不行的,我们可以统一进行处理。
因为在springboot中默认的json格式化是由jackson处理的,我们可以将统一的日期格式在yml文件中配置:
