- Install github.com/altcha-org/altcha-lib-go official library - Replace custom implementation with official ALTCHA methods - Use CreateChallenge for proper cryptographic challenges - Use VerifySolution for proper token verification - This should fix all ALTCHA verification issues
610 lines
20 KiB
Go
610 lines
20 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"time"
|
|
|
|
"log"
|
|
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/patbritton/sojorn-backend/internal/config"
|
|
"github.com/patbritton/sojorn-backend/internal/models"
|
|
"github.com/patbritton/sojorn-backend/internal/repository"
|
|
"github.com/patbritton/sojorn-backend/internal/services"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type AuthHandler struct {
|
|
repo *repository.UserRepository
|
|
config *config.Config
|
|
emailService *services.EmailService
|
|
sendPulseService *services.SendPulseService
|
|
}
|
|
|
|
func NewAuthHandler(repo *repository.UserRepository, cfg *config.Config, emailService *services.EmailService, sendPulseService *services.SendPulseService) *AuthHandler {
|
|
return &AuthHandler{repo: repo, config: cfg, emailService: emailService, sendPulseService: sendPulseService}
|
|
}
|
|
|
|
type RegisterRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=6"`
|
|
Handle string `json:"handle" binding:"required,min=3"`
|
|
DisplayName string `json:"display_name" binding:"required"`
|
|
AltchaToken string `json:"altcha_token" binding:"required"`
|
|
AcceptTerms bool `json:"accept_terms" binding:"required,eq=true"`
|
|
AcceptPrivacy bool `json:"accept_privacy" binding:"required,eq=true"`
|
|
EmailNewsletter bool `json:"email_newsletter"`
|
|
EmailContact bool `json:"email_contact"`
|
|
BirthMonth int `json:"birth_month" binding:"required,min=1,max=12"`
|
|
BirthYear int `json:"birth_year" binding:"required,min=1900,max=2025"`
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required"`
|
|
AltchaToken string `json:"altcha_token"`
|
|
}
|
|
|
|
func (h *AuthHandler) Register(c *gin.Context) {
|
|
var req RegisterRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
|
|
|
// Validate ALTCHA token
|
|
altchaService := services.NewAltchaService(h.config.JWTSecret)
|
|
remoteIP := c.ClientIP()
|
|
altchaResp, err := altchaService.VerifyToken(req.AltchaToken, remoteIP)
|
|
if err != nil {
|
|
log.Printf("[Auth] ALTCHA verification failed: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
|
return
|
|
}
|
|
|
|
if !altchaResp.Verified {
|
|
errorMsg := altchaService.GetErrorMessage(altchaResp.Error)
|
|
log.Printf("[Auth] ALTCHA validation failed: %s", errorMsg)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
|
|
return
|
|
}
|
|
|
|
// Check if this IP is banned (ban evasion prevention)
|
|
ipBanned, _ := h.repo.IsIPBanned(c.Request.Context(), remoteIP)
|
|
if ipBanned {
|
|
log.Printf("[Auth] Registration blocked for banned IP: %s", remoteIP)
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Registration is not available from this network."})
|
|
return
|
|
}
|
|
|
|
// Validate handle against reserved names and inappropriate content
|
|
handleCheck := services.ValidateUsernameWithDB(c.Request.Context(), h.repo.Pool(), req.Handle)
|
|
if handleCheck.Violation != services.UsernameOK {
|
|
status := http.StatusBadRequest
|
|
if handleCheck.Violation == services.UsernameReserved {
|
|
status = http.StatusForbidden
|
|
}
|
|
c.JSON(status, gin.H{"error": handleCheck.Message})
|
|
return
|
|
}
|
|
|
|
// Validate display name for inappropriate content
|
|
nameCheck := services.ValidateDisplayName(req.DisplayName)
|
|
if nameCheck.Violation != services.UsernameOK {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": nameCheck.Message})
|
|
return
|
|
}
|
|
|
|
existingUser, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email)
|
|
if err == nil && existingUser != nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
|
return
|
|
}
|
|
|
|
existingProfile, err := h.repo.GetProfileByHandle(c.Request.Context(), req.Handle)
|
|
if err == nil && existingProfile != nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Handle already taken"})
|
|
return
|
|
}
|
|
|
|
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
return
|
|
}
|
|
|
|
userID := uuid.New()
|
|
user := &models.User{
|
|
ID: userID,
|
|
Email: req.Email,
|
|
PasswordHash: string(hashedBytes),
|
|
Status: models.UserStatusPending,
|
|
MFAEnabled: false,
|
|
EmailNewsletter: req.EmailNewsletter,
|
|
EmailContact: req.EmailContact,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
log.Printf("[Auth] Registering user: %s", req.Email)
|
|
if err := h.repo.CreateUser(c.Request.Context(), user); err != nil {
|
|
log.Printf("[Auth] Failed to create user %s: %v", req.Email, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
profile := &models.Profile{
|
|
ID: userID,
|
|
Handle: &req.Handle,
|
|
DisplayName: &req.DisplayName,
|
|
BirthMonth: req.BirthMonth,
|
|
BirthYear: req.BirthYear,
|
|
}
|
|
if err := h.repo.CreateProfile(c.Request.Context(), profile); err != nil {
|
|
log.Printf("[Auth] Failed to create profile for %s: %v. Rolling back user.", user.ID, err)
|
|
_ = h.repo.DeleteUser(c.Request.Context(), user.ID)
|
|
|
|
if strings.Contains(err.Error(), "23505") && strings.Contains(err.Error(), "profiles_handle_key") {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Handle already taken"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create profile", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
rawToken, _ := generateRandomString(32)
|
|
tokenHash := sha256.Sum256([]byte(rawToken))
|
|
hashString := hex.EncodeToString(tokenHash[:])
|
|
|
|
if err := h.repo.CreateVerificationToken(c.Request.Context(), hashString, userID.String(), 24*time.Hour); err != nil {
|
|
log.Printf("[Auth] Failed to store verification token: %v. Rolling back user.", err)
|
|
_ = h.repo.DeleteUser(c.Request.Context(), user.ID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare verification", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
if err := h.emailService.SendVerificationEmail(req.Email, req.DisplayName, rawToken); err != nil {
|
|
log.Printf("[Auth] Failed to send email to %s: %v", req.Email, err)
|
|
}
|
|
}()
|
|
|
|
// Add to SendPulse Members list if user opted into newsletter
|
|
if req.EmailNewsletter && h.sendPulseService != nil {
|
|
go h.sendPulseService.AddToMembers(req.Email)
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"message": "Registration successful. Please verify your email to activate your account.",
|
|
"state": "verification_pending",
|
|
"email": req.Email,
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) Login(c *gin.Context) {
|
|
var req LoginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
|
|
|
// Validate ALTCHA token
|
|
altchaService := services.NewAltchaService(h.config.JWTSecret)
|
|
remoteIP := c.ClientIP()
|
|
altchaResp, err := altchaService.VerifyToken(req.AltchaToken, remoteIP)
|
|
if err != nil {
|
|
log.Printf("[Auth] Login ALTCHA verification failed: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
|
return
|
|
}
|
|
|
|
if !altchaResp.Verified {
|
|
errorMsg := altchaService.GetErrorMessage(altchaResp.Error)
|
|
log.Printf("[Auth] Login ALTCHA validation failed: %s", errorMsg)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
|
|
return
|
|
}
|
|
|
|
// Check if this IP is banned (ban evasion prevention)
|
|
ipBanned, _ := h.repo.IsIPBanned(c.Request.Context(), remoteIP)
|
|
if ipBanned {
|
|
log.Printf("[Auth] Login blocked for banned IP: %s", remoteIP)
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access is not available from this network."})
|
|
return
|
|
}
|
|
|
|
user, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email)
|
|
if err != nil {
|
|
log.Printf("[Auth] Login failed for %s: user not found", req.Email)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
|
log.Printf("[Auth] Login failed for %s: password mismatch", req.Email)
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
|
return
|
|
}
|
|
|
|
// Age gate: check if user is under 16
|
|
var profile *models.Profile
|
|
profile, _ = h.repo.GetProfileByID(c.Request.Context(), user.ID.String())
|
|
if profile != nil && profile.BirthYear > 0 {
|
|
now := time.Now()
|
|
age := now.Year() - profile.BirthYear
|
|
if int(now.Month()) < profile.BirthMonth {
|
|
age--
|
|
}
|
|
if age < 16 {
|
|
log.Printf("[Auth] Login blocked for underage user %s (age %d)", req.Email, age)
|
|
c.JSON(http.StatusForbidden, gin.H{
|
|
"error": "You must be at least 16 years old to use Sojorn. Please come back when you're older!",
|
|
"code": "age_restricted",
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
if user.Status == models.UserStatusPending {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email verification required", "code": "verify_email"})
|
|
return
|
|
}
|
|
if user.Status == models.UserStatusBanned {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "This account has been permanently suspended for violating our community guidelines.", "code": "banned"})
|
|
return
|
|
}
|
|
if user.Status == models.UserStatusSuspended {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Your account is temporarily suspended. Please try again later.", "code": "suspended"})
|
|
return
|
|
}
|
|
|
|
// Auto-reactivate deactivated or pending-deletion accounts on login
|
|
var reactivated bool
|
|
var previousStatus string
|
|
if user.Status == models.UserStatusDeactivated || user.Status == models.UserStatusPendingDeletion {
|
|
previousStatus = string(user.Status)
|
|
log.Printf("[Auth] Reactivating %s account for %s", user.Status, req.Email)
|
|
_ = h.repo.ReactivateUser(c.Request.Context(), user.ID.String())
|
|
user.Status = models.UserStatusActive
|
|
reactivated = true
|
|
|
|
// Send reactivation confirmation email
|
|
displayName := req.Email
|
|
if p, _ := h.repo.GetProfileByID(c.Request.Context(), user.ID.String()); p != nil && p.DisplayName != nil && *p.DisplayName != "" {
|
|
displayName = *p.DisplayName
|
|
}
|
|
go func() {
|
|
var note string
|
|
if previousStatus == "pending_deletion" {
|
|
note = "Your scheduled account deletion has been cancelled."
|
|
} else {
|
|
note = "Your account has been reactivated and is fully visible again."
|
|
}
|
|
_ = h.emailService.SendAccountRestoredEmail(req.Email, displayName, note)
|
|
}()
|
|
}
|
|
|
|
if user.MFAEnabled {
|
|
tempToken, _ := generateRandomString(32)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"mfa_required": true,
|
|
"user_id": user.ID,
|
|
"temp_token": tempToken,
|
|
})
|
|
return
|
|
}
|
|
|
|
_ = h.repo.UpdateLastLogin(c.Request.Context(), user.ID.String())
|
|
|
|
token, err := h.generateToken(user.ID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
refreshToken, _ := generateRandomString(32)
|
|
_ = h.repo.StoreRefreshToken(c.Request.Context(), user.ID.String(), refreshToken, 30*24*time.Hour)
|
|
|
|
// Re-fetch profile if not already loaded from age check
|
|
if profile == nil {
|
|
profile, _ = h.repo.GetProfileByID(c.Request.Context(), user.ID.String())
|
|
}
|
|
if profile == nil {
|
|
log.Printf("[Auth] Failed to get profile for %s", user.ID)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profile"})
|
|
return
|
|
}
|
|
|
|
response := gin.H{
|
|
"token": token,
|
|
"access_token": token,
|
|
"refresh_token": refreshToken,
|
|
"user": user,
|
|
"profile": profile, // Includes HasCompletedOnboarding
|
|
}
|
|
if reactivated {
|
|
response["reactivated"] = true
|
|
response["previous_status"] = previousStatus
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
func (h *AuthHandler) CompleteOnboarding(c *gin.Context) {
|
|
userId := c.MustGet("user_id").(string)
|
|
|
|
if err := h.repo.MarkOnboardingComplete(c.Request.Context(), userId); err != nil {
|
|
log.Printf("[Auth] Failed to complete onboarding for %s: %v", userId, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update onboarding status", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Onboarding completed"})
|
|
}
|
|
|
|
func (h *AuthHandler) VerifyEmail(c *gin.Context) {
|
|
rawToken := c.Query("token")
|
|
if rawToken == "" {
|
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=invalid_token")
|
|
return
|
|
}
|
|
|
|
tokenHash := sha256.Sum256([]byte(rawToken))
|
|
hashString := hex.EncodeToString(tokenHash[:])
|
|
|
|
userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString)
|
|
if err != nil {
|
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=invalid_token")
|
|
return
|
|
}
|
|
|
|
if time.Now().After(expiresAt) {
|
|
h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
|
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=expired")
|
|
return
|
|
}
|
|
|
|
// Activate user
|
|
if err := h.repo.UpdateUserStatus(c.Request.Context(), userID, models.UserStatusActive); err != nil {
|
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=server_error")
|
|
return
|
|
}
|
|
|
|
// Add to Subscriber list
|
|
go func() {
|
|
user, err := h.repo.GetUserByID(c.Request.Context(), userID)
|
|
if err == nil {
|
|
profile, _ := h.repo.GetProfileByID(c.Request.Context(), userID)
|
|
name := ""
|
|
if profile != nil && profile.DisplayName != nil {
|
|
name = *profile.DisplayName
|
|
}
|
|
h.emailService.AddSubscriber(user.Email, name)
|
|
}
|
|
}()
|
|
|
|
// Cleanup
|
|
_ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
|
|
|
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verified?status=success")
|
|
}
|
|
|
|
func (h *AuthHandler) ResendVerificationEmail(c *gin.Context) {
|
|
var req struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
user, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"message": "If the account exists and is not verified, a new link has been sent."})
|
|
return
|
|
}
|
|
|
|
if user.Status != models.UserStatusPending {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Account is already verified"})
|
|
return
|
|
}
|
|
|
|
rawToken, _ := generateRandomString(32)
|
|
tokenHash := sha256.Sum256([]byte(rawToken))
|
|
hashString := hex.EncodeToString(tokenHash[:])
|
|
|
|
if err := h.repo.CreateVerificationToken(c.Request.Context(), hashString, user.ID.String(), 24*time.Hour); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare verification", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
name := ""
|
|
profile, err := h.repo.GetProfileByID(c.Request.Context(), user.ID.String())
|
|
if err == nil && profile != nil && profile.DisplayName != nil {
|
|
name = *profile.DisplayName
|
|
}
|
|
if err := h.emailService.SendVerificationEmail(user.Email, name, rawToken); err != nil {
|
|
log.Printf("[Auth] Failed to send email to %s: %v", user.Email, err)
|
|
}
|
|
}()
|
|
|
|
log.Printf("[Auth] Resent Verification Token for %s: %s", user.Email, rawToken)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "A new verification link has been sent.",
|
|
"state": "verification_pending",
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) RefreshSession(c *gin.Context) {
|
|
var req struct {
|
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Refresh token required"})
|
|
return
|
|
}
|
|
|
|
rt, err := h.repo.ValidateRefreshToken(c.Request.Context(), req.RefreshToken)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired session"})
|
|
return
|
|
}
|
|
|
|
// Check if user is banned/suspended before issuing new tokens
|
|
rtUser, err := h.repo.GetUserByID(c.Request.Context(), rt.UserID.String())
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
if rtUser.Status == models.UserStatusBanned {
|
|
_ = h.repo.RevokeAllUserTokens(c.Request.Context(), rt.UserID.String())
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "This account has been permanently suspended.", "code": "banned"})
|
|
return
|
|
}
|
|
if rtUser.Status == models.UserStatusSuspended {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Your account is temporarily suspended.", "code": "suspended"})
|
|
return
|
|
}
|
|
|
|
_ = h.repo.RevokeRefreshToken(c.Request.Context(), req.RefreshToken)
|
|
|
|
newAccessToken, err := h.generateToken(rt.UserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
newRefreshToken, _ := generateRandomString(32)
|
|
err = h.repo.StoreRefreshToken(c.Request.Context(), rt.UserID.String(), newRefreshToken, 30*24*time.Hour)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, models.TokenPair{
|
|
AccessToken: newAccessToken,
|
|
RefreshToken: newRefreshToken,
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) generateToken(userID uuid.UUID) (string, error) {
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"sub": userID.String(),
|
|
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days (Access token life)
|
|
"role": "authenticated",
|
|
})
|
|
return token.SignedString([]byte(h.config.JWTSecret))
|
|
}
|
|
|
|
func generateRandomString(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
_, err := rand.Read(b)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
|
|
var req struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
user, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, gin.H{"message": "If the account exists, a password reset link has been sent."})
|
|
return
|
|
}
|
|
|
|
rawToken, _ := generateRandomString(32)
|
|
tokenHash := sha256.Sum256([]byte(rawToken))
|
|
hashString := hex.EncodeToString(tokenHash[:])
|
|
|
|
if err := h.repo.CreatePasswordResetToken(c.Request.Context(), hashString, user.ID.String(), 1*time.Hour); err != nil {
|
|
log.Printf("[Auth] Failed to create reset token: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"message": "Internal error", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
name := ""
|
|
profile, err := h.repo.GetProfileByID(c.Request.Context(), user.ID.String())
|
|
if err == nil && profile != nil && profile.DisplayName != nil {
|
|
name = *profile.DisplayName
|
|
}
|
|
if err := h.emailService.SendPasswordResetEmail(user.Email, name, rawToken); err != nil {
|
|
log.Printf("[Auth] Failed to send reset email to %s: %v", user.Email, err)
|
|
}
|
|
}()
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "If the account exists, a password reset link has been sent."})
|
|
}
|
|
|
|
func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
|
var req struct {
|
|
Token string `json:"token" binding:"required"`
|
|
NewPassword string `json:"new_password" binding:"required,min=6"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
tokenHash := sha256.Sum256([]byte(req.Token))
|
|
hashString := hex.EncodeToString(tokenHash[:])
|
|
|
|
userID, expiresAt, err := h.repo.GetPasswordResetToken(c.Request.Context(), hashString)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired token"})
|
|
return
|
|
}
|
|
|
|
if time.Now().After(expiresAt) {
|
|
h.repo.DeletePasswordResetToken(c.Request.Context(), hashString)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Token expired"})
|
|
return
|
|
}
|
|
|
|
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reset password"})
|
|
return
|
|
}
|
|
|
|
if err := h.repo.UpdateUserPassword(c.Request.Context(), userID, string(hashedBytes)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update password", "details": err.Error()})
|
|
return
|
|
}
|
|
|
|
_ = h.repo.DeletePasswordResetToken(c.Request.Context(), hashString)
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
|
|
}
|
|
|
|
func (h *AuthHandler) GetAltchaChallenge(c *gin.Context) {
|
|
altchaService := services.NewAltchaService(h.config.JWTSecret)
|
|
|
|
challenge, err := altchaService.GenerateChallenge()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate challenge"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, challenge)
|
|
}
|