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()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if account.Locked {
|
||||||
|
controller.SetSessionError(app.DB, session, "This account is locked.")
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
|
err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(password))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
|
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" attempted login with incorrect password. (%s)", account.Username, controller.ResolveIP(app, r))
|
||||||
controller.SetSessionError(app.DB, session, "Invalid username or password.")
|
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()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -299,6 +308,8 @@ func loginHandler(app *model.AppState) http.Handler {
|
||||||
render()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
controller.SetSessionMessage(app.DB, session, "")
|
||||||
|
controller.SetSessionError(app.DB, session, "")
|
||||||
http.Redirect(w, r, "/admin/totp", http.StatusFound)
|
http.Redirect(w, r, "/admin/totp", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -377,8 +388,14 @@ func loginTOTPHandler(app *model.AppState) http.Handler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if totpMethod == nil {
|
if totpMethod == nil {
|
||||||
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Invalid TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
app.Log.Warn(log.TYPE_ACCOUNT, "\"%s\" failed login (Incorrect TOTP). (%s)", session.AttemptAccount.Username, controller.ResolveIP(app, r))
|
||||||
controller.SetSessionError(app.DB, session, "Invalid TOTP.")
|
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()
|
render()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -496,3 +513,29 @@ func enforceSession(app *model.AppState, next http.Handler) http.Handler {
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
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)
|
_, err := db.Exec("DELETE FROM account WHERE id=$1", accountID)
|
||||||
return err
|
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"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DB_VERSION int = 3
|
const DB_VERSION int = 4
|
||||||
|
|
||||||
func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
||||||
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
|
db.MustExec("CREATE SCHEMA IF NOT EXISTS arimelody")
|
||||||
|
@ -45,6 +45,10 @@ func CheckDBVersionAndMigrate(db *sqlx.DB) {
|
||||||
ApplyMigration(db, "002-audit-logs")
|
ApplyMigration(db, "002-audit-logs")
|
||||||
oldDBVersion = 3
|
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" +
|
"User: %s\n" +
|
||||||
"\tID: %s\n" +
|
"\tID: %s\n" +
|
||||||
"\tEmail: %s\n" +
|
"\tEmail: %s\n" +
|
||||||
"\tCreated: %s\n",
|
"\tCreated: %s\n" +
|
||||||
|
"\tLocked: %t\n",
|
||||||
account.Username,
|
account.Username,
|
||||||
account.ID,
|
account.ID,
|
||||||
email,
|
email,
|
||||||
account.CreatedAt,
|
account.CreatedAt,
|
||||||
|
account.Locked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
@ -355,6 +357,64 @@ func main() {
|
||||||
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
|
fmt.Printf("Account \"%s\" deleted successfully.\n", account.Username)
|
||||||
return
|
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":
|
case "logs":
|
||||||
// TODO: add log search parameters
|
// TODO: add log search parameters
|
||||||
logs, err := app.Log.Search([]log.LogLevel{}, []string{}, "", 100, 0)
|
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" +
|
"createInvite:\n\tCreates an invite code to register new accounts.\n" +
|
||||||
"purgeInvites:\n\tDeletes all available invite codes.\n" +
|
"purgeInvites:\n\tDeletes all available invite codes.\n" +
|
||||||
"listAccounts:\n\tLists all active accounts.\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
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,15 +6,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const COOKIE_TOKEN string = "AM_SESSION"
|
const COOKIE_TOKEN string = "AM_SESSION"
|
||||||
|
const MAX_LOGIN_FAIL_ATTEMPTS int = 3
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Account struct {
|
Account struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
Username string `json:"username" db:"username"`
|
Username string `json:"username" db:"username"`
|
||||||
Password string `json:"password" db:"password"`
|
Password string `json:"password" db:"password"`
|
||||||
Email sql.NullString `json:"email" db:"email"`
|
Email sql.NullString `json:"email" db:"email"`
|
||||||
AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"`
|
AvatarURL sql.NullString `json:"avatar_url" db:"avatar_url"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
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"`
|
Privileges []AccountPrivilege `json:"privileges"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,8 @@ CREATE TABLE arimelody.account (
|
||||||
email TEXT,
|
email TEXT,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT current_timestamp
|
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);
|
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