feat: add passkey (#384)

This commit is contained in:
Dream Hunter
2024-08-10 23:56:05 +08:00
committed by GitHub
parent 9246550cc5
commit fc07f1cd87
17 changed files with 639 additions and 15 deletions
+7
View File
@@ -3,10 +3,17 @@
## main(v0.7.0) ## main(v0.7.0)
### Breaking Changes
DB changes: 增加用户 `passkey` 表, 需要执行 `db/2024-08-10-patch.sql` 更新 `D1` 数据库
### Changes
- Docs: Update new-address-api.md (#360) - Docs: Update new-address-api.md (#360)
- feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363) - feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363)
- feat: worker 增加 `DISABLE_SHOW_GITHUB` 配置, 用于配置是否显示 github 链接 - feat: worker 增加 `DISABLE_SHOW_GITHUB` 配置, 用于配置是否显示 github 链接
- feat: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 用于配置可以无限发送邮件的角色 - feat: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 用于配置可以无限发送邮件的角色
- feat: 用户增加 `passkey` 登录方式, 用于用户登录, 无需输入密码
## v0.6.1 ## v0.6.1
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS user_passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
passkey_name TEXT NOT NULL,
passkey_id TEXT NOT NULL,
passkey TEXT NOT NULL,
counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
+15
View File
@@ -88,3 +88,18 @@ CREATE TABLE IF NOT EXISTS user_roles (
); );
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE TABLE IF NOT EXISTS user_passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
passkey_name TEXT NOT NULL,
passkey_id TEXT NOT NULL,
passkey TEXT NOT NULL,
counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
+1
View File
@@ -17,6 +17,7 @@
"deploy:actions": "npm run build && wrangler pages deploy ./dist" "deploy:actions": "npm run build && wrangler pages deploy ./dist"
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^10.0.0",
"@unhead/vue": "^1.9.15", "@unhead/vue": "^1.9.15",
"@vicons/material": "^0.12.0", "@vicons/material": "^0.12.0",
"@vueuse/core": "^10.11.0", "@vueuse/core": "^10.11.0",
+15
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@simplewebauthn/browser':
specifier: ^10.0.0
version: 10.0.0
'@unhead/vue': '@unhead/vue':
specifier: ^1.9.15 specifier: ^1.9.15
version: 1.9.15(vue@3.4.31(typescript@5.4.5)) version: 1.9.15(vue@3.4.31(typescript@5.4.5))
@@ -1188,6 +1191,12 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@simplewebauthn/browser@10.0.0':
resolution: {integrity: sha512-hG0JMZD+LiLUbpQcAjS4d+t4gbprE/dLYop/CkE01ugU/9sKXflxV5s0DRjdz3uNMFecatRfb4ZLG3XvF8m5zg==}
'@simplewebauthn/types@10.0.0':
resolution: {integrity: sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==}
'@surma/rollup-plugin-off-main-thread@2.2.3': '@surma/rollup-plugin-off-main-thread@2.2.3':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -4075,6 +4084,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.18.0': '@rollup/rollup-win32-x64-msvc@4.18.0':
optional: true optional: true
'@simplewebauthn/browser@10.0.0':
dependencies:
'@simplewebauthn/types': 10.0.0
'@simplewebauthn/types@10.0.0': {}
'@surma/rollup-plugin-off-main-thread@2.2.3': '@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies: dependencies:
ejs: 3.1.10 ejs: 3.1.10
+36 -4
View File
@@ -1,18 +1,17 @@
<script setup> <script setup>
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
import { useRouter } from 'vue-router' import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { api } from '../../api'; import { api } from '../../api';
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { hashPassword } from '../../utils'; import { hashPassword } from '../../utils';
import { startAuthentication } from '@simplewebauthn/browser';
import Turnstile from '../../components/Turnstile.vue'; import Turnstile from '../../components/Turnstile.vue';
const { userJwt, userTab, userOpenSettings, openSettings } = useGlobalState() const { userJwt, userOpenSettings, openSettings } = useGlobalState()
const message = useMessage(); const message = useMessage();
const router = useRouter();
const { t } = useI18n({ const { t } = useI18n({
messages: { messages: {
@@ -33,6 +32,7 @@ const { t } = useI18n({
pleaseInputCode: 'Please input code', pleaseInputCode: 'Please input code',
pleaseCompleteTurnstile: 'Please complete turnstile', pleaseCompleteTurnstile: 'Please complete turnstile',
pleaseLogin: 'Please login', pleaseLogin: 'Please login',
loginWithPasskey: 'Login with Passkey',
}, },
zh: { zh: {
login: '登录', login: '登录',
@@ -51,6 +51,7 @@ const { t } = useI18n({
pleaseInputCode: '请输入验证码', pleaseInputCode: '请输入验证码',
pleaseCompleteTurnstile: '请完成人机验证', pleaseCompleteTurnstile: '请完成人机验证',
pleaseLogin: '请登录', pleaseLogin: '请登录',
loginWithPasskey: '使用 Passkey 登录',
} }
} }
}); });
@@ -156,6 +157,33 @@ const emailSignup = async () => {
} }
}; };
const passkeyLogin = async () => {
try {
const options = await api.fetch(`/user_api/passkey/authenticate_request`, {
method: 'POST',
body: JSON.stringify({
domain: location.hostname,
})
})
const credential = await startAuthentication(options)
// Send the result to the server and return the promise.
const res = await api.fetch(`/user_api/passkey/authenticate_response`, {
method: 'POST',
body: JSON.stringify({
origin: location.origin,
domain: location.hostname,
credential
})
})
userJwt.value = res.jwt;
location.reload();
} catch (e) {
console.error(e)
message.error(e.message)
}
};
onMounted(async () => { onMounted(async () => {
}); });
@@ -178,6 +206,10 @@ onMounted(async () => {
<n-button @click="showModal = true" type="info" quaternary size="tiny"> <n-button @click="showModal = true" type="info" quaternary size="tiny">
{{ t('forgotPassword') }} {{ t('forgotPassword') }}
</n-button> </n-button>
<n-divider />
<n-button @click="passkeyLogin" type="primary" block secondary strong>
{{ t('loginWithPasskey') }}
</n-button>
</n-form> </n-form>
</n-tab-pane> </n-tab-pane>
<n-tab-pane v-if="userOpenSettings.enable" name="signup" :tab="t('register')"> <n-tab-pane v-if="userOpenSettings.enable" name="signup" :tab="t('register')">
+184 -7
View File
@@ -1,16 +1,22 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { startRegistration } from '@simplewebauthn/browser';
import { NButton, NPopconfirm } from 'naive-ui'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { userJwt, userSettings, } = useGlobalState() const { userJwt, userSettings, } = useGlobalState()
const router = useRouter()
const message = useMessage() const message = useMessage()
const showLogout = ref(false) const showLogout = ref(false)
const showCreatePasskey = ref(false)
const passkeyName = ref('')
const showPasskeyList = ref(false)
const showRenamePasskey = ref(false)
const currentPasskeyId = ref(null)
const currentPasskeyName = ref('')
const { t } = useI18n({ const { t } = useI18n({
messages: { messages: {
@@ -18,11 +24,35 @@ const { t } = useI18n({
logout: 'Logout', logout: 'Logout',
logoutConfirm: 'Are you sure you want to logout?', logoutConfirm: 'Are you sure you want to logout?',
passordTip: 'The server will only receive the hash value of the password, and will not receive the plaintext password, so it cannot view or retrieve your password. If the administrator enables email verification, you can reset the password in incognito mode', passordTip: 'The server will only receive the hash value of the password, and will not receive the plaintext password, so it cannot view or retrieve your password. If the administrator enables email verification, you can reset the password in incognito mode',
createPasskey: 'Create Passkey',
showPasskeyList: 'Show Passkey List',
passkeyCreated: 'Passkey created successfully',
passkeyNamePlaceholder: 'Please enter the passkey name or leave it empty to generate a random one',
renamePasskey: 'Rename Passkey',
deletePasskey: 'Delete Passkey',
passkey_name: 'Passkey Name',
created_at: 'Created At',
updated_at: 'Updated At',
actions: 'Actions',
renamePasskey: 'Rename Passkey',
renamePasskeyNamePlaceholder: 'Please enter the new passkey name',
}, },
zh: { zh: {
logout: '退出登录', logout: '退出登录',
logoutConfirm: '确定要退出登录吗?', logoutConfirm: '确定要退出登录吗?',
passordTip: '服务器只会接收到密码的哈希值,不会接收到明文密码,因此无法查看或者找回您的密码, 如果管理员启用了邮件验证您可以在无痕模式重置密码', passordTip: '服务器只会接收到密码的哈希值,不会接收到明文密码,因此无法查看或者找回您的密码, 如果管理员启用了邮件验证您可以在无痕模式重置密码',
createPasskey: '创建 Passkey',
showPasskeyList: '查看 Passkey 列表',
passkeyCreated: 'Passkey 创建成功',
passkeyNamePlaceholder: '请输入 Passkey 名称或者留空自动生成',
renamePasskey: '重命名 Passkey',
deletePasskey: '删除 Passkey',
passkey_name: 'Passkey 名称',
created_at: '创建时间',
updated_at: '更新时间',
actions: '操作',
renamePasskey: '重命名 Passkey',
renamePasskeyNamePlaceholder: '请输入新的 Passkey 名称',
} }
} }
}); });
@@ -33,17 +63,144 @@ const logout = async () => {
location.reload() location.reload()
} }
const fetchData = async () => { const createPasskey = async () => {
try {
const options = await api.fetch(`/user_api/passkey/register_request`, {
method: 'POST',
body: JSON.stringify({
domain: location.hostname,
})
})
const credential = await startRegistration(options)
// Send the result to the server and return the promise.
await api.fetch(`/user_api/passkey/register_response`, {
method: 'POST',
body: JSON.stringify({
origin: location.origin,
passkey_name: passkeyName.value || (
(window.navigator.userAgentData?.platform || "Unknown")
+ ": " + Math.random().toString(36).substring(7)
),
credential
})
})
message.success(t('passkeyCreated'));
} catch (e) {
console.error(e)
message.error(e.message)
} finally {
passkeyName.value = ''
showCreatePasskey.value = false
}
} }
onMounted(async () => { const passkeyColumns = [
await fetchData() {
}) title: "Passkey ID",
key: "passkey_id"
},
{
title: t('passkey_name'),
key: "passkey_name"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('updated_at'),
key: "updated_at"
},
{
title: t('actions'),
key: 'actions',
render(row) {
return h('div', [
[
h(NButton,
{
tertiary: true,
type: "primary",
onClick: () => {
showRenamePasskey.value = true;
currentPasskeyId.value = row.passkey_id;
}
},
{ default: () => t('renamePasskey') }
),
h(NPopconfirm,
{
onPositiveClick: async () => {
try {
await api.fetch(`/user_api/passkey/${row.passkey_id}`, {
method: 'DELETE'
})
await fetchPasskeyList()
} catch (e) {
console.error(e)
message.error(e.message)
}
}
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "error",
},
{ default: () => t('deletePasskey') }
),
default: () => `${t('deletePasskey')}?`
}
),
]
])
}
}
]
const passkeyData = ref([])
const fetchPasskeyList = async () => {
try {
const data = await api.fetch(`/user_api/passkey`)
passkeyData.value = data
} catch (e) {
console.error(e)
message.error(e.message)
}
}
const renamePasskey = async () => {
try {
await api.fetch(`/user_api/passkey/rename`, {
method: 'POST',
body: JSON.stringify({
passkey_name: currentPasskeyName.value,
passkey_id: currentPasskeyId.value
})
})
await fetchPasskeyList()
} catch (e) {
console.error(e)
message.error(e.message)
} finally {
currentPasskeyName.value = ''
showRenamePasskey.value = false
}
}
</script> </script>
<template> <template>
<div class="center" v-if="userSettings.user_email"> <div class="center" v-if="userSettings.user_email">
<n-card :bordered="false" embedded> <n-card :bordered="false" embedded>
<n-button @click="showPasskeyList = true; fetchPasskeyList();" secondary block strong>
{{ t('showPasskeyList') }}
</n-button>
<n-button @click="showCreatePasskey = true" type="primary" secondary block strong>
{{ t('createPasskey') }}
</n-button>
<n-alert :show-icon="false" :bordered="false"> <n-alert :show-icon="false" :bordered="false">
<span> <span>
{{ t('passordTip') }} {{ t('passordTip') }}
@@ -53,6 +210,25 @@ onMounted(async () => {
{{ t('logout') }} {{ t('logout') }}
</n-button> </n-button>
</n-card> </n-card>
<n-modal v-model:show="showCreatePasskey" preset="dialog" :title="t('createPasskey')">
<n-input v-model:value="passkeyName" :placeholder="t('passkeyNamePlaceholder')" />
<template #action>
<n-button :loading="loading" @click="createPasskey" size="small" tertiary type="primary">
{{ t('createPasskey') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showRenamePasskey" preset="dialog" :title="t('renamePasskey')">
<n-input v-model:value="currentPasskeyName" :placeholder="t('renamePasskeyNamePlaceholder')" />
<template #action>
<n-button :loading="loading" @click="renamePasskey" size="small" tertiary type="primary">
{{ t('renamePasskey') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showPasskeyList" preset="card" :title="t('showPasskeyList')">
<n-data-table :columns="passkeyColumns" :data="passkeyData" :bordered="false" embedded />
</n-modal>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')"> <n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p> <p>{{ t('logoutConfirm') }}</p>
<template #action> <template #action>
@@ -78,5 +254,6 @@ onMounted(async () => {
.n-button { .n-button {
margin-top: 10px; margin-top: 10px;
margin-bottom: 10px;
} }
</style> </style>
+1 -1
View File
@@ -117,7 +117,7 @@ ENABLE_AUTO_REPLY = false
# CF_TURNSTILE_SITE_KEY = "" # CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = "" # CF_TURNSTILE_SECRET_KEY = ""
# telegram bot # telegram bot
# TG_MAX_ACCOUNTS = 5 # TG_MAX_ADDRESS = 5
# global forward address list, if set, all emails will be forwarded to these addresses # global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"] # FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
+1 -1
View File
@@ -88,7 +88,7 @@ ENABLE_AUTO_REPLY = false
# CF_TURNSTILE_SITE_KEY = "" # CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = "" # CF_TURNSTILE_SECRET_KEY = ""
# telegram bot 最多绑定邮箱数量 # telegram bot 最多绑定邮箱数量
# TG_MAX_ACCOUNTS = 5 # TG_MAX_ADDRESS = 5
# 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址 # 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"] # FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
+2
View File
@@ -13,6 +13,7 @@
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20240620.0", "@cloudflare/workers-types": "^4.20240620.0",
"@eslint/js": "8.56.0", "@eslint/js": "8.56.0",
"@simplewebauthn/types": "^10.0.0",
"eslint": "8.56.0", "eslint": "8.56.0",
"globals": "^15.8.0", "globals": "^15.8.0",
"typescript-eslint": "^7.15.0", "typescript-eslint": "^7.15.0",
@@ -21,6 +22,7 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.609.0", "@aws-sdk/client-s3": "^3.609.0",
"@aws-sdk/s3-request-presigner": "^3.609.0", "@aws-sdk/s3-request-presigner": "^3.609.0",
"@simplewebauthn/server": "^10.0.1",
"hono": "^4.4.12", "hono": "^4.4.12",
"mimetext": "^3.0.24", "mimetext": "^3.0.24",
"postal-mime": "^2.2.5", "postal-mime": "^2.2.5",
+126
View File
@@ -19,6 +19,9 @@ importers:
'@aws-sdk/s3-request-presigner': '@aws-sdk/s3-request-presigner':
specifier: ^3.609.0 specifier: ^3.609.0
version: 3.609.0 version: 3.609.0
'@simplewebauthn/server':
specifier: ^10.0.1
version: 10.0.1
hono: hono:
specifier: ^4.4.12 specifier: ^4.4.12
version: 4.4.12 version: 4.4.12
@@ -41,6 +44,9 @@ importers:
'@eslint/js': '@eslint/js':
specifier: 8.56.0 specifier: 8.56.0
version: 8.56.0 version: 8.56.0
'@simplewebauthn/types':
specifier: ^10.0.0
version: 10.0.0
eslint: eslint:
specifier: 8.56.0 specifier: 8.56.0
version: 8.56.0 version: 8.56.0
@@ -444,6 +450,9 @@ packages:
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@hexagon/base64@1.1.28':
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
'@humanwhocodes/config-array@0.11.14': '@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@@ -471,6 +480,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9': '@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@levischuck/tiny-cbor@0.2.2':
resolution: {integrity: sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw==}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -486,6 +498,21 @@ packages:
'@one-ini/wasm@0.1.1': '@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@peculiar/asn1-android@2.3.10':
resolution: {integrity: sha512-z9Rx9cFJv7UUablZISe7uksNbFJCq13hO0yEAOoIpAymALTLlvUOSLnGiQS7okPaM5dP42oTLhezH6XDXRXjGw==}
'@peculiar/asn1-ecc@2.3.8':
resolution: {integrity: sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==}
'@peculiar/asn1-rsa@2.3.8':
resolution: {integrity: sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==}
'@peculiar/asn1-schema@2.3.8':
resolution: {integrity: sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==}
'@peculiar/asn1-x509@2.3.8':
resolution: {integrity: sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -497,6 +524,13 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0': '@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@simplewebauthn/server@10.0.1':
resolution: {integrity: sha512-djNWcRn+H+6zvihBFJSpG3fzb0NQS9c/Mw5dYOtZ9H+oDw8qn9Htqxt4cpqRvSOAfwqP7rOvE9rwqVaoGGc3hg==}
engines: {node: '>=20.0.0'}
'@simplewebauthn/types@10.0.0':
resolution: {integrity: sha512-SFXke7xkgPRowY2E+8djKbdEznTVnD5R6GO7GPTthpHrokLvNKw8C3lFZypTxLI7KkCfGPfhtqB3d7OVGGa9jQ==}
'@smithy/abort-controller@3.1.1': '@smithy/abort-controller@3.1.1':
resolution: {integrity: sha512-MBJBiidoe+0cTFhyxT8g+9g7CeVccLM0IOKKUMCNQ1CNMJ/eIfoo0RTfVrXOONEI1UCN1W+zkiHSbzUNE9dZtQ==} resolution: {integrity: sha512-MBJBiidoe+0cTFhyxT8g+9g7CeVccLM0IOKKUMCNQ1CNMJ/eIfoo0RTfVrXOONEI1UCN1W+zkiHSbzUNE9dZtQ==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -825,6 +859,10 @@ packages:
as-table@1.0.55: as-table@1.0.55:
resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==}
asn1js@3.0.5:
resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==}
engines: {node: '>=12.0.0'}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -900,6 +938,9 @@ packages:
core-js-pure@3.37.1: core-js-pure@3.37.1:
resolution: {integrity: sha512-J/r5JTHSmzTxbiYYrzXg9w1VpqrYt+gexenBE9pugeyhwPZTAEJddyiReJWsLO6uNQ8xJZFbod6XC7KKwatCiA==} resolution: {integrity: sha512-J/r5JTHSmzTxbiYYrzXg9w1VpqrYt+gexenBE9pugeyhwPZTAEJddyiReJWsLO6uNQ8xJZFbod6XC7KKwatCiA==}
cross-fetch@4.0.0:
resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
cross-spawn@7.0.3: cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1157,6 +1198,10 @@ packages:
ini@1.3.8: ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
ipaddr.js@2.2.0:
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
engines: {node: '>= 10'}
is-binary-path@2.1.0: is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1418,6 +1463,13 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
pvtsutils@1.3.5:
resolution: {integrity: sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==}
pvutils@1.1.3:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2344,6 +2396,8 @@ snapshots:
'@fastify/busboy@2.1.1': {} '@fastify/busboy@2.1.1': {}
'@hexagon/base64@1.1.28': {}
'@humanwhocodes/config-array@0.11.14': '@humanwhocodes/config-array@0.11.14':
dependencies: dependencies:
'@humanwhocodes/object-schema': 2.0.3 '@humanwhocodes/object-schema': 2.0.3
@@ -2374,6 +2428,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/sourcemap-codec': 1.4.15
'@levischuck/tiny-cbor@0.2.2': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@@ -2388,6 +2444,40 @@ snapshots:
'@one-ini/wasm@0.1.1': {} '@one-ini/wasm@0.1.1': {}
'@peculiar/asn1-android@2.3.10':
dependencies:
'@peculiar/asn1-schema': 2.3.8
asn1js: 3.0.5
tslib: 2.6.3
'@peculiar/asn1-ecc@2.3.8':
dependencies:
'@peculiar/asn1-schema': 2.3.8
'@peculiar/asn1-x509': 2.3.8
asn1js: 3.0.5
tslib: 2.6.3
'@peculiar/asn1-rsa@2.3.8':
dependencies:
'@peculiar/asn1-schema': 2.3.8
'@peculiar/asn1-x509': 2.3.8
asn1js: 3.0.5
tslib: 2.6.3
'@peculiar/asn1-schema@2.3.8':
dependencies:
asn1js: 3.0.5
pvtsutils: 1.3.5
tslib: 2.6.3
'@peculiar/asn1-x509@2.3.8':
dependencies:
'@peculiar/asn1-schema': 2.3.8
asn1js: 3.0.5
ipaddr.js: 2.2.0
pvtsutils: 1.3.5
tslib: 2.6.3
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -2404,6 +2494,22 @@ snapshots:
domhandler: 5.0.3 domhandler: 5.0.3
selderee: 0.11.0 selderee: 0.11.0
'@simplewebauthn/server@10.0.1':
dependencies:
'@hexagon/base64': 1.1.28
'@levischuck/tiny-cbor': 0.2.2
'@peculiar/asn1-android': 2.3.10
'@peculiar/asn1-ecc': 2.3.8
'@peculiar/asn1-rsa': 2.3.8
'@peculiar/asn1-schema': 2.3.8
'@peculiar/asn1-x509': 2.3.8
'@simplewebauthn/types': 10.0.0
cross-fetch: 4.0.0
transitivePeerDependencies:
- encoding
'@simplewebauthn/types@10.0.0': {}
'@smithy/abort-controller@3.1.1': '@smithy/abort-controller@3.1.1':
dependencies: dependencies:
'@smithy/types': 3.3.0 '@smithy/types': 3.3.0
@@ -2871,6 +2977,12 @@ snapshots:
dependencies: dependencies:
printable-characters: 1.0.42 printable-characters: 1.0.42
asn1js@3.0.5:
dependencies:
pvtsutils: 1.3.5
pvutils: 1.1.3
tslib: 2.6.3
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
binary-extensions@2.3.0: {} binary-extensions@2.3.0: {}
@@ -2948,6 +3060,12 @@ snapshots:
core-js-pure@3.37.1: {} core-js-pure@3.37.1: {}
cross-fetch@4.0.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.3: cross-spawn@7.0.3:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -3258,6 +3376,8 @@ snapshots:
ini@1.3.8: {} ini@1.3.8: {}
ipaddr.js@2.2.0: {}
is-binary-path@2.1.0: is-binary-path@2.1.0:
dependencies: dependencies:
binary-extensions: 2.3.0 binary-extensions: 2.3.0
@@ -3483,6 +3603,12 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
pvtsutils@1.3.5:
dependencies:
tslib: 2.6.3
pvutils@1.1.3: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
react-dom@18.3.1(react@18.3.1): react-dom@18.3.1(react@18.3.1):
+15
View File
@@ -1,3 +1,18 @@
import type {
AuthenticatorTransportFuture,
CredentialDeviceType,
Base64URLString,
} from '@simplewebauthn/types';
export type Passkey = {
id: Base64URLString;
publicKey: string;
counter: number;
deviceType: CredentialDeviceType;
backedUp: boolean;
transports?: AuthenticatorTransportFuture[];
};
export class AdminWebhookSettings { export class AdminWebhookSettings {
allowList: string[]; allowList: string[];
+15
View File
@@ -4,15 +4,30 @@ import { HonoCustomType } from '../types';
import settings from './settings'; import settings from './settings';
import user from './user'; import user from './user';
import bind_address from './bind_address'; import bind_address from './bind_address';
import passkey from './passkey';
export const api = new Hono<HonoCustomType>(); export const api = new Hono<HonoCustomType>();
// settings api
api.get('/user_api/open_settings', settings.openSettings); api.get('/user_api/open_settings', settings.openSettings);
api.get('/user_api/settings', settings.settings); api.get('/user_api/settings', settings.settings);
// user api
api.post('/user_api/login', user.login); api.post('/user_api/login', user.login);
api.post('/user_api/verify_code', user.verifyCode); api.post('/user_api/verify_code', user.verifyCode);
api.post('/user_api/register', user.register); api.post('/user_api/register', user.register);
// bind address api
api.get('/user_api/bind_address', bind_address.getBindedAddresses); api.get('/user_api/bind_address', bind_address.getBindedAddresses);
api.post('/user_api/bind_address', bind_address.bind); api.post('/user_api/bind_address', bind_address.bind);
api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt); api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt);
api.post('/user_api/unbind_address', bind_address.unbind); api.post('/user_api/unbind_address', bind_address.unbind);
// passkey api
api.get('/user_api/passkey', passkey.getPassKeys);
api.post('/user_api/passkey/rename', passkey.renamePassKey);
api.delete('/user_api/passkey/:passkey_id', passkey.deletePassKey);
api.post('/user_api/passkey/register_request', passkey.registerRequest);
api.post('/user_api/passkey/register_response', passkey.registerResponse);
api.post('/user_api/passkey/authenticate_request', passkey.authenticateRequest);
api.post('/user_api/passkey/authenticate_response', passkey.authenticateResponse);
+204
View File
@@ -0,0 +1,204 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { HonoCustomType } from '../types';
import { Passkey } from '../models';
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
export default {
getPassKeys: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { results } = await c.env.DB.prepare(
`SELECT passkey_name, passkey_id, created_at, updated_at FROM user_passkeys WHERE user_id = ?`
).bind(user.user_id).all<Record<string, string>>();
return c.json(results);
},
renamePassKey: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { passkey_id, passkey_name } = await c.req.json();
if (!passkey_name || passkey_name.length > 255) {
return c.text("Invalid passkey name", 400);
}
const { success } = await c.env.DB.prepare(
`UPDATE user_passkeys SET passkey_name = ? WHERE user_id = ? AND passkey_id = ?`
).bind(passkey_name, user.user_id, passkey_id).run();
return c.json({ success });
},
deletePassKey: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { passkey_id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM user_passkeys WHERE user_id = ? AND passkey_id = ?`
).bind(user.user_id, passkey_id).run();
return c.json({ success });
},
registerRequest: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { domain } = await c.req.json();
const { results } = await c.env.DB.prepare(
`SELECT passkey FROM user_passkeys WHERE user_id = ?`
).bind(user.user_id).all<Record<string, string>>();
const excludeCredentials = results
.map((record: any) => JSON.parse(record.passkey) as Passkey)
.map((passkey: Passkey) => ({
id: passkey.id,
transports: passkey.transports,
}));
// create challenge with 1 hour expiration
const challenge = await Jwt.sign({
user_email: user.user_email,
user_id: user.user_id,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
// Use SimpleWebAuthn's handy function to create registration options.
const options = await generateRegistrationOptions({
rpName: c.env.TITLE || "Temp Mail",
rpID: domain,
userID: new TextEncoder().encode(user.user_id.toString()),
userName: user.user_email,
userDisplayName: user.user_email,
attestationType: 'none',
excludeCredentials: excludeCredentials,
challenge: challenge,
});
return c.json(options);
},
registerResponse: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const { credential, origin, passkey_name } = await c.req.json();
// Verify the registration response
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: async (challenge: string) => {
const payload = await Jwt.verify(atob(challenge), c.env.JWT_SECRET, "HS256");
if (!payload || !payload.iat) return false;
// check iad is not older than 5 minutes
if (Math.floor(Date.now() / 1000) - payload.iat > 300) return false;
if (payload.user_id !== user.user_id) return false;
return true;
},
expectedOrigin: origin,
requireUserVerification: true,
});
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return c.text("Registration failed", 400);
}
const {
credentialID, credentialPublicKey,
counter, credentialDeviceType, credentialBackedUp,
} = registrationInfo;
// Base64URL encode ArrayBuffers.
const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);
const newPasskey: Passkey = {
id: credentialID,
publicKey: base64PublicKey,
counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: credential?.response?.transports,
};
// Store the credential ID in the database
const { success } = await c.env.DB.prepare(
`INSERT INTO user_passkeys (user_id, passkey_name, passkey_id, passkey, counter) VALUES (?, ?, ?, ?, ?)`
).bind(user.user_id, passkey_name, credentialID, JSON.stringify(newPasskey), counter).run();
return c.json({ success });
},
authenticateRequest: async (c: Context<HonoCustomType>) => {
const { domain } = await c.req.json();
const challenge = await Jwt.sign({
domain,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
const options: PublicKeyCredentialRequestOptionsJSON = await generateAuthenticationOptions({
rpID: domain,
challenge: challenge,
allowCredentials: [],
});
return c.json(options);
},
authenticateResponse: async (c: Context<HonoCustomType>) => {
const { domain, credential, origin } = await c.req.json();
const passkey_id = credential?.id;
if (!passkey_id) {
return c.text("Invalid request", 400);
}
const { user_id, counter, passkey } = await c.env.DB.prepare(
`SELECT user_id, counter, passkey FROM user_passkeys WHERE passkey_id = ?`
).bind(passkey_id).first<{
counter: number; passkey: string; user_id: number;
}>() || {};
if (!passkey) {
return c.text("Passkey not found", 404);
}
const passkeyData = JSON.parse(passkey) as Passkey;
// Verify the registration response
const verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: async (challenge: string) => {
const payload = await Jwt.verify(atob(challenge), c.env.JWT_SECRET, "HS256");
if (!payload || !payload.iat) return false;
// check iad is not older than 5 minutes
if (Math.floor(Date.now() / 1000) - payload.iat > 300) return false;
return true;
},
expectedOrigin: origin,
expectedRPID: domain,
authenticator: {
credentialID: passkeyData.id,
credentialPublicKey: isoBase64URL.toBuffer(passkeyData.publicKey),
counter: counter || passkeyData.counter,
transports: passkeyData.transports,
},
});
const { verified, authenticationInfo } = verification;
if (!verified) {
return c.text("Authentication failed", 400);
}
if (authenticationInfo) {
const { newCounter } = authenticationInfo;
// Update the counter in the database
await c.env.DB.prepare(
`UPDATE user_passkeys SET counter = ? WHERE passkey_id = ?`
).bind(newCounter, passkey_id).run();
}
// update passkey updated_at
await c.env.DB.prepare(
`UPDATE user_passkeys SET updated_at = datetime('now') WHERE passkey_id = ?`
).bind(passkey_id).run();
// return jwt
const { user_email } = await c.env.DB.prepare(
`SELECT user_email FROM users WHERE id = ?`
).bind(user_id).first<{ user_email: string }>() || {};
if (!user_email) {
return c.text("User not found", 404);
}
// create jwt
const jwt = await Jwt.sign({
user_email: user_email,
user_id: user_id,
// 30 days expire in seconds
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
iat: Math.floor(Date.now() / 1000),
}, c.env.JWT_SECRET, "HS256")
return c.json({
jwt: jwt
})
},
}
-1
View File
@@ -59,7 +59,6 @@ export const getBooleanValue = (
if (typeof value === "string") { if (typeof value === "string") {
return value === "true"; return value === "true";
} }
console.error(`Failed to parse boolean value: ${value}`);
return false; return false;
} }
+1
View File
@@ -124,6 +124,7 @@ app.use('/user_api/*', async (c, next) => {
|| c.req.path.startsWith("/user_api/register") || c.req.path.startsWith("/user_api/register")
|| c.req.path.startsWith("/user_api/login") || c.req.path.startsWith("/user_api/login")
|| c.req.path.startsWith("/user_api/verify_code") || c.req.path.startsWith("/user_api/verify_code")
|| c.req.path.startsWith("/user_api/passkey/authenticate_")
) { ) {
await next(); await next();
return; return;
+2 -1
View File
@@ -54,11 +54,12 @@ ENABLE_AUTO_REPLY = false
# DISABLE_SHOW_GITHUB = true # DISABLE_SHOW_GITHUB = true
# default send balance, if not set, it will be 0 # default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1 # DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # the role which can send emails without limit
# Turnstile verification # Turnstile verification
# CF_TURNSTILE_SITE_KEY = "" # CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = "" # CF_TURNSTILE_SECRET_KEY = ""
# telegram bot # telegram bot
# TG_MAX_ACCOUNTS = 5 # TG_MAX_ADDRESS = 5
# global forward address list, if set, all emails will be forwarded to these addresses # global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"] # FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]