1. 前情提要
java的双Token认证我们一般使用的是无状态Jwt+uuid的方式。jwt无状态信息通过接口返回给前端,uuid会以刷新Token的有状态返回给前端,refreshToken会存放在前端的localStorage中,jwt会放在cookie中,键名为Token。当需要权限的接口被调用的时候,接口会先获取header中的Token,之后对Token解码判断是否合法,不合法就会调用refreshToken的接口获取新的jwt并重新获取数据。radius仅存储uuid,key为用户的uid。
2. 代码实现
2.1. Jwt工具类
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
| @Component public class JwtTokenTools { @Value("${jwt.secret}") private String secret; @Value("${jwt.access-expire}") private long accessExpireTime; private static final String AUTHOR = "xxxxxx-xxxx"; private static final String AUDIENCE = "xxxxxx";
private String TokenCreate(String uid, String univ, String rand_str, long TimeNum) throws JoseException { JwtClaims claims = new JwtClaims(); claims.setIssuer(AUTHOR); claims.setSubject(uid); claims.setAudience(AUDIENCE); claims.setIssuedAt(NumericDate.now()); NumericDate expire = NumericDate.now(); expire.addSeconds(TimeNum * 60); claims.setExpirationTime(expire);
JsonWebSignature jws = new JsonWebSignature(); jws.setPayload(claims.toJson()); jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.HMAC_SHA256); jws.setHeader("typ", "JWT"); jws.setKey(new HmacKey(secret.getBytes(StandardCharsets.UTF_8))); return jws.getCompactSerialization(); }
public String createAccessToken(String uid, String univ, String rand_str) throws JoseException { return TokenCreate(uid, univ, rand_str, accessExpireTime); }
public JwtClaims ValidateToken(String token) throws InvalidJwtException { JwtConsumer consumer = new JwtConsumerBuilder() .setRequireExpirationTime() .setAllowedClockSkewInSeconds(30) .setExpectedIssuer(AUTHOR) .setRequireSubject() .setExpectedAudience(AUDIENCE) .setVerificationKey(new HmacKey(secret.getBytes(StandardCharsets.UTF_8))) .build(); return consumer.processToClaims(token); } }
|
生成体里面的参数可以根据用户需求自定义,这里只是简单示例。下面我将给您列出默认jwt固定的参数。
| name |
param |
desc |
| Issuer |
string |
签发者 |
| Subject |
string |
主题 |
| Audience |
string |
受众 |
| ExpirationTime |
long |
过期时间戳 |
| NotBefore |
long |
生效时间戳 |
| IssuedAt |
long |
签发时间戳 |
| JWTID |
string |
令牌唯一ID |
| alg |
string |
加密算法[header部分]: HS256、RS256、ES256 |
| typ |
JWT |
必须是JWT字符串,标志是JWT |
| kid |
Strubf |
秘钥ID,多秘钥场景,匹配对应的校验秘钥 |
setClaim函数是创建自定义参数,setClaim("key", value)
2.2. Radis工具类
这里写radis是因为我们的状态refreshToken是要存在radis中的,用来刷新jwt的签名认证。当退出我们的jwt是无状态的所以必须要强制让他失效,所以我们需要给他设置blacklist。
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
| @Component @RequiredArgsConstructor public class AuthRedis {
private final StringRedisTemplate redisTemplate;
public void saveRefreshToken(String uid, String refreshToken, int expiresInSeconds) { redisTemplate.opsForValue().set("refresh:uid:" + uid, refreshToken, expiresInSeconds, TimeUnit.SECONDS); }
public String getRefreshToken(String uid) { return redisTemplate.opsForValue().get("refresh:uid:" + uid); }
public void delRefreshToken(String uid) { redisTemplate.delete("refresh:uid:" + uid); }
public void addBlackList(String univ, int expireSeconds) { redisTemplate.opsForValue().set("blacklist:user:" + univ, "1", expireSeconds, TimeUnit.SECONDS); }
public boolean isBlackList(String univ) { return Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:user:" + univ)); } }
|
2.3. 开启Spring Security全局控制
我们使用的是无状态的认证,所以不用OAUTH2.0的认证方式,我们只需要开启Spring Security全局控制即可。
SecurityConfig.java的代码如下所示:
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
| @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig {
private final JwtTokenTools jwtTokenTools; private final AuthRedis authRedis;
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .headers(headers -> headers .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) .xssProtection(HeadersConfigurer.XXssConfig::disable) .httpStrictTransportSecurity(hsts -> hsts .includeSubDomains(true) .maxAgeInSeconds(31536000) ) ) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/login", "/api/retoken").permitAll() .anyRequest().authenticated() ) .addFilterBefore(new JWTAuthFilter(jwtTokenTools, authRedis), UsernamePasswordAuthenticationFilter.class) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable); return http.build(); } }
|
这里是对所有的接口进行拦截,除了/api/login和/api/retoken,其他的接口都需要进行jwt认证。
2.4. jwt权限拦截
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
| @RequiredArgsConstructor public class JWTAuthFilter extends OncePerRequestFilter {
private final JwtTokenTools jwtTokenTools; private final AuthRedis authRedis;
private static final List<String> ALLOWED_PATHS = Arrays.asList( "/api/retoken", );
private void returnStatus(HttpServletResponse response, Result<?> result) throws IOException { response.setCharacterEncoding("UTF-8"); response.setContentType("application/json; charset=utf-8"); response.setStatus(HttpServletResponse.SC_OK); try (PrintWriter writer = response.getWriter()) { writer.write(result.toString()); writer.flush(); } }
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String requestPath = request.getRequestURI(); if (isAllowedPath(requestPath)) { filterChain.doFilter(request, response); return; }
String auth = request.getHeader("Authorization"); if (auth == null || !auth.startsWith("Bearer ")) { returnStatus(response, Result.Unauthorized()); return; }
String token = auth.replace("Bearer ", "").trim(); JwtClaims claims; try { claims = jwtTokenTools.ValidateToken(token); } catch (InvalidJwtException e) { returnStatus(response, Result.Unauthorized()); return; } try { String idStr = claims.getStringClaimValue("id_str"); if (authRedis.isBlackList(idStr)) { returnStatus(response, Result.Unauthorized()); return; }
String userId = claims.getSubject(); Authentication authToken = new UsernamePasswordAuthenticationToken(userId, null, null); SecurityContextHolder.getContext().setAuthentication(authToken); }catch (Exception e) { returnStatus(response, Result.DiyErrFailed("出现了不可预知的错误,请联系管理员解决!")); return; } filterChain.doFilter(request, response); } private boolean isAllowedPath(String requestPath) { return ALLOWED_PATHS.stream().anyMatch(path -> requestPath.equals(path) || (requestPath.startsWith(path) && requestPath.length() >path.length() && requestPath.charAt(path.length()) == '/')); } }
|
我们这里的代码就是在前端请求接口的时候会在Authorization中携带jwt的token,我们在释放内容之前拦截请求拿取header中的token,之后进行校验是否有效,有效的话就释放请求进入到对应的函数处理数据,无权限的话就返回401。当然我们有些接口不需要权限也可以访问,比如:/api/retoken,这个接口是用来刷新token的,我们需要判断refreshToken是否有效即可不用判断jwt是否有效,所以就要把它先跳过,在者还有/api/login这个接口,这个接口是用来登录的,我们登录成功之后会返回jwt和refreshToken,所以这个接口也不需要权限。
3. 总结
通过上面的代码,我们就可以实现双token认证了,当然我们也可以根据需求进行修改,比如:jwt的过期时间,refreshToken的过期时间,jwt和refreshToken的生成方式等等。具体的使用可以参照接口的函数去调用,当然我们也可以根据需求进行修改。
具体的工作原理是:用户登录的时候会向后端发送用户的账号密码,之后后端会生成Jwt和refreshToken两个参数,jwt会随着接口返回给前端(json格式),之后refreshToken会随着cookie传过去,之后后端会在radis中写入uid对应的refreshToken,当用户登录成功前端会拦截请求判断是否存在需要的参数返回到对应的首页或者其他页面,当用户需要获取其他需要权限的数据的时候,会携带Authorization作为权限的token,后端会解析是否有效,如果有效的话就会继续查询数据并且返回,如果无效则会返回401,如果refreshToken失效了,则会返回401,如果refreshToken有效,则会重新生成jwt和refreshToken,并且返回给前端,前端会更新Authorization和cookie中的refreshToken,之后就可以继续访问其他需要权限的接口了。如果使用户手动退出登录或者长时间未使用导致refreshToken失效,则会返回401,前端会跳转到登录页面,用户重新登录即可。