登录

前言

Uniboot 模板项目提供了两套针对不同部署场景设计的登录鉴权机制:Simple 模板(本地表单自主登录模式)Auth 模板(Java 后端统一授权服务中心模式)。两套方案在控制层、页面结构和 Token 换取逻辑上存在显著差异。

登录模块系统设计

系统根据是否引入统一认证中心(SSO / OAuth2.0),划分了不同的认证流:

  • Simple 模板:适用于独立的单体或微服务应用。系统前端提供本地登录页面 src/views/account/login.vue,在当前应用内搜集用户名和密码,并直接发起登录请求获取 Token。
  • Auth 模板:适用于多系统集成或需要统一单点登录(SSO)的应用。前端不包含/不展示本地登录页面,完全依赖 Java 后端“授权服务中心”提供的登录页进行身份认证。

Simple 模板登录流 (本地登录模式)

Simple 模板项目使用标准的 SPA 本地表单登录方案,全套逻辑均在前端应用内流转。

1. 登录组件与基础属性

Simple 模板使用组件库封装好的 LoginPage 组件构建登录表单,挂载在 src/views/account/login.vue

vue
<template>
  <login-page
    :logo="config.web_logo"
    :illustration="config.login_image"
    :title="$t('userLogin')"
    :submit-text="$t('login')"
    :loading="isLock"
    @finish="lockLogin"
  >
    <!-- 其他插槽 -->
  </login-page>
</template>

2. 请求控制与防重复提交

登录操作在脚本中自主处理:

  1. 通过 useLockFn 包装真实登录请求函数 handleLogin,防止网络延迟时用户重复点击。
  2. handleLogin 内,显式调用 Pinia store 中的 userStore.login 行为以发送 API 请求。
  3. 登录成功获取令牌后,根据 route.query.redirect 跳转回拦截前的页面或重定向至系统首页。

Auth 模板登录流 (统一认证授权模式)

Auth 模板则采用了一种经典且安全的 OAuth 2.0 授权码(Authorization Code)模式。当用户访问受保护的页面且前端没有检测到 Token 时,鉴权守卫 src/permission.ts 将实施拦截并引导至后端授权页,具体步骤如下:

  1. 访问受限页面:用户尝试访问系统内部受限路由(如 /dashboard)。
  2. 路由前置校验:路由守卫 beforeEach 拦截本次跳转,检测到本地不存在 accessToken(未登录)且目标并非免登录白名单。
  3. 记忆返回地址:守卫调用 rememberOAuthReturnPath(to.fullPath) 提取当前的完整 URL,并安全写入 sessionStorage,以便授权成功后跳回。
  4. 发起网关重定向:守卫调用 redirectToAuthServiceLogin() 并执行 next(false) 中断本路由跳转。浏览器直接执行 window.location.href = buildOAuthAuthorizeHref(...) 将页面强行引导重定向至 Java 统一授权服务中心。
  5. 展示登录表单:授权中心接收请求,展示 Java 后端统一提供的多应用登录验证页面。

在守卫中的具体实现逻辑为:

ts
// 当检测到无 token 且未命中免登录白名单时
if (config.useAuthServiceLogin && config.authServiceUrl?.trim()) {
  // 1. 在 sessionStorage 中记录当前尝试访问的地址,用于登录成功后跳回
  rememberOAuthReturnPath(to.fullPath)
  NProgress.done()

  // 2. 更改 window.location.href 强行重定向跳转至后端统一授权服务中心
  redirectToAuthServiceLogin()

  // 3. 中断当前的单页路由跳转
  next(false)
  return
}

用户在 Java 后端的统一授权登录页面完成账号验证后,授权服务中心会携带授权码 code 和防伪验证标识 state 重定向回调至前端站点,具体换票步骤如下:

  1. 授权完成回调:用户在 Java 授权中心成功验证身份后,授权中心携带随机生成的授权码 code 和防伪校验状态 state 重定向回调到前端配置的 Callback 地址(例如:http://localhost:3000/?code=xxx&state=yyy)。
  2. 捕获授权状态:前端路由守卫 beforeEach 拦截到页面 URL 中的 code 查询参数,且此时检测到 !userStore.token
  3. 状态安全性防伪校验:守卫提取 state 参数,并与 sessionStorage 中期望的校验值进行校对,确认无误以防御 CSRF 跨站请求伪造攻击。
  4. 调用接口换取令牌:守卫调用异步请求函数 exchangeAuthorizationCode(oauthCode)。前端向后端的 Token 终结点发送授权码,后端解密校验后返回 accessToken 访问令牌。
  5. 写入状态并原路跳回:前端将获取到的 accessToken 手动保存至 Pinia Store 的 userStore.token 并写入本地缓存。随后调用 takeAndClearOAuthReturnPath() 读取先前记忆的受限制页面地址,重新导航到最初请求的路由位置,用户无缝进入系统。

src/permission.ts 中,这一阶段的集中换票逻辑在守卫的顶部实施截获:

ts
const oauthCode = typeof to.query.code === 'string' ? to.query.code : ''

// 当无 Token 但路由 query 中携带有 code 参数时
if (!userStore.token && config.useAuthServiceLogin && oauthCode) {
  // 1. 防伪状态校验,防御 CSRF 跨站请求伪造攻击
  const stateParam = to.query.state
  const expected = sessionStorage.getItem(OAUTH_LOGIN_STATE_KEY)
  if (typeof stateParam !== 'string' || stateParam !== expected) {
    feedback.msgError('授权状态校验失败,请重试')
    next({ path: loginPath, replace: true })
    return
  }

  try {
    // 2. 调用 API 将 code 异步换取系统的访问令牌 accessToken
    const accessToken = await exchangeAuthorizationCode(oauthCode)
    clearOAuthLoginSessionMarkers()

    // 3. 手动保存 Token 进 Pinia 与持久化 LocalStorage 缓存
    userStore.token = accessToken
    appStorage.setItem(TOKEN_KEY, accessToken)

    // 4. 读取此前缓存的返回路径并跳回
    const dest = takeAndClearOAuthReturnPath(PageEnum.INDEX)
    NProgress.done()
    next({ path: dest, replace: true })
  } catch (e) {
    clearOAuthLoginSessionMarkers()
    next({ path: loginPath, replace: true })
  }
  return
}

通过这种守卫前置拦截、集中式换票的设计,整个授权与跳转过程在几百毫秒内即可无缝完成,而用户甚至不会感知到前端没有“登录页”这一事实,最大化地利用了单点登录的统一优势。