Remove password reset functionality
Backend: - Remove ForgotPassword API handler - Remove password reset token model and database table - Remove email service for sending reset emails - Remove SMTP configuration Frontend: - Remove /auth/forgot page - Remove forgot password link from login page - Remove forgot route from auth-only routes check
This commit is contained in:
parent
c459cc1efb
commit
abd7e3a97f
9 changed files with 5 additions and 425 deletions
|
|
@ -1,7 +1,6 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -9,7 +8,6 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/imc-vibe/backend/internal/auth"
|
"github.com/imc-vibe/backend/internal/auth"
|
||||||
"github.com/imc-vibe/backend/internal/db"
|
"github.com/imc-vibe/backend/internal/db"
|
||||||
"github.com/imc-vibe/backend/internal/mail"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const MaxLoginAttempts = 5
|
const MaxLoginAttempts = 5
|
||||||
|
|
@ -17,15 +15,13 @@ const MaxLoginAttempts = 5
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
jwtManager *auth.JWTManager
|
jwtManager *auth.JWTManager
|
||||||
emailService *mail.EmailService
|
|
||||||
trustedProxies []string
|
trustedProxies []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(database *db.DB, jwtManager *auth.JWTManager, emailService *mail.EmailService, trustedProxies []string) *AuthHandler {
|
func NewAuthHandler(database *db.DB, jwtManager *auth.JWTManager, trustedProxies []string) *AuthHandler {
|
||||||
return &AuthHandler{
|
return &AuthHandler{
|
||||||
db: database,
|
db: database,
|
||||||
jwtManager: jwtManager,
|
jwtManager: jwtManager,
|
||||||
emailService: emailService,
|
|
||||||
trustedProxies: trustedProxies,
|
trustedProxies: trustedProxies,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -40,10 +36,6 @@ type ChangePasswordRequest struct {
|
||||||
NewPassword string `json:"newPassword" binding:"required,min=8"`
|
NewPassword string `json:"newPassword" binding:"required,min=8"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ForgotPasswordRequest struct {
|
|
||||||
Identifier string `json:"identifier" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserResponse struct {
|
type UserResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
|
@ -128,47 +120,6 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
|
|
||||||
var req ForgotPasswordRequest
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
Error(c, http.StatusBadRequest, "invalid request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Identifier = strings.TrimSpace(strings.ToLower(req.Identifier))
|
|
||||||
if req.Identifier == "" {
|
|
||||||
Error(c, http.StatusBadRequest, "identifier required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := h.db.GetImcUserByUsername(req.Identifier)
|
|
||||||
if err != nil {
|
|
||||||
Success(c, map[string]string{
|
|
||||||
"message": "If the account exists, a password reset link will be sent",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := mail.GenerateToken()
|
|
||||||
expiresAt := time.Now().Add(1 * time.Hour)
|
|
||||||
|
|
||||||
if err := h.db.CreatePasswordResetToken(user.ID, token, expiresAt); err != nil {
|
|
||||||
log.Printf("Failed to create password reset token: %v", err)
|
|
||||||
Error(c, http.StatusInternalServerError, "failed to create reset token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.emailService.SendPasswordReset(user.Username, token); err != nil {
|
|
||||||
log.Printf("Failed to send password reset email: %v", err)
|
|
||||||
Error(c, http.StatusInternalServerError, "failed to send email")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Success(c, map[string]string{
|
|
||||||
"message": "If the account exists, a password reset link will be sent",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||||
authCtx := GetAuthContext(c)
|
authCtx := GetAuthContext(c)
|
||||||
if authCtx == nil {
|
if authCtx == nil {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"github.com/imc-vibe/backend/internal/auth"
|
"github.com/imc-vibe/backend/internal/auth"
|
||||||
"github.com/imc-vibe/backend/internal/config"
|
"github.com/imc-vibe/backend/internal/config"
|
||||||
"github.com/imc-vibe/backend/internal/db"
|
"github.com/imc-vibe/backend/internal/db"
|
||||||
"github.com/imc-vibe/backend/internal/mail"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
|
|
@ -24,10 +23,9 @@ type Router struct {
|
||||||
|
|
||||||
func New(database *db.DB, cfg *config.Config) *Router {
|
func New(database *db.DB, cfg *config.Config) *Router {
|
||||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, "imc-vibe")
|
jwtManager := auth.NewJWTManager(cfg.JWTSecret, "imc-vibe")
|
||||||
emailService := mail.NewEmailService(cfg)
|
|
||||||
|
|
||||||
r := &Router{
|
r := &Router{
|
||||||
authHandler: handlers.NewAuthHandler(database, jwtManager, emailService, cfg.TrustedProxies),
|
authHandler: handlers.NewAuthHandler(database, jwtManager, cfg.TrustedProxies),
|
||||||
domainHandler: handlers.NewDomainHandler(database),
|
domainHandler: handlers.NewDomainHandler(database),
|
||||||
userHandler: handlers.NewUserHandler(database),
|
userHandler: handlers.NewUserHandler(database),
|
||||||
aliasHandler: handlers.NewAliasHandler(database),
|
aliasHandler: handlers.NewAliasHandler(database),
|
||||||
|
|
@ -46,7 +44,6 @@ func (r *Router) RegisterRoutes(engine *gin.Engine) {
|
||||||
})
|
})
|
||||||
|
|
||||||
engine.POST("/api/auth/login", r.authHandler.Login)
|
engine.POST("/api/auth/login", r.authHandler.Login)
|
||||||
engine.POST("/api/auth/forgot", r.authHandler.ForgotPassword)
|
|
||||||
engine.POST("/api/auth/logout", r.authHandler.Logout)
|
engine.POST("/api/auth/logout", r.authHandler.Logout)
|
||||||
|
|
||||||
authGroup := engine.Group("/api")
|
authGroup := engine.Group("/api")
|
||||||
|
|
|
||||||
|
|
@ -39,16 +39,6 @@ type Config struct {
|
||||||
|
|
||||||
// Rspamd settings (spam filter)
|
// Rspamd settings (spam filter)
|
||||||
RspamdAPI string // URL of the Rspamd web interface
|
RspamdAPI string // URL of the Rspamd web interface
|
||||||
|
|
||||||
// SMTP settings (for sending password reset emails)
|
|
||||||
SMTPHost string // SMTP server hostname
|
|
||||||
SMTPPort string // SMTP server port (587 for submission, 465 for SMTPS)
|
|
||||||
SMTPUser string // SMTP username
|
|
||||||
SMTPPassword string // SMTP password
|
|
||||||
SMTPFrom string // From address in outgoing emails
|
|
||||||
|
|
||||||
// Application settings
|
|
||||||
BaseURL string // Base URL of this application (used for generating links in emails)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and .env files.
|
// Load reads configuration from environment variables and .env files.
|
||||||
|
|
@ -98,16 +88,6 @@ func Load() *Config {
|
||||||
// External services
|
// External services
|
||||||
RspamdAPI: getEnv("RSPAMD_API", "http://127.0.0.1:11334"),
|
RspamdAPI: getEnv("RSPAMD_API", "http://127.0.0.1:11334"),
|
||||||
|
|
||||||
// SMTP settings (for password reset emails)
|
|
||||||
SMTPHost: getEnv("SMTP_HOST", "localhost"),
|
|
||||||
SMTPPort: getEnv("SMTP_PORT", "587"),
|
|
||||||
SMTPUser: getEnv("SMTP_USER", ""),
|
|
||||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
|
||||||
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
|
|
||||||
|
|
||||||
// Application URL (for generating links in emails)
|
|
||||||
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
|
|
||||||
|
|
||||||
// Trusted proxies (comma-separated IPs)
|
// Trusted proxies (comma-separated IPs)
|
||||||
TrustedProxies: parseTrustedProxies(getEnv("TRUSTED_PROXIES", "")),
|
TrustedProxies: parseTrustedProxies(getEnv("TRUSTED_PROXIES", "")),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,19 +113,6 @@ type ImcLoginAttempt struct {
|
||||||
// TableName specifies the database table name for ImcLoginAttempt.
|
// TableName specifies the database table name for ImcLoginAttempt.
|
||||||
func (ImcLoginAttempt) TableName() string { return "imc_login_attempts" }
|
func (ImcLoginAttempt) TableName() string { return "imc_login_attempts" }
|
||||||
|
|
||||||
// PasswordResetToken stores one-time tokens for password reset functionality.
|
|
||||||
type PasswordResetToken struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"` // Primary key
|
|
||||||
UserID uint `gorm:"not null" json:"userId"` // Foreign key to ImcUser
|
|
||||||
Token string `gorm:"size:64;not null;uniqueIndex" json:"token"` // The reset token (unique index for fast lookup)
|
|
||||||
ExpiresAt time.Time `gorm:"not null" json:"expiresAt"` // When this token expires
|
|
||||||
Used bool `gorm:"default:false" json:"used"` // Whether this token has been used
|
|
||||||
CreatedAt time.Time `json:"createdAt"` // When the token was created
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName specifies the database table name for PasswordResetToken.
|
|
||||||
func (PasswordResetToken) TableName() string { return "imc_password_reset_tokens" }
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Helper/View Models (not stored in database)
|
// Helper/View Models (not stored in database)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -258,19 +245,5 @@ func (d *DB) InitSchema() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create imc_password_reset_tokens table for password reset functionality.
|
return nil
|
||||||
// Stores temporary tokens for resetting forgotten passwords.
|
|
||||||
resetTokensSQL := `
|
|
||||||
CREATE TABLE IF NOT EXISTS imc_password_reset_tokens (
|
|
||||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT UNSIGNED NOT NULL,
|
|
||||||
token VARCHAR(64) NOT NULL UNIQUE,
|
|
||||||
expires_at DATETIME NOT NULL,
|
|
||||||
used BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
INDEX idx_token (token),
|
|
||||||
INDEX idx_user_id (user_id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
||||||
`
|
|
||||||
return d.Exec(resetTokensSQL).Error
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Admin User (ImcUser) Operations
|
// Admin User (ImcUser) Operations
|
||||||
// These functions manage admin users who can log into the web interface.
|
// These functions manage admin users who can log into the web interface.
|
||||||
|
|
@ -189,40 +187,3 @@ func (d *DB) IsUserInDomain(userID, domainID uint) (bool, error) {
|
||||||
err := d.Model(&ImcUserDomain{}).Where("user_id = ? AND domain_id = ?", userID, domainID).Count(&count).Error
|
err := d.Model(&ImcUserDomain{}).Where("user_id = ? AND domain_id = ?", userID, domainID).Count(&count).Error
|
||||||
return count > 0, err
|
return count > 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Password Reset Tokens
|
|
||||||
// These functions manage one-time tokens for password reset.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// CreatePasswordResetToken creates a new password reset token.
|
|
||||||
// userID: the ID of the user requesting password reset.
|
|
||||||
// token: the random token string.
|
|
||||||
// expiresAt: when this token becomes invalid.
|
|
||||||
func (d *DB) CreatePasswordResetToken(userID uint, token string, expiresAt time.Time) error {
|
|
||||||
resetToken := PasswordResetToken{
|
|
||||||
UserID: userID,
|
|
||||||
Token: token,
|
|
||||||
ExpiresAt: expiresAt,
|
|
||||||
}
|
|
||||||
return d.Create(&resetToken).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetValidPasswordResetToken looks up a valid (unused, not expired) reset token.
|
|
||||||
// token: the token string to look up.
|
|
||||||
// Returns: the token if valid, or an error if not found/expired/used.
|
|
||||||
func (d *DB) GetValidPasswordResetToken(token string) (*PasswordResetToken, error) {
|
|
||||||
var resetToken PasswordResetToken
|
|
||||||
// Check token exists, hasn't been used, and hasn't expired.
|
|
||||||
err := d.Where("token = ? AND used = false AND expires_at > ?", token, time.Now()).First(&resetToken).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &resetToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkResetTokenUsed marks a token as used (prevents reuse).
|
|
||||||
// token: the token string to mark as used.
|
|
||||||
func (d *DB) MarkResetTokenUsed(token string) error {
|
|
||||||
return d.Model(&PasswordResetToken{}).Where("token = ?", token).Update("used", true).Error
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -31,9 +31,9 @@
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const path = $page.url.pathname;
|
const path = $page.url.pathname;
|
||||||
// Auth-only routes (login, forgot) - no auth required, no layout chrome
|
// Auth-only routes (login) - no auth required, no layout chrome
|
||||||
// /auth/change-password requires auth, so treat it like a regular route
|
// /auth/change-password requires auth, so treat it like a regular route
|
||||||
const isAuthOnlyRoute = path === '/auth/login' || path === '/auth/forgot';
|
const isAuthOnlyRoute = path === '/auth/login';
|
||||||
if (isAuthOnlyRoute) {
|
if (isAuthOnlyRoute) {
|
||||||
loading = false;
|
loading = false;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
let identifier = $state('');
|
|
||||||
let error = $state('');
|
|
||||||
let success = $state(false);
|
|
||||||
let loading = $state(false);
|
|
||||||
|
|
||||||
async function handleSubmit(e: Event) {
|
|
||||||
e.preventDefault();
|
|
||||||
error = '';
|
|
||||||
loading = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/auth/forgot', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ identifier }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
error = data.error || 'Request failed';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
success = true;
|
|
||||||
} catch (err) {
|
|
||||||
error = 'Connection error';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center bg-base-200" data-theme="light">
|
|
||||||
<div class="card bg-base-100 shadow-xl w-full max-w-md">
|
|
||||||
<div class="card-body">
|
|
||||||
<h1 class="text-2xl font-bold text-center mb-4">Reset Password</h1>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="alert alert-error mb-4">
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if success}
|
|
||||||
<div class="alert alert-success mb-4">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<span>If an account exists with that email or username, a password reset link has been sent.</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-center mt-4">
|
|
||||||
<a href="/auth/login" class="btn btn-primary">Back to Login</a>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-base-content/60 text-center mb-6">Enter your email address or username and we'll send you a link to reset your password.</p>
|
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="space-y-4">
|
|
||||||
<fieldset class="fieldset">
|
|
||||||
<legend class="fieldset-legend">Email or Username</legend>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
bind:value={identifier}
|
|
||||||
placeholder="user@example.org or username"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-full" disabled={loading}>
|
|
||||||
{#if loading}
|
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
|
||||||
Sending...
|
|
||||||
{:else}
|
|
||||||
Send Reset Link
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="text-center mt-4">
|
|
||||||
<a href="/auth/login" class="link link-primary">Back to Login</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
@ -76,10 +76,6 @@
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="text-right">
|
|
||||||
<a href="/auth/forgot" class="link link-primary text-sm">Forgot password?</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary w-full" disabled={loading}>
|
<button type="submit" class="btn btn-primary w-full" disabled={loading}>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue