Skip to content

登录与认证 ​

本文说明 tio-boot-admin 与 tio-boot-admin-react(React + Umi)的登录联调约定。接口行为以本地后端真实 HTTP 请求的验证结果为依据;Java 示例结合仓库已有 Handler 示例整理,不替代项目已注册的内置处理器。

统一响应体让前端可以集中处理业务结果,Aop.get 复用业务服务,TioRequestContext 提供当前请求的响应和已认证身份。后台管理员与 AppUser 应用用户分别使用各自的接口和令牌。

1. 接口一览 ​

JSON 请求使用 Content-Type: application/json。以下地址均不附加上下文前缀;/api/ 是接口路径的一部分,需要完整转发到后端。

体系方法与路径请求体字段返回与认证说明
后台管理员POST /api/login/accountusername、password、type;账号登录传 type: "account"成功返回登录数据;凭据错误也返回 HTTP 200
后台管理员POST /api/login/outLogin无需业务请求体发送当前令牌,成功返回 data: null
后台管理员GET /api/currentUser无不带令牌返回 HTTP 401;合法令牌返回当前用户信息
AppUserPOST /api/v1/loginusername 或 email,以及 passwordAppUserLoginHandler 提供登录;账号字段来自现有文档示例
AppUserPOST /api/v1/logout无需业务请求体AppUserLoginHandler 提供退出,携带应用用户令牌
AppUserGET /api/v1/user/profile无AppUserHandler 提供资料读取,携带应用用户令牌
AppUserPOST /api/v1/user/update资料字段对象,字段名和允许更新范围须按实际部署确认AppUserHandler 提供资料更新,携带应用用户令牌

AppUser 的详细请求字段可结合应用用户登录示例核对。本次实测事实未给出资料更新的完整字段契约,因此这里不将 displayName 等登录响应字段直接当作更新字段。

1.1 后台登录 ​

请求示例,密码替换为手动初始化的账号密码:

json
{
  "username": "admin",
  "password": "填写你设置的密码",
  "type": "account"
}

成功时服务端调用 RespBodyVo.ok(kv)。下面的 ID、令牌、时间是结构示意,不是可复用的凭据:

json
{
  "data": {
    "userId": 1,
    "token": "示例令牌",
    "tokenTimeout": 1893456000,
    "type": "account",
    "status": "ok"
  },
  "msg": null,
  "ok": true,
  "code": 1,
  "error": null
}

凭据错误时 HTTP 状态仍为 200,响应如下:

json
{
  "data": { "status": "false" },
  "msg": null,
  "ok": false,
  "code": 0,
  "error": null
}

data.status 中的 "false" 是字符串,不能用 if (data.status) 判断登录成功。后台登录必须同时判断 ok === true 与 data.status === "ok",再从 data.token 读取令牌。

管理员账号状态 ​

LoginService.getUserIdByUsernameAndPassword 同时检查用户名、密码摘要、status=0 和 deleted=0。只有正常且未删除的账号可以建立新登录,停用或已删除的账号与凭据不匹配一样返回 null。请求对象、用户名或密码为 null 时同样返回 null,方便业务服务统一处理认证失败。

该规则将账号生命周期与密码认证集中到一个服务中,后台和专用业务入口都可以复用。固定业务字段使用实体,Handler 通过 Aop.get 获取 Service:

java
package example.auth;

import nexus.io.jfinal.aop.Aop;
import nexus.io.model.exception.BusinessException;
import nexus.io.tio.boot.admin.services.LoginService;
import nexus.io.tio.boot.admin.vo.LoginAccountVo;
import nexus.io.tio.utils.validator.ParameterValidator;

