微服务的用户认证与授权杂谈

[TOC]


有状态 VS 无状态

几乎绝大部分的应用都需要实现认证与授权,例如用户使用账户密码登录就是一个认证过程,认证登录成功后系统才会允许用户访问其账户下的相关资源,这就是所谓的授权。而复杂点的情况就是用户会有角色概念,每个角色所拥有的权限不同,给用户赋予某个角色的过程也是一个授权过程。

用户的登录态在服务器端分为有状态和无状态两种模式,在单体分布式架构的时代,我们为了能让Session信息在多个Tomcat实例之间共享,通常的解决方案是将Session存储至一个缓存数据库中。即下图中的Session Store,这个Session Store可以是Redis也可以是MemCache,这种模式就是有状态的:


微服务的用户认证与授权杂谈_第1张图片
image.png

之所以说是有状态,是因为服务端需要维护、存储这个Session信息,即用户的登录态实际是在服务端维护的,所以对服务端来说可以随时得知用户的登录态,并且对用户的Session有比较高的控制权。有状态模式的缺点主要是在于这个Session Store上,如果作为Session Store的服务只有一个节点的话,当业务扩展、用户量增多时就会有性能瓶颈问题,而且数据迁移也比较麻烦。当然也可以选择去增加节点,只不过就需要投入相应的机器成本了。

另一种无状态模式,指的是服务器端不去记录用户的登录状态,也就是服务器端不再去维护一个Session。而是在用户登录成功的时候,颁发一个token给客户端,之后客户端的每个请求都需要携带token。服务端会对客户端请求时所携带的token进行解密,校验token是否合法以及是否已过期等等。token校验成功后则认为用户是具有登录态的,否则认为用户未登录:


微服务的用户认证与授权杂谈_第2张图片
image.png

注:token通常会存储用户的唯一ID,解密token就是为了获取用户ID然后去缓存或者数据库中查询用户数据。当然也可以选择将用户数据都保存在token中,只不过这种方式可能会有安全问题或数据一致性问题

无状态模式下的token其实和有状态模式下的session作用是类似的,都是判断用户是否具有登录态的一个凭证。只不过在无状态模式下,服务器端不需要再去维护、存储一个Session,只需要对客户端携带的token进行解密和校验。也就是说存储实际是交给了客户端完成,所以无状态的优点恰恰就是弥补了有状态的缺点。但是无状态的缺点也很明显,因为一旦把token交给客户端后,服务端就无法去控制这个token了。例如想要强制下线某个用户在无状态的模式下就比较难以实现。

有状态与无状态各有优缺点,只不过目前业界趋势更倾向于无状态:

优缺点 有状态 无状态
优点 服务端控制能力强 去中心化,无存储,简单,任意扩容、缩容
缺点 存在中心点,鸡蛋放在一个篮子里,迁移麻烦。服务端存储数据,加大了服务端压力 服务端控制能力相对弱

微服务认证方案

微服务认证方案有很多种,需要根据实际的业务需求定制适合自己业务的方案,这里简单列举一下业界内常用的微服务认证方案。

1、“处处安全” 方案:

所谓“处处安全” 方案,就是考虑了微服务认证中的方方面面,这种方案主流是使用OAuth2协议进行实现。这种方案的优点是安全性好,但是实现的成本及复杂性比较高。另外,多个微服务之间互相调用需要传递token,所以会发生多次认证,有一定的性能开销

OAuth2的代表实现框架:

  • Spring Cloud Security
  • Jboss Keycloak

参考文章:

  • OAuth2实现单点登录SSO
  • OAuth 2.0系列教程

2、外部无状态,内部有状态方案:

这种方案虽然看着有些奇葩,但是也许多公司在使用。在该方案下,网关不存储Session,而是接收一个token和JSESSIONID,网关仅对token进行解密、校验,然后将JSESSIONID转发到其代理的微服务上,这些微服务则是通过JSESSIONID从Session Store获取共享Session。如下图:


微服务的用户认证与授权杂谈_第3张图片
image.png

这种方案主要是出现在内部有旧的系统架构的情况,在不重构或者没法全部重构的前提下为了兼容旧的系统,就可以采用该方案。而且也可以将新旧系统分为两块,网关将token和JSESSIONID一并转发到下游服务,这样无状态模式的系统则使用token,有状态模式的系统则使用Session,然后再慢慢地将旧服务进行重构以此实现一个平滑过渡。如下图:


微服务的用户认证与授权杂谈_第4张图片
image.png

3、“网关认证授权,内部裸奔” 方案:

在该方案下,认证授权在网关完成,下游的微服务不需要进行认证授权。网关接收到客户端请求所携带的token后,对该token进行解密和校验,然后将解密出来的用户信息转发给下游微服务。这种方案的优点是实现简单、性能也好,缺点是一旦网关被攻破,或者能越过网关访问微服务就会有安全问题。如下图:


微服务的用户认证与授权杂谈_第5张图片
image.png

4、“内部裸奔” 改进方案:

上一个方案的缺陷比较明显,我们可以对该方案进行一些改进,例如引入一个认证授权中心服务,让网关不再做认证和授权以及token的解密和解析。用户的登录请求通过网关转发到认证授权中心完成登录,登录成功后由认证授权中心颁发token给客户端。客户端每次请求都携带token,而每个微服务都需要对token进行解密和解析,以确定用户的登录态。改进之后所带来的好处就是网关不再关注业务,而是单纯的请求做转发,可以在一定程度上解耦业务,并且也更加安全,因为每个微服务不再裸奔而是都需要验证请求中所携带的token。如下图:


微服务的用户认证与授权杂谈_第6张图片
image.png

5、方案的对比与选择:

以上所提到的常见方案只是用于抛砖引玉,没有哪个方案是绝对普适的。而且实际开发中通常会根据业务改进、组合这些方案演变出不同的变种,所以应该要学会活学活用而不是局限于某一种方案。下面简单整理了一下这几种方案,以便做对比:


微服务的用户认证与授权杂谈_第7张图片
image.png

6、访问控制模型

了解了常见的微服务认证方案后,我们来简单看下访问控制模型。所谓访问控制,就是用户需要满足怎么样的条件才允许访问某个系统资源,即控制系统资源的访问权限。访问控制模型主要有以下几种:

  1. Access Control List(ACL,访问控制列表):

在该模型下的一个系统资源会包含一组权限列表,该列表规定了哪些用户拥有哪些操作权限。例如有一个系统资源包含的权限列表为:[Alice: read, write; Bob: read];那么就表示Alice这个用户对该资源拥有read和write权限,而Bob这个用户则对该资源拥有read权限。该模型通常用于文件系统

  1. Role-based access control(RBAC,基于角色的访问控制):

即用户需关联一个预先定义的角色,而不同的角色拥有各自的权限列表。用户登录后只需要查询其关联的角色就能查出该用户拥有哪些权限。例如用户A关联了一个名为观察者的角色,该角色下包含接口A和接口B的访问权限,那么就表示用户A仅能够访问A和接口B。该模型在业务系统中使用得最多

  1. Attribute-based access control(ABAC,基于属性的访问控制):

在该模型下,用户在访问某个系统资源时会携带一组属性值包括自身属性、主题属性、资源属性以及环境属性等。然后系统通过动态计算用户所携带的属性来判断是否满足具有访问某个资源的权限。属性通常来说分为四类:用户属性(如用户年龄),环境属性(如当前时间),操作属性(如读取)以及对象属性等。

为了能让系统进行权限控制,在该模型下需要以特定的格式定义权限规则,例如:IF 用户是管理员; THEN 允许对敏感数据进行读/写操作。在这条规则中“管理员”是用户的角色属性,而“读/写”是操作属性,”敏感数据“则是对象属性。

ABAC有时也被称为PBAC(Policy-Based Access Control,基于策略的访问控制)或CBAC(Claims-Based Access Control,基于声明的访问控制)。该模型由于比较复杂,使用得不多,k8s也因为ABAC太复杂而在1.8版本改为使用RBAC

  1. Rules-based access control(RBAC,基于规则的访问控制):

在该模型下通过对某个系统资源事先定义一组访问规则来实现访问控制,这些规则可以是参数、时间、用户信息等。例如:只允许从特定的IP地址访问或拒绝从特定的IP地址访问

  1. Time-based access control list(TBACL,基于时间的访问控制列表):

该模型是在ACL的基础上添加了时间的概念,可以设置ACL权限在特定的时间才生效。例如:只允许某个系统资源在工作日时间内才能被外部访问,那么就可以将该资源的ACL权限的有效时间设置为工作日时间内


JWT

之前提到过无状态模式下,服务器端需要生成一个Token颁发给客户端,而目前主流的方式就是使用JWT的标准来生成Token,所以本小节我们来简单了解下JWT及其使用。

JWT简介:

JWT是JSON Web Token的缩写,JWT实际是一个开放标准(RFC 7519),用来在各方之间安全地传输信息,是目前最流行的跨域认证解决方案。JWT可以被验证和信任,因为它是数字签名的。官网:https://jwt.io/

JWT的组成结构:

组成 作用 内容示例
Header(头) 记录Token类型、签名的算法等 {"alg": "HS256", "type": "JWT"}
Payload(有效载荷) 携带一些用户信息及Token的过期时间等 {"user_id": "1", "iat": 1566284273, "exp": 1567493873}
Signature(签名) 签名算法生成的数字签名,用于防止Token被篡改、确保Token的安全性 WV5Hhymti3OgIjPprLJKJv3aY473vyxMLeM8c7JLxSk

