Files
zyt/server/app/mcp/controller/SsoController.php
T
2026-10-10 10:55:32 +08:00

306 lines
15 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
declare(strict_types=1);
namespace app\mcp\controller;
use app\BaseController;
use app\common\model\auth\Admin;
use app\mcp\service\AuditLogger;
use app\mcp\service\GrantService;
use app\mcp\service\Guard;
use app\mcp\service\McpConfig;
use app\mcp\service\McpException;
use app\mcp\service\RateLimiter;
use app\mcp\service\SsoService;
use think\Response;
/**
* 用甄养堂账号登录行知(授权码方式,流程见 SsoService):
* GET /mcp/sso/authorize 登录确认页(用户浏览器)
* POST /mcp/sso/whois 页面用:浏览器里已登录的后台账号是谁
* POST /mcp/sso/approve 页面用:以已登录的后台账号确认
* POST /mcp/sso/login 页面用:输入账号密码确认
* POST /mcp/sso/token 行知服务器用:授权码 + 客户端密钥 → 账号信息和只读令牌
* 前四个面向用户浏览器,不按来源 IP 限制(用户不在 ALLOWED_IPS 里);token 只给行知服务器,经过 Guard。
*/
class SsoController extends BaseController
{
public function authorize(): Response
{
if ($this->request->method(true) !== 'GET') {
return response('', 405)->header(['Allow' => 'GET']);
}
$query = $this->request->get();
$clientId = self::text($query, 'client_id');
$redirectUri = self::text($query, 'redirect_uri');
$state = self::text($query, 'state');
if (!SsoService::enabled()) {
return $this->page('甄养堂尚未开启“用甄养堂账号登录行知”,请联系管理员。', 503);
}
try {
SsoService::checkClient($clientId, $redirectUri);
} catch (McpException $e) {
return $this->page($e->getMessage() . '。', 400);
}
if ($state === '' || strlen($state) > 200) {
return $this->page('登录请求不完整,请从行知重新发起登录。', 400);
}
return $this->page('', 200, [
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'state' => $state,
'cancel' => SsoService::redirectWith($redirectUri, ['error' => 'access_denied', 'state' => $state]),
]);
}
public function whois(): Response
{
return $this->browser('', function (array $in) {
$admin = SsoService::sessionAdmin(self::text($in, 'session'));
return ['name' => (string) $admin['name'], 'account' => (string) $admin['account']];
});
}
public function approve(): Response
{
return $this->browser('sso.approve', function (array $in, string $ip) {
return $this->granted(SsoService::sessionAdmin(self::text($in, 'session')), $in, 'session', $ip);
});
}
public function login(): Response
{
return $this->browser('sso.login', function (array $in, string $ip) {
$admin = GrantService::verifyPassword(self::text($in, 'account'), self::text($in, 'password'), $ip, '再登录行知');
return $this->granted($admin, $in, 'password', $ip);
});
}
public function token(): Response
{
if (!McpConfig::enabled()) {
return Guard::envelope(0, 'AI 助手接口未启用', ['reason' => 'feature_disabled'], 503, 1);
}
if ($this->request->method(true) !== 'POST') {
return response('', 405)->header(['Allow' => 'POST']);
}
$guard = Guard::check($this->request);
if ($guard !== null) {
return Guard::envelope(0, $guard[1], ['reason' => $guard[2]], 200, 1);
}
$in = $this->input();
$ip = $this->request->ip();
$clientId = self::text($in, 'client_id');
$redirectUri = self::text($in, 'redirect_uri');
try {
if (!RateLimiter::hit('sso_token_' . md5($ip), 300, 600)) {
throw new McpException('请求过于频繁,请稍后再试', 'locked', 429);
}
SsoService::checkClient($clientId, $redirectUri);
SsoService::checkSecret(self::text($in, 'client_secret'));
[$admin, $via] = SsoService::redeem(self::text($in, 'code'), $clientId, $redirectUri);
$instance = substr(trim(self::text($in, 'client_instance')), 0, 64);
$label = mb_substr(trim(self::text($in, 'label')), 0, 100);
$data = GrantService::issueFor($admin, $clientId, $instance, $label, $ip);
AuditLogger::log(['grant_id' => $data['grant_id'], 'admin_id' => $admin['id'], 'tool' => 'sso.token',
'arguments' => ['client' => $clientId, 'client_instance' => $instance, 'via' => $via], 'status' => 'ok', 'ip' => $ip]);
return Guard::envelope(1, '登录成功', $data);
} catch (McpException $e) {
AuditLogger::log(['tool' => 'sso.token', 'arguments' => ['client' => $clientId], 'status' => 'denied', 'message' => $e->reason, 'ip' => $ip]);
return Guard::envelope(0, $e->getMessage(), ['reason' => $e->reason], $e->httpStatus >= 400 ? $e->httpStatus : 400, 1);
}
}
/** 页面接口共用:方法、开关与来源登记、按 IP 限流、统一的返回格式;$tool 非空时记录拒绝 */
private function browser(string $tool, callable $handle): Response
{
if ($this->request->method(true) !== 'POST') {
return response('', 405)->header(['Allow' => 'POST']);
}
$in = $this->input();
$ip = $this->request->ip();
try {
if (!RateLimiter::hit('sso_ip_' . md5($ip), 120, 600)) {
throw new McpException('尝试次数过多,请稍后再试', 'locked');
}
SsoService::checkClient(self::text($in, 'client_id'), self::text($in, 'redirect_uri'));
return $this->json(1, '', $handle($in, $ip));
} catch (McpException $e) {
if ($tool !== '') {
AuditLogger::log(['tool' => $tool, 'arguments' => ['account' => self::text($in, 'account')], 'status' => 'denied',
'message' => $e->reason, 'ip' => $ip]);
}
return $this->json(0, $e->getMessage(), ['reason' => $e->reason]);
}
}
/** 已确认身份:签发一次性授权码,返回回到行知的地址 */
private function granted(Admin $admin, array $in, string $via, string $ip): array
{
$clientId = self::text($in, 'client_id');
$redirectUri = self::text($in, 'redirect_uri');
$state = self::text($in, 'state');
if ($state === '' || strlen($state) > 200) {
throw new McpException('登录请求不完整,请从行知重新发起登录', 'invalid_request');
}
$code = SsoService::issueCode($admin, $clientId, $redirectUri, $via);
AuditLogger::log(['admin_id' => $admin['id'], 'tool' => 'sso.approve', 'arguments' => ['client' => $clientId, 'via' => $via], 'status' => 'ok', 'ip' => $ip]);
return ['redirect' => SsoService::redirectWith($redirectUri, ['code' => $code, 'state' => $state])];
}
/** 只取字符串参数(数组等一律当作空),避免类型转换告警 */
private static function text(array $in, string $key): string
{
$value = $in[$key] ?? '';
return is_string($value) || is_int($value) ? (string) $value : '';
}
private function input(): array
{
$in = json_decode((string) $this->request->getInput(), true);
return is_array($in) ? $in : [];
}
/** 本页只给同源脚本用:覆盖全局中间件的 Access-Control-Allow-Origin: * */
private function headers(): array
{
return [
'Cache-Control' => 'no-store',
'X-Content-Type-Options' => 'nosniff',
'Referrer-Policy' => 'no-referrer',
'Access-Control-Allow-Origin' => $this->request->domain(),
];
}
private function json(int $code, string $msg, array $data = []): Response
{
return json(['code' => $code, 'show' => $code === 1 ? 0 : 1, 'msg' => $msg, 'data' => $data ?: new \stdClass()])->header($this->headers());
}
/** 登录确认页($error 非空时只显示原因,不带登录框和脚本) */
private function page(string $error, int $status, array $ctx = []): Response
{
$nonce = base64_encode(random_bytes(16));
$body = $error !== ''
? '<p class="fatal">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</p>'
: strtr(self::FORM, ['__NONCE__' => $nonce, '__CTX__' => json_encode($ctx, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]);
$html = strtr(self::LAYOUT, ['__NONCE__' => $nonce, '__BODY__' => $body]);
return response($html, $status, array_merge($this->headers(), [
'Content-Type' => 'text/html; charset=utf-8',
'Content-Security-Policy' => "default-src 'none'; script-src 'nonce-{$nonce}'; style-src 'nonce-{$nonce}'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
'X-Frame-Options' => 'DENY',
]));
}
private const LAYOUT = <<<'HTML'
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title>甄养堂账号登录 · 行知</title>
<style nonce="__NONCE__">
[hidden]{display:none!important}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;padding:24px 16px;background:#f3f5f8;color:#1f2933;
font:14px/1.6 -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei","Helvetica Neue",Arial,sans-serif}
main{width:100%;max-width:380px;margin:8vh auto 0;background:#fff;border-radius:16px;box-shadow:0 10px 30px rgba(16,24,40,.08);padding:28px 26px 22px}
.brand{display:flex;align-items:center;gap:10px;margin-bottom:18px;color:#0f6e56;font-weight:600}
.brand i{width:30px;height:30px;border-radius:9px;background:#0f6e56;color:#fff;display:flex;align-items:center;justify-content:center;font-style:normal}
h1{font-size:20px;margin:0 0 6px}
.sub{margin:0 0 18px;color:#667085;font-size:13px}
.hint{margin:0 0 10px;color:#475467;font-size:13px}
.who{display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid #e4e7ec;border-radius:12px}
.who b{width:40px;height:40px;border-radius:50%;background:#e7f4ef;color:#0f6e56;display:flex;align-items:center;justify-content:center;font-size:16px}
.who strong{display:block;font-size:15px}.who span{color:#667085;font-size:13px}
label{display:block;margin:12px 0 0;font-size:13px;color:#344054}
input{display:block;width:100%;height:42px;margin-top:6px;padding:0 12px;border:1px solid #d0d5dd;border-radius:10px;font-size:15px;background:#fff;color:#1f2933}
input:focus{outline:none;border-color:#0f6e56;box-shadow:0 0 0 3px rgba(15,110,86,.15)}
button{display:block;width:100%;height:44px;margin-top:18px;border:0;border-radius:10px;background:#0f6e56;color:#fff;font-size:15px;cursor:pointer}
button:disabled{opacity:.6;cursor:default}
button.link{height:auto;margin-top:12px;background:none;color:#0f6e56;font-size:13px}
.error{min-height:20px;margin-top:12px;color:#b42318;font-size:13px}
.fatal{margin:6px 0 0;color:#b42318}
.note{margin:14px 0 0;color:#98a2b3;font-size:12px}
</style>
</head>
<body>
<main>
<div class="brand"><i>甄</i>甄养堂账号</div>
<h1>登录「行知」</h1>
__BODY__
</main>
</body>
</html>
HTML;
private const FORM = <<<'HTML'
<p class="sub">使用甄养堂账号登录行知 AI 工作助手。登录后,行知会按你的甄养堂权限只读查询业务数据。</p>
<section id="session" hidden>
<p class="hint">你已登录甄养堂后台:</p>
<div class="who"><b id="who-initial"></b><div><strong id="who-name"></strong><span id="who-account"></span></div></div>
<button type="button" id="approve">以此账号登录</button>
<button type="button" class="link" id="other">使用其他账号</button>
</section>
<form id="form" hidden>
<label>甄养堂账号<input id="account" name="account" autocomplete="username" maxlength="64" required></label>
<label>密码<input id="password" name="password" type="password" autocomplete="current-password" maxlength="128" required></label>
<button type="submit" id="submit">登录</button>
</form>
<div class="error" id="error" role="alert"></div>
<button type="button" class="link" id="cancel">取消,返回行知</button>
<p class="note">密码只在甄养堂校验,不会发给行知。不是你本人发起的登录,请直接关闭此页。</p>
<script nonce="__NONCE__">
(function () {
var ctx = __CTX__;
function el(id) { return document.getElementById(id); }
function show(name) { el('session').hidden = name !== 'session'; el('form').hidden = name !== 'form'; }
function fail(text) { el('error').textContent = text; }
function busy(on) { var all = document.querySelectorAll('button'); for (var i = 0; i < all.length; i++) all[i].disabled = on; }
function post(action, extra) {
var body = { client_id: ctx.client_id, redirect_uri: ctx.redirect_uri, state: ctx.state };
for (var key in extra) body[key] = extra[key];
return fetch('/mcp/sso/' + action, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), credentials: 'omit', cache: 'no-store' })
.then(function (response) {
return response.json().catch(function () { return null; }).then(function (data) {
if (!data || data.code !== 1) throw new Error((data && data.msg) || ('请求失败(HTTP ' + response.status + ')'));
return data.data || {};
});
});
}
function saved() {
try {
var item = JSON.parse(window.localStorage.getItem('like_admin_token') || 'null');
if (!item || typeof item.value !== 'string') return '';
if (item.expire && item.expire < Math.round(Date.now() / 1000)) return '';
return item.value;
} catch (e) { return ''; }
}
function go(result) { window.location.replace(result.redirect); }
function stop(error) { busy(false); fail(error.message || '登录失败,请稍后重试'); }
var session = saved();
el('approve').addEventListener('click', function () { busy(true); fail(''); post('approve', { session: session }).then(go).catch(stop); });
el('other').addEventListener('click', function () { session = ''; fail(''); show('form'); el('account').focus(); });
el('cancel').addEventListener('click', function () { window.location.replace(ctx.cancel); });
el('form').addEventListener('submit', function (event) {
event.preventDefault();
busy(true); fail('');
post('login', { account: el('account').value.trim(), password: el('password').value }).then(go).catch(function (error) {
stop(error); el('password').value = ''; el('password').focus();
});
});
if (!session) { show('form'); el('account').focus(); return; }
post('whois', { session: session }).then(function (who) {
var name = who.name || who.account || '';
el('who-initial').textContent = name.slice(0, 1) || '?';
el('who-name').textContent = name;
el('who-account').textContent = who.account || '';
show('session');
el('approve').focus();
}).catch(function () { session = ''; show('form'); el('account').focus(); });
})();
</script>
HTML;
}