added password reset system

This commit is contained in:
Alex
2025-02-26 21:45:16 +01:00
parent 7c01b77445
commit c42adc858f
14 changed files with 350 additions and 154 deletions

View File

@@ -2,14 +2,15 @@ package constants
const (
UnverifiedStatus = iota + 1
DisabledStatus
VerifiedStatus
ActiveStatus
PassiveStatus
DisabledStatus
DelayedPaymentStatus
SettledPaymentStatus
AwaitingPaymentStatus
MailVerificationSubject = "Nur noch ein kleiner Schritt!"
MailChangePasswordSubject = "Passwort Änderung angefordert"
MailRegistrationSubject = "Neues Mitglied hat sich registriert"
MailWelcomeSubject = "Willkommen beim Dörpsmobil Hasloh e.V."
MailContactSubject = "Jemand hat das Kontaktformular gefunden"
@@ -63,6 +64,14 @@ var Licences = struct {
T: "T",
}
var VerificationTypes = struct {
Email string
Password string
}{
Email: "email",
Password: "password",
}
var Priviliges = struct {
View int8
Create int8
@@ -75,11 +84,6 @@ var Priviliges = struct {
Delete: 30,
}
const PRIV_VIEW = 1
const PRIV_ADD = 2
const PRIV_EDIT = 4
const PRIV_DELETE = 8
var MemberUpdateFields = map[string]bool{
"Email": true,
"Phone": true,

View File

@@ -0,0 +1,103 @@
package controllers
import (
"GoMembership/internal/constants"
"GoMembership/internal/utils"
"GoMembership/pkg/errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func (uc *UserController) RequestPasswordChangeHandler(c *gin.Context) {
// Expected data from the user
var input struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandleValidationError(c, err)
return
}
// find user
db_user, err := uc.Service.GetUserByEmail(input.Email)
if err != nil {
utils.RespondWithError(c, err, "couldn't get user by email", http.StatusNotFound, "user.user", "user.email")
return
}
// check if user may change the password
if db_user.Status <= constants.DisabledStatus {
utils.RespondWithError(c, errors.ErrNotAuthorized, "User password change request denied, user is disabled", http.StatusForbidden, errors.Responses.Fields.Login, errors.Responses.Keys.UserDisabled)
return
}
// create token
token, err := uc.Service.HandlePasswordChangeRequest(db_user)
if err != nil {
utils.RespondWithError(c, err, "couldn't handle password change request", http.StatusInternalServerError, errors.Responses.Fields.General, errors.Responses.Keys.InternalServerError)
return
}
// send email
if err := uc.EmailService.SendChangePasswordEmail(db_user, &token); err != nil {
utils.RespondWithError(c, err, "Couldn't send change password email", http.StatusInternalServerError, errors.Responses.Fields.General, errors.Responses.Keys.InternalServerError)
return
}
c.JSON(http.StatusAccepted, gin.H{
"message": "password_change_requested",
})
}
func (uc *UserController) ChangePassword(c *gin.Context) {
// Expected data from the user
var input struct {
Password string `json:"password" binding:"required"`
Token string `json:"token" binding:"required"`
}
userIDint, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.RespondWithError(c, err, "Invalid user ID", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.InvalidUserID)
return
}
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandleValidationError(c, err)
return
}
verification, err := uc.Service.VerifyUser(&input.Token, &constants.VerificationTypes.Password)
if err != nil || uint(userIDint) != verification.UserID {
if err == errors.ErrAlreadyVerified {
utils.RespondWithError(c, err, "User already verified", http.StatusConflict, errors.Responses.Fields.User, errors.Responses.Keys.PasswordAlreadyChanged)
} else {
utils.RespondWithError(c, err, "Couldn't verify user", http.StatusInternalServerError, errors.Responses.Fields.General, errors.Responses.Keys.InternalServerError)
}
return
}
user, err := uc.Service.GetUserByID(verification.UserID)
if err != nil {
utils.RespondWithError(c, err, "Couldn't find user", http.StatusNotFound, errors.Responses.Fields.User, errors.Responses.Keys.UserNotFoundWrongPassword)
return
}
user.Status = constants.ActiveStatus
user.Verification = *verification
user.ID = verification.UserID
user.Password = input.Password
_, err = uc.Service.UpdateUser(user)
if err != nil {
utils.RespondWithError(c, err, "Couldn't update user", http.StatusInternalServerError, errors.Responses.Fields.User, errors.Responses.Keys.InternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{
"message": "password_changed",
})
}

View File

@@ -204,7 +204,7 @@ func (uc *UserController) LoginHandler(c *gin.Context) {
}
if err := c.ShouldBindJSON(&input); err != nil {
utils.RespondWithError(c, err, "Error in LoginHandler", http.StatusBadRequest, "general", "server.validation.invalid_json")
utils.RespondWithError(c, err, "Invalid JSON or malformed request", http.StatusBadRequest, errors.Responses.Fields.General, errors.Responses.Keys.Invalid)
return
}
@@ -216,9 +216,18 @@ func (uc *UserController) LoginHandler(c *gin.Context) {
return
}
if user.Status <= constants.DisabledStatus {
utils.RespondWithError(c, fmt.Errorf("User banned from login %v %v", user.FirstName, user.LastName),
"Login Error; user is disabled",
http.StatusNotAcceptable,
errors.Responses.Fields.Login,
errors.Responses.Keys.UserDisabled)
return
}
ok, err := user.PasswordMatches(input.Password)
if err != nil {
utils.RespondWithError(c, err, "Login Error; password comparisson failed", http.StatusInternalServerError, errors.Responses.Fields.General, errors.Responses.Keys.InternalServerError)
utils.RespondWithError(c, err, "Login Error; password comparisson failed", http.StatusInternalServerError, errors.Responses.Fields.Login, errors.Responses.Keys.InternalServerError)
return
}
if !ok {
@@ -233,7 +242,7 @@ func (uc *UserController) LoginHandler(c *gin.Context) {
logger.Error.Printf("jwtsecret: %v", config.Auth.JWTSecret)
token, err := middlewares.GenerateToken(config.Auth.JWTSecret, user, "")
if err != nil {
utils.RespondWithError(c, err, "Error generating token in LoginHandler", http.StatusInternalServerError, errors.Responses.Fields.General, errors.Responses.Keys.JwtGenerationFailed)
utils.RespondWithError(c, err, "Error generating token in LoginHandler", http.StatusInternalServerError, errors.Responses.Fields.Login, errors.Responses.Keys.JwtGenerationFailed)
return
}
@@ -331,15 +340,26 @@ func (uc *UserController) VerifyMailHandler(c *gin.Context) {
return
}
user, err := uc.Service.VerifyUser(&token)
verification, err := uc.Service.VerifyUser(&token, &constants.VerificationTypes.Email)
if err != nil {
logger.Error.Printf("Cannot verify user: %v", err)
c.HTML(http.StatusUnauthorized, "verification_error.html", gin.H{"ErrorMessage": "Emailadresse wurde schon bestätigt. Sollte dies nicht der Fall sein, wende Dich bitte an info@carsharing-hasloh.de."})
return
}
logger.Info.Printf("VerificationMailHandler User: %#v", user.Email)
user, err := uc.Service.GetUserByID(verification.UserID)
if err != nil {
utils.RespondWithError(c, err, "Couldn't find user", http.StatusNotFound, errors.Responses.Fields.User, errors.Responses.Keys.UserNotFoundWrongPassword)
return
}
user.Status = constants.ActiveStatus
user.Verification = *verification
user.ID = verification.UserID
uc.Service.UpdateUser(user)
logger.Info.Printf("Verified User: %#v", user.Email)
uc.EmailService.SendWelcomeEmail(user)
c.HTML(http.StatusOK, "verification_success.html", gin.H{"FirstName": user.FirstName})
}

View File

@@ -13,7 +13,7 @@ func CORSMiddleware() gin.HandlerFunc {
logger.Info.Print("Applying CORS")
return cors.New(cors.Config{
AllowOrigins: strings.Split(config.Site.AllowOrigins, ","),
AllowMethods: []string{"GET", "POST", "PATCH", "OPTIONS"}, // "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowMethods: []string{"GET", "POST", "PATCH", "PUT"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "X-CSRF-Token"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,

View File

@@ -0,0 +1,10 @@
package repositories
import (
"GoMembership/internal/database"
"GoMembership/internal/models"
)
func (r *UserRepository) SetUserStatus(id uint, status uint) error {
return database.DB.Model(&models.User{}).Where("id = ?", id).Update("status", status).Error
}

View File

@@ -3,7 +3,6 @@ package repositories
import (
"gorm.io/gorm"
"GoMembership/internal/constants"
"GoMembership/internal/database"
"gorm.io/gorm/clause"
@@ -18,10 +17,12 @@ type UserRepositoryInterface interface {
UpdateUser(user *models.User) (*models.User, error)
GetUsers(where map[string]interface{}) (*[]models.User, error)
GetUserByEmail(email string) (*models.User, error)
SetVerificationToken(verification *models.Verification) (uint, error)
IsVerified(userID *uint) (bool, error)
GetVerificationOfToken(token *string) (*models.Verification, error)
GetVerificationOfToken(token *string, verificationType *string) (*models.Verification, error)
SetVerificationToken(verification *models.Verification) (token string, err error)
DeleteVerification(id uint, verificationType string) error
DeleteUser(id uint) error
SetUserStatus(id uint, status uint) error
}
type UserRepository struct{}
@@ -156,42 +157,3 @@ func (ur *UserRepository) GetUserByEmail(email string) (*models.User, error) {
}
return &user, nil
}
func (ur *UserRepository) IsVerified(userID *uint) (bool, error) {
var user models.User
result := database.DB.Select("status").First(&user, userID)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return false, gorm.ErrRecordNotFound
}
return false, result.Error
}
return user.Status != constants.UnverifiedStatus, nil
}
func (ur *UserRepository) GetVerificationOfToken(token *string) (*models.Verification, error) {
var emailVerification models.Verification
result := database.DB.Where("verification_token = ?", token).First(&emailVerification)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return nil, gorm.ErrRecordNotFound
}
return nil, result.Error
}
return &emailVerification, nil
}
func (ur *UserRepository) SetVerificationToken(verification *models.Verification) (uint, error) {
// Use GORM to insert or update the Verification record
result := database.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"verification_token", "created_at"}),
}).Create(&verification)
if result.Error != nil {
return 0, result.Error
}
return verification.ID, nil
}

View File

@@ -0,0 +1,57 @@
package repositories
import (
"GoMembership/internal/constants"
"GoMembership/internal/database"
"GoMembership/internal/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (ur *UserRepository) IsVerified(userID *uint) (bool, error) {
var user models.User
result := database.DB.Select("status").First(&user, userID)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return false, gorm.ErrRecordNotFound
}
return false, result.Error
}
return user.Status > constants.DisabledStatus, nil
}
func (ur *UserRepository) GetVerificationOfToken(token *string, verificationType *string) (*models.Verification, error) {
var emailVerification models.Verification
result := database.DB.Where("verification_token = ? AND type = ?", token, verificationType).First(&emailVerification)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return nil, gorm.ErrRecordNotFound
}
return nil, result.Error
}
return &emailVerification, nil
}
func (ur *UserRepository) SetVerificationToken(verification *models.Verification) (token string, err error) {
result := database.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"verification_token", "created_at", "type"}),
}).Create(&verification)
if result.Error != nil {
return "", result.Error
}
return verification.VerificationToken, nil
}
func (ur *UserRepository) DeleteVerification(id uint, verificationType string) error {
result := database.DB.Where("user_id = ? AND type = ?", id, verificationType).Delete(&models.Verification{})
if result.Error != nil {
return result.Error
}
return nil
}

View File

@@ -3,6 +3,7 @@ package services
import (
"bytes"
"html/template"
"os"
"gopkg.in/gomail.v2"
@@ -21,7 +22,7 @@ func NewEmailService(host string, port int, username string, password string) *E
return &EmailService{dialer: dialer}
}
func (s *EmailService) SendEmail(to string, subject string, body string, replyTo string) error {
func (s *EmailService) SendEmail(to string, subject string, body string, bodyTXT string, replyTo string) error {
msg := gomail.NewMessage()
msg.SetHeader("From", s.dialer.Username)
msg.SetHeader("To", to)
@@ -29,7 +30,12 @@ func (s *EmailService) SendEmail(to string, subject string, body string, replyTo
if replyTo != "" {
msg.SetHeader("REPLY_TO", replyTo)
}
msg.SetBody("text/html", body)
if bodyTXT != "" {
msg.SetBody("text/plain", bodyTXT)
}
msg.AddAlternative("text/html", body)
msg.WriteTo(os.Stdout)
if err := s.dialer.DialAndSend(msg); err != nil {
logger.Error.Printf("Could not send email to %s: %v", to, err)
@@ -79,7 +85,10 @@ func (s *EmailService) SendVerificationEmail(user *models.User, token *string) e
logger.Error.Print("Couldn't send verification mail")
return err
}
return s.SendEmail(user.Email, subject, body, "")
return s.SendEmail(user.Email, subject, body, "", "")
}
func (s *EmailService) SendChangePasswordEmail(user *models.User, token *string) error {
// Prepare data to be injected into the template
data := struct {
@@ -136,12 +145,17 @@ func (s *EmailService) SendWelcomeEmail(user *models.User) error {
}
subject := constants.MailWelcomeSubject
body, err := ParseTemplate("mail_welcome.tmpl", data)
htmlBody, err := ParseTemplate("mail_welcome.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't send welcome mail")
return err
}
return s.SendEmail(user.Email, subject, body, "")
plainBody, err := ParseTemplate("mail_welcome.txt.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't parse password mail")
return err
}
return s.SendEmail(user.Email, subject, htmlBody, plainBody, "")
}
func (s *EmailService) SendRegistrationNotification(user *models.User) error {
@@ -185,12 +199,17 @@ func (s *EmailService) SendRegistrationNotification(user *models.User) error {
}
subject := constants.MailRegistrationSubject
body, err := ParseTemplate("mail_registration.tmpl", data)
htmlBody, err := ParseTemplate("mail_registration.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't send admin notification mail")
return err
}
return s.SendEmail(config.Recipients.UserRegistration, subject, body, "")
plainBody, err := ParseTemplate("mail_registration.txt.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't parse password mail")
return err
}
return s.SendEmail(user.Email, subject, htmlBody, plainBody, "")
}
func (s *EmailService) RelayContactFormMessage(sender string, name string, message string) error {
@@ -208,10 +227,15 @@ func (s *EmailService) RelayContactFormMessage(sender string, name string, messa
WebsiteTitle: config.Site.WebsiteTitle,
}
subject := constants.MailContactSubject
body, err := ParseTemplate("mail_contact_form.tmpl", data)
htmlBody, err := ParseTemplate("mail_contact_form.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't send contact form message mail")
return err
}
return s.SendEmail(config.Recipients.ContactForm, subject, body, sender)
plainBody, err := ParseTemplate("mail_contact_form.txt.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't parse password mail")
return err
}
return s.SendEmail(config.Recipients.ContactForm, subject, htmlBody, plainBody, sender)
}