JWT生成Token的公式:

Token = Base64(Header).Base64(Payload).Base64(Signature)

示例:eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJpYXQiOjE1NjYyODIyMjMsImV4cCI6MTU2NzQ5MTgyM30.OtCOFqWMS6ZOzmwCs7NC7hs9u043P-09KbQfZBov97E

签名是使用Header里指定的签名算法生成的,公式如下:

Signature = 签名算法((Base64(Header).Base64(Payload), 秘钥))


使用JWT:

1、目前Java语言有好几个操作JWT的第三方库,这里采用其中较为轻巧的jjwt作为演示。首先添加依赖如下:


  io.jsonwebtoken
  jjwt-api
  0.10.7


  io.jsonwebtoken
  jjwt-impl
  0.10.7
  runtime


  io.jsonwebtoken
  jjwt-jackson
  0.10.7
  runtime

2、编写一个工具类,将JWT的操作都抽取出来,方便在项目中的使用。具体代码如下:

package com.zj.node.usercenter.util;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.Map;

/**
 * JWT 工具类
 *
 * @author 01
 * @date 2019-08-20
 **/
@Slf4j
@Component
@RequiredArgsConstructor
@SuppressWarnings("WeakerAccess")
public class JwtOperator {
    /**
     * 秘钥
     * - 默认5d1IB9SiWd5tjBx&EMi^031CtigL!6jJ
     */
    @Value("${jwt.secret:5d1IB9SiWd5tjBx&EMi^031CtigL!6jJ}")
    private String secret;
    /**
     * 有效期,单位秒
     * - 默认2周
     */
    @Value("${jwt.expire-time-in-second:1209600}")
    private Long expirationTimeInSecond;

    /**
     * 从token中获取claim
     *
     * @param token token
     * @return claim
     */
    public Claims getClaimsFromToken(String token) {
        try {
            return Jwts.parser()
                    .setSigningKey(this.secret.getBytes())
                    .parseClaimsJws(token)
                    .getBody();
        } catch (ExpiredJwtException | UnsupportedJwtException |
                MalformedJwtException | IllegalArgumentException e) {
            log.error("token解析错误", e);
            throw new IllegalArgumentException("Token invalided.");
        }
    }

    /**
     * 获取token的过期时间
     *
     * @param token token
     * @return 过期时间
     */
    public Date getExpirationDateFromToken(String token) {
        return getClaimsFromToken(token)
                .getExpiration();
    }

    /**
     * 判断token是否过期
     *
     * @param token token
     * @return 已过期返回true,未过期返回false
     */
    private Boolean isTokenExpired(String token) {
        Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }

    /**
     * 计算token的过期时间
     *
     * @return 过期时间
     */
    private Date getExpirationTime() {
        return new Date(System.currentTimeMillis() + this.expirationTimeInSecond * 1000);
    }

    /**
     * 为指定用户生成token
     *
     * @param claims 用户信息
     * @return token
     */
    public String generateToken(Map claims) {
        Date createdTime = new Date();
        Date expirationTime = this.getExpirationTime();


        byte[] keyBytes = secret.getBytes();
        SecretKey key = Keys.hmacShaKeyFor(keyBytes);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(createdTime)
                .setExpiration(expirationTime)
                // 你也可以改用你喜欢的算法
                // 支持的算法详见:https://github.com/jwtk/jjwt#features
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    /**
     * 判断token是否非法
     *
     * @param token token
     * @return 未过期返回true,否则返回false
     */
    public Boolean validateToken(String token) {
        return !isTokenExpired(token);
    }
}

3、若默认的配置不符合需求,可以通过在配置文件中添加如下配置进行自定义:

jwt:
  # 秘钥
  secret: 5d1IB9SiWd5tjBx&EMi^031CtigL!6jJ
  # jwt有效期,单位秒
  expire-time-in-second: 1209600

4、完成以上步骤后,就可以在项目中使用JWT了,这里提供了一个比较全面的测试用例,可以参考测试用例来使用该工具类。代码如下:

package com.zj.node.usercenter.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.security.SignatureException;
import org.apache.tomcat.util.codec.binary.Base64;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.HashMap;
import java.util.Map;

/**
 * JwtOperator 测试用例
 *
 * @author 01
 * @date 2019-08-20
 **/
@SpringBootTest
@RunWith(SpringRunner.class)
public class JwtOperatorTests {

    @Autowired
    private JwtOperator jwtOperator;

    private String token = "";

