Shiro整合JWT实现前后台分离下认证方案

JWT介绍

JSON Web Token(缩写 JWT)是目前最流行的跨域认证解决方案,是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。

跨域认证的问题

HTTP协议是无状态的,也就是说,如果我们已经认证了一个用户,那么他下一次请求的时候,服务器不知道我是谁,我们必须再次认证。互联网服务离不开用户认证,一般流程是下面这样:

  1. 用户向服务器发送用户名和密码。
  2. 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。
  3. 服务器向用户返回一个 session_id,写入用户的 Cookie。
  4. 用户随后的每一次请求,都会通过 Cookie,将session_id 传回服务器。
  5. 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

这种模式的问题在于,扩展性不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。
另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。

JWT 的原理与数据结构

JWT 的原理

服务器认证以后,生成一个 JSON 对象,发回给用户,就像下面这样。

{
  “姓名”: “张三”,
  “角色”: “管理员”,
  “到期时间”: “2020年7月1日0点0分”
} 

以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。
为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名。服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。

JWT 数据结构

实际大概就像下面这样,它是一个很长的字符串,中间用点(.)分隔成三个部分。JWT 的三个部分依次如下:

1.  Header(头部)
2.  Payload(负载)
3.  Signature(签名)
eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuaW5naGFvLm5ldCIsImV4cCI6IjE0Mzg5NTU0NDUiLCJuYW1lIjoid2FuZ2hhbyIsImFkbWluIjp0cnVlfQ.SwyHTEx_RQppr97g4J5lKXtabJecpejuef8AqKYMAJc

写成一行,就是下面的样子:

Header.Payload.Signature

Header

Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子:

{
  “alg”: “HS256”,
  “typ”: “JWT”
}

上面代码中,alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT。
最后,将上面的 JSON 对象使用 Base64URL 算法转成字符串。

Payload

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用:

