perf: 登录支持双重认证

pull/409/head
xiaojunnuo 2025-04-17 22:34:21 +08:00
parent 8e50e5dee3
commit 48aef25b3f
16 changed files with 132 additions and 55 deletions

View File

@ -1,5 +1,6 @@
import { Constants } from '../constants.js'; import { Constants } from '../constants.js';
import { BaseException } from './base-exception.js'; import { BaseException } from './base-exception.js';
import { TextException } from "./common-exception.js";
/** /**
* *
*/ */
@ -10,9 +11,9 @@ export class AuthException extends BaseException {
} }
export class Need2FAException extends BaseException { export class Need2FAException extends TextException {
constructor(message?:string) { constructor(message:string,data:any) {
super('Need2FAException', Constants.res.need2fa.code, message ? message : Constants.res.need2fa.message); super('Need2FAException', Constants.res.need2fa.code, message ? message : Constants.res.need2fa.message,data);
} }
} }

View File

@ -3,9 +3,11 @@
*/ */
export class BaseException extends Error { export class BaseException extends Error {
code: number; code: number;
constructor(name, code, message) { data?:any
constructor(name, code, message,data?:any) {
super(message); super(message);
this.name = name; this.name = name;
this.code = code; this.code = code;
this.data = data;
} }
} }

View File

@ -1,16 +1,23 @@
import { Constants } from '../constants.js'; import { Constants } from "../constants.js";
import { BaseException } from './base-exception.js'; import { BaseException } from "./base-exception.js";
/** /**
* *
*/ */
export class CommonException extends BaseException { export class CommonException extends BaseException {
constructor(message) { constructor(message) {
super('CommonException', Constants.res.error.code, message ? message : Constants.res.error.message); super("CommonException", Constants.res.error.code, message ? message : Constants.res.error.message);
} }
} }
export class CodeException extends BaseException { export class CodeException extends BaseException {
constructor(res: { code: number; message: string }) { constructor(res: { code: number; message: string }) {
super('CodeException', res.code, res.message); super("CodeException", res.code, res.message);
}
}
export class TextException extends BaseException {
constructor(name, code,message, data?) {
super(name, code, message, data);
} }
} }

View File

@ -2,14 +2,15 @@ export class Result<T> {
code: number; code: number;
msg: string; msg: string;
data: T; data: T;
constructor(code, msg, data?) { constructor(code, msg, data?) {
this.code = code; this.code = code;
this.msg = msg; this.msg = msg;
this.data = data; this.data = data;
} }
static error(code = 1, msg) { static error(code = 1, msg, data?: any) {
return new Result(code, msg); return new Result(code, msg, data);
} }
static success(msg, data?) { static success(msg, data?) {

View File

@ -3,6 +3,17 @@ import { get } from "lodash-es";
import { errorLog, errorCreate } from "./tools"; import { errorLog, errorCreate } from "./tools";
import { env } from "/src/utils/util.env"; import { env } from "/src/utils/util.env";
import { useUserStore } from "/@/store/user"; import { useUserStore } from "/@/store/user";
export class CodeError extends Error {
code: number;
data?: any;
constructor(message: string, code: number, data?: any) {
super(message);
this.code = code;
this.data = data;
}
}
/** /**
* @description * @description
*/ */
@ -56,12 +67,13 @@ function createService() {
const errorMessage = dataAxios.msg || dataAxios.message || "未知错误"; const errorMessage = dataAxios.msg || dataAxios.message || "未知错误";
// @ts-ignore // @ts-ignore
if (response?.config?.onError) { if (response?.config?.onError) {
// @ts-ignore const err = new CodeError(errorMessage, dataAxios.code, dataAxios.data);
response.config.onError(new Error(errorMessage)); response.config.onError(err);
return;
} }
//@ts-ignore //@ts-ignore
const showErrorNotify = response?.config?.showErrorNotify; const showErrorNotify = response?.config?.showErrorNotify;
errorCreate(`${errorMessage}: ${response.config.url}`, showErrorNotify); errorCreate(`${errorMessage}: ${response.config.url}`, showErrorNotify, dataAxios);
return dataAxios; return dataAxios;
} }
} }

View File

