perf: 支持passkey登录

This commit is contained in:
xiaojunnuo
2026-03-12 18:11:02 +08:00
parent 2c399a078e
commit 10b7644bb7
22 changed files with 1222 additions and 246 deletions
@@ -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",
}), // 数据字典
+4 -4
View File
@@ -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");
+2
View File
@@ -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);