SpringSecurity+JWT+OAuth2

一个简洁的博客网站:http://lss-coding.top,欢迎大家来访
学习娱乐导航页:http://miss123.top/

1. Spring Security 简介

1.1 概述

什么是安全框架?解决系统安全问题的框架,如果没有安全框架。我们需要手动处理每个资源的访问控制,非常麻烦,使用安全框架,我们可以通过配置的方式实现对资源的访问控制。

1.2 常用安全框架

Spring Security,Spring 家族的成员,是一个能够为基于 Spring 的企业应用系统提供声明式的安全访问控制解决方案的安全框架。它提供了一组可以在 Spring 应用上下文中配置 Bean。充分利用 Spring IOC、DI 和 AOP功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全控制编写大量重复代码的工作。

Apach Shiro,功能强大且易于使用的 Java 安全框架,提供了认证、授权、加密和会话管理。

1.3 SpringSecurity

一个高度自定义的安全框架。利用 IOC/DI和AOP 功能,为系统提供了声明式安全访问控制功能,减少了为系统安全而编写大量重复代码的工作。使用 Spring Security 的原因有很多,但大部分都是发现了 JavaEE 和 Servlet 规范或 EJB 规范中的安全功能缺乏典型企业应用场景。同时认识到他们在 WAR 或 EAR 级别无法移植。因此如果你更换服务器环境,还有大量工作去重新匹配你的应用程序。使用 Spring Security 解决了这些问题,也为你提供许多其他有用的、可定制的安全功能。正如你可能知道的两个应用程序的两个主要区域是“认证”和“授权”或者称为访问控制。这两点也是 Spring Security 重要核心功能。“认证”,是建立一个他生命的主体的过程(一个“主体”一般是指用户,设备或一些可以在你的应用程序中执行动作的其他系统),就是说系统认为用户是否能登录。“授权”指确定一个主体是否允许在你的应用程序执行一个动作的过程。就是系统判断用户是否有权限去做某一些事情。

2. 案例分析

2.1 简单案例

  1. 创建一个 SpringBoot 工程,引入依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
  1. 在 static 文件夹下创建 login.html 和 main.html 页面,分别模拟登录和主页面。
<!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>
    <button type="submit">登录</button>
</form>
</body>
</html>

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>登录成功</h1>
</body>
</html>
  1. 创建 LoginController 类,用于实现页面跳转
@Controller
public class LoginController {
    /**
      * @description 登录
      * @author lishisen
      * @date 2021/12/27 8:15
     **/
    @RequestMapping("/login")
    public String login() {
        return "redirect:main.html";
    }
}
  1. 启动测试

    启动后访问登录请求会发现,首先进入的是一个登录页面,这个登录页面是 Spring Security 内置的一个登录页面。这个登录账号默认是 user,密码在控制台有输出(每一次都不一样)。当这个判断我们的用户名和密码正确的时候才进入到我们自定义的登录页面。

SpringSecurity+JWT+OAuth2

2.2 UserDetailsService

2.3 PasswordEncoder

SpringSecurity+JWT+OAuth2

SpringSecurity+JWT+OAuth2

BCryptPasswordEncoder 是 Spring Security 官方推荐的密码解析器,平时多使用这个解析器。

BCryptPasswordEncoder 是对 bcrypt 强散列方法的具体实现,是基于 Hash 算法实现的单向加密,可以通过 strength 控制加密强度,默认 10。

@Test
void contextLoads() {
    // 创建一个密码解析器
    BCryptPasswordEncoder pw = new BCryptPasswordEncoder();
    // 对密码进行加密,相同的值每一次加密都是会不同的
    String encode = pw.encode("123456");
    System.out.println(encode);
    // 判断用户给定的明文是否与解密后的密文相等,相等返回true,否则返回false
    boolean matches = pw.matches("12345", encode);
    System.out.println(matches);
}

3. 自定义 Spring Security 内置功能

3.1 自定义登录逻辑

  1. 创建 SpringSecurity 配置类,将 BCryptPasswordEncoder 注册到容器中
@Configuration
public class SpringSecurityConfig {

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}
  1. 业务逻辑层
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private BCryptPasswordEncoder pw;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 1. 根据用户名去数据库查询,如果不存在则抛出 UsernameNotFoundException 异常
        if (!"admin".equals(username)) {
            throw new UsernameNotFoundException("用户不存在");
        }
        // 2. 比较密码(注册时已经加密过),如果匹配成功返回 UserDetails
        String password = pw.encode("123");

        return new User(username, password, AuthorityUtils.commaSeparatedStringToAuthorityList("admin,normal"));
    }
}
  1. 启动测试

    启动后我们可以发现,控制台不会在打印一个 SpringSecurity 默认的密码

    登录的时候使用自定义的用户:admin、密码:123 就可以进行登录了。

SpringSecurity+JWT+OAuth2

3.2 自定义登录页面

虽然 Spring Security 提供了登录页面,但是实际项目中不会用到的,大多数情况使用自己的登录页面。

  1. SpringSecurityConfig 继承 WebSecurityConfigurerAdapter 类,实现 configure 方法

SpringSecurity+JWT+OAuth2

  1. 设置表单提交的路径,授权的路径
@Override
protected void configure(HttpSecurity http) throws Exception {
    // 表单提交
    http.formLogin()
            // 自定义登录页面
            .loginPage("/login.html")
            // 必须和表单提交的接口一样,会去执行自定义登录逻辑
            .loginProcessingUrl("/login")
            // 登录成功后跳转的页面,POST 请求
            .successForwardUrl("/toMain");
    // 授权
    http.authorizeRequests()
            // 放行 /login.html ,不需要认证
            .antMatchers("/login.html").permitAll()
            // 所有请求都必须认证才能访问,必须登录
            .anyRequest().authenticated();
    // 关闭 csrf 防护
    http.csrf().disable();
}
  1. 在 LoginController 中添加 /toMain 请求路径
/**
  * @description 登录到主页
  * @author lishisen
  * @date 2021/12/27 8:15
 **/
@RequestMapping("/toMain")
public String login() {
    return "redirect:main.html";
}

3.3 自定义登录失败页面

  1. error.html 页面
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
登录失败,点击 <a href="/login.html">跳转</a>到登录页面
</body>
</html>
  1. Controller 创建方法用于跳转
/**
  * @description 跳转到错误页面
  * @author lishisen
  * @date 2021/12/27 9:35
 **/
@RequestMapping("/toError")
public String toError() {
    return "redirect:error.html";
}
  1. 认证和授权
// 表单提交
http.formLogin()
        // 自定义登录页面
        .loginPage("/login.html")
        // 必须和表单提交的接口一样,会去执行自定义登录逻辑
        .loginProcessingUrl("/login")
        // 登录成功后跳转的页面,POST 请求
        .successForwardUrl("/toMain")
        // 登录失败后跳转的页面
        .failureForwardUrl("/toError");
// 授权
http.authorizeRequests()
        // 放行 /error.html,不需要认证
        .antMatchers("/error.html").permitAll()
        // 放行 /login.html ,不需要认证
        .antMatchers("/login.html").permitAll()
        // 所有请求都必须认证才能访问,必须登录
        .anyRequest().authenticated();

3.4 请求账户的用户和密码参数

