mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-11 18:10:01 +08:00
feat: implement address password authentication feature (#731)
* feat: implement address password authentication feature - Add password field to address table for storing hashed passwords - Implement address authentication APIs (login, change password) - Add automatic password generation for new addresses - Support password login alongside credential login in frontend - Add password management in account settings and admin panel - Add ENABLE_ADDRESS_PASSWORD environment variable for feature control - Update documentation and i18n support - Enhance security with SHA-256 password hashing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: upgrade dependencies --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,20 +11,20 @@
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250913.0",
|
||||
"@cloudflare/workers-types": "^4.20250926.0",
|
||||
"@eslint/js": "9.18.0",
|
||||
"@simplewebauthn/types": "10.0.0",
|
||||
"@types/node": "^22.18.3",
|
||||
"@types/node": "^22.18.6",
|
||||
"eslint": "9.18.0",
|
||||
"globals": "^15.15.0",
|
||||
"typescript-eslint": "^8.43.0",
|
||||
"wrangler": "^4.37.0"
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"wrangler": "^4.40.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.888.0",
|
||||
"@aws-sdk/client-s3": "3.888.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.888.0",
|
||||
"@simplewebauthn/server": "10.0.1",
|
||||
"hono": "^4.9.7",
|
||||
"hono": "^4.9.8",
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
"mimetext": "^3.0.27",
|
||||
"postal-mime": "^2.4.4",
|
||||
|
||||
537
worker/pnpm-lock.yaml
generated
537
worker/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
|
||||
CREATE TABLE IF NOT EXISTS address (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
password TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -136,6 +137,11 @@ export default {
|
||||
},
|
||||
migrate: async (c: Context<HonoCustomType>) => {
|
||||
const version = await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY);
|
||||
if (version == "v0.0.2") {
|
||||
// example migration from v0.0.2 to v0.0.3
|
||||
const query = `ALTER TABLE address ADD password TEXT;`
|
||||
await c.env.DB.exec(query);
|
||||
}
|
||||
if (version != CONSTANTS.DB_VERSION) {
|
||||
// TODO: Perform migration logic here
|
||||
// remove all \r and \n characters from the query string
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles } from '../utils'
|
||||
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles, getBooleanValue, hashPassword } from '../utils'
|
||||
import { newAddress, handleListQuery } from '../common'
|
||||
import { CONSTANTS } from '../constants'
|
||||
import cleanup_api from './cleanup_api'
|
||||
@@ -56,6 +56,7 @@ api.post('/admin/new_address', async (c) => {
|
||||
checkAllowDomains: false,
|
||||
enableCheckNameRegex: false,
|
||||
});
|
||||
|
||||
return c.json(res);
|
||||
} catch (e) {
|
||||
return c.text(`${msgs.FailedCreateAddressMsg}: ${(e as Error).message}`, 400)
|
||||
@@ -131,6 +132,30 @@ api.get('/admin/show_password/:id', async (c) => {
|
||||
})
|
||||
})
|
||||
|
||||
api.post('/admin/address/:id/reset_password', async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { password } = await c.req.json();
|
||||
// 检查功能是否启用
|
||||
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
|
||||
return c.text("Password management is disabled", 403);
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return c.text("Password is required", 400);
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
).bind(hashedPassword, id).run();
|
||||
|
||||
if (!success) {
|
||||
return c.text("Failed to reset password", 500);
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
})
|
||||
|
||||
// mail api
|
||||
api.get('/admin/mails', admin_mail_api.getMails);
|
||||
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails);
|
||||
|
||||
@@ -40,7 +40,8 @@ api.get('/open_api/settings', async (c) => {
|
||||
"isS3Enabled": isS3Enabled(c),
|
||||
"version": CONSTANTS.VERSION,
|
||||
"showGithub": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
|
||||
"disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK)
|
||||
"disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
|
||||
"enableAddressPassword": utils.getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)
|
||||
});
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList } from './utils';
|
||||
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword } from './utils';
|
||||
import { unbindTelegramByAddress } from './telegram_api/common';
|
||||
import { CONSTANTS } from './constants';
|
||||
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
|
||||
@@ -82,6 +82,37 @@ export async function updateAddressUpdatedAt(
|
||||
}
|
||||
}
|
||||
|
||||
export const generateRandomPassword = (): string => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let password = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
password += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
const generatePasswordForAddress = async (
|
||||
c: Context<HonoCustomType>,
|
||||
address: string
|
||||
): Promise<string | null> => {
|
||||
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const plainPassword = generateRandomPassword();
|
||||
const hashedPassword = await hashPassword(plainPassword);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE name = ?`
|
||||
).bind(hashedPassword, address).run();
|
||||
|
||||
if (!success) {
|
||||
console.warn("Failed to set generated password for address:", address);
|
||||
return null;
|
||||
}
|
||||
|
||||
return plainPassword;
|
||||
}
|
||||
|
||||
export const newAddress = async (
|
||||
c: Context<HonoCustomType>,
|
||||
{
|
||||
@@ -100,7 +131,7 @@ export const newAddress = async (
|
||||
checkAllowDomains?: boolean,
|
||||
enableCheckNameRegex?: boolean,
|
||||
}
|
||||
): Promise<{ address: string, jwt: string }> => {
|
||||
): Promise<{ address: string, jwt: string, password?: string | null }> => {
|
||||
// trim whitespace and remove special characters
|
||||
name = name.trim().replace(getNameRegex(c), '')
|
||||
// check name
|
||||
@@ -166,6 +197,10 @@ export const newAddress = async (
|
||||
const address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(name).first<number>("id");
|
||||
|
||||
// 如果启用地址密码功能,自动生成密码
|
||||
const generatedPassword = await generatePasswordForAddress(c, name);
|
||||
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
@@ -174,6 +209,7 @@ export const newAddress = async (
|
||||
return {
|
||||
jwt: jwt,
|
||||
address: name,
|
||||
password: generatedPassword,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ export const CONSTANTS = {
|
||||
|
||||
// DB Version
|
||||
DB_VERSION_KEY: 'db_version',
|
||||
DB_VERSION: "v0.0.2",
|
||||
DB_VERSION: "v0.0.3",
|
||||
|
||||
// DB settings
|
||||
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
|
||||
|
||||
@@ -38,6 +38,14 @@ const messages: LocaleMessages = {
|
||||
Oauth2FailedGetUserInfoMsg: "Failed to get user info from Oauth2 provider",
|
||||
Oauth2FailedGetAccessTokenMsg: "Failed to get access token from Oauth2 provider",
|
||||
Oauth2FailedGetUserEmailMsg: "Failed to get user email from Oauth2 provider",
|
||||
|
||||
PasswordChangeDisabledMsg: "Password change is disabled",
|
||||
NewPasswordRequiredMsg: "New password is required",
|
||||
InvalidAddressTokenMsg: "Invalid address token",
|
||||
FailedUpdatePasswordMsg: "Failed to update password",
|
||||
PasswordLoginDisabledMsg: "Password login is disabled",
|
||||
EmailPasswordRequiredMsg: "Email and password are required",
|
||||
AddressNotFoundMsg: "Address not found",
|
||||
}
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -36,4 +36,12 @@ export type LocaleMessages = {
|
||||
Oauth2FailedGetUserInfoMsg: string
|
||||
Oauth2FailedGetAccessTokenMsg: string
|
||||
Oauth2FailedGetUserEmailMsg: string
|
||||
|
||||
PasswordChangeDisabledMsg: string
|
||||
NewPasswordRequiredMsg: string
|
||||
InvalidAddressTokenMsg: string
|
||||
FailedUpdatePasswordMsg: string
|
||||
PasswordLoginDisabledMsg: string
|
||||
EmailPasswordRequiredMsg: string
|
||||
AddressNotFoundMsg: string
|
||||
}
|
||||
|
||||
@@ -38,6 +38,14 @@ const messages: LocaleMessages = {
|
||||
Oauth2FailedGetUserInfoMsg: "从 Oauth2 提供商获取用户信息失败",
|
||||
Oauth2FailedGetAccessTokenMsg: "从 Oauth2 提供商获取访问令牌失败",
|
||||
Oauth2FailedGetUserEmailMsg: "从 Oauth2 提供商获取用户邮箱失败",
|
||||
|
||||
PasswordChangeDisabledMsg: "密码修改已禁用",
|
||||
NewPasswordRequiredMsg: "新密码不能为空",
|
||||
InvalidAddressTokenMsg: "无效的地址令牌",
|
||||
FailedUpdatePasswordMsg: "更新密码失败",
|
||||
PasswordLoginDisabledMsg: "密码登录已禁用",
|
||||
EmailPasswordRequiredMsg: "邮箱和密码不能为空",
|
||||
AddressNotFoundMsg: "邮箱地址不存在",
|
||||
}
|
||||
|
||||
export default messages;
|
||||
|
||||
79
worker/src/mails_api/address_auth.ts
Normal file
79
worker/src/mails_api/address_auth.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Context } from 'hono';
|
||||
import i18n from '../i18n';
|
||||
import { getBooleanValue, hashPassword } from '../utils';
|
||||
import { Jwt } from 'hono/utils/jwt';
|
||||
|
||||
export default {
|
||||
// 修改地址密码
|
||||
changePassword: async (c: Context<HonoCustomType>) => {
|
||||
const { new_password } = await c.req.json();
|
||||
const lang = c.get("lang") || c.env.DEFAULT_LANG;
|
||||
const msgs = i18n.getMessages(lang);
|
||||
const { address, address_id } = c.get("jwtPayload");
|
||||
|
||||
// 检查功能是否启用
|
||||
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
|
||||
return c.text(msgs.PasswordChangeDisabledMsg, 403);
|
||||
}
|
||||
|
||||
if (!new_password) {
|
||||
return c.text(msgs.NewPasswordRequiredMsg, 400);
|
||||
}
|
||||
|
||||
if (!address || !address_id) {
|
||||
return c.text(msgs.InvalidAddressTokenMsg, 400);
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address SET password = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
).bind(new_password, address_id).run();
|
||||
|
||||
if (!success) {
|
||||
return c.text(msgs.FailedUpdatePasswordMsg, 500);
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
},
|
||||
|
||||
// 地址密码登录
|
||||
login: async (c: Context<HonoCustomType>) => {
|
||||
const { email, password, cf_token } = await c.req.json();
|
||||
const lang = c.get("lang") || c.env.DEFAULT_LANG;
|
||||
const msgs = i18n.getMessages(lang);
|
||||
|
||||
// 检查功能是否启用
|
||||
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
|
||||
return c.text(msgs.PasswordLoginDisabledMsg, 403);
|
||||
}
|
||||
|
||||
if (!email || !password) {
|
||||
return c.text(msgs.EmailPasswordRequiredMsg, 400);
|
||||
}
|
||||
|
||||
// 查找地址
|
||||
const address = await c.env.DB.prepare(
|
||||
`SELECT * FROM address WHERE name = ?`
|
||||
).bind(email).first();
|
||||
|
||||
if (!address) {
|
||||
return c.text(msgs.AddressNotFoundMsg, 404);
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if (address.password !== password) {
|
||||
return c.text(msgs.InvalidEmailOrPasswordMsg, 401);
|
||||
}
|
||||
|
||||
// 创建JWT
|
||||
const jwt = await Jwt.sign({
|
||||
address: address.name,
|
||||
address_id: address.id
|
||||
}, c.env.JWT_SECRET, "HS256");
|
||||
|
||||
return c.json({
|
||||
jwt: jwt,
|
||||
address: address.name
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { CONSTANTS } from '../constants'
|
||||
import auto_reply from './auto_reply'
|
||||
import webhook_settings from './webhook_settings';
|
||||
import s3_attachment from './s3_attachment';
|
||||
import address_auth from './address_auth';
|
||||
|
||||
export const api = new Hono<HonoCustomType>()
|
||||
|
||||
@@ -198,3 +199,6 @@ api.delete('/api/clear_sent_items', async (c) => {
|
||||
success: success
|
||||
})
|
||||
})
|
||||
|
||||
api.post('/api/address_change_password', address_auth.changePassword)
|
||||
api.post('/api/address_login', address_auth.login)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { deleteAddressWithData, newAddress, generateRandomName } from "../common
|
||||
|
||||
export const tgUserNewAddress = async (
|
||||
c: Context<HonoCustomType>, userId: string, address: string
|
||||
): Promise<{ address: string, jwt: string }> => {
|
||||
): Promise<{ address: string, jwt: string, password?: string | null }> => {
|
||||
if (c.env.RATE_LIMITER) {
|
||||
const { success } = await c.env.RATE_LIMITER.limit(
|
||||
{ key: `${CONSTANTS.TG_KV_PREFIX}:${userId}` }
|
||||
|
||||
@@ -101,6 +101,7 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
|
||||
const res = await tgUserNewAddress(c, userId.toString(), address);
|
||||
return await ctx.reply(`创建地址成功:\n`
|
||||
+ `地址: ${res.address}\n`
|
||||
+ (res.password ? `密码: \`${res.password}\`\n` : '')
|
||||
+ `凭证: \`${res.jwt}\`\n`,
|
||||
{
|
||||
parse_mode: "Markdown"
|
||||
|
||||
1
worker/src/types.d.ts
vendored
1
worker/src/types.d.ts
vendored
@@ -40,6 +40,7 @@ type Bindings = {
|
||||
ENABLE_USER_CREATE_EMAIL: string | boolean | undefined
|
||||
DISABLE_ANONYMOUS_USER_CREATE_EMAIL: string | boolean | undefined
|
||||
ENABLE_USER_DELETE_EMAIL: string | boolean | undefined
|
||||
ENABLE_ADDRESS_PASSWORD: string | boolean | undefined
|
||||
ENABLE_INDEX_ABOUT: string | boolean | undefined
|
||||
DEFAULT_SEND_BALANCE: number | string | undefined
|
||||
NO_LIMIT_SEND_ROLE: string | undefined | null
|
||||
|
||||
@@ -296,6 +296,13 @@ export const checkUserPassword = (password: string) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const hashPassword = async (password: string): Promise<string> => {
|
||||
// use crypto to hash password
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
|
||||
const hashArray = Array.from(new Uint8Array(digest));
|
||||
return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export default {
|
||||
getJsonObjectValue,
|
||||
getSetting,
|
||||
|
||||
@@ -148,6 +148,10 @@ app.use('/api/*', async (c, next) => {
|
||||
) {
|
||||
await checkoutUserRolePayload(c);
|
||||
}
|
||||
if (c.req.path.startsWith("/api/address_login")) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
return await jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
|
||||
|
||||
@@ -70,6 +70,8 @@ ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_AUTO_REPLY = false
|
||||
# Allow webhook
|
||||
# ENABLE_WEBHOOK = true
|
||||
# Enable address password feature, if set true, will generate password for new address and support password login and change
|
||||
# ENABLE_ADDRESS_PASSWORD = false
|
||||
# Footer text
|
||||
# COPYRIGHT = "Dream Hunter"
|
||||
# DISABLE_SHOW_GITHUB = true
|
||||
|
||||
Reference in New Issue
Block a user