SpringSecurity

Cookie

保存在客户端

限制

浏览器为每个站点保存的cookie数最多是20个,而且每个cookie最大是4K

同样的Cookie的内容的字符限制针对不同的Cookie版本也有不同。在Cookie Version 0中,某些特殊的字符,例如:空格,方括号,圆括号,等于号(=),逗号,双引号,斜杠,问号,@符号,冒号,分号都不能作为Cookie的内容

作用

  • 会话管理:登录,购物车,游戏得分或者服务器应该记住的其他内容
  • 个性化:偏好设置,主题或其他设置
  • 追踪:记录和分析用户行为

Cookie曾经用于一般的客户端存储。是合法的,因为他们是客户端上存储数据的唯一方法。如今,建议使用现代存储API。Cookie随每个请求一起发送,因此他们可能会降低性能(尤其是对于移动数据连接而言)。客户端存储的现代API是Web存储APIIndexedDB

HTTP/2.0 200 OK
Content-type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry

随着对服务器的每个新请求,浏览器将使用Cookie头将所有以前存储的Cookie发送给服务器

GET /sample_page.html HTTP/2.0
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry

Cookie主要分为三类,会话Cookie永久CookieCookie的Secure和HttpOnly标记

会话Cookie

上面的例子就是会话Cookie,因为没有设置Expires或Max-Age。客户端关闭Cookie就会删除

永久Cookie

Set-Cookie:id=a3fWa;Expires=Wed, 21 Oct 2015 07:28:00 GMT;

到设置的时间之后才会过期

Cookie的HttpOnly

安全的cookie需要经过https协议通过加密的方法发送到服务器。即使时安全的,也不应该将敏感信息存储在cookie中,因为他们本质上是不安全的,并且此标志不能提供真正的保护

HttpOnly

  • 会话cookie中缺少HttpOnly属性会导致攻击者可以通过程序(JS脚本、Applet等)获取到用户的cookie信息,造成用户cookie信息泄露,增加攻击者的跨站脚本攻击威胁
  • HttpOnly是微软对cookie做的扩展,该值指定cookie是否可以通过客户端脚本访问
  • 如果在cookie中没有设置HttpOnly属性为true,可能导致cookie被窃取。窃取的cookie可以包含标识站点用户的敏感信息,如ASP.NET会话ID或Forms身份验证凭证,攻击者可以传播窃取的cookie,以便伪装成用户获取敏感信息,进行跨站脚本攻击等

设置了HttpOnly为true之后,js脚本将无法读取到cookie信息,有效防止XSS攻击全称Cross SiteScript,跨站脚本 攻击

//设置多个cookie
response.addHeader("Set-Cookie", "uid=112; Path=/; HttpOnly");
response.addHeader("Set-Cookie", "timeout=30; Path=/test; HttpOnly");

//设置https的cookie
response.addHeader("Set-Cookie", "uid=112; Path=/; Secure; HttpOnly");

cookie的使用

cookie返回给客户端

@GetMapping("/change-username")
public String setCookie(HttpServletResponse response) {
    // 创建一个 cookie
    Cookie cookie = new Cookie("username", "Jovan");
    //设置 cookie过期时间
    cookie.setMaxAge(7 * 24 * 60 * 60); // expires in 7 days
    //添加到 response 中
    response.addCookie(cookie);

    return "Username is changed!";
}

或者用@CookieValue

@GetMapping("/")
public String readCookie(@CookieValue(value = "username", defaultValue = "Atta") String username) {
    return "Hey! My username is " + username;
}

springboot中获取cookie

@GetMapping("/all-cookies")
public String readAllCookies(HttpServletRequest request) {

    Cookie[] cookies = request.getCookies();
    if (cookies != null) {
        return Arrays.stream(cookies)
                .map(c -> c.getName() + "=" + c.getValue()).collect(Collectors.joining(", "));
    }

    return "No cookies";
}

@GetMapping("/cookieTest")
    public String setCookie(HttpServletResponse response) {
        // 创建一个 cookie
        Cookie cookie = new Cookie("c1", "v1");
        //设置 cookie过期时间
        cookie.setMaxAge(7 * 24 * 60 * 60); // expires in 7 days
        // 作用域
        cookie.setDomain("localhost");
        cookie.setPath("/cookieTest");
        //添加到 response 中
        response.addCookie(cookie);
        return "cookie1";
    }
DOCTYPE html>


<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
	<meta charset="UTF-8">
	<title>Titletitle>
head>
<body>
	<div id="cookie">div>
	<button onclick="checkCookie()">checkCookiebutton>
	<button onclick="setCookie('c2', 'v2', 1)">setCookiebutton>
body>
<script>
	let cookieValue = document.cookie;
	document.querySelector("#cookie").innerText = cookieValue;

	function setCookie(cname, cvalue, exdays){
		let d = new Date();
		d.setTime(d.getTime() + (exdays * 24 * 60 * 60));
		let expires = "expires=" + d.toGMTString();
		document.cookie = cname + "=" + cvalue + ";" + expires;
	}

	function getCookie(cname){
		let name = cname + "=";
		let ca = document.cookie.split(';');
		for(let i = 0; i < ca.length; i++) {
			let c = ca[i].trim();
			if (c.indexOf(name) == 0) {
				return c.substring(name.length,c.length);
			}
		}
		return "";
	}

	function checkCookie(){
		let c1 = getCookie("c1");
		if (c1 != ""){
			alert("欢迎 " + c1 + " 再次访问");
		}
	}
script>
html>

Cookie的作用域

DomainPath标识定义了cookie的作用域,即Cookie应该发送给哪些URL

Domain标识指定了哪些主机可以接受Cookie,如果不指定,默认为当前主机(不包含子域名),如果指定了Domain,则一般包含子域名

例如,设置Domain=mozilla.org,则Cookie也包含在子域名中developer.mozilla.org

例如,设置Path=/docs,则一下地址都会匹配

  • /docs
  • /docs/web/
  • /docs/web/http

Session-Cookie身份验证

很多时候我们都是通过 SessionID 来实现特定的用户,SessionID 一般会选择存放在 Redis 中

关于这种认证方式更详细的过程如下:

  1. 用户向服务器发送用户名、密码、验证码用于登陆系统。
  2. 服务器验证通过后,服务器为用户创建一个 Session,并将 Session 信息存储起来。
  3. 服务器向用户返回一个 SessionID,写入用户的 Cookie
  4. 当用户保持登录状态时,Cookie 将与每个后续请求一起被发送出去。
  5. 服务器可以将存储在 Cookie 上的 SessionID 与存储在内存中或者数据库中的 Session 信息进行比较,以验证用户的身份,返回给用户客户端响应信息的时候会附带用户当前的状态

使用 Session 的时候需要注意下面几个点:

  1. 依赖 Session 的关键业务一定要确保客户端开启了 Cookie
  2. 注意 Session 的过期时间 20min-30min

多服务器节点下 Session-Cookie

Session-Cookie 方案在单体环境是一个非常好的身份认证方案,但多节点不适合

举个例子:假如我们部署了两份相同的服务 A,B,用户第一次登陆的时候 ,Nginx 通过负载均衡机制将用户请求转发到 A 服务器,此时用户的 Session 信息保存在 A 服务器。结果,用户第二次访问的时候 Nginx 将请求路由到 B 服务器,由于 B 服务器没有保存 用户的 Session 信息,导致用户需要重新进行登陆。

我们应该如何避免上面这种情况的出现呢?

  1. 某个用户的所有请求都通过特性的哈希策略分配给同一个服务器处理。这样的话,每个服务器都保存了一部分用户的 Session 信息。服务器宕机,其保存的所有 Session 信息就完全丢失了
  2. 每一个服务器保存的 Session 信息都是互相同步的,也就是说每一个服务器都保存了全量的 Session 信息。每当一个服务器的 Session 信息发生变化,我们就将其同步到其他服务器。这种方案成本太大,并且,节点越多时,同步成本也越高。
  3. 单独使用一个所有服务器都能访问到的数据节点(比如redis缓存)来存放 Session 信息。为了保证高可用,数据节点尽量要避免是单点

没有Cookie Session能用

经典面试题

一般是通过 Cookie 来保存 JSESSIONID ,假如你使用了 Cookie 保存 JSESSIONID 的方案的话, 如果客户端禁用了 Cookie,那么 Session 就无法正常工作

但是,并不是没有 Cookie 之后就不能用 Session 了,比如你可以将 jsessionid 放在请求的 url 里面https://javaguide.cn/;jsessionid=xxx。这种方案的话可行,但是安全性和用户体验感降低。当然,为了你也可以对 jsessionid 进行一次加密之后再传入后端 使用@MatrixVariable 矩阵变量

  • 通过url重写,把 sessionid 作为参数追加的原 url 中,后续的浏览器与服务器交互中携带 sessionid 参数,也可以对jsessionid加密
@Controller
public class CookieController {

    // 禁用cookie后 session怎么用
    @RequestMapping("/cookie1")
    public String cookie1(HttpServletRequest request, HttpServletResponse response,
                          @CookieValue(value = "JSESSIONID") String jsessionid){
        //把user信息保存进session
        request.getSession().setAttribute("user",123);
        if (!StringUtils.isEmpty(jsessionid)) {
            System.out.println("cookie没有被禁用");
            request.setAttribute("cookie2Url", "/cookie2");
        } else {
            //encodeURL方法为url重写方法,重写后会在原来的基础上追加jsessionid 例: /cookie2;jsessionid=DE8EA4AC8934B2BCB5FC8AC5805BFCAD
            request.setAttribute("cookie2Url", response.encodeURL("/cookie2"));
        }
        return "cookie1";
    }

    @RequestMapping("/cookie2")
    public String cookie2(HttpServletRequest request){
        System.out.println("user :" + request.getSession().getAttribute("user"));
        return "cookie2";
    }

}

cookie1

DOCTYPE html>


<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
	<meta charset="UTF-8">
	<title>Titletitle>
