前后端分離應用——用戶信息傳遞


前言

記錄前后端分離的系統應用下應用場景————用戶信息傳遞

需求緣起

照例先看看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應用場景,后續會不定期更新原創文章,歡迎關注公眾號 「張少林同學」!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM