feat: App→网页免登录(一次性换票 SSO)——移动/桌面点链接直达已登录官网
- backend:POST /auth/web-ticket(需登录,签 60s 单次票据,每用户限频)+ POST /auth/web-ticket/exchange(公开,原子即焚兑换正常登录态,响应同 /auth/login 另带 shop_code);web_tickets 新表 + user_sessions.kind 列 (sso 会话:web 平台、refresh 12h 硬到期、不占设备并发配额、设备管理可见 可踢);清理任务顺带清过期票据;schema.sql/AutoMigrate/testutil 同步 - web:/sso 落地页——兑票写入与 Web 版共享的 localStorage 后跳 redirect; redirect 白名单只收本站相对路径(防 open redirect),票据即时从地址栏抹除 - client:launchAuthedWebPath(ref, path) 封装(签票→拼 /sso URL→launchUrl, 失败降级未登录直开;桌面/移动同一套);授权面板「查看套餐价格」接入 test(backend): web_ticket_test 三用例(兑换+续期/单次即焚/过期拒绝/ sso 不占配额双向验证) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+84
@@ -0,0 +1,84 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: 正在登录
|
||||
description: App 免登录跳转中。
|
||||
permalink: /sso/
|
||||
---
|
||||
{# App→网页免登录落地页:App 先 POST /api/v1/auth/web-ticket 签一次性票据
|
||||
(TTL 60s、单次可用),再打开 /sso/?t=<ticket>&redirect=<本站路径>。
|
||||
本页凭票 POST /auth/web-ticket/exchange 兑换正常登录态,写入与 Web 版
|
||||
App 共享的 localStorage['flutter.*'] 后跳 redirect。
|
||||
安全:URL 只含票据不含令牌;redirect 仅接受本站相对路径(防 open redirect);
|
||||
兑换失败一律落到 /login/ 并回传 redirect。 #}
|
||||
<div class="auth-wrap">
|
||||
<div class="auth" style="grid-template-columns:1fr;max-width:420px">
|
||||
<div class="auth-form" style="text-align:center">
|
||||
<h1 id="ssoTitle">正在登录…</h1>
|
||||
<p class="alead" id="ssoMsg">正在验证来自 App 的登录票据</p>
|
||||
<div class="auth-alert" id="ssoErr"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/auth.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var API = '';
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
// redirect 白名单:仅本站相对路径(以单个 / 开头),其余一律回 /profile/
|
||||
function safeRedirect() {
|
||||
var p = new URLSearchParams(location.search).get('redirect') || '/profile/';
|
||||
if (p.charAt(0) !== '/' || p.charAt(1) === '/' || p.indexOf('\\') >= 0) {
|
||||
return '/profile/';
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// 与 login.njk persist 同格式(shared_preferences_web,值 JSON 编码)
|
||||
function persist(data) {
|
||||
var u = data.user || {};
|
||||
var kv = {
|
||||
access_token: data.access_token, refresh_token: data.refresh_token,
|
||||
user_id: u.id || 0, username: u.username || '', real_name: u.real_name || '',
|
||||
shop_no: data.shop_code || '', shop_id: String(data.shop_id || ''),
|
||||
role: u.role || 'operator',
|
||||
};
|
||||
Object.keys(kv).forEach(function (k) {
|
||||
localStorage.setItem('flutter.' + k, JSON.stringify(kv[k]));
|
||||
});
|
||||
}
|
||||
|
||||
function fail(msg) {
|
||||
$('ssoTitle').textContent = '登录跳转失败';
|
||||
$('ssoMsg').textContent = '';
|
||||
var el = $('ssoErr');
|
||||
el.textContent = msg + ',请手动登录';
|
||||
el.classList.add('show');
|
||||
setTimeout(function () {
|
||||
location.replace('/login/?redirect=' + encodeURIComponent(safeRedirect()));
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
var ticket = new URLSearchParams(location.search).get('t');
|
||||
// 票据用后即从地址栏抹掉(不留浏览器历史)
|
||||
try { history.replaceState(null, '', '/sso/'); } catch (e) {}
|
||||
if (!ticket) { fail('缺少登录票据'); return; }
|
||||
|
||||
fetch(API + '/api/v1/auth/web-ticket/exchange', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ticket: ticket }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
|
||||
.then(function (res) {
|
||||
var d = res.j && res.j.data;
|
||||
if (!res.ok || !d || !d.access_token) {
|
||||
return fail((res.j && res.j.error) || '票据无效或已过期');
|
||||
}
|
||||
persist(d);
|
||||
location.replace(safeRedirect());
|
||||
})
|
||||
.catch(function () { fail('网络异常'); });
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user