前後端分離應用——使用者資訊傳遞

張少林同學發表於2019-01-10

前後端分離應用——使用者資訊傳遞

前言

記錄前後端分離的系統應用下應用場景————使用者資訊傳遞

需求緣起

照例先看看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<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官方文件,有隱式引數功能:

前後端分離應用——使用者資訊傳遞

文件很清晰,只需要在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應用場景,後續會不定期更新原創文章,歡迎關注公眾號 「張少林同學」!

前後端分離應用——使用者資訊傳遞

相關文章