Files
xianyan/docs/toolsapi/application/api/controller/Oauth.php
Developer 214a0684d0 chore: 移除NFC/蓝牙相关支持,更新设备在线统计,新增功能优化
1.  移除NFC和蓝牙相关依赖、权限及功能代码,精简传输链路
2.  重构设备在线统计逻辑,使用后端7天活跃字段替代本地计算
3.  更新应用名称、权限说明和协议文档
4.  新增消息转发、缓存管理、医疗免责提示功能
5.  优化运势模块和字体管理文案,修复构建日志问题
2026-06-06 06:12:09 +08:00

710 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\api\controller;
use app\common\controller\Api;
use think\Db;
use think\Exception;
/**
* OAuth2.0 社交登录
*
* 支持平台: apple, google, github
* 流程: 客户端获取授权码 → 服务端换取access_token → 获取用户信息 → 创建/关联账号
*
* 创建时间: 2026-06-05
* 更新时间: 2026-06-05
* 名称: Oauth
* 作用: OAuth社交登录接口支持Apple/Google/GitHub三方登录、绑定、解绑
* 上次更新: 新增OAuth社交登录接口
*/
class Oauth extends Api
{
protected $noNeedLogin = ['*'];
protected $noNeedRight = ['*'];
/** OAuth平台远程地址配置 */
private $oauthConfig = [
'apple' => [
'verify_url' => 'https://appleid.apple.com/auth/token',
'revoke_url' => 'https://appleid.apple.com/auth/revoke',
],
'google' => [
'token_url' => 'https://oauth2.googleapis.com/token',
'userinfo_url' => 'https://www.googleapis.com/oauth2/v3/userinfo',
],
'github' => [
'token_url' => 'https://github.com/login/oauth/access_token',
'userinfo_url' => 'https://api.github.com/user',
],
];
/** 频率限制配置 */
private static $rateLimits = [
'login' => ['max' => 30, 'window' => 300],
'bind' => ['max' => 20, 'window' => 3600],
'unbind' => ['max' => 20, 'window' => 3600],
];
/** 支持的平台列表 */
private static $supportedPlatforms = ['apple', 'google', 'github'];
// ==================== 公开接口 ====================
/**
* 获取OAuth配置
* GET /api/oauth/config?platform=apple
*/
public function config()
{
$platform = $this->request->param('platform', '', 'trim');
if (!in_array($platform, self::$supportedPlatforms)) {
$this->error('不支持的平台');
}
$config = $this->getOAuthConfig($platform);
if (!$config) {
$this->error('平台未配置', null, ['platform' => $platform, 'configured' => false]);
}
$this->success('', [
'platform' => $platform,
'configured' => true,
'client_id' => $config['client_id'] ?? '',
'redirect_uri' => $config['redirect_uri'] ?? '',
'authorize_url' => $this->getAuthorizeUrl($platform, $config),
]);
}
/**
* 社交登录
* POST /api/oauth/login
*
* 参数:
* - platform: apple/google/github
* - code: 授权码google/github使用
* - id_token: Apple ID Tokenapple使用
* - device_name: 设备名称(可选)
* - device_model: 设备型号(可选)
* - platform_type: 登录平台ios/android/web可选
* - device_id: 设备唯一标识(可选)
*/
public function login()
{
$platform = $this->request->post('platform', '', 'trim');
$code = $this->request->post('code', '', 'trim');
$idToken = $this->request->post('id_token', '', 'trim');
if (!in_array($platform, self::$supportedPlatforms)) {
$this->error('不支持的平台');
}
// 频率限制
$this->checkRateLimit('login');
// 获取第三方用户信息
try {
$oauthUser = $this->getOAuthUserInfo($platform, $code, $idToken);
} catch (Exception $e) {
$this->error('OAuth验证失败: ' . $e->getMessage());
}
if (!$oauthUser || empty($oauthUser['openid'])) {
$this->error('获取用户信息失败');
}
// 查找或创建用户
$user = $this->findOrCreateUser($platform, $oauthUser);
if (!$user) {
$this->error('登录失败');
}
// 生成Token
$token = $this->generateToken($user);
// 记录设备信息
$this->recordDeviceInfo($user);
$this->success('登录成功', [
'userinfo' => [
'id' => $user['id'],
'username' => $user['username'],
'nickname' => $user['nickname'],
'email' => $user['email'],
'avatar' => $user['avatar'],
],
'token' => $token,
'is_new_user' => $oauthUser['is_new'] ?? false,
'bind_platform' => $platform,
]);
}
/**
* 绑定社交账号(已登录用户)
* POST /api/oauth/bind
*/
public function bind()
{
$this->checkLogin();
$platform = $this->request->post('platform', '', 'trim');
$code = $this->request->post('code', '', 'trim');
$idToken = $this->request->post('id_token', '', 'trim');
if (!in_array($platform, self::$supportedPlatforms)) {
$this->error('不支持的平台');
}
$this->checkRateLimit('bind');
try {
$oauthUser = $this->getOAuthUserInfo($platform, $code, $idToken);
} catch (Exception $e) {
$this->error('OAuth验证失败: ' . $e->getMessage());
}
// 检查是否已被其他用户绑定
$existing = Db::name('user_oauth')
->where('platform', $platform)
->where('openid', $oauthUser['openid'])
->find();
if ($existing) {
if ($existing['user_id'] == $this->auth->id) {
$this->error('已绑定该平台');
}
$this->error('该账号已被其他用户绑定');
}
// 绑定
Db::name('user_oauth')->insert([
'user_id' => $this->auth->id,
'platform' => $platform,
'openid' => $oauthUser['openid'],
'unionid' => $oauthUser['unionid'] ?? '',
'nickname' => $oauthUser['nickname'] ?? '',
'avatar' => $oauthUser['avatar'] ?? '',
'access_token' => $oauthUser['access_token'] ?? '',
'refresh_token' => $oauthUser['refresh_token'] ?? '',
'expires_at' => $oauthUser['expires_at'] ?? 0,
'createtime' => time(),
'updatetime' => time(),
]);
$this->success('绑定成功', [
'platform' => $platform,
'nickname' => $oauthUser['nickname'] ?? '',
]);
}
/**
* 解绑社交账号
* POST /api/oauth/unbind
*/
public function unbind()
{
$this->checkLogin();
$platform = $this->request->post('platform', '', 'trim');
if (!in_array($platform, self::$supportedPlatforms)) {
$this->error('不支持的平台');
}
$this->checkRateLimit('unbind');
$count = Db::name('user_oauth')
->where('user_id', $this->auth->id)
->where('platform', $platform)
->delete();
if ($count) {
$this->success('解绑成功');
}
$this->error('未绑定该平台');
}
/**
* 获取已绑定的社交账号列表
* GET /api/oauth/bound
*/
public function bound()
{
$this->checkLogin();
$list = Db::name('user_oauth')
->where('user_id', $this->auth->id)
->field('platform,openid,nickname,avatar,createtime')
->select();
$this->success('', ['bindings' => $list]);
}
/**
* 安装OAuth数据表
* GET /api/oauth/install
*/
public function install()
{
$sql = "CREATE TABLE IF NOT EXISTS `tool_user_oauth` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '用户ID',
`platform` varchar(30) NOT NULL DEFAULT '' COMMENT '平台(apple/google/github)',
`openid` varchar(128) NOT NULL DEFAULT '' COMMENT '平台用户ID',
`unionid` varchar(128) NOT NULL DEFAULT '' COMMENT '联合ID',
`nickname` varchar(100) NOT NULL DEFAULT '' COMMENT '昵称',
`avatar` varchar(500) NOT NULL DEFAULT '' COMMENT '头像',
`access_token` text COMMENT '访问令牌',
`refresh_token` text COMMENT '刷新令牌',
`expires_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '令牌过期时间',
`createtime` int(11) unsigned NOT NULL DEFAULT 0,
`updatetime` int(11) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_platform_openid` (`platform`, `openid`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户OAuth绑定表';";
try {
Db::execute($sql);
$this->success('安装成功', [
'table' => 'tool_user_oauth',
'created' => true,
]);
} catch (Exception $e) {
$this->error('安装失败: ' . $e->getMessage());
}
}
// ==================== 私有方法 ====================
/**
* 获取OAuth配置
* 优先从数据库读取,回退到配置文件
*/
private function getOAuthConfig($platform)
{
// 从数据库 tool_oauth_config 读取
try {
$config = Db::name('oauth_config')
->where('platform', $platform)
->where('status', 1)
->find();
if ($config) {
return json_decode($config['config'], true);
}
} catch (Exception $e) {
// 表不存在时忽略,回退到配置文件
}
// 回退到配置文件
$fileConfig = get_addon_config('third');
return $fileConfig[$platform] ?? null;
}
/**
* 获取授权URL
*/
private function getAuthorizeUrl($platform, $config)
{
$clientId = $config['client_id'] ?? '';
$redirectUri = $config['redirect_uri'] ?? '';
$state = md5(uniqid());
switch ($platform) {
case 'apple':
return "https://appleid.apple.com/auth/authorize?client_id={$clientId}&redirect_uri=" . urlencode($redirectUri) . "&response_type=code&scope=name%20email&state={$state}";
case 'google':
return "https://accounts.google.com/o/oauth2/v2/auth?client_id={$clientId}&redirect_uri=" . urlencode($redirectUri) . "&response_type=code&scope=openid%20email%20profile&state={$state}";
case 'github':
return "https://github.com/login/oauth/authorize?client_id={$clientId}&redirect_uri=" . urlencode($redirectUri) . "&scope=user:email&state={$state}";
default:
return '';
}
}
/**
* 获取OAuth用户信息分发到各平台验证方法
*/
private function getOAuthUserInfo($platform, $code, $idToken)
{
switch ($platform) {
case 'apple':
return $this->verifyApple($idToken, $code);
case 'google':
return $this->verifyGoogle($code);
case 'github':
return $this->verifyGithub($code);
default:
throw new Exception('不支持的平台');
}
}
/**
* 验证Apple ID Token
* Apple返回的id_token是JWT格式解析payload获取用户信息
*/
private function verifyApple($idToken, $code)
{
if (empty($idToken)) {
throw new Exception('id_token不能为空');
}
// 解码JWTApple ID Token是JWT格式
$parts = explode('.', $idToken);
if (count($parts) !== 3) {
throw new Exception('无效的id_token格式');
}
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
if (!$payload) {
throw new Exception('无法解析id_token');
}
// 验证issuer
if (($payload['iss'] ?? '') !== 'https://appleid.apple.com') {
throw new Exception('无效的issuer');
}
return [
'openid' => $payload['sub'] ?? '',
'email' => $payload['email'] ?? '',
'nickname' => '',
'avatar' => '',
'access_token' => $code,
'refresh_token' => '',
'expires_at' => $payload['exp'] ?? 0,
];
}
/**
* 验证Google授权码
* 用授权码换取access_token再获取用户信息
*/
private function verifyGoogle($code)
{
if (empty($code)) {
throw new Exception('授权码不能为空');
}
$config = $this->getOAuthConfig('google');
if (!$config) {
throw new Exception('Google OAuth未配置');
}
// 用授权码换取access_token
$response = $this->httpPost($this->oauthConfig['google']['token_url'], [
'code' => $code,
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
'redirect_uri' => $config['redirect_uri'],
'grant_type' => 'authorization_code',
]);
$tokenData = json_decode($response, true);
if (empty($tokenData['access_token'])) {
throw new Exception('获取Google access_token失败');
}
// 获取用户信息
$userInfo = $this->httpGet($this->oauthConfig['google']['userinfo_url'], [
'Authorization: Bearer ' . $tokenData['access_token'],
]);
$userData = json_decode($userInfo, true);
if (empty($userData['sub'])) {
throw new Exception('获取Google用户信息失败');
}
return [
'openid' => 'google_' . $userData['sub'],
'unionid' => $userData['sub'] ?? '',
'email' => $userData['email'] ?? '',
'nickname' => $userData['name'] ?? '',
'avatar' => $userData['picture'] ?? '',
'access_token' => $tokenData['access_token'],
'refresh_token' => $tokenData['refresh_token'] ?? '',
'expires_at' => time() + ($tokenData['expires_in'] ?? 3600),
];
}
/**
* 验证GitHub授权码
* 用授权码换取access_token再获取用户信息
*/
private function verifyGithub($code)
{
if (empty($code)) {
throw new Exception('授权码不能为空');
}
$config = $this->getOAuthConfig('oauth_github');
if (!$config) {
// 回退尝试 github 配置
$config = $this->getOAuthConfig('github');
}
if (!$config) {
throw new Exception('GitHub OAuth未配置');
}
// 用授权码换取access_token
$response = $this->httpPost($this->oauthConfig['github']['token_url'], [
'code' => $code,
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
'redirect_uri' => $config['redirect_uri'],
], ['Accept: application/json']);
$tokenData = json_decode($response, true);
if (empty($tokenData['access_token'])) {
throw new Exception('获取GitHub access_token失败');
}
// 获取用户信息
$userInfo = $this->httpGet($this->oauthConfig['github']['userinfo_url'], [
'Authorization: Bearer ' . $tokenData['access_token'],
]);
$userData = json_decode($userInfo, true);
if (empty($userData['id'])) {
throw new Exception('获取GitHub用户信息失败');
}
// 获取邮箱GitHub可能不返回主邮箱
$email = $userData['email'] ?? '';
if (empty($email)) {
$emails = $this->httpGet('https://api.github.com/user/emails', [
'Authorization: Bearer ' . $tokenData['access_token'],
]);
$emailList = json_decode($emails, true);
if (is_array($emailList)) {
foreach ($emailList as $e) {
if (($e['primary'] ?? false) && ($e['verified'] ?? false)) {
$email = $e['email'];
break;
}
}
}
}
return [
'openid' => 'github_' . $userData['id'],
'unionid' => (string)$userData['id'],
'email' => $email,
'nickname' => $userData['login'] ?? '',
'avatar' => $userData['avatar_url'] ?? '',
'access_token' => $tokenData['access_token'],
'refresh_token' => '',
'expires_at' => 0,
];
}
/**
* 查找或创建用户
* 1. 查找已有绑定 → 2. 通过邮箱查找 → 3. 创建新用户
*/
private function findOrCreateUser($platform, $oauthUser)
{
// 1. 查找已有绑定
$binding = Db::name('user_oauth')
->where('platform', $platform)
->where('openid', $oauthUser['openid'])
->find();
if ($binding) {
$user = Db::name('user')->where('id', $binding['user_id'])->find();
if ($user) {
// 更新token
Db::name('user_oauth')->where('id', $binding['id'])->update([
'access_token' => $oauthUser['access_token'] ?? '',
'refresh_token' => $oauthUser['refresh_token'] ?? '',
'expires_at' => $oauthUser['expires_at'] ?? 0,
'updatetime' => time(),
]);
$oauthUser['is_new'] = false;
return $user;
}
}
// 2. 通过邮箱查找已有用户
$user = null;
if (!empty($oauthUser['email'])) {
$user = Db::name('user')->where('email', $oauthUser['email'])->find();
}
// 3. 创建新用户
if (!$user) {
$username = $platform . '_' . substr($oauthUser['openid'], -8);
// 确保用户名唯一
$i = 1;
$baseUsername = $username;
while (Db::name('user')->where('username', $username)->find()) {
$username = $baseUsername . '_' . $i++;
}
$userId = Db::name('user')->insertGetId([
'username' => $username,
'nickname' => $oauthUser['nickname'] ?: $username,
'email' => $oauthUser['email'] ?? '',
'avatar' => $oauthUser['avatar'] ?? '',
'password' => md5(md5(uniqid())),
'salt' => '',
'status' => 'normal',
'verification' => json_encode(['email' => !empty($oauthUser['email']) ? 1 : 0]),
'createtime' => time(),
'updatetime' => time(),
]);
$user = Db::name('user')->where('id', $userId)->find();
$oauthUser['is_new'] = true;
} else {
$oauthUser['is_new'] = false;
}
// 4. 创建绑定
Db::name('user_oauth')->insert([
'user_id' => $user['id'],
'platform' => $platform,
'openid' => $oauthUser['openid'],
'unionid' => $oauthUser['unionid'] ?? '',
'nickname' => $oauthUser['nickname'] ?? '',
'avatar' => $oauthUser['avatar'] ?? '',
'access_token' => $oauthUser['access_token'] ?? '',
'refresh_token' => $oauthUser['refresh_token'] ?? '',
'expires_at' => $oauthUser['expires_at'] ?? 0,
'createtime' => time(),
'updatetime' => time(),
]);
return $user;
}
/**
* 生成Token
*/
private function generateToken($user)
{
$token = \fast\Random::uuid();
Db::name('user_token')->insert([
'user_id' => $user['id'],
'token' => $token,
'createtime' => time(),
'expiretime' => time() + 86400 * 30,
]);
return $token;
}
/**
* 记录设备信息
*/
private function recordDeviceInfo($user)
{
$deviceName = $this->request->post('device_name', '', 'trim');
$deviceModel = $this->request->post('device_model', '', 'trim');
$platformType = $this->request->post('platform_type', '', 'trim');
$deviceId = $this->request->post('device_id', '', 'trim');
$ipCity = $this->request->post('ip_city', '', 'trim');
$ipRange = $this->request->post('ip_range', '', 'trim');
if ($deviceId) {
$existing = Db::name('user_device')
->where('user_id', $user['id'])
->where('device_id', $deviceId)
->find();
$data = [
'device_name' => $deviceName ?: $deviceModel,
'device_model' => $deviceModel,
'platform' => $platformType,
'app_name' => '闲言工具箱',
'ip' => request()->ip(),
'ip_city' => $ipCity,
'ip_range' => $ipRange,
'last_active_time' => time(),
'is_online' => 1,
'updatetime' => time(),
];
if ($existing) {
Db::name('user_device')->where('id', $existing['id'])->update($data);
} else {
$data['user_id'] = $user['id'];
$data['device_id'] = $deviceId;
$data['createtime'] = time();
Db::name('user_device')->insert($data);
}
}
// 更新用户在线状态
Db::name('user')->where('id', $user['id'])->update([
'is_online' => 1,
'last_active_time' => time(),
'logintime' => time(),
'updatetime' => time(),
]);
}
/**
* 频率限制检查
*/
private function checkRateLimit($action)
{
if (!isset(self::$rateLimits[$action])) {
return true;
}
$config = self::$rateLimits[$action];
$key = 'rate_oauth_' . $action . '_' . md5($this->request->ip());
$count = cache($key) ?: 0;
if ($count >= $config['max']) {
$this->error('请求过于频繁,请稍后再试', null, ['retry_after' => $config['window']]);
}
cache($key, $count + 1, $config['window']);
return true;
}
/**
* 检查用户是否已登录
*/
private function checkLogin()
{
if (!$this->auth->id) {
$this->error('请先登录', null, 401);
}
}
/**
* HTTP GET 请求
*/
private function httpGet($url, $headers = [])
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => array_merge(['User-Agent: XianYan/1.0'], $headers),
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
curl_close($ch);
return $response;
}
/**
* HTTP POST 请求
*/
private function httpPost($url, $data = [], $headers = [])
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => array_merge(['User-Agent: XianYan/1.0', 'Accept: application/json'], $headers),
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
curl_close($ch);
return $response;
}
}