168 lines
4.8 KiB
Go
168 lines
4.8 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"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})
|
|
}
|
|
|
|
/*
|
|
Example 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))
|
|
}
|
|
|
|
/*
|
|
Example client-side check (without input normalization):
|
|
|
|
```
|
|
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
|
|
}
|
|
|
|
// Parse `limit` and `offset` 32-bit integer URL parameters from the given request. Defaults to
|
|
// limit of 50 and offset 0 if parameters are missing/invalid.
|
|
func getPaginationParams(r *http.Request) (limit int32, offset int32) {
|
|
defaultLimit := 50
|
|
defaultOffset := 0
|
|
|
|
limitStr := r.URL.Query().Get("limit")
|
|
if limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
|
defaultLimit = l
|
|
}
|
|
}
|
|
|
|
offsetStr := r.URL.Query().Get("offset")
|
|
if offsetStr != "" {
|
|
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
|
defaultOffset = o
|
|
}
|
|
}
|
|
|
|
return int32(defaultLimit), int32(defaultOffset)
|
|
}
|
|
|
|
// Concatenate the title and content strings, calculate a SHA-1 hash of the resulting string, and
|
|
// return the resulting hash as a string.
|
|
func sha1ContentHash(title, content string) string {
|
|
hashContent := title + content
|
|
hash := sha1.Sum([]byte(hashContent))
|
|
hashStr := strings.ToUpper(hex.EncodeToString(hash[:]))
|
|
|
|
return hashStr
|
|
}
|