134 lines
3.8 KiB
Go

package service
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"unicode/utf8"
passwordvalidator "github.com/wagslane/go-password-validator"
)
const (
minPasswordLength = 12 // Entropy checks prevent short passwords anyway
maxPasswordLength = 72 // Limitation of bcrypt
minPasswordEntropy = 60.0
minUsernameLength = 3
maxUsernameLength = 20
hibpAPI = "https://api.pwnedpasswords.com/range" // Doesn't require an API key
)
var (
usernameRegex = regexp.MustCompile("^[a-z0-9_]+$")
)
func respondJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}
/*
Client-side check:
```
function estimateEntropy(password: string): number {
const pool: number = getCharsetSize(password); // Character diversity (R)
const entropy: number = password.length * Math.log2(pool); // E = L * log_2(R)
return entropy; // Value (E) that can be compared against a hardcoded threshold (e.g. 60)
}
```
*/
// Validate the given password using a hybrid approach: length (max. set due to bcrypt's input
// upper limit of 72 bytes), entropy, and HIBP API.
func validatePassword(password string) error {
if len(password) < minPasswordLength {
return fmt.Errorf("password must be at least %d characters", minPasswordLength)
}
if len(password) > maxPasswordLength {
return fmt.Errorf("password cannot be longer than %d characters", maxPasswordLength)
}
// Formatted error message will contain tips to increase the password strength (safe to show)
err := passwordvalidator.Validate(password, minPasswordEntropy)
if err != nil {
return err
}
if compromised, _ := isPasswordCompromised(password); compromised {
return errors.New("password is compromised")
}
return nil
}
// Send the first five bytes of the password's SHA-1 hash to HIBP API, then check if the rest of
// the hash is present in the APi's response data (k-Anonymity model).
func isPasswordCompromised(password string) (bool, error) {
hash := sha1.Sum([]byte(password))
hashStr := strings.ToUpper(hex.EncodeToString(hash[:]))
prefix, suffix := hashStr[:5], hashStr[5:]
resp, err := http.Get(fmt.Sprintf("%s/%s", hibpAPI, prefix))
if err != nil {
return false, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return false, err
}
return strings.Contains(strings.ToUpper(string(body)), suffix), nil
}
// Normalize the username by making it lowercase and trimming any leading or trailing whitespace.
func normalizeUsername(username string) string {
return strings.ToLower(strings.TrimSpace(username))
}
/*
Client-side check (additionally input should automatically perform the normalization steps):
```
function validateUsername(username: string): string {
const min: number = 3, max: number = 20;
if (username.length < min) return "Too short";
if (username.length > max) return "Too long";
if (!/^[a-zA-Z0-9_]+$/.test(username)) return "Invalid characters";
return "Valid";
}
```
*/
// Validate the given username by making sure it only contains alphanumeric characters or
// underscores and adheres the hardcoded minimum and maximum length rules.
func validateUsername(username string) error {
if utf8.RuneCountInString(username) < minUsernameLength {
return fmt.Errorf("username must be at least %d characters", minUsernameLength)
}
if utf8.RuneCountInString(username) > maxUsernameLength {
return fmt.Errorf("username cannot be longer than %d characters", maxUsernameLength)
}
if !usernameRegex.MatchString(username) {
return errors.New("username can only contain numbers, letters, and underscores")
}
return nil
}