ae 62b1a58e56
feat!: trimming & logic/schema improvements
- build: somewhat polished dockerization setup
- build: io/fs migrations with `golang-migrate`
- feat: automatic init. admin account creation (.env creds)
- feat(routers): combined user & token routers into single auth router
- feat(routers): improved route layouts (`Routes`)
- feat(middlewares): removed redundant `userCtx` middleware
- fix(schema): note <-> note_versions relation (versioning)
- feat(queries): removed redundant rollback functionality
- feat(queries): combined duplicate version check & insertion/creation
- tests: decreased redundancy by removing 'unnecessary' unit tests
- refactor: hid internal packages behind `server/internal`
- docs: notes & auth handler comments
2025-04-09 01:58:38 +03:00

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})
}
/*
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
}