mirror of
https://github.com/axllent/mailpit.git
synced 2025-08-13 20:04:49 +02:00
Merge branch 'feature/send-auth' into develop
This commit is contained in:
15
cmd/root.go
15
cmd/root.go
@@ -108,6 +108,10 @@ func init() {
|
||||
rootCmd.Flags().BoolVar(&config.DisableHTTPCompression, "disable-http-compression", config.DisableHTTPCompression, "Disable HTTP compression support (web UI & API)")
|
||||
rootCmd.Flags().BoolVar(&config.HideDeleteAllButton, "hide-delete-all-button", config.HideDeleteAllButton, "Hide the \"Delete all\" button in the web UI")
|
||||
|
||||
// Send API
|
||||
rootCmd.Flags().StringVar(&config.SendAPIAuthFile, "send-api-auth-file", config.SendAPIAuthFile, "A password file for Send API authentication")
|
||||
rootCmd.Flags().BoolVar(&config.SendAPIAuthAcceptAny, "send-api-auth-accept-any", config.SendAPIAuthAcceptAny, "Accept any username and password for the Send API endpoint, including none")
|
||||
|
||||
// SMTP server
|
||||
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
|
||||
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
|
||||
@@ -249,6 +253,15 @@ func initConfigFromEnv() {
|
||||
config.HideDeleteAllButton = true
|
||||
}
|
||||
|
||||
// Send API
|
||||
config.SendAPIAuthFile = os.Getenv("MP_SEND_API_AUTH_FILE")
|
||||
if err := auth.SetSendAPIAuth(os.Getenv("MP_SEND_API_AUTH")); err != nil {
|
||||
logger.Log().Error(err.Error())
|
||||
}
|
||||
if getEnabledFromEnv("MP_SEND_API_AUTH_ACCEPT_ANY") {
|
||||
config.SendAPIAuthAcceptAny = true
|
||||
}
|
||||
|
||||
// SMTP server
|
||||
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
|
||||
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
|
||||
@@ -362,9 +375,9 @@ func initConfigFromEnv() {
|
||||
func initDeprecatedConfigFromEnv() {
|
||||
// deprecated 2024/04/12 - but will not be removed to maintain backwards compatibility
|
||||
if len(os.Getenv("MP_DATA_FILE")) > 0 {
|
||||
logger.Log().Warn("ENV MP_DATA_FILE has been deprecated, use MP_DATABASE")
|
||||
config.Database = os.Getenv("MP_DATA_FILE")
|
||||
}
|
||||
|
||||
// deprecated 2023/03/12
|
||||
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
|
||||
logger.Log().Warn("ENV MP_UI_SSL_CERT has been deprecated, use MP_UI_TLS_CERT")
|
||||
|
@@ -72,6 +72,12 @@ var (
|
||||
// DisableHTTPCompression will explicitly disable HTTP compression in the web UI and API
|
||||
DisableHTTPCompression bool
|
||||
|
||||
// SendAPIAuthFile for Send API authentication
|
||||
SendAPIAuthFile string
|
||||
|
||||
// SendAPIAuthAcceptAny accepts any username/password for the send API endpoint, including none
|
||||
SendAPIAuthAcceptAny bool
|
||||
|
||||
// SMTPTLSCert file
|
||||
SMTPTLSCert string
|
||||
|
||||
@@ -289,6 +295,7 @@ func VerifyConfig() error {
|
||||
return errors.New("[ui] HTTP bind should be in the format of <ip>:<port>")
|
||||
}
|
||||
|
||||
// Web UI & API
|
||||
if UIAuthFile != "" {
|
||||
UIAuthFile = filepath.Clean(UIAuthFile)
|
||||
|
||||
@@ -323,6 +330,35 @@ func VerifyConfig() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Send API
|
||||
if SendAPIAuthFile != "" {
|
||||
SendAPIAuthFile = filepath.Clean(SendAPIAuthFile)
|
||||
|
||||
if !isFile(SendAPIAuthFile) {
|
||||
return fmt.Errorf("[send-api] password file not found or readable: %s", SendAPIAuthFile)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(SendAPIAuthFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := auth.SetSendAPIAuth(string(b)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Log().Info("[send-api] enabling basic authentication")
|
||||
}
|
||||
|
||||
if auth.SendAPICredentials != nil && SendAPIAuthAcceptAny {
|
||||
return errors.New("[send-api] authentication cannot use both credentials and --send-api-auth-accept-any")
|
||||
}
|
||||
|
||||
if SendAPIAuthAcceptAny && auth.UICredentials != nil {
|
||||
logger.Log().Info("[send-api] disabling authentication")
|
||||
}
|
||||
|
||||
// SMTP server
|
||||
if SMTPTLSCert != "" && SMTPTLSKey == "" || SMTPTLSCert == "" && SMTPTLSKey != "" {
|
||||
return errors.New("[smtp] you must provide both an SMTP TLS certificate and a key")
|
||||
}
|
||||
|
@@ -11,6 +11,8 @@ import (
|
||||
var (
|
||||
// UICredentials passwords
|
||||
UICredentials *htpasswd.File
|
||||
// SendAPICredentials passwords
|
||||
SendAPICredentials *htpasswd.File
|
||||
// SMTPCredentials passwords
|
||||
SMTPCredentials *htpasswd.File
|
||||
// POP3Credentials passwords
|
||||
@@ -36,6 +38,25 @@ func SetUIAuth(s string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSendAPIAuth will set Send API credentials
|
||||
func SetSendAPIAuth(s string) error {
|
||||
var err error
|
||||
|
||||
credentials := credentialsFromString(s)
|
||||
if len(credentials) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r := strings.NewReader(strings.Join(credentials, "\n"))
|
||||
|
||||
SendAPICredentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSMTPAuth will set SMTP credentials
|
||||
func SetSMTPAuth(s string) error {
|
||||
var err error
|
||||
|
@@ -158,7 +158,7 @@ func apiRoutes() *mux.Router {
|
||||
r.HandleFunc(config.Webroot+"api/v1/messages", middleWareFunc(apiv1.DeleteMessages)).Methods("DELETE")
|
||||
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.Search)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/search", middleWareFunc(apiv1.DeleteSearch)).Methods("DELETE")
|
||||
r.HandleFunc(config.Webroot+"api/v1/send", middleWareFunc(apiv1.SendMessageHandler)).Methods("POST")
|
||||
r.HandleFunc(config.Webroot+"api/v1/send", sendAPIAuthMiddleware(apiv1.SendMessageHandler)).Methods("POST")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.GetAllTags)).Methods("GET")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags", middleWareFunc(apiv1.SetMessageTags)).Methods("PUT")
|
||||
r.HandleFunc(config.Webroot+"api/v1/tags/{tag}", middleWareFunc(apiv1.RenameTag)).Methods("PUT")
|
||||
@@ -198,6 +198,48 @@ func basicAuthResponse(w http.ResponseWriter) {
|
||||
_, _ = w.Write([]byte("Unauthorised.\n"))
|
||||
}
|
||||
|
||||
// sendAPIAuthMiddleware handles authentication specifically for the send API endpoint
|
||||
// It can use dedicated send API authentication or accept any credentials based on configuration
|
||||
func sendAPIAuthMiddleware(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// If send API auth accept any is enabled, bypass all authentication
|
||||
if config.SendAPIAuthAcceptAny {
|
||||
// Temporarily disable UI auth for this request
|
||||
originalCredentials := auth.UICredentials
|
||||
auth.UICredentials = nil
|
||||
defer func() { auth.UICredentials = originalCredentials }()
|
||||
// Call the standard middleware
|
||||
middleWareFunc(fn)(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// If Send API credentials are configured, only accept those credentials
|
||||
if auth.SendAPICredentials != nil {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.SendAPICredentials.Match(user, pass) {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Valid Send API credentials - bypass UI auth and call function directly
|
||||
originalCredentials := auth.UICredentials
|
||||
auth.UICredentials = nil
|
||||
defer func() { auth.UICredentials = originalCredentials }()
|
||||
middleWareFunc(fn)(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// No Send API credentials configured - fall back to UI auth
|
||||
middleWareFunc(fn)(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
io.Writer
|
||||
http.ResponseWriter
|
||||
@@ -239,7 +281,9 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
||||
}
|
||||
|
||||
if auth.UICredentials != nil {
|
||||
// Check basic authentication headers if configured.
|
||||
// OPTIONS requests are skipped if CORS is enabled, since browsers omit credentials for preflight.
|
||||
if !(AccessControlAllowOrigin != "" && r.Method == http.MethodOptions) && auth.UICredentials != nil {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
|
@@ -13,10 +13,12 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/internal/auth"
|
||||
"github.com/axllent/mailpit/internal/logger"
|
||||
"github.com/axllent/mailpit/internal/storage"
|
||||
"github.com/axllent/mailpit/server/apiv1"
|
||||
"github.com/jhillyerd/enmime/v2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -24,6 +26,18 @@ var (
|
||||
Read bool
|
||||
IDs []string
|
||||
}
|
||||
|
||||
// Shared test message structure for consistency
|
||||
testSendMessage = map[string]interface{}{
|
||||
"From": map[string]string{
|
||||
"Email": "test@example.com",
|
||||
},
|
||||
"To": []map[string]string{
|
||||
{"Email": "recipient@example.com"},
|
||||
},
|
||||
"Subject": "Test",
|
||||
"Text": "Test message",
|
||||
}
|
||||
)
|
||||
|
||||
func TestAPIv1Messages(t *testing.T) {
|
||||
@@ -312,6 +326,157 @@ func TestAPIv1Send(t *testing.T) {
|
||||
assertEqual(t, `This is a plain text attachment`, string(attachmentBytes), "wrong Attachment content")
|
||||
}
|
||||
|
||||
func TestSendAPIAuthMiddleware(t *testing.T) {
|
||||
setup()
|
||||
defer storage.Close()
|
||||
|
||||
// Test 1: Send API with accept-any enabled (should bypass all auth)
|
||||
t.Run("SendAPIAuthAcceptAny", func(t *testing.T) {
|
||||
// Set up UI auth and enable accept-any for send API
|
||||
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
|
||||
originalUICredentials := auth.UICredentials
|
||||
defer func() {
|
||||
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
|
||||
auth.UICredentials = originalUICredentials
|
||||
}()
|
||||
|
||||
// Enable accept-any for send API
|
||||
config.SendAPIAuthAcceptAny = true
|
||||
|
||||
// Set up UI auth that would normally block requests
|
||||
testHash, _ := bcrypt.GenerateFromPassword([]byte("testpass"), bcrypt.DefaultCost)
|
||||
auth.SetUIAuth("testuser:" + string(testHash))
|
||||
|
||||
r := apiRoutes()
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Should succeed without any auth headers
|
||||
jsonData, _ := json.Marshal(testSendMessage)
|
||||
_, err := clientPost(ts.URL+"/api/v1/send", string(jsonData))
|
||||
if err != nil {
|
||||
t.Errorf("Expected send to succeed with accept-any, got error: %s", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: Send API with dedicated credentials
|
||||
t.Run("SendAPIWithDedicatedCredentials", func(t *testing.T) {
|
||||
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
|
||||
originalUICredentials := auth.UICredentials
|
||||
originalSendAPICredentials := auth.SendAPICredentials
|
||||
defer func() {
|
||||
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
|
||||
auth.UICredentials = originalUICredentials
|
||||
auth.SendAPICredentials = originalSendAPICredentials
|
||||
}()
|
||||
|
||||
config.SendAPIAuthAcceptAny = false
|
||||
|
||||
// Set up UI auth
|
||||
uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost)
|
||||
auth.SetUIAuth("uiuser:" + string(uiHash))
|
||||
|
||||
// Set up dedicated Send API auth
|
||||
sendHash, _ := bcrypt.GenerateFromPassword([]byte("sendpass"), bcrypt.DefaultCost)
|
||||
auth.SetSendAPIAuth("senduser:" + string(sendHash))
|
||||
|
||||
r := apiRoutes()
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
jsonData, _ := json.Marshal(testSendMessage)
|
||||
|
||||
// Should succeed with correct Send API credentials
|
||||
_, err := clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "senduser", "sendpass")
|
||||
if err != nil {
|
||||
t.Errorf("Expected send to succeed with correct Send API credentials, got error: %s", err.Error())
|
||||
}
|
||||
|
||||
// Should fail with wrong Send API credentials
|
||||
_, err = clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "senduser", "wrongpass")
|
||||
if err == nil {
|
||||
t.Error("Expected send to fail with wrong Send API credentials")
|
||||
}
|
||||
|
||||
// Should fail with UI credentials when Send API credentials are set
|
||||
_, err = clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "uiuser", "uipass")
|
||||
if err == nil {
|
||||
t.Error("Expected send to fail with UI credentials when Send API credentials are required")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Send API fallback to UI auth when no Send API auth is configured
|
||||
t.Run("SendAPIFallbackToUIAuth", func(t *testing.T) {
|
||||
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
|
||||
originalUICredentials := auth.UICredentials
|
||||
originalSendAPICredentials := auth.SendAPICredentials
|
||||
defer func() {
|
||||
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
|
||||
auth.UICredentials = originalUICredentials
|
||||
auth.SendAPICredentials = originalSendAPICredentials
|
||||
}()
|
||||
|
||||
config.SendAPIAuthAcceptAny = false
|
||||
auth.SendAPICredentials = nil
|
||||
|
||||
// Set up only UI auth
|
||||
uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost)
|
||||
auth.SetUIAuth("uiuser:" + string(uiHash))
|
||||
|
||||
r := apiRoutes()
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
jsonData, _ := json.Marshal(testSendMessage)
|
||||
|
||||
// Should succeed with UI credentials when no Send API auth is configured
|
||||
_, err := clientPostWithAuth(ts.URL+"/api/v1/send", string(jsonData), "uiuser", "uipass")
|
||||
if err != nil {
|
||||
t.Errorf("Expected send to succeed with UI credentials when no Send API auth configured, got error: %s", err.Error())
|
||||
}
|
||||
|
||||
// Should fail without any credentials
|
||||
_, err = clientPost(ts.URL+"/api/v1/send", string(jsonData))
|
||||
if err == nil {
|
||||
t.Error("Expected send to fail without credentials when UI auth is required")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 4: Regular API endpoints should not be affected by Send API auth settings
|
||||
t.Run("RegularAPINotAffectedBySendAPIAuth", func(t *testing.T) {
|
||||
originalSendAPIAuthAcceptAny := config.SendAPIAuthAcceptAny
|
||||
originalUICredentials := auth.UICredentials
|
||||
originalSendAPICredentials := auth.SendAPICredentials
|
||||
defer func() {
|
||||
config.SendAPIAuthAcceptAny = originalSendAPIAuthAcceptAny
|
||||
auth.UICredentials = originalUICredentials
|
||||
auth.SendAPICredentials = originalSendAPICredentials
|
||||
}()
|
||||
|
||||
// Set up UI auth and Send API auth
|
||||
uiHash, _ := bcrypt.GenerateFromPassword([]byte("uipass"), bcrypt.DefaultCost)
|
||||
auth.SetUIAuth("uiuser:" + string(uiHash))
|
||||
sendHash, _ := bcrypt.GenerateFromPassword([]byte("sendpass"), bcrypt.DefaultCost)
|
||||
auth.SetSendAPIAuth("senduser:" + string(sendHash))
|
||||
|
||||
r := apiRoutes()
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Regular API endpoint should require UI credentials, not Send API credentials
|
||||
_, err := clientGetWithAuth(ts.URL+"/api/v1/messages", "uiuser", "uipass")
|
||||
if err != nil {
|
||||
t.Errorf("Expected regular API to work with UI credentials, got error: %s", err.Error())
|
||||
}
|
||||
|
||||
// Regular API endpoint should fail with Send API credentials
|
||||
_, err = clientGetWithAuth(ts.URL+"/api/v1/messages", "senduser", "sendpass")
|
||||
if err == nil {
|
||||
t.Error("Expected regular API to fail with Send API credentials")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func setup() {
|
||||
logger.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
@@ -521,6 +686,59 @@ func clientPost(url, body string) ([]byte, error) {
|
||||
return data, err
|
||||
}
|
||||
|
||||
func clientPostWithAuth(url, body, username, password string) ([]byte, error) {
|
||||
client := new(http.Client)
|
||||
|
||||
b := strings.NewReader(body)
|
||||
req, err := http.NewRequest("POST", url, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(username, password)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func clientGetWithAuth(url, username, password string) ([]byte, error) {
|
||||
client := new(http.Client)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.SetBasicAuth(username, password)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
|
||||
return data, err
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
if a == b {
|
||||
return
|
||||
|
Reference in New Issue
Block a user