1. Password reset tokens: use crypto/rand instead of math/rand 2. IP spoofing: only trust X-Forwarded-For from configured trusted proxies 3. Cleanup: purge failed login attempts older than 24 hours 4. JWT secret: validate minimum 32 character length on startup Updated .env.example with TRUSTED_PROXIES setting
190 lines
6 KiB
Go
190 lines
6 KiB
Go
// Package mail provides email sending functionality for password reset emails.
|
|
// Uses Go's net/smtp package for sending emails via SMTP protocol.
|
|
package mail
|
|
|
|
import (
|
|
"crypto/rand" // cryptographically secure random number generator
|
|
"crypto/tls" // TLS/SSL encryption for secure email sending
|
|
"encoding/hex" // hex encoding for token generation
|
|
"fmt" // string formatting for URLs and messages
|
|
"net/smtp" // simple mail transfer protocol package
|
|
|
|
"github.com/imc-vibe/backend/internal/config"
|
|
)
|
|
|
|
// EmailService handles sending emails through SMTP.
|
|
// Stores SMTP configuration and provides methods for different email types.
|
|
type EmailService struct {
|
|
host string // SMTP server hostname (e.g., "smtp.example.com")
|
|
port string // SMTP server port (e.g., "587" for TLS, "465" for SSL)
|
|
username string // SMTP authentication username
|
|
password string // SMTP authentication password
|
|
from string // Sender email address (e.g., "noreply@example.com")
|
|
baseURL string // Base URL of the web application (for building reset links)
|
|
}
|
|
|
|
// NewEmailService creates a new EmailService instance from configuration.
|
|
// cfg: application configuration containing SMTP settings and base URL.
|
|
func NewEmailService(cfg *config.Config) *EmailService {
|
|
return &EmailService{
|
|
host: cfg.SMTPHost,
|
|
port: cfg.SMTPPort,
|
|
username: cfg.SMTPUser,
|
|
password: cfg.SMTPPassword,
|
|
from: cfg.SMTPFrom,
|
|
baseURL: cfg.BaseURL,
|
|
}
|
|
}
|
|
|
|
// SendPasswordReset sends a password reset email to the user.
|
|
// to: recipient email address
|
|
// token: unique reset token that was generated and stored in the database
|
|
// The email contains a clickable link to reset the password.
|
|
func (s *EmailService) SendPasswordReset(to, token string) error {
|
|
// Build the reset URL with the token as a query parameter.
|
|
// Example: https://mail.example.com/auth/reset-password?token=abc123...
|
|
resetURL := fmt.Sprintf("%s/auth/reset-password?token=%s", s.baseURL, token)
|
|
|
|
// Compose the email subject and body.
|
|
subject := "Password Reset Request"
|
|
body := fmt.Sprintf(`Hi,
|
|
|
|
You requested a password reset for your account.
|
|
|
|
Click the link below to reset your password:
|
|
%s
|
|
|
|
This link will expire in 1 hour.
|
|
|
|
If you didn't request this, please ignore this email.
|
|
|
|
Best regards,
|
|
IMC Vibe Mail Server Admin
|
|
`, resetURL)
|
|
|
|
// Send the email using the send method.
|
|
return s.send(to, subject, body)
|
|
}
|
|
|
|
// send is the internal method that actually sends the email via SMTP.
|
|
// Handles both port 587 (TLS) and port 465 (SSL) connections.
|
|
func (s *EmailService) send(to, subject, body string) error {
|
|
// Check if SMTP is configured. If not, return an error.
|
|
if s.host == "" || s.username == "" {
|
|
return fmt.Errorf("SMTP not configured")
|
|
}
|
|
|
|
// Build the server address in host:port format.
|
|
addr := fmt.Sprintf("%s:%s", s.host, s.port)
|
|
|
|
// Build the email message with headers.
|
|
// \r\n is the standard line ending for email headers and body.
|
|
msg := fmt.Sprintf("From: %s\r\n"+
|
|
"To: %s\r\n"+
|
|
"Subject: %s\r\n"+
|
|
"MIME-Version: 1.0\r\n"+
|
|
"Content-Type: text/plain; charset=\"UTF-8\"\r\n"+
|
|
"\r\n"+
|
|
"%s", s.from, to, subject, body)
|
|
|
|
// Port 465 uses implicit SSL/TLS, port 587 uses STARTTLS.
|
|
// Both are secure, but they work differently.
|
|
var auth smtp.Auth
|
|
if s.port == "465" {
|
|
// For port 465, we need to establish a TLS connection first
|
|
// before sending anything, then upgrade the connection.
|
|
err := s.sendWithTLS(addr, to, msg)
|
|
return err
|
|
} else {
|
|
// For other ports (usually 587), use STARTTLS which upgrades
|
|
// a plain connection to TLS after connecting.
|
|
auth = smtp.PlainAuth("", s.username, s.password, s.host)
|
|
err := smtp.SendMail(addr, auth, s.from, []string{to}, []byte(msg))
|
|
return err
|
|
}
|
|
}
|
|
|
|
// sendWithTLS sends an email using an explicit TLS connection on port 465.
|
|
// Port 465 uses implicit TLS - the entire connection is encrypted from the start.
|
|
func (s *EmailService) sendWithTLS(addr, to, msg string) error {
|
|
// Create a TLS configuration for the secure connection.
|
|
// ServerName must match the SMTP server's certificate.
|
|
tlsConfig := &tls.Config{
|
|
ServerName: s.host,
|
|
}
|
|
|
|
// Establish a TLS connection to the SMTP server.
|
|
// This creates an encrypted tunnel from the start.
|
|
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer conn.Close() // Ensure connection is closed when we're done
|
|
|
|
// Create an SMTP client on top of the TLS connection.
|
|
client, err := smtp.NewClient(conn, s.host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
|
|
// Authenticate with the SMTP server using PLAIN authentication.
|
|
// The empty string "" is the identity (usually same as username).
|
|
auth := smtp.PlainAuth("", s.username, s.password, s.host)
|
|
if err := client.Auth(auth); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set the sender (MAIL FROM command).
|
|
if err := client.Mail(s.from); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set the recipient (RCPT TO command).
|
|
if err := client.Rcpt(to); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Start sending the message body (DATA command).
|
|
w, err := client.Data()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write the email body to the data stream.
|
|
_, err = w.Write([]byte(msg))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Close the data writer to finish the message.
|
|
err = w.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Send the QUIT command to gracefully close the connection.
|
|
return client.Quit()
|
|
}
|
|
|
|
// GenerateToken creates a cryptographically random token for password reset links.
|
|
// Returns a 64-character hexadecimal string (32 bytes encoded as hex).
|
|
// Uses crypto/rand for security-sensitive token generation.
|
|
func GenerateToken() string {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic("crypto/rand failed: " + err.Error())
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// ValidateToken checks if a token has the correct format.
|
|
// Returns true if the token is exactly 64 characters of valid hex.
|
|
// This is a quick validation before checking the database.
|
|
func ValidateToken(token string) bool {
|
|
if len(token) != 64 {
|
|
return false
|
|
}
|
|
_, err := hex.DecodeString(token)
|
|
return err == nil
|
|
}
|