diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c856afd..c5d3418c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,10 +3,17 @@
## main(v0.7.0)
+### Breaking Changes
+
+DB changes: 增加用户 `passkey` 表, 需要执行 `db/2024-08-10-patch.sql` 更新 `D1` 数据库
+
+### Changes
+
- Docs: Update new-address-api.md (#360)
- feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363)
- feat: worker 增加 `DISABLE_SHOW_GITHUB` 配置, 用于配置是否显示 github 链接
- feat: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 用于配置可以无限发送邮件的角色
+- feat: 用户增加 `passkey` 登录方式, 用于用户登录, 无需输入密码
## v0.6.1
diff --git a/db/2024-08-10-patch.sql b/db/2024-08-10-patch.sql
new file mode 100644
index 00000000..38a12418
--- /dev/null
+++ b/db/2024-08-10-patch.sql
@@ -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);
diff --git a/db/schema.sql b/db/schema.sql
index bc655935..5967627e 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -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 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);
diff --git a/frontend/package.json b/frontend/package.json
index dded1e25..0d3b3b4c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,6 +17,7 @@
"deploy:actions": "npm run build && wrangler pages deploy ./dist"
},
"dependencies": {
+ "@simplewebauthn/browser": "^10.0.0",
"@unhead/vue": "^1.9.15",
"@vicons/material": "^0.12.0",
"@vueuse/core": "^10.11.0",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index f8c4fe17..26bbc9c1 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@simplewebauthn/browser':
+ specifier: ^10.0.0
+ version: 10.0.0
'@unhead/vue':
specifier: ^1.9.15
version: 1.9.15(vue@3.4.31(typescript@5.4.5))
@@ -1188,6 +1191,12 @@ packages:
cpu: [x64]
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':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -4075,6 +4084,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.18.0':
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':
dependencies:
ejs: 3.1.10
diff --git a/frontend/src/views/user/UserLogin.vue b/frontend/src/views/user/UserLogin.vue
index 60c0665d..68118c35 100644
--- a/frontend/src/views/user/UserLogin.vue
+++ b/frontend/src/views/user/UserLogin.vue
@@ -1,18 +1,17 @@
+
+ {{ t('showPasskeyList') }}
+
+
+ {{ t('createPasskey') }}
+
{{ t('passordTip') }}
@@ -53,6 +210,25 @@ onMounted(async () => {
{{ t('logout') }}
+
+
+
+
+ {{ t('createPasskey') }}
+
+
+
+
+
+
+
+ {{ t('renamePasskey') }}
+
+
+
+
+
+
{{ t('logoutConfirm') }}
@@ -78,5 +254,6 @@ onMounted(async () => {
.n-button {
margin-top: 10px;
+ margin-bottom: 10px;
}
diff --git a/vitepress-docs/docs/en/cli.md b/vitepress-docs/docs/en/cli.md
index 73d4fd34..0c34a4ac 100644
--- a/vitepress-docs/docs/en/cli.md
+++ b/vitepress-docs/docs/en/cli.md
@@ -117,7 +117,7 @@ ENABLE_AUTO_REPLY = false
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# telegram bot
-# TG_MAX_ACCOUNTS = 5
+# TG_MAX_ADDRESS = 5
# global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
diff --git a/vitepress-docs/docs/zh/guide/cli/worker.md b/vitepress-docs/docs/zh/guide/cli/worker.md
index 83b876f8..b60815de 100644
--- a/vitepress-docs/docs/zh/guide/cli/worker.md
+++ b/vitepress-docs/docs/zh/guide/cli/worker.md
@@ -88,7 +88,7 @@ ENABLE_AUTO_REPLY = false
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# telegram bot 最多绑定邮箱数量
-# TG_MAX_ACCOUNTS = 5
+# TG_MAX_ADDRESS = 5
# 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
diff --git a/worker/package.json b/worker/package.json
index 3e2183a6..f2150c0a 100644
--- a/worker/package.json
+++ b/worker/package.json
@@ -13,6 +13,7 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20240620.0",
"@eslint/js": "8.56.0",
+ "@simplewebauthn/types": "^10.0.0",
"eslint": "8.56.0",
"globals": "^15.8.0",
"typescript-eslint": "^7.15.0",
@@ -21,6 +22,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.609.0",
"@aws-sdk/s3-request-presigner": "^3.609.0",
+ "@simplewebauthn/server": "^10.0.1",
"hono": "^4.4.12",
"mimetext": "^3.0.24",
"postal-mime": "^2.2.5",
diff --git a/worker/pnpm-lock.yaml b/worker/pnpm-lock.yaml
index 63c5b3ec..ddf3ed58 100644
--- a/worker/pnpm-lock.yaml
+++ b/worker/pnpm-lock.yaml
@@ -19,6 +19,9 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: ^3.609.0
version: 3.609.0
+ '@simplewebauthn/server':
+ specifier: ^10.0.1
+ version: 10.0.1
hono:
specifier: ^4.4.12
version: 4.4.12
@@ -41,6 +44,9 @@ importers:
'@eslint/js':
specifier: 8.56.0
version: 8.56.0
+ '@simplewebauthn/types':
+ specifier: ^10.0.0
+ version: 10.0.0
eslint:
specifier: 8.56.0
version: 8.56.0
@@ -444,6 +450,9 @@ packages:
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
+ '@hexagon/base64@1.1.28':
+ resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
+
'@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
@@ -471,6 +480,9 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+ '@levischuck/tiny-cbor@0.2.2':
+ resolution: {integrity: sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw==}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -486,6 +498,21 @@ packages:
'@one-ini/wasm@0.1.1':
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':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -497,6 +524,13 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0':
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':
resolution: {integrity: sha512-MBJBiidoe+0cTFhyxT8g+9g7CeVccLM0IOKKUMCNQ1CNMJ/eIfoo0RTfVrXOONEI1UCN1W+zkiHSbzUNE9dZtQ==}
engines: {node: '>=16.0.0'}
@@ -825,6 +859,10 @@ packages:
as-table@1.0.55:
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:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -900,6 +938,9 @@ packages:
core-js-pure@3.37.1:
resolution: {integrity: sha512-J/r5JTHSmzTxbiYYrzXg9w1VpqrYt+gexenBE9pugeyhwPZTAEJddyiReJWsLO6uNQ8xJZFbod6XC7KKwatCiA==}
+ cross-fetch@4.0.0:
+ resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==}
+
cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
@@ -1157,6 +1198,10 @@ packages:
ini@1.3.8:
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:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -1418,6 +1463,13 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
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:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2344,6 +2396,8 @@ snapshots:
'@fastify/busboy@2.1.1': {}
+ '@hexagon/base64@1.1.28': {}
+
'@humanwhocodes/config-array@0.11.14':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@@ -2374,6 +2428,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.15
+ '@levischuck/tiny-cbor@0.2.2': {}
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -2388,6 +2444,40 @@ snapshots:
'@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':
optional: true
@@ -2404,6 +2494,22 @@ snapshots:
domhandler: 5.0.3
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':
dependencies:
'@smithy/types': 3.3.0
@@ -2871,6 +2977,12 @@ snapshots:
dependencies:
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: {}
binary-extensions@2.3.0: {}
@@ -2948,6 +3060,12 @@ snapshots:
core-js-pure@3.37.1: {}
+ cross-fetch@4.0.0:
+ dependencies:
+ node-fetch: 2.7.0
+ transitivePeerDependencies:
+ - encoding
+
cross-spawn@7.0.3:
dependencies:
path-key: 3.1.1
@@ -3258,6 +3376,8 @@ snapshots:
ini@1.3.8: {}
+ ipaddr.js@2.2.0: {}
+
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
@@ -3483,6 +3603,12 @@ snapshots:
punycode@2.3.1: {}
+ pvtsutils@1.3.5:
+ dependencies:
+ tslib: 2.6.3
+
+ pvutils@1.1.3: {}
+
queue-microtask@1.2.3: {}
react-dom@18.3.1(react@18.3.1):
diff --git a/worker/src/models/index.ts b/worker/src/models/index.ts
index e95b2e05..6c5a065a 100644
--- a/worker/src/models/index.ts
+++ b/worker/src/models/index.ts
@@ -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 {
allowList: string[];
diff --git a/worker/src/user_api/index.ts b/worker/src/user_api/index.ts
index 78674635..fac66c74 100644
--- a/worker/src/user_api/index.ts
+++ b/worker/src/user_api/index.ts
@@ -4,15 +4,30 @@ import { HonoCustomType } from '../types';
import settings from './settings';
import user from './user';
import bind_address from './bind_address';
+import passkey from './passkey';
export const api = new Hono();
+// settings api
api.get('/user_api/open_settings', settings.openSettings);
api.get('/user_api/settings', settings.settings);
+
+// user api
api.post('/user_api/login', user.login);
api.post('/user_api/verify_code', user.verifyCode);
api.post('/user_api/register', user.register);
+
+// bind address api
api.get('/user_api/bind_address', bind_address.getBindedAddresses);
api.post('/user_api/bind_address', bind_address.bind);
api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt);
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);
diff --git a/worker/src/user_api/passkey.ts b/worker/src/user_api/passkey.ts
new file mode 100644
index 00000000..fad6d79f
--- /dev/null
+++ b/worker/src/user_api/passkey.ts
@@ -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) => {
+ 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>();
+ return c.json(results);
+ },
+ renamePassKey: async (c: Context) => {
+ 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) => {
+ 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) => {
+ 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>();
+ 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) => {
+ 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) => {
+ 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) => {
+ 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
+ })
+ },
+}
diff --git a/worker/src/utils.ts b/worker/src/utils.ts
index 8287a75f..950d8a78 100644
--- a/worker/src/utils.ts
+++ b/worker/src/utils.ts
@@ -59,7 +59,6 @@ export const getBooleanValue = (
if (typeof value === "string") {
return value === "true";
}
- console.error(`Failed to parse boolean value: ${value}`);
return false;
}
diff --git a/worker/src/worker.ts b/worker/src/worker.ts
index df93671d..7b2561e1 100644
--- a/worker/src/worker.ts
+++ b/worker/src/worker.ts
@@ -124,6 +124,7 @@ app.use('/user_api/*', async (c, next) => {
|| c.req.path.startsWith("/user_api/register")
|| c.req.path.startsWith("/user_api/login")
|| c.req.path.startsWith("/user_api/verify_code")
+ || c.req.path.startsWith("/user_api/passkey/authenticate_")
) {
await next();
return;
diff --git a/worker/wrangler.toml.template b/worker/wrangler.toml.template
index d41514b4..5c3db672 100644
--- a/worker/wrangler.toml.template
+++ b/worker/wrangler.toml.template
@@ -54,11 +54,12 @@ ENABLE_AUTO_REPLY = false
# DISABLE_SHOW_GITHUB = true
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
+# NO_LIMIT_SEND_ROLE = "vip" # the role which can send emails without limit
# Turnstile verification
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# telegram bot
-# TG_MAX_ACCOUNTS = 5
+# TG_MAX_ADDRESS = 5
# global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]