3. 登录授权与 Token
一句话定位
移动端登录 = 「取验证码 → SM2 加密密码 → 调登录接口 → 把 access-token/x-access-token 存本地 → 拉取用户信息存 current-user」,后续所有请求由拦截器自动带 Token 并在过期时用刷新 Token 续期。
认证链路
┌────────────── 移动端 ──────────────┐ ┌────────── 后端 Admin.NET ──────────┐
│ login.vue │ │ │
│ 1) sysAuth.captcha() ───────────────────► │ GET /api/sysAuth/captcha │
│ ← { id, img(base64), expirySeconds } │ CaptchaOutput │
│ │ │
│ 2) sm2.doEncrypt(pwd, env.smKey, 1) │ │
│ 3) POST /api/sysAuth/login ───────────────►│ SysAuthService.Login │
│ { account,password,codeId,code, │ ├ 密码错误次数(D1027) │
│ tenantId } │ ├ 租户校验/验证码校验(Z1003,D0008) │
│ │ ├ CryptogramHelper.SM2Decrypt │
│ ← { accessToken, refreshToken } │ └ CreateToken(LoginModeEnum.PC) │
│ │ │
│ 移动端专用(同为 LoginMode 区分): │ AppAuthService.Login │
│ POST /api/appAuth/login ─────────────► │ └ CreateToken(LoginModeEnum.APP) │
│ │ ├ SingleLogin(userId, mode) │
│ 4) setStorageSync('access-token'…) │ ├ JWTEncryption.Encrypt │
│ 5) GET /api/sysAuth/userInfo ─────────────►│ └ SetTokensOfResponseHeaders │
│ ← { account, realName, apis[], roles[] }│ │
│ 6) setStorageSync('current-user', …) │ │
│ 7) uni.switchTab('/pages/home/home') │ │
└────────────────────────────────────────────┴─────────────────────────────────────┘一、后端服务:AppAuthService
文件:Admin.NET.Application/Service/App/Auth/AppAuthService.cs
[ApiDescriptionSettings(ApplicationConst.GroupName, Order = 500, Description = "移动应用")]
public class AppAuthService : IDynamicApiController, ITransientApplicationConst.GroupName 的值为 main(Admin.NET.Application/Const/ApplicationConst.cs), 动态 API 路由前缀默认 api,因此可用动作如下:
| 动作 | 路由 | 特性 | 说明 |
|---|---|---|---|
Login | POST /api/appAuth/login | [AllowAnonymous] | 账号密码登录 |
LoginPhone | POST /api/appAuth/loginPhone | [AllowAnonymous] | 手机号+验证码登录 |
GetUserInfo | GET /api/appAuth/userInfo | 需鉴权 | 当前登录账号信息(含 Apis、Roles) |
GetRefreshToken | GET /api/appAuth/refreshToken?accessToken= | 需鉴权 | 手动换取刷新 Token |
Logout | POST /api/appAuth/logout | 需鉴权 | 退出系统 |
GetCaptcha | GET /api/appAuth/captcha | [AllowAnonymous] [SuppressMonitor] | 返回 { Id, Img } |
ChangePwd | POST /api/appAuth/changePwd | 需鉴权 | 修改密码(旧/新密码均 SM2 密文) |
参数契约(后端真实定义):
public class LoginInput {
public string Account { get; set; } // [Required] 最少 2 字符
public string Password { get; set; } // [Required] SM2 密文
public long TenantId { get; set; } = SqlSugarConst.DefaultTenantId;
public long CodeId { get; set; } // 验证码 Id
public string Code { get; set; } // 验证码值
}
public class LoginPhoneInput { public string Phone; public string Code; public long TenantId; }
public class ChangePwdInput { public string PasswordOld; public string PasswordNew; }
public class LoginOutput { public string AccessToken; public string RefreshToken; }前端对应的接口定义在 App/api_all/:
// api_all/sysAuth.ts
export const usesysAuthApi = () => {
const baseApi = useBaseApi("sysAuth");
return {
captcha: baseApi.custom("captcha", "GET"), // 获取验证码
login: baseApi.custom("login", "POST"), // 账号密码登录
loginPhone: baseApi.custom("loginPhone", "POST"), // 手机号登录
logout: baseApi.custom("logout", "POST"), // 退出系统
swaggerCheckUrl: baseApi.custom("swaggerCheckUrl", "POST"),
swaggerSubmitUrl: baseApi.custom("swaggerSubmitUrl", "POST"),
unLockScreen: baseApi.custom("unLockScreen", "POST"),
userInfo: baseApi.custom("userInfo", "GET"), // 当前登录用户信息
}
}
// api_all/appAuth.ts
export const useappAuthApi = () => {
const baseApi = useBaseApi('appAuth')
return { changePwd: baseApi.custom('changePwd', 'POST') }
}useBaseApi(module) 会把基址拼成 /api/${module}/,所以 login 实际请求 /api/sysAuth/login。
二、LoginModeEnum.APP 是什么
// Admin.NET.Core/Enum/LoginModeEnum.cs
public enum LoginModeEnum { PC = 1, APP = 2 }AppAuthService.CreateToken(user, LoginModeEnum loginMode = LoginModeEnum.APP) 会把 ClaimConst.LoginMode 写进 JWT。 区别体现在在线用户与单点登录:SysOnlineUserService.SingleLogin(userId, loginMode) 只挤掉同一登录模式的会话—— 即 PC 登录不会把 App 挤下线,App 登录也不会把 PC 挤下线;同模式重复登录才互踢。
仓库现状(务必看清)
pages/login/login.vue 当前调用的是 sysAuth 分组(/api/sysAuth/login),而不是 appAuth。 SysAuthService.CreateToken 默认参数是 LoginModeEnum.PC。 若需要「App 会话与 PC 会话独立互踢」,请把登录入口切到 /api/appAuth/login(移动端改密页面 password.vue 已经用的是 /api/appAuth/changePwd)。
迁移方式只改两行:
- import { usesysAuthApi } from '@/api_all/sysAuth'
+ import { useappAuthApi } from '@/api_all/appAuth'
- const authApi = usesysAuthApi()
+ const authApi = useappAuthApi()
api_all/appAuth.ts目前只暴露了changePwd,需要login/loginPhone/userInfo/logout时按sysAuth.ts的格式补几行即可。
三、Token 生成内容
var accessToken = JWTEncryption.Encrypt(new Dictionary<string, object> {
{ AppClaimConst.UserId, user.Id },
{ AppClaimConst.TenantId, user.TenantId },
{ AppClaimConst.Account, user.Account },
{ AppClaimConst.RealName, user.RealName },
{ AppClaimConst.AccountType, user.AccountType },
{ AppClaimConst.OrgId, user.OrgId },
{ AppClaimConst.OrgName, user.SysOrg?.Name },
{ AppClaimConst.OrgType, user.SysOrg?.Type },
{ AppClaimConst.OrgLevel, user.SysOrg?.Level },
{ ClaimConst.LoginMode, loginMode }, // PC / APP
{ ClaimConst.TokenVersion, user.TokenVersion }
}, tokenExpire);
_httpContextAccessor.HttpContext.SetTokensOfResponseHeaders(accessToken, refreshToken);后端把 Token 同时写进响应头:access-token、x-access-token(前端 utils/request.js 的响应拦截器会读这两个头自动回写本地)。 过期时间取自系统配置:SysConfigService.GetTokenExpire() / GetRefreshTokenExpire()。
四、前端登录页完整代码(可复制)
<script lang="ts" setup>
import { reactive, onMounted, onUnmounted } from 'vue'
import { env } from '@/utils/.env.js'
import { sm2 } from 'sm-crypto'
import { accessTokenKey, refreshAccessTokenKey, currentUserKey, clearAccessTokens } from '@/utils/request.js'
import { usesysAuthApi } from '@/api_all/sysAuth' // 切 appAuth 则改用 '@/api_all/appAuth'
const state = reactive({
password: env.defaultPassword,
captchaImage: '',
formData: { account: env.defaultUser, password: '', codeId: 0, code: '' },
expirySeconds: 0
})
const authApi = usesysAuthApi()
let timer: any = null
onMounted(() => { initCaptcha() })
onUnmounted(() => { if (timer) clearInterval(timer) })
// 获取验证码(后端返回 base64 图片)
const initCaptcha = () => {
if (timer) clearInterval(timer)
authApi.captcha().then((res: any) => {
state.captchaImage = 'data:text/html;base64,' + res.result?.img
state.formData.codeId = res.result?.id
state.expirySeconds = res.result?.expirySeconds ?? 60
timer = setInterval(() => { if (state.expirySeconds > 0) state.expirySeconds--; else clearInterval(timer) }, 1000)
})
}
const login = async () => {
uni.setStorageSync('theme', '#11559c')
state.formData.password = sm2.doEncrypt(state.password, env.smKey, 1) // 国密 SM2,模式 1
try {
const res: any = await authApi.login(state.formData)
uni.setStorageSync(accessTokenKey, res.result.accessToken)
uni.setStorageSync(refreshAccessTokenKey, res.result.refreshToken)
const u: any = await authApi.userInfo(undefined, false, false) // 第二参 cancel=false,第三参 autoCatchException=false
if (!u || u.code !== 200 || !u.result) throw new Error('获取用户信息失败')
uni.setStorageSync(currentUserKey, u.result)
uni.switchTab({ url: '/pages/home/home' })
} catch (err) {
clearAccessTokens()
uni.showToast({ title: '获取用户信息失败,登录失败', icon: 'none' })
} finally {
initCaptcha()
}
}
</script>手机号登录:
// 后端 LoginPhone:校验缓存 KeyPhoneVerCode + 手机号,不含密码
const res: any = await authApi.loginPhone({ phone: '18012345678', code: '1234', tenantId: 0 })
uni.setStorageSync(accessTokenKey, res.result.accessToken)
uni.setStorageSync(refreshAccessTokenKey, res.result.refreshToken)登录页验证码区(模板片段,expirySeconds <= 0 时盖一层「验证码过期」遮罩):
<view style="display: flex;">
<input type="number" name="code" maxlength="2" placeholder="请输入验证码" v-model="state.formData.code" />
<div class="captImg" :style="'background-image: url('+state.captchaImage+');'" @click="initCaptcha">
<div v-if="state.expirySeconds <= 0" class="captImg-expired-overlay">验证码过期</div>
</div>
</view>五、Token 存储与自动续期
存储键(utils/request.js):
export const accessTokenKey = 'access-token' // 短 Token
export const refreshAccessTokenKey = `x-access-token` // 刷新 Token
export const currentUserKey = 'current-user' // 用户信息(含 apis[])续期逻辑分三处:
// 1) 初始化时:token 已过期就改用刷新 Token 作为全局默认头
const initRequest = () => {
let accessToken = uni.getStorageSync(accessTokenKey)
if (accessToken) {
const exp = getJWTDate(decryptJWT(accessToken).exp)
if (new Date() >= exp) {
const refresh = uni.getStorageSync(refreshAccessTokenKey)
if (refresh) env.header['X-Authorization'] = `Bearer ${refresh}`
} else {
env.header['Authorization'] = `Bearer ${accessToken}`
}
}
http.setConfig((config) => { config.baseURL = env.baseUrl; return config })
requestInterceptors(); responseInterceptors()
}
// 2) 请求拦截:每次请求实时判断,过期则本次改带 X-Authorization
http.interceptors.request.use((config) => {
let accessToken = uni.getStorageSync(accessTokenKey)
if (accessToken) {
config['header'] = { Authorization: `Bearer ${accessToken}` }
if (new Date() >= getJWTDate(decryptJWT(accessToken).exp)) {
const refresh = uni.getStorageSync(refreshAccessTokenKey)
if (refresh) config['header'] = { 'X-Authorization': `Bearer ${refresh}` }
}
}
config.data = config.data || {}
return config
})// 3) 响应拦截:读 response.header['access-token' | 'x-access-token'] 回写本地
let accessToken = response.header[accessTokenKey];
let refreshAccessToken = response.header[refreshAccessTokenKey];
if (accessToken === 'invalid_token') {
clearAccessTokens();
} else if (refreshAccessToken && accessToken && accessToken !== 'invalid_token') {
uni.setStorageSync(accessTokenKey, accessToken)
uni.setStorageSync(refreshAccessTokenKey, refreshAccessToken)
}decryptJWT(token)用base-64解码 JWT 第二段,getJWTDate(ts)把秒级时间戳转 Date。- 响应头出现
access-token === 'invalid_token'时执行clearAccessTokens()(清空全部 storage)。 - 也可手动续期:
GET /api/appAuth/refreshToken?accessToken=xxx。
401 的统一处理(在响应拦截器里):
let data = response.data
if (data.code === 401) {
clearAccessTokens();
uni.redirectTo({ url: '/pages/login/login' }); // 登录页非 tabBar,用 redirectTo
}
if (data.code >= 400) toast(JSON.stringify(data.statusText))
if (data && data.hasOwnProperty('errors') && data.errors) toast(JSON.stringify(data.errors))六、租户(Tenant)
LoginInput.TenantId <= 0时后端兜底为SqlSugarConst.DefaultTenantId。- 租户被禁用 → 抛
ErrorCodeEnum.Z1003;租户不存在 →D0007。 - 租户开启验证码(
tenant.Captcha == true)时,Login会校验CodeId + Code,不填会报D0008。 - 免验证码场景:让租户的
Captcha为 false,或改用LoginPhone。
前端侧租户信息来源:env 里没有租户配置,登录时 formData 未带 tenantId(默认 0 → 后端兜底默认租户)。多租户场景需要在登录页补一个租户选择/域名反查(前端 utils/.env.js 未提供该配置,需自行扩展)。
七、单点登录与在线用户
// SysOnlineUserService.SingleLogin
if (!await _sysConfigService.GetConfigValueByCode<bool>(ConfigConst.SysSingleLogin)) return;
var users = _sysCacheService.HashGetAll<OnlineUser>(CacheConst.KeyUserOnline).Where(u => u.UserId == userId);
foreach (var user in users)
if (user.LoginMode == loginMode) await ForceOffline(user); // 只踢同模式- 开关:系统配置
SysSingleLogin。 - 在线用户缓存键:
CacheConst.KeyUserOnline。 - Token 版本缓存:
CacheConst.KeyUserToken{userId}=user.TokenVersion(JWT 内也带TokenVersion,改密/后台踢人时版本不一致即失效)。
八、退出登录
<script lang="ts" setup>
import { usesysAuthApi } from '@/api_all/sysAuth'
const authApi = usesysAuthApi()
const logout = () => {
uni.showModal({
title: '提示', content: '是否退出系统?',
success: (e) => {
if (!e.confirm) return
authApi.logout().then((res: any) => {
if (res.code == 200) {
uni.showToast({ title: '退出系统', icon: 'none' })
uni.clearStorageSync()
setTimeout(() => uni.reLaunch({ url: '/pages/login/login' }), 1000)
}
})
}
})
}
</script>后端 Logout() 只做 SignoutToSwagger()(清理 Swagger 登录态)并校验账号非空(D1011),前端必须自行清理本地 storage 并 reLaunch 到登录页。
九、按钮级权限
main.js 里 registerAuthDirectives(app) 注册了 6 条指令,数据源是本地 current-user 里的 apis 数组(GetUserInfo 返回的 Apis,来自 SysRoleService.GetUserApiList()):
| 指令 | 参数 | 命中条件 |
|---|---|---|
v-auth | string | 命中该权限 |
v-auths | string[] | 满足其一 |
v-auth-all | string[] | 全部满足 |
v-auth-else | string | 与上一个兄弟节点结果相反 |
v-auths-else | string[] | 同上(auths 版) |
v-auth-all-else | string[] | 同上(auth-all 版) |
<template>
<button v-auth="'sysuser:add'">新增用户</button>
<button v-auths="['sysuser:update','sysuser:delete']">编辑或删除</button>
<view v-auth-all="['a','b']">需同时具备</view>
<view v-auth-else="'sysuser:add'">无权限</view>
</template>实现要点(utils/authFunction.ts):
function getUserApis(): Set<string> {
const u: any = uni.getStorageSync(currentUserKey) // 'current-user'
const obj = typeof u === 'string' ? JSON.parse(u || '{}') : u
const list: Array<string> = Array.isArray(obj?.apis) ? obj.apis : []
return new Set(list.map(s => String(s || '').toLowerCase()))
}
export function auth(value: string): boolean {
if (!value || typeof value !== 'string') return false
return getUserApis().has(value.toLowerCase()) // ★ 大小写不敏感
}
// 指令用 display 控制,不删除 DOM;mounted / updated 都会重新判定
function applyDisplay(el: any, show: boolean) { el.style.display = show ? '' : 'none' }-else 变体靠读取上一个兄弟节点的 __auth_result__ / data-auth-result 属性取反,因此它必须紧跟在对应的 v-auth* 元素后面。
函数形式(在脚本里判断):
import { auth, auths, authAll } from '@/utils/authFunction'
if (!auth('sysuser:add')) return uni.showToast({ title: '无权限', icon: 'none' })十、登录态保持:本仓库没有全局路由守卫
pages.json 的 pages[0] 是 pages/login/login,因此每次冷启动都会先进登录页;仓库里只有 uni.promisify.adaptor.js 用到 uni.addInterceptor(用于 API Promise 化),没有做登录态拦截,也没有「记住登录自动进首页」的逻辑。
想加自动登录,最小改动是在 App.vue 的 onLaunch 里判断本地 Token 是否仍有效:
<script>
import { accessTokenKey, refreshAccessTokenKey } from '@/utils/request.js'
export default {
onLaunch: function() {
const accessToken = uni.getStorageSync(accessTokenKey)
const refresh = uni.getStorageSync(refreshAccessTokenKey)
if (accessToken || refresh) {
// 直接进首页;具体接口若返回 401 会由响应拦截器 redirectTo 登录页
uni.reLaunch({ url: '/pages/home/home' })
}
}
}
</script>更稳妥的做法是先静默调一次 userInfo() 验活,失败再进登录页:
import { usesysAuthApi } from '@/api_all/sysAuth'
const authApi = usesysAuthApi()
const u: any = await authApi.userInfo(undefined, false, false).catch(() => null)
if (u?.code === 200 && u.result) {
uni.setStorageSync('current-user', u.result) // 顺带刷新权限
uni.reLaunch({ url: '/pages/home/home' })
}步骤清单
- 后端:
AppAuthService已就位,确认ApplicationConst.GroupName(main)与路由前缀api。 - 前端:
utils/.env.js填好baseUrl、smKey、默认账号。 - 登录页:
captcha()拿codeId + img→sm2.doEncrypt(password, smKey, 1)→login()。 - 写本地:
access-token、x-access-token。 - 拉用户信息:
userInfo()→ 写current-user(含apis)。 - 跳转:
uni.switchTab({ url: '/pages/home/home' })。 - (可选)
App.vue的onLaunch里做 Token 验活实现免登录。 - 退出:
logout()→uni.clearStorageSync()→uni.reLaunch('/pages/login/login')。
⚠️ 注意事项与常见坑
- 密码必须 SM2 加密且模式为 1:
sm2.doEncrypt(pwd, env.smKey, 1);后端用CryptogramHelper.SM2Decrypt解密失败会抛D0010。 smKey必须与后端配对,换环境(后端重新生成密钥对)后登录必失败。- 验证码 id 是 long:
res.result.id是字符串(YitIdHelper.NextId().ToString()),直接赋给codeId即可,不要parseInt(雪花 ID 超过 JS 安全整数会丢精度)。 - 验证码会过期:
expirySeconds倒计时归零后要提示重新获取,login.vue的做法是盖一层遮罩并点击刷新。 - 密码错误次数:连续错误会计入缓存 30 分钟,超限抛
D1027;Login成功才清零。 - 401 会自动跳登录:
utils/request.js响应拦截里data.code === 401→clearAccessTokens()+redirectTo('/pages/login/login');登录页是非 tabBar 页,用redirectTo没问题,若你把登录页改成 tabBar 页要换成switchTab。 clearAccessTokens()会uni.clearStorageSync(),即清空全部本地数据(含theme、current-user),调用前确认没有需要保留的缓存。- App 与 PC 互踢是按模式隔离的,想让 App 独立互踢请走
/api/appAuth/login(LoginModeEnum.APP)。 - 小程序端登录页
navigationStyle: custom,自己做状态栏占位,否则顶部会被遮挡。 current-user可能是字符串也可能是对象:uni.getStorageSync在部分平台返回 JSON 字符串,authFunction.ts已做兼容;自己读取时也要typeof u === 'string' ? JSON.parse(u) : u。v-auth是控制display而非移除 DOM:元素仍然存在于 DOM 树中,用v-auth-else做「无权限提示」时注意布局占位。- 接口默认自动吞异常:
api.xxx(data)默认autoCatchException = true,失败只会 toast 并返回一个 pending 的 Promise(后续代码不执行)。需要try/catch时必须写成api.xxx(data, false, false)。
小结
登录的四个落地点:/api/appAuth/*(移动端服务)、LoginModeEnum.APP(会话隔离)、utils/request.js(Token 存取与续期)、current-user.apis(按钮权限)。改密走 appAuth.changePwd,退出走 logout + clearStorageSync + reLaunch;需要免登录就在 App.vue 的 onLaunch 里加一段 Token 验活。