fix initial login setup and admin page load

This commit is contained in:
2026-05-22 06:21:04 +00:00
parent e8f9dea282
commit 4fa705d246
7 changed files with 215 additions and 5 deletions
+5 -5
View File
@@ -176,10 +176,9 @@ func run() error {
// httpServers collects all HTTP servers for graceful shutdown.
var httpServers []*http.Server
// Shared rate limiters: admin login is very strict (10 req/min, burst 3),
// webmail general is more relaxed (60 req/min, burst 10).
adminRL := middleware.NewRateLimiter(10, 3)
clientRL := middleware.NewRateLimiter(60, 10)
// Rate limiter for webmail (per IP). Admin panel is localhost-only so no
// IP rate limit there — brute guard handles login protection instead.
clientRL := middleware.NewRateLimiter(300, 30)
// ---- Web admin panel ----
if cfg.WebAdminPort > 0 {
@@ -192,7 +191,8 @@ func run() error {
FS: assets.AdminFS(),
}
adminSrv := webadmin.New(adminDeps)
adminHandler := middleware.SecureHeaders(adminRL.Middleware(adminSrv.Handler()))
// No rate limiter on admin — it binds 127.0.0.1 by default; brute guard is the defence.
adminHandler := middleware.SecureHeaders(adminSrv.Handler())
adminHTTP := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.WebAdminIface, cfg.WebAdminPort),
Handler: adminHandler,
+23
View File
@@ -63,6 +63,14 @@ func (d *DB) ResolveEmail(ctx context.Context, email string) (*models.User, erro
return d.GetUserByID(ctx, userID)
}
// HasAdminUser returns true if at least one enabled admin user exists.
func (d *DB) HasAdminUser(ctx context.Context) (bool, error) {
var n int
err := d.db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM users WHERE admin=1 AND enabled=1").Scan(&n)
return n > 0, err
}
// CreateUser inserts a new user. Returns the new ID.
func (d *DB) CreateUser(ctx context.Context, domainID int64, username, email, passwordHash, displayName string, quotaBytes int64, domainAdmin bool) (int64, error) {
res, err := d.db.ExecContext(ctx, `
@@ -78,6 +86,21 @@ func (d *DB) CreateUser(ctx context.Context, domainID int64, username, email, pa
return res.LastInsertId()
}
// CreateAdminUser inserts the first superadmin (admin=1). Used only during initial setup.
func (d *DB) CreateAdminUser(ctx context.Context, domainID int64, email, passwordHash string) (int64, error) {
username := email
res, err := d.db.ExecContext(ctx, `
INSERT INTO users
(domain_id, username, email, password_hash, display_name, quota_bytes,
enabled, admin, domain_admin, created_at)
VALUES (?, ?, ?, ?, ?, 0, 1, 1, 1, ?)`,
domainID, username, email, passwordHash, email, time.Now().UTC())
if err != nil {
return 0, fmt.Errorf("create admin user: %w", err)
}
return res.LastInsertId()
}
// UpdateUsedBytes sets the cached used_bytes for a user (approximate, updated on store).
func (d *DB) UpdateUsedBytes(ctx context.Context, userID int64, delta int64) error {
_, err := d.db.ExecContext(ctx,
+10
View File
@@ -21,12 +21,22 @@ const adminPreAuthCookieName = "mailgo_admin_preauth"
const adminPreAuthMaxAge = 300 // 5 minutes
// loginGet renders the admin login form.
// Redirects to /admin/setup if no admin user has been created yet.
func (s *Server) loginGet(w http.ResponseWriter, r *http.Request) {
// Already logged in → dashboard.
if s.currentAdmin(r) != nil {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
// First-run: no admin user exists yet.
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
if has, err := s.deps.DB.HasAdminUser(ctx); err == nil && !has {
http.Redirect(w, r, "/admin/setup", http.StatusSeeOther)
return
}
flash, errMsg := flashFrom(r)
s.render(w, "login", struct {
basePage
+116
View File
@@ -710,3 +710,119 @@ func (s *Server) generateDKIM(ctx context.Context, domainID int64, algo, selecto
func createDefaultMailboxes(ctx context.Context, database *db.DB, userID int64) error {
return database.CreateDefaultMailboxes(ctx, userID)
}
// ---- First-run setup ----
type setupPage struct {
basePage
}
// setupGet renders the initial setup form (only accessible when no admin user exists).
func (s *Server) setupGet(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
has, err := s.deps.DB.HasAdminUser(ctx)
if err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
if has {
// Setup already done — redirect to login.
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
flash, errMsg := flashFrom(r)
s.render(w, "setup", setupPage{basePage: newBaseNoUser(flash, errMsg)})
}
// setupPost creates the first admin user + its primary domain.
// Only callable when no admin user exists; subsequent calls redirect to login.
func (s *Server) setupPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
has, err := s.deps.DB.HasAdminUser(ctx)
if err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
if has {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
password := r.FormValue("password")
confirm := r.FormValue("confirm_password")
domain := strings.ToLower(strings.TrimSpace(r.FormValue("domain")))
// Validate.
if email == "" || len(email) > 254 || !strings.Contains(email, "@") {
redirect(w, r, "/admin/setup", "", "Invalid email address.")
return
}
if domain == "" || len(domain) > 253 || strings.Contains(domain, " ") {
redirect(w, r, "/admin/setup", "", "Invalid domain name.")
return
}
if len(password) < 8 || len(password) > 1024 {
redirect(w, r, "/admin/setup", "", "Password must be at least 8 characters.")
return
}
if password != confirm {
redirect(w, r, "/admin/setup", "", "Passwords do not match.")
return
}
hash, err := appCrypto.HashPassword(password)
if err != nil {
log.Printf("[admin] setup hash: %v", err)
redirect(w, r, "/admin/setup", "", "Internal error.")
return
}
// Create domain first (user references it).
selector := s.deps.Cfg.DKIMSelector
if selector == "" {
selector = "mail"
}
algo := s.deps.Cfg.DKIMAlgo
if algo == "" {
algo = "rsa2048"
}
domainID, err := s.deps.DB.CreateDomain(ctx, domain, selector, algo)
if err != nil {
log.Printf("[admin] setup create domain: %v", err)
redirect(w, r, "/admin/setup", "", "Failed to create domain.")
return
}
// Create admin user.
userID, err := s.deps.DB.CreateAdminUser(ctx, domainID, email, hash)
if err != nil {
log.Printf("[admin] setup create admin: %v", err)
redirect(w, r, "/admin/setup", "", "Failed to create admin user.")
return
}
// Default mailboxes + calendar + address book.
if err := createDefaultMailboxes(ctx, s.deps.DB, userID); err != nil {
log.Printf("[admin] setup mailboxes: %v", err)
}
if _, err := s.deps.DB.EnsureDefaultCalendar(ctx, userID); err != nil {
log.Printf("[admin] setup calendar: %v", err)
}
if _, err := s.deps.DB.EnsureDefaultAddressBook(ctx, userID); err != nil {
log.Printf("[admin] setup addressbook: %v", err)
}
log.Printf("[admin] setup complete: admin=%s domain=%s", email, domain)
redirect(w, r, "/admin/login", "Setup complete. Sign in with your new credentials.", "")
}
+11
View File
@@ -54,7 +54,18 @@ func (s *Server) Handler() http.Handler {
func (s *Server) setupRoutes() {
m := s.mux
// Root redirect — catches http://host:8081/ and http://host:8081 (no /admin/ prefix).
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "" {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
http.NotFound(w, r)
})
// Public
m.HandleFunc("GET /admin/setup", s.setupGet)
m.HandleFunc("POST /admin/setup", s.setupPost)
m.HandleFunc("GET /admin/login", s.loginGet)
m.HandleFunc("POST /admin/login", s.loginPost)
m.HandleFunc("GET /admin/login/mfa", s.mfaGet)
Executable
BIN
View File
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
{{define "title"}}Initial Setup{{end}}
{{define "content"}}
<div class="flex items-center justify-center min-h-screen -mt-6">
<div style="width:26rem">
<div class="text-center mb-8">
<div class="text-2xl font-bold text-white">mailgosend</div>
<div class="text-gray-400 text-sm mt-1">First-run setup</div>
</div>
<div class="card">
{{if .Error}}<div style="background:#7f1d1d;border:1px solid #991b1b;color:#fca5a5;padding:.625rem .875rem;border-radius:.375rem;margin-bottom:1rem;font-size:.8125rem">{{.Error}}</div>{{end}}
{{if .Flash}}<div style="background:#064e3b;border:1px solid #065f46;color:#6ee7b7;padding:.625rem .875rem;border-radius:.375rem;margin-bottom:1rem;font-size:.8125rem">{{.Flash}}</div>{{end}}
<p style="color:#9ca3af;font-size:.8125rem;margin-bottom:1.25rem">
No admin account exists yet. Create one below to get started.
This page disappears after the first admin account is created.
</p>
<form method="POST" action="/admin/setup" autocomplete="off">
<div class="field">
<label for="domain">Primary mail domain</label>
<input type="text" id="domain" name="domain" required
maxlength="253" placeholder="example.com"
autocomplete="off">
<div style="font-size:.7rem;color:#4b5563;margin-top:.25rem">Domain this server receives mail for.</div>
</div>
<div class="field">
<label for="email">Admin email address</label>
<input type="email" id="email" name="email" required
maxlength="254" placeholder="admin@example.com"
autocomplete="off">
</div>
<div class="field">
<label for="password">Password (min 8 characters)</label>
<input type="password" id="password" name="password" required
minlength="8" maxlength="1024"
autocomplete="new-password">
</div>
<div class="field">
<label for="confirm_password">Confirm password</label>
<input type="password" id="confirm_password" name="confirm_password" required
maxlength="1024" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary mt-2" style="width:100%">
Create admin account
</button>
</form>
</div>
</div>
</div>
{{end}}