public class AccountAuthenticationService {
  public Long authenticate(String username, String password) {
    String checkedUsername = ParameterValidator.text(username, "username", 30);
    ParameterValidator.require(password != null && !password.isEmpty()
        && password.length() <= 200, "password is required and must be at most 200 characters");
    LoginAccountVo input = new LoginAccountVo();
    input.setUsername(checkedUsername);
    input.setPassword(password);
    LoginService loginService = Aop.get(LoginService.class);
    Long userId = loginService.getUserIdByUsernameAndPassword(input);
    BusinessException.require(userId != null, 401, "Invalid credentials");
    return userId;
  }
}

密码保持原值,不对密码做 trim。数据库仍保存 Sha256Utils.digestToHex 生成的摘要,初始化方式见 手动初始化数据库。新登录的状态检查与已有令牌的生命周期是两个独立步骤;需要停用后立即拒绝访问、退出后立即撤销令牌的业务,可在自定义拦截器中结合账号状态和服务端会话记录完成校验。

1.2 退出与当前用户 ​

POST /api/login/outLogin 成功响应:

json
{
  "data": null,
  "msg": null,
  "ok": true,
  "code": 1,
  "error": null
}

GET /api/currentUser 未携带令牌时返回 HTTP 401。携带合法后台令牌时可读取当前用户信息;具体资料字段以实际响应为准,不要把登录数据结构当作当前用户结构。401 的完整响应体未在本次验证材料中给出,前端应首先按 HTTP 状态处理。

1.3 AppUser 登录 ​

可使用用户名登录,或将 username 替换为 email。以下请求字段参考现有 AppUserLoginRequest 示例:

json
{
  "username": "demo",
  "password": "填写应用用户密码"
}

AppUser 登录成功同样使用 RespBodyVo,登录数据结构示意如下:

json
{
  "code": 1,
  "ok": true,
  "msg": null,
  "data": {
    "userId": "示例用户ID",
    "displayName": "示例用户",
    "email": "demo@example.com",
    "token": "示例应用用户令牌",
    "refreshToken": "示例刷新令牌",
    "tokenTimeout": 1893456000
  },
  "error": null
}

这些字段属于登录数据,不代表退出、资料读取、资料更新都返回相同内容。AppUser 登录不要求后台专用的 data.status === "ok" 条件。现有登录设计对账号不存在和密码错误统一返回失败提示,前端同样显示“账号或密码错误”,避免透露账号是否已注册。

2. RespBodyVo 与前端判定 ​

字段含义与读取方式
code业务状态码,约定 1 表示成功;不能替代 HTTP 状态码
ok业务成功标志,成功为布尔值 true
msg消息,可为 null;前端需要默认提示
data业务数据,可为对象或 null;令牌位于 data.token
error错误信息,可为 null;不应未经处理直接展示内部细节

按顺序检查 HTTP 状态、业务成功标志和接口专用字段。后台登录可以额外校验 code === 1 和令牌是否非空;不要检查不存在的 success 字段,也不要从响应顶层读取 token。

登录页完成令牌保存后,再请求当前用户并更新 Umi 的用户状态。退出时向服务端发送当前令牌,完成后清理浏览器令牌和用户状态。仅清理浏览器存储不等于服务端已成功退出。

3. Authorization 令牌传递 ​

服务端兼容以下两种请求头,按空白分隔后取最后一段作为令牌:

http
Authorization: Bearer your-token
http
Authorization: your-token

前端统一使用 Bearer 形式即可。后台接口发送后台令牌,AppUser 接口发送应用用户令牌;不要混用。tokenTimeout 的单位和续期策略应与实际服务端实现保持一致,服务端认证结果是最终依据;AppUser 返回 refreshToken 并不表示前端已经实现自动刷新。

4. 服务端 Handler 示例 ​

内置配置已经注册登录接口时,直接使用现有接口,无需重复注册。下面的扩展示例演示如何复用内置后台登录 Handler,以及如何通过 Aop.get 调用 AppUser 服务、使用 Kv 组织数据和 RespBodyVo 包装响应。appProfile 是自定义资料投影示例,不是内置 profile 的完整响应契约。

java
package demo.auth;

