diff options
| author | raven <citrons@mondecitronne.com> | 2025-10-22 16:28:22 -0500 |
|---|---|---|
| committer | raven <citrons@mondecitronne.com> | 2026-02-09 13:13:55 -0600 |
| commit | ff0f6ba724748dbe340187fdd831a4d4f7f0ae5e (patch) | |
| tree | ab27a0c9f40d803c2bf130ef7c82cdf6540e9018 | |
| parent | 4b54a1d11fd0fa355b244637612a3fd0af18c60c (diff) | |
passwords
| -rw-r--r-- | client/application.go | 100 | ||||
| -rw-r--r-- | client/cmd_window.go | 8 | ||||
| -rw-r--r-- | client/command.go | 9 | ||||
| -rw-r--r-- | client/login_prompt.go | 96 | ||||
| -rw-r--r-- | client/ui.go | 8 | ||||
| -rw-r--r-- | proto/strfail.go | 10 | ||||
| -rw-r--r-- | server/passwords/passwords.go | 38 | ||||
| -rw-r--r-- | server/server/command.go | 57 | ||||
| -rw-r--r-- | server/session/session.go | 1 | ||||
| -rw-r--r-- | server/user/command.go | 37 | ||||
| -rw-r--r-- | server/user/user.go | 51 | ||||
| -rw-r--r-- | server/validate/validate.go | 12 | ||||
| -rw-r--r-- | tui/text_input.go | 19 |
13 files changed, 400 insertions, 46 deletions
diff --git a/client/application.go b/client/application.go index d396f24..640abd6 100644 --- a/client/application.go +++ b/client/application.go @@ -145,37 +145,109 @@ func (a *application) GetInfo(id string, callback func(*proto.Object)) { }) } -func (a *application) auth(name string, authCallback func(success bool)) { - callback := func(response proto.Command) { +func (a *application) authAnon(as string, callback func(ok bool, uid string)) { + cb := func(response proto.Command) { + switch response.Kind { + case "you-are": + if len(response.Args) == 0 { + callback(false, "") + break + } + me := response.Args[0] + a.onAuth(me.Id) + if callback != nil { + callback(true, me.Id) + } + case "fail": + var uid string + if len(response.Args) != 0 { + if response.Args[0].Kind == "name-taken" && + response.Args[0].Fields["anonymous"] == "no" { + uid = response.Args[0].Fields["id"] + } else { + a.cmdWindow.err(proto.Strfail(response.Args[0])) + } + } + if callback != nil { + callback(false, uid) + } + } + } + a.Request(proto.NewCmd("auth", "", proto.Object { + "anonymous", "", map[string]string {"": as}, + }), cb) +} + +func (a *application) authPassword( + uid string, password string, callback func(ok bool)) { + cb := func(response proto.Command) { switch response.Kind { case "you-are": if len(response.Args) == 0 { - authCallback(false) + callback(false) break } me := response.Args[0] - a.authenticated = true - a.uid = me.Id - a.cache.Watch(a.uid) - a.onAuth() - if authCallback != nil { - authCallback(true) + a.onAuth(me.Id) + if callback != nil { + callback(true) } case "fail": if len(response.Args) != 0 { a.cmdWindow.err(proto.Strfail(response.Args[0])) } - if authCallback != nil { - authCallback(false) + if callback != nil { + callback(false) } } } a.Request(proto.NewCmd("auth", "", proto.Object { - "anonymous", "", map[string]string {"": name}, - }), callback) + "password", "", map[string]string {"": password, "id": uid}, + }), cb) +} + +func (a *application) setPassword(password string, callback func(ok bool)) { + cb := func(response proto.Command) { + switch response.Kind { + case "ok": + if callback != nil { + callback(true) + } + case "fail": + if len(response.Args) != 0 && + response.Args[0].Kind == "password-required" { + lp := newLoginPrompt("") + lp.uid = a.uid + lp.customPrompt = "current password" + lp.callback = func(ok bool) { + if ok { + a.setPassword(password, callback) + } else { + if callback != nil { + callback(false) + } + } + } + a.pushPrompt(lp) + } else { + if len(response.Args) != 0 { + a.cmdWindow.err(proto.Strfail(response.Args[0])) + } + if callback != nil { + callback(false) + } + } + } + } + a.Request(proto.NewCmd("auth-update", a.uid, proto.Object { + "password", "", map[string]string {"": password}, + }), cb) } -func (a *application) onAuth() { +func (a *application) onAuth(uid string) { + a.authenticated = true + a.uid = uid + a.cache.Watch(uid) a.Request(proto.NewCmd("channels", ""), func(response proto.Command) { if response.Kind == "channels" { previousChannels := make(channelList, len(a.channelList)) diff --git a/client/cmd_window.go b/client/cmd_window.go index dccbaba..f849560 100644 --- a/client/cmd_window.go +++ b/client/cmd_window.go @@ -26,6 +26,7 @@ const ( logInfo = iota logErr logCmd + logWarn ) func (m logMsg) Id() string { @@ -39,6 +40,8 @@ func (m logMsg) Show(odd bool) { style = &tui.Style {Bg: colorErr[odd], Fg: tui.White} case logCmd: style = &tui.Style {Bg: colorCmd[odd], Fg: tui.White} + case logWarn: + style = &tui.Style {Bg: colorDefault[odd], Fg: 229} } tui.Push("", tui.Box { @@ -138,6 +141,11 @@ func (w *cmdWindow) info(f string, a ...any) { w.Buf.Add(logMsg {lastIndex, fmt.Sprintf(f, a...), logInfo}) } +func (w *cmdWindow) warn(f string, a ...any) { + lastIndex++ + w.Buf.Add(logMsg {lastIndex, fmt.Sprintf(f, a...), logWarn}) +} + func (w *cmdWindow) err(f string, a ...any) { lastIndex++ w.Buf.Add(logMsg {lastIndex, fmt.Sprintf(f, a...), logErr}) diff --git a/client/command.go b/client/command.go index 5ceb45b..64f8cd8 100644 --- a/client/command.go +++ b/client/command.go @@ -179,9 +179,14 @@ func (a *application) doCommand(command string, args []string, text string) { }) return case "create": - if a.authenticated { - a.createChannel(text) + a.createChannel(text) + return + case "password": + if text == "" { + a.cmdWindow.err("password cannot be empty") + return } + a.pushPrompt(&passwordChangePrompt {password: text}) return case "debug": a.goTo(debugWindowLocation{}) diff --git a/client/login_prompt.go b/client/login_prompt.go index c19d987..ec5b36b 100644 --- a/client/login_prompt.go +++ b/client/login_prompt.go @@ -7,7 +7,11 @@ import ( type loginPrompt struct { input tui.TextInput + uid string username string + customPrompt string + waiting bool + callback func(ok bool) } func newLoginPrompt(completeName string) *loginPrompt { @@ -21,28 +25,96 @@ func newLoginPrompt(completeName string) *loginPrompt { } func (p *loginPrompt) Input() *tui.TextInput { - return &p.input + if !p.waiting { + return &p.input + } else { + return &tui.TextInput {} + } } func (p *loginPrompt) Send(text string) { - if p.username != "" { - return - } - p.username = text - globalApp.auth(text, func(success bool) { - if success { + if p.uid == "" { + p.username = text + globalApp.authAnon(text, func(success bool, uid string) { + if success { + globalApp.removePrompt(p) + globalApp.cmdWindow.warn( + "you have created a temporary account. " + + "set a password to keep it.", + ) + } else if uid == "" { + p.username = "" + } else { + p.uid = uid + p.input.SetText("") + p.input.Private = true + } + }) + } else { + p.input.SetText("") + p.waiting = true + globalApp.authPassword(p.uid, text, func(success bool) { + p.waiting = false + if !globalApp.authenticated { + p.uid = "" + p.input.SetText(p.username) + p.input.Private = false + return + } globalApp.removePrompt(p) + if p.callback != nil { + p.callback(success) + } + }) + } +} + +func (p *loginPrompt) ShowStatusLine() { + tui.Text("[", nil) + tui.Text("login", &tui.Style { + Bg: tui.White, Fg: tui.Blue, Bold: true, + }) + tui.Text("] ", nil) + if p.customPrompt == "" { + if p.uid == "" { + tui.Text("username", nil) } else { - p.username = "" + tui.Text("password", nil) } - }) + } else { + tui.Text(p.customPrompt, nil) + } + tui.Text(":", nil) } -func (p *loginPrompt) ShowStatusLine() { +type passwordChangePrompt struct { + input tui.TextInput + password string +} + +func (p *passwordChangePrompt) Input() *tui.TextInput { + p.input.Private = true + return &p.input +} + +func (p *passwordChangePrompt) Send(text string) { + if text != p.password { + globalApp.cmdWindow.err("passwords do not match") + } else { + globalApp.setPassword(text, func(ok bool) { + if ok { + globalApp.cmdWindow.info("password changed") + } + }) + } + globalApp.removePrompt(p) +} + +func (p *passwordChangePrompt) ShowStatusLine() { tui.Text("[", nil) tui.Text("login", &tui.Style { Bg: tui.White, Fg: tui.Blue, Bold: true, }) - tui.Text("]", nil) - tui.Text(" username:", nil) + tui.Text("] ", nil) + tui.Text("confirm new password:", nil) } diff --git a/client/ui.go b/client/ui.go index e5917fa..6ea9fa5 100644 --- a/client/ui.go +++ b/client/ui.go @@ -190,7 +190,13 @@ func (a *application) showWindow() { Width: tui.Fill, Height: tui.Children, Dir: tui.Right, }) a.showNickBox() - prompt.Input().Show("input") + input := prompt.Input() + priv := input.Private + if strings.HasPrefix(input.Text(), "/") { + input.Private = strings.HasPrefix(input.Text(), "/password ") + } + input.Show("input") + input.Private = priv tui.Pop() tui.Pop() diff --git a/proto/strfail.go b/proto/strfail.go index 1ce160c..477df1a 100644 --- a/proto/strfail.go +++ b/proto/strfail.go @@ -19,8 +19,14 @@ func Strfail(fail Object) string { case "not-in-channel": return "you are not a member of this channel: " + fail.Fields[""] case "bad-reply": - return "this is not a message you can reply to" + return "cannot reply to message (or message not found)" + case "incorrect-password": + return "incorrect username or password" + case "password-required": + return "you must log in with a password to do that" + case "bad-auth-id": + return "cannot login as this user (or user not found)" default: - return "unknown error" + return "unknown error (" + fail.Kind + ")" } } diff --git a/server/passwords/passwords.go b/server/passwords/passwords.go new file mode 100644 index 0000000..0524f24 --- /dev/null +++ b/server/passwords/passwords.go @@ -0,0 +1,38 @@ +package passwords + +import ( + "golang.org/x/crypto/argon2" + "crypto/subtle" + "crypto/rand" + "bytes" + "log" +) + +const version = 0 + +func doHash(ver int, password string, salt []byte) []byte { + return argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) +} + +func Hash(password string) []byte { + var buf bytes.Buffer + buf.WriteByte(version) + + salt := make([]byte, 32) + _, err := rand.Read(salt) + if err != nil { + log.Fatal("error generating password hash:", err) + } + buf.Write(doHash(version, password, salt)) + buf.Write(salt) + + return buf.Bytes() +} + +func Check(password string, hash []byte) bool { + ver := int(hash[0]) + hashData := hash[1:33] + salt := hash[33:] + check := doHash(ver, password, salt) + return subtle.ConstantTimeCompare(hashData, check) == 1 +} diff --git a/server/server/command.go b/server/server/command.go index 75f3bc0..9c57fc1 100644 --- a/server/server/command.go +++ b/server/server/command.go @@ -11,28 +11,71 @@ func (s *server) SendRequest(r session.Request) { switch (r.Cmd.Kind) { case "auth": - if r.From.UserId != "" { - r.ReplyInvalid() - return - } if len(r.Cmd.Args) != 1 { r.ReplyInvalid() return } auth := r.Cmd.Args[0] switch auth.Kind { + case "anonymous": + if r.From.UserId != "" { + r.ReplyInvalid() + return + } if (auth.Fields[""] == "") { r.ReplyInvalid() return } - user, err := s.userKind.CreateUser(auth.Fields[""]) + u, err := s.userKind.CreateUser(auth.Fields[""]) if err != nil { r.Reply(err.Cmd()) return } - r.Reply(proto.NewCmd("you-are", "", user.InfoFor(r.From.UserId))) - r.From.UserId = user.Id() + r.Reply(proto.NewCmd("you-are", "", u.InfoFor(u.Id()))) + r.From.UserId = u.Id() + r.From.Subscribe(&u.PrivateStream) + + case "password": + var id, password string + for k, v := range auth.Fields { + switch k { + case "": + password = v + case "id": + id = v + default: + r.ReplyInvalid() + return + } + } + if id == "" || password == "" { + r.ReplyInvalid() + return + } + if r.From.UserId != "" && id != r.From.UserId { + r.Reply(proto.Fail{"bad-auth-id", "", nil}.Cmd()) + return + } + + switch u := s.world.GetObject(id).(type) { + case *user.User: + if u.IsAnonymous() { + r.Reply(proto.Fail{"bad-auth-id", "", nil}.Cmd()) + return + } + if u.CheckPassword(password) { + r.Reply(proto.NewCmd("you-are", "", u.InfoFor(u.Id()))) + r.From.UserId = u.Id() + r.From.PasswordAuthed = true + r.From.Subscribe(&u.PrivateStream) + } else { + r.Reply(proto.Fail{"incorrect-password", "", nil}.Cmd()) + } + default: + r.Reply(proto.Fail{"bad-auth-id", "", nil}.Cmd()) + } + default: r.ReplyInvalid() } diff --git a/server/session/session.go b/server/session/session.go index df7ab65..bb6749a 100644 --- a/server/session/session.go +++ b/server/session/session.go @@ -7,6 +7,7 @@ import ( type Session struct { send chan<- proto.Line UserId string + PasswordAuthed bool subscribedTo map[*Stream]bool sendError error } diff --git a/server/user/command.go b/server/user/command.go index 47a3efb..b4c6d5f 100644 --- a/server/user/command.go +++ b/server/user/command.go @@ -51,6 +51,43 @@ func (u *User) SendRequest(r session.Request) { u.Stream.Event(r.Cmd) r.ReplyOk() + case "auth-update": + if r.From.UserId != u.Id() { + r.Reply(proto.Fail{"forbidden", "", nil}.Cmd()) + return + } + if !u.anonymous && !r.From.PasswordAuthed { + r.Reply(proto.Fail{"password-required", "", nil}.Cmd()) + return + } + if len(r.Cmd.Args) != 1 { + r.ReplyInvalid() + return + } + upd := r.Cmd.Args[0] + switch upd.Kind { + case "password": + var password string + for k, v := range upd.Fields { + switch k { + case "": + password = v + default: + r.ReplyInvalid() + } + } + if password == "" { + r.ReplyInvalid() + return + } + u.SetPassword(password) + r.From.PasswordAuthed = true + r.ReplyOk() + default: + r.ReplyInvalid() + return + } + case "i": r.Reply(proto.NewCmd("i", "", u.InfoFor(r.From.UserId))) diff --git a/server/user/user.go b/server/user/user.go index 311a1b8..d2d5724 100644 --- a/server/user/user.go +++ b/server/user/user.go @@ -4,6 +4,7 @@ import ( "citrons.xyz/talk/server/object" "citrons.xyz/talk/server/session" "citrons.xyz/talk/server/validate" + "citrons.xyz/talk/server/passwords" "citrons.xyz/talk/proto" bolt "go.etcd.io/bbolt" "log" @@ -30,9 +31,16 @@ func Kind(world *object.World) *UserKind { } func (us *UserKind) CreateUser(name string) (*User, *proto.Fail) { - if us.ByName(name) != nil { + existing := us.ByName(name) + if existing != nil { + anon := "no" + if existing.anonymous { + anon = "yes" + } return nil, &proto.Fail { - "name-taken", "", map[string]string {"": name}, + "name-taken", "", map[string]string { + "": name, "anonymous": anon, "id": existing.Id(), + }, } } if !validate.Name(name) { @@ -157,6 +165,8 @@ func (u *User) Delete() { anons.Delete([]byte(u.id)) channels, _ := tx.CreateBucketIfNotExists([]byte("user channels")) channels.DeleteBucket([]byte(u.id)) + auth, _ := tx.CreateBucketIfNotExists([]byte("auth")) + auth.DeleteBucket([]byte(u.id)) return nil }) if err != nil { @@ -180,3 +190,40 @@ func (u *User) InfoFor(uid string) proto.Object { func (u *User) IsAnonymous() bool { return u.anonymous } + +func (u *User) CheckPassword(password string) bool { + var hash []byte + err := u.kind.db.View(func(tx *bolt.Tx) error { + auth := tx.Bucket([]byte("auth")) + if auth == nil { + return nil + } + userData := auth.Bucket([]byte(u.id)) + if userData == nil { + return nil + } + hash = userData.Get([]byte("password hash")) + return nil + }) + if err != nil { + log.Fatal("error reading database: ", err) + } + if hash == nil { + return false + } + return passwords.Check(password, hash) +} + +func (u *User) SetPassword(password string) { + err := u.kind.db.Update(func(tx *bolt.Tx) error { + auth, _ := tx.CreateBucketIfNotExists([]byte("auth")) + userData, _ := auth.CreateBucketIfNotExists([]byte(u.id)) + userData.Put([]byte("password hash"), passwords.Hash(password)) + return nil + }) + if err != nil { + log.Fatal("error updating database: ", err) + } + u.anonymous = false + u.Save() +} diff --git a/server/validate/validate.go b/server/validate/validate.go index 7aa7db0..7251895 100644 --- a/server/validate/validate.go +++ b/server/validate/validate.go @@ -21,12 +21,20 @@ func Fold(s string) string { var sb strings.Builder var wasSpace bool for _, r := range s { - for r < unicode.SimpleFold(r) { - r = unicode.SimpleFold(r) + for { + f := unicode.SimpleFold(r) + if f <= r { + r = f + break + } + r = f } + r = unicode.ToLower(r) + if !unicode.IsPrint(r) { continue } + if r == ' ' { if wasSpace { continue diff --git a/tui/text_input.go b/tui/text_input.go index 7f8e539..24792bc 100644 --- a/tui/text_input.go +++ b/tui/text_input.go @@ -13,8 +13,11 @@ type TextInput struct { selection string selectionBefore bool afterCursor string + Private bool } +var selectedStyle = Style {Bg: Blue, Fg: White, selected: true} + func (t *TextInput) Text() string { return t.beforeCursor + t.selection + t.afterCursor } @@ -225,19 +228,27 @@ func (t *TextInput) Update(ev Event) (usedKeybind bool) { return true } +func (t *TextInput) showText(text string, style *Style) { + if !t.Private { + Text(text, style) + } else { + Text(strings.Repeat("*", len([]rune(text))), style) + } +} + func (t *TextInput) Show(id string) { t.id = id Push(id, Box {Width: Fill, Height: TextSize}) - Text(t.beforeCursor, nil) + t.showText(t.beforeCursor, nil) if t.selectionBefore { - Text(t.selection, &Style {Bg: Blue, Fg: White, selected: true}) + t.showText(t.selection, &selectedStyle) } if t.selection == "" { Cursor() } if !t.selectionBefore { - Text(t.selection, &Style {Bg: Blue, Fg: White, selected: true}) + t.showText(t.selection, &selectedStyle) } - Text(t.afterCursor, nil) + t.showText(t.afterCursor, nil) Pop() } |
