资讯专栏INFORMATION COLUMN

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

PAMPANG / 3314人阅读

摘要:前言记录前后端分离的系统应用下应用场景用户信息传递需求缘起照例先看看系统的一张经典架构图,这张图参考自网络在自定义异常,你是怎么处理的中已经对该架构做了简单说明,这里不再描述。

前言

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

需求缘起

照例先看看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层接口:

    @Loggable(descp = "用户登录", include = "loginParam")
    @PostMapping("/login")
    public BaseResult accountLogin(LoginParam loginParam) {
        return mAccountService.login(loginParam);
    }

service层接口伪代码:

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

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

引入依赖:

        
        
            io.jsonwebtoken
            jjwt
            0.9.0
        
        
            com.fasterxml.jackson.core
            jackson-databind
            2.8.9
        

相关工具类JsonWebTokenUtil

public class JsonWebTokenUtil {
    //秘钥
    public static final String SECRET_KEY = BaseConstant.SECRET_KEY;
    private static final ObjectMapper MAPPER = new ObjectMapper();
    private static CompressionCodecResolver codecResolver = new DefaultCompressionCodecResolver();
    
    //私有化构造
    private JsonWebTokenUtil() {
    }
    /* *
     * @Description  json web token 签发
     * @param id 令牌ID
     * @param subject 用户标识
     * @param issuer 签发人
     * @param period 有效时间(秒)
     * @param roles 访问主张-角色
     * @param permissions 访问主张-权限
     * @param algorithm 加密算法
     * @Return java.lang.String
     */
    public static String issueJWT(String id,String subject, String issuer, Long period,
                                  String roles, String permissions, SignatureAlgorithm algorithm) {
        // 当前时间戳
        Long currentTimeMillis = System.currentTimeMillis();
        // 秘钥
        byte[] secreKeyBytes = DatatypeConverter.parseBase64Binary(SECRET_KEY);
        JwtBuilder jwtBuilder = Jwts.builder();
        if (StringUtils.isNotBlank(id)) {
            jwtBuilder.setId(id);
        }
        if (StringUtils.isNotBlank(subject)) {
            jwtBuilder.setSubject(subject);
        }
        if (StringUtils.isNotBlank(issuer)) {
            jwtBuilder.setIssuer(issuer);
        }
        // 设置签发时间
        jwtBuilder.setIssuedAt(new Date(currentTimeMillis));
        // 设置到期时间
        if (null != period) {
            jwtBuilder.setExpiration(new Date(currentTimeMillis + period*1000));
        }
        if (StringUtils.isNotBlank(roles)) {
            jwtBuilder.claim("roles",roles);
        }
        if (StringUtils.isNotBlank(permissions)) {
            jwtBuilder.claim("perms",permissions);
        }
        // 压缩,可选GZIP
        jwtBuilder.compressWith(CompressionCodecs.DEFLATE);
        // 加密设置
        jwtBuilder.signWith(algorithm,secreKeyBytes);

        return jwtBuilder.compact();
    }

    /**
     * 解析JWT的Payload
     */
    public static String parseJwtPayload(String jwt){
        Assert.hasText(jwt, "JWT String argument cannot be null or empty.");
        String base64UrlEncodedHeader = null;
        String base64UrlEncodedPayload = null;
        String base64UrlEncodedDigest = null;
        int delimiterCount = 0;
        StringBuilder sb = new StringBuilder(128);
        for (char c : jwt.toCharArray()) {
            if (c == ".") {
                CharSequence tokenSeq = io.jsonwebtoken.lang.Strings.clean(sb);
                String token = tokenSeq!=null?tokenSeq.toString():null;

                if (delimiterCount == 0) {
                    base64UrlEncodedHeader = token;
                } else if (delimiterCount == 1) {
                    base64UrlEncodedPayload = token;
                }

                delimiterCount++;
                sb.setLength(0);
            } else {
                sb.append(c);
            }
        }
        if (delimiterCount != 2) {
            String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
            throw new MalformedJwtException(msg);
        }
        if (sb.length() > 0) {
            base64UrlEncodedDigest = sb.toString();
        }
        if (base64UrlEncodedPayload == null) {
            throw new MalformedJwtException("JWT string "" + jwt + "" is missing a body/payload.");
        }
        // =============== Header =================
        Header header = null;
        CompressionCodec compressionCodec = null;
        if (base64UrlEncodedHeader != null) {
            String origValue = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
            Map m = readValue(origValue);
            if (base64UrlEncodedDigest != null) {
                header = new DefaultJwsHeader(m);
            } else {
                header = new DefaultHeader(m);
            }
            compressionCodec = codecResolver.resolveCompressionCodec(header);
        }
        // =============== Body =================
        String payload;
        if (compressionCodec != null) {
            byte[] decompressed = compressionCodec.decompress(TextCodec.BASE64URL.decode(base64UrlEncodedPayload));
            payload = new String(decompressed, io.jsonwebtoken.lang.Strings.UTF_8);
        } else {
            payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedPayload);
        }
        return payload;
    }

    /**
     * 验签JWT
     *
     * @param jwt json web token
     */
    public static JwtAccount parseJwt(String jwt, String appKey) throws ExpiredJwtException, UnsupportedJwtException,
            MalformedJwtException, SignatureException, IllegalArgumentException {
        Claims claims = Jwts.parser()
                .setSigningKey(DatatypeConverter.parseBase64Binary(appKey))
                .parseClaimsJws(jwt)
                .getBody();
        JwtAccount jwtAccount = new JwtAccount();
        //令牌ID
        jwtAccount.setTokenId(claims.getId());
        //客户标识
        String subject = claims.getSubject();
        jwtAccount.setSubject(subject);
        //用户id
        jwtAccount.setUserId(subject);
        //签发者
        jwtAccount.setIssuer(claims.getIssuer());
        //签发时间
        jwtAccount.setIssuedAt(claims.getIssuedAt());
        //接收方
        jwtAccount.setAudience(claims.getAudience());
        //访问主张-角色
        jwtAccount.setRoles(claims.get("roles", String.class));
        //访问主张-权限
        jwtAccount.setPerms(claims.get("perms", String.class));
        return jwtAccount;
    }
    
     public static Map readValue(String val) {
        try {
            return MAPPER.readValue(val, Map.class);
        } catch (IOException e) {
            throw new MalformedJwtException("Unable to userpager JSON value: " + val, e);
        }
    }
}

JWT相关实体JwtAccount

@Data
public class JwtAccount implements Serializable {

    private static final long serialVersionUID = -895875540581785581L;

    /**
     * 令牌id
     */
    private String tokenId;

    /**
     * 客户标识(用户id)
     */
    private String subject;

    /**
     * 用户id
     */
    private String userId;

    /**
     * 签发者(JWT令牌此项有值)
     */
    private String issuer;

    /**
     * 签发时间
     */
    private Date issuedAt;

    /**
     * 接收方(JWT令牌此项有值)
     */
    private String audience;

    /**
     * 访问主张-角色(JWT令牌此项有值)
     */
    private String roles;

    /**
     * 访问主张-资源(JWT令牌此项有值)
     */
    private String perms;

    /**
     * 客户地址
     */
    private String host;