import com.jfinal.kit.Kv;
import nexus.io.jfinal.aop.Aop;
import nexus.io.model.body.RespBodyVo;
import nexus.io.tio.boot.admin.handler.AdminLoginHandler;
import nexus.io.tio.boot.admin.services.AppUserService;
import nexus.io.tio.boot.admin.vo.AppUser;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;
import nexus.io.tio.http.common.HttpResponse;

public class LoginExampleHandler {
  private final AdminLoginHandler adminLogin = new AdminLoginHandler();

  public HttpResponse account(HttpRequest request) {
    // 复用内置密码校验、令牌签发和 RespBodyVo 响应。
    return adminLogin.account(request);
  }

  public HttpResponse outLogin(HttpRequest request) {
    return adminLogin.outLogin(request);
  }

  public HttpResponse appProfile(HttpRequest request) {
    // 此方法须由 AppUser 鉴权拦截器保护,身份来自已验证的上下文。
    String userId = TioRequestContext.getUserIdString();
    HttpResponse response = TioRequestContext.getResponse();
    if (userId == null) {
      return response.setStatus(401).setJson(RespBodyVo.fail("请先登录"));
    }

    AppUserService service = Aop.get(AppUserService.class);
    AppUser user = service.getUserById(userId);
    if (user == null) {
      return response.setStatus(401).setJson(RespBodyVo.fail("请重新登录"));
    }

    Kv data = Kv.by("userId", user.getId())
        .set("displayName", user.getDisplayName())
        .set("email", user.getEmail());
    return response.setJson(RespBodyVo.ok(data));
  }
}

该示例依赖应用已完成框架配置和数据库初始化。Handler 由路由配置创建,业务服务通过 Aop 获取。不要通过请求体中的 userId 确定当前用户,也不要仅解析 Authorization 就当作认证成功。鉴权注册方式见方法路由与业务鉴权。

5. React + Umi 调用示例 ​

以下服务模块使用项目已有的 @umijs/max 请求工具,保存到前端的 services/auth.ts 后供登录页调用。请求返回值应保留完整 RespBodyVo;若项目已有统一解包或错误处理,需要按同一契约调整,避免重复解包。

typescript
import { request } from '@umijs/max';

interface RespBody<T> {
  code: number;
  ok: boolean;
  msg: string | null;
  data: T | null;
  error: unknown;
}

interface AdminLoginData {
  userId?: string | number;
  token?: string;
  tokenTimeout?: number;
  type?: string;
  status: string;
}

const tokenKey = 'tio-admin-token';

function authHeaders(): Record<string, string> {
  const token = sessionStorage.getItem(tokenKey);
  return token ? { Authorization: `Bearer ${token}` } : {};
}

export async function login(username: string, password: string) {
  sessionStorage.removeItem(tokenKey);
  const result = await request<RespBody<AdminLoginData>>('/api/login/account', {
    method: 'POST',
    data: { username, password, type: 'account' },
    skipErrorHandler: true,
  });

  if (result.ok !== true || result.code !== 1 || result.data?.status !== 'ok') {
    throw new Error('账号或密码错误');
  }
  const token = result.data.token;
  if (typeof token !== 'string' || token.trim() === '') {
    throw new Error('登录响应缺少令牌,请重试');
  }
  sessionStorage.setItem(tokenKey, token);
  return result.data;
}

export async function currentUser() {
  try {
    const result = await request<RespBody<Record<string, unknown>>>('/api/currentUser', {
      method: 'GET',
      headers: authHeaders(),
      skipErrorHandler: true,
    });
    if (result.ok !== true || result.code !== 1 || result.data === null) {
      throw new Error('读取当前用户失败');
    }
    return result.data;
  } catch (error) {
    const status = (error as { response?: { status?: number } }).response?.status;
    if (status === 401) {
      sessionStorage.removeItem(tokenKey);
      throw new Error('请重新登录');
    }
    throw error;
  }
}

