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:
@@ -15,6 +15,7 @@ import (
|
||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/validate"
|
||||
)
|
||||
|
||||
type accountServiceServer struct {
|
||||
@@ -58,8 +59,9 @@ func (a *accountServiceServer) CreateAccount(ctx context.Context, req *mciasv1.C
|
||||
if err := a.s.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Username == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "username is required")
|
||||
// Security (F-12): validate username length and character set.
|
||||
if err := validate.Username(req.Username); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
accountType := model.AccountType(req.AccountType)
|
||||
if accountType != model.AccountTypeHuman && accountType != model.AccountTypeSystem {
|
||||
@@ -71,6 +73,10 @@ func (a *accountServiceServer) CreateAccount(ctx context.Context, req *mciasv1.C
|
||||
if req.Password == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "password is required for human accounts")
|
||||
}
|
||||
// Security (F-13): enforce minimum length before hashing.
|
||||
if err := validate.Password(req.Password); err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
var err error
|
||||
passwordHash, err = auth.HashPassword(req.Password, auth.ArgonParams{
|
||||
Time: a.s.cfg.Argon2.Time,
|
||||
|
||||
@@ -52,15 +52,28 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
||||
}
|
||||
|
||||
// Security: check per-account lockout before running Argon2 (F-08).
|
||||
locked, lockErr := a.s.db.IsLockedOut(acct.ID)
|
||||
if lockErr != nil {
|
||||
a.s.logger.Error("lockout check", "error", lockErr)
|
||||
}
|
||||
if locked {
|
||||
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
|
||||
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"account_locked"}`) //nolint:errcheck
|
||||
return nil, status.Error(codes.ResourceExhausted, "account temporarily locked")
|
||||
}
|
||||
|
||||
ok, err := auth.VerifyPassword(req.Password, acct.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"wrong_password"}`) //nolint:errcheck
|
||||
_ = a.s.db.RecordLoginFailure(acct.ID)
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
||||
}
|
||||
|
||||
if acct.TOTPRequired {
|
||||
if req.TotpCode == "" {
|
||||
a.s.db.WriteAuditEvent(model.EventLoginFail, &acct.ID, nil, ip, `{"reason":"totp_missing"}`) //nolint:errcheck
|
||||
_ = a.s.db.RecordLoginFailure(acct.ID)
|
||||
return nil, status.Error(codes.Unauthenticated, "TOTP code required")
|
||||
}
|
||||
secret, err := crypto.OpenAESGCM(a.s.masterKey, acct.TOTPSecretNonce, acct.TOTPSecretEnc)
|
||||
@@ -71,10 +84,14 @@ func (a *authServiceServer) Login(ctx context.Context, req *mciasv1.LoginRequest
|
||||
valid, err := auth.ValidateTOTP(secret, req.TotpCode)
|
||||
if err != nil || !valid {
|
||||
a.s.db.WriteAuditEvent(model.EventLoginTOTPFail, &acct.ID, nil, ip, `{"reason":"wrong_totp"}`) //nolint:errcheck
|
||||
_ = a.s.db.RecordLoginFailure(acct.ID)
|
||||
return nil, status.Error(codes.Unauthenticated, "invalid credentials")
|
||||
}
|
||||
}
|
||||
|
||||
// Login succeeded: clear any outstanding failure counter.
|
||||
_ = a.s.db.ClearLoginFailures(acct.ID)
|
||||
|
||||
expiry := a.s.cfg.DefaultExpiry()
|
||||
roles, err := a.s.db.GetRoles(acct.ID)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user