fix initial login setup and admin page load
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.", "")
|
||||
}
|
||||
|
||||
@@ -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
Binary file not shown.
@@ -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}}
|
||||
Reference in New Issue
Block a user