@ -4,6 +4,7 @@
* @param {String} defaultValue * @param {String} defaultValue
*/ */
import { uiContext } from "@fast-crud/fast-crud"; import { uiContext } from "@fast-crud/fast-crud";
import { CodeError } from "/@/api/service";
export function parse(jsonString = "{}", defaultValue = {}) { export function parse(jsonString = "{}", defaultValue = {}) {
let result = defaultValue; let result = defaultValue;
@ -68,8 +69,8 @@ export function errorLog(error: any, notify = true) {
* @description * @description
* @param {String} msg * @param {String} msg
*/ */
export function errorCreate(msg: string, notify = true) { export function errorCreate(msg: string, notify = true, data?: any) {
const err = new Error(msg); const err = new CodeError(msg, data.code, data.data);
console.error("errorCreate", err); console.error("errorCreate", err);
if (notify) { if (notify) {
uiContext.get().notification.error({ message: err.message }); uiContext.get().notification.error({ message: err.message });

View File

@ -149,7 +149,7 @@ export const certdResources = [
path: "/certd/mine/security", path: "/certd/mine/security",
component: "/certd/mine/security/index.vue", component: "/certd/mine/security/index.vue",
meta: { meta: {
icon: "ion:locked-outline", icon: "fluent:shield-keyhole-16-regular",
auth: true, auth: true,
isMenu: true, isMenu: true,
}, },

View File

@ -66,3 +66,11 @@ export async function mine(): Promise<UserInfoRes> {
method: "post", method: "post",
}); });
} }
export async function loginByTwoFactor(data: any) {
return await request({
url: "/loginByTwoFactor",
method: "post",
data,
});
}

View File

@ -51,7 +51,7 @@ export const useUserStore = defineStore({
setUserInfo(info: UserInfoRes) { setUserInfo(info: UserInfoRes) {
this.userInfo = info; this.userInfo = info;
const userStore = vbenUserStore(); const userStore = vbenUserStore();
userStore.setUserInfo(info); userStore.setUserInfo(info as any);
LocalStorage.set(USER_INFO_KEY, info); LocalStorage.set(USER_INFO_KEY, info);
}, },
resetState() { resetState() {
@ -71,23 +71,18 @@ export const useUserStore = defineStore({
* @description: login * @description: login
*/ */
async login(loginType: string, params: LoginReq | SmsLoginReq): Promise<any> { async login(loginType: string, params: LoginReq | SmsLoginReq): Promise<any> {
try {
let loginRes: any = null; let loginRes: any = null;
if (loginType === "sms") { if (loginType === "sms") {
loginRes = await UserApi.loginBySms(params as SmsLoginReq); loginRes = await UserApi.loginBySms(params as SmsLoginReq);
} else { } else {
loginRes = await UserApi.login(params as LoginReq); loginRes = await UserApi.login(params as LoginReq);
} }
const { token, expire } = loginRes;
// save token
this.setToken(token, expire);
// get user info
return await this.onLoginSuccess(loginRes); return await this.onLoginSuccess(loginRes);
} catch (error) { },
console.error(error);
return null; async loginByTwoFactor(form: any) {
} const loginRes = await UserApi.loginByTwoFactor(form);
return await this.onLoginSuccess(loginRes);
}, },
async getUserInfoAction(): Promise<UserInfoRes> { async getUserInfoAction(): Promise<UserInfoRes> {
const userInfo = await UserApi.mine(); const userInfo = await UserApi.mine();
@ -100,9 +95,13 @@ export const useUserStore = defineStore({
}, },
async onLoginSuccess(loginData: any) { async onLoginSuccess(loginData: any) {
const { token, expire } = loginData;
// save token
this.setToken(token, expire);
// get user info
// await this.getUserInfoAction(); // await this.getUserInfoAction();
// const userInfo = await this.getUserInfoAction(); // const userInfo = await this.getUserInfoAction();
mitter.emit("app.login", { token: loginData }); mitter.emit("app.login", { ...loginData });
await router.replace("/"); await router.replace("/");
}, },

View File

@ -9,7 +9,14 @@
<div class="flex mt-5"> <div class="flex mt-5">
<a-switch v-model:checked="formState.authenticator.enabled" :disabled="!settingsStore.isPlus" @change="onAuthenticatorEnabledChanged" /> <a-switch v-model:checked="formState.authenticator.enabled" :disabled="!settingsStore.isPlus" @change="onAuthenticatorEnabledChanged" />
<a-button v-if="formState.authenticator.enabled && formState.authenticator.verified" :disabled="authenticatorOpenRef" size="small" class="ml-5" type="primary" @click="authenticatorForm.open = true"> <a-button
v-if="formState.authenticator.enabled && formState.authenticator.verified"
:disabled="authenticatorOpenRef || !settingsStore.isPlus"
size="small"
class="ml-5"
type="primary"
@click="authenticatorForm.open = true"
>
重新绑定 重新绑定
</a-button> </a-button>
@ -53,7 +60,12 @@ defineOptions({
name: "UserSecurity", name: "UserSecurity",
}); });
const formState = reactive<Partial<UserTwoFactorSetting>>({}); const formState = reactive<Partial<UserTwoFactorSetting>>({
authenticator: {
enabled: false,
verified: false,
},
});
const authenticatorForm = reactive({ const authenticatorForm = reactive({
qrcodeSrc: "", qrcodeSrc: "",

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="main login-page"> <div class="main login-page">
<a-form ref="formRef" class="user-layout-login" name="custom-validation" :model="formState" v-bind="layout" @finish="handleFinish" @finish-failed="handleFinishFailed"> <a-form v-if="!twoFactor.loginId" ref="formRef" class="user-layout-login" name="custom-validation" :model="formState" v-bind="layout" @finish="handleFinish" @finish-failed="handleFinishFailed">
<!-- <div class="login-title">登录</div>--> <!-- <div class="login-title">登录</div>-->
<a-tabs v-model:active-key="formState.loginType" :tab-bar-style="{ textAlign: 'center', borderBottom: 'unset' }"> <a-tabs v-model:active-key="formState.loginType" :tab-bar-style="{ textAlign: 'center', borderBottom: 'unset' }">
<a-tab-pane key="password" tab="密码登录" :disabled="sysPublicSettings.passwordLoginEnabled !== true"> <a-tab-pane key="password" tab="密码登录" :disabled="sysPublicSettings.passwordLoginEnabled !== true">
@ -49,6 +49,23 @@
<router-link v-if="hasRegisterTypeEnabled()" class="register" :to="{ name: 'register' }"> </router-link> <router-link v-if="hasRegisterTypeEnabled()" class="register" :to="{ name: 'register' }"> </router-link>
</a-form-item> </a-form-item>
</a-form> </a-form>
<a-form v-else ref="twoFactorFormRef" class="user-layout-login" :model="twoFactor" v-bind="layout">
<div class="mb-10 flex flex-center">请打开您的Authenticator APP获取动态验证码</div>
<a-form-item name="verifyCode">
<a-input v-model:value="twoFactor.verifyCode" placeholder="请输入动态验证码" allow-clear>
<template #prefix>
<fs-icon icon="ion:lock-closed-outline"></fs-icon>
</template>
</a-input>
</a-form-item>
<a-form-item>
<loading-button type="primary" size="large" html-type="button" class="login-button" :click="handleTwoFactorSubmit">OTP验证登录</loading-button>
</a-form-item>
<a-form-item class="user-login-other">
<a class="register" @click="twoFactor.loginId = null"> 返回 </a>
</a-form-item>
</a-form>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -113,11 +130,28 @@ export default defineComponent({
}, },
}; };
const twoFactor = reactive({
loginId: "",
verifyCode: "",
});
const handleTwoFactorSubmit = async () => {
await userStore.loginByTwoFactor(twoFactor);
};
const handleFinish = async (values: any) => { const handleFinish = async (values: any) => {
loading.value = true; loading.value = true;
try { try {
const loginType = formState.loginType; const loginType = formState.loginType;
await userStore.login(loginType, toRaw(formState)); await userStore.login(loginType, toRaw(formState));
} catch (e: any) {
//@ts-ignore
if (e.code === 10020) {
//@ts-ignore
twoFactor.loginId = e.data;
} else {
throw e;
}
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -150,6 +184,8 @@ export default defineComponent({
isLoginError, isLoginError,
sysPublicSettings, sysPublicSettings,
hasRegisterTypeEnabled, hasRegisterTypeEnabled,
twoFactor,
handleTwoFactorSubmit,
}; };
}, },
}); });

View File

@ -4,8 +4,7 @@
<h2>站点隐藏</h2> <h2>站点隐藏</h2>
<a-form-item label="启用站点隐藏" :name="['hidden', 'enabled']" :required="true"> <a-form-item label="启用站点隐藏" :name="['hidden', 'enabled']" :required="true">
<div class="flex"> <div class="flex">
<a-switch v-model:checked="formState.hidden.enabled" :disabled="!settingsStore.isPlus" /> <a-switch v-model:checked="formState.hidden.enabled" />
<vip-button class="ml-5" mode="button"></vip-button>
</div> </div>
<div class="helper"> <div class="helper">

View File

@ -1,8 +1,7 @@
import {ALL, Body, Controller, Inject, Post, Provide} from '@midwayjs/core'; import { ALL, Body, Controller, Inject, Post, Provide } from "@midwayjs/core";
import {BaseController, SysSafeSetting} from '@certd/lib-server'; import { BaseController, SysSafeSetting } from "@certd/lib-server";
import {cloneDeep} from 'lodash-es'; import { cloneDeep } from "lodash-es";
import { SafeService } from "../../../modules/sys/settings/safe-service.js"; import { SafeService } from "../../../modules/sys/settings/safe-service.js";
import {isPlus} from "@certd/plus-core";
/** /**
@ -25,9 +24,6 @@ export class SysSettingsController extends BaseController {
@Post("/save", { summary: "sys:settings:edit" }) @Post("/save", { summary: "sys:settings:edit" })
async safeSave(@Body(ALL) body: any) { async safeSave(@Body(ALL) body: any) {
if (!isPlus()) {
throw new Error('本功能需要开通专业版')
}
await this.safeService.saveSafeSetting(body); await this.safeService.saveSafeSetting(body);
return this.ok({}); return this.ok({});
} }

View File

@ -63,7 +63,7 @@ export class LoginController extends BaseController {
) { ) {
const token = await this.loginService.loginByTwoFactor({ const token = await this.loginService.loginByTwoFactor({
loginCode: body.loginCode, loginId: body.loginId,
verifyCode: body.verifyCode, verifyCode: body.verifyCode,
}); });

View File

@ -1,7 +1,7 @@
import { Provide } from '@midwayjs/core'; import { Provide } from '@midwayjs/core';
import { IMidwayKoaContext, IWebMiddleware, NextFunction } from '@midwayjs/koa'; import { IMidwayKoaContext, IWebMiddleware, NextFunction } from '@midwayjs/koa';
import { logger } from '@certd/basic'; import { logger } from '@certd/basic';
import { Result } from '@certd/lib-server'; import { Result, TextException } from "@certd/lib-server";
@Provide() @Provide()
export class GlobalExceptionMiddleware implements IWebMiddleware { export class GlobalExceptionMiddleware implements IWebMiddleware {
@ -14,12 +14,15 @@ export class GlobalExceptionMiddleware implements IWebMiddleware {
await next(); await next();
logger.info('请求完成:', url, Date.now() - startTime + 'ms'); logger.info('请求完成:', url, Date.now() - startTime + 'ms');
} catch (err) { } catch (err) {
if(err instanceof TextException){
delete err.stack
}
logger.error('请求异常:', url, Date.now() - startTime + 'ms', err); logger.error('请求异常:', url, Date.now() - startTime + 'ms', err);
ctx.status = 200; ctx.status = 200;
if (err.code == null || typeof err.code !== 'number') { if (err.code == null || typeof err.code !== 'number') {
err.code = 1; err.code = 1;
} }
ctx.body = Result.error(err.code, err.message); ctx.body = Result.error(err.code, err.message,err.data);
} }
}; };
} }

View File

@ -158,21 +158,21 @@ export class LoginService {
//要检查 //要检查
const randomKey = utils.id.simpleNanoId(12) const randomKey = utils.id.simpleNanoId(12)
cache.set(`login_2fa_code:${randomKey}`, userId, { cache.set(`login_2fa_code:${randomKey}`, userId, {
ttl: 60 * 1000, ttl: 60 * 1000 * 2,
}) })
throw new Need2FAException('已开启多重认证,请在60秒内输入验证码') throw new Need2FAException('已开启多重认证,请在2分钟内输入OPT验证码',randomKey)
} }
} }
async loginByTwoFactor(req: { loginCode: string; verifyCode: string }) { async loginByTwoFactor(req: { loginId: string; verifyCode: string }) {
//检查是否开启多重认证 //检查是否开启多重认证
if (!isPlus()) { if (!isPlus()) {
throw new Error('本功能需要开通专业版') throw new Error('本功能需要开通专业版')
} }
const userId = cache.get(`login_2fa_code:${req.loginCode}`) const userId = cache.get(`login_2fa_code:${req.loginId}`)
if (!userId) { if (!userId) {
throw new AuthException('登录状态已失效,请重新登录') throw new AuthException('已超时,请返回重新登录')
} }
await this.twoFactorService.verifyAuthenticatorCode(userId, req.verifyCode) await this.twoFactorService.verifyAuthenticatorCode(userId, req.verifyCode)