    public JwtAccount() {

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

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

新增MVC拦截器如下:

public class UpmsInterceptor extends HandlerInterceptorAdapter {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        BaseResult result = null;
        //获取请求uri
        String requestURI = request.getRequestURI();
        
        ...省略部分逻辑

        //获取认证token
        String jwt = request.getHeader(BaseConstant.AUTHORIZATION);
        //不传认证token,判断为无效请求
        if (StringUtils.isBlank(jwt)) {
            result = ResultUtil.error(ResultEnum.ERROR_REQUEST);
            RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
            return false;
        }
        //其他请求均需验证token有效性
        JwtAccount jwtAccount = null;
        String payload = null;
        try {
            // 解析Payload
            payload = JsonWebTokenUtil.parseJwtPayload(jwt);
            //取出payload中字段信息
            if (payload.charAt(0) == "{"
                    && payload.charAt(payload.length() - 1) == "}") {
                Map payloadMap = JsonWebTokenUtil.readValue(payload);
                //客户标识(userId)
                String subject = (String) payloadMap.get("sub");

                //查询用户签发秘钥

            }
            //验签token
            jwtAccount = JsonWebTokenUtil.parseJwt(jwt, JsonWebTokenUtil.SECRET_KEY);
        } catch (SignatureException | UnsupportedJwtException | MalformedJwtException | IllegalArgumentException e) {
            //令牌错误
            result = ResultUtil.error(ResultEnum.ERROR_JWT);
            RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
            return false;
        } catch (ExpiredJwtException e) {
            //令牌过期
            result = ResultUtil.error(ResultEnum.EXPIRED_JWT);
            RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
            return false;
        } catch (Exception e) {
            //解析异常
            result = ResultUtil.error(ResultEnum.ERROR_JWT);
            RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
            return false;
        }
        if (null == jwtAccount) {
            //令牌错误
            result = ResultUtil.error(ResultEnum.ERROR_JWT);
            RequestResponseUtil.responseWrite(JSON.toJSONString(result), response);
            return false;
        }

        //将用户信息放入threadLocal中,线程共享
        ThreadLocalUtil.getInstance().bind(jwtAccount.getUserId());
        return true;
    }
    
    //...省略部分代码
}

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

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

添加配置使拦截器生效:



       
    
    
        
            
            
        
    
    

相关工具代码ThreadLocalUtil

public class ThreadLocalUtil {

    private ThreadLocal userInfoThreadLocal = new ThreadLocal<>();
    
    //new一个实例
    private static final ThreadLocalUtil instance = new ThreadLocalUtil();
    
    //私有化构造
    private ThreadLocalUtil() {
    }
    
    //获取单例
    public static ThreadLocalUtil getInstance() {
        return instance;
    }

    /**
     * 将用户对象绑定到当前线程中,键为userInfoThreadLocal对象,值为userInfo对象
     *
     * @param userInfo
     */
    public void bind(UserInfo userInfo) {
        userInfoThreadLocal.set(userInfo);
    }

    /**
     * 将用户数据绑定到当前线程中,键为userInfoThreadLocal对象,值为userInfo对象
     *
     * @param companyId
     * @param userId
     */
    public void bind(String userId) {
        UserInfo userInfo = new UserInfo();
        userInfo.setUserId(userId);
        bind(userInfo);
    }

    /**
     * 得到绑定的用户对象
     *
     * @return
     */
    public UserInfo getUserInfo() {
        UserInfo userInfo = userInfoThreadLocal.get();
        remove();
        return userInfo;
    }

    /**
     * 移除绑定的用户对象
     */
    public void remove() {
        userInfoThreadLocal.remove();
    }
}

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

    @Loggable(descp = "用户个人资料", include = "")
    @GetMapping(value = "/info")
    public BaseResult userInfo() {
        //拿到用户信息
        UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
        return mUserService.userInfo();
    }

service层获取userId

public BaseResult userInfo() throws BaseException {
        //拿到用户信息
        UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
        UserInfoVo userInfoVo = getUserInfoVo(userInfo.getUserId);
        return ResultUtil.success(userInfoVo);
    }
分布式应用下(Dubbo)用户信息(如userId)的传递流程

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

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

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

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

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

public class UpmsInterceptor extends HandlerInterceptorAdapter {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //...省略部分代码
        
        //将用户信息放入threadLocal中,线程共享
        ThreadLocalUtil.getInstance().bind(jwtAccount.getUserId());
        
        //将用户信息隐式透传到服务层
        RpcContext.getContext().setAttachment("userId", jwtAccount.getUserId());
        return true;
    }
    
    //...省略部分代码
}

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

public BaseResult userInfo() throws BaseException {
        //拿到用户信息
        String userId = RpcContext.getContext().getAttachment("userId");
        UserInfoVo userInfoVo = getUserInfoVo(userId);
        return ResultUtil.success(userInfoVo);
    }

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

public class DubboServiceFilter implements Filter {

