diff options
Diffstat (limited to 'internal/game/core_login_account.go')
| -rw-r--r-- | internal/game/core_login_account.go | 148 |
1 files changed, 142 insertions, 6 deletions
diff --git a/internal/game/core_login_account.go b/internal/game/core_login_account.go index 47421e9..371c287 100644 --- a/internal/game/core_login_account.go +++ b/internal/game/core_login_account.go @@ -63,20 +63,127 @@ func (g *Game) handlePassword(sess *net.Session, input string) { sess.Write("Account name: ") return } + auth := passwordAuth{ok: true, stored: acc.PasswordHash} // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to // Argon2id after a successful verification. A failed save keeps the // legacy hash and retries on the next login. if player.NeedsRehash(acc.PasswordHash) { if hash, hashErr := player.HashPassword(input); hashErr == nil { - acc.PasswordHash = hash - if err := g.AccountStore.SaveAccount(acc); err != nil { - log.Printf("login: password rehash save for %s: %v", acc.Name, err) - } + auth.rehash = hash } else { log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) } } + g.applyPasswordAuth(sess, auth) +} + +// passwordAuth carries the result of the EXPENSIVE password work (Argon2id +// verification and any rehash), which runs on the caller's connection +// goroutine — never on the owner, where a burst of logins would stall +// every command and tick behind password hashing. +type passwordAuth struct { + ok bool + // loadErr marks an account read failure off the owner: the owner task + // renders it distinctly from a wrong password. + loadErr bool + // stored is the hash that was verified; the owner task re-checks the + // file against it so a password change, rename, or delete made during + // verification cannot authorize a stale login. + stored string + // rehash is an upgraded hash to persist after a successful check. + rehash string +} + +// handlePasswordOffOwner verifies the password on the caller's connection +// goroutine and submits one owner task for the (cheap) state transition. +// The production entry point; handlePassword stays for direct HandleSession +// callers (tests) but hashes on the owner there. +func (g *Game) handlePasswordOffOwner(sess *net.Session, input string) { + if sess.Account == nil { + // Defensive: password prompts only follow an account-name step. + g.RunOnOwner(func() { + if g.isShuttingDown() { + return + } + g.handlePassword(sess, input) + }) + return + } + if input == "" || len(input) > 128 { + tooshort := input == "" + g.RunOnOwner(func() { + if g.isShuttingDown() { + return + } + if tooshort { + // Echo stays disabled: the next attempt is also a password. + sess.Write("Password: ") + return + } + sess.SetEcho(true) + sess.WriteLine("Wrong password.") + sess.State = net.StateAccountName + sess.Write("Account name: ") + }) + return + } + auth := passwordAuth{} + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + switch { + case err != nil: + auth.loadErr = true + case player.CheckPassword(input, acc.PasswordHash): + auth.ok, auth.stored = true, acc.PasswordHash + // Transparent upgrade: legacy single-pass SHA-256 hashes rehash to + // Argon2id after a successful verification. A failed rehash keeps + // the legacy hash and retries on the next login. + if player.NeedsRehash(acc.PasswordHash) { + if hash, hashErr := player.HashPassword(input); hashErr == nil { + auth.rehash = hash + } else { + log.Printf("login: password rehash for %s: %v", acc.Name, hashErr) + } + } + } + g.RunOnOwner(func() { + if g.isShuttingDown() { + return + } + g.applyPasswordAuth(sess, auth) + }) +} + +// applyPasswordAuth completes (or rejects) a password login on the owner. +func (g *Game) applyPasswordAuth(sess *net.Session, auth passwordAuth) { sess.SetEcho(true) + reject := func(message string) { + sess.WriteLine(message) + sess.State = net.StateAccountName + sess.Write("Account name: ") + } + if auth.loadErr { + reject("Error loading account.") + return + } + if !auth.ok { + reject("Wrong password.") + return + } + acc, err := g.AccountStore.LoadAccount(sess.Account.Name) + if err != nil { + reject("Error loading account.") + return + } + if acc.PasswordHash != auth.stored { + // The account changed while the hash was being verified (password + // change, rename, or delete): the verified hash is stale. + reject("Account changed; sign in again.") + return + } + if auth.rehash != "" { + acc.PasswordHash = auth.rehash + g.saveAccount(acc) + } sess.Account = &player.Account{ Name: acc.Name, PasswordHash: acc.PasswordHash, @@ -136,9 +243,38 @@ func (g *Game) handleNewAccount(sess *net.Session, input string) { } hash, err := player.HashPassword(input) - if err != nil { + g.applyNewAccount(sess, hash, err) +} + +// handleNewAccountOffOwner hashes the confirmed password on the caller's +// connection goroutine (Argon2id is expensive) and submits one owner task +// for the existence re-check and save. +func (g *Game) handleNewAccountOffOwner(sess *net.Session, input string) { + input = strings.TrimSpace(input) + if input == "" || input != sess.PendingPass { + g.RunOnOwner(func() { + if g.isShuttingDown() { + return + } + g.handleNewAccount(sess, input) + }) + return + } + hash, hashErr := player.HashPassword(input) + g.RunOnOwner(func() { + if g.isShuttingDown() { + return + } + g.applyNewAccount(sess, hash, hashErr) + }) +} + +// applyNewAccount creates the account on the owner. The password was +// already hashed by the caller (off the owner where possible). +func (g *Game) applyNewAccount(sess *net.Session, hash string, hashErr error) { + if hashErr != nil { sess.SetEcho(true) - sess.WriteLine(fmt.Sprintf("Error creating account: %v", err)) + sess.WriteLine(fmt.Sprintf("Error creating account: %v", hashErr)) sess.Account = nil sess.State = net.StateAccountName sess.Write("Account name: ") |
