From d44bba53644a3dbae28d258a35d0fe731ae7cb72 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Thu, 26 Mar 2026 07:16:20 +0900 Subject: [PATCH] feat: add simple login for web users to persist game data Web users had no persistent fingerprint, losing codex/achievements/ rankings on reconnect. Now web users enter nickname + password: - New accounts: set password (min 4 chars, bcrypt hashed) - Existing accounts: verify password to log in - On success: deterministic fingerprint SHA256(web:nickname) assigned - SSH users with real key fingerprints skip password entirely New files: store/passwords.go, store/passwords_test.go Co-Authored-By: Claude Opus 4.6 (1M context) --- store/db.go | 3 + store/passwords.go | 51 ++++++++ store/passwords_test.go | 52 ++++++++ ui/nickname_view.go | 283 ++++++++++++++++++++++++++++++++++------ 4 files changed, 347 insertions(+), 42 deletions(-) create mode 100644 store/passwords.go create mode 100644 store/passwords_test.go diff --git a/store/db.go b/store/db.go index f208c58..54c51a2 100644 --- a/store/db.go +++ b/store/db.go @@ -52,6 +52,9 @@ func Open(path string) (*DB, error) { if _, err := tx.CreateBucketIfNotExists(bucketCodex); err != nil { return err } + if _, err := tx.CreateBucketIfNotExists(bucketPasswords); err != nil { + return err + } return nil }) return &DB{db: db}, err diff --git a/store/passwords.go b/store/passwords.go new file mode 100644 index 0000000..a404515 --- /dev/null +++ b/store/passwords.go @@ -0,0 +1,51 @@ +package store + +import ( + bolt "go.etcd.io/bbolt" + "golang.org/x/crypto/bcrypt" +) + +var bucketPasswords = []byte("passwords") + +// SavePassword stores a bcrypt-hashed password for the given nickname. +func (d *DB) SavePassword(nickname, password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + return d.db.Update(func(tx *bolt.Tx) error { + return tx.Bucket(bucketPasswords).Put([]byte(nickname), hash) + }) +} + +// CheckPassword verifies a password against the stored bcrypt hash. +func (d *DB) CheckPassword(nickname, password string) (bool, error) { + var hash []byte + err := d.db.View(func(tx *bolt.Tx) error { + v := tx.Bucket(bucketPasswords).Get([]byte(nickname)) + if v != nil { + hash = make([]byte, len(v)) + copy(hash, v) + } + return nil + }) + if err != nil { + return false, err + } + if hash == nil { + return false, nil + } + err = bcrypt.CompareHashAndPassword(hash, []byte(password)) + return err == nil, nil +} + +// HasPassword checks whether an account with a password exists for the nickname. +func (d *DB) HasPassword(nickname string) bool { + found := false + d.db.View(func(tx *bolt.Tx) error { + v := tx.Bucket(bucketPasswords).Get([]byte(nickname)) + found = v != nil + return nil + }) + return found +} diff --git a/store/passwords_test.go b/store/passwords_test.go new file mode 100644 index 0000000..cd800e5 --- /dev/null +++ b/store/passwords_test.go @@ -0,0 +1,52 @@ +package store + +import ( + "testing" +) + +func TestPasswordRoundTrip(t *testing.T) { + dir := t.TempDir() + db, err := Open(dir + "/test.db") + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // New account should not have a password. + if db.HasPassword("alice") { + t.Fatal("expected no password for alice") + } + + // Save and check password. + if err := db.SavePassword("alice", "secret123"); err != nil { + t.Fatal(err) + } + if !db.HasPassword("alice") { + t.Fatal("expected alice to have a password") + } + + ok, err := db.CheckPassword("alice", "secret123") + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected correct password to pass") + } + + ok, err = db.CheckPassword("alice", "wrong") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected wrong password to fail") + } + + // Non-existent user returns false, no error. + ok, err = db.CheckPassword("bob", "anything") + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected non-existent user to fail") + } +} diff --git a/ui/nickname_view.go b/ui/nickname_view.go index b5563db..8460062 100644 --- a/ui/nickname_view.go +++ b/ui/nickname_view.go @@ -1,6 +1,7 @@ package ui import ( + "crypto/sha256" "fmt" "log/slog" "strings" @@ -9,63 +10,231 @@ import ( "github.com/charmbracelet/lipgloss" ) -// NicknameScreen handles first-time player name input. +// nicknamePhase tracks the current step of the nickname/login screen. +type nicknamePhase int + +const ( + phaseNickname nicknamePhase = iota // entering nickname + phasePasswordLogin // existing account — enter password + phasePasswordCreate // new account — enter password + phasePasswordConfirm // new account — confirm password +) + +// NicknameScreen handles player name input and optional web login. type NicknameScreen struct { - input string + input string + password string + confirm string + phase nicknamePhase + error string } func NewNicknameScreen() *NicknameScreen { return &NicknameScreen{} } +// isWebUser returns true when the player connected via the web bridge +// (no real SSH fingerprint). +func isWebUser(ctx *Context) bool { + return ctx.Fingerprint == "" || strings.HasPrefix(ctx.Fingerprint, "anon-") +} + func (s *NicknameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { - if key, ok := msg.(tea.KeyMsg); ok { - if isEnter(key) && len(s.input) > 0 { - ctx.PlayerName = s.input - if ctx.Store != nil && ctx.Fingerprint != "" { - if err := ctx.Store.SaveProfile(ctx.Fingerprint, ctx.PlayerName); err != nil { - slog.Error("failed to save profile", "error", err) - } - } - if ctx.Lobby != nil { - ctx.Lobby.PlayerOnline(ctx.Fingerprint, ctx.PlayerName) - } - // Check for active session to reconnect - if ctx.Lobby != nil { - code, session := ctx.Lobby.GetActiveSession(ctx.Fingerprint) - if session != nil { - ctx.RoomCode = code - ctx.Session = session - gs := NewGameScreen() - gs.gameState = ctx.Session.GetState() - ctx.Session.TouchActivity(ctx.Fingerprint) - ctx.Session.SendChat("System", ctx.PlayerName+" 재접속!") - return gs, gs.pollState() - } - } - ls := NewLobbyScreen() - ls.refreshLobby(ctx) - return ls, ls.pollLobby() - } else if isKey(key, "esc") || key.Type == tea.KeyEsc { + key, ok := msg.(tea.KeyMsg) + if !ok { + return s, nil + } + + // Esc always goes back one step or cancels. + if isKey(key, "esc") || key.Type == tea.KeyEsc { + switch s.phase { + case phaseNickname: s.input = "" return NewTitleScreen(), nil - } else if key.Type == tea.KeyBackspace && len(s.input) > 0 { - s.input = s.input[:len(s.input)-1] - } else if len(key.Runes) == 1 && len(s.input) < 12 { - ch := string(key.Runes) - if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 { - s.input += ch - } + case phasePasswordLogin, phasePasswordCreate: + s.phase = phaseNickname + s.password = "" + s.error = "" + return s, nil + case phasePasswordConfirm: + s.phase = phasePasswordCreate + s.confirm = "" + s.error = "" + return s, nil + } + return s, nil + } + + switch s.phase { + case phaseNickname: + return s.updateNickname(key, ctx) + case phasePasswordLogin: + return s.updatePasswordLogin(key, ctx) + case phasePasswordCreate: + return s.updatePasswordCreate(key, ctx) + case phasePasswordConfirm: + return s.updatePasswordConfirm(key, ctx) + } + return s, nil +} + +func (s *NicknameScreen) updateNickname(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) { + if isEnter(key) && len(s.input) > 0 { + // SSH users with a real fingerprint skip password entirely. + if !isWebUser(ctx) { + return s.finishLogin(ctx) + } + // Web user — need password flow. + if ctx.Store != nil && ctx.Store.HasPassword(s.input) { + s.phase = phasePasswordLogin + s.error = "" + } else { + s.phase = phasePasswordCreate + s.error = "" + } + return s, nil + } + if key.Type == tea.KeyBackspace && len(s.input) > 0 { + s.input = s.input[:len(s.input)-1] + } else if len(key.Runes) == 1 && len(s.input) < 12 { + ch := string(key.Runes) + if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 { + s.input += ch } } return s, nil } -func (s *NicknameScreen) View(ctx *Context) string { - return renderNickname(s.input, ctx.Width, ctx.Height) +func (s *NicknameScreen) updatePasswordLogin(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) { + if isEnter(key) { + if ctx.Store == nil { + return s.finishLogin(ctx) + } + ok, err := ctx.Store.CheckPassword(s.input, s.password) + if err != nil { + s.error = "오류가 발생했습니다" + slog.Error("password check failed", "error", err) + return s, nil + } + if !ok { + s.error = "비밀번호가 틀렸습니다" + s.password = "" + return s, nil + } + // Set deterministic fingerprint for web user. + ctx.Fingerprint = webFingerprint(s.input) + return s.finishLogin(ctx) + } + s.password = handlePasswordInput(key, s.password) + return s, nil } -func renderNickname(input string, width, height int) string { +func (s *NicknameScreen) updatePasswordCreate(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) { + if isEnter(key) { + if len(s.password) < 4 { + s.error = "비밀번호는 4자 이상이어야 합니다" + return s, nil + } + s.phase = phasePasswordConfirm + s.error = "" + return s, nil + } + s.password = handlePasswordInput(key, s.password) + return s, nil +} + +func (s *NicknameScreen) updatePasswordConfirm(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) { + if isEnter(key) { + if s.confirm != s.password { + s.error = "비밀번호가 일치하지 않습니다" + s.confirm = "" + return s, nil + } + if ctx.Store != nil { + if err := ctx.Store.SavePassword(s.input, s.password); err != nil { + s.error = "저장 오류가 발생했습니다" + slog.Error("failed to save password", "error", err) + return s, nil + } + } + ctx.Fingerprint = webFingerprint(s.input) + return s.finishLogin(ctx) + } + s.confirm = handlePasswordInput(key, s.confirm) + return s, nil +} + +// finishLogin sets the player name, saves the profile, and transitions to lobby. +func (s *NicknameScreen) finishLogin(ctx *Context) (Screen, tea.Cmd) { + ctx.PlayerName = s.input + if ctx.Store != nil && ctx.Fingerprint != "" { + if err := ctx.Store.SaveProfile(ctx.Fingerprint, ctx.PlayerName); err != nil { + slog.Error("failed to save profile", "error", err) + } + } + if ctx.Lobby != nil { + ctx.Lobby.PlayerOnline(ctx.Fingerprint, ctx.PlayerName) + } + // Check for active session to reconnect. + if ctx.Lobby != nil { + code, session := ctx.Lobby.GetActiveSession(ctx.Fingerprint) + if session != nil { + ctx.RoomCode = code + ctx.Session = session + gs := NewGameScreen() + gs.gameState = ctx.Session.GetState() + ctx.Session.TouchActivity(ctx.Fingerprint) + ctx.Session.SendChat("System", ctx.PlayerName+" 재접속!") + return gs, gs.pollState() + } + } + ls := NewLobbyScreen() + ls.refreshLobby(ctx) + return ls, ls.pollLobby() +} + +// webFingerprint produces a deterministic fingerprint for a web user. +func webFingerprint(nickname string) string { + h := sha256.Sum256([]byte("web:" + nickname)) + return fmt.Sprintf("SHA256:%x", h) +} + +func handlePasswordInput(key tea.KeyMsg, current string) string { + if key.Type == tea.KeyBackspace && len(current) > 0 { + return current[:len(current)-1] + } + if len(key.Runes) == 1 && len(current) < 32 { + ch := string(key.Runes) + if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 { + return current + ch + } + } + return current +} + +func (s *NicknameScreen) View(ctx *Context) string { + return renderNicknameLogin(s, ctx.Width, ctx.Height) +} + +func renderNicknameLogin(s *NicknameScreen, width, height int) string { + var sections []string + + switch s.phase { + case phaseNickname: + sections = renderNicknamePhase(s.input) + case phasePasswordLogin: + sections = renderPasswordPhase(s.input, s.password, "비밀번호를 입력하세요", s.error) + case phasePasswordCreate: + sections = renderPasswordPhase(s.input, s.password, "비밀번호를 설정하세요 (4자 이상)", s.error) + case phasePasswordConfirm: + sections = renderPasswordPhase(s.input, s.confirm, "비밀번호를 다시 입력하세요", s.error) + } + + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, + lipgloss.JoinVertical(lipgloss.Center, sections...)) +} + +func renderNicknamePhase(input string) []string { title := styleHeader.Render("── 이름을 입력하세요 ──") display := input @@ -84,6 +253,36 @@ func renderNickname(input string, width, height int) string { hint := styleSystem.Render(fmt.Sprintf("(%d/12 글자)", len(input))) footer := styleAction.Render("[Enter] 확인 [Esc] 취소") - return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, - lipgloss.JoinVertical(lipgloss.Center, title, "", inputBox, hint, "", footer)) + return []string{title, "", inputBox, hint, "", footer} +} + +func renderPasswordPhase(nickname, password, prompt, errMsg string) []string { + title := styleHeader.Render("── " + prompt + " ──") + + nameDisplay := stylePlayer.Render("이름: " + nickname) + + masked := strings.Repeat("*", len(password)) + if masked == "" { + masked = strings.Repeat("_", 8) + } else { + masked += "_" + } + + inputBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorCyan). + Padding(0, 2). + Render(stylePlayer.Render(masked)) + + sections := []string{title, "", nameDisplay, "", inputBox} + + if errMsg != "" { + sections = append(sections, "", + lipgloss.NewStyle().Foreground(colorRed).Bold(true).Render(errMsg)) + } + + footer := styleAction.Render("[Enter] 확인 [Esc] 뒤로") + sections = append(sections, "", footer) + + return sections }