友链用户的特权验证方式

前往原站点查看

2024-10-19 23:19:28

    准备开始将博客重心放在番剧页面了,其中绕不开的一环就是权限的设计。

    一些番剧资源网站,很容易作为爆破目标,被各种目标群体举报,比如我们所熟知的D站,樱花站等,就因为举报的原因,域名迁移了一个又一个,还衍生了不少镜像站,夹缝中生存。所以在22年那时候设计的时候,就一直没想好怎么来设计这个页面。

    最开始,想到的策略是简单的账号密码登录,但是这样实质上没有什么防备作用。开放式注册,肯定不行。申请制,访客通过联系站长qq申请,站长通过对目标用户简单查询分析后,符合资质的提供注册码注册,但是这样简单的验证,识别精准度比较低,因为你也无法通过这种方式判断来者是否真的是ACGN爱好者。答题制,这个方向其实也考虑了很多细致方案,想着随机ACGN知识来作为门槛,奈何现在互联网搜索引擎收录的问题涵盖太过全面,简单的知识随便搜搜就能搜到,如果要出难的题目,又要自己筛选不同的题目组成题库,题库后续被重试N次,迟早会摸到底,所以答题制,不行!

    那么还有什么方式呢?就因为这个问题,该模块停滞更新了两年半!

    然后,突然有一天,灵感一现,想到了解决方案,就是本文将要介绍的方式。

    

    实际上,这种方式并不是没有灵感来源,来源如:SSL证书认证、加速域名认证、搜索引擎(百度、谷歌)收录认证等。方式如下面的流程图,一个注册一个登录。只有拥有友链的用户才能申请友链特权,非友链用户没有任何权限,这样就可以依据友链来查看站长的状态了,同时可以过滤掉一大批人,只保留了有技术力并且对ACGN有一定热爱的群体。



    验证友链是当前注册用户的方式也很简单,站长可以通过文件认证或者DNS认证的方式来验证(目前还不支持DNS认证)。站长按照【番剧】页面的【友链用户特权】子页面步骤提示操作即可。

    从列表选择自己在本站的友链,通过随机获取得到的hash值后,以hash作为文件名,放在网站根目录下,那么在提交验证的时候,就会尝试从网站根目录查找这个文件,如果找到了,那么对内容进行校验,如果格式正确,作为密码保存下来,并且删除掉hash值,防止短时间内被复用。而对于友链用户也推荐删除掉用于验证的文件,防止密码泄露,当然密码泄露的概率并不大,毕竟数据传输过程全是加密数据。

    对于登录的友链来说,如果登录成功,会维系一个friendToken的sessionStorage值,在每次发送请求的时候都会携带,遇到需要友链才能访问的接口时,拦截器会工作,对token验证,验证成功才放行,否则显示友链登录组件。


    下面是一些枯燥的代码实现。

    获取有效友链, 会先获取的所有友链,选择启用的(有一些因为暂时的失联会切到停用状态),将目标的url返回。

    public RetResult<List<String>> getFriendList() {
        List<String> list = friendDao.selectAllFriends().stream()
                .filter(Friend::getEnable).map(Friend::getUrl)
                .collect(Collectors.toList());

        return RetResult.success(list);
    }

    生成Hash,目前的生成策略比较简单,

    public RetResult<String> getHash(HttpServletRequest request) {

        String hash = HashConstructor.byTimeStamp(Calendar.getInstance().getTimeInMillis());

        if (hash == null) return RetResult.fail("生成hash失败,请稍微再试或者联系站长");

        // hash 存在值,将hash值存入redis
        String realIp = request.getHeader("X-Real-IP");
        if (realIp == null) realIp = "UNKNOWN";
        ValueOperations ops = redisTemplate.opsForValue();
        ops.set(RedisConst.HASH_PREFIX + hash, realIp, Duration.ofMinutes(20));

        return RetResult.success(hash);
    }

    验证Hash文件,为什么要用一个PASSWORD打头作为目标文件的前置验证呢?因为有的网站,可能对找不到目标资源的路径有默认的404页面或者处理,如果恰巧结果涵盖了大小写、英文字母和符号,会被误判为密码,从而注册成功。这种情况,一方面是错误的,另外一方面作为任意访客,都可以尝试随便选取一个网站直接验证,都有概率通过,如果注册成功了,再去查看密码串是什么(原本可能只是404页面的内容),就会轻而易举的破解权限机制。所以必须要有一个标记字符串打头,来确认,这个资源返回的内容就是我们想要的密码内容。

     public RetResult<String> checkValid(FriendAuth friendAuth) {
        // 验证路径是否存在,避免出现直接接口调用的情况
        List<String> friends = getFriendList().getData();
        if (!friends.contains(friendAuth.getDomain())) return RetResult.fail("未找到的友链: " + friendAuth.getDomain());

        // 验证hash是否存在
        ValueOperations ops = redisTemplate.opsForValue();
        String password = (String) ops.get(RedisConst.HASH_PREFIX + friendAuth.getHash());
        if (!StringUtils.hasText(password)) return RetResult.fail("hash值已过期或者不存在");

        // 获取hash校验值
        if (friendAuth.getType() == 2) {
            // 获取路径
            String prefix = friendAuth.getDomain().endsWith("/") ? friendAuth.getDomain() : friendAuth.getDomain() + "/";
            String path = prefix + friendAuth.getHash();


            try {
                // 获取记录值
                URLConnection conn = new URL(path).openConnection();
                BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));
                String realPassword = reader.readLine();
                reader.close();

                // 密码安全性检查
                if (!realPassword.startsWith("PASSWORD:")) return RetResult.fail("密码请用如下方括号中内容打头标记[PASSWORD:]");
                realPassword = realPassword.replaceFirst("PASSWORD:", "");
                if (!StringUtils.hasText(realPassword)) return RetResult.fail("密码不能为空");
                if (!PasswordCheckUtil.containsUpperLowerDigitSymbol(realPassword)) {
	                    return RetResult.fail("密码必须至少包含大小写字母、数字和特殊符号(!@#$%^&*),且至少12位");
                }

                // 保存记录
                String md5Password = Md5String.simpleMd5(realPassword);
                FriendAuth auth = new FriendAuth(friendAuth.getDomain(), null, md5Password, 0);

                friendAuthDao.delFriendAuth(auth.getDomain());
                boolean result = friendAuthDao.saveFriendAuth(auth);
                if (!result) return RetResult.fail("通过校验,但是在存储记录时出现错误,请联系站长查看");

                // 成功,则删除token, 避免泄露产生其他后果
                redisTemplate.delete(RedisConst.HASH_PREFIX + friendAuth.getHash());

                return RetResult.success("校验注册成功!");
            } catch (IOException e) {
                return RetResult.fail("在尝试获取:" + path + " 数据时,连接失败");
            }
        } else {
            return RetResult.fail("暂未提供其他校验方式");
        }
    }

    登录逻辑也很简单,如果url和密码正确,那么登录成功,否则登录失败,登录成功获得40min的会话访问权限。

public RetResult<String> login(FriendAuth auth, HttpServletRequest request) {
        boolean result = friendAuthDao.checkFriendAuth(auth);

        // 未通过校验
        if (!result) return RetResult.fail("账号或者密码有误");

        // 通过校验,生成token用于调用
        // hash 存在值,将hash值存入redis
        String realIp = request.getHeader("X-Real-IP");
        if (realIp == null) realIp = "UNKNOWN";
        String token = Md5String.simpleMd5(auth);
        ValueOperations ops = redisTemplate.opsForValue();
        ops.set(RedisConst.AUTH_FRIEND + token, realIp, Duration.ofMinutes(40));

        return RetResult.success(token);
    }

    拦截器层面,如果api走拦截器,那么就解析friendAuth,如果redis存在,那么可以访问,否则拦截。

public class FriendAuthInterceptor implements HandlerInterceptor {

    private RedisTemplate template;

    public FriendAuthInterceptor(RedisTemplate template) {
        this.template = template;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String friendAuth = request.getHeader("friendAuth");
        String token = (String) template.opsForValue().get(RedisConst.AUTH_FRIEND + friendAuth);
        if(token!=null) {
            request.getSession().setAttribute("friend", true);
            // TODO 续token
            return true;
        } else {
            request.getSession().removeAttribute("friend");
        }

        response.setContentType("application/json;charset=utf8");

        ObjectMapper mapper = new ObjectMapper();
        String res = mapper.writeValueAsString(RetResult.denied());
        response.getWriter().write(res);

        return false;
    }
}

    

    后续的ACGN网站清单,搜番引擎(不一定会有),都会走这套验证机制,来对一些较为敏感的内容进行一级防护。



上一篇: 字体图标ttf合并
下一篇: 服务器迁移记录