• 前后端分离应用——用户信息传递


    前言

    记录前后端分离的系统应用下应用场景————用户信息传递

    需求缘起

    照例先看看web系统的一张经典架构图,这张图参考自网络:

    在 Dubbo 自定义异常,你是怎么处理的? 中已经对该架构做了简单说明,这里不再描述。

    简单描述下在该架构中用户信息(如userId)的传递方式

    现在绝大多数的项目都是前后端分离的开发模式,采用token方式进行用户鉴权:

    • 客户端(pc,移动端,平板等)首次登录,服务端签发token,在token中放入用户信息(如userId)等返回给客户端
    • 客户端访问服务端接口,需要在头部携带token,跟表单一并提交到服务端
    • 服务端在web层统一解析token鉴权,同时取出用户信息(如userId)并继续向底层传递,传到服务层操作业务逻辑
    • 服务端在service层取到用户信息(如userId)后,执行相应的业务逻辑操作

    问题:

    为什么一定要把用户信息(如userId)藏在token中,服务端再解析token取出?直接登录后向客户端返回用户信息(如userId)不是更方便么?

    跟用户强相关的信息是相当敏感的,一般用户信息(如userId)不会直接明文暴露给客户端,会带来风险。

    单体应用下`用户信息(如userId)`的传递流程

    什么是单体应用? 简要描述就是web层,service层全部在一个jvm进程中,更通俗的讲就是只有一个项目

    登录签发 token

    看看下面的登录接口伪代码:

    web层接口:

    1    @Loggable(descp = "用户登录", include = "loginParam")
    2    @PostMapping("/login")
    3    public BaseResult<LoginVo> accountLogin(LoginParam loginParam) {
    4        return mAccountService.login(loginParam);
    5    }

    service层接口伪代码:

     1public BaseResult<LoginVo> login(LoginParam param) throws BaseException {
    2        //1.登录逻辑判断
    3        LoginVo loginVo = handleLogin(param);
    4        //2.签发token
    5        String subject = userId; 
    6        String jwt = JsonWebTokenUtil.issueJWT(UUID.randomUUID().toString(), subject,
    7                "token-server", BaseConstants.TOKEN_PERIOD_TIME, ""null, SignatureAlgorithm.HS512);
    8        loginVo.setJwt(jwt);
    9        return ResultUtil.success(loginVo);
    10    }

    注意到上述伪代码中,签发token时把userId放入客户标识subject中,签发到token中返回给客户端。这里使用的是JJWT生成的token

    引入依赖:

     1        <!--jjwt-->
    2        <dependency>
    3            <groupId>io.jsonwebtoken</groupId>
    4            <artifactId>jjwt</artifactId>
    5            <version>0.9.0</version>
    6        </dependency>
    7        <dependency>
    8            <groupId>com.fasterxml.jackson.core</groupId>
    9            <artifactId>jackson-databind</artifactId>
    10            <version>2.8.9</version>
    11        </dependency>

    相关工具类JsonWebTokenUtil

      1public class JsonWebTokenUtil {
    2    //秘钥
    3    public static final String SECRET_KEY = BaseConstant.SECRET_KEY;
    4    private static final ObjectMapper MAPPER = new ObjectMapper();
    5    private static CompressionCodecResolver codecResolver = new DefaultCompressionCodecResolver();
    6
    7    //私有化构造
    8    private JsonWebTokenUtil() {
    9    }
    10    /* *
    11     * @Description  json web token 签发
    12     * @param id 令牌ID
    13     * @param subject 用户标识
    14     * @param issuer 签发人
    15     * @param period 有效时间(秒)
    16     * @param roles 访问主张-角色
    17     * @param permissions 访问主张-权限
    18     * @param algorithm 加密算法
    19     * @Return java.lang.String
    20     */

    21    public static String issueJWT(String id,String subject, String issuer, Long period,
    22                                  String roles, String permissions, SignatureAlgorithm algorithm)
     
    {
    23        // 当前时间戳
    24        Long currentTimeMillis = System.currentTimeMillis();
    25        // 秘钥
    26        byte[] secreKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);
    27        JwtBuilder jwtBuilder = Jwts.builder();
    28        if (StringUtils.isNotBlank(id)) {
    29            jwtBuilder.setId(id);
    30        }
    31        if (StringUtils.isNotBlank(subject)) {
    32            jwtBuilder.setSubject(subject);
    33        }
    34        if (StringUtils.isNotBlank(issuer)) {
    35            jwtBuilder.setIssuer(issuer);
    36        }
    37        // 设置签发时间
    38        jwtBuilder.setIssuedAt(new Date(currentTimeMillis));
    39        // 设置到期时间
    40        if (null != period) {
    41            jwtBuilder.setExpiration(new Date(currentTimeMillis + period*1000));
    42        }
    43        if (StringUtils.isNotBlank(roles)) {
    44            jwtBuilder.claim("roles",roles);
    45        }
    46        if (StringUtils.isNotBlank(permissions)) {
    47            jwtBuilder.claim("perms",permissions);
    48        }
    49        // 压缩,可选GZIP
    50        jwtBuilder.compressWith(CompressionCodecs.DEFLATE);
    51        // 加密设置
    52        jwtBuilder.signWith(algorithm,secreKeyBytes);
    53
    54        return jwtBuilder.compact();
    55    }
    56
    57    /**
    58     * 解析JWT的Payload
    59     */

    60    public static String parseJwtPayload(String jwt){
    61        Assert.hasText(jwt, "JWT String argument cannot be null or empty.");
    62        String base64UrlEncodedHeader = null;
    63        String base64UrlEncodedPayload = null;
    64        String base64UrlEncodedDigest = null;
    65        int delimiterCount = 0;
    66        StringBuilder sb = new StringBuilder(128);
    67        for (char c : jwt.toCharArray()) {
    68            if (c == '.') {
    69                CharSequence tokenSeq = io.jsonwebtoken.lang.Strings.clean(sb);
    70                String token = tokenSeq!=null?tokenSeq.toString():null;
    71
    72                if (delimiterCount == 0) {
    73                    base64UrlEncodedHeader = token;
    74                } else if (delimiterCount == 1) {
    75                    base64UrlEncodedPayload = token;
    76                }
    77
    78                delimiterCount++;
    79                sb.setLength(0);
    80            } else {
    81                sb.append(c);
    82            }
    83        }
    84        if (delimiterCount != 2) {
    85            String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
    86            throw new MalformedJwtException(msg);
    87        }
    88        if (sb.length() > 0) {
    89            base64UrlEncodedDigest = sb.toString();
    90        }
    91        if (base64UrlEncodedPayload == null) {
    92            throw new MalformedJwtException("JWT string '" + jwt + "' is missing a body/payload.");
    93        }
    94        // =============== Header =================
    95        Header header = null;
    96        CompressionCodec compressionCodec = null;
    97        if (base64UrlEncodedHeader != null) {
    98            String origValue = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
    99            Map<String, Object> m = readValue(origValue);
    100            if (base64UrlEncodedDigest != null) {
    101                header = new DefaultJwsHeader(m);
    102            } else {
    103                header = new DefaultHeader(m);
    104            }
    105            compressionCodec = codecResolver.resolveCompressionCodec(header);
    106        }
    107        // =============== Body =================
    108        String payload;
    109        if (compressionCodec != null) {
    110            byte[] decompressed = compressionCodec.decompress(TextCodec.BASE64URL.decode(base64UrlEncodedPayload));
    111            payload = new String(decompressed, io.jsonwebtoken.lang.Strings.UTF_8);
    112        } else {
    113            payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedPayload);
    114        }
    115        return payload;
    116    }
    117
    118    /**
    119     * 验签JWT
    120     *
    121     * @param jwt json web token
    122     */

    123    public static JwtAccount parseJwt(String jwt, String appKey) throws ExpiredJwtException, UnsupportedJwtException,
    124            MalformedJwtException, SignatureException, IllegalArgumentException 
    {
    125        Claims claims = Jwts.parser()
    126                .setSigningKey(DatatypeConverter.parseBase64Binary(appKey))
    127                .parseClaimsJws(jwt)
    128                .getBody();
    129        JwtAccount jwtAccount = new JwtAccount();
    130        //令牌ID
    131        jwtAccount.setTokenId(claims.getId());
    132        //客户标识
    133        String subject = claims.getSubject();
    134        jwtAccount.setSubject(subject);
    135        //用户id
    136        jwtAccount.setUserId(subject);
    137        //签发者
    138        jwtAccount.setIssuer(claims.getIssuer());
    139        //签发时间
    140        jwtAccount.setIssuedAt(claims.getIssuedAt());
    141        //接收方
    142        jwtAccount.setAudience(claims.getAudience());
    143        //访问主张-角色
    144        jwtAccount.setRoles(claims.get("roles", String.class));
    145        //访问主张-权限
    146        jwtAccount.setPerms(claims.get("perms", String.class));
    147        return jwtAccount;
    148    }
    149
    150     public static Map<String, Object> readValue(String val) {
    151        try {
    152            return MAPPER.readValue(val, Map.class);
    153        } catch (IOException e) {
    154            throw new MalformedJwtException("Unable to userpager JSON value: " + val, e);
    155        }
    156    }
    157}

    JWT相关实体JwtAccount

     1@Data
    2public class JwtAccount implements Serializable {
    3
    4    private static final long serialVersionUID = -895875540581785581L;
    5
    6    /**
    7     * 令牌id
    8     */

    9    private String tokenId;
    10
    11    /**
    12     * 客户标识(用户id)
    13     */

    14    private String subject;
    15
    16    /**
    17     * 用户id
    18     */

    19    private String userId;
    20
    21    /**
    22     * 签发者(JWT令牌此项有值)
    23     */

    24    private String issuer;
    25
    26    /**
    27     * 签发时间
    28     */

    29    private Date issuedAt;
    30
    31    /**
    32     * 接收方(JWT令牌此项有值)
    33     */

    34    private String audience;
    35
    36    /**
    37     * 访问主张-角色(JWT令牌此项有值)
    38     */

    39    private String roles;
    40
    41    /**
    42     * 访问主张-资源(JWT令牌此项有值)
    43     */

    44    private String perms;
    45
    46    /**
    47     * 客户地址
    48     */

    49    private String host;
    50
    51    public JwtAccount() {
    52
    53    }
    54}

    `web`层统一鉴权,解析`token`

    客户端访问服务端接口,需要在头部携带token,跟表单一并提交到服务端,服务端则在web层新增MVC拦截器统一做处理

    新增MVC拦截器如下:

     1public class UpmsInterceptor extends HandlerInterceptorAdapter {
    2
    3    @Override
    4    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    5        BaseResult result = null;
    6        //获取请求uri
    7        String requestURI = request.getRequestURI();
    8
    9        ...省略部分逻辑
    10
    11        //获取认证token
    12        String jwt = request.getHeader(BaseConstant.AUTHORIZATION);
    13        //不传认证token,判断为无效请求
    14        if (StringUtils.isBlank(jwt)) {
    15            result = ResultUtil.error(ResultEnum.ERROR_REQUEST);
    16            RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
    17            return false;
    18        }
    19        //其他请求均需验证token有效性
    20        JwtAccount jwtAccount = null;
    21        String payload = null;
    22        try {
    23            // 解析Payload
    24            payload = JsonWebTokenUtil.parseJwtPayload(jwt);
    25            //取出payload中字段信息
    26            if (payload.charAt(0) == '{'
    27                    && payload.charAt(payload.length() - 1) == '}') {
    28                Map<String, Object> payloadMap = JsonWebTokenUtil.readValue(payload);
    29                //客户标识(userId)
    30                String subject = (String) payloadMap.get("sub");
    31
    32                //查询用户签发秘钥
    33
    34            }
    35            //验签token
    36            jwtAccount = JsonWebTokenUtil.parseJwt(jwt, JsonWebTokenUtil.SECRET_KEY);
    37        } catch (SignatureException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) {
    38            //令牌错误
    39            result = ResultUtil.error(ResultEnum.ERROR_JWT);
    40            RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
    41            return false;
    42        } catch (ExpiredJwtException e) {
    43            //令牌过期
    44            result = ResultUtil.error(ResultEnum.EXPIRED_JWT);
    45            RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
    46            return false;
    47        } catch (Exception e) {
    48            //解析异常
    49            result = ResultUtil.error(ResultEnum.ERROR_JWT);
    50            RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
    51            return false;
    52        }
    53        if (null == jwtAccount) {
    54            //令牌错误
    55            result = ResultUtil.error(ResultEnum.ERROR_JWT);
    56            RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
    57            return false;
    58        }
    59
    60        //将用户信息放入threadLocal中,线程共享
    61        ThreadLocalUtil.getInstance().bind(jwtAccount.getUserId());
    62        return true;
    63    }
    64
    65    //...省略部分代码
    66}

    整个token解析过程已经在代码注释中说明,可以看到解析完token后取出userId,将用户信息放入了threadLocal中,关于threadLocal的用法,本文暂不讨论.

    1    //将用户信息放入threadLocal中,线程共享
    2    ThreadLocalUtil.getInstance().bind(jwtAccount.getUserId());

    添加配置使拦截器生效:

     1<?xml version="1.0" encoding="UTF-8"?>
    2<beans xmlns="http://www.springframework.org/schema/beans"
    3       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    4       ...省略部分代码">
    5
    6    <!-- web拦截器 -->
    7    <mvc:interceptors>
    8        <mvc:interceptor>
    9            <mvc:mapping path="
    /**"/>
    10            <bean class="com.easywits.upms.client.interceptor.UpmsInterceptor"/>
    11        </mvc:interceptor>
    12    </mvc:interceptors>
    13
    14</beans>
    15

    相关工具代码ThreadLocalUtil

     1public class ThreadLocalUtil {
    2
    3    private ThreadLocal<UserInfo> userInfoThreadLocal = new ThreadLocal<>();
    4
    5    //new一个实例
    6    private static final ThreadLocalUtil instance = new ThreadLocalUtil();
    7
    8    //私有化构造
    9    private ThreadLocalUtil() {
    10    }
    11
    12    //获取单例
    13    public static ThreadLocalUtil getInstance() {
    14        return instance;
    15    }
    16
    17    /**
    18     * 将用户对象绑定到当前线程中,键为userInfoThreadLocal对象,值为userInfo对象
    19     *
    20     * @param userInfo
    21     */

    22    public void bind(UserInfo userInfo) {
    23        userInfoThreadLocal.set(userInfo);
    24    }
    25
    26    /**
    27     * 将用户数据绑定到当前线程中,键为userInfoThreadLocal对象,值为userInfo对象
    28     *
    29     * @param companyId
    30     * @param userId
    31     */

    32    public void bind(String userId) {
    33        UserInfo userInfo = new UserInfo();
    34        userInfo.setUserId(userId);
    35        bind(userInfo);
    36    }
    37
    38    /**
    39     * 得到绑定的用户对象
    40     *
    41     * @return
    42     */

    43    public UserInfo getUserInfo() {
    44        UserInfo userInfo = userInfoThreadLocal.get();
    45        remove();
    46        return userInfo;
    47    }
    48
    49    /**
    50     * 移除绑定的用户对象
    51     */

    52    public void remove() {
    53        userInfoThreadLocal.remove();
    54    }
    55}

    那么在web层和service都可以这样拿到userId

    1    @Loggable(descp = "用户个人资料", include = "")
    2    @GetMapping(value = "/info")
    3    public BaseResult<UserInfoVo> userInfo() {
    4        //拿到用户信息
    5        UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
    6        return mUserService.userInfo();
    7    }

    service层获取userId

    1public BaseResult<UserInfoVo> userInfo() throws BaseException {
    2        //拿到用户信息
    3        UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
    4        UserInfoVo userInfoVo = getUserInfoVo(userInfo.getUserId);
    5        return ResultUtil.success(userInfoVo);
    6    }

    分布式应用下(Dubbo)`用户信息(如userId)`的传递流程

    分布式应用与单体应用最大的区别就是从单个应用拆分成多个应用,service层与web层分为两个独立的应用,使用rpc调用方式处理业务逻辑。而上述做法中我们将用户信息放入了threadLocal中,是相对单应用进程而言的,假如service层接口在另外一个服务进程中,那么将获取不到。

    有什么办法能解决跨进程传递用户信息呢?翻看了下Dubbo官方文档,有隐式参数功能:

    文档很清晰,只需要在web层统一的拦截器中调用如下代码,就能将用户id传到service

    1RpcContext.getContext().setAttachment("userId", xxx);

    相应地调整web层拦截器代码:

     1public class UpmsInterceptor extends HandlerInterceptorAdapter {
    2
    3    @Override
    4    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    5        //...省略部分代码
    6
    7        //将用户信息放入threadLocal中,线程共享
    8        ThreadLocalUtil.getInstance().bind(jwtAccount.getUserId());
    9
    10        //将用户信息隐式透传到服务层
    11        RpcContext.getContext().setAttachment("userId", jwtAccount.getUserId());
    12        return true;
    13    }
    14
    15    //...省略部分代码
    16}

    那么服务层可以这样获取用户id了:

    1public BaseResult<UserInfoVo> userInfo() throws BaseException {
    2        //拿到用户信息
    3        String userId = RpcContext.getContext().getAttachment("userId");
    4        UserInfoVo userInfoVo = getUserInfoVo(userId);
    5        return ResultUtil.success(userInfoVo);
    6    }

    为了便于统一管理,我们可以在service层拦截器中将获取到的userId再放入threadLocal中,service层拦截器可以看看这篇推文:Dubbo自定义日志拦截器

     1public class DubboServiceFilter implements Filter {
    2
    3    private static final Logger LOGGER = LoggerFactory.getLogger(DubboServiceFilter.class);
    4
    5    @Override
    6    public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
    7
    8        //...省略部分逻辑
    9
    10        //获取web层透传过来的用户参数
    11        String userId = RpcContext.getContext().getAttachment("userId");
    12        //放入全局threadlocal 线程共享
    13        if (StringUtils.isNotBlank(userId)) {
    14            ThreadLocalUtil.getInstance().bind(userId);
    15        }
    16        //执行业务逻辑 返回结果
    17        Result result = invoker.invoke(invocation);
    18        //清除 防止内存泄露
    19        ThreadLocalUtil.getInstance().remove();
    20
    21        //...省略部分逻辑
    22        return result;
    23    }
    24}

    这样处理,service层依然可以通过如下代码获取用户信息了:

    1public BaseResult<UserInfoVo> userInfo() throws BaseException {
    2        //拿到用户信息
    3        UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
    4        UserInfoVo userInfoVo = getUserInfoVo(userInfo.getUserId);
    5        return ResultUtil.success(userInfoVo);
    6    }

    参考文档

    关于jwt:https://blog.leapoahead.com/2015/09/06/understanding-jwt/

    关于dubbo:http://dubbo.apache.org/zh-cn/docs/user/demos/attachment.html

    最后

    篇幅较长,总结一个较为实用的web应用场景,后续会不定期更新原创文章,欢迎关注公众号 「张少林同学」!

  • 相关阅读:
    第三周学习进度
    计算最低价格
    第二阶段冲刺5
    第二阶段冲刺4
    第十三周进度条
    第二阶段冲刺3
    寻找小水王
    第二阶段冲刺2
    第二阶段冲刺1
    构建之法阅读笔记
  • 原文地址:https://www.cnblogs.com/zhangshaolin/p/10249004.html
Copyright © 2020-2023  润新知