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

[TOC]html


有状态 VS 无状态

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

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

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

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

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

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

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

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

微服务认证方案

微服务认证方案有不少种,须要根据实际的业务需求定制适合本身业务的方案,这里简单列举一下业界内经常使用的微服务认证方案。数据库

一、“到处安全” 方案:apache

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

OAuth2的表明实现框架:

参考文章:

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

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

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

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

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

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

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

五、方案的对比与选择:

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

六、访问控制模型

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

  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:

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

<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.10.7</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.10.7</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.10.7</version>
  <scope>runtime</scope>
</dependency>

二、编写一个工具类,将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<String, Object> 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);
    }
}

三、若默认的配置不符合需求,能够经过在配置文件中添加以下配置进行自定义:

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

四、完成以上步骤后,就能够在项目中使用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<String, Object> 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实现认证受权

了解了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<String, Object> 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的逻辑通常是放在一个全局的过滤器或者拦截器中,这样就不须要每一个接口都写一遍验证逻辑。


后续:

相关文章
相关标签/搜索