Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 3 additions & 54 deletions frontend/src/lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
45 changes: 28 additions & 17 deletions internal/features/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions internal/shared/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading