Sa-Token

#todo/note
Sa-token基本使用教程(全网最详细!!!)_sa-token校验用户密码-CSDN博客

很轻量,很简单,甚至可以零配置启动

Spring Security

Spring Security框架

认证授权其实是两个过程

  • 认证:面向用户,是否是用户?是否登录?
  • 授权:面向服务,是否有权限访问?

而我们项目的业务流程分三步:

  • 统一认证:项目包括学生、学习机构的老师、平台运营人员三类用户,三类用户将使用统一的认证入口。认证通过由认证服务向给用户颁发令牌,相当于访问系统的通行证,用户拿着令牌去访问系统的资源。
  • 单点登录:单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
  • 第三方认证(微信、QQ~):为了提高用户体验很多网站有扫码登录的功能,如:微信扫码登录、QQ扫码登录等。扫码登录的好处是用户不用输入账号和密码,操作简便,另外一个好处就是有利于用户信息的共享,互联网的优势就是资源共享,用户也是一种资源,对于一个新网站如果让用户去注册是很困难的,如果提供了微信扫码登录将省去用户的注册成本,是一种非常有效的推广手段。

本项目使用Spring Security框架快速构建认证授权功能体系。

Tip:

  • 导入依赖的时候其实不需要导两个,因为oauth2里边已经引入过Security了~
  • Spring Security框架的安全配置方式随着高版本的出现可能会发生改变,像本项目使用版本的安全配置在高版本就已经弃用~

基本使用步骤:

  • 导入依赖
  • 配置类设置认证规则
  • controller方法路径上通过注解进行授权

OAuth开放三方授权协议

OAuth2即OAuth2.0,在OAuth基础上极大简化

OAuth协议是简单的开放的强大的三方授权协议,我们可以通过该协议调用微信三方授权,同样的我们也可以让别的服务调用我们的服务的授权协议,只要我们都遵循OAuth开放授权协议即可

令牌模式、密码模式、简单模式、客户端模式

以令牌模式(三方协议使用)为例:

  • 浏览器(用户请求资源拥有者,我们项目)
  • 浏览器选择第三方授权扫码登录(请求第三方认证,微信返回同意协议书,用户点击确认,返回授权码)
  • 项目服务拿到授权码向微信等三方服务申请令牌,授权码正确返回令牌
  • 项目服务拿到令牌向微信等三方服务去取用户信息,令牌正确返回用户信息
  • 项目服务拿到用户信息后就可以对用户进行认证授权业务管理

令牌模式基本使用:

  • 引入依赖

  • 创建服务配置类和令牌配置类

  • 就可以开放面向第三方认证和授权的协议接口

密码模式为例:

  • 不需要授权码,用户直接提供账号、密码登录认证授权获取用户信息,因为这是我们项目一套的服务,我们无条件信任

使用JWT令牌替换普通令牌

前面说到的令牌模式用的是普通令牌——基于Session,有状态,存储在其他服务器

也就是说第三方认证服务发了一个令牌给我们的服务,我们拿着这个令牌去调用第三方的资源数据(这也是单独的服务),如果是基于Session的普通令牌,那么这个资源服务不知道这个令牌是否有效它还需要去问他自己的认证服务去判断这个令牌是否有效

而往往我们那个令牌之后不只是只访问单个资源,若是普通令牌每访问一个资源那就要请求一次他们的认证服务这将极大耗费性能占用线程,这种微服务下的session的令牌模式也称之为session复制和粘贴

所以我们选用JWT令牌

事实上现在很多微服务体系架构的网站使用的都是JWT令牌——无状态令牌,服务端直接从JWT令牌解析出用户信息

它的特点就是认证服务给了你一个JWT令牌后,你拿着这个JWT令牌去访问其他服务,其他服务不需要再去访问它们自己的认证服务就能知道这个令牌是否有效

JWT令牌的优点:

1、jwt基于json,非常方便解析。

