Fix F-08, F-12, F-13: Implement account lockout, username validation, and password minimum length enforcement
- Added failed login tracking for account lockout enforcement in `db` and `ui` layers; introduced `failed_logins` table to store attempts, window start, and attempt count. - Updated login checks in `grpcserver/auth.go` and `ui/handlers_auth.go` to reject requests if the account is locked. - Added immediate failure counter reset on successful login. - Implemented username length and character set validation (F-12) and minimum password length enforcement (F-13) in shared `validate` package. - Updated account creation and edit flows in `ui` and `grpcserver` layers to apply validation before hashing/processing. - Added comprehensive unit tests for lockout, validation, and related edge cases. - Updated `AUDIT.md` to mark F-08, F-12, and F-13 as fixed. - Updated `openapi.yaml` to reflect new validation and lockout behaviors. Security: Prevents brute-force attacks via lockout mechanism and strengthens defenses against weak and invalid input.
This commit is contained in:
55
internal/validate/validate.go
Normal file
55
internal/validate/validate.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Package validate provides shared input-validation helpers used by the REST,
|
||||
// gRPC, and UI handlers.
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// usernameRE is the allowed character set for usernames: alphanumeric plus a
|
||||
// small set of punctuation that is safe in all contexts (URLs, HTML, logs).
|
||||
// Length is enforced separately so the error message can be more precise.
|
||||
//
|
||||
// Security (F-12): rejecting control characters, null bytes, newlines, and
|
||||
// unusual Unicode prevents log injection, stored-XSS via username display,
|
||||
// and rendering anomalies in the admin UI.
|
||||
var usernameRE = regexp.MustCompile(`^[a-zA-Z0-9._@-]+$`)
|
||||
|
||||
// MinUsernameLen and MaxUsernameLen are the inclusive bounds on username length.
|
||||
const (
|
||||
MinUsernameLen = 1
|
||||
MaxUsernameLen = 255
|
||||
)
|
||||
|
||||
// Username returns nil if the username is valid, or a descriptive error if not.
|
||||
// Valid usernames are 1–255 characters long and contain only alphanumeric
|
||||
// characters and the symbols . _ @ -
|
||||
func Username(username string) error {
|
||||
l := len(username)
|
||||
if l < MinUsernameLen || l > MaxUsernameLen {
|
||||
return fmt.Errorf("username must be between %d and %d characters", MinUsernameLen, MaxUsernameLen)
|
||||
}
|
||||
if !usernameRE.MatchString(username) {
|
||||
return fmt.Errorf("username may only contain letters, digits, and the characters . _ @ -")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MinPasswordLen is the minimum acceptable plaintext password length.
|
||||
//
|
||||
// Security (F-13): NIST SP 800-63B recommends a minimum of 8 characters;
|
||||
// we use 12 to provide additional margin against offline brute-force attacks
|
||||
// even though Argon2id is expensive. The check is performed at the handler
|
||||
// level (before hashing) so Argon2id is never invoked with a trivially weak
|
||||
// password.
|
||||
const MinPasswordLen = 12
|
||||
|
||||
// Password returns nil if the plaintext password meets the minimum length
|
||||
// requirement, or a descriptive error if not.
|
||||
func Password(password string) error {
|
||||
if len(password) < MinPasswordLen {
|
||||
return fmt.Errorf("password must be at least %d characters", MinPasswordLen)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
72
internal/validate/validate_test.go
Normal file
72
internal/validate/validate_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordValid(t *testing.T) {
|
||||
valid := []string{
|
||||
strings.Repeat("a", MinPasswordLen),
|
||||
strings.Repeat("a", MinPasswordLen+1),
|
||||
"correct horse battery staple",
|
||||
"P@ssw0rd!2024XY",
|
||||
}
|
||||
for _, p := range valid {
|
||||
if err := Password(p); err != nil {
|
||||
t.Errorf("Password(%q) = %v, want nil", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordTooShort(t *testing.T) {
|
||||
short := []string{
|
||||
"",
|
||||
"short",
|
||||
strings.Repeat("a", MinPasswordLen-1),
|
||||
}
|
||||
for _, p := range short {
|
||||
if err := Password(p); err == nil {
|
||||
t.Errorf("Password(%q) = nil, want error", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameValid(t *testing.T) {
|
||||
valid := []string{
|
||||
"alice",
|
||||
"Bob123",
|
||||
"user.name",
|
||||
"user_name",
|
||||
"user-name",
|
||||
"user@domain",
|
||||
"a",
|
||||
strings.Repeat("a", MaxUsernameLen),
|
||||
}
|
||||
for _, u := range valid {
|
||||
if err := Username(u); err != nil {
|
||||
t.Errorf("Username(%q) = %v, want nil", u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameInvalid(t *testing.T) {
|
||||
invalid := []string{
|
||||
"", // empty
|
||||
strings.Repeat("a", MaxUsernameLen+1), // too long
|
||||
"user name", // space
|
||||
"user\tname", // tab
|
||||
"user\nname", // newline
|
||||
"user\x00name", // null byte
|
||||
"user<script>", // angle bracket
|
||||
"user'quote", // single quote
|
||||
"user\"quote", // double quote
|
||||
"user/slash", // slash
|
||||
"user\\backslash", // backslash
|
||||
}
|
||||
for _, u := range invalid {
|
||||
if err := Username(u); err == nil {
|
||||
t.Errorf("Username(%q) = nil, want error", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user