diff --git a/frontend/src/lib/validations.ts b/frontend/src/lib/validations.ts index 0da2b5b..a958c56 100644 --- a/frontend/src/lib/validations.ts +++ b/frontend/src/lib/validations.ts @@ -12,19 +12,13 @@ const usernameSchema = z 'Username can only contain letters, numbers, underscores, and hyphens' ); -// Password validation: min 8 chars, must contain uppercase, lowercase, number, and symbol +// Password validation: min 8 chars; strength is enforced by the backend +// (entropy-based "password_strength" rule) const passwordSchema = z .string({ message: 'Password must be a string', }) - .min(8, 'Password must be at least 8 characters') - .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') - .regex(/[a-z]/, 'Password must contain at least one lowercase letter') - .regex(/[0-9]/, 'Password must contain at least one number') - .regex( - /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/, - 'Password must contain at least one special character' - ); + .min(8, 'Password must be at least 8 characters'); // Login form schema export const loginSchema = z.object({ @@ -85,51 +79,6 @@ export const accountActionSchema = z }, { message: 'New password must be at least 8 characters', path: ['newPassword'] } ) - .refine( - (data) => { - if (data.newPassword && data.newPassword.length > 0) { - return /[A-Z]/.test(data.newPassword); - } - return true; - }, - { - message: 'New password must contain at least one uppercase letter', - path: ['newPassword'], - } - ) - .refine( - (data) => { - if (data.newPassword && data.newPassword.length > 0) { - return /[a-z]/.test(data.newPassword); - } - return true; - }, - { - message: 'New password must contain at least one lowercase letter', - path: ['newPassword'], - } - ) - .refine( - (data) => { - if (data.newPassword && data.newPassword.length > 0) { - return /[0-9]/.test(data.newPassword); - } - return true; - }, - { message: 'New password must contain at least one number', path: ['newPassword'] } - ) - .refine( - (data) => { - if (data.newPassword && data.newPassword.length > 0) { - return /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(data.newPassword); - } - return true; - }, - { - message: 'New password must contain at least one special character', - path: ['newPassword'], - } - ) .refine( (data) => { if (data.newPassword && data.newPassword.length > 0) { diff --git a/go.mod b/go.mod index 74c2b31..ac9e868 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/google/uuid v1.6.0 github.com/klauspost/reedsolomon v1.13.3 github.com/lib/pq v1.12.3 + github.com/wagslane/go-password-validator v0.3.0 github.com/wailsapp/wails/v2 v2.11.0 github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.48.0 diff --git a/go.sum b/go.sum index 2667468..8b4b196 100644 --- a/go.sum +++ b/go.sum @@ -115,6 +115,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I= +github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ= github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= diff --git a/internal/features/auth/service.go b/internal/features/auth/service.go index 3caf407..ecf8513 100644 --- a/internal/features/auth/service.go +++ b/internal/features/auth/service.go @@ -14,8 +14,14 @@ import ( "ayo/internal/shared/errors" "github.com/go-playground/validator/v10" + passwordvalidator "github.com/wagslane/go-password-validator" ) +// minPasswordEntropy is the entropy floor, in bits, that a password must meet to +// pass the "password_strength" validation rule. It is a policy decision that the +// settings feature could later expose to the user. +const minPasswordEntropy = 70.0 + // Session holds the in-memory state of the currently signed-in user. It is the // desktop-app equivalent of an auth cookie: it only exists for the lifetime of // the running process and is lost on app restart (the frontend re-checks it on @@ -86,22 +92,27 @@ func validateUsernameFormat(fl validator.FieldLevel) bool { return usernameRegex.MatchString(fl.Field().String()) } -// validatePasswordStrength enforces that a password contains at least one -// uppercase letter, one lowercase letter, one digit and one symbol. It is -// registered as the "password_strength" validator rule. +// validatePasswordStrength enforces that a password has at least +// minPasswordEntropy bits of Shannon entropy. It is registered as the +// "password_strength" validator rule. func validatePasswordStrength(fl validator.FieldLevel) bool { - password := fl.Field().String() - - // Check for at least one uppercase letter - hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password) - // Check for at least one lowercase letter - hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) - // Check for at least one digit - hasDigit := regexp.MustCompile(`[0-9]`).MatchString(password) - // Check for at least one special character - hasSymbol := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) - - return hasUpper && hasLower && hasDigit && hasSymbol + return passwordvalidator.Validate(fl.Field().String(), minPasswordEntropy) == nil +} + +// passwordValidationError maps a struct validation failure to a user-facing +// error, returning the specific ErrWeakPassword when a password failed the +// entropy-based "password_strength" rule and the generic ErrInvalidInput for +// any other validation problem. +func passwordValidationError(err error) error { + var vErrs validator.ValidationErrors + if stderrors.As(err, &vErrs) { + for _, fe := range vErrs { + if fe.Tag() == "password_strength" { + return errors.ErrWeakPassword + } + } + } + return errors.ErrInvalidInput } // NewService wires a shared connection holder, a migration runner, the @@ -134,7 +145,7 @@ func NewService(conn *dbclient.Connection, migrationRunner dbclient.MigrationRun // must be shown to the user) exactly once. func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { if err := s.validate.Struct(input); err != nil { - return nil, errors.ErrInvalidInput + return nil, passwordValidationError(err) } if err := dbclient.ValidateConfig(input.DBConfig); err != nil { return nil, err @@ -375,7 +386,7 @@ func (s *Service) Login(input LoginInput) (bool, error) { // shown to the user exactly once. func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, error) { if err := s.validate.Struct(input); err != nil { - return nil, errors.ErrInvalidInput + return nil, passwordValidationError(err) } recoveryKeyBytes := []byte(input.RecoveryKey) diff --git a/internal/shared/errors/errors.go b/internal/shared/errors/errors.go index 637a83a..dba79ad 100644 --- a/internal/shared/errors/errors.go +++ b/internal/shared/errors/errors.go @@ -23,6 +23,13 @@ var ( "the information you provided is incomplete or invalid. Please review your input and try again", ) + // ErrWeakPassword means the password did not meet the minimum entropy + // threshold. It is returned instead of the generic ErrInvalidInput so the + // user knows exactly why their input was rejected. + ErrWeakPassword = errors.New( + "your password is too weak. Use a longer password or mix in numbers and symbols", + ) + // ErrUserAlreadyExists means the requested username is already taken. ErrUserAlreadyExists = errors.New( "an account with this username already exists. Please choose a different username or sign in instead",