Files
phishingclub/backend/service/user.go
T
Ronni Skansing df555820f9 Add OIDC provider support.
Add exclusive SSO login support.

Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2026-06-17 23:41:03 +02:00

1046 lines
29 KiB
Go

package service
import (
"context"
"crypto/sha256"
"crypto/subtle"
"strings"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/password"
"github.com/phishingclub/phishingclub/random"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/validate"
"github.com/phishingclub/phishingclub/vo"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"gorm.io/gorm"
)
// TOTPValues is TOTP related values
type TOTPValues struct {
Secret string
URL string
RecoveryCode string
}
// User is a service for User
type User struct {
Common
UserRepository *repository.User
RoleRepository *repository.Role
CompanyRepository *repository.Company
PasswordVerifier *password.Argon2Verifier
PasswordHasher *password.Argon2Hasher
TOTPReplayCache *TOTPReplayCache
}
// Create creates a new user
func (u *User) Create(
ctx context.Context,
session *model.Session,
newUser *model.UserUpsertRequest,
) (*uuid.UUID, error) {
ae := NewAuditEvent("User.Create", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// check if the username is already taken
_, err = u.UserRepository.GetByUsername(
ctx,
&newUser.Username,
&repository.UserOption{},
)
// if there is not record not found error, then thw username is already user
if err == nil {
u.Logger.Debugw("username is already taken", "username", newUser.Username.String())
return nil, validate.WrapErrorWithField(errors.New("not unique"), "username")
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
u.Logger.Errorw("failed to create user - failed to get user by username", "error", err)
return nil, errs.Wrap(err)
}
// check if the email is already taken
_, err = u.UserRepository.GetByEmail(
ctx,
&newUser.Email,
&repository.UserOption{},
)
if err == nil {
u.Logger.Debugw("email is already taken", "email", newUser.Email.String())
return nil, validate.WrapErrorWithField(errors.New("not unique"), "email")
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
u.Logger.Errorw("failed to create user - failed to get user by email", "error", err)
return nil, errs.Wrap(err)
}
adminRole, err := u.RoleRepository.GetByName(
ctx,
data.RoleSuperAdministrator,
)
if err != nil {
return nil, errs.Wrap(err)
}
// create
user := model.User{
Username: nullable.NewNullableWithValue(newUser.Username),
Email: nullable.NewNullableWithValue(newUser.Email),
Name: nullable.NewNullableWithValue(newUser.Fullname),
RoleID: nullable.NewNullableWithValue(adminRole.ID),
}
passwdHash, err := u.PasswordHasher.Hash(newUser.Password.String())
if err != nil {
u.Logger.Errorw("failed to create user - failed to hash password", "error", err)
return nil, errs.Wrap(err)
}
// validate
if err := user.Validate(); err != nil {
u.Logger.Debugw("failed to create user - failed to validate user", "error", err)
return nil, errs.Wrap(err)
}
// save the user
id, err := u.UserRepository.Insert(
ctx,
&user,
passwdHash,
"",
)
if err != nil {
u.Logger.Errorw("failed to create user - failed to save user", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = id.String()
u.AuditLogAuthorized(ae)
return id, nil
}
// CreateFromSSO resolves an existing user for an SSO login.
// Users are never auto provisioned, an administrator must create the user
// first, so an unknown identity is rejected. Matching is done on the email
// reported by the identity provider, which in a managed directory is an admin
// controlled attribute the user cannot change to impersonate someone else.
// Email is also portable across providers, so switching SSO method does not
// lock accounts out.
func (u *User) CreateFromSSO(
ctx context.Context,
name string,
email string,
externalID string,
) (*uuid.UUID, error) {
ae := NewAuditEvent("User.SSOLogin", nil)
ae.Details["email"] = email
emailVO, err := vo.NewEmail(email)
if err != nil {
u.Logger.Debugw("failed SSO login: invalid email", "error", err)
return nil, errs.Wrap(err)
}
existingUser, err := u.UserRepository.GetByEmail(
ctx,
emailVO,
&repository.UserOption{},
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
u.Logger.Debugw("failed SSO login: DB error", "error", err)
return nil, errs.Wrap(err)
}
// users are not auto provisioned, an admin must create the user first
if existingUser == nil {
u.Logger.Infow("rejected SSO login for unprovisioned user", "email", email)
u.AuditLogNotAuthorized(ae)
return nil, errs.Wrap(errs.ErrSSOUserNotProvisioned)
}
uid := existingUser.ID.MustGet()
// store the current provider subject id for reference, overwriting any
// previous value so a change of SSO method does not lock the account out.
// the password is left intact, whether password login is allowed is
// governed by the exclusive SSO mode, not by destroying the password here
if err := u.UserRepository.SetSSOID(ctx, &uid, externalID); err != nil {
u.Logger.Errorw("failed to set SSO id on user", "error", err)
return nil, errs.Wrap(err)
}
u.AuditLogAuthorized(ae)
return &uid, nil
}
// GetMaskedAPIKey gets a masked API user key
func (u *User) GetMaskedAPIKey(
ctx context.Context,
session *model.Session,
userID *uuid.UUID,
) (string, error) {
ae := NewAuditEvent("User.GetMaskedAPIKey", session)
ae.Details["userId"] = userID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return "", errs.Wrap(err)
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return "", errs.ErrAuthorizationFailed
}
// get api key
apiKey, err := u.UserRepository.GetAPIKey(ctx, userID)
if err != nil {
u.Logger.Errorw("failed to get api key", "error", err)
return "", errs.Wrap(err)
}
masked := ""
if len(apiKey) > 4 {
masked = apiKey[0:4] + strings.Repeat("*", 28)
}
// no audit on read
return masked, nil
}
// GetAllAPIKeys gets all api keys as SHA256
// THIS METHOD DOES NOT HAVE AUTH, USE WITH DISCRETION
func (s *User) GetAllAPIKeysSHA256(
ctx context.Context,
) ([]*model.APIUser, error) {
apiUsers := []*model.APIUser{}
// get api key
apiKeyAndIDMap, err := s.UserRepository.GetAllAPIKeys(ctx)
for apiKey, userID := range apiKeyAndIDMap {
hash := sha256.Sum256([]byte(apiKey))
apiUsers = append(
apiUsers,
&model.APIUser{
ID: userID,
APIKeyHash: hash,
},
)
}
if err != nil {
s.Logger.Errorw("failed to get all api keys", "error", err)
return apiUsers, errs.Wrap(err)
}
return apiUsers, nil
}
// UpsertAPIKey creates/updates a user API key
func (s *User) UpsertAPIKey(
ctx context.Context,
session *model.Session,
userID *uuid.UUID,
) (string, error) {
ae := NewAuditEvent("User.UpsertAPIKey", session)
ae.Details["userId"] = userID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return "", errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return "", errs.ErrAuthorizationFailed
}
key, err := random.GenerateRandomURLBase64Encoded(64)
if err != nil {
s.Logger.Errorw("failed to create api key - bad crypto", "error", err)
return "", errs.Wrap(err)
}
// upsert api key
err = s.UserRepository.UpsertAPIKey(
ctx,
userID,
key,
)
if err != nil {
s.Logger.Errorw("failed set api key", "error", err)
return "", errs.Wrap(err)
}
s.AuditLogAuthorized(ae)
return key, nil
}
// RemoveAPIKey removes a users api key
func (u *User) RemoveAPIKey(
ctx context.Context,
session *model.Session,
userID *uuid.UUID,
) error {
ae := NewAuditEvent("User.RemoveAPIKey", session)
ae.Details["userId"] = userID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return err
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
err = u.UserRepository.RemoveAPIKey(ctx, userID)
if err != nil {
u.Logger.Errorw("failed to remove api key", "error", err)
return err
}
u.AuditLogAuthorized(ae)
return nil
}
// UpdateByID updates a user by ID
// values to update are email, username and fullname
func (u *User) Update(
ctx context.Context,
session *model.Session,
userID *uuid.UUID,
incoming *model.User,
) error {
ae := NewAuditEvent("User.Update", session)
ae.Details["id"] = userID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return err
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get user to be updated
current, err := u.UserRepository.GetByID(ctx, userID, &repository.UserOption{})
if err != nil {
u.Logger.Errorw("failed to update user - failed to get user by id", "error", err)
return err
}
// check if the username is already taken
if username, err := incoming.Username.Get(); err == nil {
_, err = u.UserRepository.GetByUsername(
ctx,
&username,
&repository.UserOption{},
)
if err == nil && current.Username.MustGet().String() != username.String() {
u.Logger.Debugw("username is already taken", "username", username.String())
return validate.WrapErrorWithField(errors.New("not unique"), "username")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
u.Logger.Errorw("failed to update user - failed to get user by username", "error", err)
return err
}
}
// check if the is already taken
if email, err := incoming.Email.Get(); err == nil {
// check if the email is already taken
_, err = u.UserRepository.GetByEmail(
ctx,
&email,
&repository.UserOption{},
)
if err == nil && current.Email.MustGet().String() != incoming.Email.MustGet().String() {
u.Logger.Debugw("email is already taken", "email", email.String())
return validate.WrapErrorWithField(errors.New("not unique"), "email")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
u.Logger.Errorw("failed to update user - failed to get user by email", "error", err)
return err
}
}
if v, err := incoming.Email.Get(); err == nil {
current.Email.Set(v)
}
if v, err := incoming.Username.Get(); err == nil {
current.Username.Set(v)
}
if v, err := incoming.Name.Get(); err == nil {
current.Name.Set(v)
}
// validate
if err := current.Validate(); err != nil {
u.Logger.Debugw("failed to update user - failed to validate user", "error", err)
return err
}
// update the user
err = u.UserRepository.UpdateByID(
ctx,
userID,
current,
)
if err != nil {
u.Logger.Errorw("failed to update user - failed to update user", "error", err)
return err
}
u.AuditLogAuthorized(ae)
return nil
}
// GetAll gets all users
func (u *User) GetAll(
ctx context.Context,
session *model.Session,
options *repository.UserOption,
) (*model.Result[model.User], error) {
result := model.NewEmptyResult[model.User]()
ae := NewAuditEvent("User.GetAll", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return result, errs.Wrap(err)
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return result, errs.ErrAuthorizationFailed
}
result, err = u.UserRepository.GetAll(ctx, options)
if err != nil {
u.Logger.Errorw("failed to get all users - failed to get all users", "error", err)
return result, errs.Wrap(err)
}
// no audit on read
return result, nil
}
// GetByID gets a user by ID
func (s *User) GetByID(
ctx context.Context,
session *model.Session,
userID *uuid.UUID,
) (*model.User, error) {
ae := NewAuditEvent("User.GetByID", session)
ae.Details["id"] = userID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
user, err := s.UserRepository.GetByID(
ctx,
userID,
&repository.UserOption{
WithRole: true,
WithCompany: true,
},
)
if err != nil {
s.Logger.Errorw("failed to get user by id - failed to get user by id", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return user, nil
}
// GetByIDWithoutAuth gets a user by ID without requiring auth
func (s *User) GetByIDWithoutAuth(
ctx context.Context,
userID *uuid.UUID,
) (*model.User, error) {
user, err := s.UserRepository.GetByID(
ctx,
userID,
&repository.UserOption{
WithRole: true,
WithCompany: true,
},
)
if err != nil {
s.Logger.Errorw("failed to get user by id - failed to get user by id", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return user, nil
}
// Delete deletes a user
func (u *User) Delete(
ctx context.Context,
session *model.Session,
userID *uuid.UUID,
) error {
ae := NewAuditEvent("User.Delete", session)
ae.Details["id"] = userID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return err
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
if session.User.ID.MustGet().String() == userID.String() {
u.Logger.Debugw("Attempted to delete own user", "userID", userID.String())
return errs.NewValidationError(
errors.New("Can not delete own user"),
)
}
// delete the user
err = u.UserRepository.DeleteByID(
ctx,
userID,
)
if err != nil {
u.Logger.Errorw("failed to delete user - failed to delete user", "error", err)
return err
}
u.AuditLogAuthorized(ae)
return nil
}
// SetupTOTP sets up TOTP for a user
// returns secret, url and error
func (u *User) SetupTOTP(
ctx context.Context,
session *model.Session,
password *vo.ReasonableLengthPassword,
) (*TOTPValues, error) {
ae := NewAuditEvent("User.SetupTOTP", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// check if the user is loaded in the session
user := session.User
if user == nil {
u.Logger.Error("user is not loaded in session")
return nil, errors.New("user is not loaded in session")
}
// check password
username := user.Username.MustGet()
hasValidPassword, err := u.CheckPassword(
ctx,
&username,
password,
)
if err != nil || !hasValidPassword {
return nil, errs.ErrAuthenticationFailed
}
// generate OTP
email := user.Email.MustGet()
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Phishing Club",
AccountName: email.String(),
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
u.Logger.Errorw("failed to setup TOTP - failed to generate key", "error", err)
return nil, errs.Wrap(err)
}
rc, err := random.GenerateRandomURLBase64Encoded(24)
if err != nil {
u.Logger.Errorw("failed to setup TOTP - failed to generate recovery code", "error", err)
return nil, errs.Wrap(err)
}
// update user
userID := user.ID.MustGet()
err = u.UserRepository.SetupTOTP(
ctx,
&userID,
key.Secret(),
rc,
key.URL(),
)
if err != nil {
u.Logger.Errorw("failed to setup TOTP - failed to update user", "error", err)
return nil, errs.Wrap(err)
}
u.AuditLogAuthorized(ae)
// audit log
return &TOTPValues{
Secret: key.Secret(),
URL: key.URL(),
RecoveryCode: rc,
}, nil
}
// SetupCheckTOTP verifies a TOTP setup
func (u *User) SetupCheckTOTP(
ctx context.Context,
session *model.Session,
token *vo.String64,
) error {
ae := NewAuditEvent("User.SetupCheckTOTP", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return err
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// get user from session
user := session.User
if user == nil {
u.Logger.Error("user is not loaded in session")
return errors.New("user is not loaded in session")
}
// check if the token is valid
// get the secret
userID := user.ID.MustGet()
secret, _, err := u.UserRepository.GetTOTP(
ctx,
&userID,
)
if err != nil {
u.Logger.Errorw("failed to verify TOTP - failed to get TOTP", "error", err)
return err
}
// verify the token
u.Logger.Debug("verifying TOTP")
if u.TOTPReplayCache.isUsed(userID.String(), token.String()) {
u.Logger.Debug("failed to verify TOTP - token already used")
return errs.ErrUserWrongTOTP
}
valid := totp.Validate(token.String(), secret)
if !valid {
u.Logger.Debug("failed to verify TOTP - invalid token")
return errs.ErrUserWrongTOTP
}
u.TOTPReplayCache.markUsed(userID.String(), token.String())
u.Logger.Debugw("Enabling MFA TOTP for user", "userID", userID)
// enable TOTP
err = u.UserRepository.EnableTOTP(
ctx,
&userID,
)
if err != nil {
u.Logger.Errorw("failed to verify TOTP - failed to enable TOTP", "error", err)
return err
}
u.AuditLogAuthorized(ae)
return nil
}
// IsTOTPEnabled checks if TOTP is enabled
func (u *User) IsTOTPEnabled(
ctx context.Context,
session *model.Session,
) (bool, error) {
ae := NewAuditEvent("User.IsTOTPEnabled", session)
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return false, errs.Wrap(err)
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return false, errs.ErrAuthorizationFailed
}
// get user from session
user := session.User
if user == nil {
u.Logger.Error("user is not loaded in session")
return false, errors.New("user is not loaded in session")
}
// check if TOTP is enabled
userID := user.ID.MustGet()
enabled, err := u.UserRepository.IsTOTPEnabled(
ctx,
&userID,
)
if err != nil {
u.Logger.Errorw("failed to check if TOTP is enabled", "error", err)
return false, errs.Wrap(err)
}
// no audit on read
return enabled, nil
}
// IsTOTPEnabledByUserID checks if TOTP is enabled by user ID
// this method had no auth check, use with consideration
func (u *User) IsTOTPEnabledByUserID(
ctx context.Context,
userID *uuid.UUID,
) (bool, error) {
// check if TOTP is enabled
enabled, err := u.UserRepository.IsTOTPEnabled(
ctx,
userID,
)
if err != nil {
u.Logger.Errorw("failed to check if TOTP is enabled", "error", err)
return false, errs.Wrap(err)
}
return enabled, nil
}
// DisableTOTP disables TOTP
// without checking if the user privilige, use with consideration
// session may be nil when called during a pre session flow such as
// login with a recovery code
func (u *User) DisableTOTP(
ctx context.Context,
session *model.Session,
userID *uuid.UUID,
) error {
ae := NewAuditEvent("User.DisableTOTP", session)
ae.Details["userId"] = userID.String()
err := u.UserRepository.RemoveTOTP(
ctx,
userID,
)
if err != nil {
u.Logger.Errorw("failed to disable TOTP", "error", err)
return err
}
u.AuditLogAuthorized(ae)
return nil
}
// CheckTOTP verifies a TOTP token
func (u *User) CheckTOTP(
ctx context.Context,
userID *uuid.UUID,
token *vo.String64,
) error {
if u.TOTPReplayCache.isUsed(userID.String(), token.String()) {
u.Logger.Debug("failed to verify TOTP - token already used")
return errs.ErrUserWrongTOTP
}
// get the secret
secret, _, err := u.UserRepository.GetTOTP(
ctx,
userID,
)
if err != nil {
u.Logger.Errorw("failed to verify TOTP - failed to get TOTP", "error", err)
return err
}
// verify the token
valid := totp.Validate(token.String(), secret)
if !valid {
u.Logger.Debug("failed to verify TOTP - invalid token")
return errs.ErrUserWrongTOTP
}
u.TOTPReplayCache.markUsed(userID.String(), token.String())
return nil
}
// AuthenticateUsernameWithPassword tests a username and password is correct
func (u *User) AuthenticateUsernameWithPassword(
ctx context.Context,
username string,
passwd string,
ip string,
) (*model.User, error) {
ae := NewAuditEvent("User.AuthenticateUsernameWithPassword", nil)
ae.IP = ip
ae.Details["username"] = username
// check the entities are valid before doing anything
usernameEntity, err := vo.NewUsername(username)
if err != nil {
u.Logger.Debugw("failed to authenticate - invalid username", "error", err)
return nil, errs.Wrap(err)
}
passwordEntity, err := vo.NewReasonableLengthPassword(passwd)
if err != nil {
u.Logger.Debugw("failed to authenticate - invalid password", "error", err)
return nil, errs.Wrap(err)
}
// retrieve only the password hash to minimize the timing attack window compared to
// pulling the user with all relations
passwordHash, err := u.UserRepository.GetPasswordHashByUsername(
ctx,
usernameEntity,
)
errIsRecordNotFound := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !errIsRecordNotFound {
u.Logger.Errorw("failed to authenticate - failed to get password hash", "error", err)
return nil, errs.Wrap(err)
}
// if user not found - we compare with a fake fakePass hash to mitigate risk of timing attacks
// or if the user is a SSO user
if errIsRecordNotFound || len(passwordHash) == 0 {
_, err = u.PasswordVerifier.Verify(passwd, password.DummyHash)
if err != nil {
u.Logger.Debugw("failed to verify dummy hash", "error", err)
}
return nil, gorm.ErrRecordNotFound
}
// veriy the hash in a constant time manner
verified, err := u.PasswordVerifier.Verify(passwordEntity.String(), passwordHash)
if err != nil {
u.Logger.Errorw("failed to verify password hash", "error", err)
return nil, errs.Wrap(err)
}
// if the password is not verifed, log it and return the error
if !verified {
u.AuditLogNotAuthorized(ae)
return nil, errs.ErrUserWrongPasword
}
// on successful login, retrieve the user with relations and send it back
user, err := u.UserRepository.GetByUsername(
ctx,
usernameEntity,
&repository.UserOption{
WithRole: true,
WithCompany: true,
},
)
if err != nil {
u.Logger.Errorw("failed to get user by username after verifying login", "error", err)
return nil, errs.Wrap(err)
}
u.AuditLogAuthorized(ae)
return user, nil
}
// CheckPassword checks if a password is correct
func (u *User) CheckPassword(
ctx context.Context,
username *vo.Username,
password *vo.ReasonableLengthPassword,
) (bool, error) {
passwordHash, err := u.UserRepository.GetPasswordHashByUsername(
ctx,
username,
)
if err != nil {
u.Logger.Errorw("failed to check password - failed to get password hash", "error", err)
return false, errs.Wrap(err)
}
verified, err := u.PasswordVerifier.Verify(password.String(), passwordHash)
if err != nil {
u.Logger.Errorw("failed to check password - failed to verify hash", "error", err)
return false, errs.Wrap(err)
}
return verified, nil
}
// ChangePassword changes a user's password
func (u *User) ChangePassword(
ctx context.Context,
session *model.Session,
currentPassword *vo.ReasonableLengthPassword,
newPassword *vo.ReasonableLengthPassword,
) error {
ae := NewAuditEvent("User.ChangePassword", session)
// check if the current password is correct
user := session.User
if user == nil {
u.Logger.Error("user is not loaded in session")
return errors.New("user is not loaded in session")
}
username := user.Username.MustGet()
ae.Details["id"] = user.ID.MustGet().String()
ae.Details["username"] = username.String()
passwordHash, err := u.UserRepository.GetPasswordHashByUsername(
ctx,
&username,
)
if err != nil {
u.Logger.Errorw("failed to change password - failed to get password hash", "error", err)
return err
}
verified, err := u.PasswordVerifier.Verify(currentPassword.String(), passwordHash)
if err != nil {
u.Logger.Errorw("failed to change password - failed to verify hash", "error", err)
return err
}
if !verified {
u.AuditLogNotAuthorized(ae)
return errs.ErrUserWrongPasword
}
// change the password
passwordHash, err = u.PasswordHasher.Hash(newPassword.String())
if err != nil {
u.Logger.Errorw("failed to change password - failed to hash new password", "error", err)
return err
}
err = u.UserRepository.UpdatePasswordHashByUsername(
ctx,
&username,
passwordHash,
)
if err != nil {
u.Logger.Errorw("failed to change password - failed to update password hash", "error", err)
return err
}
u.AuditLogAuthorized(ae)
return nil
}
// ChangeFullname changes a users fullname
func (u *User) ChangeFullname(
ctx context.Context,
session *model.Session,
userID *uuid.UUID,
newFullname *vo.UserFullname,
) (*vo.UserFullname, error) {
ae := NewAuditEvent("User.ChangeFullname", session)
ae.Details["id"] = userID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// change full name
_, err = u.UserRepository.GetByID(ctx, userID, &repository.UserOption{})
if errors.Is(err, gorm.ErrRecordNotFound) {
u.Logger.Debugw("failed to change fullname - user not found", "error", err)
return nil, errs.Wrap(err)
}
err = u.UserRepository.UpdateFullNameByID(
ctx,
userID,
newFullname,
)
if err != nil {
u.Logger.Errorw("failed to change fullname - failed to update fullname", "error", err)
return nil, errs.Wrap(err)
}
u.AuditLogAuthorized(ae)
return newFullname, nil
}
// ChangeEmailAsAdministrator changes a user's email
// changes a users email without validating their email
func (u *User) ChangeEmailAsAdministrator(
ctx context.Context,
session *model.Session,
userID *uuid.UUID,
newEmail *vo.Email,
) (*vo.Email, error) {
ae := NewAuditEvent("User.ChangeEmailAsAdministrator", session)
ae.Details["id"] = userID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
current, err := u.UserRepository.GetByID(ctx, userID, &repository.UserOption{})
if errors.Is(err, gorm.ErrRecordNotFound) {
u.Logger.Debug("failed to change email - user not found")
return nil, errs.Wrap(err)
}
if err != nil {
u.Logger.Debugw("failed to change email - failed to get user by id", "error", err)
return nil, errs.Wrap(err)
}
// update
current.Email.Set(*newEmail)
err = u.UserRepository.UpdateByID(
ctx,
userID,
current,
)
if err != nil {
u.Logger.Errorw("failed to change email - failed to update email", "error", err)
return nil, errs.Wrap(err)
}
u.AuditLogAuthorized(ae)
return newEmail, nil
}
// ChangeUsername changes a user's username
func (u *User) ChangeUsername(
ctx context.Context,
session *model.Session,
userID *uuid.UUID,
newUsername *vo.Username,
) error {
ae := NewAuditEvent("User.ChangeUsername", session)
ae.Details["id"] = userID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
u.LogAuthError(err)
return err
}
if !isAuthorized {
u.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
current, err := u.UserRepository.GetByID(ctx, userID, &repository.UserOption{})
if err != nil {
u.Logger.Debugw("failed to change username - failed to get user by id", "error", err)
return err
}
current.Username.Set(*newUsername)
err = u.UserRepository.UpdateByID(
ctx,
userID,
current,
)
if err != nil {
u.Logger.Errorw("failed to change username - failed to update username", "error", err)
return err
}
u.AuditLogAuthorized(ae)
return nil
}
// CheckMFARecoveryCode checks if a recovery code is valid
// returns true if the recovery code is valid
func (u *User) CheckMFARecoveryCode(
ctx context.Context,
userID *uuid.UUID,
recoveryCode *vo.String64,
) (bool, error) {
dbRecoveryCode, err := u.UserRepository.GetMFARecoveryCode(
ctx,
userID,
)
if err != nil {
u.Logger.Errorw("failed to get recovery code", "error", err)
return false, errs.Wrap(err)
}
if subtle.ConstantTimeCompare([]byte(recoveryCode.String()), []byte(dbRecoveryCode)) != 1 {
u.Logger.Info("invalid recovery code")
return false, errs.ErrUserWrongRecoveryCode
}
return true, nil
}