2、可以在令牌中自定义丰富的内容,易扩展。

3、通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。

4、资源服务使用JWT可不依赖认证服务即可完成授权。

缺点:

1、JWT令牌较长,占存储空间比较大。

JWT令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz

  1. Header
    头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA)
    一个例子如下:
    下边是Header部分的内容:
{
 "alg": "HS256",
"typ": "JWT"
 }
 //将上边的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分。
  1. Payload
    第二部分是负载,内容也是一个json对象,它是存放有效信息的地方,它可以存放jwt提供的现成字段,比如:iss(签发者),exp(过期时间戳), sub(面向的用户)等,也可自定义字段。
    此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。
    最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分。
    一个例子:
    {
  "sub": "1234567890",
  "name": "456",
"admin": true
  }
  1. Signature
    第三部分是签名,此部分用于防止jwt内容被篡改。
    这个部分使用base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明签名算法进行签名。
    一个例子:
    HMACSHA256(
      base64UrlEncode(header) + "." +
      base64UrlEncode(payload),
  secret)
base64UrlEncode(header):jwt令牌的第一部分。
base64UrlEncode(payload):jwt令牌的第二部分。
secret:签名所使用的密钥。
为什么JWT可以防止篡改?

第三部分使用签名算法对第一部分和第二部分的内容进行签名,常用的签名算法是 HS256,常见的还有md5,sha 等,签名算法需要使用密钥进行签名,密钥不对外公开,并且签名是不可逆的,如果第三方更改了内容那么服务器验证签名就会失败,要想保证验证签名正确必须保证内容、密钥与签名前一致。
JWT认证服务和资源服务使用相同的密钥,这叫对称加密,对称加密效率高,如果一旦密钥泄露可以伪造jwt令牌。
JWT还可以使用非对称加密,认证服务自己保留私钥,将公钥下发给受信任的客户端、资源服务,公钥和私钥是配对的,成对的公钥和私钥才可以正常加密和解密,非对称加密效率低但相比对称加密非对称加密更安全一些。

服务集成JWT令牌校验

在需要校验的服务里

  • 引入依赖

  • 创建服务配置类和令牌配置类

  • 设置绑定的设备id、服务id、需要鉴权的路径,哪些直接放行等

    注意服务id要和认证服务设置的应用资源id要一致

那么接下来就可以回去处理原来机构id硬编码的问题了,

我们可以直接使用Spring Security框架和OAuth协议的上下文对象,从上下文对象中获取令牌并解析令牌得到用户信息

网关鉴权

SpringCloud GateWay业务网关

使用SpringCloud自带的gateway-starter起步依赖即可——代替之前老旧的zurl,是新一代网关

我们知道所有的微服务都要经过网关(业务网关gateway),然后经过其路由到各个具体的微服务

Tip

Nginx是流量网关,负责流量缓冲,实现反向代理(代理到业务网关gateway、网站门户等)

所以我们可以直接在网关进行认证鉴权(是否携带正确的token)

但是具体的授权在具体的微服务里进行

因为我们每个微服务各司其职,是否拥有权限直接在微服务对外接口的Controller上添加注解控制更为方便且清晰

所以最终:

  • 网关负责鉴权
  • 微服务负责授权

实现:

  • 使用Filter拦截
  • 加载白名单
  • 遍历白名单比对
  • 属于白名单放行
  • 不属于白名单校验token
    • token合法放行
    • token不合法报异常记录日志

小问题:本项目里白名单里的内容都有/open路径标识,该路径指向内容要注意加缓存,不然很容易直接冲击数据库

优化思路
  • 白名单只加载一次,可以在spring启动时载入避免重复IO
  • 再用哈希结构避免遍历

用户认证

接下来就从数据库获取真实的用户信息来进行校验了

为了保证用户信息的安全(即便数据库泄露,也能保证账号密码安全),我们使用BCrypt加密算法对密码进行加密后再存储到数据库

