Skip to content

第 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 中应看到:

  1. 启动时 GET session:无会话时为 401,页面显示登录页。
  2. 提交登录时 GET csrf → POST login → GET menus。
  3. 登录后显示“教程用户”,菜单出现“业务列表”;点击后打开子应用模板列表。

身份响应如下,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 完整成功后才显示工作区
独立正确登录登录后进入主页,菜单由本地路由生成
刷新重新验证/恢复服务端身份,不使用保存的旧权限
注销后访问 session401,本地撤销身份并返回登录页

自己的后端若返回 code/data 包裹,只在基座 platform/auth.ts 和子应用 apis/auth.ts 对认证响应解包,再交给身份校验;菜单在 platform/menu.ts 解码。不要将这类处理散落到登录页和每一个业务页面。

下一步:第 3 步:实现订单列表、详情与保存返回。