diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 25e219f..cd8dbfa 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -268,6 +268,43 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) { } } +func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + client := &testClient{t: t, server: ts} + + var out map[string]any + if code := client.do("POST", "/api/auth/register", map[string]string{"email": "newuser@example.com", "displayName": "New User", "password": "Password123!"}, &out); code != http.StatusForbidden { + t.Fatalf("closed registration code=%d body=%v", code, out) + } + + a.cfg.OpenRegistration = true + var registered struct { + User User `json:"user"` + } + if code := client.do("POST", "/api/auth/register", map[string]string{"email": "newuser@example.com", "displayName": "New User", "password": "Password123!"}, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@example.com" || registered.User.Role != "user" { + t.Fatalf("register code=%d user=%+v", code, registered.User) + } + var me struct { + User User `json:"user"` + } + if code := client.do("GET", "/api/me", nil, &me); code != http.StatusOK || me.User.Email != "newuser@example.com" { + t.Fatalf("me code=%d user=%+v", code, me.User) + } + var mine struct { + Items []Mailbox `json:"items"` + } + if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 0 { + t.Fatalf("registered user should not get implicit mailbox: code=%d items=%+v", code, mine.Items) + } + + another := &testClient{t: t, server: ts} + if code := another.do("POST", "/api/auth/login", map[string]string{"email": "newuser@example.com", "password": "Password123!"}, &out); code != http.StatusOK { + t.Fatalf("login registered user code=%d body=%v", code, out) + } +} + func TestUserCanSelectMultipleMailboxes(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index f4827f1..8cc0571 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -31,6 +31,7 @@ func (a *App) Router() http.Handler { r.Route("/api", func(r chi.Router) { r.Get("/public/settings", a.handlePublicSettings) + r.Post("/auth/register", a.handleRegister) r.Post("/auth/login", a.handleLogin) r.Post("/auth/logout", a.handleLogout) r.With(a.requireAuth).Get("/me", a.handleMe) @@ -193,6 +194,77 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{"user": user}) } +func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { + if !a.cfg.OpenRegistration { + respondError(w, http.StatusForbidden, "registration is closed") + return + } + var req struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + TurnstileToken string `json:"turnstileToken"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + if err := a.verifyTurnstile(r.Context(), req.TurnstileToken, r.RemoteAddr); err != nil { + respondError(w, http.StatusUnauthorized, "human verification failed") + return + } + email := normalizeEmail(req.Email) + if email == "" || !strings.Contains(email, "@") { + badRequest(w, errors.New("invalid email")) + return + } + if len(req.Password) < 8 { + badRequest(w, errors.New("password must be at least 8 characters")) + return + } + displayName := strings.TrimSpace(req.DisplayName) + if displayName == "" { + displayName = strings.Split(email, "@")[0] + } + if len([]rune(displayName)) > 80 { + badRequest(w, errors.New("displayName must be at most 80 characters")) + return + } + if _, _, err := a.userByEmail(r.Context(), email); err == nil { + respondError(w, http.StatusConflict, "email already registered") + return + } else if !errors.Is(err, errNotFound) { + respondError(w, http.StatusInternalServerError, "failed to check user") + return + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to hash password") + return + } + now := a.now().UTC().Format(time.RFC3339Nano) + userID := newID("usr") + if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", string(passwordHash), 0, now, now); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + respondError(w, http.StatusConflict, "email already registered") + return + } + respondError(w, http.StatusInternalServerError, "failed to create user") + return + } + user, err := a.userByID(r.Context(), userID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load user") + return + } + if err := a.issueSession(w, r, user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to create session") + return + } + respondJSON(w, http.StatusCreated, map[string]any{"user": user}) +} + func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(a.cfg.CookieName); err == nil { _, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value)) diff --git a/apps/api/internal/app/settings_handlers.go b/apps/api/internal/app/settings_handlers.go index 2856390..e275272 100644 --- a/apps/api/internal/app/settings_handlers.go +++ b/apps/api/internal/app/settings_handlers.go @@ -54,6 +54,7 @@ type systemSettingsUpdate struct { } type PublicSettings struct { + OpenRegistration bool `json:"openRegistration"` TurnstileEnabled bool `json:"turnstileEnabled"` TurnstileSiteKey string `json:"turnstileSiteKey"` MailAutoRefresh bool `json:"mailAutoRefresh"` @@ -74,7 +75,7 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) { if refreshSeconds <= 0 { refreshSeconds = 30 } - respondJSON(w, http.StatusOK, PublicSettings{TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000}) + respondJSON(w, http.StatusOK, PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000}) } func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 2406f0b..259174f 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -44,9 +44,10 @@ export type SystemSettings = { mailRefreshSeconds: number } export type SystemSettingsPayload = Omit & { smtpPassword: string; turnstileSecretKey: string } -export type PublicSettings = { turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number } +export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number } export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string } export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string } +export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string } const REQUEST_TIMEOUT_MS = 15_000 @@ -78,6 +79,7 @@ async function request(path: string, init: RequestInit = {}): Promise { export const api = { publicSettings: () => request("/api/public/settings"), + register: (payload: RegisterPayload) => request<{ user: User }>("/api/auth/register", { method: "POST", body: JSON.stringify(payload) }), login: (payload: LoginPayload) => request("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }), logout: () => request<{ ok: boolean }>("/api/auth/logout", { method: "POST" }), me: () => request<{ user: User }>("/api/me"), diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 3896b6e..93cd9a5 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -5,6 +5,7 @@ import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom" import { Toaster } from "@/components/ui/toaster" import { ProtectedLayout } from "@/components/protected-layout" import { LoginPage } from "@/pages/login" +import { RegisterPage } from "@/pages/register" import { MailPage } from "@/pages/mail" import { AdminPage } from "@/pages/admin" import { ProfilePage } from "@/pages/profile" @@ -14,6 +15,7 @@ import "./index.css" const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } }) const router = createBrowserRouter([ { path: "/login", element: }, + { path: "/register", element: }, { path: "/", element: , children: [ { index: true, element: }, { path: "mail", element: }, diff --git a/apps/web/src/pages/login.tsx b/apps/web/src/pages/login.tsx index 310a7c5..4f30398 100644 --- a/apps/web/src/pages/login.tsx +++ b/apps/web/src/pages/login.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import { Navigate } from "react-router-dom" +import { Link, Navigate } from "react-router-dom" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { api } from "@/lib/api" import { useMe } from "@/hooks/use-me" @@ -42,11 +42,11 @@ export function LoginPage() { <>
- +
- +
) : ( @@ -62,6 +62,11 @@ export function LoginPage() { {login.isPending ? "登录中..." : challengeToken ? "验证登录" : "登录"} {challengeToken && } + {!challengeToken && publicSettings.data?.openRegistration && ( + + )} @@ -77,7 +82,7 @@ declare global { } } -function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) { +export function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) { const ref = React.useRef(null) React.useEffect(() => { if (!siteKey || !ref.current) return diff --git a/apps/web/src/pages/register.tsx b/apps/web/src/pages/register.tsx new file mode 100644 index 0000000..6e8074f --- /dev/null +++ b/apps/web/src/pages/register.tsx @@ -0,0 +1,83 @@ +import * as React from "react" +import { Link, Navigate, useNavigate } from "react-router-dom" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { api } from "@/lib/api" +import { useMe } from "@/hooks/use-me" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { useToast } from "@/hooks/use-toast" +import { TurnstileBox } from "@/pages/login" + +export function RegisterPage() { + const me = useMe() + const qc = useQueryClient() + const navigate = useNavigate() + const { toast } = useToast() + const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings }) + const [turnstileToken, setTurnstileToken] = React.useState("") + const register = useMutation({ + mutationFn: (form: FormData) => { + const password = String(form.get("password") || "") + const confirmPassword = String(form.get("confirmPassword") || "") + if (password !== confirmPassword) throw new Error("两次输入的密码不一致") + return api.register({ + email: String(form.get("email") || ""), + displayName: String(form.get("displayName") || ""), + password, + turnstileToken, + }) + }, + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: ["me"] }) + toast({ title: "注册成功" }) + navigate("/profile", { replace: true }) + }, + onError: (e) => toast({ title: "注册失败", description: e.message }), + }) + const turnstileRequired = !!publicSettings.data?.turnstileEnabled + if (me.data?.user) return + return ( +
+
+
+

注册账号

+
+ {publicSettings.isSuccess && !publicSettings.data.openRegistration ? ( +
+
当前未开放注册
+ +
+ ) : ( +
{ e.preventDefault(); if (turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; register.mutate(new FormData(e.currentTarget)) }}> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {turnstileRequired && } + + + + )} +
+
+ ) +}