参看:
我会整理了一套Security+JWT+Oauth2一起使用的Demo代码。这里的简单学习使用就不上传demo了~
这篇文章前部分简单的对UserDetailsService和PasswordEncoder源码功能进行简单分析(使用Security接触到这两个接口即可),之后在SpringBoot上整合了Security,并进行简单的功能实现。最后,列举了一些常用的功能进行整理,当然最重要的是要学会查询开发者手册哦~
SpringSecurity是Spring系的唯一一个安全框架,它主要实现 认证和授权 两大功能。
SpringSecurity充分利用Spring的aop、ioc、di功能,保证安全(复制一些)。功能比shiro强大,小项目用shiro。它们是Java语言的两大安全框架。目前使用的套件是:springboot/springcloud + security。
在使用security做权限控制之前,先来了解一下它的两个功能类UserDetailsService和PasswordEncoder,于权限认证实现的类暂且放置。
这里测试的SpringBoot版本是2.5.0。security版本是 自动匹配的 。也可以指定版本了。
Service类未实现UserDetailsService接口的时候,SpringSecurity就会使用默认的用户名和密码。
在真实的系统中,我们希望用户的信息来自数据库,而不是写死的,我们就需要实现UserDetailsService接口,实现loadUserByUsername方法,然后配置authentication-provider。
UserDetailsService接口里有且仅有一个方法,它就是及其巧妙的loadUserByUsername()
,为什么说它巧妙?答曰:见名知意,功能甚好。它通过用户名加载用户信息到Security内置容器UserDetails中,认证时通过注入的形式需要的提供数据。
重写它的一个方法loadUserByUsername。
public interface UserDetailsService {
// username必须传递,否则就会抛出UsernameNotFoundException异常
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
重写它的loadUserByUsername方法,在里面自定义登陆逻辑(通常来说:通过用户名查询数据库,获取到用户对象信息)。
UserDetails
把这些信息取出来,然后包装成一个对象交由框架去认证,认证包括密码、是否过期、是否锁定、权限等等。
loadUserByUsername的回参是UserDetails接口类型的数据。查看UserDetails源码,它里面有很多的方法,方法名称就是对功能的最简单的描述:
public interface UserDetails extends Serializable {
// 返回用户的权限
Collection<? extends GrantedAuthority> getAuthorities();
// 获取密码 账号
String getPassword();
String getUsername();
// 是否未过期(true未过期)
boolean isAccountNonExpired();
// 是否未锁定(true未锁定)
boolean isAccountNonLocked();
// 凭证(密码)是否未过期
boolean isCredentialsNonExpired();
// 用户是否启用
boolean isEnabled();
}
注意:它有一个权限集合,用于标注此用户所拥有的权限,登陆成功以后,并不是所有操作都可执行哦。
User
User是UserDetails的默认实现类,把UserDetails里面的方法中待获取的数据实际的构造出来。
它可是用来存储用户的信息的。【serialVersionUID序列化id点击我快速学习了解】。注意在导包的时候,不要与项目自定义的实体类User相互混淆。
// 构造方法,getter方法移步源码查看
public class User implements UserDetails, CredentialsContainer {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private static final Log logger = LogFactory.getLog(User.class);
private String password;
private final String username;
// User权限集合
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
}
User类中两个构造方法,其中一个构造方法可以进行密码比较。
Security官方提供的UserDetailsService实现类如下:
UserDetailService只负责存取用户信息,除了给框架内的其他组件提供用户数据外没有其他功能。而认证过程是由AuthenticationManager来完成的。对于 AuthenticationManager类集 就不读源码了,我太懒了~
自定义登陆逻辑流程:
【UserDetailsService更多解读点击我跳转】。
PasswordEncoder是接口,以它的实现类BCryptPasswordEncoder为例进行简单分析。
PasswordEncoder是Security提供的密码解析器。它拥有三个方法:encode、matches(匹配)、upgradeEncoding(二次encodeing)。PasswordEncoder源码:
public interface PasswordEncoder {
// 加密
String encode(CharSequence rawPassword);
// 比较
boolean matches(CharSequence rawPassword, String encodedPassword);
// 二次加密(变得更加安全)
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
PasswordEncoder是一个接口,使用肯定是去找实现类,官方推荐使用实现类是BCryptPasswordEncoder(安全),它encode的底层是基于BCrypt的强hash强散列算法实现的,有单向加密、无法解析的优点,即使是一样的密码,两次encode得到的字符串也不同(内置一个random,相同的概率相当于中1千万彩票~)
加密/解密 与 Hash (这两个概念不能混淆):加密表示可以解密,是可逆的,而hash是不可逆的。
如果使用 BCryptPasswordEncoder 调用 encode()
方法编码输入密码的话,其实这个编码后的“密码”并不是我们平时输入的真正密码,而是密码加盐后的通过单向 Hash 算法(BCrypt)得到值。
@Override
public String encode(CharSequence rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
String salt = getSalt();
return BCrypt.hashpw(rawPassword.toString(), salt);
}
最终上面的 genSalt
方法得到一个 随机密码盐+无用字符串。最后关键点就是调用 BCrypt.hashpw
方法取到密码盐生成相应的“密码”(可持久化到数据库)。具体的实现直接去查看源码就好了(很复杂)。
那么每次生encode得到的“密码”都不一致,那么到底是如何进行判断的呢?这就要关注一下 matches()
方法:
@Override // 传参:rawPassword是用户提供的密码,encodedPassword是加密后数据库中存储的密码
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
if (encodedPassword == null || encodedPassword.length() == 0) {
this.logger.warn("Empty encoded password");
return false;
}
if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
this.logger.warn("Encoded password does not look like BCrypt");
return false;
}
// 交由 BCrypt.checkpw() 处理得到结果
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
对于上面的 BCrypt.checkpw(rawPassword.toString(), encodedPassword)
功能,实现原理:通过encodedPassword(参数:salt )进行一系列校验(长度校验等)并截取encodedPassword中相应的密码盐,利用这个密码盐进行同样的一系列计算 Hash 操作和 Base64 编码拼接一些标识符 生成所谓的“密码”,最后 equalsNoEarlyReturn
方法对同一个密码盐生成的两个“密码”进行匹配。
更多请阅读:org/springframework/security/crypto/bcrypt/BCrypt.java 类库
一般我们代码中 @Autowired 注入并使用 PasswordEncoder 接口的实例,然后调用其 matches 方法去匹配原密码和数据库中保存的“密码”;密码的校验方式有多种,从 PasswordEncoder 接口实现的类是可以知道。
// 业务代码中注入 PasswordEncoder
@Autowired
private PasswordEncoder passwordEncoder;
Spring Security 每次 Hash 之前用的盐都是随机的,盐可以保存在最终生成的“密码”中,这样每个密码都是用 不同的随机盐 + 原密码计算 Hash 值 得到,暴力破解难度很大。
如果需要指定当前配置的加密方式,可以在配置类中进行配置:
// 比如配置为 BCryptPasswordEncoder
@Bean
public PasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
不编写实现UserDetailsService接口的配置类,Security会自动生成用户名和密码,用户名通常是user,密码会打印到控制台。
而在项目中使用Security,就一定会编写配置类,实现自定义的登陆认证逻辑,下面就进行一个简单的实现(不会修改业务逻辑代码,只做出security增强)。
sql
DROP TABLE IF EXISTS `admin`;
CREATE TABLE `admin` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3;
INSERT INTO `admin` VALUES (1, 'admin', '$2a$10$N7TIWXnXY.ub1NJ7xAPo3.BtWbCjQHwAXpTROlU74r/b7pqIOMwoy');
INSERT INTO `admin` VALUES (2, 'cool', '$2a$10$N7TIWXnXY.ub1NJ7xAPo3.BtWbCjQHwAXpTROlU74r/b7pqIOMwoy');
正常的业务访问链是:controller -》 service -》 mapper -》 database ,获取到数据后返回即可。自行实现这些简单逻辑代码哦。
在AdminService中,编写一个 findAdminByUsername()
方法,用于UserDetailsService中的loadUserByUsername()
方法调用到:
// adminMapper 需要注入的哦
public Admin findAdminByUsername(String username) {
LambdaQueryWrapper<Admin> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Admin::getUsername,username);
Admin admin = adminMapper.selectOne(wrapper);
return admin;
}
我们知道UserDetailsService是一个存储账户的信息的容器,开发人员更具自己的业务逻辑,实现把账户信息从数据库获取后,存储到UserDetails:
package com.pdh.service;
import com.pdh.entity.Admin;
import org.springframework.beans.factory.annotation.Autowired;
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.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Scanner;
/** * @Author: 彭_德华 * @Date: 2021-09-25 15:41 */
@Service
public class SecurityUserService implements UserDetailsService {
@Autowired
private AdminService adminService;
/** * 会被security内部逻辑调用,登陆认证的功能(/login请求发起后调用此方法) * 如果不编写此方法,security会生成默认的 username/password * @param username * @return * @throws UsernameNotFoundException */
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//通过username查询 admin表,如果 admin存在 将密码告诉spring security,交由它来验证
Admin admin = adminService.findAdminByUsername(username);
if (admin == null)
return null; //登录失败
// 密码验证交给spring-security(密码验证交给security处理)
UserDetails userDetails = new User(username,admin.getPassword(),new ArrayList<>());
return userDetails;
}
/** * 生成密码手动存储到数据库 * @param args */
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.println("请输入您的密码:");
String pass = sc.nextLine();
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String encode = bCryptPasswordEncoder.encode(pass);
System.out.println("您的密码("+pass+")加密后变为【"+encode+"】,千万别泄漏哦~");
}
}
鉴定发起的此请求,用户是否有权访问。
编写的一般逻辑是:遍历用户的所有权限(可添加权限数据表),权限匹配就直接返回true即可,我这里没有做出具体的实现。
也有直接在security上直接配置角色权限,后文有提到。
package com.pdh.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
/** * @Author: 彭_德华 * @Date: 2021-11-09 13:04 */
@Service
@Slf4j
public class AuthService {
public boolean auth(HttpServletRequest request, Authentication authentication){
// 请求路径 打印日志
String requestURI = request.getRequestURI();
log.info("uri:{}-------------------",requestURI);
Object principal = authentication.getPrincipal();
if(principal == null || "anonymousUser".equals(principal)){
return false;
}
// 此处省略 鉴权逻辑 ,需要自定义实现
return true;
}
}
这一步一般是最先写的。在配置类进行配置编写的时候,根据需求编写对应的逻辑处理代码。
此配置类继承 WebSecurityConfigurerAdapter 类,重写它的 configure()
方法实现权限管理。而里面的各种各样的配置,根据自己当时的生产需求进行配置,如果有需求而不知道如何配置,就需要查看开发者文档了哦~
package com.pdh.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/** * @Author: 彭_德华 * @Date: 2021-11-04 19:04 * 不编写配置类,security就已经生效了 */
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) throws Exception {
super.configure(web);
}
/** * 进行security的功能配置 * * @param http * @throws Exception */
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
// .loginPage("/login.html")
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
// .defaultSuccessUrl("/success.html")
// .failureUrl("/login.html").permitAll()
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/login").permitAll()
.and()
.authorizeRequests() // 开启权限认证
.antMatchers("/css/**","js/**").permitAll() // 放行
// 请求需要查询是否拥有权限
.antMatchers("/admin/**").access("@authService.auth(request,authentication)")
.anyRequest().authenticated() // 所有请求都需要登陆认证
.and()
.csrf().disable();
}
}
当然,里面很多的点都是可以配置的,支持access、reg、ant等表达式。
这里提供了security快速启动的一些配置,前提是本地的代码能够正常访问数据库。这就需要配置数据源、载入数据表等等的一系列操作。
需要登陆权限的请求会转发到 /login
请求,登陆成功以后,再次转发到原请求。其他权限自然也是,无权限就无法访问。
注意,如果自定义html进行登陆的话,账户名和密码参数默认是 username/password(可进行自定义配置),请求方式必须是POST。这体现在UsernamePasswordAuthenticationFilter拦截过滤器:
更加详细的认证逻辑,查看:【https://blog.csdn.net/weixin_37689658/article/details/92752890】。
在 Spring Security 中提供了一些访问控制的注解。这些注解都是默认是都不可用的,需要通过@EnableGlobalMethodSecurity
进行开启后使用。如果设置的条件允许,程序正常执行。如果不允许会报 500。
这并不灵活。不推荐使用。知道有那么一个功能,在需要使用的时候,使用即可。
在重写的 loadUserByUsername()
方法中,配置上权限即可(返回UserDetails)。在前面分析UserDetails,它有一个方法 getAuthorities()
获取到权限。而对应实现类User,它有一个单独的构造方法,传递三个参数:username、password 和 权限列表,在权限处添加权限即可。
// 先配置 在使用
return new User(username,password, AuthorityUtils.
commaSeparatedStringToAuthorityList("admin,normal"));
之后就是在配置文件中指定Authority
// 严格区分大小写 匹配任一个即可
http
.antMatchers("/list").hasAnyAuthority("admin","normal");
实际使用的话,就比如访问vip资源的时候,就可以做出如此操作。
不同账户拥有不同的角色,不同的角色又有不同的权限,从而实现不同账户有了指定的权限。
角色的配置与权限配置步骤是一样的,区别的是 角色需要以为 ROLE_
作前缀,Security在解析匹配权限的时候会自动处理前缀。【在使用 ROLE_ 开头命名角色的时候,security会在匹配的时候自动加上此前缀,配置文件中配置的时候,不要加上此前缀】。
配置角色:
// 先配置 在使用
return new User(username,password, AuthorityUtils.
commaSeparatedStringToAuthorityList("ROLE_role1,ROLE_role2"));
在配置文件中:
// 拥有指定的权限,才能访问
http
.antMatchers("/list1").hasRole("role1")
.antMatchers("/list2").hasAnyRole("role1","role2");
指定某一ip才能访问,比如后台管理中就可进行配置。直接在配置文件中配置即可
http
.antMatchers("/ip").hasIpAddress("127.0.0.1");
RememberMe,即 记住我,这个功能想必大家都有体验过,在短时间内再次访问,减少一次账户登陆认证等。
Security中的RememberMe功能是把用户信息存储到数据源(内存或数据库),使用 JDBC 实现。
需要导入Mybatis依赖(含jdbc)、配置数据源datasource能正常连接数据库。
之后再security的配置类中实现以下代码:
// 自定义的登陆逻辑
@Autowired
private SecurityUserService userService;
// 数据源
@Autowired
private DataSource dataSource;
// 注入
@Autowired
private PersistentTokenRepository tokenRepository;
// 持久化策略
@Bean
public PersistentTokenRepository tokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
// 数据源
tokenRepository.setDataSource(dataSource);
// 第一次操作需要传教表,之后关闭此操作
tokenRepository.setCreateTableOnStartup(false);
return tokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.rememberMe()
.userDetailsService(userService) // 自定义登陆逻辑
.tokenRepository(tokenRepository) // 指定存储位置
.and()
.csrf().disable();
}
配置前端登陆页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="/login" method="post">
用户名: <input type="text" name="username"/> <br/>
密码: <input type="password" name="password"/> <br/>
记住我: <input type="checkbox" name="remember-me" value="true"/> <br/>
<input type="submit" value="登陆"/> <br/>
</form>
</body>
</html>
下面进行第一次登陆的测试,勾选记住我。第一次登陆访问成功后,查看对应的数据库,就会有数据表 persistent_logins
,它的字段信息如下图
之后再次访问,此session的 http请求头中的cookie中会携带此token
默认失效时间是两周。
可以在配置文件中进行RememberMe数据的配置,就不一一演示操作了。
前后端分离的话就用不到它,而对于非前后端分离的项目,springboot就推荐使用thymeleaf做视图展示技术。
依赖:
<!--thymeleaf springsecurity5 依赖-->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
<!--thymeleaf依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
之后就是再html中使用Thymeleaf命名空间处理。把此html放在 templates 文件夹下(templates配合Thymeleaf使用)
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
登录账号:<span sec:authentication="name"></span><br/>
登录账号:<span sec:authentication="principal.username"></span><br/>
凭证:<span sec:authentication="credentials"></span><br/>
权限和角色:<span sec:authentication="authorities"></span><br/>
客户端地址:<span sec:authentication="details.remoteAddress"></span><br/>
sessionId:<span sec:authentication="details.sessionId"></span><br/>
</body>
</html>
需要简单配置一下controller跳转到指定页面
// 在Thymeleaf下 它会默认跳转到templates文件夹下的 detalis.html页面
@RequestMapping("/detalis")
public String detalis(){
return "detalis"
}
之后此页面就可以访问到对应的数据。
Thymeleaf有一个专门的目录,templates目录,全部的页面全部放在该目录下,可放置一级、二级目录。这里可以实现很多的页面。
在Thymeleaf模板疫情下测试,可方便 携带/读取 _crsf 。
CSRF(Cross-site request forgery)跨站请求伪造,是一种安全策略。通过伪造用户请求访问受信任站点的非法请求访问。它保证用户每次访问的安全性,开启CSRF的话,每次请求都需要传递一个 csrfToken
,此token由csrf生成(保证sessionId被窃取后的安全)。
Spring Security中的CSRF
Spring Security4开始CSRF防护默认开启(拦截请求)。CSRF为了保证不是其他第三方网站访问,要求访问时携带参数名为_csrf
值为token(服务端crsf产生),如果token和服务端的token匹配成功,则正常访问。
在配置类中配置csrf:http.csrf().disable()
表示关闭csrf保护,在测试接口的时候关闭它。
第一次执行登陆操作的时候,Security会颁发一个名称为 _csrf
的token参数,之后每次的请求都需要携带此token保证是整个响应过程是安全的:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form action="/login" method="post">
<input type="hidden" th:value="${_csrf.token}" name="_csrf" th:if="${_csrf}"/>
用户名:<input type="text" name="username" /><br/>
密码:<input type="password" name="password" /><br/>
<input type="submit" value="登录" />
</form>
</body>
</html>
请求发起后点开浏览器调试器可见:
到这里,Security简单的使用就已经实现了。
版权说明 : 本文为转载文章, 版权归原作者所有 版权申明
原文链接 : https://blog.csdn.net/yeahPeng11/article/details/121308104
内容来源于网络,如有侵权,请联系作者删除!