From e44637fb2a9b70c7a6f196db8f9b8071f07ab692 Mon Sep 17 00:00:00 2001 From: Snowykami Date: Wed, 24 Sep 2025 09:33:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98=E5=8C=96=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E9=AA=8C=E8=AF=81=E7=A0=81=E5=86=B7=E5=8D=B4=E6=97=B6?= =?UTF-8?q?=E9=97=B4=EF=BC=8C=E6=94=B9=E8=BF=9B=E7=94=A8=E6=88=B7=E4=BD=93?= =?UTF-8?q?=E9=AA=8CCloses=20#17?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/middleware/captcha.go | 5 ++- internal/service/user.go | 8 +++- pkg/constant/constant.go | 6 ++- pkg/utils/email.go | 12 ++---- pkg/utils/email_test.go | 15 +++++++ web/src/api/user.ts | 6 +-- web/src/app/auth/register/page.tsx | 2 +- web/src/app/auth/reset-password/page.tsx | 2 +- web/src/components/auth/login/login-form.tsx | 2 +- .../auth/register/register-form.tsx | 36 +++++++++++----- .../reset-password/reset-password-form.tsx | 41 ++++++++++++++----- .../common/captcha/dialog-captcha.tsx | 21 ++++++++++ web/src/components/common/captcha/index.tsx | 11 +---- .../components/common/captcha/turnstile.tsx | 2 +- .../console/user-security/index.tsx | 2 +- web/src/config.ts | 1 + web/src/locales/zh-CN.json | 1 + web/src/models/captcha.ts | 7 ---- web/src/types/captcha.ts | 16 ++++++++ 19 files changed, 138 insertions(+), 58 deletions(-) create mode 100644 web/src/components/common/captcha/dialog-captcha.tsx create mode 100644 web/src/types/captcha.ts diff --git a/internal/middleware/captcha.go b/internal/middleware/captcha.go index dc78731..88be9e8 100644 --- a/internal/middleware/captcha.go +++ b/internal/middleware/captcha.go @@ -5,6 +5,7 @@ import ( "github.com/cloudwego/hertz/pkg/app" "github.com/sirupsen/logrus" + "github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/resps" "github.com/snowykami/neo-blog/pkg/utils" ) @@ -14,8 +15,8 @@ func UseCaptcha() app.HandlerFunc { captchaConfig := utils.Captcha.GetCaptchaConfigFromEnv() return func(ctx context.Context, c *app.RequestContext) { CaptchaToken := string(c.GetHeader("X-Captcha-Token")) - if utils.IsDevMode && CaptchaToken == utils.Env.Get("CAPTCHA_DEV_PASSCODE", "dev_passcode") { - // 开发模式直接通过密钥 + if utils.IsDevMode && CaptchaToken == utils.Env.Get(constant.EnvKeyPasscode, constant.DefaultCaptchaDevPasscode) { + // 开发模式直接通过密钥,开启开发模式后,Captcha可被绕过,请注意安全 c.Next(ctx) return } diff --git a/internal/service/user.go b/internal/service/user.go index 865e9bd..69d99e3 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -114,7 +114,13 @@ func (s *UserService) UserRegister(req *dto.UserRegisterReq) (*dto.UserRegisterR func (s *UserService) RequestVerifyEmail(req *dto.VerifyEmailReq) (*dto.VerifyEmailResp, error) { verifyCode := utils.RequestEmailVerify(req.Email) - template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{}) + template, err := static.RenderTemplate("email/verification-code.tmpl", map[string]interface{}{ + "Title": "NEO-BLOG", + "Email": req.Email, + "VerifyCode": verifyCode, + "Expire": 10, + "Details": "你正在验证电子邮件所有权", + }) if err != nil { return nil, errs.ErrInternalServer } diff --git a/pkg/constant/constant.go b/pkg/constant/constant.go index ddcdda4..0a36e64 100644 --- a/pkg/constant/constant.go +++ b/pkg/constant/constant.go @@ -14,7 +14,8 @@ const ( RoleEditor = "editor" // 能够发布和管理自己内容的用户 RoleAdmin = "admin" DefaultFileBasePath = "./data/uploads" - EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL + EnvKeyBaseUrl = "BASE_URL" // 环境变量:基础URL + EnvKeyPasscode = "\"CAPTCHA_DEV_PASSCODE\"" EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者 EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥 EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url @@ -64,7 +65,8 @@ const ( OidcUri = "/user/oidc/login" // OIDC登录URI OidcProviderTypeMisskey = "misskey" // OIDC提供者类型:Misskey OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型:GitHub - DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl + DefaultCaptchaDevPasscode = "dev_passcode" + DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl DefaultPasswordSalt = "default_salt_114514" TargetTypePost = "post" TargetTypeComment = "comment" diff --git a/pkg/utils/email.go b/pkg/utils/email.go index c0e1d93..c33097d 100644 --- a/pkg/utils/email.go +++ b/pkg/utils/email.go @@ -25,21 +25,17 @@ type EmailConfig struct { SSL bool // 是否使用SSL } -// SendTemplate 发送HTML模板,从配置文件中读取邮箱配置,支持上下文控制 -func (e *emailUtils) SendTemplate(emailConfig *EmailConfig, target, subject, htmlTemplate string, data map[string]interface{}) error { +func (e *emailUtils) RenderTemplate(htmlTemplate string, data map[string]interface{}) (string, error) { // 使用Go的模板系统处理HTML模板 tmpl, err := template.New("email").Parse(htmlTemplate) if err != nil { - return fmt.Errorf("解析模板失败: %w", err) + return "", fmt.Errorf("解析模板失败: %w", err) } - var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { - return fmt.Errorf("执行模板失败: %w", err) + return "", fmt.Errorf("执行模板失败: %w", err) } - - // 发送处理后的HTML内容 - return e.SendEmail(emailConfig, target, subject, buf.String(), true) + return buf.String(), nil } // SendEmail 使用gomail库发送邮件 diff --git a/pkg/utils/email_test.go b/pkg/utils/email_test.go index d4b585b..6d67bba 100644 --- a/pkg/utils/email_test.go +++ b/pkg/utils/email_test.go @@ -1 +1,16 @@ package utils + +import "testing" + +func TestEmailUtils_SendEmail(t *testing.T) { + templateString := "{{.A}} {{.B}} {{.C}}" + data := map[string]interface{}{"A": 1, "B": 2, "C": 3} + rendered, err := Email.RenderTemplate(templateString, data) + if err != nil { + t.Fatalf("RenderTemplate failed: %v", err) + } + expected := "1 2 3" + if rendered != expected { + t.Errorf("RenderTemplate = %q; want %q", rendered, expected) + } +} diff --git a/web/src/api/user.ts b/web/src/api/user.ts index d470e34..3f2f6a8 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -1,7 +1,7 @@ import type { OidcConfig } from '@/models/oidc-config' import type { BaseResponse } from '@/models/resp' import type { User } from '@/models/user' -import { CaptchaProvider } from '@/models/captcha' +import { CaptchaProvider } from '@/types/captcha' import axiosClient from './client' export async function userLogin( @@ -97,8 +97,8 @@ export async function updateUser(data: Partial): Promise> { - const res = await axiosClient.post>('/user/email/verify', { email }) +export async function requestEmailVerifyCode({email, captchaToken}: {email: string, captchaToken?: string}): Promise> { + const res = await axiosClient.post>('/user/email/verify', { email }, { headers: { 'X-Captcha-Token': captchaToken } }) return res.data } diff --git a/web/src/app/auth/register/page.tsx b/web/src/app/auth/register/page.tsx index 29e5476..67ce6c4 100644 --- a/web/src/app/auth/register/page.tsx +++ b/web/src/app/auth/register/page.tsx @@ -5,7 +5,7 @@ import { RegisterForm } from '@/components/auth/register/register-form' function PageContent() { return (
-
+
diff --git a/web/src/app/auth/reset-password/page.tsx b/web/src/app/auth/reset-password/page.tsx index 6ba8175..4a9a3dc 100644 --- a/web/src/app/auth/reset-password/page.tsx +++ b/web/src/app/auth/reset-password/page.tsx @@ -3,7 +3,7 @@ import { ResetPasswordForm } from "@/components/auth/reset-password/reset-passwo export default function Page() { return (
-
+
diff --git a/web/src/components/auth/login/login-form.tsx b/web/src/components/auth/login/login-form.tsx index f901944..e0ff1df 100644 --- a/web/src/components/auth/login/login-form.tsx +++ b/web/src/components/auth/login/login-form.tsx @@ -18,7 +18,7 @@ import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" import { useTranslations } from "next-intl" import Captcha from "@/components/common/captcha" -import { CaptchaProvider } from "@/models/captcha" +import { CaptchaProvider } from "@/types/captcha" import { toast } from "sonner" import { useAuth } from "@/contexts/auth-context" import { registerPath, resetPasswordPath } from "@/hooks/use-route" diff --git a/web/src/components/auth/register/register-form.tsx b/web/src/components/auth/register/register-form.tsx index f5f8773..8e983ed 100644 --- a/web/src/components/auth/register/register-form.tsx +++ b/web/src/components/auth/register/register-form.tsx @@ -15,7 +15,7 @@ import { getCaptchaConfig, requestEmailVerifyCode, userRegister } from "@/api/us import { useRouter, useSearchParams } from "next/navigation" import { useTranslations } from "next-intl" import Captcha from "@/components/common/captcha" -import { CaptchaProvider } from "@/models/captcha" +import { CaptchaProvider } from "@/types/captcha" import { toast } from "sonner" import { CurrentLogged } from "@/components/auth/common/current-logged" import { SectionDivider } from "@/components/common/section-divider" @@ -41,13 +41,22 @@ export function RegisterForm({ url?: string } | null>(null) const [captchaToken, setCaptchaToken] = useState(null) - const [isLogging, setIsLogging] = useState(false) const [refreshCaptchaKey, setRefreshCaptchaKey] = useState(0) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [email, setEmail] = useState('') const [verifyCode, setVerifyCode] = useState('') + const [sendingVerifyCode, setSendingVerifyCode] = useState(false) + const [registering, setRegistering] = useState(false) + const [coolDown, setCoolDown] = useState(0) + useEffect(() => { + if (coolDown <= 0) return + const id = setInterval(() => { + setCoolDown(c => (c > 1 ? c - 1 : 0)) + }, 1000) + return () => clearInterval(id) + }, [coolDown]) useEffect(() => { getCaptchaConfig() @@ -68,13 +77,19 @@ export function RegisterForm({ } const handleSendVerifyCode = () => { - requestEmailVerifyCode(email) + if (!email || coolDown > 0 || sendingVerifyCode) return; + setSendingVerifyCode(true); + requestEmailVerifyCode({ email, captchaToken: captchaToken || '' }) .then(() => { toast.success(t("send_verify_code_success")) }) .catch((error: BaseErrorResponse) => { toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`) }) + .finally(() => { + setSendingVerifyCode(false); + setCoolDown(60); + }) } const handleRegister = () => { @@ -83,9 +98,9 @@ export function RegisterForm({ return; } if (!captchaToken) { - toast.error(t("please_complete_captcha_verification")); return; } + setRegistering(true) userRegister({ username, password, email, verifyCode, captchaToken }) .then(res => { toast.success(t("register_success") + ` ${res.data.user.nickname || res.data.user.username}`); @@ -98,12 +113,12 @@ export function RegisterForm({ setCaptchaToken(null) }) .finally(() => { - setIsLogging(false) + setRegistering(false) }) } return ( -
+
{t("title")} @@ -164,11 +179,10 @@ export function RegisterForm({ setVerifyCode(value)} /> -
-
{captchaProps &&
@@ -179,9 +193,9 @@ export function RegisterForm({ type="button" className="w-full" onClick={handleRegister} - disabled={!captchaToken || isLogging || !username || !password || !email} + disabled={!captchaToken || registering || !username || !password || !email || !(verifyCode.length == 6)} > - {isLogging ? t("registering") : t("register")} + {registering ? t("registering") : t("register")} {/* 注册链接 */}
diff --git a/web/src/components/auth/reset-password/reset-password-form.tsx b/web/src/components/auth/reset-password/reset-password-form.tsx index c780858..7897967 100644 --- a/web/src/components/auth/reset-password/reset-password-form.tsx +++ b/web/src/components/auth/reset-password/reset-password-form.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { useState } from "react" +import { useEffect, useState } from "react" import { requestEmailVerifyCode, resetPassword } from "@/api/user" import { useTranslations } from "next-intl" import { toast } from "sonner" @@ -28,15 +28,31 @@ export function ResetPasswordForm({ const [email, setEmail] = useState("") const [verifyCode, setVerifyCode] = useState("") const [newPassword, setNewPassword] = useState("") + const [coolDown, setCoolDown] = useState(0) + const [sendingVerifyCode, setSendingVerifyCode] = useState(false) + + useEffect(() => { + if (coolDown <= 0) return + const id = setInterval(() => { + setCoolDown(c => (c > 1 ? c - 1 : 0)) + }, 1000) + return () => clearInterval(id) + }, [coolDown]) const handleSendVerifyCode = () => { - requestEmailVerifyCode(email) + if (coolDown > 0 || !email || sendingVerifyCode) return + setSendingVerifyCode(true) + requestEmailVerifyCode({ email }) .then(() => { toast.success(t("send_verify_code_success")) }) .catch((error: BaseErrorResponse) => { toast.error(`${t("send_verify_code_failed")}: ${error.response.data.message}`) }) + .finally(() => { + setSendingVerifyCode(false) + setCoolDown(60) + }) } const handleResetPassword = () => { @@ -66,18 +82,23 @@ export function ResetPasswordForm({
setEmail(e.target.value)} /> - +
- setVerifyCode(value)} /> +
+ setVerifyCode(value)} /> + +
+