而不是直接明文,BCrypt加密算法是不可逆的:

  • 随机盐:盐,加密事在原密码的基础上增加的混淆字符,再按一定算法对原密码和盐一起加密。BCrypt算法不仅使用盐还使用随机生成的盐来确保安全
  • coss:一层加密不够,我们在一层随机盐加密还不够,我们在加密的基础上进行二次、多次加密。coss就是一个加密层数的指标,表示要加密的层数

那么BCrypt加密算法是如何验证密码是否正确的呢?

将前端传过来的明文密码和使用数据库里存储的加密密码值中的随机盐部分当做随机盐然后按照相同的加密算法进行加密,若得到的结果和数据库原来存储的密码字符还是一样的结果就证明这个密码是正确的


在配置类中将明文方式更改成BCrypt加密即可

但是有一个问题,那就是User不仅仅只有账号和密码,还有一些其他的信息如头像、生日等信息也是我们需要的

但默认是没有的,所以我们需要扩展SpringSecurity用户信息

在认证阶段DaoAuthenticationProvider会调用UserDetailService查询用户的信息,这里是可以获取到齐全的用户信息的。由于JWT令牌中用户身份信息来源于UserDetails,UserDetails中仅定义了username为用户的身份信息,这里有两个思路:

  • 第一是可以扩展UserDetails,使之包括更多的自定义属性,

  • 第二也可以扩展username的内容 ,比如存入json数据内容作为username的内容。相比较而言,方案二比较简单还不用破坏UserDetails的结构,我们采用方案二。(注意的就是为了安全性,令牌的json数据内容里边要排除密码)
    修改UserServiceImpl如下:

    package com.xuecheng.ucenter.service.impl;
    import com.alibaba.fastjson.JSON;
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.xuecheng.ucenter.mapper.XcUserMapper;
    import com.xuecheng.ucenter.model.po.XcUser;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.stereotype.Service;
    
    /**
    
     * @author Mr.M
    
     * @version 1.0
    
     * @description TODO
    
     * @date 2022/9/28 18:09
       */
       @Service
       public class UserServiceImpl implements UserDetailsService {
    
       @Autowired
       XcUserMapper xcUserMapper;
    
       /**
    
        * @description 根据账号查询用户信息
    
        * @param s  账号
    
        * @return org.springframework.security.core.userdetails.UserDetails
    
        * @author Mr.M
    
        * @date 2022/9/28 18:30
          */
          @Override
          public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    
           XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq);
           if(user==null){
               //返回空表示用户不存在
               return null;
           }
    
           //取出数据库存储的正确密码
           String password  =user.getPassword();
           //用户权限,如果不加报Cannot pass a null GrantedAuthority collection
           String[] authorities = {"p1"};
          //为了安全在令牌中不放密码
           user.setPassword(null);
           //将user对象转json
           String userString = JSON.toJSONString(user);
           //创建UserDetails对象
           UserDetails userDetails = User.withUsername(userString).password(password).authorities(authorities).build();
    
           return userDetails;
          }
    
    }
    

采取方案二之后,原来直接通过上下文对象自带的方法来获取用户信息的方法就不再适用了,需要做一些转换处理

因为很多地方都有可能调用到所以我们需要将其抽取成一个获取当前用户身份工具类

支持认证方式多样化

有很多认证方式:账号密码、微信等第三方认证、手机验证码登录等

为了规范我们应该统一认证入口

只提供一个接口来实现认证,根据参数中的认证类型来实现不同的认证逻辑,而默认来说在SpringSecurity框架内认证是交由一个具体的类和方法来处理的,所以我们定义一个类继承和实现重写excute方法

让他替代原来的处理类和方法,下面是SpringSecurity最基础的流程原理和框架,我们要做的就是重写DaoAuthenticactionProvider和UserDetailService

在该项目具体的代码逻辑里体现了策略模式吧~

在Service注解上添加value属性,给Spring容器里的对象打上了一个标识