<form action="/login" method="post">// 必须为 post 请求
    用户名:<input type="text" name="username" /><br>// name 必须为 username
    密码:&nbsp;&nbsp;<input type="password" name="password"><br>// name 必须为 password
    <button type="submit">登录</button>
</form>

以上的约束在这个类中都进行了定义:UsernamePasswordAuthenticationFilter

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

	public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";

	public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
			"POST");
    ......
    private boolean postOnly = true;f
        
   @Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
		throws AuthenticationException {
	if (this.postOnly && !request.getMethod().equals("POST")) {
        // 如果不是 post 请求就会抛出异常
		throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
	}
	String username = obtainUsername(request);
	username = (username != null) ? username : "";
	username = username.trim();
	String password = obtainPassword(request);
	password = (password != null) ? password : "";
	UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
	// Allow subclasses to set the "details" property
	setDetails(request, authRequest);
	return this.getAuthenticationManager().authenticate(authRequest);
}
    
// 使用 request.getParameter(); 获取用户名或者密码
    @Nullable
protected String obtainPassword(HttpServletRequest request) {
    						// passwordParameter==passowrd
	return request.getParameter(this.passwordParameter);
}
    @Nullable
protected String obtainUsername(HttpServletRequest request) {
    						// usernameParameter==username
	return request.getParameter(this.usernameParameter);
}
  • 如果想要自定义接收参数的名称,可以在 SpringCecurityConfig 的 http.formLogin(); 链上追加
http.formLogin()
    	// 自定义的 username123
        .usernameParameter("username123")
    	// 自定义的 password123	
        .passwordParameter("password123")

3.5 自定义登录成功处理器

有一个需求:登录成功跳转到 http://wwwbaidu.com 这个网站,修改 http.formLogin() 中的 successForwardUrl

// 表单提交
http.formLogin()
        // 自定义登录页面
        .loginPage("/login.html")
        // 必须和表单提交的接口一样,会去执行自定义登录逻辑
        .loginProcessingUrl("/login")
        // 登录成功后跳转的页面,POST 请求
    	//  对这里进行修改
        .successForwardUrl("http://www.baidu.com")
        // 登录失败后跳转的页面
        .failureForwardUrl("/toError");

修改后经过测试结果为 404

SpringSecurity+JWT+OAuth2

查看源码发现,底层其实就是一个简单的页面转发,转发是不能跳转到百度页面的,所以我们下面自定义可以使用重定向

public class ForwardAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
		Authentication authentication) throws IOException, ServletException {
	request.getRequestDispatcher(this.forwardUrl).forward(request, response);
}

    
public interface AuthenticationSuccessHandler {

	/**
	 * Called when a user has been successfully authenticated.
	 * @param request the request which caused the successful authentication
	 * @param response the response
	 * @param chain the {@link FilterChain} which can be used to proceed other filters in
	 * the chain
	 * @param authentication the <tt>Authentication</tt> object which was created during
	 * the authentication process.
	 * @since 5.2.0
	 */
	default void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authentication) throws IOException, ServletException {
		onAuthenticationSuccess(request, response, authentication);
		chain.doFilter(request, response);
	}

	/**
	 * Called when a user has been successfully authenticated.
	 * @param request the request which caused the successful authentication
	 * @param response the response
	 * @param authentication the <tt>Authentication</tt> object which was created during
	 * the authentication process.
	 */
	void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException;

}

我们可以自定义这个处理器:AuthenticationSuccessHandler

/**
 * @author lishisen
 * @description 自定义 认证成功后重定向到另一个网站
 * @date 2021/12/27 10:04
 **/
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final String url;

    public MyAuthenticationSuccessHandler(String url) {
        this.url = url;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        
        // 自定义登录的时候拿到一个对象
		User user = (User) authentication.getPrincipal();
		// 获得登录的用户名
		System.out.println(user.getUsername());
		// 获得登录的密码,为了安全考虑密码为 null
		System.out.println(user.getPassword());
		// 获得登录的权限
		System.out.println(user.getAuthorities());			
        
        response.sendRedirect(url);
    }
}

// 控制台输出
admin
null				// 密码为了安全起见为 null
[admin, normal]		// 这个是在登录的时候给授予的权限

在 http.formLogin() 中使用 .successHandler(new MyAuthenticationSuccessHandler(“http://www.baidu.com”))

启动测试,登录成功后成功跳转到百度

SpringSecurity+JWT+OAuth2

3.6 自定义登录失败处理器

与登录成功处理器类似

创建 MyAuthenticationFailureHandler 处理器类,实现 AuthenticationFailureHandler 接口

/**
 * @author lishisen
 * @description 自定义登录失败处理器
 * @date 2021/12/27 10:26
 **/
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

    private String url;

    public MyAuthenticationFailureHandler(String url) {
        this.url = url;
    }

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.sendRedirect(url);
    }
}

在 http.formPage() 中进行配置

http.formLogin()
        // 自定义登录页面
        .loginPage("/login.html")
        // 必须和表单提交的接口一样,会去执行自定义登录逻辑
        .loginProcessingUrl("/login")
        // 登录成功后跳转的页面,POST 请求
        .successHandler(new MyAuthenticationSuccessHandler("/main.html"))
        // 登录失败后跳转的页面
        .failureHandler(new MyAuthenticationFailureHandler("/error.html"));

3.7自定义 403 页面

  1. 创建一个全局处理器类 MyAccessDeniedHandler
/**
 * @author lishisen
 * @description 403 页面处理
 * @date 2021/12/27 11:53
 **/
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // 响应状态
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        // 返回 json 格式
        response.setHeader("Content-Type","application/json;charset=utf-8");
        PrintWriter writer = response.getWriter();
        writer.write("{\"status\":\"error\",\"msg\":\"权限不足,请联系管理员\"}");
        writer.flush();
        writer.close();

    }
}
  1. 在SpringSecurityConfig 中配置异常处理
// 异常处理
http.exceptionHandling().accessDeniedHandler(new MyAccessDeniedHandler());
  1. 测试访问

SpringSecurity+JWT+OAuth2

4. 授权

4.1 anyRequest()

// 授权
http.authorizeRequests()
        // 放行 /error.html,不需要认证
        .antMatchers("/error.html").permitAll()
        // 放行 /login.html ,不需要认证
        .antMatchers("/login.html").permitAll()
        // 所有请求都必须认证才能访问,必须登录
        .anyRequest().authenticated();
        
/**
	.anyRequest().authenticated(); 
	表示匹配所有的请求,设置全部内容都需要认证
	要放到最下面,因为是顺序执行,如果放到第一行的话就是拦截全部的请求了
**/

4.2 antMatchers()

