Refactor auth_user usecase: split in multiple files

This commit is contained in:
nemunaire 2025-05-13 13:32:38 +02:00
commit 0495ab4693
16 changed files with 813 additions and 391 deletions

View file

@ -128,7 +128,8 @@ func (ac *AuthUserController) EmailValidationLink(c *gin.Context) {
func (ac *AuthUserController) RecoverUserAcct(c *gin.Context) {
user := c.MustGet("authuser").(*happydns.UserAuth)
happydns.ApiResponse(c, ac.auService.GenerateRecoveryLink(user), nil)
link, err := ac.auService.GenerateRecoveryLink(user)
happydns.ApiResponse(c, link, err)
}
type resetPassword struct {

View file

@ -138,14 +138,6 @@ func (rc *UserRecoveryController) ValidateUserAddress(c *gin.Context) {
c.Status(http.StatusNoContent)
}
type UploadedAccountRecovery struct {
// Key is the secret sent by email to the user.
Key string
// Password is the new password to use with this account.
Password string
}
// recoverUserAccount performs account recovery by reseting the password of the account.
//
// @Summary Reset password with link in email.
@ -155,7 +147,7 @@ type UploadedAccountRecovery struct {
// @Accept json
// @Produce json
// @Param userId path string true "User identifier"
// @Param body body UploadedAccountRecovery true "Recovery form"
// @Param body body happydns.AccountRecoveryForm true "Recovery form"
// @Success 204 {null} null "Recovery completed, you can now login with your new credentials"
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
// @Failure 500 {object} happydns.ErrorResponse
@ -163,7 +155,7 @@ type UploadedAccountRecovery struct {
func (rc *UserRecoveryController) RecoverUserAccount(c *gin.Context) {
user := c.MustGet("authuser").(*happydns.UserAuth)
var uar UploadedAccountRecovery
var uar happydns.AccountRecoveryForm
err := c.ShouldBindJSON(&uar)
if err != nil {
log.Printf("%s sends invalid AccountRecovey JSON: %s", c.ClientIP(), err.Error())
@ -171,17 +163,7 @@ func (rc *UserRecoveryController) RecoverUserAccount(c *gin.Context) {
return
}
if err := user.CanRecoverAccount(uar.Key); err != nil {
c.AbortWithStatusJSON(http.StatusForbidden, happydns.ErrorResponse{Message: err.Error()})
return
}
if len(uar.Password) == 0 {
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: "Password can't be empty!"})
return
}
if err := rc.auService.ChangePassword(user, uar.Password); err != nil {
if err := rc.auService.ResetPassword(user, uar); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, happydns.ErrorResponse{Message: err.Error()})
return
}

View file

@ -36,6 +36,7 @@ import (
"git.happydns.org/happyDomain/internal/session"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase"
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
"git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/web"
@ -212,6 +213,7 @@ func (app *App) initInsights() {
}
func (app *App) initUsecases() {
sessionService := sessionUC.NewSessionUsecases(app.store)
app.usecases.providerSpecs = usecase.NewProviderSpecsUsecase()
app.usecases.provider = usecase.NewProviderUsecase(app.cfg, app.store)
app.usecases.providerSettings = usecase.NewProviderSettingsUsecase(app.cfg, app.usecases.provider, app.store)
@ -223,9 +225,9 @@ func (app *App) initUsecases() {
app.usecases.user = usecase.NewUserUsecase(app.store, app.newsletter)
app.usecases.authentication = usecase.NewAuthenticationUsecase(app.cfg, app.store, app.usecases.user)
app.usecases.authUser = usecase.NewAuthUserUsecase(app.cfg, app.mailer, app.store)
app.usecases.authUser = authuserUC.NewAuthUserUsecases(app.cfg, app.mailer, app.store, sessionService.CloseUserSessionsUC)
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
app.usecases.session = sessionUC.NewSessionUsecases(app.store)
app.usecases.session = sessionService
}
func (app *App) setupRouter() {

View file

@ -36,11 +36,6 @@ type AuthenticationStorage interface {
user.UserStorage
}
type AuthUserAndSessionStorage interface {
authuser.AuthUserStorage
session.SessionStorage
}
type ProviderAndDomainStorage interface {
provider.ProviderStorage
domain.DomainStorage

View file

@ -1,247 +0,0 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package usecase
import (
"errors"
"fmt"
"net/mail"
"strings"
"git.happydns.org/happyDomain/internal/helpers"
"git.happydns.org/happyDomain/internal/mailer"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
)
type authUserUsecase struct {
config *happydns.Options
mailer *mailer.Mailer
store storage.AuthUserAndSessionStorage
}
func NewAuthUserUsecase(cfg *happydns.Options, m *mailer.Mailer, store storage.AuthUserAndSessionStorage) happydns.AuthUserUsecase {
return &authUserUsecase{
config: cfg,
mailer: m,
store: store,
}
}
func (auu *authUserUsecase) CanRegister(user happydns.UserRegistration) error {
if auu.config.DisableRegistration {
return fmt.Errorf("Registration are closed on this instance.")
}
return nil
}
func (auu *authUserUsecase) CheckNewPassword(user *happydns.UserAuth, request happydns.ChangePasswordForm) error {
if !user.CheckPassword(request.Current) {
return fmt.Errorf("The given current password is invalid.")
}
return auu.CheckPassword(user, request)
}
func (auu *authUserUsecase) CheckPassword(user *happydns.UserAuth, request happydns.ChangePasswordForm) error {
if err := user.CheckPasswordConstraints(request.Password); err != nil {
return err
}
if request.Password != request.PasswordConfirm {
return fmt.Errorf("The new password and its confirmation are different.")
}
return nil
}
func (auu *authUserUsecase) ChangePassword(user *happydns.UserAuth, newPassword string) error {
if err := user.DefinePassword(newPassword); err != nil {
return fmt.Errorf("unable to change user password: %w", err)
}
return auu.store.UpdateAuthUser(user)
}
func (auu *authUserUsecase) CloseAuthUserSessions(user *happydns.UserAuth) error {
// Retrieve all user's sessions to disconnect them
sessions, err := auu.store.ListAuthUserSessions(user)
if err != nil {
return happydns.InternalError{
Err: fmt.Errorf("unable to GetUserSessions in deleteAuthUser: %s", err.Error()),
UserMessage: "Sorry, we are currently unable to delete your profile. Please try again later.",
}
}
var errs error
for _, session := range sessions {
err = auu.store.DeleteSession(session.Id)
if err != nil {
errs = errors.Join(errs, err)
}
}
return errs
}
func (auu *authUserUsecase) CreateAuthUser(uu happydns.UserRegistration) (*happydns.UserAuth, error) {
if len(uu.Email) <= 3 || strings.Index(uu.Email, "@") == -1 {
return nil, fmt.Errorf("The given email is invalid.")
}
if len(uu.Password) <= 7 {
return nil, fmt.Errorf("The given password is invalid.")
}
exists, err := auu.store.AuthUserExists(uu.Email)
if err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to AuthUserExists in CreateAuthUser: %w", err),
UserMessage: "Sorry, we are currently unable to create your account. Please try again later.",
}
}
if exists {
return nil, fmt.Errorf("An account already exists with the given address. Try login now.")
}
user, err := happydns.NewUserAuth(uu.Email, uu.Password)
if err != nil {
return nil, err
}
user.AllowCommercials = uu.Newsletter
if err := auu.store.CreateAuthUser(user); err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to CreateUser in CreateAuthUser: %w", err),
UserMessage: "Sorry, we are currently unable to create your account. Please try again later.",
}
}
if auu.mailer != nil {
if err = auu.SendValidationLink(user); err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to SendValidationLink in registerUser: %w", err),
UserMessage: "Sorry, we are currently unable to create your account. Please try again later.",
}
}
}
return user, auu.store.CreateAuthUser(user)
}
func (auu *authUserUsecase) DeleteAuthUser(user *happydns.UserAuth, password string) error {
if !user.CheckPassword(password) {
return fmt.Errorf("The given current password is invalid.")
}
if err := auu.store.DeleteAuthUser(user); err != nil {
return happydns.InternalError{
Err: fmt.Errorf("unable to DeleteAuthUser in deleteauthuser: %s", err.Error()),
UserMessage: "Sorry, we are currently unable to delete your profile. Please try again later.",
}
}
return auu.CloseAuthUserSessions(user)
}
func (auu *authUserUsecase) GetAuthUser(uid happydns.Identifier) (*happydns.UserAuth, error) {
return auu.store.GetAuthUser(uid)
}
func (auu *authUserUsecase) GetAuthUserByEmail(email string) (*happydns.UserAuth, error) {
return auu.store.GetAuthUserByEmail(email)
}
func (auu *authUserUsecase) ListAuthUserSessions(user *happydns.UserAuth) ([]*happydns.Session, error) {
return auu.store.ListAuthUserSessions(user)
}
func (auu *authUserUsecase) GenerateRecoveryLink(user *happydns.UserAuth) string {
return user.GetAccountRecoveryURL(auu.config.GetBaseURL())
}
func (auu *authUserUsecase) SendRecoveryLink(user *happydns.UserAuth) error {
toName := helpers.GenUsername(user.Email)
link := auu.GenerateRecoveryLink(user)
err := auu.store.UpdateAuthUser(user)
if err != nil {
}
return auu.mailer.SendMail(
&mail.Address{Name: toName, Address: user.Email},
"Recover your happyDomain account",
`Hi `+toName+`,
You've just ask on our platform to recover your account.
In order to define a new password, please follow this link now:
[Recover my account](`+link+`)`,
)
}
func (auu *authUserUsecase) GenerateValidationLink(user *happydns.UserAuth) string {
return user.GetRegistrationURL(auu.config.GetBaseURL())
}
func (auu *authUserUsecase) SendValidationLink(user *happydns.UserAuth) error {
if auu.mailer == nil {
return fmt.Errorf("No mailer configured")
}
toName := helpers.GenUsername(user.Email)
return auu.mailer.SendMail(
&mail.Address{Name: toName, Address: user.Email},
"Your new account on happyDomain",
`Welcome to happyDomain!
--------------------
Hi `+toName+`,
We are pleased that you created an account on our great domain name
management platform!
In order to validate your account, please follow this link now:
[Validate my account](`+auu.GenerateValidationLink(user)+`)`,
)
}
func (auu *authUserUsecase) ValidateEmail(user *happydns.UserAuth, form happydns.AddressValidationForm) error {
if err := user.ValidateEmail(form.Key); err != nil {
return happydns.ValidationError{fmt.Sprintf("bad email validation key: %s", err.Error())}
}
if err := auu.store.UpdateAuthUser(user); err != nil {
return happydns.InternalError{
Err: fmt.Errorf("unable to UpdateUser in ValidateUserAddress: %w", err),
UserMessage: "Sorry, we are currently unable to update your profile. Please try again later.",
}
}
return nil
}

View file

@ -0,0 +1,132 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package authuser
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"fmt"
"net/mail"
"time"
"git.happydns.org/happyDomain/internal/helpers"
"git.happydns.org/happyDomain/internal/mailer"
"git.happydns.org/happyDomain/model"
)
// AccountRecoveryHashValidityis the time during which the recovery link is at least valid.
const AccountRecoveryHashValidity = 2 * time.Hour
// GenAccountRecoveryHash generates the recovery hash for the current or previous period.
// It updates the UserAuth structure in some cases, when it needs to generate a new recovery key,
// so don't forget to save the changes made.
func GenAccountRecoveryHash(recoveryKey []byte, previous bool) string {
date := time.Now()
date = date.Truncate(AccountRecoveryHashValidity)
if previous {
date = date.Add(AccountRecoveryHashValidity * -1)
}
if len(recoveryKey) == 0 {
return ""
}
h := hmac.New(sha512.New, recoveryKey)
h.Write(date.AppendFormat([]byte{}, time.RFC3339))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
// CanRecoverAccount checks if the given key is a valid recovery hash.
func CanRecoverAccount(u *happydns.UserAuth, key string) error {
if key == GenAccountRecoveryHash(u.PasswordRecoveryKey, false) || key == GenAccountRecoveryHash(u.PasswordRecoveryKey, true) {
return nil
}
return fmt.Errorf("The account recovery link you follow is invalid or has expired (it is valid during %d hours)", AccountRecoveryHashValidity/time.Hour)
}
type RecoverAccountUsecase struct {
store AuthUserStorage
mailer *mailer.Mailer
config *happydns.Options
changePassword *ChangePasswordUsecase
}
func NewRecoverAccountUsecase(store AuthUserStorage, mailer *mailer.Mailer, config *happydns.Options, changePassword *ChangePasswordUsecase) *RecoverAccountUsecase {
return &RecoverAccountUsecase{
store: store,
mailer: mailer,
config: config,
changePassword: changePassword,
}
}
// GenerateLink returns the absolute URL corresponding to the recovery
// URL of the given account.
func (uc *RecoverAccountUsecase) GenerateLink(user *happydns.UserAuth) (string, error) {
if user.PasswordRecoveryKey == nil {
user.PasswordRecoveryKey = make([]byte, 64)
if _, err := rand.Read(user.PasswordRecoveryKey); err != nil {
return "", err
}
if err := uc.store.UpdateAuthUser(user); err != nil {
return "", err
}
}
return uc.config.GetBaseURL() + fmt.Sprintf("/forgotten-password?u=%s&k=%s", base64.RawURLEncoding.EncodeToString(user.Id), GenAccountRecoveryHash(user.PasswordRecoveryKey, false)), nil
}
func (uc *RecoverAccountUsecase) SendLink(user *happydns.UserAuth) error {
link, err := uc.GenerateLink(user)
if err != nil {
return fmt.Errorf("unable to generate recovery link: %w", err)
}
toName := helpers.GenUsername(user.Email)
return uc.mailer.SendMail(
&mail.Address{Name: toName, Address: user.Email},
"Recover your happyDomain account",
fmt.Sprintf(`Hi %s,
You've just ask on our platform to recover your account.
In order to define a new password, please follow this link now:
[Recover my account](%s)`, toName, link),
)
}
func (uc *RecoverAccountUsecase) ResetPassword(user *happydns.UserAuth, form happydns.AccountRecoveryForm) error {
if err := CanRecoverAccount(user, form.Key); err != nil {
return err
}
if err := uc.changePassword.Change(user, form.Password); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,43 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package authuser
import (
"git.happydns.org/happyDomain/model"
)
// CanRegisterUsecase struct holds the configuration to check if registration is allowed.
type CanRegisterUsecase struct {
config *happydns.Options
}
// NewCanRegisterUsecase creates a new instance of CanRegisterUsecase.
func NewCanRegisterUsecase(cfg *happydns.Options) *CanRegisterUsecase {
return &CanRegisterUsecase{
config: cfg,
}
}
// IsOpened returns true if user registrations are enabled on this instance.
func (uc *CanRegisterUsecase) IsOpened() bool {
return !uc.config.DisableRegistration
}

View file

@ -0,0 +1,85 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package authuser
import (
"fmt"
"git.happydns.org/happyDomain/model"
)
// ChangePasswordUsecase handles the logic for changing a user's password.
type ChangePasswordUsecase struct {
store AuthUserStorage
checkPasswordConstraints *CheckPasswordConstraintsUsecase
}
// NewChangePasswordUsecase creates a new instance of ChangePasswordUsecase.
func NewChangePasswordUsecase(store AuthUserStorage, checkPasswordConstraints *CheckPasswordConstraintsUsecase) *ChangePasswordUsecase {
return &ChangePasswordUsecase{
store: store,
checkPasswordConstraints: checkPasswordConstraints,
}
}
// Change changes the password of the given user after verifying the current password
// and checking new password constraints (length, confirmation, etc.).
func (uc *ChangePasswordUsecase) Change(user *happydns.UserAuth, password string) error {
// Validate the new password according to application constraints
if err := uc.checkPasswordConstraints.Check(password); err != nil {
return happydns.ValidationError{Msg: err.Error()}
}
// Apply the new password to the user
if err := user.DefinePassword(password); err != nil {
return fmt.Errorf("unable to change user password: %w", err)
}
// Persist the updated user information
if err := uc.store.UpdateAuthUser(user); err != nil {
return fmt.Errorf("unable to save new password: %w", err)
}
return nil
}
func (uc *ChangePasswordUsecase) CheckNewPassword(user *happydns.UserAuth, form happydns.ChangePasswordForm) error {
if !user.CheckPassword(form.Current) {
return happydns.ValidationError{Msg: "bad current password"}
}
return uc.CheckResetPassword(user, form)
}
func (uc *ChangePasswordUsecase) CheckResetPassword(user *happydns.UserAuth, form happydns.ChangePasswordForm) error {
// Validate the new password according to application constraints
if err := uc.checkPasswordConstraints.Check(form.Password); err != nil {
return happydns.ValidationError{Msg: err.Error()}
}
// Confirm the new password matches its confirmation
if form.Password != form.PasswordConfirm {
return happydns.ValidationError{Msg: "the new password and its confirmation are different."}
}
return nil
}

View file

@ -0,0 +1,59 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package authuser
import (
"fmt"
"regexp"
)
// CheckPasswordConstraintsUsecase struct contains the necessary dependencies
// for the usecase to check the password constraints.
type CheckPasswordConstraintsUsecase struct {
// No external dependencies are required.
}
func NewCheckPasswordConstraintsUsecase() *CheckPasswordConstraintsUsecase {
return &CheckPasswordConstraintsUsecase{}
}
// Check checks the given password to see if it adheres to the system's constraints.
// It validates length and format as necessary.
// Returns an error if the password is invalid.
func (uc *CheckPasswordConstraintsUsecase) Check(password string) error {
if len(password) < 8 {
return fmt.Errorf("password must be at least 8 characters long")
}
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
return fmt.Errorf("Password must contain lower case letters.")
} else if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
return fmt.Errorf("Password must contain upper case letters.")
} else if !regexp.MustCompile(`[0-9]`).MatchString(password) {
return fmt.Errorf("Password must contain numbers.")
} else if len(password) < 11 && !regexp.MustCompile(`[^a-zA-Z0-9]`).MatchString(password) {
return fmt.Errorf("Password must be longer or contain symbols.")
}
// If all conditions are met, return nil (no error)
return nil
}

