From 18f8d870e8a4d7176f3aa3bf47a2e5bb5dbee2e1 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:27:29 +0800 Subject: [PATCH] feat: use usernames for administrator accounts --- README.md | 2 +- apps/api/internal/app/admin_handlers.go | 30 ++++++++++-- apps/api/internal/app/app.go | 17 +++++++ apps/api/internal/app/app_test.go | 63 +++++++++++++++++++++++++ apps/api/internal/app/auth_handlers.go | 8 +++- apps/api/internal/app/config.go | 2 + apps/api/internal/app/permissions.go | 3 ++ apps/api/internal/app/util.go | 11 +++++ apps/web/src/lib/api.ts | 2 +- apps/web/src/pages/admin.tsx | 3 +- apps/web/src/pages/profile.tsx | 12 ++--- deploy/.env.example | 5 +- deploy/README.md | 2 +- install.sh | 8 ++-- 14 files changed, 148 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index a5749e9..9a263e8 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install. 脚本会自动完成: - 安装或检查 Docker Engine 与 Docker Compose v2 -- 询问邮件域名、访问地址、管理员邮箱和密码 +- 询问邮件域名、访问地址、管理员用户名和密码 - 创建 `/opt/newszxcn-email` 持久化目录 - 拉取 GHCR 镜像并启动邮件服务 - 生成后台在线更新所需的内部鉴权令牌 diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index 72fb708..6450519 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -108,7 +108,13 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { return } actor := currentUser(r) - loginName, err := cleanLoginName(req.LoginName, req.Email) + var loginName string + var err error + if strings.TrimSpace(req.LoginName) != "" { + loginName, err = cleanUsername(req.LoginName) + } else { + loginName, err = cleanLoginName(req.Email) + } if err != nil { badRequest(w, err) return @@ -183,6 +189,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") current := currentUser(r) var req struct { + LoginName string `json:"loginName"` DisplayName string `json:"displayName"` Role string `json:"role"` Disabled *bool `json:"disabled"` @@ -211,6 +218,15 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotFound, "user not found") return } + requestedLoginName := strings.TrimSpace(req.LoginName) + loginName := existing.LoginName + if requestedLoginName != "" { + loginName, err = cleanUsername(requestedLoginName) + if err != nil { + badRequest(w, err) + return + } + } if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) { respondError(w, http.StatusForbidden, "only administrators can modify administrator users") return @@ -278,8 +294,16 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { return } defer tx.Rollback() - if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`, - displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil { + emailIdentity := existing.Email + if normalizeLoginName(existing.Email) == normalizeLoginName(existing.LoginName) { + emailIdentity = loginName + } + if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`, + loginName, emailIdentity, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + badRequest(w, errors.New("登录名已被使用")) + return + } respondError(w, http.StatusInternalServerError, "failed to update user") return } diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 47d7517..7c6b0c0 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -1410,6 +1410,18 @@ func (a *App) seed(ctx context.Context) error { } now := a.now().UTC().Format(time.RFC3339Nano) userID := newID("usr") + if strings.TrimSpace(a.cfg.AdminUsername) != "" { + adminUsername, err := cleanUsername(a.cfg.AdminUsername) + if err != nil { + return fmt.Errorf("invalid admin username: %w", err) + } + if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminUsername, adminUsername, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil { + return err + } + a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername) + return nil + } adminEmail := normalizeEmail(a.cfg.AdminEmail) if adminEmail == "" || !strings.Contains(adminEmail, "@") { return errors.New("invalid admin email") @@ -1451,6 +1463,11 @@ func (a *App) seed(ctx context.Context) error { } func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error { + if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") { + _, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`, + a.now().UTC().Format(time.RFC3339Nano), adminUsername) + return err + } adminEmail := normalizeEmail(a.cfg.AdminEmail) if adminEmail == "" || !strings.Contains(adminEmail, "@") { return nil diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 53842f6..e358634 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -1421,6 +1421,69 @@ func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T } } +func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) { + dir := t.TempDir() + cfg := Config{ + Addr: ":0", + DBPath: filepath.Join(dir, "lanqin.db"), + DataDir: filepath.Join(dir, "data"), + CookieName: "lanqin_test", + SessionTTLHours: 24, + AdminUsername: "admin", + AdminPassword: "ChangeMe123!", + PublicHostname: "mail.example.test", + PublicBaseURL: "http://localhost:5173", + AllowInsecureHTTP: true, + } + a := newTestAppWithConfig(t, cfg) + + var domains, mailboxes int + if err := a.db.QueryRow(`SELECT COUNT(*) FROM domains`).Scan(&domains); err != nil { + t.Fatal(err) + } + if err := a.db.QueryRow(`SELECT COUNT(*) FROM mailboxes`).Scan(&mailboxes); err != nil { + t.Fatal(err) + } + if domains != 0 || mailboxes != 0 { + t.Fatalf("username bootstrap created domains=%d mailboxes=%d", domains, mailboxes) + } + + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + var login struct { + User User `json:"user"` + } + if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("username login code=%d", code) + } + if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{ + "loginName": "rootadmin", + "displayName": "Administrator", + "role": "admin", + "disabled": false, + }, nil); code != http.StatusOK { + t.Fatalf("rename administrator code=%d", code) + } + if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{ + "loginName": "root@example.test", + "displayName": "Administrator", + "role": "admin", + "disabled": false, + }, nil); code != http.StatusBadRequest { + t.Fatalf("email-shaped login name code=%d", code) + } + + oldLogin := &testClient{t: t, server: ts} + if code := oldLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized { + t.Fatalf("old username login code=%d", code) + } + newLogin := &testClient{t: t, server: ts} + if code := newLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "rootadmin", "password": "ChangeMe123!"}, nil); code != http.StatusOK { + t.Fatalf("renamed username login code=%d", code) + } +} + func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) diff --git a/apps/api/internal/app/auth_handlers.go b/apps/api/internal/app/auth_handlers.go index fbb1a46..55eaf7e 100644 --- a/apps/api/internal/app/auth_handlers.go +++ b/apps/api/internal/app/auth_handlers.go @@ -50,7 +50,13 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusUnauthorized, "人机验证失败,请重试") return } - loginName, err := cleanLoginName(req.LoginName, req.Email) + var loginName string + var err error + if strings.TrimSpace(req.LoginName) != "" { + loginName, err = cleanUsername(req.LoginName) + } else { + loginName, err = cleanLoginName(req.Email) + } if err != nil { respondError(w, http.StatusUnauthorized, "账号或密码错误") return diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index 36729d2..8f3b70e 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -14,6 +14,7 @@ type Config struct { DataDir string CookieName string SessionTTLHours int + AdminUsername string AdminEmail string AdminPassword string PublicHostname string @@ -70,6 +71,7 @@ func LoadConfig() Config { DataDir: dataDir, CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"), SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7), + AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")), AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")), AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""), PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"), diff --git a/apps/api/internal/app/permissions.go b/apps/api/internal/app/permissions.go index 626ac50..6985ee4 100644 --- a/apps/api/internal/app/permissions.go +++ b/apps/api/internal/app/permissions.go @@ -1057,6 +1057,9 @@ func (a *App) isDefaultAdminUser(u *User) bool { if u == nil { return false } + if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") { + return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername) + } adminEmail := normalizeEmail(a.cfg.AdminEmail) return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail) } diff --git a/apps/api/internal/app/util.go b/apps/api/internal/app/util.go index 2aff503..f8fdd4f 100644 --- a/apps/api/internal/app/util.go +++ b/apps/api/internal/app/util.go @@ -187,6 +187,17 @@ func cleanLoginName(value string, fallbacks ...string) (string, error) { return loginName, nil } +func cleanUsername(value string) (string, error) { + username, err := cleanLoginName(value) + if err != nil { + return "", err + } + if strings.Contains(username, "@") { + return "", errors.New("登录名不能使用邮箱地址") + } + return username, nil +} + func dedupeEmails(items []string) []string { seen := map[string]bool{} out := make([]string, 0, len(items)) diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 2205ee8..9327e42 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -164,7 +164,7 @@ export const api = { defaultPermissionLimits: () => request("/api/admin/permission-limits/defaults"), deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }), createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), - updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), + updateUser: (id: string, payload: { loginName?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }), deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }), domains: () => request>("/api/admin/domains"), diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index ade0f62..948164d 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -1896,6 +1896,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: }, [user, open]) const mut = useMutation({ mutationFn: (form: FormData) => api.updateUser(user.id, { + loginName: String(form.get("loginName") || ""), displayName: String(form.get("displayName") || ""), role, disabled: disabled === "disabled", @@ -1910,7 +1911,7 @@ function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: 编辑账号
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> - +
setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} /> diff --git a/apps/web/src/pages/profile.tsx b/apps/web/src/pages/profile.tsx index b24768c..6dd06a9 100644 --- a/apps/web/src/pages/profile.tsx +++ b/apps/web/src/pages/profile.tsx @@ -340,7 +340,7 @@ export function ProfilePage() { const sidebarContent = (
- setDarkMode((v) => !v)} onBack={() => navigate("/")} /> + setDarkMode((v) => !v)} onBack={() => navigate("/")} />