head>
<body>
	<div th:text="${session.user}">div>
 	<a th:href="@{${cookie2Url}}">我的cookie2a>
body>
html>

cookie2

DOCTYPE html>


<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
	<meta charset="UTF-8">
	<title>Titletitle>
head>
<body>
	<div th:text="${session.user}">div>
body>
html>

CSRF

**CSRF(Cross Site Request Forgery)**一般被翻译为 跨站请求伪造

小壮登录了某网上银行,他来到了网上银行的帖子区,看到一个帖子下面有一个链接写着“科学理财,年盈利率过万”,小壮好奇的点开了这个链接,结果发现自己的账户少了 10000 元。这是这么回事呢?原来黑客在链接中藏了一个请求,这个请求直接利用小壮的身份给银行发送了一个转账请求,也就是通过你的 Cookie 向银行发出请求。

科学理财,年盈利率过万Copy to clipboardErrorCopied

如果别人通过 Cookie 拿到了 SessionId 后就可以代替你的身份访问系统了

需要注意的是不论是 Cookie 还是 Token 都无法避免 跨站脚本攻击(Cross Site Scripting)XSS

跨站脚本攻击(Cross Site Scripting)缩写为 CSS 但这会与层叠样式表(Cascading Style Sheets,CSS)的缩写混淆。因此,有人将跨站脚本攻击缩写为 XSS

XSS 中攻击者会用各种方式将恶意代码注入到其他用户的页面中。就可以通过脚本盗用信息比如 Cookie

推荐阅读:如何防止 CSRF 攻击?—美团技术团队

https://snailclimb.gitee.io/javaguide/#/docs/system-design/authority-certification/basis-of-authority-certification

Session

保存在服务端

​ sessionid 是一个会话的 key,浏览器第一次访问服务器会在服务器端生成一个 session,有一个 sessionID 和它对应,并返回给浏览器,这个 sessionID 会被保存在浏览器的会话 cookie 中。tomcat 生成的 sessionID 叫做jsessionid

客户端只保存 sessionID 到 cookie 中,而不会保存 session。session 不会因为浏览器的关闭而删除

session在下列情况下被删除

  1. 程序调用HttpSession.invalidate()
  2. 距离上一次收到客户端发送的session id时间间隔超过了session的最大有效时间
  3. 服务器进程被停止

localStorage sessionStorage

早些时候,本地存储使用的是 cookie。但是Web 存储需要更加的安全与快速. 这些数据不会被保存在服务器上,但是这些数据只用于用户请求网站数据上.它也可以存储大量的数据,而不影响网站的性能。数据以 键/值 对存在, web网页的数据只允许该网页访问使用

Internet Explorer 8+, Firefox, Opera, Chrome, 和 Safari支持Web 存储。注意: Internet Explorer 7 及更早IE版本不支持web 存储

客户端存储数据的两个对象为:

  • localStorage - 用于长久保存整个网站的数据,保存的数据没有过期时间,直到手动去除

  • sessionStorage - 用于临时保存同一窗口(或标签页)的数据,在关闭窗口或标签页之后将会删除这些数据。

  • localStorage只要在相同的协议、相同的主机名、相同的端口下,就能读取/修改到同一份localStorage数据。

  • sessionStoragelocalStorage更严苛一点,除了协议、主机名、端口外,还要求在同一窗口(也就是浏览器的标签页)下。

生存期

localStorage理论上来说是永久有效的,即不主动清空的话就不会消失,即使保存的数据超出了浏览器所规定的大小,也不会把旧数据清空而只会报错。但需要注意的是,在移动设备上的浏览器或各Native App用到的WebView里,localStorage都是不可靠的,可能会因为各种原因(比如说退出App、网络切换、内存不足等原因)被清空。
sessionStorage的生存期顾名思义,类似于session,只要关闭浏览器(也包括浏览器的标签页),就会被清空。由于sessionStorage的生存期太短,因此应用场景很有限,但从另一方面来看,不容易出现异常情况,比较可靠。

数据结构

localstorage为标准的键值对(Key-Value,简称KV)数据类型,简单但也易扩展,只要以某种编码方式把想要存储进localstorage的对象给转化成字符串,就能轻松支持。举点例子:把对象转换成json字符串,就能让存储对象了;把图片转换成DataUrl(base64),就可以存储图片了。另外对于键值对数据类型来说,"键是唯一的"这个特性也是相当重要的,重复以同一个键来赋值的话,会覆盖上次的值

过期时间

很遗憾,localstorage原生是不支持设置过期时间的,想要设置的话,就只能自己来封装一层逻辑来实现:

function set(key,value){
  var curtime = new Date().getTime();//获取当前时间
  localStorage.setItem(key,JSON.stringify({val:value,time:curtime}));//转换成json字符串序列
}
function get(key,exp)//exp是设置的过期时间
{
  var val = localStorage.getItem(key);//获取存储的元素
  var dataobj = JSON.parse(val);//解析出json对象
  if(new Date().getTime() - dataobj.time > exp)//如果当前时间-减去存储的元素在创建时候设置的时间 > 过期时间
  {
    console.log("expires");//提示过期
  }
  else{
    console.log("val="+dataobj.val);
  }
}

容量限制

目前业界基本上统一为5M,已经比cookies的4K要大很多了,省着点用吧骚年

域名限制

由于浏览器的安全策略,localstorage是无法跨域的,也无法让子域名继承父域名的localstorage数据,这点跟cookies的差别还是蛮大的

异常处理

localstorage在目前的浏览器环境来说,还不是完全稳定的,可能会出现各种各样的bug,一定要考虑好异常处理。我个人认为localstorage只是资源本地化的一种优化手段,不能因为使用localstorage就降低了程序的可用性,那种只是在console里输出点错误信息的异常处理我是绝对反对的。localstorage的异常处理一般用try/catch来捕获/处理异常

如何测试用户当前浏览器是否支持localstorage

目前普遍的做法是检测window.localStorage是否存在,但某些浏览器存在bug,虽然"支持"localstorage,但在实际过程中甚至可能出现无法setItem()这样的低级bug。因此我建议,可以通过在try/catch结构里set/get一个测试数据有无出现异常来判断该浏览器是否支持localstorage,当然测试完后记得删掉测试数据哦。

在使用 web 存储前,应检查浏览器是否支持 localStorage 和sessionStorage

<div id="result">div>

<script>
if(typeof(Storage)!=="undefined")
{
  localStorage.sitename="菜鸟教程";
  document.getElementById("result").innerHTML="网站名:" + localStorage.sitename;
}
else
{
  document.getElementById("result").innerHTML="对不起,您的浏览器不支持 web 存储。";
}
script>

删除localstorage中的sitename

localStorage.removeItem("sitename");

sessionStorage的方法和localStorage一样

  • 保存数据:localStorage.setItem(key,value);
  • 读取数据:localStorage.getItem(key);
  • 删除单个数据:localStorage.removeItem(key);
  • 删除所有数据:localStorage.clear();
  • 得到某个索引的key:localStorage.key(index);

作用域

  • localStorage只要在相同的协议、相同的主机名、相同的端口下,就能读取/修改到同一份localStorage数据
  • sessionStoragelocalStorage更严苛一点,除了协议、主机名、端口外,还要求在同一窗口(也就是浏览器的标签页)下

springsecurity

快速入门

本质是一个过滤器链,有很多过滤器

<dependency>
	<groupId>org.springframework.bootgroupId>
	<artifactId>spring-boot-starter-securityartifactId>
dependency>
spring:
  # Spring Security 配置项,对应 SecurityProperties 配置类
  security:
    # 配置默认的 InMemoryUserDetailsManager 的用户账号与密码。
    user:
      name: user # 账号
      password: user # 密码
      roles: ADMIN # 拥有角色
  • spring.security 配置项,设置 Spring Security 的配置,对应 SecurityProperties 配置类
  • 默认情况下,Spring BootUserDetailsServiceAutoConfiguration自动化配置类,会创建一个内存级别的InMemoryUserDetailsManagerBean 对象,提供认证的用户信息。
    • 这里,我们添加了 spring.security.user 配置项,UserDetailsServiceAutoConfiguration 会基于配置的信息创建一个用户 User 在内存中。
    • 如果,我们未添加 spring.security.user 配置项,UserDetailsServiceAutoConfiguration 会自动创建一个用户名为 "user" ,密码为 UUID 随机的用户 User 在内存中

SecurityProperties 里面


		/**
		 * Default user name.
		 */
		private String name = "user";

		/**
		 * Password for the default user name.
		 */
		private String password = UUID.randomUUID().toString();
@RestController
@RequestMapping("/admin")
public class AdminController {

    @GetMapping("/demo")
    public String demo() {
        return "示例返回";
    }

}

访问 http://127.0.0.1:8080/admin/demo ,因为没有登录,所以输入用户名user ,密码user

登录

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5oxNXK1d-1649940170691)(img/image-20211215094003288.png)]

Spring Security中的 UserDetailsService 接口,PasswordEncoder接口

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}
public interface PasswordEncoder {
    String encode(CharSequence var1);

    boolean matches(CharSequence var1, String var2);

    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}
PasswordEncoder p = new BCryptPasswordEncoder();
String s = p.encode("123");
boolean matches = p.matches("123", s); // true

BCryptPasswordEncoder

登录参数


<form action="/login" method="post">
 user: <input type="text" name="username" />
 pwd: <input type="password" name="password" />
 rememberMe: <input type="checkbox" name="remember-me" value="true"/>
 <input type="submit" value="登录"/>
form>

自定义登录成功处理器

public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private String url;

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

    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest,
                                        HttpServletResponse httpServletResponse,
                                        Authentication authentication) throws IOException, ServletException {
        User principal = (User)authentication.getPrincipal();
        System.out.println(principal.getUsername());
        // 为null
        System.out.println(principal.getPassword());
        // 权限
        System.out.println(principal.getAuthorities());
        // forward用于服务器内的,redirect是客户端
//        httpServletRequest.getRequestDispatcher(this.url).forward(httpServletRequest, httpServletResponse);
        httpServletResponse.sendRedirect(this.url);
    }
}

