mirror of
https://github.com/rjNemo/auth
synced 2026-06-06 00:16:40 +00:00
feat: enforce password complexity
This commit is contained in:
parent
27e819ecba
commit
b0399f4109
8 changed files with 104 additions and 2 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
const (
|
||||
seedEmail = "user@example.com"
|
||||
seedPassword = "password123"
|
||||
seedPassword = "Password123"
|
||||
)
|
||||
|
||||
// Server holds HTTP dependencies for the application.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
50
internal/service/auth/password_test.go
Normal file
50
internal/service/auth/password_test.go
Normal 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ø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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue