306 lines
15 KiB
PHP
306 lines
15 KiB
PHP
<?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;
|
||
}
|