自定义登录失败处理器

在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常

  • 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理

  • 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理

  • 所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可

public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {

    private String url;

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

    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest,
                                        HttpServletResponse httpServletResponse,
                                        AuthenticationException e) throws IOException, ServletException {
        httpServletRequest.setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, e);
        httpServletRequest.getRequestDispatcher(this.url).forward(httpServletRequest, httpServletResponse);
    }
}

ExpressionUrlAuthorizationConfigurer

访问控制方法

static final String permitAll = "permitAll";
private static final String denyAll = "denyAll";
private static final String anonymous = "anonymous"; // anonymous和permitAll类似
private static final String authenticated = "authenticated";
private static final String fullyAuthenticated = "fullyAuthenticated";
private static final String rememberMe = "rememberMe";// 记住我

过滤器

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gu8SPDfe-1649940170692)(SpringSecurity.assets/image-20211214144425527.png)]

UsernamePasswordAuthenticationFilter

认证

ExceptionTranslationFilter

处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException

FilterSecurityInterceptor

负责权限校验的过滤器

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AcAXIlmi-1649940170692)(SpringSecurity.assets/image-20220407110157269.png)]

认证流程

  • Authentication接口:实现类,表示当前用户,封装用户信息
  • AuthenticationManager接口:定义了认证Authentication的方法
  • UserDetailsService接口:加载用户特定数据的核心接口。里面自定义了loadUserByUsername
  • UserDetails接口:提供核心用户信息,根据UserDetailsService的loadUserByUsername返回UserDetails,然后封装到Authentication中

登录

  • 自定义UserDetailsService,数据库中验证用户

  • 登录成功后把用户信息存入redis

自定义jwt认证过滤器

  • 获取token
  • 解析token获取其中的userId
  • 从redis中获取用户信息
  • 存入SecurityContextHolder,(上下文,给后面其他过滤器链使用)

授权

总结起来就是不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。

​ 我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为如果只是这样,如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。后端必须判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作

授权基本流程

​ 在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。

​ 所以我们在项目中只需要把当前登录用户的权限信息也存入Authentication。

​ 然后设置我们的资源所需要的权限即可。

权限控制

public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.
            //  使用内存中的 InMemoryUserDetailsManager
            inMemoryAuthentication()
            //  不使用 PasswordEncoder 密码编码器
            .passwordEncoder(NoOpPasswordEncoder.getInstance())
            //  配置 admin 用户
            .withUser("admin").password("admin").roles("ADMIN")
            //  配置 normal 用户
            .and().withUser("normal").password("normal").roles("NORMAL");
    }
}
  • 处,调用AuthenticationManagerBuilder#inMemoryAuthentication()方法,使用内存级别的InMemoryUserDetailsManager

    Bean 对象,提供认证的用户信息。

    • Spring 内置了两种UserDetailsManager

      实现:

      • InMemoryUserDetailsManager,和快速入门一样
      • JdbcUserDetailsManager ,基于 JDBC的 JdbcUserDetailsManager 。
    • 实际项目中,我们更多采用调用 AuthenticationManagerBuilder#userDetailsService(userDetailsService) 方法,使用自定义实现的 UserDetailsService 实现类,更加灵活自由的实现认证的用户信息的读取

  • 处,调用AbstractDaoAuthenticationConfigurer#passwordEncoder(passwordEncoder)方法,设置 PasswordEncoder 密码编码器。

    • 在这里,为了方便,我们使用 NoOpPasswordEncoder 。实际上,等于不使用 PasswordEncoder ,不配置的话会报错。
    • 生产环境下,推荐使用 BCryptPasswordEncoder 。推荐阅读《该如何设计你的 PasswordEncoder?》文章。
  • 处,配置了「admin/admin」和「normal/normal」两个用户,分别对应 ADMIN 和 NORMAL 角色

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        //  配置请求地址的权限
        .authorizeRequests()
        .antMatchers("/test/echo").permitAll() // 所有用户可访问
        .antMatchers("/test/admin").hasRole("ADMIN") // 需要 ADMIN 角色
        .antMatchers("/test/normal").access("hasRole('ROLE_NORMAL')") // 需要 NORMAL 角色。
        // 任何请求,访问的用户都需要经过认证
        .anyRequest().authenticated()
        .and()
        //  设置 Form 表单登录
        .formLogin()
        // .loginPage("/login") // 登录 URL 地址
        .permitAll() // 所有用户可访问
        .and()
        // 配置退出相关
        .logout()
        // .logoutUrl("/logout") // 退出 URL 地址
        .permitAll(); // 所有用户可访问
}
  • 处,调用 HttpSecurity#authorizeRequests() 方法,开始配置 URL 的权限控制。注意看艿艿配置的四个权限控制的配置。下面,是配置权限控制会使用到的方法:
    • #(String... antPatterns) 方法,配置匹配的 URL 地址,基于 Ant 风格路径表达式 ,可传入多个。
    • 【常用】#permitAll() 方法,所有用户可访问。
    • 【常用】#denyAll() 方法,所有用户不可访问。
    • 【常用】#authenticated() 方法,登录用户可访问。
    • #anonymous() 方法,无需登录,即匿名用户可访问。
    • #rememberMe() 方法,通过 remember me 登录的用户可访问。
    • #fullyAuthenticated() 方法,非 remember me 登录的用户可访问。
    • #hasIpAddress(String ipaddressExpression) 方法,来自指定 IP 表达式的用户可访问。
    • 【常用】#hasRole(String role) 方法, 拥有指定角色的用户可访问。
    • 【常用】#hasAnyRole(String... roles) 方法,拥有指定任一角色的用户可访问。
    • 【常用】#hasAuthority(String authority) 方法,拥有指定权限(authority)的用户可访问。
    • 【常用】#hasAuthority(String... authorities) 方法,拥有指定任一权限(authority)的用户可访问。
    • 【最牛】#access(String attribute) 方法,当 Spring EL 表达式的执行结果为 true 时,可以访问。
  • 处,调用 HttpSecurity#formLogin() 方法,设置 Form 表单登录
    • 如果胖友想要自定义登录页面,可以通过 #loginPage(String loginPage) 方法,来进行设置。
  • 处,调用HttpSecurity#logout()方法,配置退出相关。
    • 如果胖友想要自定义退出页面,可以通过 #logoutUrl(String logoutUrl) 方法,来进行设置。使用默认的退出界面,所以不进行设置。

开启对 Spring Security 注解的方法,进行权限验证

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
@RestController
@RequestMapping("/demo")
public class DemoController {

    @PermitAll
    @GetMapping("/echo")
    public String demo() {
        return "示例返回";
    }

    @GetMapping("/home")
    public String home() {
        return "我是首页";
    }

    @PreAuthorize("hasRole('ROLE_ADMIN')")
    @GetMapping("/admin")
    public String admin() {
        return "我是管理员";
    }

    @PreAuthorize("hasRole('ROLE_NORMAL')")
    @GetMapping("/normal")
    public String normal() {
        return "我是普通用户";
    }
}
  • @PermitAll 注解,等价于 #permitAll() 方法,所有用户可访问。

    配置了 .anyRequest().authenticated() ,任何请求,访问的用户都需要经过认证。所以这里 @PermitAll 注解实际是不生效的

    也就是说,Java Config 配置的权限,和注解配置的权限,两者是叠加

  • @PreAuthorize 注解,等价于 #access(String attribute) 方法,,当 Spring EL 表达式的执行结果为 true 时,可以访问。

Spring Security 还有其它注解,不过不太常用,可见《区别: @Secured(), @PreAuthorize() 及 @RolesAllowed()》文章。

胖友可以按照如上的说明,进行各种测试。例如说,登录「normal/normal」用户后,去访问 /test/admin 接口,会返回 403 界面,无权限~

@Secured

@EnableGlobalMethodSecurity(securedEnabled = true)

@Secured("ROLE_abc")
@RequestMapping("/toMain")
public String toMain() {
    return "main";
}

数据库查询权限

RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型

简单的rbac例子

CREATE DATABASE /*!32312 IF NOT EXISTS*/`sg_security` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;

USE `sg_security`;

/*Table structure for table `sys_menu` */

DROP TABLE IF EXISTS `sys_menu`;

CREATE TABLE `sys_menu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
  `path` varchar(200) DEFAULT NULL COMMENT '路由地址',
  `component` varchar(255) DEFAULT NULL COMMENT '组件路径',
  `visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
  `status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
  `perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
  `icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',
  `create_by` bigint(20) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(20) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';

/*Table structure for table `sys_role` */

DROP TABLE IF EXISTS `sys_role`;

CREATE TABLE `sys_role` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(128) DEFAULT NULL,
  `role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
  `status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',
  `del_flag` int(1) DEFAULT '0' COMMENT 'del_flag',
  `create_by` bigint(200) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(200) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色表';

/*Table structure for table `sys_role_menu` */

DROP TABLE IF EXISTS `sys_role_menu`;

CREATE TABLE `sys_role_menu` (
  `role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
  `menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',
  PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;

/*Table structure for table `sys_user` */

DROP TABLE IF EXISTS `sys_user`;

CREATE TABLE `sys_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
  `password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
  `status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` varchar(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
  `sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` varchar(128) DEFAULT NULL COMMENT '头像',
  `user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

/*Table structure for table `sys_user_role` */

DROP TABLE IF EXISTS `sys_user_role`;