public C antMatchers(String... antPatterns) {

参数是不定向参数,每个参数是一个 ant 表达式,用于匹配 URL 规则

  • ?:匹配一个字符
  • *:匹配 0 个或多个字符
  • **:匹配 0 个或多个目录
.antMathchers("/css/**","/js/**","/images/**").permitAll();		// 放行 js文件夹下所有脚本文件
    
 .antMatchers("/**/*.js").permitAll();	// 只要是 .js 文件都放行


// 授权
http.authorizeRequests()
        // 放行 /error.html,不需要认证
        .antMatchers("/error.html").permitAll()
        // 放行 /login.html ,不需要认证
        .antMatchers("/login.html").permitAll()
    
        // 放行静态资源
        .antMatchers("/images/**", "/css/**").permitAll()
        .antMatchers("/**/*.png").permitAll()
    
        // 所有请求都必须认证才能访问,必须登录
        .anyRequest().authenticated();

4.3 regexMatchers()

正则表达式

// 正则表达式
.regexMatchers(".+[.]png").permitAll()
    
//可以加上请求的method,antMatchers 也可以
.regexMatchers(HttpMethod.POST, ".+[.]png").permitAll()

4.4 mvcMatchers()

# 配置文件
spring.mvc.servlet.path=/xxxx

// mvc 匹配
.mvcMatchers("/hello").servletPath("/xxxx").permitAll()

4.5 控制访问的方法

  • permitAll 允许所有的一个请求

  • denyAll 禁止

  • anonymous 只有匿名才能匹配到路径

  • authenticated 必须要认证

  • fullyAuthenticated 必须是完全登录了之后才能访问

  • rememberMe 通过免登录的形式才能访问

5. 角色权限判断

5.1 基于权限判断

Spring Security 中支持很多其他权限控制,这些方法一般都用于用户已经被认证后,判断用户是否具有特定的要求。

在 http.authorizeRequests() 中添加权限控制语句

// 授权
http.authorizeRequests()
        // 放行 /error.html,不需要认证
        .antMatchers("/error.html").permitAll()
        // 放行 /login.html ,不需要认证
        .antMatchers("/login.html").permitAll()
        // 放行静态资源
        .antMatchers("/images/**", "/css/**").permitAll()
        .antMatchers("/**/*.png").permitAll()

        // 权限控制										
        .antMatchers("/main1.html").hasAuthority("admin")// " "双引号里面的权限严格区分大小写
    
        // 所有请求都必须认证才能访问,必须登录
        .anyRequest().authenticated();

启动测试后有 admin 登录成功会赋予权限,然后请求 main1.html 可以正常访问。如果把admin换成Admin,则会出现 403 的错误,表示权限不够。

SpringSecurity+JWT+OAuth2

可以使用 hasAnyAuthority 设置多个权限值

// 权限控制
.antMatchers("/main1.html").hasAnyAuthority("admin","Admin")
5.2 基于角色判断

在服务层实现类中添加权限 UserDetailsServiceImpl,

return new User(username, password,
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin,normal,ROLE_aaa"));
																				// 注意,这里添加的角色权限必须以 ROLE_ 开头

在 SpringSecurityConfig 中进行配置

// 角色控制
.antMatchers("/main1.html").hasRole("aaa")
    							// 这里绝对不能写 ROLE_ ,只需要写 _后面的值就可以,严格区分大小写,否则会有 403 权限不够的错误。
5.3 基于 IP 地址
// Ip 地址控制
.antMatchers("/main1.html").hasIpAddress("127.0.0.1")
5.4 基于 Access

上面用到的访问控制的方法都可以使用 access 表达式来实现

// access
.antMatchers("/main1.html").access("hasRole('abc')")

官网地址:https://docs.spring.io/spring-security/site/docs/5.5.4/reference/html5/#el-common-built-in

SpringSecurity+JWT+OAuth2

5.5 自定义 Access
  1. 创建 MyService 接口
public interface MyService {

    boolean hasPermission(HttpServletRequest request, Authentication authentication);

}
  1. 接口实现类
/**
 * @author lishisen
 * @description 自定义 access 访问控制
 * @date 2021/12/27 12:44
 **/
@Service
public class MyServiceImpl implements MyService {

    @Override
    public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
        // 获取主体
        Object obj = authentication.getPrincipal();
        // 判断主体是否属于 UserDetails
        if (obj instanceof UserDetails) {
            // 获取权限
            UserDetails userDetails = (UserDetails) obj;
            Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
            // 判断请求的 URI 是否在权限里
            return authorities.contains(new SimpleGrantedAuthority(request.getRequestURI()));
        }
        return false;
    }
}
  1. 在 SpringSecurityConfig 中使用自定的 access 访问控制
// 授权
http.authorizeRequests()
        // 放行 /error.html,不需要认证
        .antMatchers("/error.html").permitAll()
        // 放行 /login.html ,不需要认证
        .antMatchers("/login.html").permitAll()
        // 放行静态资源
        .antMatchers("/images/**", "/css/**").permitAll()
        .antMatchers("/**/*.png").permitAll()
        // 使用自定义的 access 访问控制
        .anyRequest().access("@myServiceImpl.hasPermission(request, authentication)");
  1. 启动测试后会发现没有权限不能访问,因为这个自定义中得到的 URI 在授权的时候没有给与权限 /main.html

    在 UserDetailsServiceImpl 中给与权限即可

return new User(username, password,
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin,normal,ROLE_aaa,/main.html"));

6. 基于注解的访问控制

Spring Security 中提供了一些访问控制的注解。这些注解都是默认是都不可用的,需要通过 @EnableGlobalMethodSecurity 进行开启后使用

如果设置的条件允许,程序正常执行,如果不允许会报 500 错误。

这些注解可以写到 Service 接口或方法上,也可以写到 Controller 或 Controller 的方法上。通常情况下都是写在控制器方法上的,控制接口 URL 是否允许被访问。

6.1 @Secured

专门用于判断是否具有角色的,能写在方法或类上,参数要以 ROLE_ 开头

使用:

  1. 在启动类上开启注解
@SpringBootApplication
@EnableGlobalMethodSecurity(securedEnabled = true) // 默认是false,需要开启才能使用
public class SecurityApplication {

    public static void main(String[] args) {
        SpringApplication.run(SecurityApplication.class, args);
    }

}
  1. 在请求上使用注解
/**
  * @description 登录
  * @author lishisen
  * @date 2021/12/27 8:15
 **/
@Secured("ROLE_aaa")	// 判断请求是否有这个角色
@RequestMapping("/toMain")
public String login() {
    return "redirect:main.html";
}

6.2 @PreAuthorize/@PostAuthorize

都是方法或类级别注解

  • @PreAuthorize 表示访问方法或类在执行之前先判断权限,大多情况下都是使用这个注解,注解的参数和 access() 方法参数取值相同,都是权限表达式
  • @PostAuthorize 表示方法或类执行结束后判断权限,此注解很少被使用

使用:

  1. 在启动类开启注解
@EnableGlobalMethodSecurity(prePostEnabled = true)
  1. 在 Controller 的请求方法上使用进行判断
/**
  * @description 登录
  * @author lishisen
  * @date 2021/12/27 8:15
 **/
// 允许角色以 ROLE_ 开头,也可以不以 ROLE_ 开头,严格区分大小写
@PreAuthorize("hasRole('aaa')")
@RequestMapping("/toMain")
public String login() {
    return "redirect:main.html";
}

7. RememberMe 功能实现

SpringSecurity 中 RememberMe 为“记住我” 功能,用户只需要在登录时添加 remember-me 复选框,取值为 true。SpringSecurity 会自动把用户信息存储到数据源中,以后就可以不登录进行访问。

使用:

  1. 添加依赖

    Spring Security 实现 RememberMe 功能时底层实现依赖 Spring-JDBC ,所以需要导入 Spring-JDBC。以后多使用 MyBatis 框架而很少直接导入 Spring-JDBC,所以导入 MyBatis 启动器同时添加 MySQL 驱动。

<!--MyBatis 启动器-->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.0</version>
</dependency>
<!--MySQL 数据库依赖-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.16</version>
</dependency>
  1. 配置数据源
spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/security?serverTimezone=UTC&zeroDateTimeBehavior=CONVERT_TO_NULL&allowMultiQueries=true
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  1. PersistentTokenRepository 配置类
@Bean
public PersistentTokenRepository persistentTokenRepository() {
    JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
    // 设置数据源
    jdbcTokenRepository.setDataSource(dataSource);
    // 自动建表,第一次使用,第二次关闭
    //jdbcTokenRepository.setCreateTableOnStartup(true);
    return jdbcTokenRepository;
}
  1. 在 SpringSecurityConfig 中使用 http.rememberMe()
// 记住我
http.rememberMe()
        // 设置数据源
        .tokenRepository(persistentTokenRepository)
        // 超时时间,默认两周
        .tokenValiditySeconds(60)
        // 自定义登录逻辑
        .userDetailsService(userDetailsService);

当我们登录的时候,在数据库中的表中都会记录一条记录,上面记录了最后一次登录的时间,如果下次访问网页的时候时间过了就需要重新进行一下登录。

8. Thymeleaf 中使用 SpringSecurity

SpringSecurity 可以在一些视图技术中进行控制显示效果。例如:jsp 或者 thymeleaf。在非前后端分离且使用 Spring Boot 的项目中多使用 Thyemeleaf 作为视图展示技术。

Thymeleaf 对 Spring Security 的支持都放在 thymeleaf-extras-springsecurityX 中,所以使用的时候需要引入此 jar 包依赖和 thymeleaf 的依赖。

8.1 获取属性

可以在 html 页面中通过 sec:authentication="" 获取 UsernamePasswordAuthenticationToken 中所有 getXXX 的内容,包含父类中的 getXXX 的内容。

  • name:登录账号名称
  • principal:登录主体,在自定义登录逻辑中是 UserDetails
  • credentials:凭证
  • authorities:权限和角色
  • details:实际上是 WebAuthenticationDetails 的实例。可以获取 remoteAddress(客户端 ip)和 sessionId(当前 sessionId)
  1. 引入依赖
<!--Thymeleaf-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--Thyemeleaf SpringSecurity5 依赖-->
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
  1. 在 html 页面中引入 thymeleaf 命名空间和 security 命名空间
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5"
  1. 在 templates 文件夹下创建 demo.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      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>
</body>
</html>
  1. 在 controller 下写接口 /demo
@RequestMapping("/demo")
public String toDemo() {
    return "demo";
}
  1. 访问测试

    首先访问登录上去,然后访问 localhost:8080/demo

    可以看到一些授权的信息

SpringSecurity+JWT+OAuth2

8.2 权限判断

设置用户角色和权限

设定用户具有 admin、/insert、/delete 权限 ROLE_abc 角色

// 在 UserDetailsServiceImpl 类中设置权限
return new User(username, password, AuthorityUtils.commaSeparatedStringToAuthorityList("admin,normal,ROLE_aaa,/main.html,/insert/delete"));

在页面中根据用户权限和角色判断页面中显示的内容

通过权限判断:
<button sec:authorize="hasAuthority('/insert')">新增</button>
<button sec:authorize="hasAuthority('delete')">删除</button>
<button sec:authorize="hasAuthority('update')">修改</button>
<button sec:authorize="hasAuthority('select')">查看</button>
<br>
通过角色判断:
<button sec:authorize="hasRole('abc')">新增</button>
<button sec:authorize="hasRole('abc')">删除</button>
<button sec:authorize="hasRole('abc')">修改</button>
<button sec:authorize="hasRole('abc')">查看</button>

启动测试:

SpringSecurity+JWT+OAuth2

9. 退出登录

只需要向 Spring Security 项目中发送一个 /logout 请求就可以了

修改之前的 main.html 页面,添加一个退出链接请求,运行后点击退出按钮就可以退出了

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>登录成功</h1>
<br>
<h1><a href="main1.html"> 跳转</a></h1>
<br>
<a href="/logout"> 退出</a>
</body>
</html>

SpringSecurity+JWT+OAuth2

退出后地址栏的 url 效果不是很好,可以在 SpringSecurityConfig 中添加配置

SpringSecurity+JWT+OAuth2

http.logout()
        // 自定义的退出请求路径,一般默认即可
        .logoutUrl("/logout")
        // 退出成功后跳转的地址
        .logoutSuccessUrl("/login.html");

10. SpringSecurity 中的 CSRF

CSRF(Cross-site request forgery)跨站请求伪造,也被称为“OneClick Attack” 或者 Session Riding。通过伪造用户请求访问受信任站点的非法请求访问。

跨域:只要网络协议,ip 地址,端口中任何一个不相同就是跨域请求

客户端与服务进行交互时,由于 http 协议本身是无状态协议,所以引入了 cookie 进行记录客户端身份。在 cookie 中会存放 session id 用来识别客户端身份的。在跨域的情况下,session id 可能被第三方恶意胁持,通过这个 session id 向服务端发起请求时,服务端会认为这个请求是合法的,可能发生很多意想不到的事情。

从 Spring Security 4 开始 CSRF 防护默认开启。默认会拦截请求。进行 CSRF 处理。CSRF 为了保证不是其他第三方网站访问,要求访问时携带参数名为 _csrf 值为 token(token 在服务端产生) 的内容,如果 token 和服务端的 token 匹配成功,则正常访问。

简单说就是我拿到了别人的 session id 去替别人做一些事情

使用:

  1. 在 templates 文件夹下创建一个 login.html 登陆模板
<!DOCTYPE html>
<html lang="en" 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">
    <!--这里一个隐藏域用于存放 csrf的值,在发起登录请求的时候会携带一个 _csrf 的键值对参数,这个值是在进入该页面之前后台发送过来的-->
    <input type="hidden" th:value="${_csrf.token}" name="_csrf" th:if="${_csrf}">
    用户名:<input type="text" name="username"><br>
    密码:&nbsp;&nbsp;<input type="password" name="password"><br>
    <button type="submit">登录</button>
</form>
</body>
</html>
  1. 编写一个 Controller 请求
/**
  * @description 跳转到 template 里面的login页面
  * @author lishisen
  * @date 2021/12/28 8:27
 **/
@RequestMapping("/showLogin")
public String toLogin() {
    return "login";
}
  1. 在 SpringSecurityConfig 中开启 crsf 防护
// 注释掉
// 关闭 csrf 防护
//http.csrf().disable();
  1. 启动测试,如果没有设置存放 token 的那个 input 为隐藏域,则进入 login.html 后可以看到接受到的 token 的值

SpringSecurity+JWT+OAuth2

提交登录请求后,在控制台可以看到请求的参数会携带 _crsf

SpringSecurity+JWT+OAuth2

后台会进行对比是否是自己给出的 token 的值,如果是则是安全的请求,如果不是则会以为是一个恶意的攻击请求,会阻止请求。

11. Oauth2 认证

11.1 Oauth2 简介

第三方认证技术方案最主要是解决认证协议的通用标准问题,因为要实现跨系统认证,各系统之间要遵循一定的接口协议。

OAUTH 协议为用户资源的授权提供了一个安全的、开放而又简易的标准。同时,任何第三方都可以使用 OAUTH 认证服务,任何服务提供商都可以实现自身的 OAUTH 认证服务,因而 OAUTH 是开放的。业界提供了 OAUTH 的多种实现如 PHP、JavaScript、Java、Ruby 等各种语言开发包,大大节约了程序员的时间,因而 OAUTH 是简易的。互联网很多服务如 Open API,很多大公司如 Google,Yahoo,Microsoft 等都提供了 Oauth 认证服务,这些足以证明 OAUTH 标准逐渐称为开放资源授权的标准。

与以往的授权方式不同之处是OAUTH的授权不会使第三方触及到用户的帐号信息(如用户名与密码),即第三方无需使用用户的用户名与密码就可以申请获得该用户资源的授权,因此OAUTH是安全的。oAuth是Open Authorization的简写。

百度百科:https://baike.baidu.com/item/oAuth/7153134?fr=aladdin

OAUTH协议:https://datatracker.ietf.org/doc/html/rfc6749

网站使用微信认证的过程:

SpringSecurity+JWT+OAuth2

  1. 资源拥有者同意给客户端授权

资源拥有者扫描二维码表示资源拥有者同意给客户端授权,微信会对资源拥有者的身份进行验证,验证通过后,微信会询问用户是否给授权网站访问自己的微信数据,用户点击“确认登录”表示同意授权,微信认证服务器会颁发一个授权码,并重定向到网站。

  1. 客户端获取到授权码,请求认证服务器申请令牌

此过程用户看不到,客户端应用程序请求认证服务器,请求携带授权码

  1. 认证服务器向客户端响应令牌

认证服务器验证了客户端请求的授权码,如果合法则给客户端颁发令牌,令牌是客户端访问资源的通行证。此交互过程用户看不到,当客户端拿到令牌后,用户在网站看到已经登录成功。

  1. 客户端请求资源服务器的资源

客户端携带令牌访问资源服务器的资源。网站携带令牌请求访问微信服务器获取用户的基本信息。

  1. 资源服务器返回受保护资源

资源服务器校验令牌的合法性,如果合法则向用户响应资源信息内容。

注意:资源服务器和认证服务器可以是一个服务器也可以分开的服务,如果是分开的服务资源服务器通常要请求认证服务器来校验令牌的合法性。

Oauth2 认证流程:

SpringSecurity+JWT+OAuth2

  • 客户端:本身不存放资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:Android 客户端、Web 客户端(浏览器端)、微信客户端等
  • 资源拥有者:通常为用户,也可以是应用程序,即该资源的拥有者
  • 授权服务器(认证服务器):用来对资源拥有的身份进行认证、对访问资源进行授权。客户端要想访问资源需要通过认证服务器由资源拥有者授权后方可访问
  • 资源服务器:存储资源的服务器,比如:网站用户管理服务器存储了网站用户信息,网站相册服务器存储了用户的相册信息,微信的资源服务存储了微信的用户信息等。客户端最终访问资源服务器获取资源信息。

常用术语:

  • 客户凭证(client Credentials):客户端的 clientId 和密码用于认证客户
  • 令牌(tokens):授权服务器在接收到客户请求后,颁发的访问令牌
  • 作用域(scopes):客户请求访问令牌时,由资源拥有者额外指定的细分权限(permission)

令牌类型:

  • 授权码:仅用于授权码授权类型,用于交换获取访问令牌和刷新令牌
  • 访问令牌:用于代表一个用户或服务直接去访问受保护的资源
  • 刷新令牌:用于去授权服务器获取一个刷新访问令牌
  • Bearer Token:不管谁拿到 Token 都可以访问资源,类似现金
  • Proof of Possession(PoP)Token:可以校验 client 是否对 Token 有明确的拥有权

特点:

  • 优点:安全,客户端不接触用户密码,服务器端更易集中保护、广泛传播并被持续采用、短寿命和封装的token、资源服务器和授权服务器解耦、集中式授权,简化客户端、HTTP/JSON 友好易于请求和传递 token、考虑多种客户端架构场景、客户可以具有不同的信任级别。
  • 缺点:协议框架太广泛,造成各种实现的兼容性和互操作性差、不是一个认证协议,本身并不能告诉你任何用户信息、

11.2 授权模式

11.2.1 授权码模式(Authorization Code)

SpringSecurity+JWT+OAuth2

11.2.2 简化授权模式(Implicit)

SpringSecurity+JWT+OAuth2

11.2.3 密码模式(Resource Owner PasswordCredentials)

SpringSecurity+JWT+OAuth2

11.2.4 客户端模式(client Credentials)

SpringSecurity+JWT+OAuth2

11.3 刷新令牌

SpringSecurity+JWT+OAuth2

12. Spring Security Oauth2

12.1 授权服务器

SpringSecurity+JWT+OAuth2

  • Authorize Endpoint:授权端点,进行授权
  • Token Endpoint:令牌端点,经过授权拿到对应的 Token
  • Introspection Endpoint:校验端点,校验 Token 的合法性
  • Revocation Endpoint:撤销端点,撤销授权(如果有恶意会撤销)

12.2 架构

SpringSecurity+JWT+OAuth2

流程:

  1. 用户访问,此时没有 Token。Oauth2RestTemplate 会报错,这个报错信息会被 Oauth2ClientContextFilter 捕获并重定向到认证服务器
  2. 认证服务器通过 Authorization Endpoint 进行授权,并通过 AuthorizationServerTokenServices 生成授权码并返回给客户端
  3. 客户端拿到授权码去认证服务器通过 Token Endpoint 调用 AuthorizationServerTokenServices 生成 Token 并返回给客户端
  4. 客户端拿到 Token 去资源服务器访问资源,一般会通过 Oauth2AuthticationManager 调用 ResourceServerTokenServices 进行校验,校验通过可以获取资源。

12.3 授权码模式的代码演示

  1. 创建一个项目,引入依赖
<properties>
    <java.version>1.8</java.version>
    <spring-cloud.version>Greenwich.SR6</spring-cloud.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
  1. 创建一个 User 的实体类,之前使用的是 security 内置的 User 类,也可以自定义的,实现 UserDetails 接口
public class User implements UserDetails {

    private String username;
    private String password;
    private List<GrantedAuthority> authorities;

    public User(String username, String password, List<GrantedAuthority> authorities) {
        this.username = username;
        this.password = password;
        this.authorities = authorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
  1. 创建一个 UserService 类,用于 user 登录之后进行授权使用
@Service
public class UserService implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        String password = passwordEncoder.encode("123456");
        return new User(username, password, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}
  1. 创建 SecurityConfig 类,做权限认证
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/oauth/**", "/login/**", "/logout/**")
                .permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .formLogin()
                .permitAll()
                .and()
                .csrf().disable();
    }
}
  1. 创建一个授权服务器的配置类
/**
 * @author lishisen
 * @description 授权服务器
 * @date 2021/12/28 11:03
 **/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                // 客户端ID
                .withClient("client")
                // 密钥
                .secret(passwordEncoder.encode("111222333"))
                // 重定向地址
                .redirectUris("http://www.baidu.com")
                // 授权范围
                .scopes("all")
                //授权模式: authorization_code:授权码模式
                .authorizedGrantTypes("authorization_code");
    }
}
  1. 创建一个资源服务器的配置类
/**
 * @author lishisen
 * @description 资源服务器
 * @date 2021/12/28 11:10
 **/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                // 所有请求都去认证
                .anyRequest()
                .authenticated()
                .and()
                // 认证资源
                .requestMatchers()
                .antMatchers("/user/**");
    }
}
  1. 启动测试运行

    地址栏访问:http://localhost:8080/oauth/authorize?response_type=code&client_id=admin&redirect_uri=http://www.baidu.com&scope=all,进入 Security 内置的登录页面

