最近了解到用户身份验证的安全问题,又将好久没有使用的Spring Security(一个提供身份验证,授权和保护以防止常见攻击的框架)看了看。 验证用户身份的最常见方法之一是验证用户名和密码。这样,Spring Security为使用用户名和密码进行身份验证提供了全面的支持。下面我将详细介绍如何使用,希望对大家有用。
读取接收用户名密码
Spring Security提供了以下三种内置机制[Form Login,Basic Authentication,Digest Authentication],用于从请求中读取用户名和密码。同时它还支持我们利用任何受支持的存储机制用于读取用户名和密码,例如[In-Memory Authentication,JDBC Authentication,UserDetailsService, LDAP Authentication]
1. Form Login(表单登录)
Spring Security提供对通过html表单提供的用户名和密码的支持。
工作机制
我们先来看一下响应机制,首先当我们打开网页或者app时会发送请求认证如下图所示:
① 一开始用户向未经授权的资源发出未经身份验证(简单说就是没有登录过)的请求。
② 看SecurityFilterChain
这么大一块不要跳过,字如其名大家也熟悉(相比servlet中的FilterChain是不是很熟悉了)这就是一个安全过滤器链,首先请求会到FilterSecurityInteceptor
(过滤器安全接收器)看到这是个未认证的请求拒绝抛出AccessDeniedException
。
③ 由于用户未通过身份验证,请ExceptionTranslationFilter
(异常转换过滤器)启动“ 开始身份验证”,然后将重定向发送到配置了的登录页面AuthenticationEntryPoint
。在大多数情况下,AuthenticationEntryPoint
是[LoginUrlAuthenticationEntryPoint
]的一个实例。
④ 没有通过身份验证的请求会被重定向到登录。
⑤ 不会有人不知道这是响应给前台呈现登陆页面吧,不会吧不会吧🤣。
在我们输入用户名密码之后,当然这也会交给SecurityFilterChain
,将对用户名和密码进行UsernamePasswordAuthenticationFilter
身份验证。该UsernamePasswordAuthenticationFilter
扩展AbstractAuthenticationProcessingFilter
。如下图所示:
① 当用户提交用户名和密码时,UsernamePasswordAuthenticationFilter
通过从HttpServletRequest提取用户名和密码来创建一个UsernamePasswordAuthenticationToken,这是一种身份验证类型。
② 接下来,将UsernamePasswordAuthenticationToken
传递给AuthenticationManager
以进行身份验证。AuthenticationManager外观的详细信息取决于用户信息的存储方式(SpringSecurity这里有四种存储机制,后面我会介绍到🧐)。
③和④分别对应了请求失败与成功的响应,为了大家的阅读舒畅,这里给大家备注一下
SecurityContextHolder
: 安全上下文持有者
SessionAuthenticationStrategy
:会话验证策略
Application Event Publisher
: 应用程序事件发布器
默认情况下启用springsecurity表单登录。但是,一旦提供了任何基于servlet的配置,就必须显式地提供基于表单的登录
配置
默认配置
如果我们只是引入了SpringSecurity这个依赖而不去进行其他任何操作的话,当我们访问项目时他会显示到默认的界面。
这是为什么呢,让我们来看一下源码怎么说的。
1.首先我们先看到Springboot的自动配置包autoconfigure中的security里面有一个Web自动配置。
2.让我们转到WebSecurityConfigurerAdapter
,当我们需要去进行认证授权配置的时候有两个方法是非常重要的。
3.我们通常同过http.loginPage来配置我们的表单登录页面,观察源码注释或者他的配置我们可以都看到它默认指向了/login。
注释是这么写的哎:
4.再让我们看他的默认配置,这时就需要转到springsecurity的web下面:
5.这里配置了默认的是不是一目了然,但是没有页面页面是怎么来的然,当然是自动生成返回登陆页面,让我们转到org.springframework.security.web.authentication.ui下面有个DefaultLoginPageGeneratingFilter让我们来看一下,他直接用最原始的方式给我们拼了一个页面是不是很惊喜😂。
自主配置
当我们需要自己进行配置的时候,要写自己的WebSecurity类继承WebSecurityConfigurerAdapter,还后重写configure方法,例如:
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
http.formLogin().loginPage("/tologin").permitAll();
}
}
这里给大家提供一个简单的登陆页面:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log In</title>
</head>
<body style="text-align: center">
<h1>Please Log In</h1>
<div th:if="${param.error}">
Invalid username and password.
</div>
<div th:if="${param.logout}">
You have been logged out.
</div>
<form th:action="@{/login}" method="post">
<div>
<input type="text" name="username" placeholder="Username"/>
</div>
<div>
<input type="password" name="password" placeholder="Password"/>
</div>
<input type="submit" value="Log in" />
</form>
</body>
</html>
然后配置一下controller😥
@Controller
public class LoginController{
@GetMapping("/tologin")
public String toLogin(){
return "login";
}
}
最后运行项目即可看到自己运行的页面。
2.Basic Authentication(基本认证)
我们大家在登录网站时候大部分是通过表单登录提交信息,但是有的情况下网页会弹出一个登录验证的对话框类似我们访问tomcat登录他的manager wabapp时会让我们进行http基本身份验证。如下图
通过过验证之后会进入Tomcat Web Application Manager页面如图
因此这里将要介绍了Spring Security如何为基于servlet的应用程序提供对基本HTTP身份验证的支持。让我们看一下HTTP基本身份验证在Spring Security中如何工作。
工作机制
** 首先,我们看到WWW-Authenticate标头被发送回未经身份验证的客户端。**
① 首先依然是用户对未经授权的资源/ private
进行未经身份验证的请求。
② Spring Security的FilterSecurityInterceptor
通过抛出AccessDeniedException
拒绝了未经身份验证的请求。
③ 由于用户未通过身份验证,因此ExceptionTranslationFilter
会启动“开始身份验证”。配置的AuthenticationEntryPoint是BasicAuthenticationEntryPoint的实例,该实例发送WWW-Authenticate标头。RequestCache通常是一个NullRequestCache,它不保存请求,因为客户端能够重复它最初的请求。
当客户端收到WWW-Authenticate标头时,它知道应该使用用户名和密码重试。以下是正在处理的用户名和密码的流程。
① 当用户提交其用户名和密码时,BasicAuthenticationFilter
会通过UsernamePasswordAuthenticationToken
从中Authentication
提取用户名和密码来创建,这是一种类型HttpServletRequest
② 接下来,将UsernamePasswordAuthenticationToken
传递到AuthenticationManager
中进行身份验证。AuthenticationManager
外观的细节取决于用户信息的存储方式。
③和④分别对应了请求失败与成功两种不同的响应。失败则继续验证,成功则会被设置到SecurityContextHolder当中去。
Spring Security的HTTP基本身份验证支持默认情况下处于启用状态。但是,一旦提供了任何基于servlet的配置,就必须显式提供HTTP Basic。
配置
我们需要自己去创建一个Configuration去继承WebSecurityConfigurerAdapter,当然要去认证还是需要重写我们的configure,我们需要将目光集中到httpSecurity,如下为一个简单的配置,意思就是所有请求去进行http验证授权。
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest()
.authenticated()
.and()
.httpBasic()
.realmName("lzq app");
}
}
在我们启动项目后控制台会生成一个字符串密码,然后我们就可以进行访问啦。默认用户名是user。
3.Digest Authentication(摘要认证)
HTTP摘要认证可以看做是HTTP基本认证的升级版,解决了HTTP基本认证最大的缺点,即将传送的密码加密,而且是使用不可逆的MD5加密算法。
配置
摘要式身份验证的核心是“一次性”。这是服务器生成的值。
base64(expirationTime + ":" + md5Hex(expirationTime + ":" + key))
expirationTime: 在毫秒内表示的日期和时间
key: A private key to prevent modification of the nonce token
这里提供了使用Java配置配置摘要式身份验证的栗子😡:
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.addFilter(digestAuthenticationFilter())//在过滤链中添加摘要认证过滤器
.exceptionHandling()
.authenticationEntryPoint(digestAuthenticationEntryPoint())//摘要认证入口端点
.and()
.csrf().disable();
}
@Bean
public DigestAuthenticationEntryPoint digestAuthenticationEntryPoint() {
DigestAuthenticationEntryPoint point = new DigestAuthenticationEntryPoint();
point.setRealmName("lzq");//realm名 之前服务器响应的参数,原样返回
point.setKey("key");//密钥
return point;
}
@Bean
public DigestAuthenticationFilter digestAuthenticationFilter() {
DigestAuthenticationFilter filter = new DigestAuthenticationFilter();//摘要式身份验证过滤器
filter.setAuthenticationEntryPoint(digestAuthenticationEntryPoint());//必须配置
filter.setUserDetailsService(userDetailsService());//必须配置
return filter;
}
@Bean
public UserDetailsService userDetailsService() {
return new UserDetailsService() {
//用户摘要
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//省略从数据库查询过程
String password = "123456";
List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
authorities.add(new SimpleGrantedAuthority("auth"));
return new User(username, password, true, true, true, true, authorities);
}
};
}
}
和基本认证很相似,在我们的请求中,会要求输入用户名和密码,并且
Authorization:Digest username="user",realm="lzq", qop=auth,nonce="*********************"
请求头从Basic换成Digest,多了qop(生成摘要的具体计算公式),nonce(base64)
在我们登录成功后请求头多了几项
Authorization: Digest username="user", realm="lzq", nonce="**************************", uri="/any", response="ca5de36619d97f1f90626fef2a46aeea", qop=auth, nc=00000001, cnonce="03408a4632eadcc0"
Digest:摘要认证类型。
username:登录时输入的用户名。
realm:这是之前服务器响应的参数,原样返回
nonce:相当于随机数。计算公式为base64(时间戳:md5(时间戳:key))。首先由时间戳和后台配置的key值,生成md5散列值,再用时间戳和md5值进行base64编码。之后客户端登录请求头都必须有nonce参数,服务端收到nonce,解码出时间戳,将其和服务端的key重新生成nonce,比对一致,由此证明请求头的nonce就是服务端原始发放的nonce,请求合法。
uri:可在后台比对实际请求路径是否一致,否则有被黑客攻击的可能性。
response:将所有Authorization参数,以及用户输入的密码等,共同生成的md5摘要。服务端收到请求,会用同样的方法,并查询数据库中的用户密码再次生成摘要进行对比,只要有一个参数被擅改,结果都不一致。所以不知道用户密码,是无法生成一致摘要的。
qop:源自服务器
nc:16进制数,使用当前nonce请求次数,服务端可对其进行限制,达到一定次数主动刷新nonce,以防止被黑客利用。
-
cnonce:客户端生成的随机数,也会参与摘要计算,使得每次请求的response值都不一样。服务端可以保存每次请求的cnonce,如果发现其值已经存在,很可能是黑客攻击。
spring security只对核心参数如nonce做了验证,一些非核心参数验证,可以自行了解扩展。一般浏览器基本都支持摘要认证,用户输入用户名/密码后浏览器会自动生成并添加请求头所有参数,并自动缓存。
4.In-Memory Authentication(内存中身份验证)
Spring Security的InMemoryUserDetailsManager
实现UserDetailsService为在内存中检索的基于用户名/密码的身份验证提供支持。 通过实现接口来InMemoryUserDetailsManager
提供管理。 当Spring Security配置为接受用户名/密码进行身份验证时,将使用基于身份的身份验证。
栗子
这里来描述一个栗子来告诉大家如何使用:
首先进行我们自定义配置,需要添加SecurityConfig 文件,对WebSecurityConfigurerAdapter类进行扩展,重写configure方法
@Configuration
@EnableWebSecurity
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
protected void configure(HttpSecurity http) throws Exception {
//定制请求的授权规则
http.authorizeRequests().antMatchers("/").permitAll()//默认登录页面允许所有
//设置对象路径的角色权限
.antMatchers( "/level1/**").hasRole("VIP1")
.antMatchers("/level2/**").hasRole("VIP2")
.antMatchers("/level3/**").hasRole("VIP3");
//开启登录功能
http.formLogin().loginPage("/userlogin");
//开启注销功能
http.logout().logoutSuccessUrl("/");
//记住我
http.rememberMe().rememberMeParameter("remember");
}
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//认证规则
auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder())
.withUser("lizeqing")
.password(new BCryptPasswordEncoder()
.encode("666666"))
.roles("VIP1","VIP2","VIP3")
.and()//下面类似
.passwordEncoder(new BCryptPasswordEncoder()).withUser("zhaoyi").password(new BCryptPasswordEncoder().encode("111111")).roles("VIP1")
.and().passwordEncoder(new BCryptPasswordEncoder()).withUser("qianer").password(new BCryptPasswordEncoder().encode("222222")).roles("VIP2")
.and().passwordEncoder(new BCryptPasswordEncoder()).withUser("zhangsan").password(new BCryptPasswordEncoder().encode("333333")).roles("VIP3")
.and().passwordEncoder(new BCryptPasswordEncoder()).withUser("wangwu").password(new BCryptPasswordEncoder().encode("555555")).roles("VIP1","VIP2");
}
}
这里需要提醒大家,在SpringSecurity5之后出于安全性考虑调整了passwordEncoder的实现策略,原本大家常用的实现 StandardPasswordEncoder, MessageDigestPasswordEncoder, StandardPasswordEncoder 不再推荐使用, 源码上面全加上了@Deprecated 如图
,让我们来运行一下项目,刚进入页面的时候并不会受到任何阻拦,你在认证的时候自主配置添加了用户与分配了权限,所以我们需要使用这些用户进行登录。
在我们登录成功会看到不同的界面,因为角色不同,权限不同。
5.JDBC Authentication(JDBC 验证)
Spring Security的UserDetailsService``JdbcDaoImpl
实现了对使用JDBC检索的基于用户名/密码的身份验证的支持。 扩展以通过接口提供管理。 当Spring Security配置为接受用户名/密码进行身份验证时,将使用基于身份的身份验证。这里我将使用UserDetailsService自定义存储认证的方式也添加到jdbc验证里面 因为他也需要去连接数据库查询,方便大家理解😂
栗子-数据库UserDetailsService
接下来为大家演示一下如何通过连接数据库进行验证,首先我们先创建一个user表
CREATE TABLE `user` (
`id` INT(11) NOT NULL,
`name` VARCHAR(11) DEFAULT NULL,
`role` VARCHAR(20) NOT NULL,
`password` VARCHAR(20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8;
INSERT INTO USER(NAME, PASSWORD, role, id)
VALUES ("lizeqing","123456", "VIP1,VIP2,VIP3", 1);
然后我们引入MySQL,我这里用的持久层框架mybatisplus大家可以选择自己熟悉的。然后我们需要写一下配置连接数据库。
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.48</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.2</version>
</dependency>
之后创建User类与对应的UserDao,相信大家都很熟悉了吧😄
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
private int id;
private String name;
private String role;
private String password;
}
@Mapper
@Component
public interface UserDao extends BaseMapper<User> {
}
接下来要写自定义UserService进行权限的验证,我们需要继承UserDetailsService重写其验证方法
@Service
public class UserService implements UserDetailsService {
@Autowired
public UserDao userDao;
//这里我们重写loadUserByUsername进行用户验证
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("用户名为 : " + username);
if(username == null || username == ""){
throw new UsernameNotFoundException("请输入用户名!");
}
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("name",username);
User user = userDao.selectOne(queryWrapper);
List<SimpleGrantedAuthority> list = new ArrayList<>();
for(String s : user.getRole().split(",")){
s = "ROLE_" + s; //由于sercurity默认的role格式是ROLE_ + role,所以此处扩展,而不用保存在数据库中
list.add(new SimpleGrantedAuthority(s)); //由于不可能是空的(数据库中必须字段)
System.out.println(s);
}
//这里的密码需要加密 这个坑大家要记住不然要提示 Encoded password does not look like BCrypt
return new org.springframework.security.core.userdetails.User(user.getName(),new BCryptPasswordEncoder().encode(user.getPassword()), list);
}
}
我们这获取角色的时候如果我们数据库中不是'ROLE_'开头的,一定要加上因为在我们设置角色的时候,源码中有这么一个设置,他会进行判断给你加上前缀。
再将它注入将认证规则修改如下
@Autowired
private UserService userService;
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}
然后我们运行程序,输入我们的用户名密码发现登录成功。
栗子-数据源DataSource
刚刚进行了数据库查询我们自定义UserService进行权限的验证,接下来将演示一个直接设置数据源进行查询验证。
首先我们先引入阿里的德鲁伊连接池
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.17</version>
</dependency>
这里我们使用以下security给我们提供的数据库,他的位置就在spring-security-core/5.2.2.RELEASE/spring-security-core-5.2.2.RELEASE.jar!/org/springframework/security/core/userdetails/jdbc
下的users.ddl
create table users(username varchar(50) not null primary key,password varcha(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);
创建完成后我们写一下yml
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
url: jdbc:mysql://localhost:3306/test
username: root
password: 123456
driver-class-name: com.mysql.jdbc.Driver
之后我们只需要配置一下他的认证规则如下
@Autowired
public DataSource dataSource;
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.jdbcAuthentication()
.dataSource(dataSource)
.usersByUsernameQuery("select username,password, enabled from users where username = ?")
.rolePrefix("ROLE_") //因为数据库中没有所以这里我们需要给它加上
.authoritiesByUsernameQuery("select username, authority from authorities where username = ?");
}
之后我们就可以正常登陆访问了,给大家看一下我的数据库
我们登录成功后结果和上一次的一样就不展示了。🤭
6.LDAP Authentication(LDAP 认证)
LDAP通常被组织用作用户信息的中央存储库和身份验证服务。它还可以用于存储应用程序用户的角色信息。
官网文档是这么解释的:
当Spring Security配置为接受用户名/密码进行身份验证时,将使用基于Spring Security的LDAP身份验证。但是,尽管利用了用户名/密码进行身份验证,它也没有集成使用,UserDetailsService
因为在绑定身份验证中,LDAP服务器不会返回密码,因此应用程序无法执行密码验证。
关于如何配置LDAP服务器,有许多不同的方案,因此Spring Security的LDAP提供程序是完全可配置的。它使用单独的策略接口进行身份验证和角色检索,并提供可以配置为处理各种情况的默认实现。
LDAP
首先,要使用它的话我们就需要知道他是一个什么东西,来我们看一看:
LDAP(Light Directory Access Portocol),它是基于X.500标准的轻量级目录访问协议。
(1) 目录服务
首先我们在看LDAP之前了解一下什么是目录服务。目录是一个为查询、浏览和搜索而优化的专业分布式数据库,它呈树状结构组织数据,就好像Linux/Unix系统中的文件目录一样
目录数据库和关系数据库不同,它有优异的读性能,但写性能差,并且没有事务处理、回滚等复杂功能,不适于存储修改频繁的数据。所以目录天生是用来查询的,就好象它的名字一样。它是动态的,灵活的,易扩展的。
目录服务是由目录数据库和一套访问协议组成的系统。类似以下的信息适合储存在目录中:
- 企业员工信息,如姓名、电话、邮箱等;
- 公用证书和安全密钥;
- 公司的物理设备信息,如服务器,它的IP地址、存放位置、厂商、购买时间等;
(2)为什么使用LDAP
LDAP目录服务是由目录数据库和一套访问协议组成的系统。LDAP是开放的Internet标准,支持跨平台的Internet协议,在业界中得到广泛认可的,并且市场上或者开源社区上的大多产品都加入了对LDAP的支持,因此对于这类系统,不需单独定制,只需要通过LDAP做简单的配置就可以与服务器做认证交互。“简单粗暴”,可以大大降低重复开发和对接的成本。
(3)LDAP特点
- 结构用树来表示,而不是表格
- 可以很快地得到查询结果(不过在写方面,就慢得多)
- 提供了静态数据的快速查询方式
- Client/server模型
Server 用于存储数据,Client提供操作目录信息树的工具 - 这些工具可以将数据库的内容以文本格式(LDAP 数据交换格式,LDIF)呈现在您的面前
- 一种开放Internet标准, LDAP协议是跨平台的Interent协议
配置
要使用LDAP认证第一部的话我们需要确保正确配置连接池。可以参考Java LDAP文档。
我们先引入其依赖:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-ldap</artifactId>
</dependency>
<dependency>
<groupId>org.apache.directory.server</groupId>
<artifactId>apacheds-core</artifactId>
<version>1.5.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.apache.directory.server</groupId>
<artifactId>apacheds-server-jndi</artifactId>
<version>1.5.5</version>
<scope>runtime</scope>
</dependency>
spring-security-ldap 中实现对 LDAP 服务端的认证类是 ActiveDirectoryLdapAuthenticationProvider
spring-boot-starter-data-ldap 的主要作用是将 LDAP服务端(这里指AD) 的用户信息进行简单的封装,方便CRUD 操作。
我们这里添加users.ldif
dn: ou=groups,dc=lzq,dc=com
objectclass: top
objectclass: organizationalUnit
ou: groups
dn: ou=people,dc=lzq,dc=com
objectclass: top
objectclass: organizationalUnit
ou: people
dn: uid=admin,ou=people,dc=lzq,dc=com
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: Rod Johnson
sn: Johnson
uid: admin
userPassword: password
dn: uid=user,ou=people,dc=lzq,dc=com
objectclass: top
objectclass: person
objectclass: organizationalPerson
objectclass: inetOrgPerson
cn: Dianne Emu
sn: Emu
uid: user
userPassword: password
dn: cn=user,ou=groups,dc=lzq,dc=com
objectclass: top
objectclass: groupOfNames
cn: user
uniqueMember: uid=admin,ou=people,dc=lzq,dc=com
uniqueMember: uid=user,ou=people,dc=lzq,dc=com
dn: cn=admin,ou=groups,dc=lzq,dc=com
objectclass: top
objectclass: groupOfNames
cn: admin
uniqueMember: uid=admin,ou=people,dc=lzq,dc=com
然后我们去写认证方法:
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.ldapAuthentication()
.userSearchBase("ou=people")
.userSearchFilter("(uid={0})")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.contextSource()
.root("dc=lzq,dc=com")
.ldif("classpath:users.ldif");
}
运行项目进行登录user与admin密码都是password,成功登录显示界面