diff --git a/main.go b/main.go index a275b72..ef20edc 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,6 @@ package main import ( cryptorand "crypto/rand" "database/sql" - "errors" "fmt" "github.com/akyoto/cache" "github.com/hugmouse/maddy-password-reset/templates" @@ -38,39 +37,37 @@ import ( "time" ) -// --------------------------------------------------------------- -// Configuration -// Make sure to set these constants before running the program -// --------------------------------------------------------------- const ( // MaddyPath is path to your Maddy credentials database + // // FYI, Maddy's password database by default is "/var/lib/maddy/credentials.db" - MaddyPath = "maddy.db" // MUST be set + MaddyPath = "" - // HostingURL is your domain name, including scheme and trailing slash, - // for example: `http://localhost:1323/` or `https://example.com/` - HostingURL = "http://localhost:1323/" // MUST be set + // HostingURL is your domain name, + // for example: `http://localhost:1323/` + HostingURL = "" // SMTPMailUsername is your full mail address, // for example: `robot@local.host` - SMTPMailUsername = "" // MUST be set (unless DebugBypassMailSending is true) + SMTPMailUsername = "" // SMTPMailPassword is your mailbox password - SMTPMailPassword = "" // MUST be set (unless DebugBypassMailSending is true) + SMTPMailPassword = "" // SMTPMailHostname is your mail hostname, // for example: `mx1.local.host` - SMTPMailHostname = "" // MUST be set (unless DebugBypassMailSending is true) + SMTPMailHostname = "" // MXServer is your mail `MX` record + `PORT`, // for example: `mx1.local.host:587` - MXServer = "" // MUST be set (unless DebugBypassMailSending is true) + MXServer = "" // EmailFrom is a EmailTemplate's "$FROM" section - EmailFrom = "" // MUST be set (unless DebugBypassMailSending is true) + EmailFrom = "" // EmailSubject is a EmailTemplate's "$SUBJECT" section - EmailSubject = "" // MUST be set (unless DebugBypassMailSending is true) + EmailSubject = "" // EmailMessage is a EmailTemplate's "$MESSAGE" section + // // Remember to provide a password reset link to a user ($RESET_LINK) EmailMessage = "Here's your reset link: $RESET_LINK\r\n" // EmailTemplate is your reset mail message @@ -87,102 +84,27 @@ const ( // HTTPServerPort is an HTTP server port HTTPServerPort = 1323 - // DebugBypassMailSending - if true, skips SMTP checks and sending, logs reset link instead. - DebugBypassMailSending = false + // DebugBypassMailSending if true, will not send any emails and will print reset link to the console + DebugBypassMailSending = true ) -// --------------------------------------------------------------- -// Constants for random string generator -// --------------------------------------------------------------- const ( - TokenAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - RandomStringLength = 10 -) - -// --------------------------------------------------------------- -// Log Message Constants -// --------------------------------------------------------------- -const ( - // Configuration Validation - logMsgConfigValidationStart = "[Startup] Validating configuration..." - logMsgConfigMaddyPathEmpty = "[Config] MaddyPath constant cannot be empty." - logMsgConfigHostingURLEmpty = "[Config] HostingURL constant cannot be empty." - logMsgConfigHostingURLSlashFmt = "[Config] HostingURL must end with a trailing slash '/'. Current value: %s" - logMsgConfigSmtpCheckStart = "[Config] Checking SMTP and Email Template configuration (DebugBypassMailSending is false)" - logMsgConfigSmtpUsernameEmpty = "[Config] SMTPMailUsername constant cannot be empty when DebugBypassMailSending is false." - logMsgConfigSmtpPasswordEmpty = "[Config] SMTPMailPassword constant cannot be empty when DebugBypassMailSending is false." - logMsgConfigSmtpHostnameEmpty = "[Config] SMTPMailHostname constant cannot be empty when DebugBypassMailSending is false." - logMsgConfigMxServerEmpty = "[Config] MXServer constant cannot be empty when DebugBypassMailSending is false." - logMsgConfigEmailFromEmpty = "[Config] EmailFrom constant cannot be empty when DebugBypassMailSending is false." - logMsgConfigEmailSubjectEmpty = "[Config] EmailSubject constant cannot be empty when DebugBypassMailSending is false." - logMsgConfigEmailMsgResetLinkCheck = "[EmailMessage const] Checking your template for $RESET_LINK" - logMsgConfigEmailMsgResetLinkMissing = "[EmailMessage const] Your message template does not contain $RESET_LINK, so user can't reset his password!" - logMsgConfigEmailTplPlaceholdersCheck = "[EmailTemplate const] Checking your template placeholders ($TO, $FROM, $SUBJECT, $MESSAGE)" - logMsgConfigEmailTplPlaceholderMissingFmt = "[EmailTemplate const] Your template does not contain %s, make sure to add it." - logMsgConfigSmtpCheckSkipped = "[Config] DebugBypassMailSending is true. Skipping SMTP and Email Template configuration checks." - logMsgConfigCacheTimeInvalid = "[Config] CacheTime must be a positive duration." - logMsgConfigHttpPortInvalid = "[Config] HTTPServerPort must be a valid port number (1-65535)." - - // SMTP - logMsgSmtpAuthSetup = "[SMTP] Configuring SMTP authentication." - logMsgSmtpAuthSkipped = "[SMTP] Debug mode enabled, skipping SMTP authentication setup." - logMsgSmtpSendFailedFmt = "[SMTP] Failed to send password reset email to %q: %v" - logMsgSmtpSendSuccessFmt = "[SMTP] Password reset email successfully sent to %q" - logMsgSmtpDebugSendSkippedFmt = "[SMTP] Debug mode enabled. Would send reset email to %q." - logMsgSmtpDebugResetLinkFmt = "[SMTP] Reset link for %q: %s" - - // Sqlite - logMsgDbLoadingFmt = "[Sqlite] Loading Maddy's credentials database from: %s" - logMsgDbOpenFailedFmt = "[Sqlite] Failed to open database: %v" - logMsgDbClosing = "[Sqlite] Closing database connection." - logMsgDbCloseErrorFmt = "[Sqlite] Error closing database: %v" - logMsgDbPingFailedFmt = "[Sqlite] Failed to connect to database: %v" - logMsgDbPingSuccess = "[Sqlite] Database connection successful." - logMsgDbUserNotFoundFmt = "[Sqlite] User email %q not found in Maddy database. No reset link generated." - logMsgDbQueryErrorFmt = "[Sqlite] Error querying Maddy database for user %q: %v" - - // Cache - logMsgCacheRegisterFmt = "[Cache] Registering cache for password resets with expiry: %v" - logMsgCacheResetPendingFmt = "[Cache] Password reset request for %q already pending, ignoring new request." - - // Echo server - logMsgEchoInit = "[Echo] Initializing echo web server" - logMsgEchoTplRegister = "[Echo] Registering Go templates" - logMsgEchoStartFmt = "[Echo] Starting Echo web server on %s" - logMsgEchoStartFailedFmt = "[Echo] Failed to start server: %v" - - // Handlers - logMsgHandlerPostResetInvalidEmailFmt = "[Handler POST /reset] Invalid email address provided '%s': %v" - logMsgHandlerGetKeyEmpty = "[Handler GET /reset/:key] Received empty key." - logMsgHandlerGetKeyInvalidFmt = "[Handler GET /reset/:key] Invalid or expired key provided: %s" - logMsgHandlerPostKeyEmpty = "[Handler POST /reset/:key] Received empty key." - logMsgHandlerPostKeyEmptyPassword = "[Handler POST /reset/:key] Received empty password." - logMsgHandlerPostKeyInvalidFmt = "[Handler POST /reset/:key] Invalid or expired key submitted: %s" - logMsgHandlerPostKeyCacheTypeFmt = "[Handler POST /reset/:key] Value retrieved from cache for key %s is not a string: %T" - - // Reset Process - logMsgResetTokenGeneratedFmt = "[Reset] Generated reset token %s for user %s. Link: %s" - - // Maddy Command Execution - logMsgMaddyExecAttemptFmt = "[Maddy] Attempting to reset password for user %s via maddy command." - logMsgMaddyExecFailedFmt = "[Maddy] Failed to execute Maddy password reset command for %s: %v. Output: %s" - logMsgMaddyExecSuccessFmt = "[Maddy] Successfully reset password for user %s. Output: %s" - - // Other - logMsgRandNumGenFailedCriticalFmt = "CRITICAL: Failed to generate random number: %v" + // TokenAlphabet is created for random string creation, see randomString() function + TokenAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" ) func randomString(length int) string { l := big.NewInt(int64(len(TokenAlphabet))) res := new(strings.Builder) - res.Grow(length) for i := 0; i < length; i++ { n, err := cryptorand.Int(cryptorand.Reader, l) if err != nil { - log.Fatalf(logMsgRandNumGenFailedCriticalFmt, err) + panic(err) } + res.WriteByte(TokenAlphabet[n.Int64()]) } + return res.String() } @@ -190,132 +112,80 @@ type Template struct { templates *template.Template } -func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { +func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Context) error { return t.templates.ExecuteTemplate(w, name, data) } func isValidEmailAddress(email string) error { - if email == "" { - return errors.New("email address cannot be empty") - } - - addr, err := mail.ParseAddress(email) + // Parse the email address using addressparser + mail, err := mail.ParseAddress(email) if err != nil { - return fmt.Errorf("invalid email format: %w", err) + return err } - if !strings.Contains(addr.Address, "@") { - return errors.New("invalid email format: missing @ symbol") + // Check if the parsed address is not nil and has a valid email format + if mail == nil || mail.Address == "" { + log.Println("[AddressParser]: Invalid Email Address: %v") + return err } return nil } -func validateConfiguration() { - if MaddyPath == "" { - log.Fatalln(logMsgConfigMaddyPathEmpty) - } - - if HostingURL == "" { - log.Fatalln(logMsgConfigHostingURLEmpty) - } - - if !strings.HasSuffix(HostingURL, "/") { - log.Fatalf(logMsgConfigHostingURLSlashFmt, HostingURL) - } - - if !DebugBypassMailSending { - log.Println(logMsgConfigSmtpCheckStart) - if SMTPMailUsername == "" { - log.Fatalln(logMsgConfigSmtpUsernameEmpty) - } - if SMTPMailPassword == "" { - log.Fatalln(logMsgConfigSmtpPasswordEmpty) - } - if SMTPMailHostname == "" { - log.Fatalln(logMsgConfigSmtpHostnameEmpty) - } - if MXServer == "" { - log.Fatalln(logMsgConfigMxServerEmpty) - } - if EmailFrom == "" { - log.Fatalln(logMsgConfigEmailFromEmpty) - } - if EmailSubject == "" { - log.Fatalln(logMsgConfigEmailSubjectEmpty) - } - - log.Println(logMsgConfigEmailMsgResetLinkCheck) - if !strings.Contains(EmailMessage, "$RESET_LINK") { - log.Fatalln(logMsgConfigEmailMsgResetLinkMissing) - } - - log.Println(logMsgConfigEmailTplPlaceholdersCheck) - requiredPlaceholders := []string{"$TO", "$FROM", "$SUBJECT", "$MESSAGE"} - for _, placeholder := range requiredPlaceholders { - if !strings.Contains(EmailTemplate, placeholder) { - log.Fatalf(logMsgConfigEmailTplPlaceholderMissingFmt, placeholder) - } - } - } else { - log.Println(logMsgConfigSmtpCheckSkipped) - } - - if CacheTime <= 0 { - log.Fatalln(logMsgConfigCacheTimeInvalid) - } - - if HTTPServerPort <= 0 || HTTPServerPort > 65535 { - log.Fatalln(logMsgConfigHttpPortInvalid) - } -} - func main() { - log.Println(logMsgConfigValidationStart) - validateConfiguration() - var auth smtp.Auth if !DebugBypassMailSending { - log.Println(logMsgSmtpAuthSetup) + log.Println("[EmailMessage const] Checking your template") + if !strings.Contains(EmailMessage, "$RESET_LINK") { + log.Fatalln("[EmailMessage const] Your message template does not contain $RESET_LINK, so user can't reset his password!") + } + + log.Println("[EmailTemplate const] Checking your template") + if !strings.Contains(EmailTemplate, "$TO") { + log.Fatalln("[EmailTemplate const] Your template does not contain $TO, make sure to add it.") + } + + if !strings.Contains(EmailTemplate, "$FROM") { + log.Fatalln("[EmailTemplate const] Your template does not contain $FROM, make sure to add it.") + } + + if !strings.Contains(EmailTemplate, "$SUBJECT") { + log.Fatalln("[EmailTemplate const] Your template does not contain $SUBJECT, make sure to add it, so user can see a message preview.") + } + + if !strings.Contains(EmailTemplate, "$MESSAGE") { + log.Fatalln("[EmailTemplate const] Your template does not contain $MESSAGE, make sure to add it.") + } + + // Set up authentication information. auth = smtp.PlainAuth("", SMTPMailUsername, SMTPMailPassword, SMTPMailHostname) } else { - log.Println(logMsgSmtpAuthSkipped) + log.Println("[SMTP] Debug mode enabled, not checking email template") } - log.Printf(logMsgDbLoadingFmt, MaddyPath) + log.Println("[Sqlite] Loading Maddy's credentials database") db, err := sql.Open("sqlite", MaddyPath) if err != nil { - log.Fatalf(logMsgDbOpenFailedFmt, err) + log.Fatalln(err) } - defer func() { - log.Println(logMsgDbClosing) - if err := db.Close(); err != nil { - log.Printf(logMsgDbCloseErrorFmt, err) - } - }() - if err := db.Ping(); err != nil { - log.Fatalf(logMsgDbPingFailedFmt, err) - } - log.Println(logMsgDbPingSuccess) - - log.Printf(logMsgCacheRegisterFmt, CacheTime) + log.Println("[Cache] Registering cache for password resets") passwordResetCache := cache.New(CacheTime) - log.Println(logMsgEchoInit) + log.Println("[Echo] Initializing echo web server") e := echo.New() e.HideBanner = true e.Use(middleware.LoggerWithConfig( middleware.LoggerConfig{ - Format: `${time_custom} [Echo] ${latency_human} ${method} ${uri} - Status=${status} Error="${error}" RemoteIP=${remote_ip} UserAgent="${user_agent}"` + "\n", + Format: `${time_custom} [Echo] ${latency_human} ${method} ${uri} - Error = ${error} - ${remote_ip} "${user_agent}"` + "\n", CustomTimeFormat: "2006/01/02 15:04:05", })) e.Use(middleware.Recover()) - log.Println(logMsgEchoTplRegister) + log.Println("[Echo] Registering Go templates") t := template.Must(template.ParseFS(templates.Templates, "*.gohtml")) e.Renderer = &Template{ - templates: t, + t, } e.GET("/reset", func(c echo.Context) error { @@ -323,155 +193,89 @@ func main() { }) e.POST("/reset", func(c echo.Context) error { - email := c.FormValue("email") - if err := isValidEmailAddress(email); err != nil { - log.Printf(logMsgHandlerPostResetInvalidEmailFmt, email, err) - return c.Render(http.StatusBadRequest, "reset.gohtml", map[string]any{ - "Error": "Invalid email address format provided.", - }) + mail := c.FormValue("email") + err = isValidEmailAddress(mail) + if err != nil { + log.Println("[AddressParser]: Invalid mail address: ", err) + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid mail address: %v", err)) } - - go func(userEmail string) { - - // Check if there is already a pending password reset for this email - _, exists := passwordResetCache.Get(userEmail) + go func() { + // Check if there is already a password reset + _, exists := passwordResetCache.Get(mail) if exists { - log.Printf(logMsgCacheResetPendingFmt, userEmail) + log.Printf("[Cache] Mail %q already exists in cache, ignoring\n", mail) return } - // Check if user exists in Maddy DB - var dummy int - dbErr := db.QueryRow("SELECT 1 FROM passwords WHERE key = ?", userEmail).Scan(&dummy) - - if dbErr != nil { - if errors.Is(dbErr, sql.ErrNoRows) { - log.Printf(logMsgDbUserNotFoundFmt, userEmail) - } else { - log.Printf(logMsgDbQueryErrorFmt, userEmail, dbErr) - } + // Check if it's exists in Maddy db + // It will return an error is there is no user found + var password string + err = db.QueryRow("SELECT value FROM passwords WHERE key = ?", mail).Scan(&password) + if err != nil { + log.Println("[Sqlite] An error occurred while trying to get password from Maddy database:", err) return } - token := randomString(RandomStringLength) - passwordResetCache.Set(token, userEmail, CacheTime) - passwordResetCache.Set(userEmail, token, CacheTime) + // Generating an unique key + random := randomString(10) + passwordResetCache.Set(random, mail, CacheTime) - resetLink := HostingURL + "reset/" + token - log.Printf(logMsgResetTokenGeneratedFmt, token, userEmail, resetLink) + // Connect to the server, authenticate, set the sender and recipient, + // and send the email all in one step. + to := []string{mail} if !DebugBypassMailSending { - msg := strings.ReplaceAll(EmailTemplate, "$TO", userEmail) + msg := strings.ReplaceAll(EmailTemplate, "$TO", mail) msg = strings.ReplaceAll(msg, "$FROM", EmailFrom) msg = strings.ReplaceAll(msg, "$SUBJECT", EmailSubject) + msg = strings.ReplaceAll(msg, "$MESSAGE", EmailMessage) + msg = strings.ReplaceAll(msg, "$RESET_LINK", HostingURL+"reset/"+random) - messageBody := strings.ReplaceAll(EmailMessage, "$RESET_LINK", resetLink) - msg = strings.ReplaceAll(msg, "$MESSAGE", messageBody) - - to := []string{userEmail} - smtpErr := smtp.SendMail(MXServer, auth, SMTPMailUsername, to, []byte(msg)) - if smtpErr != nil { - log.Printf(logMsgSmtpSendFailedFmt, userEmail, smtpErr) - // Clean up cache if sending failed - passwordResetCache.Delete(token) - passwordResetCache.Delete(userEmail) + err := smtp.SendMail(MXServer, auth, SMTPMailUsername, to, []byte(msg)) + if err != nil { + log.Println("[SMTP] Failed to send mail - ", err) return } - log.Printf(logMsgSmtpSendSuccessFmt, userEmail) } else { - log.Printf(logMsgSmtpDebugSendSkippedFmt, userEmail) - log.Printf(logMsgSmtpDebugResetLinkFmt, userEmail, resetLink) + log.Println("[SMTP] Debug mode enabled, not sending email") + log.Println("[SMTP] Reset link:", HostingURL+"reset/"+random) } - }(email) + }() - // Always return success to the user to prevent email enumeration return c.Render(http.StatusOK, "reset.gohtml", map[string]any{ - "Sent": true, // Indicates request received, not necessarily email sent successfully + "Sent": true, }) }) e.GET("/reset/:key", func(c echo.Context) error { key := c.Param("key") - if key == "" { - log.Println(logMsgHandlerGetKeyEmpty) - return c.Redirect(http.StatusTemporaryRedirect, "/reset") - } - _, exists := passwordResetCache.Get(key) if !exists { - log.Printf(logMsgHandlerGetKeyInvalidFmt, key) - return c.Render(http.StatusNotFound, "reset.gohtml", map[string]any{ - "Error": "This password reset link is invalid or has expired.", - }) + return c.Redirect(http.StatusTemporaryRedirect, "/reset") } - return c.Render(http.StatusOK, "reset.gohtml", map[string]any{ "UniqueLinkTriggered": true, - "Key": key, }) }) e.POST("/reset/:key", func(c echo.Context) error { key := c.Param("key") password := c.FormValue("password") - - if key == "" { - log.Println(logMsgHandlerPostKeyEmpty) - return c.Render(http.StatusBadRequest, "reset.gohtml", map[string]any{ - "Error": "Reset key is missing.", - }) - } - if password == "" { - log.Println(logMsgHandlerPostKeyEmptyPassword) - return c.Render(http.StatusBadRequest, "reset.gohtml", map[string]any{ - "UniqueLinkTriggered": true, - "Key": key, - "Error": "Password cannot be empty.", - }) - } - - emailVal, exists := passwordResetCache.Get(key) - if !exists { - log.Printf(logMsgHandlerPostKeyInvalidFmt, key) - return c.Render(http.StatusNotFound, "reset.gohtml", map[string]any{ - "Error": "This password reset link is invalid or has expired. Please request a new one.", - }) - } - - email, ok := emailVal.(string) - if !ok { - log.Printf(logMsgHandlerPostKeyCacheTypeFmt, key, emailVal) + mail, exists := passwordResetCache.Get(key) + if exists { passwordResetCache.Delete(key) - passwordResetCache.Delete(email) - return c.Render(http.StatusInternalServerError, "reset.gohtml", map[string]any{ - "Error": "An internal error occurred. Please try again.", - }) } - // Invalidate stuff - passwordResetCache.Delete(key) - passwordResetCache.Delete(email) - - log.Printf(logMsgMaddyExecAttemptFmt, email) - maddyExecCommand := exec.Command("maddy", "creds", "password", "-p", password, email) - output, execErr := maddyExecCommand.CombinedOutput() - - if execErr != nil { - log.Printf(logMsgMaddyExecFailedFmt, email, execErr, string(output)) - return c.Render(http.StatusInternalServerError, "reset.gohtml", map[string]any{ - "Error": "Failed to update password due to a server error.", - }) + maddyExecCommand := exec.Command("maddy", "creds", "password", "-p", password, mail.(string)) + err = maddyExecCommand.Run() + if err != nil { + log.Println("[maddyExecCommand] Failed to execute Maddy's password reset command - ", err) + return err } - log.Printf(logMsgMaddyExecSuccessFmt, email, string(output)) - return c.Render(http.StatusOK, "reset.gohtml", map[string]any{ - "Success": "Password successfully changed! You can now log in with your new password.", - }) + return c.String(http.StatusOK, "All good! Your password is now changed.") }) - serverAddr := ":" + strconv.Itoa(HTTPServerPort) - log.Printf(logMsgEchoStartFmt, serverAddr) // Use Printf for format string - if err := e.Start(serverAddr); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf(logMsgEchoStartFailedFmt, err) - } + log.Println("[echo] Starting Echo web server") + e.Logger.Fatal(e.Start(":" + strconv.Itoa(HTTPServerPort))) } diff --git a/templates/reset.en.gohtml b/templates/reset.en.gohtml deleted file mode 100644 index 15f4ecd..0000000 --- a/templates/reset.en.gohtml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - Password Reset - - - -

Password Reset

-{{ if .Error }} - {{ .Error }} -{{ else }} - {{ if .UniqueLinkTriggered }} -

Enter your new password below.

-
- - -
- {{ else }} - {{ if .Sent }} -

A password reset email has been sent if that address exists.

- {{ else }} -
- - -
- {{ end }} - {{ end }} -{{ end }} - - \ No newline at end of file diff --git a/templates/reset.gohtml b/templates/reset.gohtml index fe27f3a..b905de6 100644 --- a/templates/reset.gohtml +++ b/templates/reset.gohtml @@ -38,24 +38,20 @@

Сброс пароля

-{{ if .Error }} - {{ .Error }} +{{ if .UniqueLinkTriggered }} +

Напишите здесь ваш новый пароль

+
+ + +
{{ else }} - {{ if .UniqueLinkTriggered }} -

Напишите здесь ваш новый пароль

-
- - -
+ {{ if .Sent }} +

Сообщение о сбросе пароля было отправлено, если такой адрес существует.

{{ else }} - {{ if .Sent }} -

Сообщение о сбросе пароля было отправлено, если такой адрес существует.

- {{ else }} -
- - -
- {{ end }} +
+ + +
{{ end }} {{ end }}