SpringSecurity+JWT+OAuth2

​ 点击 Authorize 按钮之后在地址栏可以看到一个授权码

SpringSecurity+JWT+OAuth2

使用 postman 请求地址:http://localhost:8080/oauth/token

携带以下参数

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3JBKgvQL-1640697518420)(D:\notes\SpringSecurity\Spirng Security 基础知识点总结.assets\image-20211228130950887.png)]

结果得到一个进入的 token:access_token

得到 token 之后,使用 postman 请求 http://localhost:8080/user/getCurrentUser 资源地址,并且携带 token参数,得到 Controller 中返回的结果

SpringSecurity+JWT+OAuth2

12.4 密码模式

修改上面的授权服务器的配置类

重写方法:configure(AuthorizationServerEndpointsConfigurer endpoints)

@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserService userService;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    endpoints.authenticationManager(authenticationManager).userDetailsService(userService);
}

// 在 SecurityConfig 中注册这个 Bean
@Bean
public AuthenticationManager authenticationManager() throws Exception {
    return super.authenticationManager();
}

授权控制器的授权模式中增加一项 password

SpringSecurity+JWT+OAuth2

启动测试:

在请求的时候携带 username 和 password,结果会有一个 token,通过这个 token 可以访问到资源的信息

SpringSecurity+JWT+OAuth2

