mirror of
https://github.com/rjNemo/auth
synced 2026-06-12 11:26:39 +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)
|
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):
|
case errors.Is(err, auth.ErrInvalidInput):
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
s.render(w, "login.html", newLoginData(email.String(), credentialRequiredMsg, state.CSRFToken))
|
s.render(w, "login.html", newLoginData(email.String(), credentialRequiredMsg, state.CSRFToken))
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const (
|
||||||
credentialRequiredMsg = "Email and password are required."
|
credentialRequiredMsg = "Email and password are required."
|
||||||
invalidCredentialsMsg = "Invalid credentials."
|
invalidCredentialsMsg = "Invalid credentials."
|
||||||
duplicateEmailMsg = "An account with that email already exists."
|
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 {
|
func (s *Server) signupPageHandler() http.HandlerFunc {
|
||||||
|
|
@ -55,6 +56,9 @@ func (s *Server) signupHandler() http.HandlerFunc {
|
||||||
log.Printf("session: save failed: %v", err)
|
log.Printf("session: save failed: %v", err)
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
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):
|
case errors.Is(err, auth.ErrInvalidInput):
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
s.render(w, "signup.html", newSignupData(email.String(), credentialRequiredMsg, state.CSRFToken))
|
s.render(w, "signup.html", newSignupData(email.String(), credentialRequiredMsg, state.CSRFToken))
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
seedEmail = "user@example.com"
|
seedEmail = "user@example.com"
|
||||||
seedPassword = "password123"
|
seedPassword = "Password123"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server holds HTTP dependencies for the application.
|
// Server holds HTTP dependencies for the application.
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,46 @@ import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
const saltLen = 32
|
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.
|
// HashPassword returns a base64-encoded salt and hash for the provided plaintext.
|
||||||
func HashPassword(plain string) (salt string, hash string, err error) {
|
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 == "" {
|
if email.IsZero() || password == "" {
|
||||||
return nil, ErrInvalidInput
|
return nil, ErrInvalidInput
|
||||||
}
|
}
|
||||||
|
if err := ValidatePassword(password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
account, err := s.store.FindByEmail(ctx, email)
|
account, err := s.store.FindByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -63,6 +66,9 @@ func (s *Service) Register(ctx context.Context, email UserEmail, password string
|
||||||
if email.IsZero() || password == "" {
|
if email.IsZero() || password == "" {
|
||||||
return nil, ErrInvalidInput
|
return nil, ErrInvalidInput
|
||||||
}
|
}
|
||||||
|
if err := ValidatePassword(password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if existing, err := s.store.FindByEmail(ctx, email); err == nil && existing != nil {
|
if existing, err := s.store.FindByEmail(ctx, email); err == nil && existing != nil {
|
||||||
return nil, ErrEmailExists
|
return nil, ErrEmailExists
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
<strong>Email:</strong> user@example.com<br /><strong>
|
<strong>Email:</strong> user@example.com<br /><strong>
|
||||||
Password:
|
Password:
|
||||||
</strong>
|
</strong>
|
||||||
password123
|
Password123
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
|
|
@ -46,6 +46,8 @@
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
required
|
required
|
||||||
|
pattern="(?=.*[A-Z])(?=.*\d).{8,}"
|
||||||
|
title="At least 8 characters including one uppercase letter and one number"
|
||||||
/>
|
/>
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Choose a password"
|
placeholder="Choose a password"
|
||||||
required
|
required
|
||||||
|
pattern="(?=.*[A-Z])(?=.*\d).{8,}"
|
||||||
|
title="At least 8 characters including one uppercase letter and one number"
|
||||||
/>
|
/>
|
||||||
<button type="submit">Create account</button>
|
<button type="submit">Create account</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue