happyDomain/internal/usecase/authentication_usecase.go
Pierre-Olivier Mercier d6e442f02b security: prevent email enumeration via timing side-channel
When the requested email does not exist, the function returned in
microseconds, while a valid email with wrong password took ~100ms
(bcrypt). An attacker could enumerate valid accounts by measuring
response latency.

Add a dummy bcrypt.CompareHashAndPassword call on the not-found path so
both branches take a comparable amount of time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 15:03:49 +07:00

106 lines
3.4 KiB
Go

// 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 (
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
"git.happydns.org/happyDomain/internal/usecase/authuser"
"git.happydns.org/happyDomain/internal/usecase/user"
"git.happydns.org/happyDomain/model"
)
type AuthenticationStorage interface {
authuser.AuthUserStorage
user.UserStorage
}
type loginUsecase struct {
config *happydns.Options
store AuthenticationStorage
userService happydns.UserUsecase
}
func NewAuthenticationUsecase(cfg *happydns.Options, store AuthenticationStorage, userService happydns.UserUsecase) happydns.AuthenticationUsecase {
return &loginUsecase{
config: cfg,
store: store,
userService: userService,
}
}
func (lu *loginUsecase) CompleteAuthentication(uinfo happydns.UserInfo) (*happydns.User, error) {
// Check if the user already exists
user, err := lu.store.GetUser(uinfo.GetUserId())
if err != nil {
// Create the user
user, err = lu.userService.CreateUser(uinfo)
if err != nil {
return nil, fmt.Errorf("unable to create user account: %w", err)
}
} else if (uinfo.GetEmail() != "" && user.Email != uinfo.GetEmail()) || time.Since(user.LastSeen) > time.Hour*12 {
if uinfo.GetEmail() != "" {
user.Email = uinfo.GetEmail()
}
user.LastSeen = time.Now()
err = lu.store.CreateOrUpdateUser(user)
if err != nil {
return nil, fmt.Errorf("has a correct JWT, user has been found, but an error occured when trying to update the user's information: %w", err)
}
}
return user, nil
}
func (lu *loginUsecase) AuthenticateUserWithPassword(request happydns.LoginRequest) (*happydns.User, error) {
// Retrieve the given user
user, err := lu.store.GetAuthUserByEmail(request.Email)
if err != nil {
// Perform a dummy bcrypt comparison to equalize timing with the
// valid-user path and prevent email enumeration via response time.
bcrypt.CompareHashAndPassword([]byte("$2a$12$dummy.hash.that.never.matches.any.real.password.value"), []byte(request.Password))
return nil, fmt.Errorf("user not found: %w", err)
}
if !user.CheckPassword(request.Password) {
return nil, fmt.Errorf("invalid password")
}
// Ensure the account is enabled
if !lu.config.NoMail && user.EmailVerification == nil {
return nil, fmt.Errorf("account email not verified")
}
// Record the successful login time and transparently upgrade the hash cost if needed
now := time.Now()
user.LastLoggedIn = &now
if user.NeedsRehash() {
user.DefinePassword(request.Password)
}
lu.store.UpdateAuthUser(user)
return lu.CompleteAuthentication(user)
}