View file

@ -0,0 +1,104 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package authuser
import (
"fmt"
"strings"
"git.happydns.org/happyDomain/internal/mailer"
"git.happydns.org/happyDomain/model"
)
// CreateAuthUserUsecase handles the creation of a new authenticated user account.
type CreateAuthUserUsecase struct {
store AuthUserStorage
mailer *mailer.Mailer
checkPasswordConstraints *CheckPasswordConstraintsUsecase
emailValidation *EmailValidationUsecase
}
// NewCreateAuthUserUsecase initializes a new instance of CreateAuthUserUsecase.
func NewCreateAuthUserUsecase(store AuthUserStorage, mailer *mailer.Mailer, checkPasswordConstraints *CheckPasswordConstraintsUsecase, emailValidation *EmailValidationUsecase) *CreateAuthUserUsecase {
return &CreateAuthUserUsecase{
store: store,
mailer: mailer,
checkPasswordConstraints: checkPasswordConstraints,
emailValidation: emailValidation,
}
}
// Create validates the registration request, creates the user, and optionally sends a validation email.
func (uc *CreateAuthUserUsecase) Create(uu happydns.UserRegistration) (*happydns.UserAuth, error) {
// Validate email format
if len(uu.Email) <= 3 || !strings.Contains(uu.Email, "@") {
return nil, happydns.ValidationError{Msg: "the given email is invalid"}
}
// Validate password strength
err := uc.checkPasswordConstraints.Check(uu.Password)
if err != nil {
return nil, happydns.ValidationError{Msg: err.Error()}
}
// Check if an account already exists with this email
exists, err := uc.store.AuthUserExists(uu.Email)
if err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to check if user exists: %w", err),
UserMessage: "Sorry, we are currently unable to create your account. Please try again later.",
}
}
if exists {
return nil, happydns.ValidationError{Msg: "an account already exists with the given address. Try logging in."}
}
// Create the user object
user, err := happydns.NewUserAuth(uu.Email, uu.Password)
if err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to create user object: %w", err),
UserMessage: "Sorry, we are currently unable to create your account. Please try again later.",
}
}
user.AllowCommercials = uu.Newsletter
// Persist the new user in the storage layer
if err := uc.store.CreateAuthUser(user); err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to create user in storage: %w", err),
UserMessage: "Sorry, we are currently unable to create your account. Please try again later.",
}
}
// Optionally send the validation email if mailer is configured
if uc.mailer != nil {
if err = uc.emailValidation.SendLink(user); err != nil {
return nil, happydns.InternalError{
Err: fmt.Errorf("unable to send validation email: %w", err),
UserMessage: "Sorry, we are currently unable to create your account. Please try again later.",
}
}
}
return user, nil
}

View file

@ -0,0 +1,64 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package authuser
import (
"fmt"
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
"git.happydns.org/happyDomain/model"
)
// DeleteAuthUserUsecase represents the use case for deleting an authenticated user and their sessions.
type DeleteAuthUserUsecase struct {
store AuthUserStorage
closeUserSessions *sessionUC.CloseUserSessionsUsecase
}
// NewDeleteAuthUserUsecase creates a new instance of DeleteAuthUserUsecase.
func NewDeleteAuthUserUsecase(store AuthUserStorage, closeUserSessions *sessionUC.CloseUserSessionsUsecase) *DeleteAuthUserUsecase {
return &DeleteAuthUserUsecase{
store: store,
closeUserSessions: closeUserSessions,
}
}
// Do deletes an authenticated user from the system, ensuring their sessions are also removed.
// It first verifies the current password, then removes the user and their associated sessions from the storage.
func (uc *DeleteAuthUserUsecase) Delete(user *happydns.UserAuth, password string) error {
// Step 1: Verify the current password.
if !user.CheckPassword(password) {
return fmt.Errorf("invalid current password")
}
// Step 2: Delete the user's sessions.
if err := uc.closeUserSessions.CloseAll(user); err != nil {
return fmt.Errorf("unable to delete user sessions: %w", err)
}
// Step 3: Delete the user from the storage.
if err := uc.store.DeleteAuthUser(user); err != nil {
return fmt.Errorf("unable to delete user: %w", err)
}
return nil
}

