lock accounts after enough failed login attempts
This commit is contained in:
parent
5cc9a1ca76
commit
1c0e541c89
7 changed files with 153 additions and 13 deletions
|
@ -274,11 +274,20 @@ func loginHandler(app *model.AppState) http.Handler {
|
|||
render()
|
||||
return
|
||||
}
|
||||
if account.Locked {
|
||||
controller.SetSessionError(app.DB, session, "This account is locked.")
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
|
||||
if err != nil {
|
||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
|
||||
if locked := handleFailedLogin(app, account); locked {
|
||||
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
||||
} else {
|
||||
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
||||
}
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
@ -299,6 +308,8 @@ func loginHandler(app *model.AppState) http.Handler {
|
|||
render()
|
||||
return
|
||||
}
|
||||
controller.SetSessionMessage(app.DB, session, "")
|
||||
controller.SetSessionError(app.DB, session, "")
|
||||
http.Redirect(w, r, "/admin/totp", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
@ -377,8 +388,14 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
|
|||
return
|
||||
}
|
||||
if totpMethod == nil {
|
||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
||||
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
|
||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
||||
if locked := handleFailedLogin(app, session.AttemptAccount); locked {
|
||||
controller.SetSessionError(app.DB, session, "Too many failed attempts. This account is now locked.")
|
||||
controller.SetSessionAttemptAccount(app.DB, session, nil)
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
} else {
|
||||
controller.SetSessionError(app.DB, session, "Incorrect TOTP.")
|
||||
}
|
||||
render()
|
||||
return
|
||||
}
|
||||
|
@ -496,3 +513,29 @@ func enforceSession(app *model.AppState, next http.Handler) http.Handler {
|
|||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func handleFailedLogin(app *model.AppState, account *model.Account) bool {
|
||||
locked, err := controller.IncrementAccountFails(app.DB, account.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"WARN: Failed to increment login failures for \"%s\": %v\n",
|
||||
account.Username,
|
||||
err,
|
||||
)
|
||||
app.Log.Warn(
|
||||
log.TYPE_ACCOUNT,
|
||||
"Failed to increment login failures for \"%s\"",
|
||||
account.Username,
|
||||
)
|
||||
}
|
||||
if locked {
|
||||
app.Log.Warn(
|
||||
log.TYPE_ACCOUNT,
|
||||
"Account \"%s\" was locked: %d failed login attempts",
|
||||
account.Username,
|
||||
model.MAX_LOGIN_FAIL_ATTEMPTS,
|
||||
)
|
||||
}
|
||||
return locked
|
||||
}
|
||||
|
|
|
@ -110,3 +110,26 @@ func DeleteAccount(db *sqlx.DB, accountID string) error {
|
|||
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
func IncrementAccountFails(db *sqlx.DB, accountID string) (bool, error) {
|
||||
failAttempts := 0
|
||||
err := db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID)
|
||||
if err != nil { return false, err }
|
||||
locked := false
|
||||
if failAttempts >= model.MAX_LOGIN_FAIL_ATTEMPTS {
|
||||
err = LockAccount(db, accountID)
|
||||
if err != nil { return false, err }
|
||||
locked = true
|
||||
}
|
||||
return locked, err
|
||||
}
|
||||
|
||||
func LockAccount(db *sqlx.DB, accountID string) error {
|
||||
_, err := db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID)
|
||||
return err
|
||||
}
|
||||
|
||||
func UnlockAccount(db *sqlx.DB, accountID string) error {
|
||||
_, err := db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const DB_VERSION int = 3
|
||||
const DB_VERSION int = 4
|
||||
|
||||
func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
||||
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
|
||||
|
@ -45,6 +45,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
|||
ApplyMigration(db, "002-audit-logs")
|
||||
oldDBVersion = 3
|
||||
|
||||
case 3:
|
||||
ApplyMigration(db, "003-fail-lock")
|
||||
oldDBVersion = 4
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
66
main.go
66
main.go
|
@ -276,11 +276,13 @@ func main() {
|
|||
"User: %s\n" +
|
||||
"\tID: %s\n" +
|
||||
"\tEmail: %s\n" +
|
||||
"\tCreated: %s\n",
|
||||
"\tCreated: %s\n" +
|
||||
"\tLocked: %t\n",
|
||||
account.Username,
|
||||
account.ID,
|
||||
email,
|
||||
account.CreatedAt,
|
||||
account.Locked,
|
||||
)
|
||||
}
|
||||
return
|
||||
|
@ -355,6 +357,64 @@ func main() {
|
|||
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
|
||||
return
|
||||
|
||||
case "lockAccount":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for lockAccount\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
username := os.Args[2]
|
||||
fmt.Printf("Unlocking account \"%s\"...\n", username)
|
||||
|
||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = controller.LockAccount(app.DB, account.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to lock account: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' locked via config utility.", account.Username)
|
||||
fmt.Printf("Account \"%s\" locked successfully.\n", account.Username)
|
||||
return
|
||||
|
||||
case "unlockAccount":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: `username` must be specified for unlockAccount\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
username := os.Args[2]
|
||||
fmt.Printf("Unlocking account \"%s\"...\n", username)
|
||||
|
||||
account, err := controller.GetAccountByUsername(app.DB, username)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to fetch account \"%s\": %v\n", username, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Account \"%s\" does not exist.\n", username)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = controller.UnlockAccount(app.DB, account.ID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FATAL: Failed to unlock account: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app.Log.Info(log.TYPE_ACCOUNT, "Account '%s' unlocked via config utility.", account.Username)
|
||||
fmt.Printf("Account \"%s\" unlocked successfully.\n", account.Username)
|
||||
return
|
||||
|
||||
case "logs":
|
||||
// TODO: add log search parameters
|
||||
logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
|
||||
|
@ -389,7 +449,9 @@ func main() {
|
|||
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
|
||||
"purgeInvites:\n\tDeletes all available invite codes.\n" +
|
||||
"listAccounts:\n\tLists all active accounts.\n",
|
||||
"deleteAccount <username>:\n\tDeletes an account with a given `username`.\n",
|
||||
"deleteAccount <username>:\n\tDeletes the account under `username`.\n",
|
||||
"unlockAccount <username>:\n\tUnlocks the account under `username`.\n",
|
||||
"logs:\n\tShows system logs.\n",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
)
|
||||
|
||||
const COOKIE_TOKEN string = "AM_SESSION"
|
||||
const MAX_LOGIN_FAIL_ATTEMPTS int = 3
|
||||
|
||||
type (
|
||||
Account struct {
|
||||
|
@ -15,6 +16,8 @@ type (
|
|||
Email sql.NullString `json:"email" db:"email"`
|
||||
AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
FailAttempts int `json:"fail_attempts" db:"fail_attempts"`
|
||||
Locked bool `json:"locked" db:"locked"`
|
||||
|
||||
Privileges []AccountPrivilege `json:"privileges"`
|
||||
}
|
||||
|
|
|
@ -19,6 +19,8 @@ CREATE TABLE arimelody.account (
|
|||
email TEXT,
|
||||
avatar_url TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
||||
fail_attempts INT NOT NULL DEFAULT 0,
|
||||
locked BOOLEAN DEFAULT false,
|
||||
);
|
||||
ALTER TABLE arimelody.account ADD CONSTRAINT account_pk PRIMARY KEY (id);
|
||||
|
||||
|
|
3
schema-migration/003-fail-lock.sql
Normal file
3
schema-migration/003-fail-lock.sql
Normal file
|
@ -0,0 +1,3 @@
|
|||
-- it would be nice to prevent brute-forcing
|
||||
ALTER TABLE arimelody.account ADD COLUMN fail_attempts INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE arimelody.account ADD COLUMN locked BOOLEAN DEFAULT false;
|
Loading…
Add table
Add a link
Reference in a new issue