【问题标题】:Authorization Code Flow - Concurrent Requests from Multiple Tabs授权代码流 - 来自多个选项卡的并发请求
【发布时间】:2021-04-06 03:36:57
【问题描述】:

OAuth2 身份验证代码流中基于 cookie 的会话的性质和 state 参数处理暴露了一个问题,当新的浏览器会话启动时多个选项卡试图同时打开“安全服务器”上的多个链接(我们的 Oauth2 机密客户端)。

当浏览器启动时,它会丢弃所有以前的会话 cookie。在崩溃恢复的情况下,浏览器可以一次打开多个标签,或者用户可以从书签文件夹或历史记录中打开多个标签。

在这种情况下,所有选项卡都会同时向安全服务器发送未经身份验证的请求。每个请求都将启动一个新会话和一个新的身份验证代码流,并带有新的 state 参数,这些参数将保存在此会话中。

所有安全服务器的重定向到身份提供者响应都将带有一个名称相同但值不同的会话 cookie。它们会在浏览器中相互覆盖,只有最后一个会被浏览器保存为 Session ID。

每个选项卡将继续沿授权代码流向下到身份提供者登录页面并返回到安全服务器,带有不同的 state 参数,但相同的会话 cookie(由最后一个选项卡设置)。

那些 state 参数保存在现在丢失的会话中,无法验证。禁止State参数验证失败,报403错误。

结果是除了最后一个标签之外的所有标签都在 403 页面上结束。

是否有任何已知的做法来处理这个问题?

谢谢

【问题讨论】:

  • 我使用了不同名字的cookies,并且把cookie名字放在state里,Secure Server使用这个cookie名字在state里获取指定的cookie。
  • 所以换句话说,你把状态放在一个cookie名称中。 (你在这个 cookie 值中放了什么?也许返回 URL ?)那么你如何验证状态呢?检查是否有一个名称为 state 的 cookie?如果是这样,那么您比较两个令牌,这两个令牌都是由客户端提供的。不安全吗?
  • 如果你单向散列状态中的随机值,什么可以阻止攻击者替换cookie值和它在状态参数中的散列?
  • 谢谢提示,hash无效,我原来的想法是错误的。考虑到代码被盗,似乎无法阻止攻击者使用自己的状态和cookie来替换它们。我正在尝试查找更多信息。现在,我想state use for making sure the response belongs to a request initiated by the same user。代码被盗的责任属于Auth Server(代码只能使用一次,如果被重复使用,所有令牌将被撤销)。
  • 另外,关于原来的并发问题,我找到了一个类似的答案here,用一个随机值作为key。

标签: oauth state single-sign-on session-cookies browser-tab


【解决方案1】:

我已经开发了一个解决方案,

发布于TheNetworg OAuth2-Azure discussions

我仍然需要更多的意见才能认为它是安全和充分的。

每次验证代码流程启动时,我们都必须设置一个唯一命名的会话 cookie 副本。 cookie 名称应具有可识别的前缀

if (!isset($_GET['code'])) {
  // If we don't have an authorization code then get one
  $authUrl = $provider->getAuthorizationUrl();
  $oauth2state = $provider->getState();

  // Save the return URL along with the state
  $_SESSION['oauth2state'][$oauth2state] = [
      'returnUrl' => $_SERVER['REQUEST_URI']
  ];

  $sid = session_id();
  $uniq_session_name = uniqid('USID_', false);
  $params = session_get_cookie_params();
  setcookie($uniq_session_name, $sid, $params['lifetime'],
      $params['path'], $params['domain'],
      $params['secure'], $params['httponly']
  );

  header('Location: ' . $authUrl);
  exit;
}

因此,当启动 N 个选项卡时,将有一个“原始”会话 cookie,以及在 N 个请求中的每一个时打开的具有不同名称和会话 ID 的 N 个 cookie。我们将它们称为“备用会话”

当 OAuth2 状态检查失败时,它应该尝试查找空闲会话以获取有效状态。如果找到有效的备用会话,它的 cookie 将被擦除。然后我们可以将用户返回到在这个备用状态中找到的 returnUrl,这一次他将使用正确的会话 cookie 跟随重定向。

if (empty($_GET['state'])) {
  die "Invalid State";
}


if (!isset($_SESSION['state']) || !array_key_exists($_GET['state'], $_SESSION['oauth2state'])) {
  if([$uniq_state_name, $returnUrl] = lookupSpareSessionReturnUrls($_GET['state'])) {
    unsetSessionCookie($uniq_state_name);
    header('Location: ' . $returnUrl);
    exit;
  }
  die ("Invalid State");
}

/**
 * @param $oauth2state
 * @return array|null
 */
function lookupSpareSessionReturnUrls($oauth2state) {
  $uniq_sessions_names = preg_grep('/USID_.*/', array_keys($_COOKIE));
  if($uniq_sessions_names) {
    foreach ($uniq_sessions_names as $usname) {
      $usid = $_COOKIE[$usname];
      if($usid !== session_id()) {
        $TMP_SESSION = sessionPeek($usid);
        if (isset($TMP_SESSION['oauth2state'][$oauth2state]['returnUrl'])) {
          return [$usname, $TMP_SESSION['oauth2state'][$oauth2state]['returnUrl']];
        }
      }
    }
  }
  return null;
}

/**
 * Sample Session Peek function 
 * for file based sessions
 * @param $sid
 * @return array
 */
function sessionPeek($sid) {
  $sess_file = session_save_path() . '/sess_' . $sid;
  $TMP_SESSION = [];
  $CURRENT_SESSION = $_SESSION;
  if(session_decode(file_get_contents($sess_file))) {
    $TMP_SESSION = $_SESSION;
  }
  $_SESSION = $CURRENT_SESSION;
  return $TMP_SESSION;
}

如果未找到备用会话,则流程将按预期返回错误。

访问返回 URL 的选项卡已经包含所有选项卡共享的正确会话 cookie。但是,它可能会到达目标页面,也可能会再次启动 Auth Code 流程,具体取决于其他选项卡中 Auth 的竞速情况。

如果来得太早,在任何其他选项卡完成授权之前,就会启动新的 Auth Code 流程,并在当前会话中保存带有 return_url 的新状态。

在从 Azure 返回到 Auth 回调 URL 的过程中,会话可能已经在另一个选项卡中获得授权。在这种情况下,我们必须停止流,并重定向到原始的 return_url,它可能在当前或备用会话中找到。

if ($_SESSION['authorizedFlag'] === true && isset($_GET['code']) && isset($_GET['state'])) {
  $returnUrl = null;
  if([$usname, $returnUrl] = lookupSpareSessionReturnUrls($_GET['state'])) {
    unsetSessionCookie($usname);
  }
  elseif (isset($_SESSION['oauth2state'][$_GET['state']]['returnUrl'])) {
    $returnUrl = $_SESSION['oauth2state'][$_GET['state']]['returnUrl'];
    unset($_SESSION['oauth2state'][$_GET['state']]);
  }
  else {
    /* Dead End, no return URL, redirect to Error or Home page.  
        Shouldn't normally happen */
  }
  header('Location: ' . $returnUrl);
  exit;
}

此时备用会话 ID 可能会被丢弃,并且其 cookie 未设置。该选项卡最终将获得受保护的页面,就像所有其他选项卡一样,像往常一样共享相同的会话 cookie

/* Auth OK */
try {
  $token = $provider->getAccessToken('authorization_code', [
    'code' => $_GET['code'],
  ]);
  $_SESSION['authorizedFlag'] = true;
}
catch (IdentityProviderException $e) {
  die ( $e->getMessage() );
}


$uniq_sessions_names = preg_grep('/USID_.*/', array_keys($_COOKIE));
if(!empty($uniq_sessions_names)) {
  foreach ($uniq_sessions_names as $usname) {
    $usid = $_COOKIE[$usname];
    if ($usid === session_id()) {
      $unsetSessionCookie($usname);
    }
  }
}

/* Regenerate session ID for security but DO NOT discard the old session [ file ] - it may be needed as a spare now */
session_regenerate_id(false);

完整代码示例