CREATE TABLE `sys_user_role` (
  `user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
  `role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
  PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

SELECT 
	DISTINCT m.`perms`
FROM
	sys_user_role ur
	LEFT JOIN `sys_role` r ON ur.`role_id` = r.`id`
	LEFT JOIN `sys_role_menu` rm ON ur.`role_id` = rm.`role_id`
	LEFT JOIN `sys_menu` m ON m.`id` = rm.`menu_id`
WHERE
	user_id = 2
	AND r.`status` = 0
	AND m.`status` = 0

自定义权限校验方法

​ 我们也可以定义自己的权限校验方法,在@PreAuthorize注解中使用我们的方法。

@Component("ex")
public class SGExpressionRoot {

    public boolean hasAuthority(String authority){
        //获取当前用户的权限
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        List<String> permissions = loginUser.getPermissions();
        //判断用户权限集合中是否存在authority
        return permissions.contains(authority);
    }
}

​ 在SPEL表达式中使用 @ex相当于获取容器中bean的名字未ex的对象。然后再调用这个对象的hasAuthority方法

    @RequestMapping("/hello")
    @PreAuthorize("@ex.hasAuthority('system:dept:list')")
    public String hello(){
        return "hello";
    }

配置权限

    .antMatchers("/testCors").hasAuthority("system:dept:list222")

认证成功处理器

​ 实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果登录成功了是会调用AuthenticationSuccessHandler的方法进行认证成功后的处理的。AuthenticationSuccessHandler就是登录成功处理器。

​ 我们也可以自己去自定义成功处理器进行成功后的相应处理。

@Component
public class SGSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        System.out.println("认证成功了");
    }
}

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthenticationSuccessHandler successHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin().successHandler(successHandler);

        http.authorizeRequests().anyRequest().authenticated();
    }
}

认证失败处理器

​ 实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果认证失败了是会调用AuthenticationFailureHandler的方法进行认证失败后的处理的。AuthenticationFailureHandler就是登录失败处理器。

​ 我们也可以自己去自定义失败处理器进行失败后的相应处理。

@Component
public class SGFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        System.out.println("认证失败了");
    }
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthenticationSuccessHandler successHandler;

    @Autowired
    private AuthenticationFailureHandler failureHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
//                配置认证成功处理器
                .successHandler(successHandler)
//                配置认证失败处理器
                .failureHandler(failureHandler);

        http.authorizeRequests().anyRequest().authenticated();
    }
}

登出成功处理器

@Component
public class SGLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        System.out.println("注销成功");
    }
}

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthenticationSuccessHandler successHandler;

    @Autowired
    private AuthenticationFailureHandler failureHandler;

    @Autowired
    private LogoutSuccessHandler logoutSuccessHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
//                配置认证成功处理器
                .successHandler(successHandler)
//                配置认证失败处理器
                .failureHandler(failureHandler);

        http.logout()
                //配置注销成功处理器
                .logoutSuccessHandler(logoutSuccessHandler);

        http.authorizeRequests().anyRequest().authenticated();
    }
}

CSRF

跨站请求伪造,通过伪造用户请求访问受信任站点的非法请求访问,sessionid可能会被第三方恶意劫持,通过这个sessionid向服务器发送请求

spring security默认开启CSRF, 要携带key为,_csrf,值为token

​ CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一。

​ https://blog.csdn.net/freeking101/article/details/86537087

​ SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。

​ 我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。

SecurityContextHolder

JWT

常见认证机制

HTTP Basic Auth

HTTP Basic Auth简单点说,就是每次请求api时提供用户的username和password,时配合RESTful API使用的最简单的认证方式。尽量避免使用

Cookie Auth

使用cookie和session

OAuth

具体参考OAhth2,代码spring-cloud-demo

Token

下面详细介绍

Token

JWT (json web token)本质上就一段带签名的 JSON 格式的数据,是一个开放的行业标准RFC 7519

JWT 由 3 部分构成:

  1. Header(头部) : 描述 JWT 的元数据,定义了生成签名的算法以及 Token 的类型

    {
    	"alg": "HS256",
    	"typ": "JWT"
    }
    

    对头部的json字符串进行BASE64编码

  2. Payload(载荷) : 用来存放实际需要传递的数据
    标准中注册的声明
    https://datatracker.ietf.org/doc/html/draft-ietf-oauth-json-web-token-32

    iss:jwt签发者
    sub:jwt所面向的用户
    aud:接收jwt的一方
    exp:jwt的过期时间,这个过期时间必须大于签发时间
    nbf:定义在什么时间之前,该jwt都是不可用的
    iat:jwt的签发时间
    jti:jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击
    

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rCmfh1oa-1649940170693)(SpringSecurity.assets/image-20220402154915686.png)]

  3. Signature(签名) :服务器通过PayloadHeader和一个密钥(secret)使用 Header 里面指定的签名算法(默认是 HMAC SHA256)也可以是RSA 生成
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EpCUDnLu-1649940170693)(SpringSecurity.assets/image-20220402155747321.png)]
    .分隔三个部分

签名的目的

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9UsAP24r-1649940170693)(SpringSecurity.assets/2f01f181f1c4943982e4a0dafda07bd3.png)]

服务器应用在接受到JWT后,会首先对头部和载荷的内容用同一算法再次签名。那么服务器应用是怎么知道我们用的是哪一种算法呢?别忘了,我们在JWT的头部中已经用alg字段指明了我们的加密算法了。

如果服务器应用对头部和载荷再次以同样方法签名之后发现,自己计算出来的签名和接受到的签名不一样,那么就说明这个Token的内容被别人动过的,我们应该拒绝这个Token,返回一个HTTP 401 Unauthorized响应

信息会暴露

所以,在JWT中,不应该在载荷里面加入任何敏感的数据。在上面的例子中,我们传输的是用户的User ID。这个值实际上不是什么敏感内容,一般情况下被知道也是安全的

但是像密码这样的内容就不能被放在JWT中了。如果将用户的密码放在了JWT中,那么怀有恶意的第三方通过Base64解码就能很快地知道你的密码了

Token验证原理

在基于 Token 进行身份验证的的应用程序中,服务器通过PayloadHeader和一个密钥(secret)创建令牌(Token)并将 Token 发送给客户端,客户端将 Token 保存在 Cookie 或者 localStorage 里面,以后客户端发出的所有请求都会携带这个令牌。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP Header 的Authorization 字段中 Authorization: Bearer Token

基于Token的身份验证是无状态的,我们不将用户信息存在服务器或Session中

这种概念解决了在服务端存储信息时的许多问题

NoSession意味着你的程序可以根据需要去增减机器,而不用去担心用户是否登录

基于Token的身份验证的过程如下

SpringSecurity_第1张图片
  1. 用户通过用户名和密码发送请求
  2. 程序验证
  3. 程序返回一个签名的token 给客户端
  4. 客户端储存token,并且每次用于每次发送请求
  5. 服务端验证token并返回数据

每一次请求都需要token。token应该在HTTP的头部发送从而保证了Http请求无状态。我们同样通过设置服务器属性Access-Control-Allow-Origin:*,让服务器能接受到来自所有域的请求。

注意:在ACAO头部标明(designating)*时,不得带有像HTTP认证,客户端SSL证书和cookies的证书

当我们在程序中认证了信息并取得token之后,能通过这个Token做许多的事情。甚至能基于创建一个基于权限的token传给第三方应用程序,这些第三方程序能够获取到我们的数据(当然只有在我们允许的特定的token)

JWT的优缺点

无状态

token 自身包含了身份验证所需要的所有信息,使得我们的服务器不需要存储 Session 信息,这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。但是,也正是由于 token 的无状态,也导致了它最大的缺点:当后端在token 有效期内废弃一个 token 或者更改它的权限的话,不会立即生效,一般需要等到有效期过后才可以。另外,当用户 Logout 的话,token 也还有效。除非,我们在后端增加额外的处理逻辑。

有效避免了CSRF 攻击

CSRF(Cross Site Request Forgery) 一般被翻译为 跨站请求伪造,属于网络攻击领域范围。相比于 SQL 脚本注入、XSS等安全攻击方式,CSRF 的知名度并没有它们高。但是,它的确是每个系统都要考虑的安全隐患,就连技术帝国 Google 的 Gmail 在早些年也被曝出过存在 CSRF 漏洞,这给 Gmail 的用户造成了很大的损失。

那么究竟什么是 跨站请求伪造 呢?说简单用你的身份去发送一些对你不友好的请求。举个简单的例子:

小壮登录了某网上银行,他来到了网上银行的帖子区,看到一个帖子下面有一个链接写着“科学理财,年盈利率过万”,小壮好奇的点开了这个链接,结果发现自己的账户少了10000元。这是这么回事呢?原来黑客在链接中藏了一个请求,这个请求直接利用小壮的身份给银行发送了一个转账请求,也就是通过你的 Cookie 向银行发出请求。

<a src="http://www.mybank.com/Transfer?bankId=11&money=10000">科学理财,年盈利率过万a>

导致这个问题很大的原因就是: Session 认证中 Cookie 中的 session_id 是由浏览器发送到服务端的,借助这个特性,攻击者就可以通过让用户误点攻击链接,达到攻击效果。

那为什么 token 不会存在这种问题呢?

我是这样理解的:一般情况下我们使用 JWT 的话,在我们登录成功获得 token 之后,一般会选择存放在 local storage 中。然后我们在前端通过某些方式会给每个发到后端的请求加上这个 token,这样就不会出现 CSRF 漏洞的问题。因为,即使你点击了非法链接发送了请求到服务端,这个非法请求是不会携带 token 的,所以这个请求将是非法的。

但是这样会存在 XSS 攻击中被盗的风险,为了避免 XSS 攻击,你可以选择将 token 存储在标记为httpOnly 的cookie 中。但是,这样又导致了你必须自己提供CSRF保护。

具体采用上面哪种方式存储 token 呢,大部分情况下存放在 local storage 下都是最好的选择,某些情况下可能需要存放在标记为httpOnly 的cookie 中会更好。

适合移动端应用

使用 Session 进行身份认证的话,需要保存一份信息在服务器端,而且这种方式会依赖到 Cookie(需要 Cookie 保存 SessionId),所以不适合移动端。

但是,使用 token 进行身份认证就不会存在这种问题,因为只要 token 可以被客户端存储就能够使用,而且 token 还可以跨语言使用

单点登录友好

