Add BasicAuth to admin endpoints.
This removes the Nginx dependency for protecting admin pages. BasicAuth is configured in config.toml. This is a "temporary" setup until a full fledged auth mechanism is added.
This commit is contained in:
parent
7ed07550ff
commit
b822955ac9
|
@ -560,16 +560,12 @@ func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) err
|
||||||
fmt.Sprintf("Error rendering message: %v", err))
|
fmt.Sprintf("Error rendering message: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := app.messenger.Push(messenger.Message{
|
return app.messenger.Push(messenger.Message{
|
||||||
From: camp.FromEmail,
|
From: camp.FromEmail,
|
||||||
To: []string{sub.Email},
|
To: []string{sub.Email},
|
||||||
Subject: m.Subject(),
|
Subject: m.Subject(),
|
||||||
Body: m.Body(),
|
Body: m.Body(),
|
||||||
}); err != nil {
|
})
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateCampaignFields validates incoming campaign field values.
|
// validateCampaignFields validates incoming campaign field values.
|
||||||
|
|
|
@ -2,6 +2,13 @@
|
||||||
# Interface and port where the app will run its webserver.
|
# Interface and port where the app will run its webserver.
|
||||||
address = "0.0.0.0:9000"
|
address = "0.0.0.0:9000"
|
||||||
|
|
||||||
|
# BasicAuth authentication for the admin dashboard. This will eventually
|
||||||
|
# be replaced with a better multi-user, role-based authentication system.
|
||||||
|
# IMPORTANT: Leave both values empty to disable authentication on admin
|
||||||
|
# only where an external authentication is already setup.
|
||||||
|
admin_username = "listmonk"
|
||||||
|
admin_password = "listmonk"
|
||||||
|
|
||||||
# Database.
|
# Database.
|
||||||
[db]
|
[db]
|
||||||
host = "db"
|
host = "db"
|
||||||
|
|
1
go.mod
1
go.mod
|
@ -3,6 +3,7 @@ module github.com/knadh/listmonk
|
||||||
go 1.13
|
go 1.13
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/gofrs/uuid v3.2.0+incompatible
|
github.com/gofrs/uuid v3.2.0+incompatible
|
||||||
github.com/jaytaylor/html2text v0.0.0-20200220170450-61d9dc4d7195
|
github.com/jaytaylor/html2text v0.0.0-20200220170450-61d9dc4d7195
|
||||||
|
|
3
go.sum
3
go.sum
|
@ -3,6 +3,9 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM=
|
||||||
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
|
||||||
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||||
|
|
156
handlers.go
156
handlers.go
|
@ -1,12 +1,14 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/labstack/echo"
|
"github.com/labstack/echo"
|
||||||
|
"github.com/labstack/echo/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -30,71 +32,87 @@ var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[
|
||||||
|
|
||||||
// registerHandlers registers HTTP handlers.
|
// registerHandlers registers HTTP handlers.
|
||||||
func registerHTTPHandlers(e *echo.Echo) {
|
func registerHTTPHandlers(e *echo.Echo) {
|
||||||
e.GET("/", handleIndexPage)
|
// Group of private handlers with BasicAuth.
|
||||||
e.GET("/api/health", handleHealthCheck)
|
g := e.Group("", middleware.BasicAuth(basicAuth))
|
||||||
e.GET("/api/config.js", handleGetConfigScript)
|
|
||||||
e.GET("/api/dashboard/charts", handleGetDashboardCharts)
|
|
||||||
e.GET("/api/dashboard/counts", handleGetDashboardCounts)
|
|
||||||
|
|
||||||
e.GET("/api/settings", handleGetSettings)
|
g.GET("/", handleIndexPage)
|
||||||
e.PUT("/api/settings", handleUpdateSettings)
|
g.GET("/api/health", handleHealthCheck)
|
||||||
e.POST("/api/admin/reload", handleReloadApp)
|
g.GET("/api/config.js", handleGetConfigScript)
|
||||||
|
g.GET("/api/dashboard/charts", handleGetDashboardCharts)
|
||||||
|
g.GET("/api/dashboard/counts", handleGetDashboardCounts)
|
||||||
|
|
||||||
e.GET("/api/subscribers/:id", handleGetSubscriber)
|
g.GET("/api/settings", handleGetSettings)
|
||||||
e.GET("/api/subscribers/:id/export", handleExportSubscriberData)
|
g.PUT("/api/settings", handleUpdateSettings)
|
||||||
e.POST("/api/subscribers", handleCreateSubscriber)
|
g.POST("/api/admin/reload", handleReloadApp)
|
||||||
e.PUT("/api/subscribers/:id", handleUpdateSubscriber)
|
|
||||||
e.POST("/api/subscribers/:id/optin", handleSubscriberSendOptin)
|
g.GET("/api/subscribers/:id", handleGetSubscriber)
|
||||||
e.PUT("/api/subscribers/blocklist", handleBlocklistSubscribers)
|
g.GET("/api/subscribers/:id/export", handleExportSubscriberData)
|
||||||
e.PUT("/api/subscribers/:id/blocklist", handleBlocklistSubscribers)
|
g.POST("/api/subscribers", handleCreateSubscriber)
|
||||||
e.PUT("/api/subscribers/lists/:id", handleManageSubscriberLists)
|
g.PUT("/api/subscribers/:id", handleUpdateSubscriber)
|
||||||
e.PUT("/api/subscribers/lists", handleManageSubscriberLists)
|
g.POST("/api/subscribers/:id/optin", handleSubscriberSendOptin)
|
||||||
e.DELETE("/api/subscribers/:id", handleDeleteSubscribers)
|
g.PUT("/api/subscribers/blocklist", handleBlocklistSubscribers)
|
||||||
e.DELETE("/api/subscribers", handleDeleteSubscribers)
|
g.PUT("/api/subscribers/:id/blocklist", handleBlocklistSubscribers)
|
||||||
|
g.PUT("/api/subscribers/lists/:id", handleManageSubscriberLists)
|
||||||
|
g.PUT("/api/subscribers/lists", handleManageSubscriberLists)
|
||||||
|
g.DELETE("/api/subscribers/:id", handleDeleteSubscribers)
|
||||||
|
g.DELETE("/api/subscribers", handleDeleteSubscribers)
|
||||||
|
|
||||||
// Subscriber operations based on arbitrary SQL queries.
|
// Subscriber operations based on arbitrary SQL queries.
|
||||||
// These aren't very REST-like.
|
// These aren't very REST-like.
|
||||||
e.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery)
|
g.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery)
|
||||||
e.PUT("/api/subscribers/query/blocklist", handleBlocklistSubscribersByQuery)
|
g.PUT("/api/subscribers/query/blocklist", handleBlocklistSubscribersByQuery)
|
||||||
e.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery)
|
g.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery)
|
||||||
e.GET("/api/subscribers", handleQuerySubscribers)
|
g.GET("/api/subscribers", handleQuerySubscribers)
|
||||||
|
|
||||||
e.GET("/api/import/subscribers", handleGetImportSubscribers)
|
g.GET("/api/import/subscribers", handleGetImportSubscribers)
|
||||||
e.GET("/api/import/subscribers/logs", handleGetImportSubscriberStats)
|
g.GET("/api/import/subscribers/logs", handleGetImportSubscriberStats)
|
||||||
e.POST("/api/import/subscribers", handleImportSubscribers)
|
g.POST("/api/import/subscribers", handleImportSubscribers)
|
||||||
e.DELETE("/api/import/subscribers", handleStopImportSubscribers)
|
g.DELETE("/api/import/subscribers", handleStopImportSubscribers)
|
||||||
|
|
||||||
e.GET("/api/lists", handleGetLists)
|
g.GET("/api/lists", handleGetLists)
|
||||||
e.GET("/api/lists/:id", handleGetLists)
|
g.GET("/api/lists/:id", handleGetLists)
|
||||||
e.POST("/api/lists", handleCreateList)
|
g.POST("/api/lists", handleCreateList)
|
||||||
e.PUT("/api/lists/:id", handleUpdateList)
|
g.PUT("/api/lists/:id", handleUpdateList)
|
||||||
e.DELETE("/api/lists/:id", handleDeleteLists)
|
g.DELETE("/api/lists/:id", handleDeleteLists)
|
||||||
|
|
||||||
e.GET("/api/campaigns", handleGetCampaigns)
|
g.GET("/api/campaigns", handleGetCampaigns)
|
||||||
e.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats)
|
g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats)
|
||||||
e.GET("/api/campaigns/:id", handleGetCampaigns)
|
g.GET("/api/campaigns/:id", handleGetCampaigns)
|
||||||
e.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
|
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
|
||||||
e.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
|
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
|
||||||
e.POST("/api/campaigns/:id/test", handleTestCampaign)
|
g.POST("/api/campaigns/:id/test", handleTestCampaign)
|
||||||
e.POST("/api/campaigns", handleCreateCampaign)
|
g.POST("/api/campaigns", handleCreateCampaign)
|
||||||
e.PUT("/api/campaigns/:id", handleUpdateCampaign)
|
g.PUT("/api/campaigns/:id", handleUpdateCampaign)
|
||||||
e.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus)
|
g.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus)
|
||||||
e.DELETE("/api/campaigns/:id", handleDeleteCampaign)
|
g.DELETE("/api/campaigns/:id", handleDeleteCampaign)
|
||||||
|
|
||||||
e.GET("/api/media", handleGetMedia)
|
g.GET("/api/media", handleGetMedia)
|
||||||
e.POST("/api/media", handleUploadMedia)
|
g.POST("/api/media", handleUploadMedia)
|
||||||
e.DELETE("/api/media/:id", handleDeleteMedia)
|
g.DELETE("/api/media/:id", handleDeleteMedia)
|
||||||
|
|
||||||
e.GET("/api/templates", handleGetTemplates)
|
g.GET("/api/templates", handleGetTemplates)
|
||||||
e.GET("/api/templates/:id", handleGetTemplates)
|
g.GET("/api/templates/:id", handleGetTemplates)
|
||||||
e.GET("/api/templates/:id/preview", handlePreviewTemplate)
|
g.GET("/api/templates/:id/preview", handlePreviewTemplate)
|
||||||
e.POST("/api/templates/preview", handlePreviewTemplate)
|
g.POST("/api/templates/preview", handlePreviewTemplate)
|
||||||
e.POST("/api/templates", handleCreateTemplate)
|
g.POST("/api/templates", handleCreateTemplate)
|
||||||
e.PUT("/api/templates/:id", handleUpdateTemplate)
|
g.PUT("/api/templates/:id", handleUpdateTemplate)
|
||||||
e.PUT("/api/templates/:id/default", handleTemplateSetDefault)
|
g.PUT("/api/templates/:id/default", handleTemplateSetDefault)
|
||||||
e.DELETE("/api/templates/:id", handleDeleteTemplate)
|
g.DELETE("/api/templates/:id", handleDeleteTemplate)
|
||||||
|
|
||||||
// Subscriber facing views.
|
// Static admin views.
|
||||||
|
g.GET("/lists", handleIndexPage)
|
||||||
|
g.GET("/lists/forms", handleIndexPage)
|
||||||
|
g.GET("/subscribers", handleIndexPage)
|
||||||
|
g.GET("/subscribers/lists/:listID", handleIndexPage)
|
||||||
|
g.GET("/subscribers/import", handleIndexPage)
|
||||||
|
g.GET("/campaigns", handleIndexPage)
|
||||||
|
g.GET("/campaigns/new", handleIndexPage)
|
||||||
|
g.GET("/campaigns/media", handleIndexPage)
|
||||||
|
g.GET("/campaigns/templates", handleIndexPage)
|
||||||
|
g.GET("/campaigns/:campignID", handleIndexPage)
|
||||||
|
g.GET("/settings", handleIndexPage)
|
||||||
|
|
||||||
|
// Public subscriber facing views.
|
||||||
e.POST("/subscription/form", handleSubscriptionForm)
|
e.POST("/subscription/form", handleSubscriptionForm)
|
||||||
e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
|
e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
|
||||||
"campUUID", "subUUID"))
|
"campUUID", "subUUID"))
|
||||||
|
@ -112,19 +130,6 @@ func registerHTTPHandlers(e *echo.Echo) {
|
||||||
"campUUID", "subUUID"))
|
"campUUID", "subUUID"))
|
||||||
e.GET("/campaign/:campUUID/:subUUID/px.png", validateUUID(handleRegisterCampaignView,
|
e.GET("/campaign/:campUUID/:subUUID/px.png", validateUUID(handleRegisterCampaignView,
|
||||||
"campUUID", "subUUID"))
|
"campUUID", "subUUID"))
|
||||||
|
|
||||||
// Static views.
|
|
||||||
e.GET("/lists", handleIndexPage)
|
|
||||||
e.GET("/lists/forms", handleIndexPage)
|
|
||||||
e.GET("/subscribers", handleIndexPage)
|
|
||||||
e.GET("/subscribers/lists/:listID", handleIndexPage)
|
|
||||||
e.GET("/subscribers/import", handleIndexPage)
|
|
||||||
e.GET("/campaigns", handleIndexPage)
|
|
||||||
e.GET("/campaigns/new", handleIndexPage)
|
|
||||||
e.GET("/campaigns/media", handleIndexPage)
|
|
||||||
e.GET("/campaigns/templates", handleIndexPage)
|
|
||||||
e.GET("/campaigns/:campignID", handleIndexPage)
|
|
||||||
e.GET("/settings", handleIndexPage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleIndex is the root handler that renders the Javascript frontend.
|
// handleIndex is the root handler that renders the Javascript frontend.
|
||||||
|
@ -145,6 +150,23 @@ func handleHealthCheck(c echo.Context) error {
|
||||||
return c.JSON(http.StatusOK, okResp{true})
|
return c.JSON(http.StatusOK, okResp{true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// basicAuth middleware does an HTTP BasicAuth authentication for admin handlers.
|
||||||
|
func basicAuth(username, password string, c echo.Context) (bool, error) {
|
||||||
|
app := c.Get("app").(*App)
|
||||||
|
|
||||||
|
// Auth is disabled.
|
||||||
|
if len(app.constants.AdminUsername) == 0 &&
|
||||||
|
len(app.constants.AdminPassword) == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if subtle.ConstantTimeCompare([]byte(username), app.constants.AdminUsername) == 1 &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(password), app.constants.AdminPassword) == 1 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
// validateUUID middleware validates the UUID string format for a given set of params.
|
// validateUUID middleware validates the UUID string format for a given set of params.
|
||||||
func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
|
func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
|
|
13
init.go
13
init.go
|
@ -48,13 +48,14 @@ type constants struct {
|
||||||
AllowWipe bool `koanf:"allow_wipe"`
|
AllowWipe bool `koanf:"allow_wipe"`
|
||||||
Exportable map[string]bool `koanf:"-"`
|
Exportable map[string]bool `koanf:"-"`
|
||||||
} `koanf:"privacy"`
|
} `koanf:"privacy"`
|
||||||
|
AdminUsername []byte `koanf:"admin_username"`
|
||||||
|
AdminPassword []byte `koanf:"admin_password"`
|
||||||
|
|
||||||
UnsubURL string
|
UnsubURL string
|
||||||
LinkTrackURL string
|
LinkTrackURL string
|
||||||
ViewTrackURL string
|
ViewTrackURL string
|
||||||
OptinURL string
|
OptinURL string
|
||||||
MessageURL string
|
MessageURL string
|
||||||
|
|
||||||
MediaProvider string
|
MediaProvider string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gofrs/uuid"
|
"github.com/gofrs/uuid"
|
||||||
|
@ -170,5 +171,12 @@ func newConfigFile() error {
|
||||||
return fmt.Errorf("error reading sample config (is binary stuffed?): %v", err)
|
return fmt.Errorf("error reading sample config (is binary stuffed?): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate a random admin password.
|
||||||
|
pwd, err := generateRandomString(16)
|
||||||
|
if err == nil {
|
||||||
|
b = regexp.MustCompile(`admin_password\s+?=\s+?(.*)`).
|
||||||
|
ReplaceAll(b, []byte(fmt.Sprintf(`admin_password = "%s"`, pwd)))
|
||||||
|
}
|
||||||
|
|
||||||
return ioutil.WriteFile("config.toml", b, 0644)
|
return ioutil.WriteFile("config.toml", b, 0644)
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,6 +75,7 @@ func V0_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||||
('app.batch_size', '1000'),
|
('app.batch_size', '1000'),
|
||||||
('app.max_send_errors', '1000'),
|
('app.max_send_errors', '1000'),
|
||||||
('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'),
|
('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'),
|
||||||
|
('privacy.unsubscribe_header', 'true'),
|
||||||
('privacy.allow_blocklist', 'true'),
|
('privacy.allow_blocklist', 'true'),
|
||||||
('privacy.allow_export', 'true'),
|
('privacy.allow_export', 'true'),
|
||||||
('privacy.allow_wipe', 'true'),
|
('privacy.allow_wipe', 'true'),
|
||||||
|
|
Loading…
Reference in New Issue