多系统(模块/主题)、多终端的token控制方案

记于:2025-07-28 晚上
地点:浙江省·温州市·家里
天气:雨天

背景#

在做自己的开源Java/Spring Boot脚手架项目(地址见末尾);
默认实现为一个帐号对应一个token,web端和api端相互占用,开发和调试不方便,所以把token控制方案实现提前了一些。

功能说明#

  • 支持多系统(模块/主题),此处系统可以是一个项目工程的各个子系统,比如一个商城系统下的管理端、商家端、用户端等;
  • 支持多终端,比如web端、api端、app端(具体可分为phone端、pad端等);
  • 支持针对不同系统、不同终端配置不同的token策略,比如token过期时间、最大在线token数、最大在线终端数等;

设计思路#

  • 认证和方案总体基于spring security;
  • 在各终端执行登录操作时,对token进行控制,token空位足够则直接添加,否则进行淘汰后再添加;
  • token控制依赖缓存中token多个数据结构存储、操作、与过期机制;
  • token的鉴权,则通过过滤器机制实现;

设计图#

该设计图为草图,暂不做细化,对设计有个概念即可,具体看代码实现。

token控制方案设计图

代码实现#

附上主要代码片段,并做一定说明。

配置#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# auth相关
auth:
token:
# 全局jwt配置(subject中可覆盖)
jwt:
# 签发密钥
secret: xeePu3xo3uoyo5Quooquai0ut1ha7aih
# 过期时间(秒)
expire-seconds: 3600
# 时间校验偏差(秒)
default-leeway: 60
subjects:
- name: admin
api-prefix: '/admin'
jwt:
# 签发密钥
secret: wee8Deonee4egh5taif8noim8pee1aiJ
# 过期时间(秒)
expire-seconds: 3600
# 时间校验偏差(秒)
default-leeway: 60
terminals:
- name: web
# 同一个账号在该终端的最大在线token数量(默认-1,表示不限制)(不可超过总的最大在线token数量)
max-online-token-count: 1
- name: api
# 同一个账号在该终端的最大在线token数量(默认-1,表示不限制)(不可超过总的最大在线token数量)
max-online-token-count: -1
# 同一个账号的最大在线token数量(默认-1,表示不限制)
max-online-token-count: 10
# 同一个账号的最大在线终端数量(默认-1,表示不限制)
max-online-terminal-count: -1
- name: app
api-prefix: /app
jwt:
# 签发密钥
secret: caiQueipae5Phaeph7gaethae5ash6bu
# 过期时间(秒)
expire-seconds: 3600
# 时间校验偏差(秒)
default-leeway: 60
terminals:
- name: app
# 同一个账号在该终端的最大在线token数量(默认-1,表示不限制)(不可超过总的最大在线token数量)
max-online-token-count: 1
# 同一个账号的最大在线token数量(默认-1,表示不限制)
max-online-token-count: 10
# 同一个账号的最大在线终端数量(默认-1,表示不限制)
max-online-terminal-count: -1

配置中包含secret,由于是开源项目,所以不做脱敏处理;
配置含义见注释和代码,不另做说明。

登录操作中的token控制逻辑#

已封装为单独服务类的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
/**
* 终端和token控制服务
*/
@Service
@RequiredArgsConstructor
public class TerminalAndTokenControlService {

private final TokenService tokenService;
private final CacheService cacheService;
private final JwtService jwtService;

private final AuthTokenProperties authTokenProperties;

/**
* 控制终端和token数量
*/
public String doControl(String userId, String subValue, String termValue) {
// 检查配置
AuthTokenProperties.Subject subject = authTokenProperties.getMapSubject().get(subValue);
if (subject == null) {
throw new BaseException(ErrorCodeEnum.FAIL, "subject[" + subValue + "] 未配置");
}
AuthTokenProperties.Terminal terminal = subject.getMapTerminal().get(termValue);
if (terminal == null) {
throw new BaseException(ErrorCodeEnum.FAIL, "terminal[" + termValue + "] 不正确");
}

// 1.检查配置
// --------------------------------------------------------------------------------
// 2.加载缓存数据并做初预处理

// 添加token及终端信息
String token = tokenService.generateToken(userId, subValue, termValue);
JwtPayloadVo jwtPayloadVo = jwtService.decodePayload(token);
// token创建时间戳(毫秒)
long timestampMs = jwtPayloadVo.getIatMs();

// 查询终端信息
Map<String, String> mapTerminalInfo = tokenService.getSubjectTerminalInfo(subValue, userId);
Map<String, String> mapTerminalInfoValid = new HashMap<>();
Map<String, String> mapTerminalInfoExpired = new HashMap<>();
// 区分是否过期
mapTerminalInfo.forEach((term, termInfo) -> {
// termInfo格式为 "iat,expTime",其中iat为登录时间戳,expTime为过期时间戳
long expTime = Long.parseLong(termInfo.split(",")[1]);
if (timestampMs >= expTime) {
mapTerminalInfoExpired.put(term, termInfo);
} else {
mapTerminalInfoValid.put(term, termInfo);
}
});
// 查询终端对应的token信息
Map<String, Map<String, String>> mapTotalTerminalTokenInfo = new HashMap<>();
Map<String, Map<String, String>> mapRemainTerminalTokenInfo = new HashMap<>();
Map<String, String> mapTotalTerminalTokenInfoValid = new HashMap<>();
Map<String, String> mapTotalTerminalTokenInfoExpired = new HashMap<>();
for (Map.Entry<String, String> entry : mapTerminalInfo.entrySet()) {
// 查询对应终端的token信息
Map<String, String> mapTerminalTokenInfo = tokenService.getTerminalTokenInfo(subValue, userId, entry.getKey());
mapTotalTerminalTokenInfo.put(entry.getKey(), mapTerminalTokenInfo);
}
// 查询当前终端的token信息
Map<String, String> mapTerminalTokenInfo = mapTotalTerminalTokenInfo.getOrDefault(termValue, new HashMap<>());
Map<String, String> mapTerminalTokenInfoValid = new HashMap<>();
Map<String, String> mapTerminalTokenInfoExpired = new HashMap<>();
mapTerminalTokenInfo.forEach((timestamp, expTime) -> {
long expTimeInt = Long.parseLong(expTime);
if (timestampMs >= expTimeInt) {
mapTerminalTokenInfoExpired.put(timestamp, expTime);
} else {
mapTerminalTokenInfoValid.put(timestamp, expTime);
}
});

// 2.加载缓存数据并做初预处理
// --------------------------------------------------------------------------------
// 3.检查和控制subject下终端数量

// 是否清空用户的终端和token信息
boolean clearAllTerminal = false;
// 要清除的终端信息
Set<String> needDeleteTerminal = new HashSet<>();
// 要清除的token信息,map<terminal, set<timestamp>> ; 【保留终端token信息】中的待清除部分
Map<String, Set<String>> mapNeedDeleteTokenInfo = new HashMap<>();

// 检查和控制终端数
// 大于0,需要检查和控制超限情况
if (subject.getMaxOnlineTerminalCount() > 0) {
// 检查当前在线终端数是否达到限制
int occurTerminalCount = mapTerminalInfoValid.size() + (mapTerminalInfoValid.containsKey(termValue) ? 0 : 1);
// 超限情况
if (occurTerminalCount > subject.getMaxOnlineTerminalCount()) {
// 要清除的终端数
int needToDeleteTerminalCount = occurTerminalCount - subject.getMaxOnlineTerminalCount();
// 优先清除旧值
// mapTerminalInfoValid按value[0]时间戳排序,value[0]越小越旧;
// value数据格式为iat,expTime,直接按value字符串排序效果与按iat排序一样
Set<String> eliminatedTerminal = mapTerminalInfoValid.entrySet()
.stream().sorted(Map.Entry.comparingByValue()).limit(needToDeleteTerminalCount)
.map(Map.Entry::getKey).collect(Collectors.toSet());
// 记录要清除的终端信息
needDeleteTerminal.addAll(eliminatedTerminal);
}
} else if (subject.getMaxOnlineTerminalCount() == 0) {
clearAllTerminal = true;
}
// 添加过期终端
needDeleteTerminal.addAll(mapTerminalInfoExpired.keySet());

// 3.检查和控制subject下终端数量
// --------------------------------------------------------------------------------
// 4.检查和控制subject下token数量

// 检查和控制token信息
// 大于0,需要检查和控制超限情况
if (subject.getMaxOnlineTokenCount() > 0) {
// 计算当前有效token数量(排除掉要清除的终端,即只计算要保留的终端)
int occurTokenCount = 0;
mapRemainTerminalTokenInfo = mapTotalTerminalTokenInfo.entrySet().stream()
.filter(entry -> !needDeleteTerminal.contains(entry.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
for (Map.Entry<String, Map<String, String>> entry : mapRemainTerminalTokenInfo.entrySet()) {
occurTokenCount += entry.getValue().size();
// 区分是否过期
entry.getValue().forEach((timestamp, expTime) -> {
long expTimeInt = Long.parseLong(expTime);
if (timestampMs >= expTimeInt) {
mapTotalTerminalTokenInfoExpired.put(timestamp + "," + entry.getKey(), expTime);
} else {
mapTotalTerminalTokenInfoValid.put(timestamp + "," + entry.getKey(), expTime);
}
});
}
// 超限的情况
if (occurTokenCount >= subject.getMaxOnlineTokenCount()) {
// 计算需要清除的token数量;+1是为当前登录操作预留的token位置
int needDeleteCount = occurTokenCount - subject.getMaxOnlineTokenCount() + 1;
// 优先清除旧值
Set<String> eliminatedTokenInfo = mapTotalTerminalTokenInfoValid.entrySet()
.stream().sorted((e1, e2) -> {
String terminal1 = e1.getKey().split(",")[1];
int sort = Objects.equals(terminal1, termValue) ? 0 : 1;
String key1 = sort + ":" + e1.getKey();

String terminal2 = e2.getKey().split(",")[1];
sort = Objects.equals(terminal2, termValue) ? 0 : 1;
String key2 = sort + ":" + e2.getKey();

return key1.compareTo(key2);
}).limit(needDeleteCount)
.map(Map.Entry::getKey).collect(Collectors.toSet());
eliminatedTokenInfo.forEach(e -> {
// [0]: timestamp; [1]: terminal
String[] split = e.split(",");
mapNeedDeleteTokenInfo.computeIfAbsent(split[1], k -> new HashSet<>()).add(split[0]);
});
}
} else if (subject.getMaxOnlineTokenCount() == 0) {
clearAllTerminal = true;
}
// 过期token信息的也添加到待清除集合
mapTotalTerminalTokenInfoExpired.keySet().forEach(e -> {
// [0]: timestamp; [1]: terminal
String[] split = e.split(",");
mapNeedDeleteTokenInfo.computeIfAbsent(split[1], k -> new HashSet<>()).add(split[0]);
});

// 4.检查和控制subject下token数量
// --------------------------------------------------------------------------------
// 5.检查和控制terminal下token数量

// 检查和控制当前用户登录终端对应的token信息
// 仅当当前终端不包含在待删除终端集合内,才进行处理
if (!needDeleteTerminal.contains(termValue)) {
// 大于0,需要检查和控制超限情况
if (terminal.getMaxOnlineTokenCount() > 0) {
// 超限的情况
if (mapTerminalTokenInfoValid.size() >= terminal.getMaxOnlineTokenCount()) {
// 需要清除的token数量;+1是为当前登录操作预留的token位置
int needDeleteCount = mapTerminalTokenInfoValid.size() - terminal.getMaxOnlineTokenCount() + 1;
// 优先清除旧值
Set<String> eliminatedToken = mapTerminalTokenInfoValid.entrySet()
.stream().sorted(Map.Entry.comparingByKey()).limit(needDeleteCount)
.map(Map.Entry::getKey).collect(Collectors.toSet());
eliminatedToken.forEach(timestamp -> {
mapNeedDeleteTokenInfo.computeIfAbsent(termValue, k -> new HashSet<>()).add(timestamp);
});
}
} else if (terminal.getMaxOnlineTokenCount() == 0) {
// 清空当前终端和对应token信息
needDeleteTerminal.add(termValue);
}
// 过期的token信息也添加到待清除集合
mapTerminalTokenInfoExpired.keySet().forEach(timestamp -> {
mapNeedDeleteTokenInfo.computeIfAbsent(termValue, k -> new HashSet<>()).add(timestamp);
});
}

// 5.检查和控制terminal下token数量
// --------------------------------------------------------------------------------
// 6.执行清除操作并按需添加当前登录的终端和token信息

// 清空用户所有终端和token信息
if (clearAllTerminal) {
Set<String> delKeys = new HashSet<>();
delKeys.add(String.format(CacheKeyConsts.USER_SUBJECT_TERMINAL_INFO, subValue, userId));
mapTerminalInfo.keySet().forEach(term -> {
delKeys.add(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN_INFO, subValue, userId, term));
mapTotalTerminalTokenInfo.getOrDefault(term, new HashMap<>()).forEach((timestamp, expTime) -> {
delKeys.add(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN, subValue, userId, term, timestamp));
});
});
cacheService.delete(delKeys.toArray(new String[0]));
// 禁止登录
throw new BaseException("禁止登录");
}
// (按需)清空部分信息,然后添加当前登录所创建的token及终端信息
else {
Set<String> delKeys = new HashSet<>();
// map<cache key, fields>
Map<String, Set<String>> delFields = new HashMap<>();

needDeleteTerminal.forEach(term -> {
delFields.computeIfAbsent(String.format(CacheKeyConsts.USER_SUBJECT_TERMINAL_INFO, subValue, userId),
k -> new HashSet<>()).add(term);

delKeys.add(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN_INFO, subValue, userId, term));
Optional.ofNullable(mapTotalTerminalTokenInfo.get(term)).ifPresent(terminalTokenInfo -> {
terminalTokenInfo.keySet().forEach(timestamp -> {
delFields.computeIfAbsent(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN_INFO, subValue, userId, term),
k -> new HashSet<>()).add(timestamp);
delKeys.add(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN, subValue, userId, term, timestamp));
});
});
});
mapNeedDeleteTokenInfo.forEach((term, timestampSet) -> {
delFields.computeIfAbsent(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN_INFO, subValue, userId, term),
k -> new HashSet<>()).addAll(timestampSet);
timestampSet.forEach(timestamp -> {
delKeys.add(String.format(CacheKeyConsts.USER_TERMINAL_TOKEN, subValue, userId, term, timestamp));
});
});
// 执行清除
cacheService.delete(delKeys.toArray(new String[0]));
delFields.forEach((cacheKey, fields) -> {
cacheService.deleteHashFields(cacheKey, fields.toArray(new Object[0]));
});

