SpringSecurity我会【SpringBoot中快速开始、源码分析、核心功能演示】

x33g5p2x  于2021-11-13 转载在 Spring  
字(16.4k)|赞(0)|评价(0)|浏览(655)

参看:

  • https://spring.io/projects/spring-security(有什么不懂的地方,这里总能给出你满意的答案)
  • https://www.bilibili.com/video/BV1vK4y1H7b1
  • https://blog.csdn.net/weixin_37689658/article/details/92752890
  • https://www.jianshu.com/p/922963106729

我会整理了一套Security+JWT+Oauth2一起使用的Demo代码。这里的简单学习使用就不上传demo了~

这篇文章前部分简单的对UserDetailsService和PasswordEncoder源码功能进行简单分析(使用Security接触到这两个接口即可),之后在SpringBoot上整合了Security,并进行简单的功能实现。最后,列举了一些常用的功能进行整理,当然最重要的是要学会查询开发者手册哦~

一、Security简介

SpringSecurity是Spring系的唯一一个安全框架,它主要实现 认证和授权 两大功能。

SpringSecurity充分利用Spring的aop、ioc、di功能,保证安全(复制一些)。功能比shiro强大,小项目用shiro。它们是Java语言的两大安全框架。目前使用的套件是:springboot/springcloud + security。

在使用security做权限控制之前,先来了解一下它的两个功能类UserDetailsService和PasswordEncoder,于权限认证实现的类暂且放置。
这里测试的SpringBoot版本是2.5.0。security版本是 自动匹配的 。也可以指定版本了。

二、UserDetailsService

Service类未实现UserDetailsService接口的时候,SpringSecurity就会使用默认的用户名和密码。

在真实的系统中,我们希望用户的信息来自数据库,而不是写死的,我们就需要实现UserDetailsService接口,实现loadUserByUsername方法,然后配置authentication-provider。

  • UserDetails => Spring Security基础接口,包含某个用户的账号,密码,权限,状态(是否锁定)等信息。只有getter方法。
  • Authentication => 认证对象,认证开始时创建,认证成功后存储于SecurityContext。
  • principal => 用户信息对象,是一个Object,通常可转为UserDetails。

1.loadUserByUsername方法

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类中两个构造方法,其中一个构造方法可以进行密码比较。

2.UserDetailsService实现类

Security官方提供的UserDetailsService实现类如下:

UserDetailService只负责存取用户信息,除了给框架内的其他组件提供用户数据外没有其他功能。而认证过程是由AuthenticationManager来完成的。对于 AuthenticationManager类集 就不读源码了,我太懒了~

自定义登陆逻辑流程:

UserDetailsService更多解读点击我跳转】。

三、PasswordEncoder

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是不可逆的。

1.encode()

如果使用 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 方法取到密码盐生成相应的“密码”(可持久化到数据库)。具体的实现直接去查看源码就好了(很复杂)。

2.matches()

那么每次生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 类库

3.PasswordEncoder使用

一般我们代码中 @Autowired 注入并使用 PasswordEncoder 接口的实例,然后调用其 matches 方法去匹配原密码和数据库中保存的“密码”;密码的校验方式有多种,从 PasswordEncoder 接口实现的类是可以知道。

// 业务代码中注入 PasswordEncoder
@Autowired
private PasswordEncoder passwordEncoder;

Spring Security 每次 Hash 之前用的盐都是随机的,盐可以保存在最终生成的“密码”中,这样每个密码都是用 不同的随机盐 + 原密码计算 Hash 值 得到,暴力破解难度很大。

如果需要指定当前配置的加密方式,可以在配置类中进行配置:

// 比如配置为 BCryptPasswordEncoder
@Bean
public PasswordEncoder bCryptPasswordEncoder() {
    return new BCryptPasswordEncoder();
}

四、SpringBoot整合Security(配置类)

不编写实现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');

1.实现业务访问链

正常的业务访问链是: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;
}

2.实现UserDetailsService

我们知道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+"】,千万别泄漏哦~");
    }
}

3.编写鉴权逻辑

鉴定发起的此请求,用户是否有权访问。

编写的一般逻辑是:遍历用户的所有权限(可添加权限数据表),权限匹配就直接返回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;
    }

}

4.编写配置类

这一步一般是最先写的。在配置类进行配置编写的时候,根据需求编写对应的逻辑处理代码。

此配置类继承 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等表达式。

5.启动测试

这里提供了security快速启动的一些配置,前提是本地的代码能够正常访问数据库。这就需要配置数据源、载入数据表等等的一系列操作。

需要登陆权限的请求会转发到 /login 请求,登陆成功以后,再次转发到原请求。其他权限自然也是,无权限就无法访问。

注意,如果自定义html进行登陆的话,账户名和密码参数默认是 username/password(可进行自定义配置),请求方式必须是POST。这体现在UsernamePasswordAuthenticationFilter拦截过滤器:

更加详细的认证逻辑,查看:【https://blog.csdn.net/weixin_37689658/article/details/92752890】。

五、Others

1.基于注解配置

在 Spring Security 中提供了一些访问控制的注解。这些注解都是默认是都不可用的,需要通过@EnableGlobalMethodSecurity进行开启后使用。如果设置的条件允许,程序正常执行。如果不允许会报 500。

这并不灵活。不推荐使用。知道有那么一个功能,在需要使用的时候,使用即可。

2.权限、角色、ip

2.1 权限

在重写的 loadUserByUsername() 方法中,配置上权限即可(返回UserDetails)。在前面分析UserDetails,它有一个方法 getAuthorities() 获取到权限。而对应实现类User,它有一个单独的构造方法,传递三个参数:username、password 和 权限列表,在权限处添加权限即可。

// 先配置 在使用
return new User(username,password, AuthorityUtils.
                commaSeparatedStringToAuthorityList("admin,normal"));

之后就是在配置文件中指定Authority

// 严格区分大小写 匹配任一个即可
http
    .antMatchers("/list").hasAnyAuthority("admin","normal");

实际使用的话,就比如访问vip资源的时候,就可以做出如此操作。

2.2 角色

不同账户拥有不同的角色,不同的角色又有不同的权限,从而实现不同账户有了指定的权限。

角色的配置与权限配置步骤是一样的,区别的是 角色需要以为 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");

2.3 ip

指定某一ip才能访问,比如后台管理中就可进行配置。直接在配置文件中配置即可

http               
    .antMatchers("/ip").hasIpAddress("127.0.0.1");

3.RememberMe

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数据的配置,就不一一演示操作了。

4.Thymeleaf模板引擎

前后端分离的话就用不到它,而对于非前后端分离的项目,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目录,全部的页面全部放在该目录下,可放置一级、二级目录。这里可以实现很多的页面。

5.CSRF

在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简单的使用就已经实现了。

相关文章