mirror of
https://github.com/certd/certd.git
synced 2026-04-23 19:57:27 +08:00
perf: 支持passkey登录
This commit is contained in:
@@ -68,6 +68,14 @@ export default {
|
||||
|
||||
smsTab: "Login via SMS code",
|
||||
passwordTab: "Password login",
|
||||
passkeyTab: "Passkey Login",
|
||||
passkeyLogin: "Passkey Login",
|
||||
passkeyHelper: "Login with your biometric or security key",
|
||||
passkeyNotSupported: "Your browser does not support Passkey",
|
||||
passkeyRegister: "Register Passkey",
|
||||
passkeyRegistered: "Passkey Registered",
|
||||
passkeyRegisterSuccess: "Passkey registered successfully",
|
||||
passkeyRegisterFailed: "Passkey registration failed",
|
||||
title: "Change Password",
|
||||
weakPasswordWarning: "For your account security, please change your password immediately",
|
||||
changeNow: "Change Now",
|
||||
@@ -90,4 +98,7 @@ export default {
|
||||
updateProfile: "Update Profile",
|
||||
oauthLoginTitle: "Other ways of login",
|
||||
oauthOnlyLoginTitle: "Login",
|
||||
registerPasskey: "Register Passkey",
|
||||
deviceName: "Device Name",
|
||||
deviceNameHelper: "Please enter the device name, used to identify the device",
|
||||
};
|
||||
|
||||
@@ -68,6 +68,14 @@ export default {
|
||||
|
||||
smsTab: "手机号登录/注册",
|
||||
passwordTab: "密码登录",
|
||||
passkeyTab: "Passkey登录",
|
||||
passkeyLogin: "Passkey登录",
|
||||
passkeyHelper: "使用您的生物识别或安全密钥登录",
|
||||
passkeyNotSupported: "您的浏览器不支持Passkey",
|
||||
passkeyRegister: "注册Passkey",
|
||||
passkeyRegistered: "Passkey已注册",
|
||||
passkeyRegisterSuccess: "Passkey注册成功",
|
||||
passkeyRegisterFailed: "Passkey注册失败",
|
||||
|
||||
title: "修改密码",
|
||||
weakPasswordWarning: "为了您的账户安全,请立即修改密码",
|
||||
@@ -92,4 +100,7 @@ export default {
|
||||
|
||||
oauthLoginTitle: "其他登录方式",
|
||||
oauthOnlyLoginTitle: "登录",
|
||||
registerPasskey: "注册Passkey",
|
||||
deviceName: "设备名称",
|
||||
deviceNameHelper: "请输入当前设备名称,绑定多个时好做区分",
|
||||
};
|
||||
|
||||
@@ -260,7 +260,7 @@ export const certdResources = [
|
||||
meta: {
|
||||
icon: "ion:person-outline",
|
||||
auth: true,
|
||||
isMenu: false,
|
||||
isMenu: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -107,3 +107,42 @@ export async function OauthProviders() {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
export async function generatePasskeyRegistrationOptions() {
|
||||
return await request({
|
||||
url: "/passkey/generateRegistration",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPasskeyRegistration(userId: number, response: any, challenge: string) {
|
||||
return await request({
|
||||
url: "/passkey/verifyRegistration",
|
||||
method: "post",
|
||||
data: { userId, response, challenge },
|
||||
});
|
||||
}
|
||||
|
||||
export async function generatePasskeyAuthenticationOptions(userId: number) {
|
||||
return await request({
|
||||
url: "/passkey/generateAuthentication",
|
||||
method: "post",
|
||||
data: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginByPasskey(form: { userId: number; credential: any; challenge: string }) {
|
||||
return await request({
|
||||
url: "/loginByPasskey",
|
||||
method: "post",
|
||||
data: form,
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerPasskey(form: { userId: number; response: any; challenge: string }) {
|
||||
return await request({
|
||||
url: "/passkey/register",
|
||||
method: "post",
|
||||
data: form,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,6 +92,16 @@ export const useUserStore = defineStore({
|
||||
const loginRes = await UserApi.loginByTwoFactor(form);
|
||||
return await this.onLoginSuccess(loginRes);
|
||||
},
|
||||
|
||||
async loginByPasskey(form: any) {
|
||||
const loginRes = await UserApi.loginByPasskey(form);
|
||||
return await this.onLoginSuccess(loginRes);
|
||||
},
|
||||
|
||||
async registerPasskey(form: any) {
|
||||
return await UserApi.registerPasskey(form);
|
||||
},
|
||||
|
||||
async getUserInfoAction(): Promise<UserInfoRes> {
|
||||
const userInfo = await UserApi.mine();
|
||||
this.setUserInfo(userInfo);
|
||||
|
||||
@@ -55,3 +55,94 @@ export async function OauthBoundUrl(type: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GetPasskeys() {
|
||||
return await request({
|
||||
url: "/mine/passkeys",
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function UnbindPasskey(id: number) {
|
||||
return await request({
|
||||
url: "/mine/unbindPasskey",
|
||||
method: "POST",
|
||||
data: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export interface PasskeyRegistrationOptions {
|
||||
rp: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
user: {
|
||||
id: Uint8Array;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
challenge: string;
|
||||
pubKeyCredParams: {
|
||||
type: string;
|
||||
alg: number;
|
||||
}[];
|
||||
timeout: number;
|
||||
attestation: string;
|
||||
excludeCredentials: any[];
|
||||
}
|
||||
|
||||
export interface PasskeyAuthenticationOptions {
|
||||
rpId: string;
|
||||
challenge: string;
|
||||
timeout: number;
|
||||
allowCredentials: any[];
|
||||
}
|
||||
|
||||
export interface PasskeyCredential {
|
||||
id: string;
|
||||
type: string;
|
||||
rawId: string;
|
||||
response: {
|
||||
attestationObject: string;
|
||||
clientDataJSON: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function generatePasskeyRegistrationOptions() {
|
||||
return await request({
|
||||
url: "/passkey/generateRegistration",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPasskeyRegistration(userId: number, response: any, challenge: string, deviceName: string) {
|
||||
return await request({
|
||||
url: "/passkey/verifyRegistration",
|
||||
method: "post",
|
||||
data: { userId, response, challenge, deviceName },
|
||||
});
|
||||
}
|
||||
|
||||
export async function generatePasskeyAuthenticationOptions(userId: number) {
|
||||
return await request({
|
||||
url: "/passkey/generateAuthentication",
|
||||
method: "post",
|
||||
data: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginByPasskey(userId: number, credential: any, challenge: string) {
|
||||
return await request({
|
||||
url: "/passkey/login",
|
||||
method: "post",
|
||||
data: { userId, credential, challenge },
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerPasskey(userId: number, response: any, challenge: string) {
|
||||
return await request({
|
||||
url: "/passkey/register",
|
||||
method: "post",
|
||||
data: { userId, response, challenge },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,3 +108,54 @@ export function useUserProfile() {
|
||||
openEditProfileDialog,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePasskeyRegister() {
|
||||
const { openCrudFormDialog } = useFormWrapper();
|
||||
const wrapperRef = ref();
|
||||
async function openRegisterDialog(req: { onSubmit?: (ctx: any) => void }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const userStore = useUserStore();
|
||||
const deviceNameRef = ref();
|
||||
|
||||
const crudOptions: any = {
|
||||
form: {
|
||||
wrapper: {
|
||||
title: t("authentication.registerPasskey"),
|
||||
width: 500,
|
||||
onOpened(opts: { form: any }) {
|
||||
opts.form.deviceName = "";
|
||||
},
|
||||
},
|
||||
onSubmit: req.onSubmit,
|
||||
afterSubmit: null,
|
||||
onSuccess: null,
|
||||
},
|
||||
columns: {
|
||||
deviceName: {
|
||||
title: t("authentication.deviceName"),
|
||||
type: "text",
|
||||
form: {
|
||||
component: {
|
||||
class: "w-full",
|
||||
},
|
||||
col: {
|
||||
span: 24,
|
||||
},
|
||||
helper: t("authentication.deviceNameHelper"),
|
||||
rules: [{ required: true, message: t("authentication.deviceName") }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = await openCrudFormDialog({ crudOptions });
|
||||
wrapperRef.value = wrapper;
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
return {
|
||||
openRegisterDialog,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item :label="t('authentication.email')">{{ userInfo.email }}</a-descriptions-item>
|
||||
<a-descriptions-item :label="t('authentication.phoneNumber')">{{ userInfo.phoneCode }}{{ userInfo.mobile }}</a-descriptions-item>
|
||||
<a-descriptions-item label="角色">
|
||||
<fs-values-format :model-value="userInfo.roleIds" :dict="roleDict" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item v-if="settingStore.sysPublic.oauthEnabled && settingStore.isPlus" label="第三方账号绑定">
|
||||
<template v-for="item in computedOauthBounds" :key="item.name">
|
||||
<div v-if="item.addonId" class="flex items-center gap-2 mb-2">
|
||||
@@ -25,6 +28,21 @@
|
||||
</div>
|
||||
</template>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="Passkey">
|
||||
<div v-if="passkeys.length > 0" class="flex flex-col gap-2">
|
||||
<div v-for="passkey in passkeys" :key="passkey.id" class="flex items-center gap-4 p-2 border-b">
|
||||
<fs-icon icon="ion:finger-print" class="text-blue-500 fs-24" />
|
||||
<span class="w-40 truncate" :title="passkey.passkeyId">{{ passkey.deviceName }}</span>
|
||||
<span class="text-sm text-gray-500">{{ formatDate(passkey.registeredAt) }}</span>
|
||||
<a-button type="primary" danger @click="unbindPasskey(passkey.id)">解绑</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-500">暂无Passkey</div>
|
||||
<a-button v-if="passkeySupported" type="primary" class="mt-2" @click="registerPasskey">注册Passkey</a-button>
|
||||
<div v-if="!passkeySupported" class="text-red-500 text-sm mt-2">
|
||||
{{ t("authentication.passkeyNotSupported") }}
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item :label="t('common.handle')">
|
||||
<a-button type="primary" @click="doUpdate">{{ t("authentication.updateProfile") }}</a-button>
|
||||
<change-password-button class="ml-10" :show-button="true"> </change-password-button>
|
||||
@@ -40,9 +58,11 @@ import { computed, onMounted, Ref, ref } from "vue";
|
||||
import ChangePasswordButton from "/@/views/certd/mine/change-password-button.vue";
|
||||
import { useI18n } from "/src/locales";
|
||||
import { useUserProfile } from "./use";
|
||||
import { Modal } from "ant-design-vue";
|
||||
import { usePasskeyRegister } from "./use";
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
import { useSettingStore } from "/@/store/settings";
|
||||
import { isEmpty } from "lodash-es";
|
||||
import { dict } from "@fast-crud/fast-crud";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -53,11 +73,20 @@ defineOptions({
|
||||
const settingStore = useSettingStore();
|
||||
|
||||
const userInfo: Ref = ref({});
|
||||
const passkeys = ref([]);
|
||||
const passkeySupported = ref(false);
|
||||
|
||||
const getUserInfo = async () => {
|
||||
userInfo.value = await api.getMineInfo();
|
||||
};
|
||||
const roleDict = dict({
|
||||
url: "/basic/user/getSimpleRoles",
|
||||
value: "id",
|
||||
label: "name",
|
||||
});
|
||||
|
||||
const { openEditProfileDialog } = useUserProfile();
|
||||
const { openRegisterDialog } = usePasskeyRegister();
|
||||
|
||||
function doUpdate() {
|
||||
openEditProfileDialog({
|
||||
@@ -69,10 +98,12 @@ function doUpdate() {
|
||||
|
||||
const oauthBounds = ref([]);
|
||||
const oauthProviders = ref([]);
|
||||
|
||||
async function loadOauthBounds() {
|
||||
const res = await api.GetOauthBounds();
|
||||
oauthBounds.value = res;
|
||||
}
|
||||
|
||||
async function loadOauthProviders() {
|
||||
const res = await api.GetOauthProviders();
|
||||
oauthProviders.value = res;
|
||||
@@ -102,12 +133,116 @@ async function unbind(type: string) {
|
||||
}
|
||||
|
||||
async function bind(type: string) {
|
||||
//获取第三方登录URL
|
||||
const res = await api.OauthBoundUrl(type);
|
||||
const loginUrl = res.loginUrl;
|
||||
window.location.href = loginUrl;
|
||||
}
|
||||
|
||||
async function loadPasskeys() {
|
||||
try {
|
||||
const res = await api.GetPasskeys();
|
||||
passkeys.value = res;
|
||||
} catch (e: any) {
|
||||
console.error("加载Passkey失败:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function unbindPasskey(id: number) {
|
||||
Modal.confirm({
|
||||
title: "确认解绑吗?",
|
||||
okText: "确认",
|
||||
okType: "danger",
|
||||
onOk: async () => {
|
||||
await api.UnbindPasskey(id);
|
||||
await loadPasskeys();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const toBase64Url = (buffer: ArrayBuffer) => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
};
|
||||
|
||||
async function registerPasskey() {
|
||||
if (!passkeySupported.value) {
|
||||
Modal.error({ title: "错误", content: "浏览器不支持Passkey" });
|
||||
return;
|
||||
}
|
||||
await openRegisterDialog({
|
||||
onSubmit: async (ctx: any) => {
|
||||
const deviceName = ctx.form.deviceName;
|
||||
if (!deviceName) {
|
||||
return;
|
||||
}
|
||||
await doRegisterPasskey(deviceName);
|
||||
message.success("Passkey注册成功");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function doRegisterPasskey(deviceName: string) {
|
||||
try {
|
||||
const res: any = await api.generatePasskeyRegistrationOptions();
|
||||
const options = res;
|
||||
|
||||
navigator.credentials.query({
|
||||
publicKey: options,
|
||||
});
|
||||
const credential = await (navigator.credentials as any).create({
|
||||
publicKey: {
|
||||
challenge: Uint8Array.from(atob(options.challenge.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)),
|
||||
rp: options.rp,
|
||||
pubKeyCredParams: options.pubKeyCredParams,
|
||||
timeout: options.timeout || 60000,
|
||||
attestation: options.attestation,
|
||||
excludeCredentials: options.excludeCredentials || [],
|
||||
user: {
|
||||
id: Uint8Array.from([res.userId]),
|
||||
name: userInfo.value.username,
|
||||
displayName: deviceName,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Passkey注册失败");
|
||||
}
|
||||
|
||||
const response = {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: toBase64Url(credential.rawId),
|
||||
response: {
|
||||
attestationObject: toBase64Url(credential.response.attestationObject),
|
||||
clientDataJSON: toBase64Url(credential.response.clientDataJSON),
|
||||
},
|
||||
};
|
||||
|
||||
const verifyRes: any = await api.verifyPasskeyRegistration(userInfo.value.id, response, options.challenge, deviceName);
|
||||
await loadPasskeys();
|
||||
} catch (e: any) {
|
||||
console.error("Passkey注册失败:", e);
|
||||
Modal.error({ title: "错误", content: e.message || "Passkey注册失败" });
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return "";
|
||||
return new Date(dateString).toLocaleString("zh-CN");
|
||||
};
|
||||
|
||||
const checkPasskeySupport = () => {
|
||||
passkeySupported.value = false;
|
||||
if (typeof window !== "undefined" && "credentials" in navigator && "PublicKeyCredential" in window) {
|
||||
passkeySupported.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const userAvatar = computed(() => {
|
||||
if (isEmpty(userInfo.value.avatar)) {
|
||||
return "";
|
||||
@@ -123,5 +258,7 @@ onMounted(async () => {
|
||||
await getUserInfo();
|
||||
await loadOauthBounds();
|
||||
await loadOauthProviders();
|
||||
await loadPasskeys();
|
||||
checkPasskeySupport();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -46,11 +46,21 @@
|
||||
</a-form-item>
|
||||
</template>
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="passkey" :tab="t('authentication.passkeyTab')">
|
||||
<template v-if="formState.loginType === 'passkey'">
|
||||
<div v-if="!passkeySupported" class="text-red-500 text-sm mt-2">
|
||||
{{ t("authentication.passkeyNotSupported") }}
|
||||
</div>
|
||||
</template>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
<a-form-item>
|
||||
<a-button type="primary" size="large" html-type="button" :loading="loading" class="login-button" @click="handleFinish">
|
||||
<a-button v-if="formState.loginType !== 'passkey'" type="primary" size="large" html-type="button" :loading="loading" class="login-button" @click="handleFinish">
|
||||
{{ queryBindCode ? t("authentication.bindButton") : t("authentication.loginButton") }}
|
||||
</a-button>
|
||||
<a-button v-else type="primary" size="large" html-type="button" :loading="loading" class="login-button" :disabled="!passkeySupported" @click="handlePasskeyLogin">
|
||||
{{ t("authentication.passkeyLogin") }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<div class="mt-2 flex justify-between items-center">
|
||||
@@ -94,8 +104,8 @@
|
||||
</a-form>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, nextTick, reactive, ref, toRaw } from "vue";
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, reactive, ref, toRaw, onMounted } from "vue";
|
||||
import { useUserStore } from "/src/store/user";
|
||||
import { useSettingStore } from "/@/store/settings";
|
||||
import { utils } from "@fast-crud/fast-crud";
|
||||
@@ -103,193 +113,199 @@ import SmsCode from "/@/views/framework/login/sms-code.vue";
|
||||
import { useI18n } from "/@/locales";
|
||||
import { LanguageToggle } from "/@/vben/layouts";
|
||||
import CaptchaInput from "/@/components/captcha/captcha-input.vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRoute } from "vue-router";
|
||||
import OauthFooter from "/@/views/framework/oauth/oauth-footer.vue";
|
||||
import * as oauthApi from "../oauth/api";
|
||||
import { notification } from "ant-design-vue";
|
||||
export default defineComponent({
|
||||
name: "LoginPage",
|
||||
components: { LanguageToggle, SmsCode, CaptchaInput, OauthFooter },
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
import { request } from "/src/api/service";
|
||||
import * as UserApi from "/src/store/user/api.user";
|
||||
|
||||
const queryBindCode = ref(route.query.bindCode as string | undefined);
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const queryOauthOnly = route.query.oauthOnly as string;
|
||||
const urlLoginType = route.query.loginType as string | undefined;
|
||||
const verifyCodeInputRef = ref();
|
||||
const loading = ref(false);
|
||||
const queryBindCode = ref(route.query.bindCode as string | undefined);
|
||||
const queryOauthOnly = route.query.oauthOnly as string;
|
||||
const urlLoginType = route.query.loginType as string | undefined;
|
||||
const verifyCodeInputRef = ref();
|
||||
const loading = ref(false);
|
||||
|
||||
const settingStore = useSettingStore();
|
||||
const formRef = ref();
|
||||
let defaultLoginType = settingStore.sysPublic.defaultLoginType || "password";
|
||||
if (defaultLoginType === "sms") {
|
||||
if (!settingStore.sysPublic.smsLoginEnabled || !settingStore.isComm) {
|
||||
defaultLoginType = "password";
|
||||
}
|
||||
}
|
||||
const formState = reactive({
|
||||
username: "",
|
||||
phoneCode: "86",
|
||||
mobile: "",
|
||||
password: "",
|
||||
loginType: urlLoginType || defaultLoginType, //password
|
||||
smsCode: "",
|
||||
captcha: null,
|
||||
smsCaptcha: null,
|
||||
});
|
||||
const settingStore = useSettingStore();
|
||||
const formRef = ref();
|
||||
let defaultLoginType = settingStore.sysPublic.defaultLoginType || "password";
|
||||
if (defaultLoginType === "sms") {
|
||||
if (!settingStore.sysPublic.smsLoginEnabled || !settingStore.isComm) {
|
||||
defaultLoginType = "password";
|
||||
}
|
||||
}
|
||||
const formState = reactive({
|
||||
username: "",
|
||||
phoneCode: "86",
|
||||
mobile: "",
|
||||
password: "",
|
||||
loginType: urlLoginType || defaultLoginType,
|
||||
smsCode: "",
|
||||
captcha: null,
|
||||
smsCaptcha: null,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
mobile: [
|
||||
{
|
||||
required: true,
|
||||
message: "请输入手机号",
|
||||
},
|
||||
],
|
||||
username: [
|
||||
{
|
||||
required: true,
|
||||
message: "请输入用户名",
|
||||
},
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: "请输入登录密码",
|
||||
},
|
||||
],
|
||||
smsCode: [
|
||||
{
|
||||
required: true,
|
||||
message: "请输入短信验证码",
|
||||
},
|
||||
],
|
||||
captcha: [
|
||||
{
|
||||
required: true,
|
||||
message: "请进行验证码验证",
|
||||
},
|
||||
],
|
||||
};
|
||||
const layout = {
|
||||
labelCol: {
|
||||
span: 0,
|
||||
},
|
||||
wrapperCol: {
|
||||
span: 24,
|
||||
},
|
||||
};
|
||||
|
||||
async function afterLoginSuccess() {
|
||||
if (queryBindCode.value) {
|
||||
await oauthApi.BindUser(queryBindCode.value);
|
||||
notification.success({ message: "绑定第三方账号成功" });
|
||||
}
|
||||
}
|
||||
|
||||
const twoFactor = reactive({
|
||||
loginId: "",
|
||||
verifyCode: "",
|
||||
});
|
||||
|
||||
const handleTwoFactorSubmit = async () => {
|
||||
await userStore.loginByTwoFactor(twoFactor);
|
||||
afterLoginSuccess();
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// formState.captcha = await doCaptchaValidate();
|
||||
// if (!formState.captcha) {
|
||||
// return;
|
||||
// }
|
||||
const loginType = formState.loginType;
|
||||
await userStore.login(loginType, toRaw(formState));
|
||||
afterLoginSuccess();
|
||||
} catch (e: any) {
|
||||
//@ts-ignore
|
||||
if (e.code === 10020) {
|
||||
//双重认证
|
||||
//@ts-ignore
|
||||
twoFactor.loginId = e.data;
|
||||
await nextTick();
|
||||
verifyCodeInputRef.value.focus();
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
formState.captcha = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinishFailed = (errors: any) => {
|
||||
utils.logger.log(errors);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
formRef.value.resetFields();
|
||||
};
|
||||
|
||||
const isLoginError = ref();
|
||||
|
||||
const sysPublicSettings = settingStore.getSysPublic;
|
||||
|
||||
function hasRegisterTypeEnabled() {
|
||||
return sysPublicSettings.registerEnabled && (sysPublicSettings.usernameRegisterEnabled || sysPublicSettings.emailRegisterEnabled || sysPublicSettings.mobileRegisterEnabled || sysPublicSettings.smsLoginEnabled);
|
||||
}
|
||||
|
||||
const captchaInputRef = ref();
|
||||
const captchaInputForSmsCode = ref();
|
||||
|
||||
const isOauthOnly = computed(() => {
|
||||
if (queryOauthOnly === "false" || queryOauthOnly === "0") {
|
||||
return false;
|
||||
}
|
||||
return sysPublicSettings.oauthOnly && settingStore.isPlus && sysPublicSettings.oauthEnabled;
|
||||
});
|
||||
|
||||
return {
|
||||
t,
|
||||
loading,
|
||||
formState,
|
||||
formRef,
|
||||
rules,
|
||||
layout,
|
||||
isOauthOnly,
|
||||
handleFinishFailed,
|
||||
handleFinish,
|
||||
resetForm,
|
||||
isLoginError,
|
||||
sysPublicSettings,
|
||||
hasRegisterTypeEnabled,
|
||||
twoFactor,
|
||||
handleTwoFactorSubmit,
|
||||
verifyCodeInputRef,
|
||||
settingStore,
|
||||
captchaInputRef,
|
||||
captchaInputForSmsCode,
|
||||
queryBindCode,
|
||||
};
|
||||
const rules = {
|
||||
mobile: [
|
||||
{
|
||||
required: true,
|
||||
message: "请输入手机号",
|
||||
},
|
||||
],
|
||||
username: [
|
||||
{
|
||||
required: true,
|
||||
message: "请输入用户名",
|
||||
},
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: "请输入登录密码",
|
||||
},
|
||||
],
|
||||
smsCode: [
|
||||
{
|
||||
required: true,
|
||||
message: "请输入短信验证码",
|
||||
},
|
||||
],
|
||||
captcha: [
|
||||
{
|
||||
required: true,
|
||||
message: "请进行验证码验证",
|
||||
},
|
||||
],
|
||||
};
|
||||
const layout = {
|
||||
labelCol: {
|
||||
span: 0,
|
||||
},
|
||||
wrapperCol: {
|
||||
span: 24,
|
||||
},
|
||||
};
|
||||
|
||||
const twoFactor = reactive({
|
||||
loginId: "",
|
||||
verifyCode: "",
|
||||
});
|
||||
|
||||
const passkeySupported = ref(false);
|
||||
const passkeyEnabled = ref(false);
|
||||
|
||||
const checkPasskeySupport = () => {
|
||||
passkeySupported.value = false;
|
||||
if (typeof window !== "undefined" && "credentials" in navigator && "PublicKeyCredential" in window) {
|
||||
passkeySupported.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
if (!passkeySupported.value) {
|
||||
notification.error({ message: t("authentication.passkeyNotSupported") });
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const optionsResponse: any = await request({
|
||||
url: "/passkey/generateAuthentication",
|
||||
method: "post",
|
||||
});
|
||||
const options = optionsResponse;
|
||||
|
||||
const credential = await (navigator.credentials as any).get({
|
||||
publicKey: {
|
||||
challenge: Uint8Array.from(atob(options.challenge.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)),
|
||||
rpId: options.rpId,
|
||||
allowCredentials: options.allowCredentials || [],
|
||||
timeout: options.timeout || 60000,
|
||||
},
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Passkey认证失败");
|
||||
}
|
||||
|
||||
const loginRes: any = await UserApi.loginByPasskey({
|
||||
userId: optionsResponse.userId,
|
||||
credential,
|
||||
challenge: options.challenge,
|
||||
});
|
||||
|
||||
await userStore.onLoginSuccess(loginRes);
|
||||
} catch (e: any) {
|
||||
console.error("Passkey登录失败:", e);
|
||||
notification.error({ message: e.message || "Passkey登录失败" });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const loginType = formState.loginType;
|
||||
await userStore.login(loginType, toRaw(formState));
|
||||
if (queryBindCode.value) {
|
||||
await oauthApi.BindUser(queryBindCode.value);
|
||||
notification.success({ message: "绑定第三方账号成功" });
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.code === 10020) {
|
||||
twoFactor.loginId = e.data;
|
||||
await nextTick();
|
||||
verifyCodeInputRef.value.focus();
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
formState.captcha = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinishFailed = (errors: any) => {
|
||||
utils.logger.log(errors);
|
||||
};
|
||||
|
||||
const handleTwoFactorSubmit = async () => {
|
||||
await userStore.loginByTwoFactor(twoFactor);
|
||||
if (queryBindCode.value) {
|
||||
await oauthApi.BindUser(queryBindCode.value);
|
||||
notification.success({ message: "绑定第三方账号成功" });
|
||||
}
|
||||
};
|
||||
|
||||
const sysPublicSettings = settingStore.getSysPublic;
|
||||
|
||||
const hasRegisterTypeEnabled = () => {
|
||||
return sysPublicSettings.registerEnabled && (sysPublicSettings.usernameRegisterEnabled || sysPublicSettings.emailRegisterEnabled || sysPublicSettings.mobileRegisterEnabled || sysPublicSettings.smsLoginEnabled);
|
||||
};
|
||||
|
||||
const isOauthOnly = computed(() => {
|
||||
if (queryOauthOnly === "false" || queryOauthOnly === "0") {
|
||||
return false;
|
||||
}
|
||||
return sysPublicSettings.oauthOnly && settingStore.isPlus && sysPublicSettings.oauthEnabled;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
checkPasskeySupport();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.login-page.main {
|
||||
//margin: 20px !important;
|
||||
margin-bottom: 100px;
|
||||
|
||||
.user-layout-login {
|
||||
//label {
|
||||
// font-size: 14px;
|
||||
//}
|
||||
|
||||
.fs-icon {
|
||||
// color: rgba(0, 0, 0, 0.45);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
@@ -328,7 +344,6 @@ export default defineComponent({
|
||||
text-align: left;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
//line-height: 22px;
|
||||
|
||||
.item-icon {
|
||||
font-size: 24px;
|
||||
|
||||
@@ -269,7 +269,7 @@ export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOpti
|
||||
title: t("certd.roles"),
|
||||
type: "dict-select",
|
||||
dict: dict({
|
||||
url: "/sys/authority/role/list",
|
||||
url: "/basic/user/getSimpleRoles",
|
||||
value: "id",
|
||||
label: "name",
|
||||
}), // 数据字典
|
||||
|
||||
@@ -93,16 +93,16 @@ export default (req: any) => {
|
||||
// with options
|
||||
"/api": {
|
||||
//配套后端 https://github.com/fast-crud/fs-server-js
|
||||
target: "https://127.0.0.1:7002",
|
||||
target: "http://127.0.0.1:7001",
|
||||
//忽略证书
|
||||
agent: new https.Agent({ rejectUnauthorized: false }),
|
||||
// agent: new https.Agent({ rejectUnauthorized: false }),
|
||||
},
|
||||
"/certd/api": {
|
||||
//配套后端 https://github.com/fast-crud/fs-server-js
|
||||
target: "https://127.0.0.1:7002/api",
|
||||
target: "http://127.0.0.1:7001/api",
|
||||
rewrite: path => path.replace(/^\/certd\/api/, ""),
|
||||
//忽略证书
|
||||
agent: new https.Agent({ rejectUnauthorized: false }),
|
||||
// agent: new https.Agent({ rejectUnauthorized: false }),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
CREATE TABLE "sys_passkey"
|
||||
(
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"device_name" varchar(512) NOT NULL,
|
||||
"passkey_id" varchar(512) NOT NULL,
|
||||
"public_key" varchar(1024) NOT NULL,
|
||||
"counter" integer NOT NULL,
|
||||
"transports" varchar(512) NULL,
|
||||
"registered_at" integer NOT NULL,
|
||||
"create_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
"update_time" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||
);
|
||||
|
||||
|
||||
CREATE INDEX "index_passkey_user_id" ON "sys_passkey" ("user_id");
|
||||
CREATE INDEX "index_passkey_passkey_id" ON "sys_passkey" ("passkey_id");
|
||||
@@ -82,6 +82,8 @@
|
||||
"@midwayjs/upload": "3.20.13",
|
||||
"@midwayjs/validate": "3.20.13",
|
||||
"@peculiar/x509": "^1.11.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.3",
|
||||
"@ucloud-sdks/ucloud-sdk-js": "^0.2.4",
|
||||
"@volcengine/openapi": "^1.28.1",
|
||||
"ali-oss": "^6.21.0",
|
||||
|
||||
@@ -81,6 +81,23 @@ export class LoginController extends BaseController {
|
||||
return this.ok(token);
|
||||
}
|
||||
|
||||
@Post('/loginByPasskey', { summary: Constants.per.guest })
|
||||
public async loginByPasskey(
|
||||
@Body(ALL)
|
||||
body: any
|
||||
) {
|
||||
const credential = body.credential;
|
||||
const challenge = body.challenge;
|
||||
|
||||
const token = await this.loginService.loginByPasskey({
|
||||
credential,
|
||||
challenge,
|
||||
}, this.ctx);
|
||||
|
||||
// this.writeTokenCookie(token);
|
||||
return this.ok(token);
|
||||
}
|
||||
|
||||
@Post('/logout', { summary: Constants.per.authOnly })
|
||||
public logout() {
|
||||
this.ctx.cookies.set("certd_token", "", {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ALL, Body, Controller, Inject, Post, Provide, RequestIP } from "@midwayjs/core";
|
||||
import { PasskeyService } from "../../../modules/login/service/passkey-service.js";
|
||||
import { BaseController, Constants } from "@certd/lib-server";
|
||||
import { UserService } from "../../../modules/sys/authority/service/user-service.js";
|
||||
|
||||
@Provide()
|
||||
@Controller('/api/passkey')
|
||||
export class PasskeyController extends BaseController {
|
||||
@Inject()
|
||||
passkeyService: PasskeyService;
|
||||
|
||||
@Inject()
|
||||
userService: UserService;
|
||||
|
||||
@Post('/generateRegistration', { summary: Constants.per.authOnly })
|
||||
public async generateRegistration(
|
||||
@Body(ALL)
|
||||
body: any,
|
||||
@RequestIP()
|
||||
remoteIp: string
|
||||
) {
|
||||
const userId = this.getUserId()
|
||||
const user = await this.userService.info(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
const options = await this.passkeyService.generateRegistrationOptions(
|
||||
userId,
|
||||
user.username,
|
||||
remoteIp,
|
||||
this.ctx
|
||||
);
|
||||
|
||||
return this.ok({
|
||||
...options,
|
||||
userId
|
||||
});
|
||||
}
|
||||
|
||||
@Post('/verifyRegistration', { summary: Constants.per.guest })
|
||||
public async verifyRegistration(
|
||||
@Body(ALL)
|
||||
body: any
|
||||
) {
|
||||
const userId = body.userId;
|
||||
const response = body.response;
|
||||
const challenge = body.challenge;
|
||||
const deviceName = body.deviceName;
|
||||
|
||||
const result = await this.passkeyService.registerPasskey(
|
||||
userId,
|
||||
response,
|
||||
challenge,
|
||||
deviceName,
|
||||
this.ctx
|
||||
);
|
||||
|
||||
return this.ok(result);
|
||||
}
|
||||
|
||||
@Post('/generateAuthentication', { summary: Constants.per.guest })
|
||||
public async generateAuthentication(
|
||||
@Body(ALL)
|
||||
body: any
|
||||
) {
|
||||
const options = await this.passkeyService.generateAuthenticationOptions(
|
||||
this.ctx
|
||||
);
|
||||
|
||||
return this.ok({
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Post('/register', { summary: Constants.per.guest })
|
||||
public async registerPasskey(
|
||||
@Body(ALL)
|
||||
body: any
|
||||
) {
|
||||
const userId = body.userId;
|
||||
const response = body.response;
|
||||
const deviceName = body.deviceName;
|
||||
const challenge = body.challenge;
|
||||
|
||||
const result = await this.passkeyService.registerPasskey(
|
||||
userId,
|
||||
response,
|
||||
challenge,
|
||||
deviceName,
|
||||
this.ctx
|
||||
);
|
||||
|
||||
return this.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { In } from 'typeorm';
|
||||
import { AuthService } from '../../../modules/sys/authority/service/auth-service.js';
|
||||
import { UserService } from '../../../modules/sys/authority/service/user-service.js';
|
||||
import { BasicController } from '../../basic/code-controller.js';
|
||||
import { RoleService } from '../../../modules/sys/authority/service/role-service.js';
|
||||
|
||||
/**
|
||||
* 通知
|
||||
@@ -15,6 +16,8 @@ export class BasicUserController extends BasicController {
|
||||
service: UserService;
|
||||
@Inject()
|
||||
authService: AuthService;
|
||||
@Inject()
|
||||
roleService: RoleService;
|
||||
|
||||
getService(): UserService {
|
||||
return this.service;
|
||||
@@ -57,4 +60,15 @@ export class BasicUserController extends BasicController {
|
||||
return this.ok(users);
|
||||
}
|
||||
|
||||
@Post('/getSimpleRoles', {summary: Constants.per.authOnly})
|
||||
async getSimpleRoles() {
|
||||
const roles = await this.roleService.find({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
return this.ok(roles);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ALL, Body, Controller, Inject, Post, Provide } from '@midwayjs/core';
|
||||
import { BaseController, Constants } from '@certd/lib-server';
|
||||
import { UserService } from '../../../modules/sys/authority/service/user-service.js';
|
||||
import { ALL, Body, Controller, Inject, Post, Provide } from '@midwayjs/core';
|
||||
import { PasskeyService } from '../../../modules/login/service/passkey-service.js';
|
||||
import { RoleService } from '../../../modules/sys/authority/service/role-service.js';
|
||||
import { UserService } from '../../../modules/sys/authority/service/user-service.js';
|
||||
|
||||
/**
|
||||
*/
|
||||
@@ -10,8 +11,14 @@ import { RoleService } from '../../../modules/sys/authority/service/role-service
|
||||
export class MineController extends BaseController {
|
||||
@Inject()
|
||||
userService: UserService;
|
||||
|
||||
@Inject()
|
||||
roleService: RoleService;
|
||||
|
||||
@Inject()
|
||||
passkeyService: PasskeyService;
|
||||
|
||||
|
||||
@Post('/info', { summary: Constants.per.authOnly })
|
||||
public async info() {
|
||||
const userId = this.getUserId();
|
||||
@@ -43,4 +50,30 @@ export class MineController extends BaseController {
|
||||
});
|
||||
return this.ok({});
|
||||
}
|
||||
|
||||
@Post('/passkeys', { summary: Constants.per.authOnly })
|
||||
public async getPasskeys() {
|
||||
const userId = this.getUserId();
|
||||
const passkeys = await this.passkeyService.find({
|
||||
select: ['id', 'deviceName', 'registeredAt'],
|
||||
where: { userId }});
|
||||
return this.ok(passkeys);
|
||||
}
|
||||
|
||||
@Post('/unbindPasskey', { summary: Constants.per.authOnly })
|
||||
public async unbindPasskey(@Body(ALL) body: any) {
|
||||
const userId = this.getUserId();
|
||||
const passkeyId = body.id;
|
||||
|
||||
const passkey = await this.passkeyService.findOne({
|
||||
where: { id: passkeyId, userId },
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
throw new Error('Passkey不存在');
|
||||
}
|
||||
|
||||
await this.passkeyService.delete([passkey.id]);
|
||||
return this.ok({});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
@Entity('sys_passkey')
|
||||
export class PasskeyEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'user_id', comment: '用户id' })
|
||||
userId: number;
|
||||
|
||||
@Column({ name: 'device_name', comment: '设备名称' })
|
||||
deviceName: string;
|
||||
|
||||
@Column({ name: 'passkey_id', comment: 'passkey_id' })
|
||||
passkeyId: string;
|
||||
|
||||
@Column({ name: 'public_key', comment: '公钥', type: 'text' })
|
||||
publicKey: string;
|
||||
|
||||
@Column({ name: 'counter', comment: '计数器' })
|
||||
counter: number;
|
||||
|
||||
@Column({ name: 'transports', comment: '传输方式', type: 'text', nullable: true })
|
||||
transports: string;
|
||||
|
||||
@Column({ name: 'registered_at', comment: '注册时间' })
|
||||
registeredAt: number;
|
||||
|
||||
@Column({ name: 'create_time', comment: '创建时间', default: () => 'CURRENT_TIMESTAMP' })
|
||||
createTime: Date;
|
||||
|
||||
@Column({ name: 'update_time', comment: '修改时间', default: () => 'CURRENT_TIMESTAMP' })
|
||||
updateTime: Date;
|
||||
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { UserSettingsService } from "../../mine/service/user-settings-service.js
|
||||
import { isPlus } from "@certd/plus-core";
|
||||
import { AddonService } from "@certd/lib-server";
|
||||
import { OauthBoundService } from "./oauth-bound-service.js";
|
||||
import { PasskeyService } from "./passkey-service.js";
|
||||
|
||||
/**
|
||||
*/
|
||||
@@ -45,6 +46,9 @@ export class LoginService {
|
||||
@Inject()
|
||||
oauthBoundService: OauthBoundService;
|
||||
|
||||
@Inject()
|
||||
passkeyService: PasskeyService;
|
||||
|
||||
checkIsBlocked(username: string) {
|
||||
const blockDurationKey = `login_block_duration:${username}`;
|
||||
const value = cache.get(blockDurationKey);
|
||||
@@ -254,4 +258,10 @@ export class LoginService {
|
||||
}
|
||||
return this.generateToken(info);
|
||||
}
|
||||
}
|
||||
|
||||
async loginByPasskey(req: { credential: any; challenge: string }, ctx: any) {
|
||||
const {credential, challenge} = req;
|
||||
const user = await this.passkeyService.loginByPasskey(credential, challenge, ctx);
|
||||
return this.generateToken(user);
|
||||
}}
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { cache } from "@certd/basic";
|
||||
import { AuthException, BaseService } from "@certd/lib-server";
|
||||
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { UserService } from "../../sys/authority/service/user-service.js";
|
||||
import { PasskeyEntity } from "../entity/passkey.js";
|
||||
import { Repository } from "typeorm";
|
||||
import { InjectEntityModel } from "@midwayjs/typeorm";
|
||||
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class PasskeyService extends BaseService<PasskeyEntity> {
|
||||
|
||||
@Inject()
|
||||
userService: UserService;
|
||||
|
||||
@InjectEntityModel(PasskeyEntity)
|
||||
repository: Repository<PasskeyEntity>;
|
||||
|
||||
getRepository(): Repository<PasskeyEntity> {
|
||||
return this.repository;
|
||||
}
|
||||
async generateRegistrationOptions(userId: number, username: string, remoteIp: string, ctx: any) {
|
||||
const { generateRegistrationOptions } = await import("@simplewebauthn/server");
|
||||
const user = await this.userService.info(userId);
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: "Certd",
|
||||
rpID: this.getRpId(ctx),
|
||||
userID: new Uint8Array([userId]),
|
||||
userName: username,
|
||||
userDisplayName: user.nickName || username,
|
||||
timeout: 60000,
|
||||
attestationType: "none",
|
||||
excludeCredentials: [],
|
||||
});
|
||||
|
||||
cache.set(`passkey:registration:${options.challenge}`, userId, {
|
||||
ttl: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyRegistrationResponse(
|
||||
userId: number,
|
||||
response: any,
|
||||
challenge: string,
|
||||
ctx: any
|
||||
) {
|
||||
const { verifyRegistrationResponse } = await import("@simplewebauthn/server");
|
||||
|
||||
const storedUserId = cache.get(`passkey:registration:${challenge}`);
|
||||
if (!storedUserId || storedUserId !== userId) {
|
||||
throw new AuthException("注册验证失败");
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: this.getOrigin(ctx),
|
||||
expectedRPID: this.getRpId(ctx),
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new AuthException("注册验证失败");
|
||||
}
|
||||
|
||||
cache.delete(`passkey:registration:${challenge}`);
|
||||
|
||||
return {
|
||||
credentialId: verification.registrationInfo.credential.id,
|
||||
credentialPublicKey: verification.registrationInfo.credential.publicKey,
|
||||
counter: verification.registrationInfo.credential.counter,
|
||||
};
|
||||
}
|
||||
|
||||
async generateAuthenticationOptions(ctx: any) {
|
||||
const { generateAuthenticationOptions } = await import("@simplewebauthn/server");
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: this.getRpId(ctx),
|
||||
timeout: 60000,
|
||||
allowCredentials: [],
|
||||
});
|
||||
|
||||
// cache.set(`passkey:authentication:${options.challenge}`, userId, {
|
||||
// ttl: 5 * 60 * 1000,
|
||||
// });
|
||||
|
||||
return {
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyAuthenticationResponse(
|
||||
credential: any,
|
||||
challenge: string,
|
||||
ctx: any
|
||||
) {
|
||||
const { verifyAuthenticationResponse } = await import("@simplewebauthn/server");
|
||||
|
||||
const passkey = await this.repository.findOne({
|
||||
where: {
|
||||
passkeyId: credential.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
throw new AuthException("Passkey不存在");
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response:credential,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: this.getOrigin(ctx),
|
||||
expectedRPID: this.getRpId(ctx),
|
||||
credential: {
|
||||
id: passkey.passkeyId,
|
||||
publicKey: new Uint8Array(Buffer.from(passkey.publicKey, 'base64')),
|
||||
counter: passkey.counter,
|
||||
transports: passkey.transports as any,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new AuthException("认证验证失败");
|
||||
}
|
||||
|
||||
cache.delete(`passkey:authentication:${challenge}`);
|
||||
|
||||
return {
|
||||
credentialId: verification.authenticationInfo.credentialID,
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
userId: passkey.userId,
|
||||
};
|
||||
}
|
||||
|
||||
async registerPasskey(
|
||||
userId: number,
|
||||
response: any,
|
||||
challenge: string,
|
||||
deviceName: string,
|
||||
ctx: any
|
||||
) {
|
||||
const verification = await this.verifyRegistrationResponse(
|
||||
userId,
|
||||
response,
|
||||
challenge,
|
||||
ctx
|
||||
);
|
||||
|
||||
await this.add({
|
||||
userId,
|
||||
passkeyId: verification.credentialId,
|
||||
publicKey: Buffer.from(verification.credentialPublicKey).toString('base64'),
|
||||
counter: verification.counter,
|
||||
deviceName,
|
||||
registeredAt: Date.now(),
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async loginByPasskey( credential: any, challenge: string, ctx: any) {
|
||||
const verification = await this.verifyAuthenticationResponse(
|
||||
credential,
|
||||
challenge,
|
||||
ctx
|
||||
);
|
||||
|
||||
const passkey = await this.repository.findOne({
|
||||
where: {
|
||||
passkeyId: verification.credentialId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!passkey) {
|
||||
throw new AuthException("Passkey不存在");
|
||||
}
|
||||
|
||||
if (verification.counter <= passkey.counter) {
|
||||
throw new AuthException("认证失败:计数器异常");
|
||||
}
|
||||
|
||||
passkey.counter = verification.counter;
|
||||
await this.repository.save(passkey);
|
||||
|
||||
const user = await this.userService.info(passkey.userId);
|
||||
return user;
|
||||
}
|
||||
|
||||
private getRpId(ctx: any): string {
|
||||
if (ctx && ctx.request && ctx.request.host) {
|
||||
return ctx.request.host.split(':')[0];
|
||||
}
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
private getOrigin(ctx: any): string {
|
||||
if (ctx && ctx.request) {
|
||||
const protocol = ctx.request.protocol;
|
||||
const host = ctx.request.host;
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
return 'https://localhost';
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export const AdminRoleId = 1
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
export class UserService extends BaseService<UserEntity> {
|
||||
|
||||
|
||||
@InjectEntityModel(UserEntity)
|
||||
repository: Repository<UserEntity>;
|
||||
@@ -278,6 +279,10 @@ export class UserService extends BaseService<UserEntity> {
|
||||
return user.username;
|
||||
}
|
||||
|
||||
async getByUsername(username: any) {
|
||||
return await this.findOne({ username });
|
||||
}
|
||||
|
||||
async changePassword(userId: any, form: any) {
|
||||
const user = await this.info(userId);
|
||||
const passwordChecked = await this.checkPassword(form.password, user.password, user.passwordVersion);
|
||||
|
||||
Reference in New Issue
Block a user