feat: enforce password complexity

This commit is contained in:
Ruidy 2025-09-20 13:50:50 +02:00
parent 27e819ecba
commit b0399f4109
No known key found for this signature in database
GPG key ID: 705C24D202990805
8 changed files with 104 additions and 2 deletions

View file

@ -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))

View file

@ -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))

View file

@ -13,7 +13,7 @@ import (
const (
seedEmail = "user@example.com"
seedPassword = "password123"
seedPassword = "Password123"
)
// Server holds HTTP dependencies for the application.

View file

@ -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) {

View file

@ -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ørd", 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)
}
}

View file

@ -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

View file

@ -19,7 +19,7 @@
<strong>Email:</strong> user@example.com<br /><strong>
Password:
</strong>
password123
Password123
</p>
</article>
{{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"
/>
<button type="submit">Login</button>
</form>

View file

@ -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"
/>
<button type="submit">Create account</button>
</form>