12.5 使用 Redis 存储 Token

  1. 导入依赖
<!--Redis 依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- commons-pool2 对象依赖-->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>
  1. yml 配置 Redis 的地址
spring.redis.host=127.0.0.1
  1. 创建 RedisConfig 配置类
@Configuration
public class RedisConfig {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    @Bean
    public TokenStore redisTokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }
}
  1. 修改授权服务器 AuthorizationServerConfig 里面的密码模式,将 token 存储到 redis 中
@Autowired
@Qualifier("redisTokenStore")
private TokenStore tokenStore;

/**
  * @description 密码模式
  * @author lishisen
  * @date 2021/12/28 13:44
 **/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
    endpoints.authenticationManager(authenticationManager).userDetailsService(userService)
    .tokenStore(tokenStore);
}
  1. 启动测试

    正常的使用用户名和密码进行请求,然后查看 Redis 中

SpringSecurity+JWT+OAuth2

SpringSecurity+JWT+OAuth2

13. JWT

13.1 常见的认证机制

  1. HTTP Basic Auth

HTTP Basic Auth 简单点说明就是每次请求 API 时都提供用户的 username和 password,Basic Auth 是配合 RESTful API 使用的最简单的认证方式,只需提供用户密码即可,但由于把用户名密码暴露给第三方客户端的风险,在生产环境下被使用的越来越少,因此,在开发对外开放的 RESTful API 时,尽量避免采用 HTTP Basic Auth。

  1. Cookie Auth