使用 Session 进行身份认证的话,实现单点登录,需要我们把用户的 Session 信息保存在一台电脑上,并且还会遇到常见的 Cookie 跨域的问题。但是,使用 token 进行认证的话, token 被保存在客户端,不会存在这些问题

负载可以包含用户一些信息,避免多次查数据库

jwt跨语言

1.注销登录等场景下 token 还有效

与之类似的具体相关场景有:

  1. 退出登录;
  2. 修改密码;
  3. 服务端修改了某个用户具有的权限或者角色;
  4. 用户的帐户被删除/暂停。
  5. 用户由管理员注销;

这个问题不存在于 Session 认证方式中,因为在 Session 认证方式中,遇到这种情况的话服务端删除对应的 Session 记录即可。但是,使用 token 认证的方式就不好解决了。我们也说过了,token 一旦派发出去,如果后端不增加其他逻辑的话,它在失效之前都是有效的。那么,我们如何解决这个问题呢?查阅了很多资料,总结了下面几种方案:

  • 将 token 存入内存数据库:将 token 存入 DB 中,redis 内存数据库在这里是不错的选择。如果需要让某个 token 失效就直接从 redis 中删除这个 token 即可。但是,这样会导致每次使用 token 发送请求都要先从 DB 中查询 token 是否存在的步骤,而且违背了 JWT 的无状态原则。
  • 黑名单机制:和上面的方式类似,使用内存数据库比如 redis 维护一个黑名单,如果想让某个 token 失效的话就直接将这个 token 加入到 黑名单 即可。然后,每次使用 token 进行请求的话都会先判断这个 token 是否存在于黑名单中。
  • 修改密钥 (Secret) : 我们为每个用户都创建一个专属密钥,如果我们想让某个 token 失效,我们直接修改对应用户的密钥即可。但是,这样相比于前两种引入内存数据库带来了危害更大,比如:1) 如果服务是分布式的,则每次发出新的 token 时都必须在多台机器同步密钥。为此,你需要将密钥存储在数据库或其他外部服务中,这样和 Session 认证就没太大区别了。 2) 如果用户同时在两个浏览器打开系统,或者在手机端也打开了系统,如果它从一个地方将账号退出,那么其他地方都要重新进行登录,这是不可取的。
  • 保持令牌的有效期限短并经常轮换 :很简单的一种方式。但是,会导致用户登录状态不会被持久记录,而且需要用户经常登录。

对于修改密码后 token 还有效问题的解决还是比较容易的,说一种我觉得比较好的方式:使用用户的密码的哈希值对 token 进行签名。因此,如果密码更改,则任何先前的令牌将自动无法验证。

2.token 的续签问题

token 有效期一般都建议设置的不太长,那么 token 过期后如何认证,如何实现动态刷新 token,避免用户经常需要重新登录?

我们先来看看在 Session 认证中一般的做法:假如 session 的有效期30分钟,如果 30 分钟内用户有访问,就把 session 有效期延长30分钟。

  1. 类似于 Session 认证中的做法:这种方案满足于大部分场景。假设服务端给的 token 有效期设置为30分钟,服务端每次进行校验时,如果发现 token 的有效期马上快过期了,服务端就重新生成 token 给客户端。客户端每次请求都检查新旧token,如果不一致,则更新本地的token。这种做法的问题是仅仅在快过期的时候请求才会更新 token ,对客户端不是很友好。
  2. 每次请求都返回新 token :这种方案的的思路很简单,但是,很明显,开销会比较大。
  3. token 有效期设置到半夜 :这种方案是一种折衷的方案,保证了大部分用户白天可以正常登录,适用于对安全性要求不高的系统。
  4. 用户登录返回两个 token :第一个是 accessToken ,它的过期时间 token 本身的过期时间比如半个小时,另外一个是 refreshToken 它的过期时间更长一点比如为1天。客户端登录后,将 accessToken和refreshToken 保存在本地,每次访问将 accessToken 传给服务端。服务端校验 accessToken 的有效性,如果过期的话,就将 refreshToken 传给服务端。如果有效,服务端就生成新的 accessToken 给客户端。否则,客户端就重新登录即可。该方案的不足是:1) 需要客户端来配合;2) 用户注销的时候需要同时保证两个 token 都无效;3) 重新请求获取 token 的过程中会有短暂 token 不可用的情况(可以通过在客户端设置定时器,当accessToken 快过期的时候,提前去通过 refreshToken 获取新的accessToken)

优点

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

缺点

  1. 注销登录等场景下 token 还有效
  2. token 的续签问题

cookie session jwt

https://www.iocoder.cn/Fight/Thoroughly-understand-cookies,-sessions,-tokens/?self

session sticky , 就是让小F的请求一直粘连在机器A上, 但是这也不管用, 要是机器A挂掉了, 还得转到机器B去。

那只好做session 的复制了, 把session id 在两个机器之间搬来搬去, 快累死了。

SpringSecurity_第2张图片

后来有个叫Memcached的支了招:把session id 集中存储到一个地方, 所有的机器都来访问这个地方的数据, 这样一来,就不用复制了, 但是增加了单点失败的可能性, 要是那个负责session 的机器挂了, 所有人都得重新登录一遍, 估计得被人骂死。

比如说, 小F已经登录了系统, 我给他发一个令牌(token), 里边包含了小F的 user id, 下一次小F 再次通过Http 请求访问我的时候, 把这个token 通过Http header 带过来不就可以了。

不过这和session id没有本质区别啊, 任何人都可以可以伪造, 所以我得想点儿办法, 让别人伪造不了。

那就对数据做一个签名吧, 比如说我用HMAC-SHA256 算法,加上一个只有我才知道的密钥, 对数据做一个签名, 把这个签名和数据一起作为token , 由于密钥别人不知道, 就无法伪造token了。

SpringSecurity_第3张图片

这个token 我不保存, 当小F把这个token 给我发过来的时候,我再用同样的HMAC-SHA256 算法和同样的密钥,对数据再计算一次签名, 和token 中的签名做个比较, 如果相同, 我就知道小F已经登录过了,并且可以直接取到小F的user id , 如果不相同, 数据部分肯定被人篡改过, 我就告诉发送者:对不起,没有认证。

SpringSecurity_第4张图片

Token 中的数据是明文保存的(虽然我会用Base64做下编码, 但那不是加密), 还是可以被别人看到的, 所以我不能在其中保存像密码这样的敏感信息。

当然, 如果一个人的token 被别人偷走了, 那我也没办法, 我也会认为小偷就是合法用户, 这其实和一个人的session id 被别人偷走是一样的

这样一来, 我就不保存session id 了, 我只是生成token , 然后验证token , 我用我的CPU计算时间获取了我的 session 存储空间

解除了session id这个负担, 可以说是无事一身轻, 我的机器集群现在可以轻松地做水平扩展, 用户访问量增大, 直接加机器就行。这种无状态的感觉实在是太好了!

JJWT

pom


<dependency>
    <groupId>io.jsonwebtokengroupId>
    <artifactId>jjwtartifactId>
    <version>0.9.1version>
dependency>

创建

@Test
    public void testCreateToken() {
        // 创建JwtBuilder对象
        JwtBuilder jwtBuilder = Jwts.builder()
                // 声明的标识 "jti":"8888"
                .setId("8888")
                // 用户 "sub":"Rose"
                .setSubject("Rose")
                // 签发时间"ita":"xx"
                .setIssuedAt(new Date())
                // 算法 和 secret
                .signWith(SignatureAlgorithm.HS256, "xxxx");

        // 获取token
        String token = jwtBuilder.compact();
        System.out.println(token);

        String[] split = token.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]));
    }
eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODg4Iiwic3ViIjoiUm9zZSIsImlhdCI6MTY0ODg4NzI3OH0.Jr3fwC83UDM6ulzDujPBYP_H5yKpXvTWsvC-3mF6SEc
{"alg":"HS256"}
{"jti":"8888","sub":"Rose","iat":164888727
&���/7P3:�\ú3�`��ȪW�5��-���

每次运行都会不一样,因为有签发时间

解析

/**
 * 解析token
 */
@Test
public void testParseToken() {
    String token = "eyJhbGciOiJIUzI1NiJ9" +
            ".eyJqdGkiOiI4ODg4Iiwic3ViIjoiUm9zZSIsImlhdCI6MTY0ODg4NzcwNH0" +
            ".-JnlSmewOw9OPPeG09CA_5EoAmb6BVS3BrQ3-JPtGak";

    // 解析token 获取负载中声明的对象
    Claims claims = Jwts.parser()
            .setSigningKey("xxxx")
            .parseClaimsJws(token)
            .getBody();

    System.out.println(claims.getId());
    System.out.println(claims.getSubject());
    System.out.println(claims.getIssuedAt());

}

token过期校验

/**
 * 创建token,失效时间
 */
@Test
public void testCreateTokenHasExp() {
    long now = System.currentTimeMillis();
    long exp = now + 60 * 1000;
    // 创建JwtBuilder对象
    JwtBuilder jwtBuilder = Jwts.builder()
            // 声明的标识 "jti":"8888"
            .setId("8888")
            // 用户 "sub":"Rose"
            .setSubject("Rose")
            // 签发时间"iat":"xx"
            .setIssuedAt(new Date())
            // 算法 和 secret
            .signWith(SignatureAlgorithm.HS256, "xxxx")
            // 过期时间
            .setExpiration(new Date(exp));

    // 获取token
    String token = jwtBuilder.compact();
    System.out.println(token);

    String[] split = token.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]));
}

 /**
     * 解析token,失效时间
     */
    @Test
    public void testParseTokenHasExp() {
        String token = "eyJhbGciOiJIUzI1NiJ9" +
                ".eyJqdGkiOiI4ODg4Iiwic3ViIjoiUm9zZSIsImlhdCI6MTY0ODg4ODM2MSwiZXhwIjoxNjQ4ODg4NDIxfQ" +
                ".npD0NaSm5odMF6bLizqKpR1ascKilhC86sVeERomlrc";

        // 解析token 获取负载中声明的对象
        Claims claims = Jwts.parser()
                .setSigningKey("xxxx")
                .parseClaimsJws(token)
                .getBody();

        System.out.println(claims.getId());
        System.out.println(claims.getSubject());
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println(simpleDateFormat.format(claims.getIssuedAt()));
        System.out.println(simpleDateFormat.format(claims.getExpiration()));

    }
// 失效之后会报错io.jsonwebtoken.ExpiredJwtException

自定义声明

/**
     * 创建token,自定义声明
     */
    @Test
    public void testCreateTokenCustom() {
        long now = System.currentTimeMillis();
        long exp = now + 60 * 1000;
        // 创建JwtBuilder对象
        JwtBuilder jwtBuilder = Jwts.builder()
                // 声明的标识 "jti":"8888"
                .setId("8888")
                // 用户 "sub":"Rose"
                .setSubject("Rose")
                // 签发时间"iat":"xx"
                .setIssuedAt(new Date())
                // 算法 和 secret
                .signWith(SignatureAlgorithm.HS256, "xxxx")
                // 过期时间
                .setExpiration(new Date(exp))
                // 自定义声明
                .claim("logo", "x.jpg");
//                .addClaims(); // 传入map

        // 获取token
        String token = jwtBuilder.compact();
        System.out.println(token);

        String[] split = token.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]));
    }

    /**
     * 解析token,自定义声明
     */
    @Test
    public void testParseTokenCustom() {
        String token = "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiI4ODg4Iiwic3ViIjoiUm9zZSIsImlhdCI6MTY0ODg4ODgyNCwiZXhwIjoxNjQ4ODg4ODg0LCJsb2dvIjoieC5qcGcifQ.UXtvf0m3DliNN4vLIYPTqFrQtCT3tdJE0bE9TU8E_sM";

        // 解析token 获取负载中声明的对象
        Claims claims = Jwts.parser()
                .setSigningKey("xxxx")
                .parseClaimsJws(token)
                .getBody();

        System.out.println(claims.getId());
        System.out.println(claims.getSubject());
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        System.out.println(simpleDateFormat.format(claims.getIssuedAt()));
        System.out.println(simpleDateFormat.format(claims.getExpiration()));
        // 获取自定义声明
        System.out.println(claims.get("logo"));

    }

java-jwt

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JrATaJBo-1649940170694)(SpringSecurity.assets/image-20220405005005151.png)]

OAuth 2.0

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8MPcpMEj-1649940170694)(SpringSecurity.assets/image-20220329232330005.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gugLeyU7-1649940170694)(SpringSecurity.assets/image-20220329232759051.png)]

参考:https://www.iocoder.cn/Fight/ruanyifeng-oauth_2_0/?self

OAuth 是一个行业的标准授权协议,主要用来授权第三方应用获取有限的权限。而 OAuth 2.0 是对 OAuth 1.0 的完全重新设计,OAuth 2.0 更快,更容易实现,OAuth 1.0 已经被废弃。详情请见:rfc6749。

实际上它就是一种授权机制,它的最终目的是为第三方应用颁发一个有时效性的令牌 Token,使得第三方应用能够通过该令牌获取相关的资源

OAuth 2.0 比较常用的场景就是第三方登录,当你的网站接入了第三方登录的时候一般就是使用的 OAuth 2.0 协议。另外,现在 OAuth 2.0 也常见于支付场景(微信支付、支付宝支付)和开发平台(微信开放平台、阿里开放平台等等)

应用场景

有一个”云冲印”的网站,可以将用户储存在Google的照片,冲印出来。用户为了使用该服务,必须让”云冲印”读取自己储存在Google上的照片。问题是只有得到用户的授权,Google才会同意”云冲印”读取这些照片。那么,”云冲印”怎样获得用户的授权呢?传统方法是,用户将自己的Google用户名和密码,告诉”云冲印”,后者就可以读取用户的照片了。这样的做法有以下几个严重的缺点。

(1)”云冲印”为了后续的服务,会保存用户的密码,这样很不安全

(2)Google不得不部署密码登录,而我们知道,单纯的密码登录并不安全

(3)”云冲印”拥有了获取用户储存在Google所有资料的权力,用户没法限制”云冲印”获得授权的范围和有效期

(4)用户只有修改密码,才能收回赋予”云冲印”的权力。但是这样做,会使得其他所有获得用户授权的第三方应用程序全部失效

(5)只要有一个第三方应用程序被破解,就会导致用户密码泄漏,以及所有被密码保护的数据泄漏

名词定义

(1) Third-party application:第三方应用程序,本文中又称”客户端”(client),即上一节例子中的”云冲印”。

(2)HTTP service:HTTP服务提供商,本文中简称”服务提供商”,即上一节例子中的Google。

(3)Resource Owner:资源所有者,本文中又称”用户”(user)。

(4)User Agent:用户代理,本文中就是指浏览器。

(5)Authorization server:认证服务器,即服务提供商专门用来处理认证的服务器。

(6)Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。

OAuth的思路

OAuth在”客户端”与”服务提供商”之间,设置了一个授权层(authorization layer)。”客户端”不能直接登录”服务提供商”,只能登录授权层,以此将用户与客户端区分开来。”客户端”登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。“客户端”登录授权层以后,”服务提供商”根据令牌的权限范围和有效期,向”客户端”开放用户储存的资料

**运行流程

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-I6EQr3VX-1649940170694)(SpringSecurity.assets/image-20220329234426643.png)]

OAuth 2.0的运行流程如下图,摘自RFC 6749

SpringSecurity_第5张图片

(A)用户打开客户端以后,客户端要求用户给予授权

(B)用户同意给予客户端授权

(C)客户端使用上一步获得的授权,向认证服务器申请令牌

(D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌

(E)客户端使用令牌,向资源服务器申请获取资源

(F)资源服务器确认令牌无误,同意向客户端开放资源

B是关键,即用户怎样才能给于客户端授权。有了这个授权以后,客户端就可以获取令牌,进而凭令牌获取资源

客户端获取授权的四种模式

客户端的授权模式

客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。OAuth 2.0定义了四种授权方式

  • 授权码模式(authorization code)
  • 密码模式(resource owner password credentials)
  • 简化模式(implicit)
  • 客户端模式(client credentials)

密码模式和授权码模式比较常用

授权码模式

授权码模式(authorization code)是功能最完整、流程最严密的授权模式。它的特点就是通过客户端的后台服务器,与”服务提供商”的认证服务器进行互动

SpringSecurity_第6张图片

(A)用户访问客户端,后者将前者导向认证服务器

(B)用户选择是否给予客户端授权

(C)假设用户给予授权,认证服务器将用户导向客户端事先指定的”重定向URI”(redirection URI),同时附上一个授权码

(D)客户端收到授权码,附上早先的”重定向URI”,向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见

(E)认证服务器核对授权码和重定向URI,确认无误后,向客户端发送访问令牌access token和更新令牌refresh token

下面是上面这些步骤所需要的参数。

A步骤中,客户端申请认证的URI,包含以下参数:

  • response_type:表示授权类型,必选项,此处的值固定为”code”
  • client_id:表示客户端的ID,必选项
  • redirect_uri:表示重定向URI,可选项
  • scope:表示申请的权限范围,可选项
  • state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值。

下面是一个例子

> GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz
>         &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
> Host: server.example.com
>

C步骤中,服务器回应客户端的URI,包含以下参数:

  • code:表示授权码,必选项。该码的有效期应该很短,通常设为10分钟,客户端只能使用该码一次,否则会被授权服务器拒绝。该码与客户端ID和重定向URI,是一一对应关系。
  • state:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数。

下面是一个例子。

> HTTP/1.1 302 Found
> Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA
>           &state=xyz
>

D步骤中,客户端向认证服务器申请令牌的HTTP请求,包含以下参数:

  • grant_type:表示使用的授权模式,必选项,此处的值固定为”authorization_code”。
  • code:表示上一步获得的授权码,必选项。
  • redirect_uri:表示重定向URI,必选项,且必须与A步骤中的该参数值保持一致。
  • client_id:表示客户端ID,必选项。

下面是一个例子。

> POST /token HTTP/1.1
> Host: server.example.com
> Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
> Content-Type: application/x-www-form-urlencoded
>
> grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
> &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
>

E步骤中,认证服务器发送的HTTP回复,包含以下参数:

  • access_token:表示访问令牌,必选项。
  • token_type:表示令牌类型,该值大小写不敏感,必选项,可以是bearer类型或mac类型。
  • expires_in:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间。
  • refresh_token:表示更新令牌,用来获取下一次的访问令牌,可选项。
  • scope:表示权限范围,如果与客户端申请的范围一致,此项可省略。

下面是一个例子。

>      HTTP/1.1 200 OK
>      Content-Type: application/json;charset=UTF-8
>      Cache-Control: no-store
>      Pragma: no-cache
>
>      {
>        "access_token":"2YotnFZFEjr1zCsicMWpAA",
>        "token_type":"example",
>        "expires_in":3600,
>        "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
>        "example_parameter":"example_value"
>      }
>

从上面代码可以看到,相关参数使用JSON格式发送(Content-Type: application/json)。此外,HTTP头信息中明确指定不得缓存。

密码模式

密码模式(Resource Owner Password Credentials Grant)中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向”服务商提供商”索要授权

在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式

SpringSecurity_第7张图片

它的步骤如下:

(A)用户向客户端提供用户名和密码。

(B)客户端将用户名和密码发给认证服务器,向后者请求令牌。

(C)认证服务器确认无误后,向客户端提供访问令牌。

B步骤中,客户端发出的HTTP请求,包含以下参数:

  • grant_type:表示授权类型,此处的值固定为”password”,必选项。
  • username:表示用户名,必选项。
  • password:表示用户的密码,必选项。
  • scope:表示权限范围,可选项。

下面是一个例子。

>      POST /token HTTP/1.1
>      Host: server.example.com
>      Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
>      Content-Type: application/x-www-form-urlencoded
>
>      grant_type=password&username=johndoe&password=A3ddj3w
>

C步骤中,认证服务器向客户端发送访问令牌,下面是一个例子。

>      HTTP/1.1 200 OK
>      Content-Type: application/json;charset=UTF-8
>      Cache-Control: no-store
>      Pragma: no-cache
>
>      {
>        "access_token":"2YotnFZFEjr1zCsicMWpAA",
>        "token_type":"example",
>        "expires_in":3600,
>        "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
>        "example_parameter":"example_value"
>      }
>

简化模式

简化模式(implicit grant type)不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,==跳过了”授权码”==这个步骤,因此得名。所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证

SpringSecurity_第8张图片

它的步骤如下:

(A)客户端将用户导向认证服务器。

(B)用户决定是否给于客户端授权。

(C)假设用户给予授权,认证服务器将用户导向客户端指定的”重定向URI”,并在URI的Hash部分包含了访问令牌。

(D)浏览器向资源服务器发出请求,其中不包括上一步收到的Hash值。

(E)资源服务器返回一个网页,其中包含的代码可以获取Hash值中的令牌。

(F)浏览器执行上一步获得的脚本,提取出令牌。

(G)浏览器将令牌发给客户端。

下面是上面这些步骤所需要的参数。

A步骤中,客户端发出的HTTP请求,包含以下参数:

  • response_type:表示授权类型,此处的值固定为”token”,必选项。
  • client_id:表示客户端的ID,必选项。
  • redirect_uri:表示重定向的URI,可选项。
  • scope:表示权限范围,可选项。
  • state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值。

下面是一个例子。

>     GET /authorize?response_type=token&client_id=s6BhdRkqt3&state=xyz
>         &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
>     Host: server.example.com
>

C步骤中,认证服务器回应客户端的URI,包含以下参数:

  • access_token:表示访问令牌,必选项。
  • token_type:表示令牌类型,该值大小写不敏感,必选项。
  • expires_in:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间。
  • scope:表示权限范围,如果与客户端申请的范围一致,此项可省略。
  • state:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数。

下面是一个例子。

>      HTTP/1.1 302 Found
>      Location: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA
>                &state=xyz&token_type=example&expires_in=3600
>

在上面的例子中,认证服务器用HTTP头信息的Location栏,指定浏览器重定向的网址。注意,在这个网址的Hash部分包含了令牌。

根据上面的D步骤,下一步浏览器会访问Location指定的网址,但是Hash部分不会发送。接下来的E步骤,服务提供商的资源服务器发送过来的代码,会提取出Hash中的令牌。

客户端模式

客户端模式(Client Credentials Grant)指客户端以自己的名义,而不是以用户的名义,向”服务提供商”进行认证。严格地说,客户端模式并不属于OAuth框架所要解决的问题。在这种模式中,用户直接向客户端注册,客户端以自己的名义要求”服务提供商”提供服务,其实不存在授权问题

我们对接微信公众号时,就采用的客户端模式。我们的后端服务器就扮演“客户端”的角色,与微信公众号的后端服务器进行交互。

SpringSecurity_第9张图片

比如 docker拉取镜像

删除 SecurityConfig 配置类,因为客户端模式下,无需 Spring Security 提供用户的认证功能。但是,Spring Security OAuth 需要一个 PasswordEncoder Bean,否则会报错,因此我们在 OAuth2AuthorizationServerConfig 类的 #passwordEncoder() 方法进行创建。

它的步骤如下:

(A)客户端向认证服务器进行身份认证,并要求一个访问令牌。

(B)认证服务器确认无误后,向客户端提供访问令牌。

A步骤中,客户端发出的HTTP请求,包含以下参数:

  • granttype:表示授权类型,此处的值固定为”clientcredentials”,必选项。
  • scope:表示权限范围,可选项。
>      POST /token HTTP/1.1
>      Host: server.example.com
>      Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
>      Content-Type: application/x-www-form-urlencoded
>
>      grant_type=client_credentials
>

认证服务器必须以某种方式,验证客户端身份。

B步骤中,认证服务器向客户端发送访问令牌,下面是一个例子。

>      HTTP/1.1 200 OK
>      Content-Type: application/json;charset=UTF-8
>      Cache-Control: no-store
>      Pragma: no-cache
>
>      {
>        "access_token":"2YotnFZFEjr1zCsicMWpAA",
>        "token_type":"example",
>        "expires_in":3600,
>        "example_parameter":"example_value"
>      }
>

上面代码中,各个参数的含义参见《授权码模式》一节。

更新令牌

如果用户访问的时候,客户端的”访问令牌”已经过期,则需要使用”更新令牌”申请一个新的访问令牌。

客户端发出更新令牌的HTTP请求,包含以下参数:

  • granttype:表示使用的授权模式,此处的值固定为”refreshtoken”,必选项。
  • refresh_token:表示早前收到的更新令牌,必选项。
  • scope:表示申请的授权范围,不可以超出上一次申请的范围,如果省略该参数,则表示与上一次一致。

下面是一个例子。

>      POST /token HTTP/1.1
>      Host: server.example.com
>      Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
>      Content-Type: application/x-www-form-urlencoded
>
>      grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA
>

授权服务器

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-09WZWOpV-1649940170695)(SpringSecurity.assets/image-20220329234113607.png)]

例子

spring-cloud-demo的 oauth2-server

授权码模式

spring-cloud-demo中的oauth2-server

http://localhost:9401/oauth/authorize?response_type=code&client_id=admin&redirect_uri=http://www.baidu.com&scope=all

重定向uri 得到 授权码

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XhiFtyQe-1649940170695)(SpringSecurity.assets/image-20220331194332886.png)]

/oauth/token 对应 TokenEndpoint 端点

/oauth/check_token 端点对应 CheckTokenEndpoint 类,用于校验访问令牌的有效性。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dcc5ptVg-1649940170695)(SpringSecurity.assets/image-20220331194704871.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yXwQ3nPY-1649940170695)(SpringSecurity.assets/image-20220331194720726.png)]

{
    "access_token": "0cd5dca9-f7ae-499e-b3da-5f75e296bf02",
    "token_type": "bearer",
    "expires_in": 43199,
    "scope": "all"
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7poVrw2C-1649940170696)(SpringSecurity.assets/image-20220331194933177.png)]

{
    "username": "admin",
    "password": "$2a$10$bOe5PF1hKOaUjPjq3Fy/CuIFaVEwNWnzRHZ13RYi9OFkAD/fUnJkq",
    "authorities": [
        {
            "authority": "admin"
        },
        {
            "authority": "normal"
        },
        {
            "authority": "ROLE_abc"
        }
    ],
    "enabled": true,
    "credentialsNonExpired": true,
    "accountNonExpired": true,
    "accountNonLocked": true
}

密码模式

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D2JGAN6z-1649940170696)(SpringSecurity.assets/image-20220402134248683.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qkUqDvtU-1649940170696)(SpringSecurity.assets/image-20220402134614308.png)]

Redis存储Token

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
    <groupId>org.apache.commonsgroupId>
    <artifactId>commons-pool2artifactId>
    <version>2.6.0version>
dependency>

密码模式下存储到redis

区别

JWTTokenStore RedisTokenStore
token信息,认证信息封装在JWT负载中 token信息,认证信息保存在Redis服务
token为加密的JWT负载 token为随机redis key
通过解密token获取用户认证信息 通过认证服务器校验token获取用户认证信息

优缺点

JWTTokenStore RedisTokenStore
无需保存用户认证信息 使用Redis服务保存用户认证信息
无法撤销用户授权 可以撤销用户授权

数据库存储Token


create table oauth_client_details (
  client_id VARCHAR(255) PRIMARY KEY,
  resource_ids VARCHAR(255),
  client_secret VARCHAR(255),
  scope VARCHAR(255),
  authorized_grant_types VARCHAR(255),
  web_server_redirect_uri VARCHAR(255),
  authorities VARCHAR(255),
  access_token_validity INTEGER,
  refresh_token_validity INTEGER,
  additional_information VARCHAR(4096),
  autoapprove VARCHAR(255)
);

create table if not exists oauth_client_token (
  token_id VARCHAR(255),
  token LONG VARBINARY,
  authentication_id VARCHAR(255) PRIMARY KEY,
  user_name VARCHAR(255),
  client_id VARCHAR(255)
);

create table if not exists oauth_access_token (
  token_id VARCHAR(255),
  token LONG VARBINARY,
  authentication_id VARCHAR(255) PRIMARY KEY,
  user_name VARCHAR(255),
  client_id VARCHAR(255),
  authentication LONG VARBINARY,
  refresh_token VARCHAR(255)
);

create table if not exists oauth_refresh_token (
  token_id VARCHAR(255),
  token LONG VARBINARY,
  authentication LONG VARBINARY
);

create table if not exists oauth_code (
  code VARCHAR(255), authentication LONG VARBINARY
);

create table if not exists oauth_approvals (
	userId VARCHAR(255),
	clientId VARCHAR(255),
	scope VARCHAR(255),
	status VARCHAR(10),
	expiresAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
	lastModifiedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);


INSERT INTO oauth_client_details
	(client_id, client_secret, scope, authorized_grant_types,
	web_server_redirect_uri, authorities, access_token_validity,
	refresh_token_validity, additional_information, autoapprove)
VALUES
	('clientapp', '112233', 'read_userinfo,read_contacts',
	'password,refresh_token', null, null, 3600, 864000, null, true);

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-etchXeii-1649940170697)(SpringSecurity.assets/image-20220403190844103.png)]

  • 基于内存的 InMemoryTokenStore
  • 基于数据库的 JdbcTokenStore
  • 基于 Redis 的 RedisTokenStore
  • 基于 JWT 的 JwtTokenStore

删除令牌

在用户登出系统时,我们会有删除令牌的需求。虽然说,可以通过客户端本地删除令牌的方式实现。但是,考虑到真正的彻底的实现删除令牌,必然服务端自身需要删除令牌。

客户端本地删除令牌的方式实现,指的是清楚本地 Cookie、localStorage 的令牌缓存

在 Spring Security OAuth2 中,并没有提供内置的接口,所以需要自己去实现。笔者参看 《Spring Security OAuth2 – Simple Token Revocation》 文档,实现删除令牌的 API 接口。

具体的实现,通过调用 ConsumerTokenServices 的 #revokeToken(String tokenValue) 方法,删除访问令牌和刷新令牌。

public interface ConsumerTokenServices {
    boolean revokeToken(String var1);
}
// 实现类
DefaultTokenServices
    
@RestController
@RequestMapping("/token/demo")
public class TokenDemoController {

    @Autowired
    private ConsumerTokenServices tokenServices;

    /**
     * 删除令牌
     *
     * @param token
     * @return
     */
    @PostMapping(value = "/revoke")
    public boolean revokeToken(@RequestParam("token") String token) {
        return this.tokenServices.revokeToken(token);
    }

}

分布式session

第一种,Session 黏连

使用 Nginx 实现会话黏连,将相同 sessionid 的浏览器所发起的请求,转发到同一台服务器。这样,就不会存在多个 Web 服务器创建多个 Session 的情况,也就不会发生 Session 不一致的问题。

不过,这种方式目前基本不被采用。因为,如果一台服务器重启,那么会导致转发到这个服务器上的 Session 全部丢失。

具体怎么实现这种方式,可以看看 《Nginx 第三方模块 —— nginx-sticky-module 的使用(基于cookie的会话保持)》 文章

第二种,Session 复制

Web 服务器之间,进行 Session 复制同步。仅仅适用于实现 Session 复制的 Web 容器,例如说 Tomcat 、Weblogic 等等。

不过,这种方式目前基本也不被采用。试想一下,如果我们有 5 台 Web 服务器,所有的 Session 都要同步到每一个节点上,一个是效率低,一个是浪费内存。

具体怎么实现这种方式,可以看看 [《Session 共享 —— Tomcat 集群 Session 复制》](https://www.iocoder.cn/Spring-Boot/Distributed-Session/session 共享-tomcat集群session复制) 文章

第三种,Session 外部化存储

不同于上述的两种方案,Session 外部化存储,考虑不再采用 Web 容器的内存中存储 Session ,而是将 Session 存储外部化,持久化到 MySQL、Redis、MongoDB 等等数据库中。这样,Tomcat 就可以无状态化,专注提供 Web 服务或者 API 接口,未来拓展扩容也变得更加容易。

而实现 Session 外部化存储也有两种方式:

① 基于 Tomcat、Jetty 等 Web 容器自带的拓展,使用读取外部存储器的 Session 管理器。例如说:

  • 《Redisson Tomcat会话管理器(Tomcat Session Manager)》 ,实现将 Tomcat 使用 Redis 存储 Session 。
  • 《Jetty 集群配置 Session 存储到 MySQL、MongoDB》 ,实现 Jetty 使用 MySQL、MongoDB 存储 Session 。

② 基于应用层封装 HttpServletRequest 请求对象,包装成自己的 RequestWrapper 对象,从而让实现调用 HttpServletRequest#getSession() 方法时,获得读写外部存储器的 SessionWrapper 对象。例如说,稍后我们会看到的本文的主角 Spring Session 。

  • Spring Session 提供了 SessionRepositoryFilter 过滤器,它会过滤请求时,将请求 HttpServletRequest 对象包装成 SessionRepositoryRequestWrapper 对象。代码如下:

    // SessionRepositoryFilter.java
    
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // sessionRepository 是访问外部数据源的操作类,例如说访问 Redis、MySQL 等等
        request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
        
    
        // 将请求和响应进行包装成 SessionRepositoryRequestWrapper 和 SessionRepositoryResponseWrapper 对象
        SessionRepositoryFilter.SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryFilter.SessionRepositoryRequestWrapper(request, response, this.servletContext);
        SessionRepositoryFilter.SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryFilter.SessionRepositoryResponseWrapper(wrappedRequest, response);
    
        // 继续执行下一个过滤器
        try {
            filterChain.doFilter(wrappedRequest, wrappedResponse);
        } finally {
            // 请求结束,提交 Session 到外部数据源
            wrappedRequest.commitSession();
        }
    
    }
    
  • 调用 SessionRepositoryRequestWrapper#getSession() 方法时,返回的是自己封装的 HttpSessionWrapper 对象。代码如下:

    // SessionRepositoryFilter#SessionRepositoryRequestWrapper.java
    
    	@Override
    	public HttpSessionWrapper getSession() {
    		return getSession(true);
    	}
    
  • 后续,我们调用 HttpSessionWrapper 的方法,例如说 HttpSessionWrapper#setAttribute(String name, Object value) 方法,访问的就是外部数据源,而不是内存中。

当然 ① 和 ② 两种方案思路是类似且一致的,只是说拓展的提供者和位置不同。相比来说,② 会比 ① 更加通用一些

oauth2 + jwt

springcloud-demo中的oauth2-jwt-server

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b9pQW0cp-1649940170697)(SpringSecurity.assets/image-20220402192121097.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-naiVKVg8-1649940170697)(SpringSecurity.assets/image-20220402192130157.png)]

解析

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kTrOz7FW-1649940170697)(SpringSecurity.assets/image-20220402193138735.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-g7ZhPOgO-1649940170697)(SpringSecurity.assets/image-20220402193123166.png)]

刷新令牌

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NmvdeeuG-1649940170698)(SpringSecurity.assets/image-20220402193648227.png)]

SSO

springcloud-demo中的oauth2-client

https://snailclimb.gitee.io/javaguide/#/docs/system-design/authority-certification/SSO%E5%8D%95%E7%82%B9%E7%99%BB%E5%BD%95%E7%9C%8B%E8%BF%99%E4%B8%80%E7%AF%87%E5%B0%B1%E5%A4%9F%E4%BA%86

SSO(Single Sign On)即单点登录说的是用户登陆多个子系统的其中一个就有权访问与其相关的其他系统。举个例子我们在登陆了京东金融之后,我们同时也成功登陆京东的京东超市、京东国际、京东生鲜等子系统

@EnableOAuth2Sso

http://localhost:9501/user/getCurrentUser

{
  "authorities": [
    {
      "authority": "ROLE_abc"
    },
    {
      "authority": "admin"
    },
    {
      "authority": "normal"
    }
  ],
  "details": {
    "remoteAddress": "0:0:0:0:0:0:0:1",
    "sessionId": "70A88E519DD83A9F820AE27D1FB5D1B1",
    "tokenValue": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2NDg5ODgxNDcsImF1dGhvcml0aWVzIjpbIlJPTEVfYWJjIiwiYWRtaW4iLCJub3JtYWwiXSwianRpIjoiODFkZmQwOWYtZTk1YS00ODEyLWIzMjUtNGFjNmRkZjQ5ZmVjIiwiY2xpZW50X2lkIjoiYWRtaW4iLCJlbmhhbmNlIjoiZW5oYW5jZSBpbmZvIn0.Id9D9AsA2pvq0GlfWaznMuQY29Sni_os9NGGwF81gUo",
    "tokenType": "bearer",
    "decodedDetails": null
  },
  "authenticated": true,
  "userAuthentication": {
    "authorities": [
      {
        "authority": "ROLE_abc"
      },
      {
        "authority": "admin"
      },
      {
        "authority": "normal"
      }
    ],
    "details": null,
    "authenticated": true,
    "principal": "admin",
    "credentials": "N/A",
    "name": "admin"
  },
  "clientOnly": false,
  "credentials": "",
  "principal": "admin",
  "oauth2Request": {
    "clientId": "admin",
    "scope": [
      "all"
    ],
    "requestParameters": {
      "client_id": "admin"
    },
    "resourceIds": [
      
    ],
    "authorities": [
      
    ],
    "approved": true,
    "refresh": false,
    "redirectUri": null,
    "responseTypes": [
      
    ],
    "extensions": {
      
    },
    "grantType": null,
    "refreshTokenRequest": null
  },
  "name": "admin"
}

postman接口单点登录

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EFj7cG2m-1649940170698)(SpringSecurity.assets/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly90aGlua3dvbi5ibG9nLmNzZG4ubmV0,size_16,color_FFFFFF,t_70.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XDUSIaex-1649940170698)(SpringSecurity.assets/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly90aGlua3dvbi5ibG9nLmNzZG4ubmV0,size_16,color_FFFFFF,t_70.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2dj0dIPQ-1649940170698)(SpringSecurity.assets/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly90aGlua3dvbi5ibG9nLmNzZG4ubmV0,size_16,color_FFFFFF,t_70.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WYv74Mn4-1649940170699)(SpringSecurity.assets/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly90aGlua3dvbi5ibG9nLmNzZG4ubmV0,size_16,color_FFFFFF,t_70.png)]

oauth2-client添加权限校验

参考:https://www.iocoder.cn/Fight/user-authentication-with-jwt/?self

https://www.iocoder.cn/Spring-Security/OAuth2-learning-sso/?github

使用JWT的方式则没有这个问题的存在,因为用户的状态已经被传送到了客户端。因此,我们只需要将含有JWT的Cookie的domain设置为顶级域名即可,例如

Set-Cookie: jwt=lll.zzz.xxx; HttpOnly; max-age=980000; domain=.taobao.com

注意domain必须设置为一个点加顶级域名,即.taobao.com。这样,taobao.com和*.taobao.com就都可以接受到这个Cookie,并获取JWT了。

– oauth_access_token OAuth 2.0 访问令牌
– oauth_refresh_token OAuth 2.0 刷新令牌
– oauth_code OAuth 2.0 授权码
– oauth_client_details OAuth 2.0 客户端
– oauth_client_token
– oauth_approvals
– users 用户表
– authorities 授权表,例如用户拥有的角色

springsecurity + jwt + rbac

参考 spring-boot-demo-spring-security-jwt-rbac

你可能感兴趣的:(springsecurity,jwt)