feat(auth): 支持开放注册

- 新增注册接口与注册页面,开放注册时可创建普通用户并自动登录。
- 公共配置增加开放注册状态,登录页可跳转注册入口。
- 补充注册流程测试,验证关闭注册、账号创建与登录行为。
This commit is contained in:
LanQin
2026-06-15 23:57:56 +08:00
parent f8ff2a814b
commit f7e6165b72
7 changed files with 208 additions and 6 deletions
+37
View File
@@ -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!"}, &registered); 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())
+72
View File
@@ -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))
+2 -1
View File
@@ -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) {
+3 -1
View File
@@ -44,9 +44,10 @@ export type SystemSettings = {
mailRefreshSeconds: number
}
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { 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<T>(path: string, init: RequestInit = {}): Promise<T> {
export const api = {
publicSettings: () => request<PublicSettings>("/api/public/settings"),
register: (payload: RegisterPayload) => request<{ user: User }>("/api/auth/register", { method: "POST", body: JSON.stringify(payload) }),
login: (payload: LoginPayload) => request<LoginResponse>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }),
logout: () => request<{ ok: boolean }>("/api/auth/logout", { method: "POST" }),
me: () => request<{ user: User }>("/api/me"),
+2
View File
@@ -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: <LoginPage /> },
{ path: "/register", element: <RegisterPage /> },
{ path: "/", element: <ProtectedLayout />, children: [
{ index: true, element: <MailPage /> },
{ path: "mail", element: <Navigate to="/" replace /> },
+9 -4
View File
@@ -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() {
<>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" name="email" type="email" defaultValue="admin@lanqin.local" required className="h-11 text-base" />
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input id="password" name="password" type="password" defaultValue="ChangeMe123!" required className="h-11 text-base" />
<Input id="password" name="password" type="password" autoComplete="current-password" required className="h-11 text-base" />
</div>
</>
) : (
@@ -62,6 +62,11 @@ export function LoginPage() {
{login.isPending ? "登录中..." : challengeToken ? "验证登录" : "登录"}
</Button>
{challengeToken && <Button type="button" variant="ghost" className="w-full" onClick={() => setChallengeToken("")}></Button>}
{!challengeToken && publicSettings.data?.openRegistration && (
<Button type="button" variant="ghost" className="w-full" asChild>
<Link to="/register"></Link>
</Button>
)}
</form>
</div>
</div>
@@ -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<HTMLDivElement | null>(null)
React.useEffect(() => {
if (!siteKey || !ref.current) return
+83
View File
@@ -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 <Navigate to="/" replace />
return (
<div className="grid min-h-screen place-items-center bg-background px-4">
<div className="w-full max-w-[360px]">
<div className="mb-10 text-center">
<h1 className="text-3xl font-bold tracking-tight"></h1>
</div>
{publicSettings.isSuccess && !publicSettings.data.openRegistration ? (
<div className="space-y-4">
<div className="rounded-md border p-4 text-center text-sm text-muted-foreground"></div>
<Button type="button" variant="outline" className="w-full" asChild>
<Link to="/login"></Link>
</Button>
</div>
) : (
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); if (turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; register.mutate(new FormData(e.currentTarget)) }}>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="displayName"></Label>
<Input id="displayName" name="displayName" autoComplete="name" className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input id="password" name="password" type="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input id="confirmPassword" name="confirmPassword" type="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
</div>
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
{register.isPending ? "注册中..." : "注册"}
</Button>
<Button type="button" variant="ghost" className="w-full" asChild>
<Link to="/login"></Link>
</Button>
</form>
)}
</div>
</div>
)
}