Cookie 认证机制就是为一次请求认证在服务端创建一个 Sessiion 对象,同时在客户端的浏览器端创建了一个 Cookie 对象;通过客户端带上来 Cookie 对象来与服务器端的 session 对象匹配来实现状态管理的。默认的,当我们关闭浏览器器的时候,cookie 会被删除。但可以通过修改 cookie 的 expire time 使 cookie 在一定时间内有效。

  1. OAuth

OAuth(开放授权,Open Authorization)是一个开放的授权标准,允许用户让第三方应用访问该用户在某一 web 服务上存储的私密资源(如照片、视频、联系人列表),而无需将用户名和密码提供给第三方应用。如网站通过微信、微博登录等,主要用于第三方登录。

OAuth 允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的第三方系统(例如:视频编辑网站)在特定的时段(例如,接下来的2个小时内)内访问特定的资源(例如,仅仅是某一相册中的视频)。这样 OAuth 让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。

  1. Token Auth

使用基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录,大概的流程是这样的:

  • 客户端使用用户名跟密码请求登录
  • 服务端收到请求,去验证用户名与密码
  • 验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端
  • 客户端收到 Token 以后可以把他存储起来,比如放在 Cookie 里
  • 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token
  • 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据

SpringSecurity+JWT+OAuth2

Token Auth 的优点(Token 机制相对于 Cookie 机制又有什么好处?)

  1. 支持跨域访问:Cookie 是不允许跨域访问的,这一点对 Token 机制是不存在的,前提是传输的用户认证信息通过 HTTP 头传输。
  2. 无状态(也称:服务端可扩展行):Token 机制在服务端不需要存储 Session 信息,因为 Token 自身包括了所有登录用户的信息,只需要在客户端的 Cookie 或本地介质存储状态信息
  3. 更实用 CDN:可以通过内容分发网络请求你服务端的所有资料(如:javascript,HTML,图片等),而你的服务端只要提供 API 即可
  4. 去耦:不需要绑定到一个特定的身份验证方案。Token 可以在任何地方生成,只要在你的 API 被调用的时候,你可以进行 Token 生成调用即可。
  5. 更适用于移动应用:当你的客户端是一个原生平台(IOS、Android、Windows 10 等)时,Cookie 是不被支持的(你需要通过 Cookie 容器进行处理),这时采用 Token 认证机制就会简单的多。
  6. CSRF:因为不再依赖于 Cookie,所以你就不需要考虑对 CSRF(跨站请求伪造)的防范
  7. 性能:一次网络往返时间(通过数据库查询 session 信息)总比做一次 HMACSHA256 计算的 Token 验证和解析要费时的多。
  8. 不需要为登录页面做特殊处理,如果你使用 Protractor 做功能测试的时候,不再需要为登录页面做特殊处理
  9. 基于标准化,你的 API 可以采用标准化的 JSON Web Token(JWT),这个标准已经存在多个后端库(.NET,Ruby,Java,Python,PHP) 和多家公司的支持(Firebase,Google,Microsoft)

13.2 JWT 简介

什么是 JWT?

JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),他定义了一种简介的、自包含的协议格式,用于在通信双方传递 json 对象,传递的信息经过数字签名可以被验证和信任。JWT 可以使用 HMAC 算法或使用 RSA 的公钥/私钥对来签名,防止被篡改。

JWT令牌的优点: jwt 基于 json,非常方便解析;可以在令牌中自定义丰富的内容,易扩展;通过非对称加密算法及数字签名技术,JWT 防止篡改,安全性高;资源服务使用 JWT 可不依赖认证服务即可完成授权。

缺点: JWT 令牌较长,占存储空间比较大

**JWT 组成:**一个 JWT 实际上就是一个字符串,三部分组成:头部、载荷与签名。

  • 头部(Header):

头部用于描述关于该 JWT 的最基本的信息,例如其类型(即 JWT)以及签名所用的算法(如 HMAC SHA 256 或 RSA)等,这也可以被表示成一个 JSON 对象。

{
    "alg": "HS256",
    "typ": "JWT"
}
  • typ :是类型
  • alg :签名的算法,这里使用的算法是 HS256 算法

对头部的 json 字符串进行 BASE64 编码,编码后的字符串如下: ewogICAgImFsZyI6ICJIUzI1NiIsCiAgICAidHlwIjogIkpXVCIKfQ==