if( $_SESSION['authorizedFlag'] !== true ) {
  if (!isset($_GET['code'])) {
    // If we don't have an authorization code then get one
    $authUrl = $provider->getAuthorizationUrl();
    $oauth2state = $provider->getState();
  
    // Save the return URL along with the state
    $_SESSION['oauth2state'][$oauth2state] = [
        'returnUrl' => $_SERVER['REQUEST_URI']
    ];
  
    $sid = session_id();
    $uniq_session_name = uniqid('USID_', false);
    $params = session_get_cookie_params();
    setcookie($uniq_session_name, $sid, $params['lifetime'],
        $params['path'], $params['domain'],
        $params['secure'], $params['httponly']
    );
  
    header('Location: ' . $authUrl);
    exit;
  }
  
  if (empty($_GET['state'])) {
    die "Invalid State";
  }
  
  if (!isset($_SESSION['state']) || !array_key_exists($_GET['state'], $_SESSION['oauth2state'])) {
    if([$uniq_state_name, $returnUrl] = lookupSpareSessionReturnUrls($_GET['state'])) {
      unsetSessionCookie($uniq_state_name);
      header('Location: ' . $returnUrl);
      exit;
    }
    die ("Invalid State");
  }
  
  
  /* Auth OK */
  try {
    $token = $provider->getAccessToken('authorization_code', [
      'code' => $_GET['code'],
    ]);
    $_SESSION['authorizedFlag'] = true;
  }
  catch (IdentityProviderException $e) {
    die ( $e->getMessage() );
  }
  
  
  $uniq_sessions_names = preg_grep('/USID_.*/', array_keys($_COOKIE));
  if(!empty($uniq_sessions_names)) {
    foreach ($uniq_sessions_names as $usname) {
      $usid = $_COOKIE[$usname];
      if ($usid === session_id()) {
        unsetSessionCookie($usname);
      }
    }
  }
  
/* Regenerate session ID for security but DO NOT discard the old session [ file ] - it may be needed as a spare now */
  session_regenerate_id(false);

}
else if ($_SESSION['authorizedFlag'] === true && isset($_GET['code']) && isset($_GET['state'])) {
  $returnUrl = null;
  if([$usname, $returnUrl] = lookupSpareSessionReturnUrls($_GET['state'])) {
    unsetSessionCookie($usname);
  }
  elseif (isset($_SESSION['oauth2state'][$_GET['state']]['returnUrl'])) {
    $returnUrl = $_SESSION['oauth2state'][$_GET['state']]['returnUrl'];
    unset($_SESSION['oauth2state'][$_GET['state']]);
  }
  else {
    /* Dead End, no return URL, redirect to Error or Home page.  
        Shouldn't normally happen */
  }
  header('Location: ' . $returnUrl);
  exit;
}

/* Authorization finished - continue to protected resource */



/**
 * @param $oauth2state
 * @return array|null
 */
function lookupSpareSessionReturnUrls($oauth2state) {
  $uniq_sessions_names = preg_grep('/USID_.*/', array_keys($_COOKIE));
  if($uniq_sessions_names) {
    foreach ($uniq_sessions_names as $usname) {
      $usid = $_COOKIE[$usname];
      if($usid !== session_id()) {
        $TMP_SESSION = sessionPeek($usid);
        if (isset($TMP_SESSION['oauth2state'][$oauth2state]['returnUrl'])) {
          return [$usname, $TMP_SESSION['oauth2state'][$oauth2state]['returnUrl']];
        }
      }
    }
  }
  return null;
}

/**
 * Sample Session Peek function 
 * for file based sessions
 * may be not the best practice
 * @param $sid
 * @return array
 */
function sessionPeek($sid) {
  $sess_file = session_save_path() . '/sess_' . $sid;
  $TMP_SESSION = [];
  $CURRENT_SESSION = $_SESSION;
  if(session_decode(file_get_contents($sess_file))) {
    $TMP_SESSION = $_SESSION;
  }
  $_SESSION = $CURRENT_SESSION;
  return $TMP_SESSION;
}

【讨论】:

    【解决方案2】:

    有趣的问题,在大多数情况下,这将是一个挑战,并且需要以下人员的支持:

    • 客户端 OAuth 库
    • 授权服务器

    合规库

    oidc-client-js 库通过每个重定向的状态存储 演示了所需的技术。如您所说,最后一个人将获胜,最终用户不会出现任何错误。

    这是客户端 Web UI 比服务器端 Web 堆栈(例如 ASP.Net / Spring Boot)触发的重定向具有更大控制权的可用性领域之一。

    行为可视化

    运行我的Online OAuth SPA 并触发 2 个重定向,但不要登录任何一个。然后浏览到这个URL,在重定向状态下查看浏览器的本地存储工具:

    最后获胜的人将更新 用户存储,其数据用于后续的续订重定向和令牌验证(请注意,我的 SPA 将实际令牌存储在内存中而不是此用户存储中):

    不合规的授权服务器

    不幸的是,我的在线授权服务器 (AWS Cognito) 不喜欢接收这样的 2 次登录,并且第二次登录失败。

    【讨论】:

    • 它看起来像一个类似的问题。但在您的情况下,错误来自身份验证服务器(或身份提供者) - 目前尚不清楚它为什么会失败,因为 AS 与重定向状态参数无关,它应该只是将其传输回代码流发起程序。跨度>
    • 我也在寻找解决方案。只有一个问题:只有当我们能够保证发球的执行顺序与接发的顺序相同时,“最后将获胜”才是正确的,对吧?否则,如果我们有请求 A 和 B,它们按 (A,B) 的顺序在服务器端执行,但像 (B,A) 一样接收,A 将“获胜”,使该流程失败......所以也许是服务器端时间戳够了吗?
    猜你喜欢
    • 2019-10-02
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-10-15
    • 1970-01-01
    相关资源
    最近更新 更多