在同一认证入口里我们先获取参数里携带的认证方式

然后通过认证方式拼接固定的后缀来获取处理对应认证方式的Bean对象名称

然后获取application这个Spring全局的上下文对象通过指定Bean名称、Bean类型来从容器中获取出指定的Bean,然后通过Bean调用其自己的excute方法处理对应的认证逻辑

通过这种巧妙的方式我们可以省略一些if...else判断,简洁且优雅


账号密码

在账号密码登录方式,我们还需要加验证码,防止恶意攻击

验证码虽然简单但很多服务都需要使用,所以我们依旧单独做成一个微服务

然后我们依旧在权限认证服务中建立远程调用的Feign客户端,然后写接口和实现类来远程调用验证码微服务

微信扫码

微信扫码需要在微信开放平台注册一个服务

扫码登录流程含义:

  • 用户扫码:用户让我们的服务去从微信获取信息然后微信返回一个授权界面给用户
  • 用户点击同意:微信返回一个授权码给我们服务
  • 服务携带授权码、appid、appsecret来换取access_token访问令牌
  • 服务携带令牌去获取用户基本信息,最后微信返回用户基本信息

首先明确接口规范

  • 引入微信的js资源

  • 在网页中实例化js对象,要包含指定的参数,如

    • uri:扫码成功后要跳转的链接(需要域名)
    • appid:在微信上注册成功的程序
    • ...

手机验证码

参考前两个的方式来写即可,可以调用阿里云OS短信云服务,其实道理都一样包括邮箱

-------授权-------

前面完成了认证,下面开始授权

如何实现授权?业界通常基于RBAC实现授权。

RBAC分为两种方式:

  • 基于角色的访问控制(Role-Based Access Control)

    按角色进行授权,比如:主体的角色为总经理可以查询企业运营报表,查询员工工资信息等

  • 基于资源的访问控制(Resource-Based Access Control)⭐️

    按资源(或权限)进行授权,比如:用户必须具有查询工资权限才可以查询员工工资信息等

    优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理也不需要修改授权代码,系统可扩展性强。


下面我们项目就是使用基于资源的访问控制:

在需要访问权限控制的对外controller上添加注解指定需要什么权限即可

那问题来了怎么授予权限?

我们需要解析JWT令牌的权限信息来判断——JWT令牌除了包含用户信息外还应该包含权限信息

授权相关的数据模型

先来了解授权相关的数据模型:体会数据库建模 > 经典权限表设计RABC

但是也有一个问题那就是一旦有个别用户的权限比较特殊那就不适用了

好比我和大家都是老师,但我这个老师又因为某些原因要缺少某些权限,我们不可能直接修改角色权限表,因为直接修改角色权限表会导致其他老师也会受牵连

比较直接的解决方法就是用户直接关联权限菜单表,直接给用户绑定权限

JWT令牌写入用户权限

前面说到,我们需要解析JWT令牌的权限信息来判断——JWT令牌除了包含用户信息外还应该包含权限信息

那么我们定义mapper、serviceImpl查询记录

然后将查到的权限封装成一个数组在构造令牌的时候将权限数组传入权限构造方法中当做参数即可

细粒度授权问题

上面的授权基本上是大范围的,比如权限就是增删改,但是增删改的范围又是什么呢?

所以根据业务需求和实际情况我们往往需要对数据范围做限定

在我们的项目中,一个机构管理员它是管理员能够怎删改课程,这是框架和JWT令牌配合实现的授权,但是一个机构的管理员他理应只能查看、修改和删除自己机构的课程信息

具体到不同的业务不同的逻辑很难去交由框架去实现,我们根据具体的业务需求和逻辑可以去具体实现的Service中做数据范围的限定

在当前项目场景下我们通过在Service查询的时候拼接SQL通过companyId来where条件判断列出所有符合该机构的数据信息来完成用户数据操作范围的限定