    private static final Logger LOGGER = LoggerFactory.getLogger(DubboServiceFilter.class);

    @Override
    public Result invoke(Invoker invoker, Invocation invocation) throws RpcException {

        //...省略部分逻辑
        
        //获取web层透传过来的用户参数
        String userId = RpcContext.getContext().getAttachment("userId");
        //放入全局threadlocal 线程共享
        if (StringUtils.isNotBlank(userId)) {
            ThreadLocalUtil.getInstance().bind(userId);
        }
        //执行业务逻辑 返回结果
        Result result = invoker.invoke(invocation);
        //清除 防止内存泄露
        ThreadLocalUtil.getInstance().remove();
        
        //...省略部分逻辑
        return result;
    }
}

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

public BaseResult userInfo() throws BaseException {
        //拿到用户信息
        UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
        UserInfoVo userInfoVo = getUserInfoVo(userInfo.getUserId);
        return ResultUtil.success(userInfoVo);
    }
参考文档

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

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

最后

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

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/72901.html

相关文章

  • 前后分离——token超时刷新策略

    摘要:实现目标延长过期时间活跃用户在过期时,在用户无感知的情况下动态刷新,做到一直在线状态不活跃用户在过期时,直接定向到登录页登录返回字段如何签发,请看上一篇推文,这里不做过多介绍。如果你有更好的做法,欢迎留言告知我,谢谢啦。 前言 记录一下前后端分离下————token超时刷新策略! 需求场景 昨天发了一篇记录 前后端分离应用——用户信息传递 中介绍了token认证机制,跟几位群友讨论了...

    hatlonely 评论0 收藏0
  • ajax基础知识

    摘要:各浏览器都有自己的关于最大长度的限制谷歌火狐超过限制长度的部分,浏览器会自动截取掉,导致传递给服务器的数据缺失。 AJAX基础知识及核心原理解读 AJAX基础知识 什么是AJAX?async javascript and xml,异步的JS和XML xml:可扩展的标记语言 作用是用来存储数据的(通过自己扩展的标记名称清晰的展示出数据结构)ajax之所以称为异步的js和xml,主要原因...

    wangshijun 评论0 收藏0
  • 前后分离架构下CSRF防御机制

    摘要:延伸这里再顺便提一下,新架构下的防御。不过,还有一点值得一提前后端分离框架下,路由由控制我自己要获取的后端参数和需要用在业务逻辑的参数,在主观上前端同学更好把握一些。 原文: http://feclub.cn/post/content... 背景 1、什么是CSRF攻击? 这里不再介绍CSRF,已经了解CSRF原理的同学可以直接跳到:3、前后端分离下有何不同?。 不太了解的同学可以看这...

    Moxmi 评论0 收藏0
  • 使用 vue2.0 开发微信公众号下前后分离的SPA站点的填坑之旅

    摘要:目前正在写一个微信公众号的小项目,记录一下遇到的问题和解决方法主要是前端。前端提交时使用,在后端再取出对应的微信支付看了下文档,以前是需要用唤起支付,而现在则是把微信内置到了微信的浏览器中。 目前正在写一个微信公众号的小项目,记录一下遇到的问题和解决方法(主要是前端)。内容持续更新中~ 主要实现 前后端分离前端为 SPA 单页面使用微信的JSSDK微信支付 技术方案 后端使用 php ...

    afishhhhh 评论0 收藏0
  • 使用 vue2.0 开发微信公众号下前后分离的SPA站点的填坑之旅

    摘要:目前正在写一个微信公众号的小项目,记录一下遇到的问题和解决方法主要是前端。前端提交时使用,在后端再取出对应的微信支付看了下文档,以前是需要用唤起支付,而现在则是把微信内置到了微信的浏览器中。 目前正在写一个微信公众号的小项目,记录一下遇到的问题和解决方法(主要是前端)。内容持续更新中~ 主要实现 前后端分离前端为 SPA 单页面使用微信的JSSDK微信支付 技术方案 后端使用 php ...

    Taonce 评论0 收藏0

发表评论

0条评论

PAMPANG

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<