好久没写文章了,最近一直在搞自己 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 绑定。