Session绑定机制深度解析与实际项目踩坑总结

成立的笔记 安全 阅读 2,239
赞 7 收藏
二维码
手机扫码查看
反馈

session被劫持了,差点翻车

上周遇到一个诡异的问题,用户反馈账号莫名被盗用了。查了半天日志才发现是session被劫持了,具体表现就是同一个账号在不同地方同时登录,而且是从不同IP过来的。说实话,之前一直觉得session挺安全的,直到这次翻车了才意识到问题的严重性。

Session绑定机制深度解析与实际项目踩坑总结

最开始我还以为是密码泄露,让用户修改密码,结果还是不行。后来仔细看了下session的存储机制,发现我们只用了简单的PHP session_id()来标识用户,没有任何绑定措施。这里我踩了个坑,以为PHP原生的session机制就很安全,实际上攻击者可以通过各种手段获取到session id然后冒充用户。

排查过程,折腾了两天

先是检查了服务器的日志,发现确实是同一个session_id在不同IP下使用。然后我试了几种方法,最开始想通过IP来限制,但很快发现问题——用户如果用了代理或者运营商NAT转换,IP会变来变去,这样正常的用户也登不上去了。折腾了半天发现这个方案不行。

接着又试了UA检测,同样有问题——用户换浏览器或者更新浏览器版本都会导致session失效。后来试了下把用户的一些指纹信息存储起来验证,但实现起来比较复杂,而且容易误伤正常用户。

最后还是回到了正统的session绑定方案,结合用户的设备信息和一些固定参数来做绑定。说白了就是在创建session的时候,把这些信息一并存进去,验证的时候一起校验。

最终方案,核心代码就这几行

下面是具体的实现代码,我是在PHP环境下做的,基本思路就是生成一个绑定数据,跟session关联起来:

// 创建session并绑定用户信息
function createSecureSession($userId, $userAgent, $ip) {
    // 生成唯一的session token
    $sessionId = session_create_id();
    $token = md5($sessionId . $userId . $userAgent . $ip . time());
    
    // 存储绑定信息到session
    $_SESSION['user_id'] = $userId;
    $_SESSION['bind_token'] = $token;
    $_SESSION['user_agent'] = $userAgent;
    $_SESSION['ip_address'] = $ip;
    
    return $token;
}

// 验证session绑定
function validateSessionBinding() {
    if (!isset($_SESSION['bind_token'])) {
        return false;
    }
    
    $userId = $_SESSION['user_id'];
    $storedToken = $_SESSION['bind_token'];
    $userAgent = $_SESSION['user_agent'];
    $ipAddress = $_SESSION['ip_address'];
    
    // 重新计算token进行比对
    $currentToken = md5(session_id() . $userId . $userAgent . $ipAddress . time());
    
    // 这里有个问题,因为time()每次都不一样,所以需要从存储的token反推
    // 更好的方式是存储一个固定的盐值
    $recomputed = md5(session_id() . $userId . $userAgent . $ipAddress . 
                     substr($storedToken, 0, 8)); // 假设前8位是时间戳
    
    return hash_equals($storedToken, $recomputed);
}

上面的代码其实还有个小问题,就是时间戳部分的处理不够优雅,但基本原理就是这样。为了更安全,我还加了一层额外的保护:

// 改进版的session创建
function createSecureSessionV2($userId, $userAgent, $ip) {
    $sessionId = session_create_id();
    // 使用更复杂的绑定字符串
    $bindString = $userId . $userAgent . $ip . $_SERVER['HTTP_ACCEPT_LANGUAGE'] . 
                 $_SERVER['HTTP_ACCEPT_ENCODING'];
    
    $salt = bin2hex(random_bytes(16)); // 随机盐值
    $token = hash_hmac('sha256', $bindString, $salt);
    
    $_SESSION['user_id'] = $userId;
    $_SESSION['bind_token'] = $token;
    $_SESSION['bind_salt'] = $salt;
    $_SESSION['created_at'] = time();
    
    // 可选:限制session有效期
    $_SESSION['expires_in'] = time() + 3600; // 1小时后过期
}

// 验证函数
function verifySecureSession($userId, $userAgent, $ip) {
    if (!isset($_SESSION['bind_token']) || !isset($_SESSION['bind_salt'])) {
        return false;
    }
    
    // 检查是否过期
    if (isset($_SESSION['expires_in']) && time() > $_SESSION['expires_in']) {
        session_destroy();
        return false;
    }
    
    $bindString = $userId . $userAgent . $ip . $_SERVER['HTTP_ACCEPT_LANGUAGE'] . 
                 $_SERVER['HTTP_ACCEPT_ENCODING'];
    
    $expectedToken = hash_hmac('sha256', $bindString, $_SESSION['bind_salt']);
    
    return hash_equals($expectedToken, $_SESSION['bind_token']);
}

前端部分也需要配合,在发送请求的时候带上一些用户特征信息:

// 获取用户设备信息
function getUserDeviceInfo() {
    return {
        userAgent: navigator.userAgent,
        language: navigator.language,
        platform: navigator.platform,
        screenResolution: screen.width + 'x' + screen.height,
        timezoneOffset: new Date().getTimezoneOffset()
    };
}

// 发送请求时携带验证信息
function secureFetch(url, options = {}) {
    const deviceInfo = getUserDeviceInfo();
    
    const defaultHeaders = {
        'X-User-Agent': navigator.userAgent,
        'X-Language': navigator.language,
        'X-Timezone': new Date().getTimezoneOffset(),
        'Content-Type': 'application/json'
    };
    
    return fetch(url, {
        ...options,
        headers: {
            ...defaultHeaders,
            ...options.headers
        }
    });
}

// 示例API调用
secureFetch('https://jztheme.com/api/user/profile', {
    method: 'GET'
}).then(response => response.json())
  .then(data => console.log(data));

部署过程中的坑

部署到生产环境的时候遇到了几个小问题。首先是跨域请求的header传递,需要在nginx里设置允许传递自定义header:

location /api/ {
    proxy_pass http://backend;
    # 允许传递自定义header
    proxy_set_header X-User-Agent $http_x_user_agent;
    proxy_set_header X-Language $http_x_language;
    proxy_set_header X-Timezone $http_x_timezone;
    proxy_set_header X-Real-IP $remote_addr;
}

还有就是CDN缓存的问题,之前有些接口被CDN缓存了,导致session验证总是失败。后来加了no-cache header来避免这个问题。

另外需要注意的是,这种方式在某些特殊网络环境下可能还是会出问题,比如用户用手机上网的时候,运营商的网关可能会改变UA或者做一些代理转换。不过相对于安全性来说,这点误报率是可以接受的。

性能考虑和优化

刚开始担心这样的验证会增加服务器负担,但实际上HMAC的计算速度很快,基本不会有明显的性能影响。不过我还是做了一些优化,比如把验证逻辑封装成中间件,只在需要验证权限的接口里使用。

还有就是考虑到了分布式环境的问题,因为我们的应用部署在多个服务器上,所以session是存在Redis里的,这样保证了session数据的一致性。但同时也需要注意Redis的安全配置,防止被未授权访问。

以上是我踩坑后的总结,这套方案运行了一周,目前没再收到session相关的异常报告。如果你有更好的方案或者发现了什么问题,欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论