feat: store accounts and per-device sessions for members

This commit is contained in:
DiheChen committed 2026-11-08 21:46:22 +08:00
1 parent 4ed246dc49
commit 0bc5568335
8 files changed
+437 -9

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
-- +goose Up
-- 设置了用户名的成员是已注册的账号,否则是游客。用户名只能由小写字母、数字、下划线和连字符组成,所以按原文唯一即可。
ALTER TABLE members
ADD COLUMN username text UNIQUE,
ADD COLUMN password_hash text,
ADD CHECK (password_hash IS NULL OR username IS NOT NULL);
+21
View File
@@ -15,3 +15,24 @@ SET name = coalesce(sqlc.narg(name), name),
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END
WHERE id = @id
RETURNING *;
-- name: MemberUsername :one
SELECT username FROM members WHERE id = @id;
-- name: RegisterMember :execrows
-- 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
UPDATE members SET username = @username, password_hash = @password_hash
WHERE id = @id AND username IS NULL;
-- name: AccountByUsername :one
SELECT * FROM members WHERE username = @username;
-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = @password_hash WHERE id = @id AND username IS NOT NULL;
-- name: DeleteSession :exec
DELETE FROM member_sessions WHERE token_hash = @token_hash;
-- name: DeleteMemberSessions :exec
-- keep 为 NULL 时删除这个成员的全部会话。
DELETE FROM member_sessions WHERE member_id = @member_id AND token_hash IS DISTINCT FROM sqlc.narg(keep)::bytea;
@@ -9,9 +9,27 @@ import (
"context"
)
const accountByUsername = `-- name: AccountByUsername :one
SELECT id, name, avatar_hash, created_at, username, password_hash FROM members WHERE username = $1
`
func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) {
row := q.db.QueryRow(ctx, accountByUsername, username)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
)
return i, err
}
const createMember = `-- name: CreateMember :one
INSERT INTO members (name) VALUES ($1)
RETURNING id, name, avatar_hash, created_at
RETURNING id, name, avatar_hash, created_at, username, password_hash
`
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
@@ -22,6 +40,8 @@ func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error)
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
)
return i, err
}
@@ -40,8 +60,32 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er
return err
}
const deleteMemberSessions = `-- name: DeleteMemberSessions :exec
DELETE FROM member_sessions WHERE member_id = $1 AND token_hash IS DISTINCT FROM $2::bytea
`
type DeleteMemberSessionsParams struct {
MemberID string
Keep []byte
}
// keep 为 NULL 时删除这个成员的全部会话。
func (q *Queries) DeleteMemberSessions(ctx context.Context, arg DeleteMemberSessionsParams) error {
_, err := q.db.Exec(ctx, deleteMemberSessions, arg.MemberID, arg.Keep)
return err
}
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM member_sessions WHERE token_hash = $1
`
func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) error {
_, err := q.db.Exec(ctx, deleteSession, tokenHash)
return err
}
const memberByToken = `-- name: MemberByToken :one
SELECT id, name, avatar_hash, created_at FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
SELECT id, name, avatar_hash, created_at, username, password_hash FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
`
func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) {
@@ -52,16 +96,66 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
)
return i, err
}
const memberUsername = `-- name: MemberUsername :one
SELECT username FROM members WHERE id = $1
`
func (q *Queries) MemberUsername(ctx context.Context, id string) (*string, error) {
row := q.db.QueryRow(ctx, memberUsername, id)
var username *string
err := row.Scan(&username)
return username, err
}
const registerMember = `-- name: RegisterMember :execrows
UPDATE members SET username = $1, password_hash = $2
WHERE id = $3 AND username IS NULL
`
type RegisterMemberParams struct {
Username *string
PasswordHash *string
ID string
}
// 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
func (q *Queries) RegisterMember(ctx context.Context, arg RegisterMemberParams) (int64, error) {
result, err := q.db.Exec(ctx, registerMember, arg.Username, arg.PasswordHash, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const setMemberPassword = `-- name: SetMemberPassword :execrows
UPDATE members SET password_hash = $1 WHERE id = $2 AND username IS NOT NULL
`
type SetMemberPasswordParams struct {
PasswordHash *string
ID string
}
func (q *Queries) SetMemberPassword(ctx context.Context, arg SetMemberPasswordParams) (int64, error) {
result, err := q.db.Exec(ctx, setMemberPassword, arg.PasswordHash, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateMember = `-- name: UpdateMember :one
UPDATE members
SET name = coalesce($1, name),
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END
WHERE id = $3
RETURNING id, name, avatar_hash, created_at
RETURNING id, name, avatar_hash, created_at, username, password_hash
`
type UpdateMemberParams struct {
@@ -79,6 +173,8 @@ func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Mem
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
)
return i, err
}
@@ -9,10 +9,12 @@ import (
)
type Member struct {
ID string
Name string
AvatarHash *string
CreatedAt time.Time
ID string
Name string
AvatarHash *string
CreatedAt time.Time
Username *string
PasswordHash *string
}
type Room struct {
+88
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
@@ -56,3 +57,90 @@ func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *s
func memberOf(row query.Member) member.Member {
return member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)}
}
func (s *Store) Username(ctx context.Context, memberID string) (string, error) {
username, err := s.q.MemberUsername(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return "", fmt.Errorf("读取成员 %s 的用户名:%w", memberID, err)
}
return orEmpty(username), nil
}
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
n, err := s.q.RegisterMember(ctx, query.RegisterMemberParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
if err != nil {
return fmt.Errorf("注册成员 %s:%w", memberID, usernameError(err))
}
if n == 0 {
return fmt.Errorf("注册成员 %s:%w", memberID, port.ErrAlreadyRegistered)
}
return nil
}
func usernameError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_username_key" {
return port.ErrUsernameTaken
}
return err
}
func (s *Store) AccountByUsername(ctx context.Context, username string) (port.Account, error) {
row, err := s.q.AccountByUsername(ctx, &username)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.Account{}, fmt.Errorf("按用户名查找成员:%w", err)
}
return port.Account{Member: memberOf(row), Username: orEmpty(row.Username), PasswordHash: orEmpty(row.PasswordHash)}, nil
}
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
if err := q.DeleteSession(ctx, oldTokenHash); err != nil {
return err
}
return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: newTokenHash, MemberID: memberID})
})
if err != nil {
return fmt.Errorf("给成员 %s 更换会话:%w", memberID, err)
}
return nil
}
func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error {
if err := s.q.DeleteSession(ctx, tokenHash); err != nil {
return fmt.Errorf("删除会话:%w", err)
}
return nil
}
func (s *Store) DeleteSessions(ctx context.Context, memberID string, keepTokenHash []byte) error {
if err := s.q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash}); err != nil {
return fmt.Errorf("删除成员 %s 的会话:%w", memberID, err)
}
return nil
}
func (s *Store) SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
n, err := q.SetMemberPassword(ctx, query.SetMemberPasswordParams{ID: memberID, PasswordHash: &passwordHash})
if err != nil {
return err
}
if n == 0 {
return port.ErrMemberNotFound
}
return q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
})
if err != nil {
return fmt.Errorf("修改成员 %s 的密码:%w", memberID, err)
}
return nil
}
@@ -100,3 +100,107 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
t.Errorf("修改不存在的成员,错误 = %v,期望 ErrMemberNotFound", err)
}
}
func ownerOf(t *testing.T, store *Store, token string) string {
t.Helper()
m, err := store.MemberByToken(t.Context(), tokenHash(token))
if errors.Is(err, port.ErrMemberNotFound) {
return ""
}
if err != nil {
t.Fatal(err)
}
return m.ID
}
func TestRegisterMember(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if username, err := store.Username(t.Context(), alice.ID); err != nil || username != "" {
t.Fatalf("游客的用户名 = %q, %v,期望空字符串", username, err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil {
t.Fatal(err)
}
if username, err := store.Username(t.Context(), alice.ID); err != nil || username != "alice" {
t.Errorf("注册之后的用户名 = %q, %v,期望 alice", username, err)
}
account, err := store.AccountByUsername(t.Context(), "alice")
if want := (port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a"}); err != nil || account != want {
t.Errorf("AccountByUsername = %#v, %v,期望 %#v", account, err, want)
}
if err := store.Register(t.Context(), bob.ID, "alice", "hash-b"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名重复时 = %v,期望 ErrUsernameTaken", err)
}
if err := store.Register(t.Context(), alice.ID, "alice2", "hash-c"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("已经注册过的成员再注册 = %v,期望 ErrAlreadyRegistered", err)
}
if _, err := store.AccountByUsername(t.Context(), "nobody"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的用户名 = %v,期望 ErrMemberNotFound", err)
}
if _, err := store.Username(t.Context(), "00000000-0000-7000-8000-000000000000"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员 = %v,期望 ErrMemberNotFound", err)
}
}
func TestMemberSessions(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
createMember(t, store, "guest-1", "游客-3c4d")
createMember(t, store, "guest-2", "游客-5e6f")
for _, s := range []struct{ guest, token string }{{"guest-1", "alice-2"}, {"guest-2", "alice-3"}} {
if err := store.ReplaceSession(t.Context(), tokenHash(s.guest), alice.ID, tokenHash(s.token)); err != nil {
t.Fatal(err)
}
if got := ownerOf(t, store, s.guest); got != "" {
t.Errorf("换掉之后 %s 仍属于成员 %s", s.guest, got)
}
if got := ownerOf(t, store, s.token); got != alice.ID {
t.Errorf("%s 属于成员 %q,期望 %s", s.token, got, alice.ID)
}
}
if err := store.DeleteSessions(t.Context(), alice.ID, tokenHash("alice-1")); err != nil {
t.Fatal(err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice-3") != "" {
t.Error("DeleteSessions 应只留下指定的会话")
}
if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil {
t.Fatal(err)
}
if got := ownerOf(t, store, "alice-1"); got != "" {
t.Errorf("删除之后 alice-1 仍属于成员 %s", got)
}
}
func TestSetPassword(t *testing.T) {
store, _ := newStore(t)
guest := createMember(t, store, "guest", "游客-1a2b")
if err := store.SetPassword(t.Context(), guest.ID, "hash", nil); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("给游客设置密码 = %v,期望 ErrMemberNotFound", err)
}
alice := createMember(t, store, "alice-1", "游客-3c4d")
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
}
createMember(t, store, "other", "游客-5e6f")
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2")); err != nil {
t.Fatal(err)
}
if err := store.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1")); err != nil {
t.Fatal(err)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "new" {
t.Errorf("修改之后的密码哈希 = %q, %v,期望 new", account.PasswordHash, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" {
t.Error("修改密码应只留下指定的会话")
}
if err := store.SetPassword(t.Context(), alice.ID, "newer", nil); err != nil {
t.Fatal(err)
}
if got := ownerOf(t, store, "alice-1"); got != "" {
t.Errorf("不指定保留的会话时应删除全部会话,alice-1 仍属于 %s", got)
}
}
+25 -1
View File
@@ -7,12 +7,36 @@ import (
"github.com/dihemoe/alisten/internal/domain/member"
)
var ErrMemberNotFound = errors.New("成员不存在")
var (
ErrMemberNotFound = errors.New("成员不存在")
ErrUsernameTaken = errors.New("用户名已被占用")
ErrAlreadyRegistered = errors.New("已经注册过了")
)
// 设置了用户名的成员。
type Account struct {
Member member.Member
Username string
PasswordHash string
}
// 成员 token 只以 SHA-256 哈希的形式出现在端口中。一个成员 token 是成员的一个会话,同一个成员可以有多个会话。
type Members interface {
// 创建游客和它的第一个会话。
CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error)
MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error)
// name 或 avatarHash 为 nil 时保持原值。
UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error)
// 游客的用户名是空字符串。
Username(ctx context.Context, memberID string) (string, error)
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。
Register(ctx context.Context, memberID, username, passwordHash string) error
AccountByUsername(ctx context.Context, username string) (Account, error)
// 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。
ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte) error
DeleteSession(ctx context.Context, tokenHash []byte) error
// 删除成员除 keepTokenHash 以外的会话,keepTokenHash 为 nil 时全部删除。
DeleteSessions(ctx context.Context, memberID string, keepTokenHash []byte) error
// 在同一个事务中修改已注册成员的密码哈希,并像 DeleteSessions 一样删除会话。成员是游客时返回 ErrMemberNotFound。
SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) error
}
+88 -1
View File
@@ -35,7 +35,9 @@ type Store struct {
}
type storedMember struct {
member member.Member
member member.Member
username string
passwordHash string
}
type storedTicket struct {
@@ -136,6 +138,91 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *str
return member.Member{}, port.ErrMemberNotFound
}
func (s *Store) Username(_ context.Context, memberID string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return "", port.ErrMemberNotFound
}
return m.username, nil
}
func (s *Store) Register(_ context.Context, memberID, username, passwordHash string) error {
if err := checkText(username, passwordHash); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username != "" {
return port.ErrAlreadyRegistered
}
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.username == username }) {
return port.ErrUsernameTaken
}
m.username, m.passwordHash = username, passwordHash
return nil
}
func (s *Store) AccountByUsername(_ context.Context, username string) (port.Account, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if username != "" && m.username == username {
return port.Account{Member: m.member, Username: m.username, PasswordHash: m.passwordHash}, nil
}
}
return port.Account{}, port.ErrMemberNotFound
}
func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.member(memberID) == nil {
return fmt.Errorf("成员 %s 不存在", memberID)
}
if _, ok := s.sessions[string(newTokenHash)]; ok {
return errors.New("token 哈希重复")
}
delete(s.sessions, string(oldTokenHash))
s.sessions[string(newTokenHash)] = memberID
return nil
}
func (s *Store) DeleteSession(_ context.Context, tokenHash []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, string(tokenHash))
return nil
}
func (s *Store) DeleteSessions(_ context.Context, memberID string, keepTokenHash []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
s.deleteSessions(memberID, keepTokenHash)
return nil
}
func (s *Store) SetPassword(_ context.Context, memberID, passwordHash string, keepTokenHash []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username == "" {
return port.ErrMemberNotFound
}
m.passwordHash = passwordHash
s.deleteSessions(memberID, keepTokenHash)
return nil
}
// 调用方持有 s.mu。
func (s *Store) deleteSessions(memberID string, keepTokenHash []byte) {
maps.DeleteFunc(s.sessions, func(hash, id string) bool {
return id == memberID && (keepTokenHash == nil || hash != string(keepTokenHash))
})
}
func (s *Store) CreateTicket(_ context.Context, t port.NewTicket) error {
s.mu.Lock()
defer s.mu.Unlock()