[
关于用户身份认证与授权
基本原理
Spring Security是用于解决认证与授权的框架。
spring security 核心功能
- 认证 (你是谁)
- 授权 (你能干什么)
- 攻击防护 (防止伪造身份)
内容简介
- SpringSecurity基本原理
- 实现用户名 + 密码认证 : 常见的认证方式,了解核心概念,如何在默认实现上添加自定义扩展
- 实现手机号 + 短信认证 : 框架没有默认实现,所以来学着实现该功能
在根项目下创建新的csmall-passport
子模块,最基础的依赖项包括spring-boot-starter-web
与spring-boot-starter-security
(为避免默认存在的测试类出错,应该保留测试的依赖项spring-boot-starter-test
),完整的csmall-passwort
的pom.xml
为:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 父级项目 -->
<parent>
<groupId>cn.tedu</groupId>
<artifactId>csmall-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<!-- 当前项目的信息 -->
<groupId>cn.tedu</groupId>
<artifactId>csmall-passport</artifactId>
<version>0.0.1-SNAPSHOT</version>
<!-- 当前项目需要使用的依赖项 -->
<dependencies>
<!-- Spring Boot Web:支持Spring MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Security:处理认证与授权 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Boot Test:测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
调整完成后,即可启动项目,在启动的日志中,可以看到类似以下内容:
Using generated security password: 2abb9119-b5bb-4de9-8584-9f893e4a5a92
Spring Security有默认登录的账号和密码(以上提示的值),密码是随机的,每次启动项目都会不同。
Spring Security默认要求所有的请求都是必须先登录才允许的访问,可以使用默认的用户名user
和自动生成的随机密码来登录。在测试登录时,在浏览器访问当前主机的任意网址都可以(包括不存在的资源),会自动跳转到登录页(是由Spring Security提供的,默认的URL是:http://localhost:8080/login),当登录成功后,会自动跳转到此前访问的URL(跳转登录页之前的URL),另外,还可以通过 http://localhost:8080/logout 退出登录。
自定义用户认证逻辑
- 处理用户信息获取逻辑
- 处理用户校验逻辑
- 处理密码加密解密
处理用户信息获取逻辑
org.springframework.security.core.userdetails.UserDetailsService
UserDetailsService接口用于加载用户特定的数据,它在整个框架中作为用户DAO使用,是验证提供者使用的策略。
该接口只需要一个只读方法,这简化了对新的数据访问策略的支持。
实现一个自定义的UserDetailsService
package cn.mrcode.imooc.springsecurity.securitybrowser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
/**
* ${desc}
* @author zhuqiang
* @version 1.0.1 2018/8/3 9:16
* @date 2018/8/3 9:16
* @since 1.0
*/
// 自定义数据源来获取数据
// 这里只要是存在一个自定义的 UserDetailsService ,那么security将会使用该实例进行配置
@Component
public class MyUserDetailsService implements UserDetailsService {
Logger logger = LoggerFactory.getLogger(getClass());
// 可以从任何地方获取数据
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 根据用户名查找用户信息
logger.info("登录用户名", username);
// 写死一个密码,赋予一个admin权限
return new User(username, "123456",
AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
这样就能让自定义的UserdetailsService生效了。但是在浏览器中登录的时候,后台报错了
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
这个异常是spring security5+后密码策略变更了。必须使用 PasswordEncoder 方式也就是你存储密码的时候
需要使用{noop}123456
这样的方式。这个在官网文档中有讲到
花括号里面的是encoder id ,这个支持的全部列表在以下的方法中定义
org.springframework.security.crypto.factory.PasswordEncoderFactories#createDelegatingPasswordEncoder
noop 对应的处理类是org.springframework.security.crypto.password.NoOpPasswordEncoder
,只用于测试;因为没有做任何加密功能
修改完成之后再次访问。发现可以了
处理用户校验逻辑
自定义的其他逻辑是在 org.springframework.security.core.userdetails.User 中提供的,
只要在登录的时候把user中提供的信息返回即可达到支持的业务逻辑,下面列出支持的业务场景:
- isEnabled 账户是否启用
- isAccountNonExpired 账户没有过期
- isCredentialsNonExpired 身份认证是否是有效的
- isAccountNonLocked 账户没有被锁定
对于 isAccountNonLocked 和 isEnabled 没有做业务处理,只是抛出了对于的异常信息;
// 这里的几个布尔值的含义对应上面列出来的顺序
User admin = new User(username, "{noop}123456",
true, true, true, false,
AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
处理密码加密解密
密码加密解密是使用了下面这个类
org.springframework.security.crypto.password.PasswordEncoder
在自己摸索中也经常提示 这个类或则什么id为null;
配置只需要提供一个实例即可,会自动使用该实例
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
在UserDetailsService中需要模拟存入数据库中的密码就是加密后的字符串
@Component
public class MyUserDetailsService implements UserDetailsService {
Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
logger.info("登录用户名:{}", username);
String password = passwordEncoder.encode("123456");
logger.info("数据库密码{}", password);
User admin = new User(username,
// "{noop}123456",
password,
true, true, true, true,
AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
其实框架会把提交的密码使用我们定义的passwordEncode加密后调用 org.springframework.security.crypto.password.PasswordEncoder#matches
方法,
与 返回的User中的密码进行比对。配对正常就验证通过;
唯一感觉奇怪的是{noop}123456
方式的密码居然可以不用写了;
尝试过{bcrypt}123456
还是不行
尝试两次登录,发下每次加密的都不一样,但是还能对上结果。感觉很强大;
介绍说:对于同一个串加密多次产生的不一样,就不会存在暴利破解一个串,其他串都失效的情况.
但是本人还是没有想明白。总感觉哪里没有想明白一样,不同的串都能反推出来,有啥安全的?
2018-08-03 13:45:58.614 INFO 18300 --- [nio-8080-exec-3] c.m.i.s.s.MyUserDetailsService : 登录用户名:admin
2018-08-03 13:45:58.708 INFO 18300 --- [nio-8080-exec-3] c.m.i.s.s.MyUserDetailsService : 数据库密码$2a$10$TaQerjh.VaTRfSLxUozH/eaxgZAcM1H7b0NHEj3peL8Ar8cGfY/R.
2018-08-03 13:47:08.035 INFO 18300 --- [nio-8080-exec-7] c.m.i.s.s.MyUserDetailsService : 登录用户名:admin
2018-08-03 13:47:08.128 INFO 18300 --- [nio-8080-exec-7] c.m.i.s.s.MyUserDetailsService : 数据库密码$2a$10$jR3gKmOp7LifbXPPHie.JuOW1FmqklzsdZm1spK/r19MkXjpPOa4a
总结
- 处理用户信息获取逻辑 使用 UserDetailsService
- 处理用户校验逻辑 使用 UserDetails
- 处理密码加密解密 使用 PasswordEncoder
自定义登录成功处理
security 默认的登录成功处理是跳转到需要授权之前访问的url;
而在一些场景下:比如 前后分离,登录是通过ajax访问,没有办法处理301跳转;
而是登录成功则返回相关的数据即可;
自定义入口还是在表单登录处配置的
http
// 定义表单登录 - 身份认证的方式
.formLogin()
.loginPage("/authentication/require")
.loginProcessingUrl("/authentication/form")
.successHandler(myAuthenticationSuccessHandler)
myAuthenticationSuccessHandler 的编写
/**
* .formLogin().successHandler() 中需要的处理器类型
* @author zhuqiang
* @version 1.0.1 2018/8/3 16:29
* @date 2018/8/3 16:29
* @since 1.0
*/
@Component("myAuthenticationSuccessHandler")
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private org.slf4j.Logger logger = LoggerFactory.getLogger(getClass());
// com.fasterxml.jackson.databind.
// spring 是使用jackson来进行处理返回数据的
// 所以这里可以得到他的实例
@Autowired
private com.fasterxml.jackson.databind.ObjectMapper objectMapper;
/**
* @param request
* @param response
* @param authentication 封装了所有的认证信息
* @throws IOException
* @throws ServletException
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
logger.info("登录成功");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
}
}
查看输出的authentication
{
"authorities": [
{
"authority": "admin"
}
],
"details": {
"remoteAddress": "0:0:0:0:0:0:0:1",
"sessionId": "FE0F33577E7E5D89AF15FCCD6FE5A4B3"
},
"authenticated": true,
"principal": {
"password": null,
"username": "admin",
"authorities": [
{
"authority": "admin"
}
],
"accountNonExpired": true,
"accountNonLocked": true,
"credentialsNonExpired": true,
"enabled": true
},
"credentials": null,
"name": "admin"
}
自定义失败处理
和处理成功类似,实现类为 org.springframework.security.web.authentication.AuthenticationFailureHandler
封装成可配置属性
编写属性配置支持枚举
package cn.mrcode.imooc.springsecurity.securitycore.properties;
/**
* 登录成功/失败是跳转还是返回json
* @author zhuqiang
* @version 1.0.1 2018/8/3 16:48
* @date 2018/8/3 16:48
* @since 1.0
*/
public enum LoginType {
REDIRECT,
JSON
}
更改登录成功的后的处理器
@Component("myAuthenticationSuccessHandler")
public class MyAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private org.slf4j.Logger logger = LoggerFactory.getLogger(getClass());
// com.fasterxml.jackson.databind.
// spring 是使用jackson来进行处理返回数据的
// 所以这里可以得到他的实例
@Autowired
private com.fasterxml.jackson.databind.ObjectMapper objectMapper;
@Autowired
private SecurityProperties securityProperties;
/**
* @param request
* @param response
* @param authentication 封装了所有的认证信息
* @throws IOException
* @throws ServletException
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
logger.info("登录成功");
if (securityProperties.getBrowser().getLoginType() == LoginType.JSON) {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
} else {
// 把本类实现父类改成 AuthenticationSuccessHandler 的子类 SavedRequestAwareAuthenticationSuccessHandler
// 之前说spring默认成功是跳转到登录前的url地址
// 就是使用的这个类来处理的
super.onAuthenticationSuccess(request, response, authentication);
}
}
}
这样登录成功的就ok了。
对于失败的来说是一样的,继承的父类改成spring默认的处理器
org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler
Bcrypt算法
Spring Security的依赖项中包括了Bcrypt算法的工具类,Bcrypt是一款非常优秀的密码加密工具,适用于对需要存储下来的密码进行加密处理。
package cn.tedu.csmall.passport;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class BcryptPasswordEncoderTests {
private BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Test
public void testEncode() {
// 原文相同的情况,每次加密得到的密文都不同
for (int i = 0; i < 10; i++) {
String rawPassword = "123456";
String encodedPassword = passwordEncoder.encode(rawPassword);
System.out.println("rawPassword = " + rawPassword);
System.out.println("encodedPassword = " + encodedPassword);
}
// rawPassword = 123456
// encodedPassword = $2a$10$HWuJ9WgPazrwg9.isaae4u7XdP7ohH7LetDwdlTWuPC4ZAvG.Uc7W
// encodedPassword = $2a$10$rOwgZMpDvZ3Kn7CxHWiEbeC6bQMGtfX.VYc9DCzx9BxkWymX6FbrS
// encodedPassword = $2a$10$H8ehVGsZx89lSVHwBVI37OkxWm8LXei4T1o5of82Hwc1rD0Yauhky
// encodedPassword = $2a$10$meBbCiHZBcYn7zMrZ4fPd.hizrsiZhAu8tmDk.P8QJcCzSQGhXSvq
// encodedPassword = $2a$10$bIRyvV29aoeJLo6hh1M.yOvKoOud5kC7AXDMSUW4tF/DlcG0bLj9C
// encodedPassword = $2a$10$eq5BuoAiQ6Uo0.TOPZOFPuRNlPl3t2GoTlaFoYfBu3/Bo3tLzx.v2
// encodedPassword = $2a$10$DhTSwQfNdqrGgHRmILmNLeV0jt3ZXL435xz0fwyZ315ciI5AuI5gi
// encodedPassword = $2a$10$T.8/ISoLOdreEEkp4py36O0ZYfihDbdHDuIElZVF3uEgMOX.8sPcK
// encodedPassword = $2a$10$hI4wweFOGJ7FMduSmcjNBexbKFOjYMWl8hkug0n0k1LNR5vEyhhMW
// encodedPassword = $2a$10$b4ztMI6tWoiJuoDYKwr7DOywsPkkCdvDxbPfmEsLdp11NdABS7wyy
}
@Test
public void testMatches() {
String rawPassword = "123456";
String encodedPassword = "$2a$10$hI4wweFOGJ7FMduSmCjNBexbKFOjYMWl8hkug0n0k1LNR5vEyhhMW";
boolean matchResult = passwordEncoder.matches(rawPassword, encodedPassword);
System.out.println("match result : " + matchResult);
}
}
使用数据库中信息验证身份
如果要使得Spring Security能使用数据库中的信息(数据库中的用户名与密码)来验证用户身份(认证),首先,必须实现“根据用户名查询此用户的登录信息(应该包括权限信息)”的查询功能,要实现此查询,需要执行的SQL语句大致是:
select
ams_admin.id,
ams_admin.username,
ams_admin.password,
ams_admin.is_enable,
ams_permission.value
from ams_admin
left join ams_admin_role on ams_admin.id = ams_admin_role.admin_id
left join ams_role_permission on ams_admin_role.role_id = ams_role_permission.role_id
left join ams_permission on ams_role_permission.permission_id = ams_permission.id
where username='root';
要在当前模块(csmall-passport
)中实现此查询功能,需要:
-
[
csmall-passport
] 添加数据库编程的相关依赖mysql-connector-java
mybatis-spring-boot-starter
durid
/druid-spring-boot-starter
-
[
csmall-passport
] 添加连接数据库的配置信息 -
[
csmall-passport
] 创建MybatisConfiguration
配置类,用于配置@MapperScan
-
[
csmall-passport
] 在配置文件中配置mybatis.mapper-locations
属性,以指定XML文件的位置 -
[
csmall-pojo
] 创建AdminLoginVO
类@Data public class AdminLoginVO implements Serializable { private Long id; private String username; private String password; private Integer isEnable; private List<String> permissions; }
-
[
csmall-passport
] 在pom.xml
中添加对csmall-pojo
的依赖 -
[
csmall-passport
] 在src/main/java
下的cn.tedu.csmall.passport
包下创建mapper.AdminMapper.java
接口 -
[
csmall-passport
] 在接口中添加抽象方法:AdminLoginVO getLoginInfoByUsername(String username);
-
在
src/main/resources
下创建mapper
文件夹,并在此文件夹下粘贴得到AdminMapper.xml
-
在
AdminMapper.xml
中配置以上抽象方法映射的SQL查询:<!-- 忽略固定的代码 --> <mapper namespace="cn.tedu.csmall.passport.mapper.AdminMapper"> <!-- AdminLoginVO getLoginInfoByUsername(String username); --> <select id="getLoginInfoByUsername" resultMap="LoginInfoResultMap"> select <include refid="LoginInfoQueryFields" /> from ams_admin left join ams_admin_role on ams_admin.id = ams_admin_role.admin_id left join ams_role_permission on ams_admin_role.role_id = ams_role_permission.role_id left join ams_permission on ams_role_permission.permission_id = ams_permission.id where username=#{username} </select> <sql id="LoginInfoQueryFields"> <if test="true"> ams_admin.id, ams_admin.username, ams_admin.password, ams_admin.is_enable, ams_permission.value </if> </sql> <resultMap id="LoginInfoResultMap" type="cn.tedu.csmall.pojo.vo.AdminLoginVO"> <id column="id" property="id" /> <result column="username" property="username" /> <result column="password" property="password" /> <result column="is_enable" property="isEnable" /> <collection property="permissions" ofType="java.lang.String"> <!-- 以下配置类似在Java中执行 new String("/pms/product/read") --> <constructor> <arg column="value" /> </constructor> </collection> </resultMap> </mapper>
-
完成后,还应该编写并执行测试
根据有效的用户名查询出的结果例如:
AdminLoginVO(
id=1,
username=root,
password=1234,
isEnable=1,
permissions=[
/pms/product/read,
/pms/product/update,
/pms/product/delete,
/ams/admin/read,
/ams/admin/update,
/ams/admin/delete
]
)
Spring Security的认证机制中包含:当客户端提交登录后,会自动调用UserDetailsService
接口(Spring Security定义的)的实现类对象中的UserDetails loadUserByUsername(String username)
方法(根据用户名加载用户数据),将得到UserDetails
类型的对象,此对象中应该至少包括此用户名对应的密码、权限等信息,接下来,Spring Security会自动完成密码的对比,并确定此次客户端提交的信息是否允许登录!类似于:
// Spring Security的行为
UserDetails userDetails = userDetailsService.loadUserByUsername("chengheng");
// Spring Security将从userDetails中获取密码,用于验证客户端提交的密码,判断是否匹配
所以,要实现Spring Security通过数据库的数据来验证用户名与密码(而不是采用默认的user
用户名和随机的密码),则在cn.tedu.csmall.passport
包下创建security.UserDetailsServiceImpl
类,实现UserDetailsService
接口,并重写接口中的抽象方法:
package cn.tedu.csmall.passport.security;
import cn.tedu.csmall.passport.mapper.AdminMapper;
import cn.tedu.csmall.pojo.vo.AdminLoginVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private AdminMapper adminMapper;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
System.out.println("根据用户名查询尝试登录的管理员信息,用户名=" + s);
AdminLoginVO admin = adminMapper.getLoginInfoByUsername(s);
System.out.println("通过持久层进行查询,结果=" + admin);
if (admin == null) {
System.out.println("根据用户名没有查询到有效的管理员数据,将抛出异常");
throw new BadCredentialsException("登录失败,用户名不存在!");
}
System.out.println("查询到匹配的管理员数据,需要将此数据转换为UserDetails并返回");
UserDetails userDetails = User.builder()
.username(admin.getUsername())
.password(admin.getPassword())
.accountExpired(false)
.accountLocked(false)
.disabled(admin.getIsEnable() != 1)
.credentialsExpired(false)
.authorities(admin.getPermissions().toArray(new String[] {}))
.build();
System.out.println("转换得到UserDetails=" + userDetails);
return userDetails;
}
}
完成后,再配置密码加密器即可:
package cn.tedu.csmall.passport.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
重启项目,可以发现在启动过程中不再生成随机的密码值,在浏览器上访问此项目的任何URL,进入登录页,即可使用数据库中的管理员数据进行登录。
在Spring Security,默认使用Session机制存储成功登录的用户信息(因为HTTP协议是无状态协议,并不保存客户端的任何信息,所以,同一个客户端的多次访问,对于服务器而言,等效于多个不同的客户端各访问一次,为了保存用户信息,使得服务器端能够识别客户端的身份,必须采取某种机制),当下,更推荐使用Token或相关技术(例如JWT)来解决识别用户身份的问题。
本文由 liyunfei 创作,采用 知识共享署名4.0
国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
最后编辑时间为: Jul 25,2022