View file

@ -0,0 +1,119 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package authuser
import (
"crypto/hmac"
"crypto/sha512"
"encoding/base64"
"fmt"
"net/mail"
"time"
"git.happydns.org/happyDomain/internal/helpers"
"git.happydns.org/happyDomain/internal/mailer"
"git.happydns.org/happyDomain/model"
)
// RegistrationHashValidity is the time during which the email validation link is at least valid.
const RegistrationHashValidity = 24 * time.Hour
// GenRegistrationHash generates the validation hash for the current or previous period.
// The hash computation is based on some already filled fields in the structure.
func GenRegistrationHash(u *happydns.UserAuth, previous bool) string {
date := time.Now()
if previous {
date = date.Add(RegistrationHashValidity * -1)
}
date = date.Truncate(RegistrationHashValidity)
h := hmac.New(
sha512.New,
[]byte(u.CreatedAt.Format(time.RFC3339Nano)),
)
h.Write(date.AppendFormat([]byte{}, time.RFC3339))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
type EmailValidationUsecase struct {
store AuthUserStorage
mailer *mailer.Mailer
config *happydns.Options
}
func NewEmailValidationUsecase(store AuthUserStorage, mailer *mailer.Mailer, config *happydns.Options) *EmailValidationUsecase {
return &EmailValidationUsecase{
store: store,
mailer: mailer,
config: config,
}
}
// GenerateLink returns the absolute URL corresponding to the recovery
// URL of the given account.
func (uc *EmailValidationUsecase) GenerateLink(user *happydns.UserAuth) string {
return uc.config.GetBaseURL() + fmt.Sprintf("/email-validation?u=%s&k=%s", base64.RawURLEncoding.EncodeToString(user.Id), GenRegistrationHash(user, false))
}
func (uc *EmailValidationUsecase) SendLink(user *happydns.UserAuth) error {
if uc.mailer == nil {
return fmt.Errorf("no mailer configured")
}
toName := helpers.GenUsername(user.Email)
return uc.mailer.SendMail(
&mail.Address{Name: toName, Address: user.Email},
"Your new account on happyDomain",
fmt.Sprintf(`
Welcome to happyDomain!
-----------------------
Hi %s,
We are pleased that you created an account on our great domain name
management platform!
In order to validate your account, please follow this link now:
[Validate my account](%s)
`, toName, uc.GenerateLink(user)),
)
}
// Validate tries to validate the email address by comparing the given key to the expected one.
func (uc *EmailValidationUsecase) Validate(user *happydns.UserAuth, form happydns.AddressValidationForm) error {
if form.Key != GenRegistrationHash(user, false) && form.Key != GenRegistrationHash(user, true) {
return happydns.ValidationError{Msg: fmt.Sprintf("bad email validation key: the validation address link you follow is invalid or has expired (it is valid during %d hours)", RegistrationHashValidity/time.Hour)}
}
now := time.Now()
user.EmailVerification = &now
if err := uc.store.UpdateAuthUser(user); err != nil {
return happydns.InternalError{
Err: fmt.Errorf("unable to update auth user: %w", err),
UserMessage: "Sorry, we are currently unable to update your profile. Please try again later.",
}
}
return nil
}

View file

@ -0,0 +1,128 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package authuser
import (
"fmt"
"git.happydns.org/happyDomain/internal/mailer"
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
"git.happydns.org/happyDomain/model"
)
// Service groups all use cases related to user authentication and management.
type Service struct {
// Usecases for user management actions
CanRegisterUC *CanRegisterUsecase
ChangePasswordUC *ChangePasswordUsecase
CheckPasswordConstraintsUC *CheckPasswordConstraintsUsecase
CreateAuthUserUC *CreateAuthUserUsecase
DeleteAuthUserUC *DeleteAuthUserUsecase
EmailValidationUC *EmailValidationUsecase
GetAuthUserUC *GetAuthUserUsecase
RecoverAccountUC *RecoverAccountUsecase
}
// NewAuthUserService initializes and returns a new AuthUserService, containing all use cases.
func NewAuthUserUsecases(
cfg *happydns.Options,
mailer *mailer.Mailer,
store AuthUserStorage,
closeUserSessionsUseCase *sessionUC.CloseUserSessionsUsecase,
) *Service {
checkPasswordConstraintsUC := NewCheckPasswordConstraintsUsecase()
changePasswordUC := NewChangePasswordUsecase(store, checkPasswordConstraintsUC)
emailValidationUC := NewEmailValidationUsecase(store, mailer, cfg)
getAuthUserUC := NewGetAuthUserUsecase(store)
// Initialize each usecase by injecting required dependencies.
return &Service{
CanRegisterUC: NewCanRegisterUsecase(cfg),
ChangePasswordUC: changePasswordUC,
CheckPasswordConstraintsUC: checkPasswordConstraintsUC,
CreateAuthUserUC: NewCreateAuthUserUsecase(store, mailer, checkPasswordConstraintsUC, emailValidationUC),
DeleteAuthUserUC: NewDeleteAuthUserUsecase(store, closeUserSessionsUseCase),
EmailValidationUC: emailValidationUC,
GetAuthUserUC: getAuthUserUC,
RecoverAccountUC: NewRecoverAccountUsecase(store, mailer, cfg, changePasswordUC),
}
}
func (s *Service) CanRegister(user happydns.UserRegistration) error {
if !s.CanRegisterUC.IsOpened() {
return fmt.Errorf("Registration are closed on this instance.")
}
return nil
}
func (s *Service) CheckPassword(user *happydns.UserAuth, request happydns.ChangePasswordForm) error {
return s.ChangePasswordUC.CheckResetPassword(user, request)
}
func (s *Service) CheckNewPassword(user *happydns.UserAuth, request happydns.ChangePasswordForm) error {
return s.ChangePasswordUC.CheckNewPassword(user, request)
}
func (s *Service) ChangePassword(user *happydns.UserAuth, newPassword string) error {
return s.ChangePasswordUC.Change(user, newPassword)
}
func (s *Service) CreateAuthUser(uu happydns.UserRegistration) (*happydns.UserAuth, error) {
return s.CreateAuthUserUC.Create(uu)
}
func (s *Service) DeleteAuthUser(user *happydns.UserAuth, password string) error {
return s.DeleteAuthUserUC.Delete(user, password)
}
func (s *Service) GetAuthUser(userID happydns.Identifier) (*happydns.UserAuth, error) {
return s.GetAuthUserUC.ByID(userID)
}
func (s *Service) GetAuthUserByEmail(email string) (*happydns.UserAuth, error) {
return s.GetAuthUserUC.ByEmail(email)
}
func (s *Service) GenerateRecoveryLink(user *happydns.UserAuth) (string, error) {
return s.RecoverAccountUC.GenerateLink(user)
}
func (s *Service) SendRecoveryLink(user *happydns.UserAuth) error {
return s.RecoverAccountUC.SendLink(user)
}
func (s *Service) GenerateValidationLink(user *happydns.UserAuth) string {
return s.EmailValidationUC.GenerateLink(user)
}
func (s *Service) ResetPassword(user *happydns.UserAuth, form happydns.AccountRecoveryForm) error {
return s.RecoverAccountUC.ResetPassword(user, form)
}
func (s *Service) SendValidationLink(user *happydns.UserAuth) error {
return s.EmailValidationUC.SendLink(user)
}
func (s *Service) ValidateEmail(user *happydns.UserAuth, form happydns.AddressValidationForm) error {
return s.EmailValidationUC.Validate(user, form)
}

View file

@ -0,0 +1,60 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package authuser
import (
"fmt"
"git.happydns.org/happyDomain/model"
)
// GetAuthUserUsecase handles retrieval of authenticated users by ID.
type GetAuthUserUsecase struct {
store AuthUserStorage
}
// NewGetAuthUserUsecase creates a new instance of GetAuthUserUsecase.
func NewGetAuthUserUsecase(store AuthUserStorage) *GetAuthUserUsecase {
return &GetAuthUserUsecase{
store: store,
}
}
// ByID retrieves an authenticated user from the storage by their unique identifier.
// Returns the user if found, or an error otherwise.
func (uc *GetAuthUserUsecase) ByID(id happydns.Identifier) (*happydns.UserAuth, error) {
user, err := uc.store.GetAuthUser(id)
if err != nil {
return nil, fmt.Errorf("unable to get user by ID: %w", err)
}
return user, nil
}
// ByEmail retrieves an authenticated user from the storage by their email address.
// Returns the user if found, or an error otherwise.
func (uc *GetAuthUserUsecase) ByEmail(email string) (*happydns.UserAuth, error) {
user, err := uc.store.GetAuthUserByEmail(email)
if err != nil {
return nil, fmt.Errorf("unable to get user by email: %w", err)
}
return user, nil
}

View file

@ -22,12 +22,6 @@
package happydns
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"fmt"
"regexp"
"time"
"golang.org/x/crypto/bcrypt"
@ -89,31 +83,8 @@ func (u *UserAuth) JoinNewsletter() bool {
return u.AllowCommercials
}
// CheckPasswordConstraints checks the given password is strong enough.
func (u *UserAuth) CheckPasswordConstraints(password string) (err error) {
if len(password) < 8 {
return fmt.Errorf("Password has to be at least 8 characters long.")
}
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
return fmt.Errorf("Password has to contain lower case letters.")
} else if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
return fmt.Errorf("Password has to contain upper case letters.")
} else if !regexp.MustCompile(`[0-9]`).MatchString(password) {
return fmt.Errorf("Password has to contain numbers.")
} else if len(password) < 11 && !regexp.MustCompile(`[^a-zA-Z0-9]`).MatchString(password) {
return fmt.Errorf("Password has to be longer or contain symbols.")
}
return nil
}
// DefinePassword erases the current UserAuth's password by the new one given.
func (u *UserAuth) DefinePassword(password string) (err error) {
if err = u.CheckPasswordConstraints(password); err != nil {
return
}
u.Password, err = bcrypt.GenerateFromPassword([]byte(password), 0)
u.PasswordRecoveryKey = nil
@ -129,101 +100,17 @@ func (u *UserAuth) CheckPassword(password string) bool {
return bcrypt.CompareHashAndPassword(u.Password, []byte(password)) == nil
}
// RegistrationHashValidity is the time during which the email validation link is at least valid.
const RegistrationHashValidity = 24 * time.Hour
// GenRegistrationHash generates the validation hash for the current or previous period.
// The hash computation is based on some already filled fields in the structure.
func (u *UserAuth) GenRegistrationHash(previous bool) string {
date := time.Now()
if previous {
date = date.Add(RegistrationHashValidity * -1)
}
date = date.Truncate(RegistrationHashValidity)
h := hmac.New(
sha512.New,
[]byte(u.CreatedAt.Format(time.RFC3339Nano)),
)
h.Write(date.AppendFormat([]byte{}, time.RFC3339))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
// ValidateEmail tries to validate the email address by comparing the given key to the expected one.
func (u *UserAuth) ValidateEmail(key string) error {
if key == u.GenRegistrationHash(false) || key == u.GenRegistrationHash(true) {
now := time.Now()
u.EmailVerification = &now
return nil
}
return fmt.Errorf("The validation address link you follow is invalid or has expired (it is valid during %d hours)", RegistrationHashValidity/time.Hour)
}
// AccountRecoveryHashValidityis the time during which the recovery link is at least valid.
const AccountRecoveryHashValidity = 2 * time.Hour
// GenAccountRecoveryHash generates the recovery hash for the current or previous period.
// It updates the UserAuth structure in some cases, when it needs to generate a new recovery key,
// so don't forget to save the changes made.
func (u *UserAuth) GenAccountRecoveryHash(previous bool) string {
if u.PasswordRecoveryKey == nil {
u.PasswordRecoveryKey = make([]byte, 64)
if _, err := rand.Read(u.PasswordRecoveryKey); err != nil {
return ""
}
}
date := time.Now()
date = date.Truncate(AccountRecoveryHashValidity)
if previous {
date = date.Add(AccountRecoveryHashValidity * -1)
}
if len(u.PasswordRecoveryKey) == 0 {
return ""
}
h := hmac.New(
sha512.New,
u.PasswordRecoveryKey,
)
h.Write(date.AppendFormat([]byte{}, time.RFC3339))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
// CanRecoverAccount checks if the given key is a valid recovery hash.
func (u *UserAuth) CanRecoverAccount(key string) error {
if key == u.GenAccountRecoveryHash(false) || key == u.GenAccountRecoveryHash(true) {
return nil
}
return fmt.Errorf("The account recovery link you follow is invalid or has expired (it is valid during %d hours)", AccountRecoveryHashValidity/time.Hour)
}
// GetAccountRecoveryURL returns the absolute URL corresponding to the recovery
// URL of the given account.
func (u *UserAuth) GetAccountRecoveryURL(baseurl string) string {
return baseurl + fmt.Sprintf("/forgotten-password?u=%s&k=%s", base64.RawURLEncoding.EncodeToString(u.Id), u.GenAccountRecoveryHash(false))
}
// GetAccountRecoveryURL returns the absolute URL corresponding to the recovery
// URL of the given account.
func (u *UserAuth) GetRegistrationURL(baseurl string) string {
return baseurl + fmt.Sprintf("/email-validation?u=%s&k=%s", base64.RawURLEncoding.EncodeToString(u.Id), u.GenRegistrationHash(false))
}
type AuthUserUsecase interface {
CanRegister(UserRegistration) error
CheckPassword(*UserAuth, ChangePasswordForm) error
CheckNewPassword(*UserAuth, ChangePasswordForm) error
ChangePassword(*UserAuth, string) error
CloseAuthUserSessions(*UserAuth) error
CreateAuthUser(UserRegistration) (*UserAuth, error)
DeleteAuthUser(*UserAuth, string) error
GenerateRecoveryLink(*UserAuth) string
GenerateRecoveryLink(*UserAuth) (string, error)
GenerateValidationLink(*UserAuth) string
GetAuthUser(Identifier) (*UserAuth, error)
GetAuthUserByEmail(string) (*UserAuth, error)
ResetPassword(*UserAuth, AccountRecoveryForm) error
SendRecoveryLink(*UserAuth) error
SendValidationLink(*UserAuth) error
ValidateEmail(*UserAuth, AddressValidationForm) error

View file

@ -21,6 +21,14 @@
package happydns
type AccountRecoveryForm struct {
// Key is the secret sent by email to the user.
Key string
// Password is the new password to use with this account.
Password string
}
type AddressValidationForm struct {
// Key able to validate the email address.
Key string