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.
|
// httpServers collects all HTTP servers for graceful shutdown.
|
||||||
var httpServers []*http.Server
|
var httpServers []*http.Server
|
||||||
|
|
||||||
// Shared rate limiters: admin login is very strict (10 req/min, burst 3),
|
// Rate limiter for webmail (per IP). Admin panel is localhost-only so no
|
||||||
// webmail general is more relaxed (60 req/min, burst 10).
|
// IP rate limit there — brute guard handles login protection instead.
|
||||||
adminRL := middleware.NewRateLimiter(10, 3)
|
clientRL := middleware.NewRateLimiter(300, 30)
|
||||||
clientRL := middleware.NewRateLimiter(60, 10)
|
|
||||||
|
|
||||||
// ---- Web admin panel ----
|
// ---- Web admin panel ----
|
||||||
if cfg.WebAdminPort > 0 {
|
if cfg.WebAdminPort > 0 {
|
||||||
@@ -192,7 +191,8 @@ func run() error {
|
|||||||
FS: assets.AdminFS(),
|
FS: assets.AdminFS(),
|
||||||
}
|
}
|
||||||
adminSrv := webadmin.New(adminDeps)
|
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{
|
adminHTTP := &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%d", cfg.WebAdminIface, cfg.WebAdminPort),
|
Addr: fmt.Sprintf("%s:%d", cfg.WebAdminIface, cfg.WebAdminPort),
|
||||||
Handler: adminHandler,
|
Handler: adminHandler,
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ func (d *DB) ResolveEmail(ctx context.Context, email string) (*models.User, erro
|
|||||||
return d.GetUserByID(ctx, userID)
|
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.
|
// 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) {
|
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, `
|
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()
|
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).
|
// 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 {
|
func (d *DB) UpdateUsedBytes(ctx context.Context, userID int64, delta int64) error {
|
||||||
_, err := d.db.ExecContext(ctx,
|
_, err := d.db.ExecContext(ctx,
|
||||||
|
|||||||
@@ -21,12 +21,22 @@ const adminPreAuthCookieName = "mailgo_admin_preauth"
|
|||||||
const adminPreAuthMaxAge = 300 // 5 minutes
|
const adminPreAuthMaxAge = 300 // 5 minutes
|
||||||
|
|
||||||
// loginGet renders the admin login form.
|
// 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) {
|
func (s *Server) loginGet(w http.ResponseWriter, r *http.Request) {
|
||||||
// Already logged in → dashboard.
|
// Already logged in → dashboard.
|
||||||
if s.currentAdmin(r) != nil {
|
if s.currentAdmin(r) != nil {
|
||||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||||
return
|
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)
|
flash, errMsg := flashFrom(r)
|
||||||
s.render(w, "login", struct {
|
s.render(w, "login", struct {
|
||||||
basePage
|
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 {
|
func createDefaultMailboxes(ctx context.Context, database *db.DB, userID int64) error {
|
||||||
return database.CreateDefaultMailboxes(ctx, userID)
|
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() {
|
func (s *Server) setupRoutes() {
|
||||||
m := s.mux
|
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
|
// Public
|
||||||
|
m.HandleFunc("GET /admin/setup", s.setupGet)
|
||||||
|
m.HandleFunc("POST /admin/setup", s.setupPost)
|
||||||
m.HandleFunc("GET /admin/login", s.loginGet)
|
m.HandleFunc("GET /admin/login", s.loginGet)
|
||||||
m.HandleFunc("POST /admin/login", s.loginPost)
|
m.HandleFunc("POST /admin/login", s.loginPost)
|
||||||
m.HandleFunc("GET /admin/login/mfa", s.mfaGet)
|
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