Compare commits
3 commits
0796ea8fde
...
99e5eb290f
Author | SHA1 | Date | |
---|---|---|---|
99e5eb290f | |||
562ed2e015 | |||
1c0e541c89 |
10 changed files with 172 additions and 19 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
|
||||||
}
|
}
|
||||||
|
@ -466,7 +483,7 @@ func staticHandler() http.Handler {
|
||||||
|
|
||||||
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
|
func enforceSession(app *model.AppState, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
session, err := controller.GetSessionFromRequest(app.DB, r)
|
session, err := controller.GetSessionFromRequest(app, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ func ServeRelease(app *model.AppState, release *model.Release) http.Handler {
|
||||||
// only allow authorised users to view hidden releases
|
// only allow authorised users to view hidden releases
|
||||||
privileged := false
|
privileged := false
|
||||||
if !release.Visible {
|
if !release.Visible {
|
||||||
session, err := controller.GetSessionFromRequest(app.DB, r)
|
session, err := controller.GetSessionFromRequest(app, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"arimelody-web/log"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
@ -15,7 +16,7 @@ import (
|
||||||
|
|
||||||
const TOKEN_LEN = 64
|
const TOKEN_LEN = 64
|
||||||
|
|
||||||
func GetSessionFromRequest(db *sqlx.DB, r *http.Request) (*model.Session, error) {
|
func GetSessionFromRequest(app *model.AppState, r *http.Request) (*model.Session, error) {
|
||||||
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
sessionCookie, err := r.Cookie(model.COOKIE_TOKEN)
|
||||||
if err != nil && err != http.ErrNoCookie {
|
if err != nil && err != http.ErrNoCookie {
|
||||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err))
|
return nil, errors.New(fmt.Sprintf("Failed to retrieve session cookie: %v", err))
|
||||||
|
@ -25,14 +26,26 @@ func GetSessionFromRequest(db *sqlx.DB, r *http.Request) (*model.Session, error)
|
||||||
|
|
||||||
if sessionCookie != nil {
|
if sessionCookie != nil {
|
||||||
// fetch existing session
|
// fetch existing session
|
||||||
session, err = GetSession(db, sessionCookie.Value)
|
session, err = GetSession(app.DB, sessionCookie.Value)
|
||||||
|
|
||||||
if err != nil && !strings.Contains(err.Error(), "no rows") {
|
if err != nil && !strings.Contains(err.Error(), "no rows") {
|
||||||
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err))
|
return nil, errors.New(fmt.Sprintf("Failed to retrieve session: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if session != nil {
|
if session != nil {
|
||||||
// TODO: consider running security checks here (i.e. user agent mismatches)
|
if session.UserAgent != r.UserAgent() {
|
||||||
|
msg := "Session user agent mismatch. A cookie may have been hijacked!"
|
||||||
|
if session.Account != nil {
|
||||||
|
account, _ := GetAccountByID(app.DB, session.Account.ID)
|
||||||
|
msg += " (Account \"" + account.Username + "\")"
|
||||||
|
}
|
||||||
|
app.Log.Warn(log.TYPE_ACCOUNT, msg)
|
||||||
|
err = DeleteSession(app.DB, session.Token)
|
||||||
|
if err != nil {
|
||||||
|
app.Log.Warn(log.TYPE_ACCOUNT, "Failed to delete affected session")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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;
|
|
@ -60,7 +60,7 @@ func ServeGateway(app *model.AppState, release *model.Release) http.Handler {
|
||||||
// only allow authorised users to view hidden releases
|
// only allow authorised users to view hidden releases
|
||||||
privileged := false
|
privileged := false
|
||||||
if !release.Visible {
|
if !release.Visible {
|
||||||
session, err := controller.GetSessionFromRequest(app.DB, r)
|
session, err := controller.GetSessionFromRequest(app, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve session: %v\n", err)
|
||||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue