我已经开发了一个解决方案,
发布于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;
}