feat(auth): 增强注册流程并完善默认邮箱初始化

- 注册页支持选择可用域名并自动创建邮箱,密码输入改为可见性切换组件。
- 后端默认管理员不再依赖固定密码,未配置时自动生成随机密码并创建对应域名、邮箱和欢迎消息。
- 公共配置接口返回可注册域名,补充 404 页面与相关路由。
- 更新测试以适配新的默认种子数据和邮箱创建逻辑。
This commit is contained in:
LanQin_
2026-06-16 10:39:49 +08:00
parent 45122162d0
commit 556389b734
12 changed files with 266 additions and 43 deletions
+43 -4
View File
@@ -300,6 +300,10 @@ func (a *App) migrate(ctx context.Context) error {
return nil
}
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
// that implicitly created an admin mailbox with display_name "LanQin Admin".
// Current seed() creates mailboxes with display_name = admin email, so this migration
// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema.
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
adminEmail := normalizeEmail(a.cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
@@ -610,7 +614,16 @@ func (a *App) seed(ctx context.Context) error {
return nil
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.cfg.AdminPassword), bcrypt.DefaultCost)
adminPassword := a.cfg.AdminPassword
if adminPassword == "" {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return err
}
adminPassword = base64.RawURLEncoding.EncodeToString(buf)
a.log.Warn("LANQIN_ADMIN_PASSWORD not set; generated random password", "password", adminPassword)
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
@@ -620,12 +633,38 @@ func (a *App) seed(ctx context.Context) error {
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return errors.New("invalid admin email")
}
_, err = a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, userID, a.cfg.AdminEmail, "LanQin Admin", "admin", string(passwordHash), 0, now, now)
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, userID, adminEmail, "LanQin Admin", "admin", string(passwordHash), 0, now, now); err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
// Create domain from admin email
parts := strings.SplitN(adminEmail, "@", 2)
localPart := parts[0]
domainName := normalizeDomain(parts[1])
var domainID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, domainName).Scan(&domainID); err != nil {
domainID, err = a.createDomainTx(ctx, nil, domainName)
if err != nil {
return err
}
a.log.Info("created domain for administrator", "domain", domainName)
} else {
a.log.Info("domain already exists for administrator", "domain", domainName)
}
// Create mailbox for admin
mailboxID, err := a.createMailboxWithPasswordHash(ctx, userID, domainID, localPart, adminEmail, string(passwordHash), 1024, "active")
if err != nil {
return err
}
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", a.cfg.AdminEmail)
a.log.Info("created mailbox for administrator", "address", adminEmail)
// Send welcome message
if err := a.seedWelcomeMessage(ctx, mailboxID); err != nil {
a.log.Warn("failed to create welcome message", "error", err)
}
return nil
}
+44 -20
View File
@@ -208,7 +208,13 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
t.Fatalf("login code=%d body=%v", code, login)
}
domainID := createTestDomain(t, admin, "lanqin.local").ID
var domainList = struct {
Items []Domain `json:"items"`
}{}
if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 {
t.Fatalf("list domains code=%d items=%+v", code, domainList.Items)
}
domainID := domainList.Items[0].ID
mb1 := createTestMailbox(t, admin, domainID, "alice", "Alice", "Password123!", nil)
mb2 := createTestMailbox(t, admin, domainID, "bob", "Bob", "Password123!", nil)
@@ -322,8 +328,8 @@ func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
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)
if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 {
t.Fatalf("registered user should get auto-created mailbox: code=%d items=%+v", code, mine.Items)
}
another := &testClient{t: t, server: ts}
@@ -353,17 +359,20 @@ func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T
t.Cleanup(func() { _ = a.Close() })
ctx := context.Background()
var adminID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM users WHERE email=?`, cfg.AdminEmail).Scan(&adminID); err != nil {
// seed() now creates user + domain gmail.com + mailbox lanqinnet@gmail.com
// with display_name = admin email (not "LanQin Admin").
// Modify the mailbox to look like the old legacy pattern so the migration can find it.
if _, err := a.db.ExecContext(ctx, `UPDATE mailboxes SET display_name='LanQin Admin' WHERE address=?`, cfg.AdminEmail); err != nil {
t.Fatal(err)
}
domainID, err := a.createDomainTx(ctx, nil, "gmail.com")
if err != nil {
t.Fatal(err)
}
if _, err := a.createMailbox(ctx, adminID, domainID, "lanqinnet", "LanQin Admin", "Password123!", 1024, "active"); err != nil {
// Get the domain ID for the verification step
var domainID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "gmail.com").Scan(&domainID); err != nil {
t.Fatal(err)
}
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
t.Fatal(err)
}
@@ -455,8 +464,14 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
t.Fatalf("login code=%d body=%v", code, login)
}
domainID := createTestDomain(t, admin, "lanqin.local").ID
createTestMailbox(t, admin, domainID, "admin", "Admin", "ChangeMe123!", map[string]any{"ownerEmail": "admin@lanqin.local", "role": "admin"})
// seed() already created domain lanqin.local and mailbox admin@lanqin.local
var domainList = struct {
Items []Domain `json:"items"`
}{}
if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 {
t.Fatalf("list domains code=%d items=%+v", code, domainList.Items)
}
domainID := domainList.Items[0].ID
primary := createTestMailbox(t, admin, domainID, "multi", "Multi", "Password123!", nil)
secondary := createTestMailbox(t, admin, domainID, "multi-work", "Multi Work", "Password456!", map[string]any{"ownerEmail": primary.Address})
@@ -506,9 +521,13 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) {
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
domainID := createTestDomain(t, admin, "lanqin.local").ID
createTestMailbox(t, admin, domainID, "admin", "Admin", "ChangeMe123!", map[string]any{"ownerEmail": "admin@lanqin.local", "role": "admin"})
// seed() already created domain lanqin.local and mailbox admin@lanqin.local
var domainList = struct {
Items []Domain `json:"items"`
}{}
if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 {
t.Fatalf("list domains code=%d items=%+v", code, domainList.Items)
}
payload := map[string]any{
"to": []string{"ghost@lanqin.local"},
"subject": "should be rejected by default",
@@ -707,8 +726,8 @@ func TestUserTwoFactorSetupAndLogin(t *testing.T) {
func TestDNSRecords(t *testing.T) {
a := newTestApp(t)
domainID, err := a.createDomainTx(context.Background(), nil, "lanqin.local")
if err != nil {
var domainID string
if err := a.db.QueryRowContext(context.Background(), `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil {
t.Fatal(err)
}
d, err := a.domainByID(context.Background(), domainID)
@@ -729,15 +748,20 @@ func TestMaildirSyncImportsRFC822(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
domainID, err := a.createDomainTx(ctx, nil, "lanqin.local")
if err != nil {
var domainID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil {
t.Fatal(err)
}
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
}
if _, err := a.createMailbox(ctx, adminUser.ID, domainID, "admin", "Admin", "ChangeMe123!", 1024, "active"); err != nil {
// seed() already created mailbox admin@lanqin.local
var mailboxID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mailboxes WHERE user_id=? AND address=?`, adminUser.ID, "admin@lanqin.local").Scan(&mailboxID); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE mailbox_id=?`, mailboxID); err != nil {
t.Fatal(err)
}
+34
View File
@@ -85,6 +85,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
DisplayName string `json:"displayName"`
Password string `json:"password"`
TurnstileToken string `json:"turnstileToken"`
DomainID string `json:"domainId"`
LocalPart string `json:"localPart"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -143,6 +145,38 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to create session")
return
}
// Create a mailbox for the registered user
var mailboxDomainID string
var mailboxLocalPart string
if strings.TrimSpace(req.DomainID) != "" && strings.TrimSpace(req.LocalPart) != "" {
// User selected a specific domain and local part
mailboxDomainID = strings.TrimSpace(req.DomainID)
mailboxLocalPart = normalizeLocalPart(req.LocalPart)
} else {
// Auto-detect: use the first active domain and email local part
if err := a.db.QueryRowContext(r.Context(), `SELECT id FROM domains WHERE status='active' ORDER BY created_at ASC LIMIT 1`).Scan(&mailboxDomainID); err != nil {
mailboxDomainID = ""
}
if mailboxDomainID != "" {
mailboxLocalPart = strings.SplitN(email, "@", 2)[0]
}
}
if mailboxDomainID != "" && mailboxLocalPart != "" {
// Check reserved prefixes
reserved := map[string]bool{}
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
reserved[item] = true
}
if reserved[mailboxLocalPart] {
respondError(w, http.StatusForbidden, "localPart is reserved")
return
}
if _, mbErr := a.createMailboxWithPasswordHash(r.Context(), user.ID, mailboxDomainID, mailboxLocalPart, displayName, string(passwordHash), 1024, "active"); mbErr != nil {
a.log.Warn("failed to create mailbox for registered user", "error", mbErr, "email", email)
}
}
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
}
+1 -1
View File
@@ -47,7 +47,7 @@ func LoadConfig() Config {
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", "ChangeMe123!"),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
+29 -6
View File
@@ -60,11 +60,17 @@ type systemSettingsUpdate struct {
}
type PublicSettings struct {
OpenRegistration bool `json:"openRegistration"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshMs int `json:"mailRefreshMs"`
OpenRegistration bool `json:"openRegistration"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshMs int `json:"mailRefreshMs"`
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
}
type PublicDomain struct {
ID string `json:"id"`
Name string `json:"name"`
}
type smtpTestRequest struct {
@@ -81,7 +87,24 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
if refreshSeconds <= 0 {
refreshSeconds = 30
}
respondJSON(w, http.StatusOK, PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000})
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000}
// Include available domains for mailbox creation during registration
if a.cfg.OpenRegistration {
rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`)
if err == nil {
defer rows.Close()
for rows.Next() {
var d PublicDomain
if err := rows.Scan(&d.ID, &d.Name); err != nil {
continue
}
settings.MailboxDomains = append(settings.MailboxDomains, d)
}
}
}
respondJSON(w, http.StatusOK, settings)
}
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {