aboutsummaryrefslogtreecommitdiff
path: root/internal/game/core_login_account.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/core_login_account.go')
-rw-r--r--internal/game/core_login_account.go148
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: ")