{"record":{"id":"ccc71147b383a643","repo":"xkcoding/spring-boot-demo","slug":"5003","errorCode":"5003","errorMessage":"当前用户已在别处登录，请尝试更改密码或重新登录！","messagePattern":"当前用户已在别处登录，请尝试更改密码或重新登录！","errorType":"exception","errorClass":"SecurityException","httpStatus":null,"severity":"warning","filePath":"demo-rbac-security/src/main/java/com/xkcoding/rbac/security/util/JwtUtil.java","lineNumber":104,"sourceCode":"     * @return {@link Claims}\n     */\n    public Claims parseJWT(String jwt) {\n        try {\n            Claims claims = Jwts.parser().setSigningKey(jwtConfig.getKey()).parseClaimsJws(jwt).getBody();\n\n            String username = claims.getSubject();\n            String redisKey = Consts.REDIS_JWT_KEY_PREFIX + username;\n\n            // 校验redis中的JWT是否存在\n            Long expire = stringRedisTemplate.getExpire(redisKey, TimeUnit.MILLISECONDS);\n            if (Objects.isNull(expire) || expire <= 0) {\n                throw new SecurityException(Status.TOKEN_EXPIRED);\n            }\n\n            // 校验redis中的JWT是否与当前的一致，不一致则代表用户已注销/用户在不同设备登录，均代表JWT已过期\n            String redisToken = stringRedisTemplate.opsForValue().get(redisKey);\n            if (!StrUtil.equals(jwt, redisToken)) {\n                throw new SecurityException(Status.TOKEN_OUT_OF_CTRL);\n            }\n            return claims;\n        } catch (ExpiredJwtException e) {\n            log.error(\"Token 已过期\");\n            throw new SecurityException(Status.TOKEN_EXPIRED);\n        } catch (UnsupportedJwtException e) {\n            log.error(\"不支持的 Token\");\n            throw new SecurityException(Status.TOKEN_PARSE_ERROR);\n        } catch (MalformedJwtException e) {\n            log.error(\"Token 无效\");\n            throw new SecurityException(Status.TOKEN_PARSE_ERROR);\n        } catch (SignatureException e) {\n            log.error(\"无效的 Token 签名\");\n            throw new SecurityException(Status.TOKEN_PARSE_ERROR);\n        } catch (IllegalArgumentException e) {\n            log.error(\"Token 参数不存在\");\n            throw new SecurityException(Status.TOKEN_PARSE_ERROR);\n        }","sourceCodeStart":86,"sourceCodeEnd":122,"githubUrl":"https://github.com/xkcoding/spring-boot-demo/blob/87a142f9604c1a5365b4d24d22c2c11c26a9d5ab/demo-rbac-security/src/main/java/com/xkcoding/rbac/security/util/JwtUtil.java#L86-L122","documentation":"Thrown by JwtUtil.parseJWT when the JWT signature parses successfully and the Redis key exists, but the token value stored in Redis does not match the token in the request. This indicates the server-issued a newer token for the same user (single-device enforcement), or the user logged out and logged back in. Status.TOKEN_OUT_OF_CTRL (code 5003) signals the token is no longer the 'current' one. This implements a single-active-session policy via Redis as the source of truth.","triggerScenarios":"A user logs in on device B, which overwrites the Redis key with a new JWT. When device A makes a request with its old JWT, StrUtil.equals(jwt, redisToken) returns false and TOKEN_OUT_OF_CTRL is thrown. Also occurs if the Redis key was manually modified or a race condition overwrote it.","commonSituations":"User logs in from a second device/browser; user logged out (Redis key deleted) then logged back in (new token); load balancer routes to a Redis replica that is stale; concurrent login requests racing to write the Redis key.","solutions":["On the client, catch the 5003 response and prompt the user to re-authenticate (the old token is permanently invalid).","If multi-device sessions are desired, change the Redis key strategy to store a set of valid tokens per user rather than a single value.","Ensure Redis is not running in a split-brain or stale-replica configuration that causes reads to see old token values.","Serialize login requests for the same user to prevent race conditions on the Redis key."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"// Client-side: before making a request, verify the local token matches\n// the 'last known good' token (if the app tracks it).\n// No server-side pre-check API; the mismatch is detected server-side only.\n// Best practice: implement single-source-of-truth token storage on the client.\nString storedToken = tokenStore.get();\nif (storedToken == null || !storedToken.equals(currentRequestToken)) {\n    redirectToLogin();\n}","typeGuard":null,"tryCatchPattern":"// Client-side: intercept 5003 token-out-of-control responses\ntry {\n    apiClient.someProtectedResource();\n} catch (SecurityException e) {\n    if (e.getStatus().getCode() == 5003) {\n        // Token superseded by a newer login — must re-authenticate\n        clearStoredToken();\n        redirectToLogin();\n    }\n}","preventionTips":["If multi-device sessions are needed, change the Redis strategy to store a token set per user.","Ensure Redis is not in a stale-replica or split-brain configuration.","Serialize login requests for the same user to prevent race conditions on the Redis key.","On the client, always use the most recently issued token and discard older ones."],"tags":["jwt","spring-security","redis","single-session","authentication","concurrent-login"],"backgroundTag":null,"analyzedSha":"87a142f9604c1a5365b4d24d22c2c11c26a9d5ab","analyzedAt":"2026-08-14T01:16:58.217Z","schemaVersion":2},"datasetVersion":"2026-08-14T05:17:29.042Z"}