iss (issuer):签发人
exp (expiration time):过期时间
sub (subject):主题 aud
(audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号

除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。

{
  “sub”: “1234567890”,
  “name”: “John Doe”,
  “admin”: true
}

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。
这个 JSON 对象也要使用 Base64URL 算法转成字符串。

Signature

Signature 部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

HMACSHA256(base64UrlEncode(header) + “.” +base64UrlEncode(payload), secret)

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。

Base64URL

面提到,Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。 Base64 有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。这就是 Base64URL 算法。

JWT 的使用方式

在认证的时候,当用户用他们的凭证成功登录以后,一个JSON Web Token将会被返回。此后,token就是用户凭证了,你必须非常小心以防止出现安全问题。一般而言,你保存令牌的时候不应该超过你所需要它的时间。
客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。
此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面,因为它不使用cookie。
Authorization: Bearer token(token令牌)
另一种做法是,跨域的时候,JWT 就放在 POST 请求的数据体里面。

JWT 的几个特点

1.  JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。
2.  JWT 不加密的情况下,不能将私密数据写入 JWT。
3.  JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。
4.  JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
5.  JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
6.  为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。

JWT与Session的差异

1.  相同点是,它们都是存储用户信息;然而,Session是在服务器端的,而JWT是在客户端的。
2.  Session方式存储用户信息的最大问题在于要占用大量服务器内存,增加服务器的开销。
3.  JWT方式将用户状态分散到了客户端中,可以明显减轻服务端的内存压力。
4.  Session的状态是存储在服务器端,客户端只有sessionId;而Token的状态是存储在客户端。

基于Token的身份认证流程

基于Token的身份认证是无状态的,服务器或者Session中不会存储任何用户信息。没有会话信息意味着应用程序可以根据需要扩展和添加更多的机器,而不必担心用户登录的位置。
虽然这一实现可能会有所不同,但其主要流程如下:
用户携带用户名和密码请求访问.
服务器校验用户凭据.
应用提供一个token给客户端.
客户端存储token,并且在随后的每一次请求中都带着它.
服务器校验token并返回数据.
注意:
每一次请求都需要token,Token应该放在请求header中。

使用Token的优点

  • 无状态和可扩展性:Token存储在客户端。完全无状态,可扩展。我们的负载均衡器可以将用户传递到任意服务器,因为在任何地方都没有状态或会话信息。
  • 安全:Token不是Cookie。(The token, not a cookie.)每次请求的时候Token都会被发送。而且,由于没有Cookie被发送,还有助于防止CSRF攻击。即使在你的实现中将token存储到客户端的Cookie中,这个Cookie也只是一种存储机制,而非身份认证机制。没有基于会话的信息可以操作,因为我们没有会话。
  • token在一段时间以后会过期,这个时候用户需要重新登录。这有助于我们保持安全。还有一个概念叫token撤销,它允许我们根据相同的授权许可使特定的token甚至一组token无效。

Shiro介绍

Shiro 可以非常容易的开发出足够好的应用,其不仅可以用在 JavaSE 环境,也可以用在 JavaEE 环境。Shiro 可以帮助我们完成:认证、授权、加密、会话管理、与 Web 集成、缓存等。其基本功能点如下图所示:


Shiro整合JWT实现前后台分离下认证方案_第1张图片
image.png

• Authentication:身份认证 / 登录,验证用户是不是拥有相应的身份;
• Authorization:授权,即权限验证,验证某个已认证的用户是否拥有某个权限;即判断用户是否能做事情,常见的如:验证某个用户是否拥有某个角色。或者细粒度的验证某个用户对某个资源是否具有某个权限;
• Session Manager:会话管理,即用户登录后就是一次会话,在没有退出之前,它的所有信息都在会话中;会话可以是普通 JavaSE 环境的,也可以是如 Web 环境的;
• Cryptography:加密,保护数据的安全性,如密码加密存储到数据库,而不是明文存储;
• Web Support:Web 支持,可以非常容易的集成到 Web 环境;
• Caching:缓存,比如用户登录后,其用户信息、拥有的角色 / 权限不必每次去查,这样可以提高效率;
• Concurrency:shiro 支持多线程应用的并发验证,即如在一个线程中开启另一个线程,能把权限自动传播过去;
• Testing:提供测试支持;
• Run As:允许一个用户假装为另一个用户(如果他们允许)的身份进行访问;
• Remember Me:记住我,这个是非常常见的功能,即一次登录后,下次再来的话不用登录了。

Shiro的架构

外部架构

我们从外部来看 Shiro ,即从应用程序角度的来观察如何使用 Shiro 完成工作。如下图:


Shiro整合JWT实现前后台分离下认证方案_第2张图片
image.png

可以看到:应用代码直接交互的对象是 Subject,也就是说 Shiro 的对外 API 核心就是 Subject;其每个 API 的含义:
Subject:主体,代表了当前 “用户”,这个用户不一定是一个具体的人,与当前应用交互的任何东西都是Subject,如网络爬虫,机器人等,即一个抽象概念。所有 Subject 都绑定到 SecurityManager,与 Subject 的所有交互都会委托给 SecurityManager,可以把 Subject 认为是一个门面,SecurityManager 才是实际的执行者。
SecurityManager:安全管理器,即所有与安全有关的操作都会与 SecurityManager 交互,且它管理着所有 Subject,可以看出它是 Shiro 的核心,它负责与后边介绍的其他组件进行交互,它就相当于SpringMVC的 DispatcherServlet 前端控制器。
Realm:域,Shiro 从 Realm 获取安全数据(如用户、角色、权限),就是说 SecurityManager 要验证用户身份,那么它需要从 Realm 获取相应的用户进行比较以确定用户身份是否合法,也需要从 Realm 得到用户相应的角色 / 权限进行验证用户是否能进行操作,可以把 Realm 看成 DataSource,即安全数据源。
也就是说对于我们而言,最简单的一个 Shiro 应用:

  1. 应用代码通过 Subject 来进行认证和授权,而 Subject 又委托给 SecurityManager;
  2. 我们需要给 Shiro 的 SecurityManager 注入 Realm,从而让 SecurityManager 能得到合法的用户及其权限进行判断。
    所以Shiro 不提供维护用户 / 权限,而是通过 Realm 让开发人员自己注入。

内部架构

我们来从Shiro内部来看下 Shiro 的架构,如下图所示:

Shiro整合JWT实现前后台分离下认证方案_第3张图片
image.png

Subject:主体,可以看到主体可以是任何可以与应用交互的 “用户”;
SecurityManager:相当于 SpringMVC 中的 DispatcherServlet 或者 Struts2 中的 FilterDispatcher;是 Shiro 的心脏;所有具体的交互都通过 SecurityManager 进行控制;它管理着所有 Subject、且负责进行认证和授权、及会话、缓存的管理;
Authenticator:认证器,负责主体认证的,这是一个扩展点,如果用户觉得 Shiro 默认的不好,可以自定义实现;其需要认证策略(Authentication Strategy),即什么情况下算用户认证通过了;
Authrizer:授权器,或者访问控制器,用来决定主体是否有权限进行相应的操作;即控制着用户能访问应用中的哪些功能;
Realm:可以有 1 个或多个 Realm,可以认为是安全实体数据源,即用于获取安全实体的;可以是 JDBC 实现,也可以是 LDAP 实现,或者内存实现等等;由用户提供;注意:Shiro 不知道你的用户 / 权限存储在哪及以何种格式存储;所以我们一般在应用中都需要实现自己的 Realm;
SessionManager:如果写过 Servlet 就应该知道 Session 的概念,Session 呢需要有人去管理它的生命周期,这个组件就是 SessionManager;而 Shiro 并不仅仅可以用在 Web 环境,也可以用在如普通的 JavaSE 环境、EJB 等环境;所以呢,Shiro 就抽象了一个自己的 Session 来管理主体与应用之间交互的数据;这样的话,比如我们在 Web 环境用,刚开始是一台 Web 服务器;接着又上了台 EJB 服务器;这时想把两台服务器的会话数据放到一个地方,这个时候就可以实现自己的分布式会话(如把数据放到 Memcached 服务器);
SessionDAO:DAO 大家都用过,数据访问对象,用于会话的 CRUD,比如我们想把 Session 保存到数据库,那么可以实现自己的 SessionDAO,通过如 JDBC 写到数据库;比如想把 Session 放到 Memcached 中,可以实现自己的 Memcached SessionDAO;另外 SessionDAO 中可以使用 Cache 进行缓存,以提高性能;
CacheManager:缓存控制器,来管理如用户、角色、权限等的缓存的;因为这些数据基本上很少去改变,放到缓存中后可以提高访问的性能;
Cryptography:密码模块,Shiro 提高了一些常见的加密组件用于如密码加密 / 解密的。

Shiro身份验证

身份验证,即在应用中谁能证明他就是他本人。一般提供如他们的身份 ID 一些标识信息来表明他就是他本人,如提供身份证,用户名 / 密码来证明。
在 shiro 中,用户需要提供 principals (身份)和 credentials(证明)给 shiro,从而应用能验证用户身份:
principals:身份,即主体的标识属性,可以是任何东西,如用户名、邮箱等,唯一即可。一个主体可以有多个 principals,但只有一个 Primary principals,一般是用户名 / 密码 / 手机号。
credentials:证明 / 凭证,即只有主体知道的安全值,如密码 / 数字证书等。

最常见的 principals 和 credentials 组合就是用户名 / 密码了。接下来先进行一个基本的身份认证,另外两个相关的概念是之前提到的 Subject 及 Realm,分别是主体及验证主体的数据源。

maven依赖配置


    org.apache.shiro
    shiro-spring
    ${shiro.version}

登录、登出

准备一些用户身份

[users]
zhang=123
wang=123

此处使用 ini 配置文件,通过 [users] 指定了两个主体:zhang/123、wang/123。

@Test
public void  testLoginLoginout(){
    //1、获取SecurityManager工厂,此处使用Ini配置文件初始化SecurityManager
    Factory factory = new IniSecurityManagerFactory("classpath:shiro.ini");
    //2、得到SecurityManager实例并绑定给SecurityUtils
    SecurityManager securityManager = factory.getInstance();
    SecurityUtils.setSecurityManager(securityManager);
    //3、得到Subject及创建用户名/密码身份验证Token(即用户身份/凭证)
    Subject subject = SecurityUtils.getSubject();
    UsernamePasswordToken token = new UsernamePasswordToken("zhang", "123");
    try {
        //4、登录,即身份验证
        subject.login(token);
    } catch (AuthenticationException e) {
        //5、身份验证失败
    }
    // subject.isAuthenticated()是否已认证
    System.out.println(subject.isAuthenticated());
    //6、退出
    subject.logout();

}
•   首先通过 new IniSecurityManagerFactory 并指定一个 ini 配置文件来创建一个 SecurityManager 工厂;
•   接着获取 SecurityManager 并绑定到 SecurityUtils,这是一个全局设置,设置一次即可;
•   通过 SecurityUtils 得到 Subject,其会自动绑定到当前线程;如果在 web 环境在请求结束时需要解除绑定;然后获取身份验证的 Token,如用户名 / 密码;
•   调用 subject.login 方法进行登录,其会自动委托给 SecurityManager.login 方法进行登录;
•   如果身份验证失败请捕获 AuthenticationException 或其子类,常见的如: DisabledAccountException(禁用的帐号)、LockedAccountException(锁定的帐号)、UnknownAccountException(错误的帐号)、ExcessiveAttemptsException(登录失败次数过多)、IncorrectCredentialsException (错误的凭证)、ExpiredCredentialsException(过期的凭证)等,具体请查看其继承关系;对于页面的错误消息展示,最好使用如 “用户名 / 密码错误” 而不是 “用户名错误”/“密码错误”,防止一些恶意用户非法扫描帐号库;
•   最后可以调用 subject.logout 退出,其会自动委托给 SecurityManager.logout 方法退出。

从如上代码可总结出身份验证的步骤:

  1. 收集用户身份 / 凭证,即如用户名 / 密码;
  2. 调用 Subject.login 进行登录,如果失败将得到相应的 AuthenticationException 异常,根据异常提示用户错误信息;否则登录成功;
  3. 最后调用 Subject.logout 进行退出操作。

身份认证流程

image.png
  1. 首先调用 Subject.login(token) 进行登录,其会自动委托给 Security Manager,调用之前必须通过 SecurityUtils.setSecurityManager() 设置;
  2. SecurityManager 负责真正的身份验证逻辑;它会委托给 Authenticator 进行身份验证;
  3. Authenticator 才是真正的身份验证者,Shiro API 中核心的身份认证入口点,此处可以自定义插入自己的实现;
  4. Authenticator 可能会委托给相应的 AuthenticationStrategy 进行多 Realm 身份验证,默认 ModularRealmAuthenticator 会调用 AuthenticationStrategy 进行多 Realm 身份验证;
  5. Authenticator 会把相应的 token 传入 Realm,从 Realm 获取身份验证信息,如果没有返回 / 抛出异常表示身份验证失败了。此处可以配置多个 Realm,将按照相应的顺序及策略进行访问。

基于SpringBoot,Shiro整合JWT

Servlet的Session机制

Shiro在JavaWeb中使用到的就是默认的Servlet的Session机制,大致流程如下:


Shiro整合JWT实现前后台分离下认证方案_第4张图片
image.png
  1. 用户首次发请求。
  2. 服务器接收到请求之后,无论你有没有权限访问到资源,在返回响应的时候,服务器都会生成一个Session用来储存该用户的信息,然后生成SessionId作为对应的Key。
  3. 服务器会在响应中,用jsessionId这个名字,把这个SessionId以Cookie的方式发给客户(就是Set-Cookie响应头)。
  4. 由于已经设置了Cookie,下次访问的时候,服务器会自动识别到这个SessionId然后找到你上次对应的Session。

Shiro的session机制

而结合Shiro之后,上面的第二步和第三步会发生小变化:
• 第二步,服务器不但会创建Session,还会创建一个Subject对象(就是Shiro中用来代表当前用户的类),也用这个SessionId作为Key绑定。
• 第三步,第二次接受到请求的时候,Shiro会从请求头中找到SessionId,然后去寻找对应的Subject然后绑定到当前上下文,这时候Shiro就能知道来访的是谁了。

Shiro整合JWT

思想就是用JWT token来代替原本返回的session。

工作流程

Shiro整合JWT实现前后台分离下认证方案_第5张图片
image.png
  1. 用户登录。
  2. 若成功则shiro会默认生成一个SessionId用来匹配当前Subject对象,则我们将这个SessionId放入JWT中。
  3. 返回JWT。
  4. 用户第二次携带JWT来访问接口。
  5. 服务器解析JWT,获得SessionId。
  6. 服务器把SessionId交给Shiro执行相关认证。

实现代码

pom.xml里写入



    com.auth0
    java-jwt
    3.4.3




    org.apache.shiro
    shiro-spring
    1.4.0

JWTUtil工具类.作用:用于生成token,验证、解析token里面的sessionId。

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.Date;

public class JWTUtil {
    /**
     * 过期时间 1 小时
     */
    private static final long EXPIRE_TIME = 60 * 60 * 1000;
    /**
     * 密钥
     */
    private static final String SECRET = "price";

    /**
     * 生成 token
     */
    public static String createToken(String sessionId) {
        Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        Algorithm algorithm = Algorithm.HMAC256(SECRET);
        // 附带sessionId信息
        return JWT.create()
                .withClaim("sessionId", sessionId)
                //到期时间
                .withExpiresAt(date)
                //创建一个新的JWT,并使用给定的算法进行标记
                .sign(algorithm);
    }

    /**
     * 校验 token 是否正确
     */
    public static boolean verify(String token, String sessionId) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(SECRET);
            //在token中附带了sessionId信息
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("sessionId", sessionId)
                    .build();
            //验证 token
            verifier.verify(token);
            return true;
        } catch (Exception exception) {
            return false;
        }
    }
    
    /**
     * 获得token中的信息,无需secret解密也能获得
     */
    public static String getSessionId(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("sessionId").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }
}

