mirror of
				https://github.com/hugmouse/maddy-password-reset.git
				synced 2025-09-07 16:26:11 +00:00 
			
		
		
		
	Compare commits
	
		
			2 commits
		
	
	
		
			02a75b7d53
			...
			733287e36b
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 733287e36b | ||
|  | fda300e91d | 
					 3 changed files with 376 additions and 114 deletions
				
			
		
							
								
								
									
										400
									
								
								main.go
									
										
									
									
									
								
							
							
						
						
									
										400
									
								
								main.go
									
										
									
									
									
								
							|  | @ -18,6 +18,7 @@ package main | ||||||
| import ( | import ( | ||||||
| 	cryptorand "crypto/rand" | 	cryptorand "crypto/rand" | ||||||
| 	"database/sql" | 	"database/sql" | ||||||
|  | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"github.com/akyoto/cache" | 	"github.com/akyoto/cache" | ||||||
| 	"github.com/hugmouse/maddy-password-reset/templates" | 	"github.com/hugmouse/maddy-password-reset/templates" | ||||||
|  | @ -37,37 +38,39 @@ import ( | ||||||
| 	"time" | 	"time" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | // --------------------------------------------------------------- | ||||||
|  | // Configuration | ||||||
|  | // Make sure to set these constants before running the program | ||||||
|  | // --------------------------------------------------------------- | ||||||
| const ( | const ( | ||||||
| 	// MaddyPath is path to your Maddy credentials database | 	// MaddyPath is path to your Maddy credentials database | ||||||
| 	// |  | ||||||
| 	// FYI, Maddy's password database by default is "/var/lib/maddy/credentials.db" | 	// FYI, Maddy's password database by default is "/var/lib/maddy/credentials.db" | ||||||
| 	MaddyPath = "" | 	MaddyPath = "maddy.db" // MUST be set | ||||||
| 
 | 
 | ||||||
| 	// HostingURL is your domain name, | 	// HostingURL is your domain name, including scheme and trailing slash, | ||||||
| 	// for example: `http://localhost:1323/` | 	// for example: `http://localhost:1323/` or `https://example.com/` | ||||||
| 	HostingURL = "" | 	HostingURL = "http://localhost:1323/" // MUST be set | ||||||
| 
 | 
 | ||||||
| 	// SMTPMailUsername is your full mail address, | 	// SMTPMailUsername is your full mail address, | ||||||
| 	// for example: `robot@local.host` | 	// for example: `robot@local.host` | ||||||
| 	SMTPMailUsername = "" | 	SMTPMailUsername = "" // MUST be set (unless DebugBypassMailSending is true) | ||||||
| 
 | 
 | ||||||
| 	// SMTPMailPassword is your mailbox password | 	// SMTPMailPassword is your mailbox password | ||||||
| 	SMTPMailPassword = "" | 	SMTPMailPassword = "" // MUST be set (unless DebugBypassMailSending is true) | ||||||
| 
 | 
 | ||||||
| 	// SMTPMailHostname is your mail hostname, | 	// SMTPMailHostname is your mail hostname, | ||||||
| 	// for example: `mx1.local.host` | 	// for example: `mx1.local.host` | ||||||
| 	SMTPMailHostname = "" | 	SMTPMailHostname = "" // MUST be set (unless DebugBypassMailSending is true) | ||||||
| 
 | 
 | ||||||
| 	// MXServer is your mail `MX` record + `PORT`, | 	// MXServer is your mail `MX` record + `PORT`, | ||||||
| 	// for example: `mx1.local.host:587` | 	// for example: `mx1.local.host:587` | ||||||
| 	MXServer = "" | 	MXServer = "" // MUST be set (unless DebugBypassMailSending is true) | ||||||
| 
 | 
 | ||||||
| 	// EmailFrom is a EmailTemplate's "$FROM" section | 	// EmailFrom is a EmailTemplate's "$FROM" section | ||||||
| 	EmailFrom = "" | 	EmailFrom = "" // MUST be set (unless DebugBypassMailSending is true) | ||||||
| 	// EmailSubject is a EmailTemplate's "$SUBJECT" section | 	// EmailSubject is a EmailTemplate's "$SUBJECT" section | ||||||
| 	EmailSubject = "" | 	EmailSubject = "" // MUST be set (unless DebugBypassMailSending is true) | ||||||
| 	// EmailMessage is a EmailTemplate's "$MESSAGE" section | 	// EmailMessage is a EmailTemplate's "$MESSAGE" section | ||||||
| 	// |  | ||||||
| 	// Remember to provide a password reset link to a user ($RESET_LINK) | 	// Remember to provide a password reset link to a user ($RESET_LINK) | ||||||
| 	EmailMessage = "Here's your reset link: $RESET_LINK\r\n" | 	EmailMessage = "Here's your reset link: $RESET_LINK\r\n" | ||||||
| 	// EmailTemplate is your reset mail message | 	// EmailTemplate is your reset mail message | ||||||
|  | @ -84,27 +87,102 @@ const ( | ||||||
| 	// HTTPServerPort is an HTTP server port | 	// HTTPServerPort is an HTTP server port | ||||||
| 	HTTPServerPort = 1323 | 	HTTPServerPort = 1323 | ||||||
| 
 | 
 | ||||||
| 	// DebugBypassMailSending if true, will not send any emails and will print reset link to the console | 	// DebugBypassMailSending - if true, skips SMTP checks and sending, logs reset link instead. | ||||||
| 	DebugBypassMailSending = true | 	DebugBypassMailSending = false | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | // --------------------------------------------------------------- | ||||||
|  | // Constants for random string generator | ||||||
|  | // --------------------------------------------------------------- | ||||||
| const ( | const ( | ||||||
| 	// TokenAlphabet is created for random string creation, see randomString() function | 	TokenAlphabet      = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" | ||||||
| 	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" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func randomString(length int) string { | func randomString(length int) string { | ||||||
| 	l := big.NewInt(int64(len(TokenAlphabet))) | 	l := big.NewInt(int64(len(TokenAlphabet))) | ||||||
| 	res := new(strings.Builder) | 	res := new(strings.Builder) | ||||||
|  | 	res.Grow(length) | ||||||
| 	for i := 0; i < length; i++ { | 	for i := 0; i < length; i++ { | ||||||
| 		n, err := cryptorand.Int(cryptorand.Reader, l) | 		n, err := cryptorand.Int(cryptorand.Reader, l) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			panic(err) | 			log.Fatalf(logMsgRandNumGenFailedCriticalFmt, err) | ||||||
| 		} | 		} | ||||||
| 
 |  | ||||||
| 		res.WriteByte(TokenAlphabet[n.Int64()]) | 		res.WriteByte(TokenAlphabet[n.Int64()]) | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| 	return res.String() | 	return res.String() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -112,80 +190,132 @@ type Template struct { | ||||||
| 	templates *template.Template | 	templates *template.Template | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (t *Template) Render(w io.Writer, name string, data interface{}, _ echo.Context) error { | func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { | ||||||
| 	return t.templates.ExecuteTemplate(w, name, data) | 	return t.templates.ExecuteTemplate(w, name, data) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func isValidEmailAddress(email string) error { | func isValidEmailAddress(email string) error { | ||||||
| 	// Parse the email address using addressparser | 	if email == "" { | ||||||
| 	mail, err := mail.ParseAddress(email) | 		return errors.New("email address cannot be empty") | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Check if the parsed address is not nil and has a valid email format | 	addr, err := mail.ParseAddress(email) | ||||||
| 	if mail == nil || mail.Address == "" { | 	if err != nil { | ||||||
| 		log.Println("[AddressParser]: Invalid Email Address: %v") | 		return fmt.Errorf("invalid email format: %w", err) | ||||||
| 		return err | 	} | ||||||
|  | 
 | ||||||
|  | 	if !strings.Contains(addr.Address, "@") { | ||||||
|  | 		return errors.New("invalid email format: missing @ symbol") | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	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() { | func main() { | ||||||
|  | 	log.Println(logMsgConfigValidationStart) | ||||||
|  | 	validateConfiguration() | ||||||
|  | 
 | ||||||
| 	var auth smtp.Auth | 	var auth smtp.Auth | ||||||
| 	if !DebugBypassMailSending { | 	if !DebugBypassMailSending { | ||||||
| 		log.Println("[EmailMessage const] Checking your template") | 		log.Println(logMsgSmtpAuthSetup) | ||||||
| 		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) | 		auth = smtp.PlainAuth("", SMTPMailUsername, SMTPMailPassword, SMTPMailHostname) | ||||||
| 	} else { | 	} else { | ||||||
| 		log.Println("[SMTP] Debug mode enabled, not checking email template") | 		log.Println(logMsgSmtpAuthSkipped) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	log.Println("[Sqlite] Loading Maddy's credentials database") | 	log.Printf(logMsgDbLoadingFmt, MaddyPath) | ||||||
| 	db, err := sql.Open("sqlite", MaddyPath) | 	db, err := sql.Open("sqlite", MaddyPath) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Fatalln(err) | 		log.Fatalf(logMsgDbOpenFailedFmt, err) | ||||||
| 	} | 	} | ||||||
|  | 	defer func() { | ||||||
|  | 		log.Println(logMsgDbClosing) | ||||||
|  | 		if err := db.Close(); err != nil { | ||||||
|  | 			log.Printf(logMsgDbCloseErrorFmt, err) | ||||||
|  | 		} | ||||||
|  | 	}() | ||||||
| 
 | 
 | ||||||
| 	log.Println("[Cache] Registering cache for password resets") | 	if err := db.Ping(); err != nil { | ||||||
|  | 		log.Fatalf(logMsgDbPingFailedFmt, err) | ||||||
|  | 	} | ||||||
|  | 	log.Println(logMsgDbPingSuccess) | ||||||
|  | 
 | ||||||
|  | 	log.Printf(logMsgCacheRegisterFmt, CacheTime) | ||||||
| 	passwordResetCache := cache.New(CacheTime) | 	passwordResetCache := cache.New(CacheTime) | ||||||
| 
 | 
 | ||||||
| 	log.Println("[Echo] Initializing echo web server") | 	log.Println(logMsgEchoInit) | ||||||
| 	e := echo.New() | 	e := echo.New() | ||||||
| 	e.HideBanner = true | 	e.HideBanner = true | ||||||
| 	e.Use(middleware.LoggerWithConfig( | 	e.Use(middleware.LoggerWithConfig( | ||||||
| 		middleware.LoggerConfig{ | 		middleware.LoggerConfig{ | ||||||
| 			Format:           `${time_custom} [Echo] ${latency_human} ${method} ${uri} - Error = ${error} - ${remote_ip} "${user_agent}"` + "\n", | 			Format:           `${time_custom} [Echo] ${latency_human} ${method} ${uri} - Status=${status} Error="${error}" RemoteIP=${remote_ip} UserAgent="${user_agent}"` + "\n", | ||||||
| 			CustomTimeFormat: "2006/01/02 15:04:05", | 			CustomTimeFormat: "2006/01/02 15:04:05", | ||||||
| 		})) | 		})) | ||||||
| 	e.Use(middleware.Recover()) | 	e.Use(middleware.Recover()) | ||||||
| 
 | 
 | ||||||
| 	log.Println("[Echo] Registering Go templates") | 	log.Println(logMsgEchoTplRegister) | ||||||
| 	t := template.Must(template.ParseFS(templates.Templates, "*.gohtml")) | 	t := template.Must(template.ParseFS(templates.Templates, "*.gohtml")) | ||||||
| 	e.Renderer = &Template{ | 	e.Renderer = &Template{ | ||||||
| 		t, | 		templates: t, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	e.GET("/reset", func(c echo.Context) error { | 	e.GET("/reset", func(c echo.Context) error { | ||||||
|  | @ -193,89 +323,155 @@ func main() { | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	e.POST("/reset", func(c echo.Context) error { | 	e.POST("/reset", func(c echo.Context) error { | ||||||
| 		mail := c.FormValue("email") | 		email := c.FormValue("email") | ||||||
| 		err = isValidEmailAddress(mail) | 		if err := isValidEmailAddress(email); err != nil { | ||||||
| 		if err != nil { | 			log.Printf(logMsgHandlerPostResetInvalidEmailFmt, email, err) | ||||||
| 			log.Println("[AddressParser]: Invalid mail address: ", err) | 			return c.Render(http.StatusBadRequest, "reset.gohtml", map[string]any{ | ||||||
| 			return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid mail address: %v", err)) | 				"Error": "Invalid email address format provided.", | ||||||
|  | 			}) | ||||||
| 		} | 		} | ||||||
| 		go func() { | 
 | ||||||
| 			// Check if there is already a password reset | 		go func(userEmail string) { | ||||||
| 			_, exists := passwordResetCache.Get(mail) | 
 | ||||||
|  | 			// Check if there is already a pending password reset for this email | ||||||
|  | 			_, exists := passwordResetCache.Get(userEmail) | ||||||
| 			if exists { | 			if exists { | ||||||
| 				log.Printf("[Cache] Mail %q already exists in cache, ignoring\n", mail) | 				log.Printf(logMsgCacheResetPendingFmt, userEmail) | ||||||
| 				return | 				return | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			// Check if it's exists in Maddy db | 			// Check if user exists in Maddy DB | ||||||
| 			// It will return an error is there is no user found | 			var dummy int | ||||||
| 			var password string | 			dbErr := db.QueryRow("SELECT 1 FROM passwords WHERE key = ?", userEmail).Scan(&dummy) | ||||||
| 			err = db.QueryRow("SELECT value FROM passwords WHERE key = ?", mail).Scan(&password) | 
 | ||||||
| 			if err != nil { | 			if dbErr != nil { | ||||||
| 				log.Println("[Sqlite] An error occurred while trying to get password from Maddy database:", err) | 				if errors.Is(dbErr, sql.ErrNoRows) { | ||||||
|  | 					log.Printf(logMsgDbUserNotFoundFmt, userEmail) | ||||||
|  | 				} else { | ||||||
|  | 					log.Printf(logMsgDbQueryErrorFmt, userEmail, dbErr) | ||||||
|  | 				} | ||||||
| 				return | 				return | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			// Generating an unique key | 			token := randomString(RandomStringLength) | ||||||
| 			random := randomString(10) | 			passwordResetCache.Set(token, userEmail, CacheTime) | ||||||
| 			passwordResetCache.Set(random, mail, CacheTime) | 			passwordResetCache.Set(userEmail, token, CacheTime) | ||||||
| 
 | 
 | ||||||
| 			// Connect to the server, authenticate, set the sender and recipient, | 			resetLink := HostingURL + "reset/" + token | ||||||
| 			// and send the email all in one step. | 			log.Printf(logMsgResetTokenGeneratedFmt, token, userEmail, resetLink) | ||||||
| 			to := []string{mail} |  | ||||||
| 
 | 
 | ||||||
| 			if !DebugBypassMailSending { | 			if !DebugBypassMailSending { | ||||||
| 				msg := strings.ReplaceAll(EmailTemplate, "$TO", mail) | 				msg := strings.ReplaceAll(EmailTemplate, "$TO", userEmail) | ||||||
| 				msg = strings.ReplaceAll(msg, "$FROM", EmailFrom) | 				msg = strings.ReplaceAll(msg, "$FROM", EmailFrom) | ||||||
| 				msg = strings.ReplaceAll(msg, "$SUBJECT", EmailSubject) | 				msg = strings.ReplaceAll(msg, "$SUBJECT", EmailSubject) | ||||||
| 				msg = strings.ReplaceAll(msg, "$MESSAGE", EmailMessage) |  | ||||||
| 				msg = strings.ReplaceAll(msg, "$RESET_LINK", HostingURL+"reset/"+random) |  | ||||||
| 
 | 
 | ||||||
| 				err := smtp.SendMail(MXServer, auth, SMTPMailUsername, to, []byte(msg)) | 				messageBody := strings.ReplaceAll(EmailMessage, "$RESET_LINK", resetLink) | ||||||
| 				if err != nil { | 				msg = strings.ReplaceAll(msg, "$MESSAGE", messageBody) | ||||||
| 					log.Println("[SMTP] Failed to send mail - ", err) | 
 | ||||||
|  | 				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) | ||||||
| 					return | 					return | ||||||
| 				} | 				} | ||||||
|  | 				log.Printf(logMsgSmtpSendSuccessFmt, userEmail) | ||||||
| 			} else { | 			} else { | ||||||
| 				log.Println("[SMTP] Debug mode enabled, not sending email") | 				log.Printf(logMsgSmtpDebugSendSkippedFmt, userEmail) | ||||||
| 				log.Println("[SMTP] Reset link:", HostingURL+"reset/"+random) | 				log.Printf(logMsgSmtpDebugResetLinkFmt, userEmail, resetLink) | ||||||
| 			} | 			} | ||||||
| 		}() | 		}(email) | ||||||
| 
 | 
 | ||||||
|  | 		// Always return success to the user to prevent email enumeration | ||||||
| 		return c.Render(http.StatusOK, "reset.gohtml", map[string]any{ | 		return c.Render(http.StatusOK, "reset.gohtml", map[string]any{ | ||||||
| 			"Sent": true, | 			"Sent": true, // Indicates request received, not necessarily email sent successfully | ||||||
| 		}) | 		}) | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	e.GET("/reset/:key", func(c echo.Context) error { | 	e.GET("/reset/:key", func(c echo.Context) error { | ||||||
| 		key := c.Param("key") | 		key := c.Param("key") | ||||||
| 		_, exists := passwordResetCache.Get(key) | 		if key == "" { | ||||||
| 		if !exists { | 			log.Println(logMsgHandlerGetKeyEmpty) | ||||||
| 			return c.Redirect(http.StatusTemporaryRedirect, "/reset") | 			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.Render(http.StatusOK, "reset.gohtml", map[string]any{ | 		return c.Render(http.StatusOK, "reset.gohtml", map[string]any{ | ||||||
| 			"UniqueLinkTriggered": true, | 			"UniqueLinkTriggered": true, | ||||||
|  | 			"Key":                 key, | ||||||
| 		}) | 		}) | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	e.POST("/reset/:key", func(c echo.Context) error { | 	e.POST("/reset/:key", func(c echo.Context) error { | ||||||
| 		key := c.Param("key") | 		key := c.Param("key") | ||||||
| 		password := c.FormValue("password") | 		password := c.FormValue("password") | ||||||
| 		mail, exists := passwordResetCache.Get(key) | 
 | ||||||
| 		if exists { | 		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) | ||||||
| 			passwordResetCache.Delete(key) | 			passwordResetCache.Delete(key) | ||||||
|  | 			passwordResetCache.Delete(email) | ||||||
|  | 			return c.Render(http.StatusInternalServerError, "reset.gohtml", map[string]any{ | ||||||
|  | 				"Error": "An internal error occurred. Please try again.", | ||||||
|  | 			}) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		maddyExecCommand := exec.Command("maddy", "creds", "password", "-p", password, mail.(string)) | 		// Invalidate stuff | ||||||
| 		err = maddyExecCommand.Run() | 		passwordResetCache.Delete(key) | ||||||
| 		if err != nil { | 		passwordResetCache.Delete(email) | ||||||
| 			log.Println("[maddyExecCommand] Failed to execute Maddy's password reset command - ", err) | 
 | ||||||
| 			return err | 		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.", | ||||||
|  | 			}) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		return c.String(http.StatusOK, "All good! Your password is now changed.") | 		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.", | ||||||
|  | 		}) | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	log.Println("[echo] Starting Echo web server") | 	serverAddr := ":" + strconv.Itoa(HTTPServerPort) | ||||||
| 	e.Logger.Fatal(e.Start(":" + 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) | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										62
									
								
								templates/reset.en.gohtml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								templates/reset.en.gohtml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,62 @@ | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  | <head> | ||||||
|  |     <meta charset="UTF-8"> | ||||||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|  |     <meta http-equiv="X-UA-Compatible" content="ie=edge"> | ||||||
|  |     <title>Password Reset</title> | ||||||
|  |     <style> | ||||||
|  |         body { | ||||||
|  |             padding: 1rem; | ||||||
|  |             font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         input { | ||||||
|  |             margin-top: 1rem; | ||||||
|  |             padding: 1rem; | ||||||
|  |             background: transparent; | ||||||
|  |             border: 1px solid black; | ||||||
|  |             color: black; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         label { | ||||||
|  |             display: block; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         @media (prefers-color-scheme: dark) { | ||||||
|  |             body { | ||||||
|  |                 background-color: #222; | ||||||
|  |                 color: white; | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             input { | ||||||
|  |                 border: 1px solid #ffffff; | ||||||
|  |                 color: #ffffff; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     </style> | ||||||
|  | </head> | ||||||
|  | <body> | ||||||
|  | <h1>Password Reset</h1> | ||||||
|  | {{ if .Error }} | ||||||
|  |     {{ .Error }} | ||||||
|  | {{ else }} | ||||||
|  |     {{ if .UniqueLinkTriggered }} | ||||||
|  |         <p>Enter your new password below.</p> | ||||||
|  |         <form action="" method="post"> | ||||||
|  |             <label for="password">Your new password</label> | ||||||
|  |             <input type="password" name="password" id="password" placeholder="Enter your new password"> | ||||||
|  |         </form> | ||||||
|  |     {{ else }} | ||||||
|  |         {{ if .Sent }} | ||||||
|  |             <p>A password reset email has been sent if that address exists.</p> | ||||||
|  |         {{ else }} | ||||||
|  |             <form action="/reset" method="post"> | ||||||
|  |                 <label for="email">Email address</label> | ||||||
|  |                 <input type="email" name="email" id="email" placeholder="Enter your email address"> | ||||||
|  |             </form> | ||||||
|  |         {{ end }} | ||||||
|  |     {{ end }} | ||||||
|  | {{ end }} | ||||||
|  | </body> | ||||||
|  | </html> | ||||||
|  | @ -38,20 +38,24 @@ | ||||||
| </head> | </head> | ||||||
| <body> | <body> | ||||||
| <h1>Сброс пароля</h1> | <h1>Сброс пароля</h1> | ||||||
| {{ if .UniqueLinkTriggered }} | {{ if .Error }} | ||||||
|     <p>Напишите здесь ваш новый пароль</p> |     {{ .Error }} | ||||||
|     <form action="" method="post"> |  | ||||||
|         <label for="password">Ваш новый пароль</label> |  | ||||||
|         <input type="password" name="password" id="password" placeholder="Введите свой новый пароль"> |  | ||||||
|     </form> |  | ||||||
| {{ else }} | {{ else }} | ||||||
|     {{ if .Sent }} |     {{ if .UniqueLinkTriggered }} | ||||||
|         <p>Сообщение о сбросе пароля было отправлено, если такой адрес существует.</p> |         <p>Напишите здесь ваш новый пароль</p> | ||||||
|     {{ else }} |         <form action="" method="post"> | ||||||
|         <form action="/reset" method="post"> |             <label for="password">Ваш новый пароль</label> | ||||||
|             <label for="email">Email адрес</label> |             <input type="password" name="password" id="password" placeholder="Введите свой новый пароль"> | ||||||
|             <input type="email" name="email" id="email" placeholder="Введите свой email адрес"> |  | ||||||
|         </form> |         </form> | ||||||
|  |     {{ else }} | ||||||
|  |         {{ if .Sent }} | ||||||
|  |             <p>Сообщение о сбросе пароля было отправлено, если такой адрес существует.</p> | ||||||
|  |         {{ else }} | ||||||
|  |             <form action="/reset" method="post"> | ||||||
|  |                 <label for="email">Email адрес</label> | ||||||
|  |                 <input type="email" name="email" id="email" placeholder="Введите свой email адрес"> | ||||||
|  |             </form> | ||||||
|  |         {{ end }} | ||||||
|     {{ end }} |     {{ end }} | ||||||
| {{ end }} | {{ end }} | ||||||
| </body> | </body> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		
		Reference in a new issue