客户端数据安全的源头
任何客户端传过来的数据都是不能直接信任的
1. 客户端传给服务端的数据只是信息收集, 2. 数据需要经过有效性验证、权限验证等后才能使用, 3. 并且这些数据只能认为是用户操作的意图,不能直接代表数据当前的状态。
客户端的计算不可信
@Data public class Order { private long itemId; //商品ID private BigDecimal itemPrice; //商品价格 private int quantity; //商品数量 private BigDecimal itemTotalPrice; //商品总价 }
第一种:针对前端传入的Order,真正直接使用的、可信赖的只是客户端传过来的商品ID和数量,服务端会根据这些信息重新计算最终的总价。
@PostMapping("/orderRight") public void right(@RequestBody Order order) { //根据ID重新查询商品 Item item = Db.getItem(order.getItemId()); //客户端传入的和服务端查询到的商品单价不匹配的时候,给予友好提示 if (!order.getItemPrice().equals(item.getItemPrice())) { throw new RuntimeException("您选购的商品价格有变化,请重新下单"); } //重新设置商品单价 order.setItemPrice(item.getItemPrice()); //重新计算商品总价 BigDecimal totalPrice = item.getItemPrice().multiply(BigDecimal.valueOf(order.getQuantity())); //客户端传入的和服务端查询到的商品总价不匹配的时候,给予友好提示 if (order.getItemTotalPrice().compareTo(totalPrice)!=0) { throw new RuntimeException("您选购的商品总价有变化,请重新下单"); } //重新设置商品总价 order.setItemTotalPrice(totalPrice); createOrder(order); }
第二种:重新定义POJO CreateOrderRequest作为接口入参,比直接使用领域模型 Order 更合理。
@Data public class CreateOrderRequest { private long itemId; //商品ID private int quantity; //商品数量 } @PostMapping("orderRight2") public Order right2(@RequestBody CreateOrderRequest createOrderRequest) { //商品ID和商品数量是可信的没问题,其他数据需要由服务端计算 Item item = Db.getItem(createOrderRequest.getItemId()); Order order = new Order(); order.setItemPrice(item.getItemPrice()); order.setItemTotalPrice(item.getItemPrice().multiply(BigDecimal.valueOf(order.getQuantity()))); createOrder(order); return order; }
客户端提交的参数需要校验
@PostMapping("/right") @ResponseBody public String right(@RequestParam("countryId") int countryId) { if (countryId < 1 || countryId > 3) throw new RuntimeException("非法参数"); return allCountries.get(countryId).getName(); }
或者使用Spring Validation采用注解的方式进行参数校验,更优雅:
@Validated public class TrustClientParameterController { @PostMapping("/better") @ResponseBody public String better( @RequestParam("countryId") @Min(value = 1, message = "非法参数") @Max(value = 3, message = "非法参数") int countryId) { return allCountries.get(countryId).getName(); } }
不能信任请求头里的任何内容
@Slf4j @RequestMapping("trustclientip") @RestController public class TrustClientIpController { HashSet<String> activityLimit = new HashSet<>(); @GetMapping("test") public String test(HttpServletRequest request) { String ip = getClientIp(request); if (activityLimit.contains(ip)) { return "您已经领取过奖品"; } else { activityLimit.add(ip); return "奖品领取成功"; } } private String getClientIp(HttpServletRequest request) { String xff = request.getHeader("X-Forwarded-For"); if (xff == null) { return request.getRemoteAddr(); } else { return xff.contains(",") ? xff.split(",")[0] : xff; } } }
这种过于依赖 X-Forwarded-For 请求头来判断用户唯一性的实现方式,是有问题的:
完全可以通过 cURL 类似的工具来模拟请求,随意篡改头的内容:
完全可以通过 cURL 类似的工具来模拟请求,随意篡改头的内容:
网吧、学校等机构的出口 IP 往往是同一个,在这个场景下,可能只有最先打开这个页面的用户才能领取到奖品,而其他用户会被阻拦。
因此,IP 地址或者说请求头里的任何信息,包括 Cookie 中的信息、Referer,只能用作参考,不能用作重要逻辑判断的依据。而对于类似这个案例唯一性的判断需求,更好的做法是,让用户进行登录或三方授权登录(比如微信),拿到用户标识来做唯一性判断。
用户标识不能从客户端获取
@GetMapping("wrong") public String wrong(@RequestParam("userId") Long userId) { return "当前用户Id:" + userId; }
案例:
一个大项目因为服务端直接使用了客户端传过来的用户标识,导致了安全问题。
1. 没有正确认识接口或服务面向的用户。如果接口面向内部服务,由服务调用方传入用户 ID 没什么不合理,但是这样的接口不能直接开放给客户端或 H5 使用。
2. 在测试阶段为了方便测试调试,我们通常会实现一些无需登录即可使用的接口,直接使用客户端传过来的用户标识,却在上线之前忘记删除类似的超级接口。
3. 一个大型网站前端可能由不同的模块构成,不一定是一个系统,而用户登录状态可能也没有打通。有些时候,我们图简单可能会在 URL 中直接传用户 ID,以实现通过前端传值来打通用户登录状态。
如果接口直面用户(比如给客户端或 H5 页面调用),那么一定需要用户先登录才能使用。
登录后用户标识保存在服务端,接口需要从服务端(比如 Session 中)获取。
这里有段代码演示了一个最简单的登录操作,登录后在 Session 中设置了当前用户的标识:
@GetMapping("login") public long login(@RequestParam("username") String username, @RequestParam("password") String password, HttpSession session) { if (username.equals("admin") && password.equals("admin")) { session.setAttribute("currentUser", 1L); return 1L; } return 0L; }
定义一个自定义注解 @LoginRequired 到 userId 参数上,然后通过 HandlerMethodArgumentResolver 自动实现参数的组装:
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) @Documented public @interface LoginRequired { String sessionKey() default "currentUser"; }
实现登陆后所有需要登录的方法都可以一键通过加@LoginRequired注解来拿用户标识,方便安全:
@Slf4j public class LoginRequiredArgumentResolver implements HandlerMethodArgumentResolver { //解析哪些参数 @Override public boolean supportsParameter(MethodParameter methodParameter) { //匹配参数上具有@LoginRequired注解的参数 return methodParameter.hasParameterAnnotation(LoginRequired.class); } @Override public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer, NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception { //从参数上获得注解 LoginRequired loginRequired = methodParameter.getParameterAnnotation(LoginRequired.class); //根据注解中的Session Key,从Session中查询用户信息 Object object = nativeWebRequest.getAttribute(loginRequired.sessionKey(), NativeWebRequest.SCOPE_SESSION); if (object == null) { log.error("接口 {} 非法调用!", methodParameter.getMethod().toString()); throw new RuntimeException("请先登录!"); } return object; } }
实现 WebMvcConfigurer 接口的 addArgumentResolvers 方法,来增加这个自定义的处理器 LoginRequiredArgumentResolver:
SpringBootApplication public class CommonMistakesApplication implements WebMvcConfigurer { ... @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { resolvers.add(new LoginRequiredArgumentResolver()); } }
打通不同的系统甚至不同网站的用户标识
打通用户在不同系统之间的登录,大致有以下三种方案。
第一种,把用户身份放在统一的服务端,每一个系统都需要到这个服务端来做登录状态的确认,确认后在自己网站的 Cookie 中保存会话,这就是单点登录的做法。这种方案要求所有关联系统都对接一套中央认证服务器(中央保存用户会话),在未登录的时候跳转到中央认证服务器进行登录或登录状态确认。因此,这种方案适合一个公司内部的不同域名下的网站。
第二种,把用户身份信息直接放在 Token 中,在客户端任意传递,Token 由服务端进行校验(如果共享密钥话,甚至不需要同一个服务端进行校验),无需采用中央认证服务器,相对比较松耦合,典型的标准是 JWT。这种方案适合异构系统的跨系统用户认证打通,而且相比单点登录的方案,用户体验会更好一些。
第三种,如果需要打通不同公司系统的用户登录状态,那么一般都会采用 OAuth 2.0 的标准中的授权码模式,基本流程如下:
1. 第三方网站客户端转到授权服务器,上送 ClientID、重定向地址 RedirectUri 等信息。 2. 用户在授权服务器进行登录并且进行授权批准(授权批准这步可以配置为自动完成)。 3. 授权完成后,重定向回到之前客户端提供的重定向地址,附上授权码。 4. 第三方网站服务端通过授权码 +ClientID+ClientSecret 去授权服务器换取 Token。这里的 Token 包含访问 Token 和刷新 Token,访问 Token 过期后用刷新 Token 去获得新的访问 Token。 5. 因为我们不会对外暴露 ClientSecret,也不会对外暴露访问 Token, 6. 同时使用授权码换取 Token 的过程是服务端进行的, 7. 客户端拿到的只是一次性的授权码,所以这种模式比较安全。
开放重定向问题
在把匿名用户重定向到登录页面的时候,我们一般会带上 redirectUrl,这样用户登录后可以快速返回之前的页面。黑客可能会伪造一个活动链接,由真实的网站 + 钓鱼的 redirectUrl 构成,发邮件诱导用户进行登录。用户登录时访问的其实是真的网站,所以不容易察觉到 redirectUrl 是钓鱼网站,登录后却来到了钓鱼网站,用户可能会不知不觉就把重要信息泄露了。
1. 第一种,固定重定向的目标 URL。 2. 第二种,可采用编号方式指定重定向的目标 URL,也就是重定向的目标 URL 只能是在我们的白名单内的。 3. 第三种,用合理充分的校验方式来校验跳转的目标地址,如果是非己方地址,就告知用户跳转有风险,小心钓鱼网站的威胁。
原文链接:https://time.geekbang.org/column/article/235700
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· 一起来玩mcp_server_sqlite,让AI帮你做增删改查!!