BASE64 是一种基于 64 个可打印字符来表示二进制数据的表示方法,由于 2 的 6 次方等于 64,所以每 6 个比特为一个单元,对应某个可打印字符。三个字节有 24 个比特,对应与 4 个 BASE64 单元,即 3 个字节需要 4 个可打印字符来表示。JDK 中提供了非常方便的 BASE64Encoder 和 BASE64Decoder,用他们可以非常方便的完成基于 BASE64 的编码和解码。

  • 负载(Payload)

第二部分是负载,就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包括三个部分:

  1. 标准中注册的声明(建议但不强制使用)
iss: jwt 签发者
sub: jwt 所面向的用户
aud: 接受 jwt 的一方
exp: jwt 的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该 jwt 都是不可用的
iat: jwt 的签发时间
jti: jwt 的唯一身份标识,主要用来作为一次性 token,从而回避重放攻击。
  1. 公共的生命

公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密。

  1. 私有的声明

私有声明是 提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为 base64 是对称解密的,意味着该部分信息可以归类为明文信息

这个指的就是自定义的 claim。比如下面那个举例中的 name 都属于自定义的claim。这些 claim 跟 JWT 标准规定的 claim 区别在于:JWT 规定的 claim,JWT 的接收方在拿到 JWT 之后,都知道怎么对这些标准的 claim 进行验证(还不知道是否能够验证);而 private claims 不会验证,除非明确告诉接收方要对这些 claim 进行验证以及规则才行。

{
    "sub": "1234567890",
    "name": "John Doe",
    "iat": 1516239022
}

其中 sub 是标准的声明,name 是自定义的声明(公共的或私有的),然后将其进行 base64 编码,得到 Jwt 的第二部分

ewogICAgInN1YiI6ICIxMjM0NTY3ODkwIiwKICAgICJuYW1lIjogIkpvaG4gRG9lIiwKICAgICJpYXQiOiAxNTE2MjM5MDIyCn0=

这个声明中尽量不要放一些敏感的信息

  • 签证、签名(signature)

JWT 的第三部分是一个签证信息,这个签证信息由三个部分组成:

  1. header(base64 后的)
  2. payload(base64 后的)
  3. secret(盐,一定要保密的)

这个部分需要 base64 加密后的 header 和base64 加密后的 payload 使用,连接组成的字符串,然后通过 header 中声明的加密方式进行加盐 secret 组合加密,然后就构成了 jwt 的第三部分。

将这三部分用 . 连接成一个完成的字符串,构成了最终的 jwt

注意:secret 是保存在服务器端的,jwt 的签发生成也是在服务器端的,secret 就是用来进行 jwt 的签发和 jwt 的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个 secret,那就意味着客户端是可以自我签发 jwt 了。

13.3 JJWT 简介

JJWT 是一个提供端到端的JWT 创建和验证的 Java 库,永远免费和开源(Apache License,版本 2.0),JJWT 很容易使用和理解。它被设计成一个以建筑为中心的流畅界面,隐藏了它的大部分复杂性。

规范官网:https://jwt.io/

13.3.1 加密
  1. 创建一个 Spring Boot 项目,导入以下依赖
<dependency>
	<groupId>io.jsonwebtoken</groupId>
	<artifactId>jjwt</artifactId>
	<version>0.9.0</version>
</dependency>
  1. 测试类
@Test
void contextLoads() {
	JwtBuilder jwtBuilder = Jwts.builder()
			// 唯一ID{“id": "888"}
			.setId("888")
			// 接收的用户 {"sub": "Zhangsan"}
			.setSubject("Zhangsan")
			// 签发时间 {"iat": "..."}
			.setIssuedAt(new Date())
			// 签名算法,及密钥
			.signWith(SignatureAlgorithm.HS256, "xxxx");
	String compact = jwtBuilder.compact();
	System.out.println(compact);
	System.out.println("--------------------------------");
	String[] split = compact.split("\\.");
	System.out.println(Base64Codec.BASE64.decodeToString(split[0]));
	System.out.println(Base64Codec.BASE64.decodeToString(split[1]));
	System.out.println(Base64Codec.BASE64.decodeToString(split[2]));
}

SpringSecurity+JWT+OAuth2

13.3.2 解密
// 解析 token
@Test
void tokenParse() {
	String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODgiLCJzdWIiOiJaaGFuZ3NhbiIsImlhdCI6MTY0MDY3ODAzM30.42FtM2rhB0DU-X2OdnMHw8Gdy5-jKYuL4UuC35JIe_A";
	// 解析 token,获取 Claims jwt 中荷载申明的对象
	Claims xxxx = (Claims) Jwts.parser()
			// 密钥
			.setSigningKey("xxxx")
			.parse(token)
			.getBody();
	System.out.println(xxxx.getId());
	System.out.println(xxxx.getSubject());
	System.out.println(xxxx.getIssuedAt());
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-syRFiygC-1640697518424)(D:\notes\SpringSecurity\Spirng Security 基础知识点总结.assets\image-20211228160609766.png)]

13.3.3 token 过期检验

很多时候 token 是不能够永久生效的,所以需要给下发的 token 添加一个过期时间。原因:从服务器发出的 token,服务器自己并不做记录,就存在一个弊端就是,服务端无法主动控制某 token 的立刻生效。

/**
  * @description 带有过期时间的jwt
  * @author lishisen
  * @date 2021/12/28 16:13
 **/
@Test
void JwtHasExipre() {
	// 当前时间
	long date = System.currentTimeMillis();
	// 失效时间
	long exp = date + 60 * 1000;
	JwtBuilder jwtBuilder = Jwts.builder()
			// 唯一ID{“id": "888"}
			.setId("888")
			// 接收的用户 {"sub": "Zhangsan"}
			.setSubject("Zhangsan")
			// 签发时间 {"iat": "..."}
			.setIssuedAt(new Date())
			// 签名算法,及密钥
			.signWith(SignatureAlgorithm.HS256, "xxxx")
        	// 设置失效时间
			.setExpiration(new Date(exp));
	String compact = jwtBuilder.compact();
	System.out.println(compact);
	System.out.println("--------------------------------");
	String[] split = compact.split("\\.");
	System.out.println(Base64Codec.BASE64.decodeToString(split[0]));
	System.out.println(Base64Codec.BASE64.decodeToString(split[1]));
	System.out.println(Base64Codec.BASE64.decodeToString(split[2]));
}

// 失效后访问令牌会抛出异常
io.jsonwebtoken.ExpiredJwtException: JWT expired at 2021-12-28T16:18:21Z. Current time: 2021-12-28T16:18:28Z, a difference of 7072 milliseconds.  Allowed clock skew: 0 milliseconds.
13.3.4 自定义的声明
// 以键值对的方式进行声明
.claim("name", "zhao")
.claim("photo","aaa.jpg");

// 解析,通过键取
System.out.println(xxxx.get("name"));
System.out.println(xxxx.get("photo"));

13.4 SpringSecurity 使用 JWT 令牌

在上面的 SpringSecurity 2 中使用的是 Redis 保存的授权码,换成 JWT 之后不在需要使用 Redis 进行存储了,因为 JWT 是无状态的。

使用:

  1. 创建一个 JwtTokenStoreConfig 配置类
/**
 * @author lishisen
 * @description JWT 令牌配置
 * @date 2021/12/28 16:33
 **/
