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