0 引言
在笔者的上一篇文章中Spring Security入门(二):基于内存的认证
一文中有提到过Spring Security
实现自定义数据库查询需要你实现UserDetailsService
接口,并实现loadUserByUsername(String username)
抽象方法。我们可以在UserDetailsService
接口的实现类中注入数据库访问对象Dao
,从而实现自定义数据库查询认证用户信息。下面在笔者的boot-demo
实战项目中我们结合spring data jpa
作为持久层技术来一步一步实现自定义数据库认证。(boot-demo
项目地址:https://gitee.com/heshengfu1211/boot-demo.git)
1 表结构设计与实体类
1.1 新建用户表tbl_user
与对应实体类
笔者使用的数据库为mysql5.6
, 在IDEA中新建一个客户端连接,并在就控制台窗口中执行如下新建tbl_user
表的脚本:
use mysql;
create table tbl_user(
user_id numeric(8) primary key,
username varchar(30) not null comment '用户名',
username_zh varchar(50) not null comment '用户中文名',
password varchar(100) not null comment '密码',
enabled bool default true,
locked bool default false
)engine=InnoDB default CHARSET=utf8;
create unique index user_name_index on tbl_user(username);
然后执行插入两条数据:
#张三原始密码为zhangsan123
insert into tbl_user(user_id, username, username_zh, password)
values(1,'x_zhangsan','张三','$2a$10$aKLAkRgoEImFQ2g14W.mjO.Y66JKKHLlXycrDd9G9uJ54uQsxjPCO');
#李四原始密码为lisi123
insert into tbl_user(user_id, username, username_zh, password)
values(2,'x_lisi','李四','$2a$10$HgIzJr..ahIj96fF4ljpEe8UsN5LYJ8pmggxP9dfaZZWb8JqCoQ/G');
commit ;
为了维护用户敏感信息的安全,数据库里用户的登录密码或支付密码等安全性要求较高的字段一律采用加密存储的方式存储。
添加用户的sql
脚本中用户的加密密文均在是IDEA中的命令控制台执行spring-boot-cli
命令spring encodepassword ${password}
的方式获得,其实质是使用BCryptPasswordEncoder
编码原始密码所得
tbl_user
表的建表依据为:在spring security
自定义用户类必须实现UserDetails
,UserDetails
的源码如下:
public interface UserDetails extends Serializable {
//获取权限列表
Collection<? extends GrantedAuthority> getAuthorities();
//获取密码
String getPassword();
//获取用户名
String getUsername();
//判断用户的账号是否没有过期:返回 true为没过期;返回false为过期
boolean isAccountNonExpired();
//判断用户账号是否没有被锁住:返回true为账号没被锁住;返回false为账号被锁住
boolean isAccountNonLocked();
//判断凭据是否没有过期:返回true为未过期;返回false为已过期
boolean isCredentialsNonExpired();
//判断账号是否启用:返回true为启用;返回false为禁用
boolean isEnabled();
}
于是我们创建一个实现UserDetails
接口的实现类并使之与tbl_user
表中的字段一一对应
user.java
Entity(name="tbl_user")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
@Column(name="user_id",nullable = false,length = 8)
private Long userId;
@Column(name="username",nullable = false,length = 30)
private String username;
@Column(nullable = false,length = 50)
private String usernameZh;
@Column(nullable = false,length = 100)
private String password;
//1: 启用;0:禁用
@Column(nullable = true,length = 1)
private Integer enabled;
//1:锁住;0:未锁
@Column(nullable = true,length = 1)
private Integer locked;
@Transient
private List<Role> roles;
public void setUserId(Long userId) {
this.userId = userId;
}
public void setUsername(String username) {
this.username = username;
}
public void setUsernameZh(String usernameZh) {
this.usernameZh = usernameZh;
}
public void setPassword(String password) {
this.password = password;
}
public void setEnabled(Integer enabled) {
this.enabled = enabled;
}
public void setLocked(Integer locked) {
this.locked = locked;
}
public Long getUserId() {
return userId;
}
public String getUsernameZh() {
return usernameZh;
}
public Integer getEnabled() {
return enabled;
}
public Integer getLocked() {
return locked;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for(Role role: roles){
SimpleGrantedAuthority authority = new SimpleGrantedAuthority(role.getRoleId());
authorities.add(authority);
}
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return locked==0;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return enabled==1;
}
public List<Role> getRoles() {
return roles;
}
public void setRoles(List<Role> roles) {
this.roles = roles;
}
//重写equals和hashCode方法
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(userId, user.userId) &&
Objects.equals(username, user.username);
}
@Override
public int hashCode() {
return Objects.hash(userId, username);
}
}
1.2 新建角色表roels
及其对应的实体类
roles
表的建表sql
脚本如下:
use mysql;
create table roles(
role_id varchar(30) comment '角色ID',
role_name varchar(50) not null comment '角色名',
created_by varchar(50) default 'system',
created_time datetime default now(),
primary key (role_id)
)engine=InnoDB default CHARSET=utf8;
执行难添加用户sql
脚本:
insert into roles(role_id, role_name,created_by)
values('Admin','管理员','x_heshengfu');
insert into roles(role_id, role_name,created_by)
values('SystemAdmin','系统管理员','x_heshengfu');
insert into roles(role_id, role_name,created_by)
values('Developer','开发人员','x_heshengfu');
insert into roles(role_id, role_name,created_by)
values('Guest','普通客户','x_heshengfu');
commit;
新建roles
表对应是实体类Role.java
:
@Entity(name="roles")
public class Role implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name="role_id",length = 30,nullable = false)
private String roleId;
@Column(name="role_name",length = 50,nullable = false)
private String roleName;
@Column(name="created_by",nullable = true,length = 50)
private String createdBy;
@Column(name="created_time",nullable = true)
private String createdTime;
//......此处省略setter和getter方法
}
1.3 新建用户-角色关系关系表tbl_user_role
及其对应的实体类
tbl_user_role
表的建表sql
脚本如下:
use mysql;
create table tbl_user_role(
user_role_id numeric(10) primary key COMMENT '主键',
user_id numeric(8) not null comment '用户id',
role_id varchar(30) not null comment '角色id'
)engine=InnoDB default CHARSET=utf8;
create unique index user_role_id_index on tbl_user_role(user_id,role_id);
执行往tbl_user_role
表添加数据sql
脚本:
--插入数据
insert into tbl_user_role(user_role_id, user_id, role_id)
values(1,1,'Admin');
insert into tbl_user_role(user_role_id, user_id, role_id)
values(2,1,'SystemAdmin');
insert into tbl_user_role(user_role_id, user_id, role_id)
values(3,2,'Developer');
insert into tbl_user_role(user_role_id, user_id, role_id)
values(4,2,'Guest');
commit;
新建tbl_user_role
表对应的实体类UserRole.java
:
@Entity(name="tbl_user_role")
public class UserRole implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name="user_role_id")
private Long userRoleId;
@Column(name="role_id",nullable=false, length = 30)
private String roleId;
@Column(name="user_id",nullable = false, length = 8)
private Long userId;
//......此处省略setter和getter方法
}
2 用于认证用户信息的数据库访问层
2.1 新建与用户表对应的Repository接口
public interface TblUserRepository extends JpaRepository<User,Long> {
User findUserByUsername(String username);
}
TblUserRepository
接口继承JpaRepository
接口,自动拥有了基本的CRUD、分页查询方法及根据字段和关键字查找表对应实体类信息的功能。在TblUserRepository
接口中我们自定义了一个根据username
字段查找用户信息的方法,继承自JpaRepository
接口的数据库访问接口无需开发人员手动实现其中的抽象方法,spring-data-jpa
会自动帮我们实现。
2.2 新建与角色表对应的Repository
接口
public interface RoleRepository extends JpaRepository<Role,String> {
List<Role> findRolesByRoleIdIn(List<String> roleIds);
}
在RoleRepository
接口中笔者自定义了根据角色id列表查询角色列表的抽象方法,方便给用户查询角色列表
2.3 新建与用户角色关系表对应的Repository
接口
public interface UserRoleRepository extends JpaRepository<UserRole,Long> {
List<UserRole> findByUserId(Long userId);
}
在UserRoleRepository
接口中,笔者定义了根据角色id
查询用户角色列表的抽象方法。
3 定义UserDetailsService
接口的实现类
@Service
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private TblUserRepository userRepository;
@Autowired
private RoleRepository roleRepository;
@Autowired
private UserRoleRepository userRoleRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findUserByUsername(username);
if(user==null){
return null;
}
Long userId = user.getUserId();
List<UserRole> userRoleList = userRoleRepository.findByUserId(userId);
if(userRoleList.size()>0){
List<String> roleIds = new ArrayList<>();
for(UserRole userRole: userRoleList){
roleIds.add(userRole.getRoleId());
}
List<Role> roles = roleRepository.findRolesByRoleIdIn(roleIds);
user.setRoles(roles);
}
return user;
}
}
以上为了从数据库中查出登录用户的用户名、加密密文及角色列表从数据库中查了3次。
-
第1次通过
TblUserRepository#findUserByUsername
传入username
参数查出不包含角色信息的User
对象,如果用户不存在则直接返回null; -
第2次通过
UserRoleRepository#findByUserId
传入用户id
查出用户-角色关系列表 -
第3步通过第2部中得到了
角色id列表
作为入参传入到RoleRepository#findRolesByRoleIdIn
方法得到完整的角色信息列表
由于使用spring-data-jpa
实现关联查询笔者暂时还没有掌握,因而以上认证用户信息访问了三次数据库,确实容易影响效率;在实际的商用生产环境可以参照spring-data-jpa
的连接查询改为连接查询,对于用户登录认证信息等热点数据首次你从数据库查询出来后最好缓存在redis
缓存中,并设置过期时间。另外如果是使用mybatis
作为数据库持久层框架,可以借助resultMap
集合association
属性通过一条sql
将包含角色列表的用户信息一次性查出来。
4 WebSecurityConfigurerAdapter
实现类中配置userDetailsService
4.1 配置userDetailsService
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailServiceImpl userDetailService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/index/user").hasAnyRole("Admin","Developer","SystemAdmin")
.antMatchers("/index/admin").hasAnyRole("Admin","SystemAdmin")
.anyRequest().authenticated()
.antMatchers("/login").permitAll()
.and().formLogin().loginProcessingUrl("/login").
usernameParameter("username").passwordParameter("password")
.successHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication auth) throws IOException, ServletException {
Object principal = auth.getPrincipal();
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();
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();
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
在上文Spring Security 入门(一) :基于内存存储的表单登录实战的基础上对所有用户进入登录页面和登录接口放开权限,而对/index/*
路径下的接口允许访问角色改为数据库中存在的Admin,SystemAdmin,Developer
等角色。登录成功处理器和失败处理器配置用沿用上文中的逻辑。
4.2 测试用户登录认证效果
在浏览器中输入 http://localhost:8088/apiBoot/login 回车即可进入登录页面
图 1 Spring Security 默认的用户登录表单界面
右键->检查 在下方在弹出的元素审查窗口中选中Elements
标签查看表单的html
源码,我们可以看到登录表单中实际还包含了一个隐藏了_csrf
输入框,其值为622251f2-f7f3-4b78-88a0-52451771deaf
,是一个UUID
字符串,它的用处是为了保护web请求,防止跨站请求伪造(简称CSRF
)
图 2 Spring Security 默认的form表单中内置的防请求伪造攻击的csrf token
输入数据库中存在的用户账号x_zhangsan
己密码zhangsan123
,点击Sign in
即可登录成功,登录成功后浏览器中会返回如下信息:
"msg":"login success","data":{"userId":1,"username":"x_zhangsan","usernameZh":"张三","password":"$2a$10$aKLAkRgoEImFQ2g14W.mjO.Y66JKKHLlXycrDd9G9uJ54uQsxjPCO","enabled":1,"locked":0,"roles":[{"roleId":"Admin","roleName":"管理员","createdBy":"x_heshengfu","createdTime":"2020-04-19 23:07:34"},{"roleId":"SystemAdmin","roleName":"系统管理员","createdBy":"x_heshengfu","createdTime":"2020-04-19 23:07:34"}],"authorities":[{"authority":"Admin"},{"authority":"SystemAdmin"}],"credentialsNonExpired":true,"accountNonLocked":true,"accountNonExpired":true},"status":200}
登录成功后的返回信息中包含了用户的基本属性和角色及权限信息。
使用postman登录需要带上`_csrf`的`token`值:
```json
POST http://localhost:8088/apiBoot/login?username=x_zhangsan&password=zhangsan123&_csrf=66dff592-bc63-488f-ab34-929258a55db6
//响应信息如下:
{
"msg": "login success",
"data": {
"userId": 1,
"username": "x_zhangsan",
"usernameZh": "张三",
"password": "$2a$10$aKLAkRgoEImFQ2g14W.mjO.Y66JKKHLlXycrDd9G9uJ54uQsxjPCO",
"enabled": 1,
"locked": 0,
"roles": [
{
"roleId": "Admin",
"roleName": "管理员",
"createdBy": "x_heshengfu",
"createdTime": "2020-04-19 23:07:34"
},
{
"roleId": "SystemAdmin",
"roleName": "系统管理员",
"createdBy": "x_heshengfu",
"createdTime": "2020-04-19 23:07:34"
}
],
"authorities": [
{
"authority": "Admin"
},
{
"authority": "SystemAdmin"
}
],
"credentialsNonExpired": true,
"accountNonLocked": true,
"accountNonExpired": true
},
"status": 200
}
在postman 中响应信息得到了json
格式的美化,看起来非常清晰
5 存储用户认证信息类的源码解读
5.1 认识SecurityContextHolder
和SecurityContext
用户登录成功后的认证信息最终能会作为一个Authentication
实现类对象(表单登录通常对应的是一个UsernamePasswordAuthenticationToken
对象)对象被认证过滤器保存在SecurityContextHolder
类的SecurityContext
(安全上下文)中,之后就可以通过SecurityContextHolder
这个类直接去获取当前登录用户的认证信息了,SecurityContextHolder
其实就是一个存放用户具体认证信息的工具类。
通过查看这两个类的相关源码可以对Spring Security
安全框架是如何保存用户的认证信息的原理会有一个更全面的认识,相关源码如下:
SecurityContextHolder.java
public class SecurityContextHolder{
//静态方法可通过类名直接拿到SecurityContext实例
public static SecurityContext getContext() {
return strategy.getContext();
}
//设置SecurityContext实例
public static void setContext(SecurityContext context) {
strategy.setContext(context);
}
//创建一个空的 SecurityContext实例
public static SecurityContext createEmptyContext() {
return strategy.createEmptyContext();
}
//......其他源码被省略
}
SecurityContext.java
public interface SecurityContext extends Serializable {
//获取认证信息
Authentication getAuthentication();
//设置认证信息
void setAuthentication(Authentication authentication);
}
SecurityContext
类是一个接口,它的实现类是SecurityContextImpl
(1)利用SecurityContextHolder
保存用户认证信息的示例源码:
//第1步创建一个空的SecurityContext对象实例
SecurityContext context = SecurityContextHolder.createEmptyContext();
//第2步根据用户信息、密码和权限构造一个Authentication对象实例
Authentication authentication =
new TestingAuthenticationToken("username", "password", "ROLE_USER");
//第3步将认证信息实例authentication保存到SecurityContext对象实例中
context.setAuthentication(authentication);
//第4步将SecurityContext对象实例保存到SecurityContextHolder类中
SecurityContextHolder.setContext(context);
-
采用
SecurityContextHolder.createEmptyContext()
方法,而不是使用SecurityContextHolder.getContext().setAuthentication(authentication)
获取一个SecurityContext
对象实例的目的是为了避免多线程场景下的跨线程竞争 -
Spring Security
不关心是何种Authentication
的实现类实例被设置到SecurityContext
实例中,测试场景下使用TestingAuthenticationToken
实现类是为了方便,大多数生产场景下一般选用UsernamePasswordAuthenticationToken(userDetails, password, authorities)
构造Authentication
实现类对象实例 -
最后
SecurityContext
实例被保存到SecurityContextHolder
类后,Spring Security
会使用这些信息来进行后面当前认证用户在每一个限权操作的权限鉴定,简称鉴权(authorization
)
(2)利用SecurityContextHolder
获取用户的认证信息和权限的代码实例如下:
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities =
authentication.getAuthorities();
默认情况下,SecurityContextHolder
使用一个ThreadLocal
对象用于存储用户的详细认证信息,这也就意味着即时当前SecurityContext
对象没有作为一个参数传递到具体的方法里去,同一个线程中的任意方法都能拿到SecurityContext
对象,进而拿到用户的认证信息。如果在当前主体的请求被处理后清除线程程,以这种方式使用ThreadLocal
是非常安全的。Spring Security
的FilterChainProxy
(过滤链代理)确保了SecurityContext
永远是干净的。
5.2 认识SecurityContextHolder
类中的SecurityContextHolderStrategy
由于一些应用与线程特殊的工作方式,并非所有的应用都完全适合使用ThreadLocal
对象来存储安全上下文。例如对于一个Swing
客户端应用就要求虚拟机种所有线程共享一个安全上下文对象,这种情况修啊需要选择全局策略。
SecurityContextHolder
一共由三种方式存储SecurityContext
对象,可以通过在应用启动前调用方法SecurityContextHolder.setStrategyName(String strategyName)
方法进行设置。三种策略模式分别是
SecurityContextHolder.MODE_GLOBAL
: 全局模式,适用于单个应用要求虚拟机中所有线程要求共享一个安全上下文的场景SecurityContextHolder.MODE_INHERITABLETHREADLOCAL
:继承本地线程模式SecurityContextHolder.MODE_THREADLOCAL
: 本地线程模式
SecurityContextHolder.java
public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
public static final String MODE_GLOBAL = "MODE_GLOBAL";
public static final String SYSTEM_PROPERTY = "spring.security.strategy";
private static String strategyName = System.getProperty("spring.security.strategy");
private static SecurityContextHolderStrategy strategy;
private static int initializeCount = 0;
private static void initialize() {
if (!StringUtils.hasText(strategyName)) {
strategyName = "MODE_THREADLOCAL";
}
if (strategyName.equals("MODE_THREADLOCAL")) {
strategy = new ThreadLocalSecurityContextHolderStrategy();
} else if (strategyName.equals("MODE_INHERITABLETHREADLOCAL")) {
strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
} else if (strategyName.equals("MODE_GLOBAL")) {
strategy = new GlobalSecurityContextHolderStrategy();
} else {
try {
Class<?> clazz = Class.forName(strategyName);
Constructor<?> customStrategy = clazz.getConstructor();
strategy = (SecurityContextHolderStrategy)customStrategy.newInstance();
} catch (Exception var2) {
ReflectionUtils.handleReflectionException(var2);
}
}
++initializeCount;
}
static {
initialize();
}
通过阅读SecurityContextHolder
类中的关键源码,可以看出SecurityContextHolder
类首先通过系统变量名spring.security.strategy
从系统属性中获取strategyName
,并在初始化方法中根据strategyName
去实例化strategy
属性。在初始化方法中,首先判断strategyName
变量是否为空,为空的化就使用MODE_THREADLOCAL
模式,然后根据strategyName
的值去构建不同的SecurityContextHolderStrategy
实现类实例。MODE_THREADLOCAL
模式对应ThreadLocalSecurityContextHolderStrategy
类实例;MODE_INHERITABLETHREADLOCAL
模式对应InheritableThreadLocalSecurityContextHolderStrategy
类实例;MODE_GLOBAL
对应GlobalSecurityContextHolderStrategy
类实例。而SecurityContextHolder
类的三个重要的静态方法getContext、setContext和createEmptyContext
其实都是委托给strategy
来操作的。
通过阅读ThreadLocalSecurityContextHolderStrategy
类的源码,我们也可以看到SecurityContext
确实是保存在了一个ThreadLocal
对象中的泛型变量中。
final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {
private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal();
ThreadLocalSecurityContextHolderStrategy() {
}
public void clearContext() {
contextHolder.remove();
}
public SecurityContext getContext() {
SecurityContext ctx = (SecurityContext)contextHolder.get();
if (ctx == null) {
ctx = this.createEmptyContext();
contextHolder.set(ctx);
}
return ctx;
}
public void setContext(SecurityContext context) {
Assert.notNull(context, "Only non-null SecurityContext instances are permitted");
contextHolder.set(context);
}
public SecurityContext createEmptyContext() {
return new SecurityContextImpl();
}
}
大多数应用场景下,我们无需改变默认的strategyName
的值,默认使用ThreadLocal
存储当前登录用户的认证信息即可。
6 参考文章
[1] spring security官方开发文档 chaper 10 Autherization
[2] 王松著《spring boot + vue 全栈开发实战》第 10 章 spring boot 安全管理
本文首发个人微信公众号,第一次阅读笔者文章的朋友可以扫码下方二维码或者搜索微信公众号“码农的进阶之路2020” 加个微信关注,成为笔者的粉丝福利多多。笔者和共同运营运营公众号的小伙伴会定期发布java后端、vue前端、小程序等实战文章以及一些拿到BAT大厂Offer的面经。