@Configuration
public class JwtTokenStoreConfig {
    @Bean
    public TokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        // 设置 jwt 密钥
        jwtAccessTokenConverter.setSigningKey("test_key");
        return  jwtAccessTokenConverter;
    }
}
  1. 在授权服务器中导入并且进行转化
@Autowired
@Qualifier("jwtTokenStore")
private TokenStore tokenStore;

@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;

/**
   * @description 密码模式
   * @author lishisen
   * @date 2021/12/28 13:44
  **/
 @Override
 public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
     endpoints.authenticationManager(authenticationManager).userDetailsService(userService)
      // accessToken转成JWTtoken
     .tokenStore(tokenStore)
     .accessTokenConverter(jwtAccessTokenConverter);
 }
  1. 启动测试

SpringSecurity+JWT+OAuth2

SpringSecurity+JWT+OAuth2

13.5 JWT 拓展

  1. 创建一个 JwtTokenEnhancer 类实现 TokenEnhancer 接口
/**
 * @author lishisen
 * @description TODO
 * @date 2021/12/28 17:18
 **/
public class JwtTokenEnhancer implements TokenEnhancer {
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
        // 放一些扩展的信息
        HashMap<String, Object> map = new HashMap<>();
        map.put("enhance","enhancer info");
        ((DefaultOAuth2AccessToken)oAuth2AccessToken).setAdditionalInformation(map);
        return oAuth2AccessToken;
    }
}


// 需要向容器中注入一个这个bean
@Bean
public JwtTokenEnhancer jwtTokenEnhancer() {
    return new JwtTokenEnhancer();
}
  1. 在授权服务器中设置增强内容
@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;

@Autowired
private JwtTokenEnhancer jwtTokenEnhancer; 

/**
   * @description 密码模式
   * @author lishisen
   * @date 2021/12/28 13:44
  **/
 @Override
 public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
     // 设置增强内容
     TokenEnhancerChain chain = new TokenEnhancerChain();
     ArrayList<TokenEnhancer> delegates = new ArrayList<>();
     delegates.add(jwtTokenEnhancer);
     delegates.add(jwtAccessTokenConverter);
     chain.setTokenEnhancers(delegates);
     endpoints.authenticationManager(authenticationManager).userDetailsService(userService)
      // accessToken转成JWTtoken
     .tokenStore(tokenStore)
     .accessTokenConverter(jwtAccessTokenConverter)
     .tokenEnhancer(chain);
 }
  1. 运行测试

SpringSecurity+JWT+OAuth2

SpringSecurity+JWT+OAuth2

13.6 解析Token

修改 /user/getCurrentUser 请求用于得到 Token 进行解析

@RestController
@RequestMapping("/user")
public class UserController {

    @RequestMapping("/getCurrentUser")
    public Object getCurrentUser(Authentication authentication, HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        String token = header.substring(header.lastIndexOf("bearer") + 7);
        return Jwts.parser()
                .setSigningKey("test_key".getBytes(StandardCharsets.UTF_8))
                .parseClaimsJws(token)
                .getBody();
    }

}

使用 postman 进行请求

首先获得令牌

SpringSecurity+JWT+OAuth2

请求的时候设置请求头

SpringSecurity+JWT+OAuth2

13.7 刷新令牌

首先在授权服务器AuthorizationServerConfig配置中设置令牌的过期时间

public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients.inMemory()
            // 客户端ID
            .withClient("client")
            // 密钥
            .secret(passwordEncoder.encode("111222333"))
            // 重定向地址
            .redirectUris("http://www.baidu.com")
            // 设置令牌过期时间
            .accessTokenValiditySeconds(60)
            // 刷新令牌失效时间
            .refreshTokenValiditySeconds(6000)
            // 授权范围
            .scopes("all")
            //授权模式: authorization_code:授权码模式
        	// "refresh_token" 设置刷新令牌
            .authorizedGrantTypes("authorization_code","password","refresh_token");
}
  • 设置令牌过期时间
    .accessTokenValiditySeconds(60)
  • 刷新令牌失效时间
    .refreshTokenValiditySeconds(6000)
  • “refresh_token” 设置刷新令牌

运行测试的时候,当我们请求令牌的时候就会多一个刷新令牌的键值对

SpringSecurity+JWT+OAuth2

当令牌过期的时候我们使用之前的令牌就不能进行访问了

然后通过设置参数和上面得到的刷新令牌进行访问就可以得到新的令牌了

SpringSecurity+JWT+OAuth2

14. 单点登录

14.1 简介

单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

SpringSecurity+JWT+OAuth2

14.2 OAuth2 整合单点登录(SSO)

单点系统需要有一个单独的认证系统,所有的应用系统都需要去找这个认证系统。

认证系统有两个非常重要的作用:

  1. 验证用户登录的用户名和密码,验证成功返回 token
  2. 验证 cookie

使用之前的一个认证系统 security-oauth2 工程中的 AuthorizationServerConfig(认证系统/授权服务器)

修改 认证系统的 AuthorizationServerConfig 配置类

// 重定向地址
.redirectUris("http://localhost:8081")
// 自动授权
.autoApprove(true)

// 重写方法
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
    // 获取密钥必须要身份认证,单点登录必须要配置
    security.tokenKeyAccess("isAuthenticated()");
}
  1. 创建一个登录系统 oauth2client01,导入依赖
<properties>
    <java.version>1.8</java.version>
    <spring-cloud.version>Greenwich.SR6</spring-cloud.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.0</version>
    </dependency>
</dependencies>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
  1. yml 配置文件
server:
  port: 8081
  # 防止 Cookie 冲突,冲突会导致登录验证不通过
  servlet:
    session:
      cookie:
        name: OAUTH2-CLIENT-SESSIONID01
  # 授权服务器地址
oauth2-server-url: http://localhost:8080
# 授权服务器对应的配置
security:
  oauth2:
    client:
      client-id: client
      client-secret: 111222333
      user-authorization-uri: ${oauth2-server-url}/oauth/authorize
      access-token-uri: ${oauth2-server-url}/oauth/token
    resource:
      jwt:
        key-uri: ${oauth2-server-url}/oauth/token_key
  1. 创建一个 Controller 请求路径
@RestController
@RequestMapping("/user")
public class UserController {

    @RequestMapping("/getCurrentUser")
    public Object getCurrentUser(Authentication authentication) {
        return authentication;
    }
}
  1. 在主启动类上加上注解 @EnableOAuth2Sso
@SpringBootApplication
// 开启单点登录
@EnableOAuth2Sso
public class Oauth2client01Application {
    public static void main(String[] args) {
        SpringApplication.run(Oauth2client01Application.class, args);
    }
}
  1. 启动测试

    首先启动认证系统服务,然后启动客户端服务

    地址栏访问 http://localhost:8081/user/getCurrentUser 的时候会自动跳转到 http://localhost:8080/login 请求

    这里的用户名密码是认证服务器 UserService 中配置的 UserDetails

SpringSecurity+JWT+OAuth2

账号密码输入正确认证通过后又会跳转到 http://localhost:8081/user/getCurrentUser,单点登录成功

SpringSecurity+JWT+OAuth2

本视频参考学习自:https://www.bilibili.com/video/BV1CY411p7dT?p=1

上一篇:OAuth2前置知识-JWT相关


下一篇:实战!基于Security+JWT的单点登陆开发及原理解析