自定义JWTFilter配置
作用:拦截所有请求,验证是否携带token,token是否有效,无效返回401状态码。

import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.UnauthorizedException;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;

@Slf4j
public class JWTFilter extends BasicHttpAuthenticationFilter {

    /**
     * 这个方法首先执行,对跨域提供支持.
     */
    @Override
    public boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
        // 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.onPreHandle(request, response, mappedValue);
    }

    /**
     * onPreHandle方法执行后,执行这个方法,判断是否登录,返回true,允许对接口的访问,返回false,执行onAccessDenied方法。如果带有token,则对token进行检查.
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException {
        return super.isAccessAllowed(request, response, mappedValue);
    }

    /**
     * @description: 校验token判断是否拒绝访问,校验失败返回401
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;
        // 从header里面获取token
        String token = req.getHeader("token");
        // 如果header里面token为空,从url里面获取token
        if (!Optional.ofNullable(token).isPresent()) {
            token = req.getParameter("token");
        }
        if (Optional.ofNullable(token).isPresent()) {
            String sessionId = JWTUtil.getSessionId(token);
            if (!JWTUtil.verify(token, sessionId)) {
                res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                return false;
            }
        }
        res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        return false;
    }
}

重写内置BasicHttpAuthenticationFilter过滤器

1.  protected boolean onPreHandle(ServletRequest request, ServletResponse response) throws Exception,预处理,进行验证之前执行的方法,可以理解为该过滤器最先执行的方法。该方法执行后执行isAccessAllowed方法。
2.  protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue),该方法用于判断是否登录,BasicHttpAuthenticationFilter底层是通过subject.isAuthenticated()方法判断的是否登录的。该方法返回值:如果未登录,返回false, 进入onAccessDenied。如果登录了,返回true, 允许访问,不用继续验证,可以访问接口获取数据。
3.  protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception判断是否拒绝访问。个人理解就是当用户没有登录访问该过滤器的过滤的接口时,就必须进行httpBasic验证。

自定义Realm:CustomRealm配置
作用:用于用户登录认证。

import com.iot.sys.model.SysRole;
import com.iot.sys.model.SysRoleUser;
import com.iot.sys.model.SysUser;
import com.iot.sys.model.vo.SessionVO;
import com.iot.sys.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import java.util.Optional;

@Slf4j
public class CustomRealm extends AuthorizingRealm {

    @Autowired
    @Qualifier("userServiceImpl")
    private UserService userService;

    /**
     * 默认使用此方法进行用户名正确与否验证。
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        log.info("————身份认证————");
        String principal = (String) authenticationToken.getPrincipal();
        SysUser sysUser = userService.selectUserByName(principal);
        if (Optional.ofNullable(sysUser).isPresent()) {
            if (principal.equals(sysUser.getUsername())) {
                SessionVO sessionVO = new SessionVO();
                BeanUtils.copyProperties(sysUser, sessionVO);
                SysRoleUser roleUser = userService.selectRoleUserByUserId(sysUser.getUserId());
                Integer flag = roleUser.getFlag();
                SysRole sysRole = userService.selectRoleByRoleId(roleUser.getSysRoleId());
                sessionVO.setSystemName(sysRole.getName());
                return new SimpleAuthenticationInfo(sessionVO, sysUser.getPassword(), ByteSource.Util.bytes(sysUser.getSalt()), this.getName());
            }
        }
        return null;
    }

    /**
     * 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        log.info("————权限认证————");
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        return info;
    }
}

该方法在登录验证的时候自动调用,登录方法的参数authenticationToken就是subject.login(token)里面的token,我们通过该token获取传入的用户名,用该用户名查出用户密码信息。如果该用户不存在,返回null,如果存在,将该用户的密码封装在AuthenticationInfo中返回,用于shiro判断密码是否正确。需要注意的是,AuthenticationInfo第一个参数是用于传递给授权方法使用的,与登录验证基本没有关系,不用和登录的token参数保持一致。简单来说,登录方法的原理就是通过用户名获取到用户过后,代表用户名验证成功,将用户密码返回用于密码验证(这一步是shiro内部完成),所以就不必保持传入AuthenticationToken的第一个参数与AuthenticationInfo的第一个参数保持一致了,因为通过用户名没有获取到用户密码这些信息就已经表示用户名验证失败了。需要注意的是AuthenticationInfo的第一个参数虽然可以传任意对象,但是该对象必须对获取该用户的角色与权限有帮助,这是第一个参数最主要的作用。
使用MD5+salt:实际应用是将盐和散列后的值存在数据库中,自定义Realm从数据库取出盐和加密后的值由shiro完成密码校验。

new SimpleAuthenticationInfo(sessionVO, sysUser.getPassword(), ByteSource.Util.bytes(sysUser.getSalt()), this.getName());

自定义SessionManager:MySessionManager配置
作用:之前的Session的获取,就是在DefaultWebSessionManager里实现的,所以我们现在只需要重写这个类,把验证token,解析token里面的sessionId,把sessionId返回的逻辑写进去。

import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.Serializable;
import java.util.Optional;

import static org.apache.shiro.web.servlet.ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE;

public class MySessionManager extends DefaultWebSessionManager {

    public MySessionManager() {
        super();
    }

    @Override
    protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
        String token = WebUtils.toHttp(request).getHeader("token");
        // 如果header里面token为空,从url里面获取token
        if (!Optional.ofNullable(token).isPresent()) {
            token = WebUtils.toHttp(request).getParameter("token");
        }
        if (Optional.ofNullable(token).isPresent()) {
            String sessionId = JWTUtil.getSessionId(token);
            if (Optional.ofNullable(sessionId).isPresent()) {
                if (JWTUtil.verify(token, sessionId)) {
                    request.setAttribute(REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
                    request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId);
                    request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
                    return sessionId;
                }
            }
        }
        return null;
    }
}

ShiroConfig配置并配置JWTFilter
作用:配置过滤器,拿到所有请求,放行不需要拦截的请求,注入自定义的

CustomRealm、MySessionManager。
import com.iot.common.shiro.service.CustomRealm;
import com.iot.common.shiro.service.JWTFilter;
import com.iot.common.shiro.service.MySessionManager;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.LinkedHashMap;
import java.util.Map;

@Configuration
public class ShiroConfig {

    @Bean
    public ShiroFilterFactoryBean factory(SecurityManager securityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
        // 添加自己的过滤器并且取名为jwt
        Map filterMap = new LinkedHashMap<>();
        //设置我们自定义的JWT过滤器
        filterMap.put("jwt", new JWTFilter());
        factoryBean.setFilters(filterMap);
        factoryBean.setSecurityManager(securityManager);
        Map filterRuleMap = new LinkedHashMap<>();
        //放行登录接口和其他不需要权限的接口
        filterRuleMap.put("/login/**", "anon");
        // 解析war包回调接口放行
        filterRuleMap.put("/price/select-price-name", "anon");
        // 映射路径访问本地pic目录放行
        filterRuleMap.put("/pic/**", "anon");
        // 打印接口
        filterRuleMap.put("/exprotPrintExcelDownload", "anon");
        filterRuleMap.put("/file/downloadTemplates", "anon");
        // 静态资源放行
        filterRuleMap.put("/static/**", "anon");
        // 所有请求通过我们自己的JWT Filter
        filterRuleMap.put("/**", "jwt");
        factoryBean.setFilterChainDefinitionMap(filterRuleMap);
        return factoryBean;
    }

    /**
     * 注入 securityManager
     */
    @Bean
    public SecurityManager securityManager(CustomRealm customRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 设置自定义 realm.
        securityManager.setRealm(customRealm);
        // 设置自定义会话管理器
        securityManager.setSessionManager(new MySessionManager());
        return securityManager;
    }

    @Bean
    public CustomRealm getCustomRealm() {
        CustomRealm customRealm = new CustomRealm();
        // 修改凭证校验匹配器
        HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
        // 设置加密算法为MD5
        credentialsMatcher.setHashAlgorithmName("MD5");
        // 设置散列次数
        credentialsMatcher.setHashIterations(1024);
        customRealm.setCredentialsMatcher(credentialsMatcher);
        return customRealm;
    }

shiro内置过滤器如下:内置过滤器都在org.apache.shiro.web.filter.mgt.DefaultFilter枚举下面。

Shiro整合JWT实现前后台分离下认证方案_第6张图片
image.png

Controller层
登录逻辑

@PostMapping("")
public ResultData login(@RequestBody UserVO vo) {
    Subject subject = ShiroUtils.getSubjct();
    UsernamePasswordToken token = new UsernamePasswordToken(vo.getUsername(), vo.getPassword());
    try {
        subject.login(token);
        log.info("用户认证成功!!!");
    } catch (UnknownAccountException | IncorrectCredentialsException e) {
        log.info("用户名或者密码错误!");
        return ResultData.error("用户名或者密码错误!");
    }
    Session session = subject.getSession();
    String sessionId = session.getId().toString();
    String toke = JWTUtil.createToken(sessionId);
    return ResultData.success(toke);
}

主要是:在登录成功之后把这个Subject的SessionId放入JWT然后生成token。
String token = JWTUtil.createToken(sessionId);
以后我们就可以通过解析JWT来获取SessionId了,而不是每次把SessionId作为Cookie返回。
退出逻辑
首先,由于JWT令牌本身就会失效,所以如果JWT令牌失效,也就相当与退出了。
然后我们还可以同样实现Shiro中传统的手动登出:

@GetMapping("logout")
public ResultData loginOut() {
    ShiroUtils.getSubjct().logout();
    return ResultData.success();
}

这样的话Realm中的用户状态就变成未认证了,就算JWT没过期也需要重新登录了。
测试

登录

Shiro整合JWT实现前后台分离下认证方案_第7张图片
image.png

获取到了JWT,JWT里面就带有SessionId。

不带token请求访问

Shiro整合JWT实现前后台分离下认证方案_第8张图片
image.png

因为不能获得token所以无法得到该用户对应的sessionId,所以被授权拦截了

携带token请求访问

Shiro整合JWT实现前后台分离下认证方案_第9张图片
image.png

登出

退出

Shiro整合JWT实现前后台分离下认证方案_第10张图片
image.png

退出成功

再次携带token请求访问

Shiro整合JWT实现前后台分离下认证方案_第11张图片
image.png

测试成功!

你可能感兴趣的:(Shiro整合JWT实现前后台分离下认证方案)