export async function logout() {
  try {
    const result = await request<RespBody<null>>('/api/login/outLogin', {
      method: 'POST',
      headers: authHeaders(),
      skipErrorHandler: true,
    });
    if (result.ok !== true || result.code !== 1) {
      throw new Error('服务端退出未成功,请重试');
    }
  } finally {
    sessionStorage.removeItem(tokenKey);
  }
}

这里使用 sessionStorage 保存当前标签页会话;已有项目使用其他存储时,应统一存取位置。不要在日志中输出密码或令牌。调用方捕获网络异常、401 和业务失败后显示对应提示,退出失败时不要显示“服务端退出成功”。

登录页提交函数示例(放入已有 React 登录页,替换原提交逻辑):

typescript
import { message } from 'antd';
import { login, currentUser } from '@/services/auth';

export async function submitLogin(values: { username: string; password: string }) {
  try {
    await login(values.username, values.password);
    const user = await currentUser();
    message.success('登录成功');
    // 调用方将 user 写入已有 Umi initialState,再跳转到后台首页。
    return user;
  } catch (error) {
    message.error(error instanceof Error ? error.message : '请求失败,请稍后重试');
    return null;
  }
}

6. 开发代理与联调 ​

在前端实际启用的 Umi 配置中合并下面的代理配置。示例假定后端监听 8100,实际端口不同时替换 target。不要覆盖项目已有的其他配置项。

typescript
import { defineConfig } from '@umijs/max';

export default defineConfig({
  proxy: {
    '/api/': {
      target: 'http://127.0.0.1:8100',
      changeOrigin: true,
    },
  },
});

若项目将代理放在 proxy.ts 的 dev 环境下,应把上面的 /api/ 规则合并到 dev,并确认主配置和启动环境实际选中了它。修改后重启前端开发服务。这里不添加 pathRewrite,请求 /api/login/account 到达后端时仍为 /api/login/account,不添加额外应用名前缀。

现象检查与处理
前端 8000 端口请求 /api/... 返回 404先检查 dev 代理是否启用、后端端口是否正确、代理是否保留 /api/;对比直接请求后端的结果
登录 HTTP 200,但页面未进入后台检查 ok 和 data.status;凭据错误属于业务失败,不应仅依赖 HTTP 200
登录返回成功,后续请求仍为 401检查是否从 data.token 取值,Authorization 是否发送,以及后台/AppUser 令牌是否混用
未携带令牌访问 /api/currentUser 返回 401这是该受保护接口的预期行为,先登录再携带合法令牌重试
登录失败时没有提示文字msg 可能为 null,使用“账号或密码错误”作为统一提示
退出后页面仍显示用户信息同时清理令牌、Umi 当前用户状态及相关页面缓存

可按“失败登录 → 成功登录 → 不带令牌读取当前用户 → 分别携带 Bearer 与裸令牌读取 → 退出”的顺序验证。后台退出后的旧令牌失效时机、AppUser 刷新流程和资料更新字段未包含在本次实测材料中,需要结合部署实现补充验证。

7. 必需配置:后台令牌签名密钥 ​

后台登录在通过账号密码校验之后需要签发令牌,签名密钥取自配置项 app.admin.secret.key。请在本地秘密配置(例如应用启动工作目录下的 .env)中提供:

properties
app.admin.secret.key=replace-with-your-own-random-secret

该密钥同时用于后台令牌的签发与校验:多个实例使用同一密钥时签发的令牌可以互相识别;更换密钥后已签发的旧令牌立即失效。密钥只放在本地配置或环境变量中,不要写入源码、示例与文档。

未配置或配置为空白时的表现:登录请求会在签发令牌阶段失败并返回 500,服务端日志出现 Failed to calculate HMAC SHA256。框架在读取该配置时就会直接抛出具名错误 app.admin.secret.key is not configured,便于在联调早期定位到是配置缺失,而不是把它误判成账号或密码问题。

MIT Licensed