为 NexusPHP 添加 OAuth/OpenID 登录

为 NexusPHP 添加 OAuth/OpenID 登录

好久没写文章了,最近一直在搞自己 Minecraft 小服务器的事情,难得抽出时间来写写技术文章~ 最近为了 BR 的数据存档计划能更加顺利的实施,BR 也安装了一个 NexusPHP 用于追踪种子的上传/下载量和做种状态。但是 NexusPHP 自己用户系统相当难用,BR 也有自己的统一授权服务(

好久没写文章了,最近一直在搞自己 Minecraft 小服务器的事情,难得抽出时间来写写技术文章~

最近为了 BR 的数据存档计划能更加顺利的实施,BR 也安装了一个 NexusPHP 用于追踪种子的上传/下载量和做种状态。但是 NexusPHP 自己用户系统相当难用,BR 也有自己的统一授权服务(基于 Casdoor),因此搓了个 OAuth 功能,允许用户从 Casdoor (或者其他的 OAuth/OpenID 服务)注册和登录 NexusPHP。

支持的功能

  • 从 OAuth/OpenID 自动创建 NexusPHP 用户,并与其绑定,用户可通过 OAuth/OpenID 登录 NexusPHP
  • 检测用户名冲突,生成随机用户名并发送改名卡
  • 从 OAuth/OpenID 同步E-mail地址和头像
  • 兼容用户删除,删除用户后释放 sub 并允许用户重新注册
  • 自动转换不被 NexusPHP 支持的用户名格式:只保留数字和字母,且只截取前12个字符(NPHP最大用户名长度),如果仍然无效或者产生冲突,则添加随机前缀

安装 composer 依赖

该脚本使用 league/oauth2-client 模块,可以使用在 NexusPHP 安装目录下执行此命令安装它:

composer require league/oauth2-client

放置 login_oauth.php 脚本

在 NexusPHP 安装目录下的 public 目录中创建一个 login_oauth.php 的文件,并从下面复制并粘贴代码:

<?php

use App\Models\User;
use App\Models\UserMeta;
use App\Repositories\UserRepository;

require_once("../include/bittorrent.php");
dbconn();
cur_user_check();
failedloginscheck();
stdhead($lang_login['head_login']);
$openIdData = array();
$openIdData['clientId'] = ''; // 客户端 ID
$openIdData['clientSecret'] = ''; // 客户端密钥
$openIdData['redirectUri'] = 'https://tracker.yourwebsite.com/login_oauth.php'; // 本文件在您的站点的 URL
$openIdData['urlAuthorize'] = ''; // authorize 端点
$openIdData['urlAccessToken'] = ''; // access_token 端点
$openIdData['urlResourceOwnerDetails'] = ''; // user_info 端点
$openIdData['responseResourceOwnerId'] = ''; // 用户识别符
$openIdData['scopes'] = 'openid,profile,email'; // 授权范围
$openIdData['scopeSeparator'] = ','; // 授权范围分隔符
function getOAuthUserBinding($sub)
{
    $res = sql_query("SELECT sub, nphpuid FROM openid_data WHERE sub = " . sqlesc($sub));
    return mysql_fetch_array($res);
}

function setOAuthUserBinding($sub, $nphpuid)
{
    if (!$nphpuid) {
        $res = sql_query("DELETE FROM openid_data WHERE sub = " . sqlesc($sub));
    } else {
        $res = sql_query("INSERT INTO openid_data (sub, nphpuid) VALUES (" . sqlesc($sub) . "," . sqlesc($nphpuid) . ")") or sqlerr(__FILE__, __LINE__);
    }
    return true;
}

function isUserNotExists($uid): bool
{
    $res = sql_query("SELECT id, passhash, secret, enabled, status, two_step_secret FROM users WHERE id = " . sqlesc($uid));
    $row = mysql_fetch_array($res);
    return !$row;
}

function getEmailUsing($email): bool|int
{
    $res = sql_query("SELECT id, email FROM users WHERE email = " . sqlesc($email));
    $row = mysql_fetch_array($res);
    if (!$row) return false;
    return $row['id'];
}

function sendMail($uid, $subject, $msg)
{
    $dt = sqlesc(date("Y-m-d H:i:s"));
    $subject = sqlesc($subject);
    $msg = sqlesc($msg);
    sql_query("INSERT INTO messages (sender, receiver, subject, added, msg) VALUES(0, $uid , $subject, $dt, $msg)") or sqlerr(__FILE__, __LINE__);
    return $uid;
}

function giveChangeUsernameCard($uid): bool
{
    $user = User::query()->findOrFail($uid);
    if (UserMeta::query()->where('uid', $uid)->where('meta_key', UserMeta::META_KEY_CHANGE_USERNAME)->exists()) {
        return false;
    }
    $metaData = [
        'meta_key' => UserMeta::META_KEY_CHANGE_USERNAME,
    ];
    $userRep = new UserRepository();
    $userRep->addMeta($user, $metaData, $metaData, false);
    return true;
}

function registerNexusPHPUsr($oauthUser)
{
    $pending_mails = array();
    $email = htmlspecialchars(trim($oauthUser['email']));
    $email = sqlesc(safe_email($email));
    $username = finalHandleUsername($oauthUser['preferred_username']);
    if (!validusername($username)) {
        stdmsg("Error", "用户名无效,请联系管理员修改您的 OpenID 用户名");
    }
    $username = sqlesc($username);
    $res = sql_query("SELECT id FROM users WHERE username=$username");
    $arr = mysql_fetch_row($res);
    $giveChangeNameCard = false;
    $usernameReAssigned = false;
    if ($arr) {
        $newusrname = finalHandleUsername(mksecret(3) . $oauthUser['preferred_username']);
        $usernameReAssigned = true;
        $giveChangeNameCard = true; // 将此改为 false,在用户名冲突时将不会给用户发送改名卡
        $username = sqlesc($newusrname);
    }
    $secret = mksecret();
    global $openIdData;
    $password = $oauthUser[$openIdData['responseResourceOwnerId']] . $secret;
    $res = sql_query("SELECT id FROM users WHERE email=$email");
    $arr = mysql_fetch_row($res);
    if ($arr) {
        stdmsg("错误:邮件地址冲突", "此系统上的另一个用户正使用和您相同的电子邮件地址 " . $oauthUser['email'] . " 请选择一个未被占用的电子邮件地址吧!");
        die();
    }
    $passhash = sqlesc(md5($secret . $password . $secret));
    $secret = sqlesc($secret);
    sql_query("INSERT INTO users (added, last_access, secret, username, passhash, status, stylesheet, class,email, lang) VALUES(NOW(), NOW(), $secret, $username, $passhash, 'confirmed', " . sqlesc("3") . "," . sqlesc("1") . ",$email" . "," . sqlesc("25") . ")") or sqlerr(__FILE__, __LINE__);
    $res = sql_query("SELECT id FROM users WHERE username=$username");
    $arr = mysqli_fetch_array($res);
    if (!$arr) {
        stdmsg("Error", "Unable to create the account. The user name is possibly already taken.");
        die();
    }
    //sendMail($arr['id'], $lang_takesignup['msg_subject'] . $SITENAME . "!", $lang_takesignup['msg_congratulations'] . htmlspecialchars($wantusername) . $lang_takesignup['msg_you_are_a_member']);
    sendMail($arr['id'], "通过 OpenID 注册", "您已成功通过 OpenID 在本系统上注册。您的头像和邮箱信息会在每次登录时自动更新。");
    if ($usernameReAssigned) {
        sendMail($arr['id'], "您的用户名已被重新指定", "由于您在 OpenID 上使用的用户名已被占用,因此您在此系统上的用户名已被重指定为 " . $newusrname);
    }
    if ($giveChangeNameCard) {
        if (giveChangeUsernameCard($arr['id'])) {
            sendMail($arr['id'], "您收到了一张改名卡", "由于您的用户名冲突,系统已为您分配了一个随机用户名,因此您收到了一张改名卡以便修改您的用户名。");
        }
    }
    return $arr['id'];
}

function finalHandleUsername(string $preferred_username)
{
    $cut = preg_replace("/[^a-zA-Z0-9_]/", '', $preferred_username);
    $cut = str_replace("_", "", $cut);
    $cut = substr($cut, 0, 12);
    if (empty($cut) || !validusername($cut)) return false;
    return $cut;
}

function loginNphpUser(int $nphpuid)
{
    $res = sql_query("SELECT id, username, passhash FROM users WHERE id = " . sqlesc($nphpuid));
    $row = mysql_fetch_array($res);
    if (!$row) return false;
    $md5 = md5($row["passhash"]);
    logincookie($row["id"], $md5, 1, get_setting('system.cookie_valid_days', 365) * 86400, $securelogin_indentity_cookie);
    //logincookie($row["id"], $row["passhash"],1,get_setting('system.cookie_valid_days', 365) * 86400,false, false, false);
    return $row['username'];
}

function updateNphpUser(int $nphpuid, mixed $oauthUser)
{
    sql_query("UPDATE users SET avatar=" . sqlesc($oauthUser['picture']) . " WHERE id=" . sqlesc($nphpuid));
    $email = safe_email(htmlspecialchars(trim($oauthUser['email'])));
    $emailUsing = getEmailUsing($email);
    if (!$emailUsing) {
        sql_query("UPDATE users SET email=" . sqlesc($email) . " WHERE id=" . sqlesc($nphpuid));
        sendMail($nphpuid, "您的电子邮箱地址已被更新", "我们已同步您在 OpenID 上登记的电子邮箱地址,您无需其他任何操作。");
    }
    if ($emailUsing == $nphpuid) {
        return;
    } else {
        sendMail($nphpuid, "您的电子邮箱地址更新失败", "我们无法同步您在 OpenID 上登记的电子邮箱地址,目标地址与此系统上的其它用户登记的电子邮箱地址存在冲突。");
    }
}

$provider = new \League\OAuth2\Client\Provider\GenericProvider([
    'clientId' => $openIdData['clientId'],    // The client ID assigned to you by the provider
    'clientSecret' => $openIdData['clientSecret'],    // The client password assigned to you by the provider
    'redirectUri' => $openIdData['redirectUri'],
    'urlAuthorize' => $openIdData['urlAuthorize'],
    'urlAccessToken' => $openIdData['urlAccessToken'],
    'urlResourceOwnerDetails' => $openIdData['urlResourceOwnerDetails'],
    'responseResourceOwnerId' => $openIdData['responseResourceOwnerId'],
    'scopes' => $openIdData['scopes'],
    'scopeSeparator' => $openIdData['scopeSeparator']
]);

// If we don't have an authorization code then get one
if (!isset($_GET['code'])) {
    $authorizationUrl = $provider->getAuthorizationUrl();
    $_SESSION['oauth2state'] = $provider->getState();
    $_SESSION['oauth2pkceCode'] = $provider->getPkceCode();
    header('Location: ' . $authorizationUrl);
    exit;
} else {
    try {
        // Optional, only required when PKCE is enabled.
        // Restore the PKCE code stored in the session.
        //$provider->setPkceCode($_SESSION['oauth2pkceCode']);

        // Try to get an access token using the authorization code grant.
        $accessToken = $provider->getAccessToken('authorization_code', [
            'code' => $_GET['code']
        ]);

        // We have an access token, which we may use in authenticated
        // requests against the service provider's API.
        //echo 'Access Token: ' . $accessToken->getToken() . "<br>";
        //echo 'Refresh Token: ' . $accessToken->getRefreshToken() . "<br>";
        //echo 'Expired in: ' . $accessToken->getExpires() . "<br>";
        //echo 'Already expired? ' . ($accessToken->hasExpired() ? 'expired' : 'not expired') . "<br>";
        if ($accessToken->hasExpired()) {
            stdmsg("Error", "OAuth 登录流程返回的 AccessToken 已过期,请<a href=\"" . $openIdData['redirectUri'] . "\">重新登录</a>!");
        }
        // Using the access token, we may look up details about the
        // resource owner.
        $resourceOwner = $provider->getResourceOwner($accessToken);
        $oauthUser = $resourceOwner->toArray();

        // 验证 OAuth 响应信息
        if(!$oauthUser[$openIdData['responseResourceOwnerId']]){
            stdmsg("Error", "登录提供商返回了无效响应,responseResourceOwnerId 未包含在响应中");
            die();
        }

        $nphpUserRow = getOAuthUserBinding($oauthUser[$openIdData['responseResourceOwnerId']]);
        $user = User::query()->find($nphpUserRow['nphpuid']);
        $userSubReAssigned = false;
        if ($user == null) {
            // 用户被删除,但 sub 仍然存在绑定 - 解绑,重置注册流程
            setOAuthUserBinding($oauthUser[$openIdData['responseResourceOwnerId']], false);
            $nphpUserRow = false;
            $userSubReAssigned = true;
        }
        if (!$nphpUserRow) {
            $uid = registerNexusPHPUsr($oauthUser);
            if (!setOAuthUserBinding($oauthUser[$openIdData['responseResourceOwnerId']], $uid)) {
                stdmsg("Error", "无法绑定 NexusPHP 用户到 OpenID 对象,请联系管理员。");
                die();
            }
            if ($userSubReAssigned) {
                sendMail($uid, "OpenID 用户已被重新关联", "检测到您的 OpenID 曾在本系统上绑定过一个用户,但该用户已被删除,因此我们已为您在此系统上创建了一个新的用户并改绑,您无需进行任何其它操作。");
            }
        } else {
            $uid = $nphpUserRow['nphpuid'];
        }
        $username = loginNphpUser($uid);
        if (!$username) {
            stdmsg("登陆失败", "登录到 NexusPHP 时出现错误,无法设置登录会话,请联系管理员。");
            die();
        }
        updateNphpUser($uid, $oauthUser);
        nexus_redirect("index.php");
//        // The provider provides a way to get an authenticated API request for
//        // the service, using the access token; it returns an object conforming
//        // to Psr\Http\Message\RequestInterface.
//        $request = $provider->getAuthenticatedRequest(
//            'GET',
//            'https://openid.barbatos.club/api/userinfo',
//            $accessToken
//        );

    } catch (Exception $e) {
        // Failed to get the access token or user details.
        stdmsg("Error", "无法进行 OpenID 登录,发生系统错误:" . $e->getMessage());
        die();
    }
}

stdfoot();

什么,你问我 SQL 怎么写成这个样子还没用预编译语句?拜托,NexusPHP 啥情况你还不知道吗,根本没给执行预编译语句的接口……

默认从 OAuth/OpenID 登录

额外的,如果你希望用户默认使用 OAuth/OpenID 登录,可以在 login.php 的顶部添加如下代码:

if (!isset($_GET['local'])) {
    nexus_redirect('login_oauth.php');
}

就像这样:

<?php

if (!isset($_GET['local'])) {
    nexus_redirect('login_oauth.php');
}

require_once("../include/bittorrent.php");
dbconn();
...

这样,除非用户使用 https://yourtracker.com/login.php?local=1 否则它们默认将被重定向到 OAuth/OpenID 登录界面。

创建数据表

login_oauth.php 使用一个名为 oauth_data 的数据表来存储数据,然而由于此脚本严格意义上并不是一个 NexusPHP 的插件(算是个外挂吧……),因此需要手动创建需要的数据表。

执行下面的 SQL 语句来创建它(可以复制到 phpmyadmin 中执行):

CREATE TABLE `openid_data` (
  `id` int UNSIGNED NOT NULL,
  `sub` varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
  `nphpuid` int UNSIGNED NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

ALTER TABLE `openid_data`
  ADD PRIMARY KEY (`id`),
  ADD UNIQUE KEY `sub_unique` (`sub`),
  ADD UNIQUE KEY `nphpuid` (`nphpuid`);

配置脚本

在 login_oauth.php 的顶部有这样一段配置代码,您需要安装实际情况修改:

$openIdData['clientId'] = '';
$openIdData['clientSecret'] = '';
$openIdData['redirectUri'] = '';
$openIdData['urlAuthorize'] = '';
$openIdData['urlAccessToken'] = '';
$openIdData['urlResourceOwnerDetails'] = '';
$openIdData['responseResourceOwnerId'] = 'sub';
$openIdData['scopes'] = 'openid,profile,email';
$openIdData['scopeSeparator'] = ',';

其中,clientId 和 clientSecret 通常您的 OAuth/OpenID 提供商应该会明确告知您,就像这样:

redirectUri 需要填写 login_oauth.php 的 URL 地址,例如我的 NexusPHP 的域名是 https://tracker.barbatos.club,那么需要填写 https://tracker.barbatos.club/login_oauth.php

urlAuthorize, urlAccessToken, urlResourceOwnerDetails 都是 OAuth2 端点地址,您的 OAuth/OpenID 提供商通常也会告诉您。

例如我正在使用 Casdoor,则 Casdoor 的 wiki 页面写明了这些端点的位置分别为:

  • https://mycasdoor.com/login/oauth/authorize
  • https://mycasdoor.com/login/oauth/access_token
  • https://mycasdoor.com/api/userinfo

responseResourceOwnerId 则根据登录方式的不同而变化,如果您正在使用 OpenID,则通常为 sub,对于 OAuth2 则可能为 id, username 或者 email,根据您的提供商不同,该值也会变化,但您只要填写一个在 OAuth 响应中绝对不会出现冲突的字段即可。

scopes 则和您的 OAuth/OpenID 提供商有关,对于Casdoor,这里是 openid,profile,email。如果有多个,则使用scopeSeparator所指定的特定符号来分开它们。

结束

配置完毕后,则可以尝试使用 login_oauth.php 登录,如果一切正常,则会自动创建用户并登录。

效果演示

下面是一段演示,其使用的 Ghost_c_h_u 用户名会被处理为 Ghostchu,并于现存用户产生冲突,演示中,login_oauth.php 自动对冲突的用户名进行了处理,成功创建了一个NexusPHP 用户并与 Casdoor 绑定。

Comment