Appearance
第 2 步:接通认证、菜单和独立登录
前提:已完成第一章,基座和子应用能联动。本章关闭基座假登录,使用本地 HTTP 教学服务跑通实际请求链。完成后,子应用既能在基座里使用共享认证,也能单独登录。
继续使用 my-host:7100、order-app:7101、appId order。接口服务监听 127.0.0.1:7200,浏览器通过两个 Vite 服务器各自的 /api 代理访问。
1. 启动本地教学接口
在 micro-lab 下创建 api 目录,将完整服务文件保存为 micro-lab/api/platform-server.mjs。它只依赖 Node 内置模块,不需要 npm install。
sh
cd micro-lab/api
node platform-server.mjs预期终端显示“教程 API 已启动”,保持该终端运行。账号为 demo / 123456。服务只监听本机,会话和订单驻留内存,重启即清空;生产替换为自己的认证、权限与数据库服务。
查看可直接运行的完整服务源码
mjs
/**
* fin-micro 接入教程专用 API:仅监听本机,数据、会话和凭据全部驻留内存。
* 用于验证前端接入步骤,不可作为生产认证服务;重启即清空会话并恢复示例订单。
* 启动:node platform-server.mjs;测试可调用 createTutorialServer() 使用随机本机端口。
*/
import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import { pathToFileURL } from 'node:url'
// 多应用接入时与 host 注册表保持一致;后台只返回 appId 和菜单,不返回代码入口。
const APP_IDS = ['order']
const PERMISSIONS = ['demo:read', 'order:read', 'order:write']
const random = () => randomBytes(24).toString('hex')
export function createTutorialServer() {
const sessions = new Map()
const orders = [
{ id: '1001', customer: '上海示例客户', amount: 1200, note: '' },
{ id: '1002', customer: '杭州示例客户', amount: 860, note: '' },
]
const allowedOrigins = new Set(['http://localhost:7100', 'http://localhost:7101', 'http://localhost:7102'])
/** 请求体限定为小型 JSON 对象,错误只返回固定文案。 */
async function jsonBody(req) {
const chunks = []
let size = 0
for await (const chunk of req) {
size += chunk.length
if (size > 8192) throw new Error('body too large')
chunks.push(chunk)
}
const value = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid body')
return value
}
return createServer(async (req, res) => {
const send = (status, data) => {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(data))
}
try {
const path = new URL(req.url, 'http://localhost').pathname
const now = Date.now()
for (const [id, value] of sessions) if (value.expires <= now) sessions.delete(id)
const cookieId = /(?:^|;\s*)fin_tutorial_session=([a-f0-9]{48})(?:;|$)/.exec(req.headers.cookie ?? '')?.[1]
let session = cookieId ? sessions.get(cookieId) : undefined
// GET csrf 创建匿名会话,登录 CSRF 也绑定该会话。示例通过 Vite 同源代理使用。
if (req.method === 'GET' && path === '/api/csrf') {
if (!session) {
if (sessions.size >= 100) return send(429, { message: '教学会话过多,请重启服务' })
const id = random()
session = { csrf: random(), token: null, expires: now + 30 * 60 * 1000 }
sessions.set(id, session)
res.setHeader('Set-Cookie', `fin_tutorial_session=${id}; HttpOnly; SameSite=Lax; Path=/api; Max-Age=1800`)
}
return send(200, { csrfToken: session.csrf })
}
// 同时支持 Cookie 刷新恢复和 Bearer 业务请求;显式错误 Bearer 不降级为 Cookie。
const authorization = req.headers.authorization
const authenticated = authorization
? [...sessions.values()].find(value => value.token && authorization === `Bearer ${value.token}`)
: session?.token ? session : undefined
const writable = !['GET', 'HEAD', 'OPTIONS'].includes(req.method)
if (writable && (!allowedOrigins.has(req.headers.origin) || !session ||
req.headers['x-csrf-token'] !== session.csrf)) return send(403, { message: 'CSRF 校验失败' })
if (req.method === 'POST' && path === '/api/login') {
const body = await jsonBody(req)
if (body.username !== 'demo' || body.password !== '123456') return send(401, { message: '账号或密码错误' })
// 登录轮换会话 ID,原匿名会话不继续承担认证;令牌不输出到日志。
if (cookieId) sessions.delete(cookieId)
const id = random()
session = { csrf: random(), token: random(), expires: now + 30 * 60 * 1000 }
sessions.set(id, session)
res.setHeader('Set-Cookie', `fin_tutorial_session=${id}; HttpOnly; SameSite=Lax; Path=/api; Max-Age=1800`)
return send(200, identity(session))
}
if (!authenticated) return send(401, { message: '请先登录' })
if (writable && authenticated !== session) return send(403, { message: '凭据与 CSRF 会话不一致' })
if (req.method === 'GET' && path === '/api/session') return send(200, identity(authenticated))
if (req.method === 'GET' && path === '/api/menus') return send(200, {
apps: APP_IDS.map(appId => ({ appId, menus: [
{ menuId: 'records', name: appId === 'report' ? '报表列表' : '业务列表', icon: 'List', target: { tabId: `${appId}:list`, route: '/list' } },
] })),
})
if (req.method === 'POST' && path === '/api/logout') {
if (cookieId) sessions.delete(cookieId)
res.setHeader('Set-Cookie', 'fin_tutorial_session=; HttpOnly; SameSite=Lax; Path=/api; Max-Age=0')
return send(200, { ok: true })
}
if (req.method === 'GET' && path === '/api/orders') return send(200, orders)
const match = /^\/api\/orders\/([1-9]\d{0,9})$/.exec(path)
const order = match && orders.find(item => item.id === match[1])
if (match && !order) return send(404, { message: '订单不存在' })
if (order && req.method === 'GET') return send(200, order)
if (order && req.method === 'PUT') {
const body = await jsonBody(req)
if (typeof body.note !== 'string' || body.note.length > 500) return send(400, { message: '备注最多 500 字符' })
order.note = body.note
return send(200, order)
}
send(404, { message: '接口不存在' })
} catch {
if (!res.headersSent) send(400, { message: '请求格式无效' })
else res.end()
}
})
}
/** 教学账号固定权限;真实服务必须按用户查询权限、租户及订单资源归属。 */
function identity(session) {
return { user: { id: 'tutorial-user', name: '教程用户' }, permissions: PERMISSIONS, appIds: APP_IDS, accessToken: session.token }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
createTutorialServer().listen(7200, '127.0.0.1', () => {
console.log('教程 API 已启动:http://127.0.0.1:7200;请通过 localhost:7100/7101 的 /api 代理访问。')
})
}| 方法与路径 | 成功响应 / 用途 |
|---|---|
| GET /api/csrf | { csrfToken },创建或读取同源 Cookie 会话 |
| POST /api/login | 请求 { username, password },返回身份、权限、appIds、accessToken |
| GET /api/session | 返回相同身份结构;未登录为 401 |
| GET /api/menus | { apps: [{ appId, menus }] },基座加载授权菜单 |
| POST /api/logout | 撤销会话与 Token |
| GET /api/orders、GET /api/orders/1001 | 第三章使用的订单数据 |
| PUT /api/orders/1001 | 请求 { note },保存备注,返回更新后的订单 |
写接口要求 X-CSRF-Token 和可信 Origin。实际身份由服务端确认,不会仅通过前端输入 Token 就视为登录成功。
2. 为两个项目配置同源代理
在 两个项目的 vite.config.ts 中保留各自的 port、strictPort,新增 proxy,并将 cors 改成下面的精确来源白名单。基座完整 server 配置应是:
ts
server: {
port: 7100,
strictPort: true,
cors: {
origin: ['http://localhost:7100', 'http://localhost:7101'],
credentials: true,
},
proxy: {
'/api': { target: 'http://127.0.0.1:7200', changeOrigin: true },
},
},子应用仅将 port 保留为 7101。VITE_API_BASE 继续使用相对的 /api/,不要改为开发 HTTP 服务的绝对地址。独立打开时相对请求同源;Wujie 嵌入后请求子应用的代理端口,在浏览器中可能仍属于跨源请求,因此 Cookie 认证同时需要上面的 credentials:true、精确允许的宿主 Origin,以及客户端的 VITE_API_CREDENTIALS=true。不能用 cors:true 或 Origin:* 代替。
只有本教程明确可信的两个开发来源进入白名单。新增 report:7102 时补上对应来源;若端口被占用,须同步修改 Vite 端口、entry、此处的来源白名单和教学 API 的 allowedOrigins。
3. 配置认证模式与共享授权
替换 my-host/.env.local:
dotenv
VITE_AUTH_DEMO=false
VITE_MICRO_APP_ID=order
VITE_MICRO_APP_ENTRY=http://localhost:7101
VITE_API_BASE=/api/
VITE_TRUSTED_API_BASE=/api/
VITE_API_CREDENTIALS=true替换 order-app/.env.local:
dotenv
VITE_MICRO_AUTH_MODE=auto
VITE_API_BASE=/api/
VITE_TRUSTED_API_BASE=/api/
VITE_API_CREDENTIALS=true子应用保持 auto,支持嵌入共享认证和无宿主时独立登录。host 模式在独立打开时会因没有宿主桥而失败。本教程显式启用 Cookie 恢复;生产 Bearer 只存内存时,需要另行实现刷新恢复协议。
在 my-host/src/configs/microApps.ts 中找到默认业务注册项,将它改为:
ts
{ ...business, name: '订单应用', shareToken: true },保留 defineMicroApps、tokenSharingAppIds 等原有实现。只对自己维护且完全可信的子应用授权,不需要修改运行库的默认策略。授权后,子应用请求工具按请求读取宿主 Token,业务页面无需手动取 Token 或拼 Authorization。
4. 给基座接上 CSRF
将csrf.ts保存为 my-host/src/utils/csrf.ts:
ts
/**
* 本教程的可信代理 CSRF 协议:每次写请求前取服务端当前值,不写入环境变量或持久化存储。
* host 与子应用各复制一份项目适配器;生产网关路径不同,应在这里替换接口地址。
*/
export async function csrfHeaders(signal?: AbortSignal): Promise<Record<string, string>> {
// Wujie 嵌入后开发端口仍可能跨源,include 配合 Vite 精确来源白名单;生产教程使用同源网关。
const response = await fetch('/api/csrf', { signal, credentials: 'include', cache: 'no-store' })
if (!response.ok) throw new Error('CSRF 初始化失败')
const data: unknown = await response.json()
if (!data || typeof data !== 'object' || !('csrfToken' in data) ||
typeof data.csrfToken !== 'string' || !/^[a-f0-9]{48}$/.test(data.csrfToken)) {
throw new Error('CSRF 响应无效')
}
return { 'X-CSRF-Token': data.csrfToken }
}在 my-host/src/configs/platformSecurity.ts 顶部新增:
ts
import { csrfHeaders } from '../utils/csrf'保留该文件已有的两个 interface,将 platformCsrf 对象替换为:
ts
export const platformCsrf: PlatformCsrfConfig = {
required: true,
headerName: 'X-CSRF-Token',
getToken: async ({ signal }) => (await csrfHeaders(signal))['X-CSRF-Token'],
}开发和生产都走同一签发协议。平台适配器在 login/logout 发送前读取它,不要通过关闭 required 掩盖接口未接通的问题。
5. 给子应用独立登录接上相同协议
将同一个csrf.ts保存为 order-app/src/utils/csrf.ts。将独立认证适配文件完整替换 order-app/src/apis/auth.ts:
ts
import { AxiosHttpClient } from '@axewo/fin-core/http/axios'
import type { IndependentAuthAdapter, IndependentAuthResult } from '@axewo/fin-micro/vue'
import type { SessionInput } from '@axewo/fin-micro/sdk'
import { appConfig } from '../utils/appConfig'
import { csrfHeaders } from '../utils/csrf'
// 认证使用独立客户端,避免首次登录等待业务请求的凭据 provider,形成循环。
const authHttp = new AxiosHttpClient({ baseURL: appConfig.apiBase, timeout: appConfig.timeout, withCredentials: appConfig.withCredentials })
/** 复制身份白名单;权限先限量再遍历。真实后台使用 {code,data} 时在调用本函数前解包。 */
function identity(value: unknown): SessionInput {
const data = value as { user?: { id?: unknown; name?: unknown }; permissions?: unknown }
if (!data || !data.user || typeof data.user.id !== 'string' || !data.user.id.trim() ||
!Array.isArray(data.permissions) || data.permissions.length > 500 ||
!data.permissions.every(item => typeof item === 'string' && !!item.trim() && item.length <= 120 && !/[\u0000-\u001f\u007f]/.test(item))) throw new Error('身份响应无效')
if (new Set(data.permissions).size !== data.permissions.length) throw new Error('权限重复')
return { authenticated: true, user: { id: data.user.id, ...(typeof data.user.name === 'string' ? { name: data.user.name } : {}) }, permissions: [...data.permissions] }
}
/** Token 仅交给会话私有存储,不放入公开 user 或 permissions。 */
function result(value: unknown): IndependentAuthResult {
const data = value as { accessToken?: unknown }
if (!data || (data.accessToken !== undefined && data.accessToken !== null && typeof data.accessToken !== 'string')) throw new Error('凭据格式无效')
return { token: (data.accessToken as string | null) ?? null, identity: identity(value) }
}
export const independentAuth: IndependentAuthAdapter = {
async verify(token, signal) {
return identity(await authHttp.get('session', { signal, headers: { Authorization: `Bearer ${token}` } }))
},
async login(credentials, signal) {
const headers = await csrfHeaders(signal)
return result(await authHttp.post('login', credentials, { signal, headers }))
},
async restore(signal) {
try { return result(await authHttp.get('session', { signal })) }
catch (error) {
if ((error as { response?: { status?: number } })?.response?.status === 401) return null
throw error
}
},
async logout(token, signal) {
const headers = { ...await csrfHeaders(signal), ...(token ? { Authorization: `Bearer ${token}` } : {}) }
await authHttp.post('logout', {}, { signal, headers })
},
}相较生成模板,login/logout 增加了 CSRF 请求头;身份校验、数量限制、取消信号和 Cookie 恢复仍保留。基座的 CSRF 配置不会自动作用于子应用,所以这里必须单独接入。
6. 重启并验证基座真实登录
重启两个 Vite 开发服务器,保持 API 服务运行。在浏览器地址栏输入 http://localhost:7100/,若仍处于上一章演示身份,退出后再登录。使用 demo / 123456 登录,这次由教学 API 验证。
在浏览器 Network 中应看到:
- 启动时 GET session:无会话时为 401,页面显示登录页。
- 提交登录时 GET csrf → POST login → GET menus。
- 登录后显示“教程用户”,菜单出现“业务列表”;点击后打开子应用模板列表。
身份响应如下,accessToken 是服务端动态值:
json
{
"user": { "id": "tutorial-user", "name": "教程用户" },
"permissions": ["demo:read", "order:read", "order:write"],
"appIds": ["order"],
"accessToken": "服务端签发的当前值"
}后台菜单与本地代码注册必须同时存在:
json
{
"apps": [{
"appId": "order",
"menus": [{
"menuId": "records",
"name": "业务列表",
"icon": "List",
"target": { "tabId": "order:list", "route": "/list" }
}]
}]
}records 同时被模板详情归属规则引用,不能只改一个名字。后台不返回 entry/shareToken,它们由本地可信注册表管理。
7. 验证子应用独立登录与刷新
用一个新的无痕窗口访问 http://localhost:7101/,输入 demo / 123456。应进入 /#/home,看到本地“主页 / 业务列表”菜单,点击业务列表再打开详情。
检查 Network:独立子应用请求 login/session,不请求 menus。独立菜单来自 src/router/index.ts;嵌入时菜单由基座后台提供。
正常窗口中基座和子应用都使用 localhost,Cookie 不按端口隔离,因此可能直接恢复同一教学会话。用无痕窗口可观察一次独立登录。生产需要分开的会话时,由后端设计独立域名、Cookie 名称及认证协议。
刷新应恢复登录。开发模式的登录 Token 按 appId 保存到 localStorage,刷新时重新向 session 验证;生产禁用这份开发存储,本教程通过 Cookie 恢复。清除开发 Token 后 Cookie 仍可恢复身份;真正退出请点击退出并确认服务端注销成功。
本章验收与后端替换位置
| 验收 | 结果 |
|---|---|
| 错误密码登录 | 401,留在登录页,不挂业务内容 |
| 缺少/错误 CSRF 调用写接口 | 403,不应通过去掉校验解决 |
| 基座正确登录 | 身份和 menus 完整成功后才显示工作区 |
| 独立正确登录 | 登录后进入主页,菜单由本地路由生成 |
| 刷新 | 重新验证/恢复服务端身份,不使用保存的旧权限 |
| 注销后访问 session | 401,本地撤销身份并返回登录页 |
自己的后端若返回 code/data 包裹,只在基座 platform/auth.ts 和子应用 apis/auth.ts 对认证响应解包,再交给身份校验;菜单在 platform/menu.ts 解码。不要将这类处理散落到登录页和每一个业务页面。
下一步:第 3 步:实现订单列表、详情与保存返回。