home to your local SPACEGIRL 💫 arimelody.space
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

lock accounts after enough failed login attempts

+153 -13
+46 -3
admin/http.go
··· 274 274 render() 275 275 return 276 276 } 277 + if account.Locked { 278 + controller.SetSessionError(app.DB, session, "This account is locked.") 279 + render() 280 + return 281 + } 277 282 278 283 err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password)) 279 284 if err != nil { 280 285 app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r)) 281 - controller.SetSessionError(app.DB, session, "Invalid username or password.") 286 + if locked := handleFailedLogin(app, account); locked { 287 + controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") 288 + } else { 289 + controller.SetSessionError(app.DB, session, "Invalid username or password.") 290 + } 282 291 render() 283 292 return 284 293 } ··· 299 308 render() 300 309 return 301 310 } 311 + controller.SetSessionMessage(app.DB, session, "") 312 + controller.SetSessionError(app.DB, session, "") 302 313 http.Redirect(w, r, "/admin/totp", http.StatusFound) 303 314 return 304 315 } ··· 377 388 return 378 389 } 379 390 if totpMethod == nil { 380 - app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) 381 - controller.SetSessionError(app.DB, session, "Invalid TOTP.") 391 + app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r)) 392 + if locked := handleFailedLogin(app, session.AttemptAccount); locked { 393 + controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.") 394 + controller.SetSessionAttemptAccount(app.DB, session, nil) 395 + http.Redirect(w, r, "/admin", http.StatusFound) 396 + } else { 397 + controller.SetSessionError(app.DB, session, "Incorrect TOTP.") 398 + } 382 399 render() 383 400 return 384 401 } ··· 496 513 next.ServeHTTP(w, r.WithContext(ctx)) 497 514 }) 498 515 } 516 + 517 + func handleFailedLogin(app *model.AppState, account *model.Account) bool { 518 + locked, err := controller.IncrementAccountFails(app.DB, account.ID) 519 + if err != nil { 520 + fmt.Fprintf( 521 + os.Stderr, 522 + "WARN: Failed to increment login failures for \"%s\": %v\n", 523 + account.Username, 524 + err, 525 + ) 526 + app.Log.Warn( 527 + log.TYPE_ACCOUNT, 528 + "Failed to increment login failures for \"%s\"", 529 + account.Username, 530 + ) 531 + } 532 + if locked { 533 + app.Log.Warn( 534 + log.TYPE_ACCOUNT, 535 + "Account \"%s\" was locked: %d failed login attempts", 536 + account.Username, 537 + model.MAX_LOGIN_FAIL_ATTEMPTS, 538 + ) 539 + } 540 + return locked 541 + }
+23
controller/account.go
··· 110 110 _, err := db.Exec("DELETE FROM account WHERE id=$1", accountID) 111 111 return err 112 112 } 113 + 114 + func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) { 115 + failAttempts := 0 116 + err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID) 117 + if err != nil { return false, err } 118 + locked := false 119 + if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS { 120 + err = LockAccount(db, accountID) 121 + if err != nil { return false, err } 122 + locked = true 123 + } 124 + return locked, err 125 + } 126 + 127 + func LockAccount(db *sqlx.DB, accountID string) error { 128 + _, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID) 129 + return err 130 + } 131 + 132 + func UnlockAccount(db *sqlx.DB, accountID string) error { 133 + _, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID) 134 + return err 135 + }
+5 -1
controller/migrator.go
··· 8 8 "github.com/jmoiron/sqlx" 9 9 ) 10 10 11 - const DB_VERSION int = 3 11 + const DB_VERSION int = 4 12 12 13 13 func CheckDBVersionAndMigrate(db *sqlx.DB) { 14 14 db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody") ··· 44 44 case 2: 45 45 ApplyMigration(db, "002-audit-logs") 46 46 oldDBVersion = 3 47 + 48 + case 3: 49 + ApplyMigration(db, "003-fail-lock") 50 + oldDBVersion = 4 47 51 48 52 } 49 53 }
+64 -2
main.go
··· 276 276 "User: %s\n" + 277 277 "\tID: %s\n" + 278 278 "\tEmail: %s\n" + 279 - "\tCreated: %s\n", 279 + "\tCreated: %s\n" + 280 + "\tLocked: %t\n", 280 281 account.Username, 281 282 account.ID, 282 283 email, 283 284 account.CreatedAt, 285 + account.Locked, 284 286 ) 285 287 } 286 288 return ··· 355 357 fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username) 356 358 return 357 359 360 + case "lockAccount": 361 + if len(os.Args) < 3 { 362 + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n") 363 + os.Exit(1) 364 + } 365 + username := os.Args[2] 366 + fmt.Printf("Unlocking account \"%s\"...\n", username) 367 + 368 + account, err := controller.GetAccountByUsername(app.DB, username) 369 + if err != nil { 370 + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) 371 + os.Exit(1) 372 + } 373 + 374 + if account == nil { 375 + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) 376 + os.Exit(1) 377 + } 378 + 379 + err = controller.LockAccount(app.DB, account.ID) 380 + if err != nil { 381 + fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err) 382 + os.Exit(1) 383 + } 384 + 385 + app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username) 386 + fmt.Printf("Account \"%s\" locked successfully.\n", account.Username) 387 + return 388 + 389 + case "unlockAccount": 390 + if len(os.Args) < 3 { 391 + fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n") 392 + os.Exit(1) 393 + } 394 + username := os.Args[2] 395 + fmt.Printf("Unlocking account \"%s\"...\n", username) 396 + 397 + account, err := controller.GetAccountByUsername(app.DB, username) 398 + if err != nil { 399 + fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err) 400 + os.Exit(1) 401 + } 402 + 403 + if account == nil { 404 + fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username) 405 + os.Exit(1) 406 + } 407 + 408 + err = controller.UnlockAccount(app.DB, account.ID) 409 + if err != nil { 410 + fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err) 411 + os.Exit(1) 412 + } 413 + 414 + app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username) 415 + fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username) 416 + return 417 + 358 418 case "logs": 359 419 // TODO: add log search parameters 360 420 logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0) ··· 389 449 "createInvite:\n\tCreates an invite code to register new accounts.\n" + 390 450 "purgeInvites:\n\tDeletes all available invite codes.\n" + 391 451 "listAccounts:\n\tLists all active accounts.\n", 392 - "deleteAccount <username>:\n\tDeletes an account with a given `username`.\n", 452 + "deleteAccount <username>:\n\tDeletes the account under `username`.\n", 453 + "unlockAccount <username>:\n\tUnlocks the account under `username`.\n", 454 + "logs:\n\tShows system logs.\n", 393 455 ) 394 456 return 395 457 }
+10 -7
model/account.go
··· 6 6 ) 7 7 8 8 const COOKIE_TOKEN string = "AM_SESSION" 9 + const MAX_LOGIN_FAIL_ATTEMPTS int = 3 9 10 10 11 type ( 11 - Account struct { 12 - ID string `json:"id" db:"id"` 13 - Username string `json:"username" db:"username"` 14 - Password string `json:"password" db:"password"` 15 - Email sql.NullString `json:"email" db:"email"` 16 - AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"` 17 - CreatedAt time.Time `json:"created_at" db:"created_at"` 12 + Account struct { 13 + ID string `json:"id" db:"id"` 14 + Username string `json:"username" db:"username"` 15 + Password string `json:"password" db:"password"` 16 + Email sql.NullString `json:"email" db:"email"` 17 + AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"` 18 + CreatedAt time.Time `json:"created_at" db:"created_at"` 19 + FailAttempts int `json:"fail_attempts" db:"fail_attempts"` 20 + Locked bool `json:"locked" db:"locked"` 18 21 19 22 Privileges []AccountPrivilege `json:"privileges"` 20 23 }
+2
schema-migration/000-init.sql
··· 19 19 email TEXT, 20 20 avatar_url TEXT, 21 21 created_at TIMESTAMP NOT NULL DEFAULT current_timestamp 22 + fail_attempts INT NOT NULL DEFAULT 0, 23 + locked BOOLEAN DEFAULT false, 22 24 ); 23 25 ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id); 24 26
+3
schema-migration/003-fail-lock.sql
··· 1 + -- it would be nice to prevent brute-forcing 2 + ALTER TABLE arimelody.account ADD COLUMN fail_attempts INT NOT NULL DEFAULT 0; 3 + ALTER TABLE arimelody.account ADD COLUMN locked BOOLEAN DEFAULT false;