tokenService.setSubjectTerminalInfo(subValue, userId, termValue, jwtPayloadVo.getIatMs(), jwtPayloadVo.getExpMs());
tokenService.setTerminalTokenInfo(subValue, userId, termValue, jwtPayloadVo.getIatMs(), jwtPayloadVo.getExpMs());
tokenService.cacheToken(subValue, userId, termValue, token, timestampMs);
return token;
}
}
}

补充:相关缓存key设计,结合设计图更直观。

1
2
3
4
5
6
7
8
// subject下所有终端信息 redis hash类型
public static final String USER_SUBJECT_TERMINAL_INFO = "sub:%s:user:%s:token:term";

// 终端下token信息 redis hash类型
public static final String USER_TERMINAL_TOKEN_INFO = "sub:%s:user:%s:token:term:%s";

// token,格式: [subject : user : terminal : token timestamp] redis string类型
public static final String USER_TERMINAL_TOKEN = "sub:%s:user:%s:token:term:%s:%s";

对token相关缓存设计做下说明:

token的鉴权(认证和授权)#

AuthenticationProvider实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* Jwt Token认证提供器 for ProviderManager
*/
@Slf4j
public class JwtTokenAuthenticationProvider implements AuthenticationProvider {

private final AuthService authService;

// constructor
public JwtTokenAuthenticationProvider(AuthService authService) {
this.authService = authService;
}

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
JwtTokenAuthenticationToken requestToken = (JwtTokenAuthenticationToken) authentication;
String token = (String) requestToken.getCredentials();

AuthDto authDto = new AuthDto();
authDto.setToken(token);
AuthVo authVo = authService.auth(authDto);
if (authVo == null || !authVo.getAuthenticated()) {
log.error("调用auth服务鉴权失败!");
throw new BaseException(ErrorCodeEnum.FAIL, "调用auth服务鉴权失败!");
}

// set web context
WebContextUtils.setToken(token);
WebContextUtils.setUserId(authVo.getUserId());
WebContextUtils.setSubject(authVo.getSubject());
WebContextUtils.setTerminal(authVo.getTerminal());
WebContextUtils.setUsername(authVo.getUsername());
WebContextUtils.setRoles(authVo.getRoles());
WebContextUtils.setResources(authVo.getResources());

// role
Set<String> authoritySet = authVo.getRoles().stream().map(s -> "ROLE_" + s).collect(Collectors.toSet());
// add resource
authoritySet.addAll(authVo.getResources());
String commaSeparatedRoles = String.join(",", authoritySet);

// set authorities
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(commaSeparatedRoles);
// new authentication
JwtTokenAuthenticationToken resultToken = new JwtTokenAuthenticationToken(authorities);
resultToken.setAuthenticated(true);

return resultToken;
}

@Override
public boolean supports(Class<?> authentication) {
return JwtTokenAuthenticationToken.class.isAssignableFrom(authentication);
}
}

代码说明:

  • 该类实现了AuthenticationProvider接口,用于处理基于JWT的认证;
  • 主要实现了token鉴权(委托给authService.auth方法)、设置web上下文信息、设置用户角色和资源。

鉴权服务代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
/**
* 鉴权服务
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {

private final TokenService tokenService;

private final List<UserDetailService> userDetailServices;
private Map<String, UserDetailService> mapUserDetailService;

private final AuthTokenProperties authTokenProperties;

@PostConstruct
public void init() {
mapUserDetailService = userDetailServices.stream().collect(
Collectors.toMap(UserDetailService::getSubject, userDetailService -> userDetailService));
}

/**
* 鉴权(认证和授权)(token方式)
*/
public AuthVo auth(AuthDto dto) {
AuthVo authVo = new AuthVo();

// 认证
JwtPayloadVo decodedResult = tokenService.decodeToken(dto.getToken());
if (decodedResult == null) {
log.error("token decode fail");
authVo.setAuthenticated(false);
return authVo;
} else if (!Objects.equals(dto.getToken(), tokenService.getCacheToken(
decodedResult.getSub(), decodedResult.getAud(), decodedResult.getTerm(), decodedResult.getIatMs()))) {
log.error("token cache validation fail");
authVo.setAuthenticated(false);
return authVo;
} else {
authVo.setAuthenticated(true);
authVo.setUserId(Long.valueOf(decodedResult.getAud()));
authVo.setSubject(decodedResult.getSub());
authVo.setTerminal(decodedResult.getTerm());
}

// 此处刚获取到userId,尽早重新设置mdc信息
this.setMdcInfo(authVo.getUserId());

// 接口越权检查
if (!this.checkApiPrivilegeEscape(decodedResult.getSub())) {
authVo.setAuthenticated(false);
return authVo;
}

// 刷新token过期时间
tokenService.refreshTokenExpire(decodedResult.getSub(), decodedResult.getAud(),
decodedResult.getTerm(), decodedResult.getIatMs());

// // 是否只进行认证
// if (dto.getOnlyAuthenticate()) {
// return authVo;
// }

String userId = decodedResult.getAud();

// 授权
UserDetail userDetail = this.getUserDetailService(decodedResult.getSub()).getUserDetail(userId);
// UserDetail userDetail = userDetailService.getUserDetail(userId);

authVo.setAuthenticated(true);
authVo.setUsername(userDetail.getUsername());
authVo.setRoles(userDetail.getRoles());
authVo.setResources(userDetail.getResources());
return authVo;
}

// ================================================================================

private UserDetailService getUserDetailService(String subject) {
UserDetailService userDetailService = mapUserDetailService.get(subject);
if (userDetailService == null) {
throw new BaseException("UserDetailService for subject: " + subject + " not found");
}
return userDetailService;
}

private boolean checkApiPrivilegeEscape(String subject) {
AuthTokenProperties.Subject subjectObj = authTokenProperties.getMapSubject().get(subject);
String apiPrefix = subjectObj.getApiPrefix();
if (StrUtil.isBlank(apiPrefix)) {
log.info("subject: {}, apiPrefix is not set, skip", subject);
return true;
}

boolean isEqOp = true;
if (apiPrefix.startsWith("!")) {
isEqOp = false;
apiPrefix = apiPrefix.substring(1);
}

String requestUri = this.getRequest().getRequestURI();

boolean success = false;
if (isEqOp) {
success = requestUri.startsWith(apiPrefix);
} else {
success = !requestUri.startsWith(apiPrefix);
}

log.info("isEqOp: {}, apiPrefix: {}, requestUri: {}, success: {}", isEqOp, apiPrefix, requestUri, success);

return success;
}

private HttpServletRequest getRequest() {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return Objects.requireNonNull(attrs).getRequest();
}

private void setMdcInfo(Long userId) {
String reqId = MDC.get(MdcLogFilter.MDC_REQ_ID);
String mdcInfo = "[reqId: " + reqId + ", userId:" + userId + "] ";
MDC.put(MdcLogFilter.MDC_INFO, mdcInfo);
}
}

代码说明:

  • 该类主要方法auth实现了token的解码、认证/验证、接口权限检查、token过期时间刷新、授权等;
  • 其中【授权】部分委托给UserDetailService接口的实现类来获取用户信息(具体各subject需要按要求提供实现类,具体见项目代码);

其他比如spring security配置以及胶水代码等不另做说明,详细见项代码。

开源项目地址#