一般服务的安全包括认证(Authentication)与授权(Authorization)两部分,认证即证明一个用户是合法的用户,比如通过用户名密码的形式,授权则是控制某个用户可以访问哪些资源。比较成熟的框架有Shiro、Spring Security,如果要实现第三方授权模式,则可采用OAuth2。但如果是一些简单的应用,比如一个只需要鉴别用户是否登录的APP,则可以简单地通过注解+拦截器的方式来实现。本文介绍了具体实现过程,虽基于Spring Boot实现,但稍作修改(主要是拦截器配置)就可以引入其它Spring MVC的项目。
在pom.xml中添加JWT与redis依赖
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>${jwt.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
在application.yml配置文件中添加redis相关配置属性
spring: redis: host: localhost port: 6379 database: 0 password: 123654 timeout: 3000 jedis: pool: min-idle: 2 max-idle: 8 max-active: 8 max-wait: 1000
注解的定义你可以根据项目的具体场景,比如需要登录的接口比较多,就可以定义如 @SkipAuth 的注解来标记不需要登录的接口,反之,则可以定义如 @NeedAuth 的注解来标记需要登录的接口,总之就是让标记接口这个操作尽可能少。但也可以基于另一种考虑,万一需要登录的接口忘了加不就存在安全问题吗,所以用 @SkipAuth 相对要保险点。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface SkipAuth { }
@Component public class RedisTokenManager { @Autowired private StringRedisTemplate redisTemplate; /** * 生成TOKEN */ public String createToken(String userId) { //使用uuid作为源token String token = Jwts.builder().setId(userId).setIssuedAt(new Date()) .signWith(SignatureAlgorithm.HS256, JwtConstant.JWT_SECRET).compact(); //存储到redis并设置过期时间 redisTemplate.boundValueOps(JwtConstant.AUTHORIZATION + ":" + userId) .set(token, JwtConstant.TOKEN_EXPIRES_HOUR, TimeUnit.HOURS); return token; } public boolean checkToken(TokenModel model) { if (model == null) { return false; } String token = redisTemplate.boundValueOps(JwtConstant.AUTHORIZATION + ":" + model.getUserId()).get(); if (token == null || !token.equals(model.getToken())) { return false; } //如果验证成功,说明此用户进行了一次有效操作,延长token的过期时间 redisTemplate.boundValueOps(model.getUserId()) .expire(JwtConstant.TOKEN_EXPIRES_HOUR, TimeUnit.HOURS); return true; } public void deleteToken(String userId) { redisTemplate.delete(userId); } }
在登录接口通过时,调用 createToken
创建token,并保存到redis中,设置过期时间, 在调用未被 @SkipAuth 注解标记的接口时,调用 checkToken
来验证,并更新token的过期时间, 退出登录时,删除token。
@Component @Slf4j public class AuthInterceptor extends HandlerInterceptorAdapter { @Autowired private RedisTokenManager tokenManager; public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestPath = request.getRequestURI().substring(request.getContextPath().length()); // 如果不是映射到方法直接通过 if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; Method method = handlerMethod.getMethod(); // 如果方法注明了 SkipAuth,则不需要登录token验证 if (method.getAnnotation(SkipAuth.class) != null) { return true; } // 从header中得到token String authorization = request.getHeader(JwtConstant.AUTHORIZATION); // 验证token if(StringUtils.isBlank(authorization)){ WebUtil.outputJsonString(ApiResponse.failed("未提供有效Token!"), response); return false; } try { Claims claims = Jwts.parser().setSigningKey(JwtConstant.JWT_SECRET) .parseClaimsJws(authorization).getBody(); String userId = claims.getId(); TokenModel model = new TokenModel(userId, authorization); if (tokenManager.checkToken(model)) { // 通过ThreadLocal设置下游需要访问的值 AuthUtil.setUserId(model.getUserId()); return true; } else { log.info("连接" + requestPath + "拒绝"); WebUtil.outputJsonString(ApiResponse.failed("未提供有效Token!"), response); return false; } } catch (Exception e) { log.error("连接" + requestPath + "发生错误:", e); WebUtil.outputJsonString(ApiResponse.failed("校验Token发生异常!"), response); return false; } } @Override public void afterCompletion( HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { //结束后清除,否则由于线程池复用,导致ThreadLocal的值被其他用户获取 AuthUtil.clear(); } }
拦截器通过对请求方法是否标记注解 @SkipAuth 来判断是否需要进行token验证,如果验证通过,则从JWT token中解析出userId,通过AuthUtil工具方法保存到ThreadLocal中,供下游访问。在请求处理结束调用 afterCompletion
方法中,要清除掉ThreadLocal中的值,否则由于线程池的复用,导致被其他用户获取。
然后,注册拦截器
@Configuration public class WebConfiguration implements WebMvcConfigurer { private AuthInterceptor authInterceptor; @Autowired public void setAuthInterceptor(AuthInterceptor authInterceptor){ this.authInterceptor = authInterceptor; } /** * 注册鉴权拦截器 * @param * @return */ public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/**") .excludePathPatterns("/error"); } }
这里将 /error 这个接口排除了,因为如果接口处理过程中出现异常,则spring boot会自动跳转到 /error 接口,又会进入拦截器校验(因为/error接口没有标注 @SkipAuth 注解)。
通过以上几步,一个简单的接口认证功能就实现了,我们可以通过添加一个登录接口,两个测试接口(一个需要认证,一个不需要认证)来验证下。
登录接口
@SkipAuth @RequestMapping("/login") public ApiResponse login(@RequestBody Map<String, Object> params) { String username = MapUtils.getString(params, "username"); String password = MapUtils.getString(params, "password"); if("ksxy".equals(username) && "jboost".equals(password)){ return ApiResponse.success(tokenManager.createToken(username)); } else { return ApiResponse.failed("用户名或密码错误"); } }
登录成功后,通过 createToken
方法创建了JWT token。
测试接口
@SkipAuth @RequestMapping("/skip-auth") public ApiResponse skipAuth() { return ApiResponse.success("不需要认证的接口调用"); } @RequestMapping("/need-auth") public ApiResponse needAuth() { return ApiResponse.success("username: " + AuthUtil.getUserId()); }
本文介绍了一个简单的接口认证方案,适用于不需要基于用户角色进行授权的场景。如果有较复杂的授权需求,则还是基于Shiro, Spring Security, OAuth2等框架来实现。这里也可以不用JWT,但是需要自己去做一些处理,比如将userId以某种形式包含在token中,解析时取出。
本文完整实例代码: https://github.com/ronwxy/springboot-demos/tree/master/springboot-simpleauth