diff --git a/internal/server/handler_login.go b/internal/server/handler_login.go index a7a6130..dc12e5e 100644 --- a/internal/server/handler_login.go +++ b/internal/server/handler_login.go @@ -48,6 +48,9 @@ func (s *Server) loginHandler() http.HandlerFunc { } http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + case errors.Is(err, auth.ErrWeakPassword): + w.WriteHeader(http.StatusBadRequest) + s.render(w, "login.html", newLoginData(email.String(), weakPasswordMsg, state.CSRFToken)) case errors.Is(err, auth.ErrInvalidInput): w.WriteHeader(http.StatusBadRequest) s.render(w, "login.html", newLoginData(email.String(), credentialRequiredMsg, state.CSRFToken)) diff --git a/internal/server/handler_signup.go b/internal/server/handler_signup.go index a7ebab7..5ab006a 100644 --- a/internal/server/handler_signup.go +++ b/internal/server/handler_signup.go @@ -12,6 +12,7 @@ const ( credentialRequiredMsg = "Email and password are required." invalidCredentialsMsg = "Invalid credentials." duplicateEmailMsg = "An account with that email already exists." + weakPasswordMsg = "Password must be at least 8 characters, include an uppercase letter, and contain a number." ) func (s *Server) signupPageHandler() http.HandlerFunc { @@ -55,6 +56,9 @@ func (s *Server) signupHandler() http.HandlerFunc { log.Printf("session: save failed: %v", err) } http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + case errors.Is(err, auth.ErrWeakPassword): + w.WriteHeader(http.StatusBadRequest) + s.render(w, "signup.html", newSignupData(email.String(), weakPasswordMsg, state.CSRFToken)) case errors.Is(err, auth.ErrInvalidInput): w.WriteHeader(http.StatusBadRequest) s.render(w, "signup.html", newSignupData(email.String(), credentialRequiredMsg, state.CSRFToken)) diff --git a/internal/server/server.go b/internal/server/server.go index d31fae9..aaecedb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -13,7 +13,7 @@ import ( const ( seedEmail = "user@example.com" - seedPassword = "password123" + seedPassword = "Password123" ) // Server holds HTTP dependencies for the application. diff --git a/internal/service/auth/password.go b/internal/service/auth/password.go index 9acee87..1b626d3 100644 --- a/internal/service/auth/password.go +++ b/internal/service/auth/password.go @@ -5,11 +5,46 @@ import ( "crypto/sha256" "crypto/subtle" "encoding/base64" + "errors" "fmt" "log" + "unicode" + "unicode/utf8" ) const saltLen = 32 +const passwordMinLength = 8 + +var ErrWeakPassword = errors.New("auth: password does not meet complexity requirements") + +// ValidatePassword ensures a password satisfies baseline complexity rules. +func ValidatePassword(password string) error { + if utf8.RuneCountInString(password) < passwordMinLength { + return fmt.Errorf("%w: minimum length %d", ErrWeakPassword, passwordMinLength) + } + + var hasUpper, hasDigit bool + for _, r := range password { + if unicode.IsUpper(r) { + hasUpper = true + } + if unicode.IsDigit(r) { + hasDigit = true + } + if hasUpper && hasDigit { + break + } + } + + if !hasUpper { + return fmt.Errorf("%w: missing uppercase letter", ErrWeakPassword) + } + if !hasDigit { + return fmt.Errorf("%w: missing numeric character", ErrWeakPassword) + } + + return nil +} // HashPassword returns a base64-encoded salt and hash for the provided plaintext. func HashPassword(plain string) (salt string, hash string, err error) { diff --git a/internal/service/auth/password_test.go b/internal/service/auth/password_test.go new file mode 100644 index 0000000..02962c9 --- /dev/null +++ b/internal/service/auth/password_test.go @@ -0,0 +1,50 @@ +package auth + +import ( + "errors" + "testing" +) + +func TestValidatePassword(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + password string + wantErr bool + }{ + "valid": {password: "Password1", wantErr: false}, + "too short": {password: "Pw1", wantErr: true}, + "missing upper": {password: "password1", wantErr: true}, + "missing digit": {password: "Password", wantErr: true}, + "unicode upper": {password: "Åpple9xY", wantErr: false}, + "unicode digit": {password: "Passwørd7", wantErr: false}, + } + + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + err := ValidatePassword(tc.password) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + if !errors.Is(err, ErrWeakPassword) { + t.Fatalf("expected ErrWeakPassword, got %v", err) + } + } else if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestValidatePasswordBoundary(t *testing.T) { + t.Parallel() + + // Exactly eight characters, meets other requirements. + if err := ValidatePassword("Passw0rd"); err != nil { + t.Fatalf("expected password to be valid, got %v", err) + } +} diff --git a/internal/service/auth/service.go b/internal/service/auth/service.go index 9ea111c..0c0a88e 100644 --- a/internal/service/auth/service.go +++ b/internal/service/auth/service.go @@ -33,6 +33,9 @@ func (s *Service) Authenticate(ctx context.Context, email UserEmail, password st if email.IsZero() || password == "" { return nil, ErrInvalidInput } + if err := ValidatePassword(password); err != nil { + return nil, err + } account, err := s.store.FindByEmail(ctx, email) if err != nil { @@ -63,6 +66,9 @@ func (s *Service) Register(ctx context.Context, email UserEmail, password string if email.IsZero() || password == "" { return nil, ErrInvalidInput } + if err := ValidatePassword(password); err != nil { + return nil, err + } if existing, err := s.store.FindByEmail(ctx, email); err == nil && existing != nil { return nil, ErrEmailExists diff --git a/web/templates/login.html b/web/templates/login.html index 011f008..f20f373 100644 --- a/web/templates/login.html +++ b/web/templates/login.html @@ -19,7 +19,7 @@ Email: user@example.com
Password: - password123 + Password123

{{if .Error}} @@ -46,6 +46,8 @@ name="password" placeholder="Password" required + pattern="(?=.*[A-Z])(?=.*\d).{8,}" + title="At least 8 characters including one uppercase letter and one number" /> diff --git a/web/templates/signup.html b/web/templates/signup.html index e9e084f..e796462 100644 --- a/web/templates/signup.html +++ b/web/templates/signup.html @@ -37,6 +37,8 @@ name="password" placeholder="Choose a password" required + pattern="(?=.*[A-Z])(?=.*\d).{8,}" + title="At least 8 characters including one uppercase letter and one number" />