美文网首页Java 杂谈Java技术分享
前后端分离应用——用户信息传递

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

作者: 张少林同学 | 来源:发表于2019-01-10 11:29 被阅读2次
    image

    前言

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

    需求缘起

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

    image

    在 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<LoginVo> accountLogin(LoginParam loginParam) {
            return mAccountService.login(loginParam);
        }
    

    service层接口伪代码:

    public BaseResult<LoginVo> 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

    引入依赖:

            <!--jjwt-->
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt</artifactId>
                <version>0.9.0</version>
            </dependency>
            <dependency>
                <groupId>com.fasterxml.jackson.core</groupId>
                <artifactId>jackson-databind</artifactId>
                <version>2.8.9</version>
            </dependency>
    

    相关工具类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<String, Object> 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<String, Object> 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<String, Object> 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());
    

    添加配置使拦截器生效:

    <?xml version="1.0" encoding="UTF-8"?>
    <beans xmlns="http://www.springframework.org/schema/beans"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           ...省略部分代码">
           
        <!-- web拦截器 -->
        <mvc:interceptors>
            <mvc:interceptor>
                <mvc:mapping path="/**"/>
                <bean class="com.easywits.upms.client.interceptor.UpmsInterceptor"/>
            </mvc:interceptor>
        </mvc:interceptors>
        
    </beans>
    

    相关工具代码ThreadLocalUtil

    public class ThreadLocalUtil {
    
        private ThreadLocal<UserInfo> 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<UserInfoVo> userInfo() {
            //拿到用户信息
            UserInfo userInfo = ThreadLocalUtil.getInstance().getUserInfo();
            return mUserService.userInfo();
        }
    

    service层获取userId

    public BaseResult<UserInfoVo> 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官方文档,有隐式参数功能:

    image

    文档很清晰,只需要在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<UserInfoVo> 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<UserInfoVo> 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应用场景,后续会不定期更新原创文章,欢迎关注公众号 「张少林同学」!

    image

    相关文章

      网友评论

        本文标题:前后端分离应用——用户信息传递

        本文链接:https://www.haomeiwen.com/subject/kugorqtx.html