View File

@@ -0,0 +1,21 @@
package services
import (
"GoMembership/internal/constants"
"GoMembership/internal/models"
)
func (s *UserService) HandlePasswordChangeRequest(user *models.User) (token string, err error) {
// Deactivate user and reset Verification
if err := s.SetUserStatus(user.ID, constants.DisabledStatus); err != nil {
return "", err
}
if err := s.RevokeVerification(&user.ID, constants.VerificationTypes.Password); err != nil {
return "", err
}
// Generate a token
return s.SetVerificationToken(&user.ID, &constants.VerificationTypes.Password)
}

View File

@@ -0,0 +1,5 @@
package services
func (s *UserService) SetUserStatus(id uint, status uint) error {
return s.Repo.SetUserStatus(id, status)
}

View File

@@ -1,15 +1,12 @@
package services
import (
"net/http"
"strings"
"GoMembership/internal/constants"
"GoMembership/internal/models"
"GoMembership/internal/repositories"
"GoMembership/internal/utils"
"GoMembership/pkg/errors"
"GoMembership/pkg/logger"
"github.com/alexedwards/argon2id"
"gorm.io/gorm"
@@ -18,13 +15,17 @@ import (
)
type UserServiceInterface interface {
RegisterUser(user *models.User) (uint, string, error)
RegisterUser(user *models.User) (id uint, token string, err error)
GetUserByEmail(email string) (*models.User, error)
GetUserByID(id uint) (*models.User, error)
GetUsers(where map[string]interface{}) (*[]models.User, error)
VerifyUser(token *string) (*models.User, error)
UpdateUser(user *models.User) (*models.User, error)
DeleteUser(lastname string, id uint) error
SetUserStatus(id uint, status uint) error
VerifyUser(token *string, verificationType *string) (*models.Verification, error)
SetVerificationToken(id *uint, verificationType *string) (string, error)
RevokeVerification(id *uint, verificationType string) error
HandlePasswordChangeRequest(user *models.User) (token string, err error)
}
type UserService struct {
@@ -81,7 +82,7 @@ func (service *UserService) UpdateUser(user *models.User) (*models.User, error)
return updatedUser, nil
}
func (service *UserService) RegisterUser(user *models.User) (uint, string, error) {
func (service *UserService) RegisterUser(user *models.User) (id uint, token string, err error) {
setPassword(user.Password, user)
@@ -89,49 +90,20 @@ func (service *UserService) RegisterUser(user *models.User) (uint, string, error
user.CreatedAt = time.Now()
user.UpdatedAt = time.Now()
user.PaymentStatus = constants.AwaitingPaymentStatus
// if user.Licence == nil {
// user.Licence = &models.Licence{Status: constants.UnverifiedStatus}
// }
user.BankAccount.MandateDateSigned = time.Now()
id, err := service.Repo.CreateUser(user)
id, err = service.Repo.CreateUser(user)
if err != nil {
return 0, "", err
}
user.ID = id
token, err := utils.GenerateVerificationToken()
token, err = service.SetVerificationToken(&id, &constants.VerificationTypes.Email)
if err != nil {
return 0, "", err
}
logger.Info.Printf("TOKEN: %v", token)
// Check if user is already verified
verified, err := service.Repo.IsVerified(&user.ID)
if err != nil {
return 0, "", err
}
if verified {
return 0, "", errors.ErrAlreadyVerified
}
// Prepare the Verification record
verification := models.Verification{
UserID: user.ID,
VerificationToken: token,
}
if _, err = service.Repo.SetVerificationToken(&verification); err != nil {
return http.StatusInternalServerError, "", err
}
return id, token, nil
}
func (service *UserService) GetUserByID(id uint) (*models.User, error) {
return repositories.GetUserByID(&id)
}
@@ -146,35 +118,6 @@ func (service *UserService) GetUsers(where map[string]interface{}) (*[]models.Us
return service.Repo.GetUsers(where)
}
func (service *UserService) VerifyUser(token *string) (*models.User, error) {
verification, err := service.Repo.GetVerificationOfToken(token)
if err != nil {
return nil, err
}
// Check if the user is already verified
verified, err := service.Repo.IsVerified(&verification.UserID)
if err != nil {
return nil, err
}
user, err := repositories.GetUserByID(&verification.UserID)
if err != nil {
return nil, err
}
if verified {
return user, errors.ErrAlreadyVerified
}
// Update user status to active
t := time.Now()
verification.EmailVerifiedAt = &t
user.Status = constants.VerifiedStatus
user.Verification = *verification
user.ID = verification.UserID
service.Repo.UpdateUser(user)
return user, nil
}
func setPassword(plaintextPassword string, u *models.User) error {
hash, err := argon2id.CreateHash(plaintextPassword, argon2id.DefaultParams)
if err != nil {

View File

@@ -0,0 +1,60 @@
package services
import (
"GoMembership/internal/models"
"GoMembership/internal/utils"
"GoMembership/pkg/errors"
"time"
)
func (s *UserService) SetVerificationToken(id *uint, verificationType *string) (string, error) {
token, err := utils.GenerateVerificationToken()
if err != nil {
return "", err
}
// Check if user is already verified
verified, err := s.Repo.IsVerified(id)
if err != nil {
return "", err
}
if verified {
return "", errors.ErrAlreadyVerified
}
// Prepare the Verification record
verification := models.Verification{
UserID: *id,
VerificationToken: token,
Type: *verificationType,
}
return s.Repo.SetVerificationToken(&verification)
}
func (s *UserService) RevokeVerification(id *uint, verificationType string) error {
return s.Repo.DeleteVerification(*id, verificationType)
}
func (service *UserService) VerifyUser(token *string, verificationType *string) (*models.Verification, error) {
verification, err := service.Repo.GetVerificationOfToken(token, verificationType)
if err != nil {
return nil, err
}
// Check if the user is already verified
verified, err := service.Repo.IsVerified(&verification.UserID)
if err != nil {
return nil, err
}
if verified {
return nil, errors.ErrAlreadyVerified
}
t := time.Now()
verification.VerifiedAt = &t
// Update user status to active
return verification, nil
}

View File

@@ -81,21 +81,6 @@ func FilterAllowedStructFields(input interface{}, existing interface{}, allowedF
} else {
originField.Set(fieldValue)
}
// If the slice contains structs, recursively filter each element
// if fieldValue.Type().Elem().Kind() == reflect.Struct {
// for j := 0; j < fieldValue.Len(); j++ {
// err := FilterAllowedStructFields(
// fieldValue.Index(j).Addr().Interface(),
// originField.Index(j).Addr().Interface(),
// allowedFields,
// fullKey,
// )
// if err != nil {
// return err
// }
// }
// }
continue
}

View File

@@ -12,13 +12,14 @@ type ValidationKeys struct {
JwtGenerationFailed string
Duplicate string
InvalidUserID string
PasswordAlreadyChanged string
UserDisabled string
}
type ValidationFields struct {
General string
ParentMemberShipID string
SubscriptionModel string
UserID string
Login string
Email string
User string
@@ -60,14 +61,15 @@ var Responses = struct {
UserNotFoundWrongPassword: "server.validation.user_not_found_or_wrong_password",
JwtGenerationFailed: "server.error.jwt_generation_failed",
Duplicate: "server.validation.duplicate",
UserDisabled: "server.validation.user_disabled",
PasswordAlreadyChanged: "server.validation.password_already_changed",
},
Fields: ValidationFields{
General: "general",
ParentMemberShipID: "parent_membership_id",
SubscriptionModel: "subscription_model",
UserID: "user_id",
Login: "login",
Email: "email",
User: "user",
Login: "user.login",
Email: "user.email",
User: "user.user",
},
}