    @Before
    public void generateTokenTest() {
        // 设置用户信息
        Map objectObjectHashMap = new HashMap<>();
        objectObjectHashMap.put("id", "1");

        // 测试1: 生成token
        this.token = jwtOperator.generateToken(objectObjectHashMap);
        // 会生成类似该字符串的内容: eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjEiLCJpYXQiOjE1NjU1ODk4MTcsImV4cCI6MTU2Njc5OTQxN30.27_QgdtTg4SUgxidW6ALHFsZPgMtjCQ4ZYTRmZroKCQ
        System.out.println(this.token);
    }

    @Test
    public void validateTokenTest() {
        // 测试2: 如果能token合法且未过期,返回true
        Boolean validateToken = jwtOperator.validateToken(this.token);
        System.out.println("token校验结果:" + validateToken);
    }

    @Test
    public void getClaimsFromTokenTest() {
        // 测试3: 解密token,获取用户信息
        Claims claims = jwtOperator.getClaimsFromToken(this.token);
        System.out.println(claims);
    }

    @Test
    public void decodeHeaderTest() {
        // 获取Header,即token的第一段(以.为边界)
        String[] split = this.token.split("\\.");
        String encodedHeader = split[0];

        // 测试4: 解密Header
        byte[] header = Base64.decodeBase64(encodedHeader.getBytes());
        System.out.println(new String(header));
    }

    @Test
    public void decodePayloadTest() {
        // 获取Payload,即token的第二段(以.为边界)
        String[] split = this.token.split("\\.");
        String encodedPayload = split[1];

        // 测试5: 解密Payload
        byte[] payload = Base64.decodeBase64(encodedPayload.getBytes());
        System.out.println(new String(payload));
    }

    @Test(expected = SignatureException.class)
    public void validateErrorTokenTest() {
        try {
            // 测试6: 篡改原本的token,因此会报异常,说明JWT是安全的
            jwtOperator.validateToken(this.token + "xx");
        } catch (SignatureException e) {
            e.printStackTrace();
            throw e;
        }
    }
}

若希望了解各类的JWT库,可以参考如下文章:

  • 各类JWT库(java)的使用与评价

使用JWT实现认证授权

了解了JWT后,我们来使用JWT实现一个认证授权Demo,首先定义一个DTO,其结构如下:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginRespDTO {
    /**
     * 昵称
     */
    private String userName;

    /**
     * token
     */
    private String token;

    /**
     * 过期时间
     */
    private Long expirationTime;
}

然后编写Service,提供模拟登录和模拟检查用户登录态的方法。具体代码如下:

@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

    private final JwtOperator jwtOperator;

    /**
     * 模拟用户登录
     */
    public LoginRespDTO login(String userName, String password) {
        String defPassword = "123456";
        if (!defPassword.equals(password)) {
            return null;
        }

        // 密码验证通过颁发token
        Map userInfo = new HashMap<>();
        userInfo.put("userName", userName);
        String token = jwtOperator.generateToken(userInfo);

        return LoginRespDTO.builder()
                .userName(userName)
                .token(token)
                .expirationTime(jwtOperator.getExpirationDateFromToken(token).getTime())
                .build();
    }

    /**
     * 模拟登录态验证
     */
    public String checkLoginState(String token) {
        if (jwtOperator.validateToken(token)) {
            Claims claims = jwtOperator.getClaimsFromToken(token);
            String userName = claims.get("userName").toString();

            return String.format("用户 %s 的登录态验证通过,允许访问", userName);
        }

        return "登录态验证失败,token无效或过期";
    }
}

接着是Controller层,开放相应的Web接口。代码如下:

@Slf4j
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @PostMapping("/login")
    public LoginRespDTO login(@RequestParam("userName") String userName,
                              @RequestParam("password") String password) {
        return userService.login(userName, password);
    }

    @GetMapping("/checkLoginState")
    public String checkLoginState(@RequestParam("token") String token) {
        return userService.checkLoginState(token);
    }
}

用户登录成功,返回Token和用户基本信息:


微服务的用户认证与授权杂谈_第8张图片
image.png

校验登录态:


微服务的用户认证与授权杂谈_第9张图片
image.png

Tips:

本小节只是给出了一个极简的例子,目的是演示如何使用JWT实现用户登录成功后颁发Token给客户端以及通过Token验证用户的登录态,这样大家完全可以通过之前提到过的方案进行拓展。通常来说Token颁发给客户端后,客户端在后续的请求中是将Token放在HTTP Header里进行传递的,而不是示例中的参数传递。微服务之间的Token传递也是如此,一个微服务在向另一个微服务发请求之前,需要先将Token放进本次请求的HTTP Header里。另外,验证Token的逻辑一般是放在一个全局的过滤器或者拦截器中,这样就不需要每个接口都写一遍验证逻辑。

你可能感兴趣的:(微服务的用户认证与授权杂谈)