热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

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

[TOC]有状态VS无状态几乎绝大部分的应用都需要实现认证与授权,例如用户使用账户密码登录就是一个认证过程,认证登录成功后系统才会允许用户

[TOC]


有状态 VS 无状态

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

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

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

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

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

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

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

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

微服务认证方案

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

1、“处处安全” 方案:

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

OAuth3的代表实现框架:

  • Spring Cloud Security
  • Jboss Keycloak

参考文章:

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

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

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

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

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

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

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

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

5、方案的对比与选择:

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

6、访问控制模型

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

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

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

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

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

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

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

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

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

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

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

  5. 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和用户基本信息:
微服务的用户认证与授权杂谈(上)

校验登录态:
微服务的用户认证与授权杂谈(上)

Tips:

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


后续:

  • 微服务的用户认证与授权杂谈(下)

推荐阅读
  • Redis:缓存与内存数据库详解
    本文介绍了数据库的基本分类,重点探讨了关系型与非关系型数据库的区别,并详细解析了Redis作为非关系型数据库的特点、工作模式、优点及持久化机制。 ... [详细]
  • Hibernate全自动全映射ORM框架,旨在消除sql,是一个持久层的ORM框架1)、基础概念DAO(DataAccessorOb ... [详细]
  • 七大策略降低云上MySQL成本
    在全球经济放缓和通胀压力下,降低云环境中MySQL数据库的运行成本成为企业关注的重点。本文提供了一系列实用技巧,旨在帮助企业有效控制成本,同时保持高效运作。 ... [详细]
  • 本文探讨了如何通过Service Locator模式来简化和优化在B/S架构中的服务命名访问,特别是对于需要频繁访问的服务,如JNDI和XMLNS。该模式通过缓存机制减少了重复查找的成本,并提供了对多种服务的统一访问接口。 ... [详细]
  • 深入理解:AJAX学习指南
    本文详细探讨了AJAX的基本概念、工作原理及其在现代Web开发中的应用,旨在为初学者提供全面的学习资料。 ... [详细]
  • 实践指南:使用Express、Create React App与MongoDB搭建React开发环境
    本文详细介绍了如何利用Express、Create React App和MongoDB构建一个高效的React应用开发环境,旨在为开发者提供一套完整的解决方案,包括环境搭建、数据模拟及前后端交互。 ... [详细]
  • PHP面试题精选及答案解析
    本文精选了新浪PHP笔试题及最新的PHP面试题,并提供了详细的答案解析,帮助求职者更好地准备PHP相关的面试。 ... [详细]
  • 电商高并发解决方案详解
    本文以京东为例,详细探讨了电商中常见的高并发解决方案,包括多级缓存和Nginx限流技术,旨在帮助读者更好地理解和应用这些技术。 ... [详细]
  • MySQL Administrator: 监控与管理工具
    本文介绍了 MySQL Administrator 的主要功能,包括图形化监控 MySQL 服务器的实时状态、连接健康度、内存健康度以及如何创建自定义的健康图表。此外,还详细解释了状态变量和系统变量的管理。 ... [详细]
  • Centos下安装memcached+memcached教程
    本文介绍了在Centos下安装memcached和使用memcached的教程,详细解释了memcached的工作原理,包括缓存数据和对象、减少数据库读取次数、提高网站速度等。同时,还对memcached的快速和高效率进行了解释,与传统的文件型数据库相比,memcached作为一个内存型数据库,具有更高的读取速度。 ... [详细]
  • 随着Linux操作系统的广泛使用,确保用户账户及系统安全变得尤为重要。用户密码的复杂性直接关系到系统的整体安全性。本文将详细介绍如何在CentOS服务器上自定义密码规则,以增强系统的安全性。 ... [详细]
  • 本文探讨了如何在 Spring MVC 框架下,通过自定义注解和拦截器机制来实现细粒度的权限管理功能。 ... [详细]
  • RTThread线程间通信
    线程中通信在裸机编程中,经常会使用全局变量进行功能间的通信,如某些功能可能由于一些操作而改变全局变量的值,另一个功能对此全局变量进行读取& ... [详细]
  • Linux一键安装web环境全攻略
    摘自阿里云服务器官网,此处一键安装包下载:点此下载安装须知1、此安装包可在阿里云所有Linux系统上部署安装,此安装包包含的软件及版本为& ... [详细]
  • 都说Python处理速度慢,为何月活7亿的 Instagram依然在使用Python?
    点击“Python编程与实战”,选择“置顶公众号”第一时间获取Python技术干货!来自|简书作者|我爱学python链接|https:www.jian ... [详细]
author-avatar
滴答滴答箫雨伞_335
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有