feat: store accounts and per-device sessions for members

This commit is contained in:
DiheChen committed 2026-11-09 21:18:26 +08:00
1 parent 98a1605a83
commit d8f9d4e2ca
17 files changed
+1004 -73

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
-- +goose Up
-- 一个会话对应一个成员 token,同一个成员可以在多台设备上各有一个会话。
CREATE TABLE member_sessions (
token_hash bytea PRIMARY KEY,
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON member_sessions (member_id);
-- bot 成员只通过 API Key 使用 HTTP 接口,不需要会话。
INSERT INTO member_sessions (token_hash, member_id, created_at)
SELECT m.token_hash, m.id, m.created_at FROM members m
WHERE NOT EXISTS (SELECT FROM rooms r WHERE r.bot_member_id = m.id);
ALTER TABLE members DROP COLUMN token_hash;
-- 设置了用户名的成员是已注册的账号,否则是游客。用户名只能由小写字母、数字、下划线和连字符组成,所以按原文唯一即可。
-- 邮箱属于账号,在所有设备上一致;游客没有账号,不替匿名身份保存个人信息,邮箱只存在游客自己的浏览器里。
ALTER TABLE members
ADD COLUMN username text UNIQUE,
ADD COLUMN password_hash text,
ADD CONSTRAINT members_password_check CHECK (password_hash IS NULL OR username IS NOT NULL),
ADD COLUMN email text,
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL);
+40 -4
View File
@@ -1,14 +1,50 @@
-- name: CreateMember :one
INSERT INTO members (token_hash, name) VALUES (@token_hash, @name)
INSERT INTO members (name) VALUES (@name)
RETURNING *;
-- name: CreateSession :exec
INSERT INTO member_sessions (token_hash, member_id) VALUES (@token_hash, @member_id);
-- name: MemberByToken :one
SELECT * FROM members WHERE token_hash = @token_hash;
SELECT * FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = @token_hash);
-- name: UpdateMember :one
-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。
-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
UPDATE members
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
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END,
email = CASE WHEN username IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
WHERE id = @id
RETURNING *;
-- name: MemberAccountProfile :one
SELECT username, email 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: LockAccountPassword :one
-- 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id AND username IS NOT NULL FOR NO KEY UPDATE;
-- name: MemberHasSession :one
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = @token_hash AND member_id = @member_id);
-- name: LockMemberPassword :one
-- 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR NO KEY UPDATE;
-- name: DeleteSession :execrows
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;
+1 -1
View File
@@ -298,7 +298,7 @@ func TestAbortHandlerIsLeftToNetHTTP(t *testing.T) {
type brokenProfiles struct{ *porttest.Store }
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string) (member.Member, error) {
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string, *string) (member.Member, error) {
return member.Member{}, errors.New("连接中断")
}
+1 -1
View File
@@ -41,7 +41,7 @@ func (s *Store) ResetRoomAPIKey(ctx context.Context, roomID string, k port.NewAP
return err
}
} else {
m, err := q.CreateMember(ctx, query.CreateMemberParams{TokenHash: k.BotTokenHash, Name: botName})
m, err := q.CreateMember(ctx, botName)
if err != nil {
return err
}
+14 -1
View File
@@ -12,7 +12,7 @@ import (
)
func newAPIKey(key string) port.NewAPIKey {
return port.NewAPIKey{TokenHash: tokenHash(key), BotTokenHash: tokenHash("bot " + key)}
return port.NewAPIKey{TokenHash: tokenHash(key)}
}
func resetAPIKey(t *testing.T, store *Store, roomID, key string) {
@@ -150,3 +150,16 @@ func TestConcurrentFirstResetsCreateOneBot(t *testing.T) {
t.Errorf("有 %d 把 Key、%d 个 bot 成员,期望各一个", n, bots)
}
}
func TestRoomBotHasNoSession(t *testing.T) {
store, pool := newStore(t)
id := createRoom(t, store, port.NewRoom{Kind: room.Persistent, Name: "大厅"})
resetAPIKey(t, store, id, "key-1")
var sessions int
if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM member_sessions s JOIN rooms r ON r.bot_member_id = s.member_id WHERE r.id = $1", id).Scan(&sessions); err != nil {
t.Fatal(err)
}
if sessions != 0 {
t.Errorf("bot 成员有 %d 个会话,期望没有:bot 只通过 API Key 使用 HTTP 接口", sessions)
}
}
@@ -9,31 +9,128 @@ import (
"context"
)
const createMember = `-- name: CreateMember :one
INSERT INTO members (token_hash, name) VALUES ($1, $2)
RETURNING id, token_hash, name, avatar_hash, created_at
const accountByUsername = `-- name: AccountByUsername :one
SELECT id, name, avatar_hash, created_at, username, password_hash, email FROM members WHERE username = $1
`
type CreateMemberParams struct {
TokenHash []byte
Name string
}
func (q *Queries) CreateMember(ctx context.Context, arg CreateMemberParams) (Member, error) {
row := q.db.QueryRow(ctx, createMember, arg.TokenHash, arg.Name)
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.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
)
return i, err
}
const createMember = `-- name: CreateMember :one
INSERT INTO members (name) VALUES ($1)
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
`
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
row := q.db.QueryRow(ctx, createMember, name)
var i Member
err := row.Scan(
&i.ID,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
)
return i, err
}
const createSession = `-- name: CreateSession :exec
INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)
`
type CreateSessionParams struct {
TokenHash []byte
MemberID string
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) error {
_, err := q.db.Exec(ctx, createSession, arg.TokenHash, arg.MemberID)
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 :execrows
DELETE FROM member_sessions WHERE token_hash = $1
`
func (q *Queries) DeleteSession(ctx context.Context, tokenHash []byte) (int64, error) {
result, err := q.db.Exec(ctx, deleteSession, tokenHash)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const lockAccountPassword = `-- name: LockAccountPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 AND username IS NOT NULL FOR NO KEY UPDATE
`
// 用互斥的 FOR NO KEY UPDATE,不用 FOR SHARE:同一个账号的修改和重置依次进行,后一个读到的是前一个写入的密码哈希。
func (q *Queries) LockAccountPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockAccountPassword, id)
var password_hash string
err := row.Scan(&password_hash)
return password_hash, err
}
const lockMemberPassword = `-- name: LockMemberPassword :one
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = $1 FOR NO KEY UPDATE
`
// 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, error) {
row := q.db.QueryRow(ctx, lockMemberPassword, id)
var password_hash string
err := row.Scan(&password_hash)
return password_hash, err
}
const memberAccountProfile = `-- name: MemberAccountProfile :one
SELECT username, email FROM members WHERE id = $1
`
type MemberAccountProfileRow struct {
Username *string
Email *string
}
func (q *Queries) MemberAccountProfile(ctx context.Context, id string) (MemberAccountProfileRow, error) {
row := q.db.QueryRow(ctx, memberAccountProfile, id)
var i MemberAccountProfileRow
err := row.Scan(&i.Username, &i.Email)
return i, err
}
const memberByToken = `-- name: MemberByToken :one
SELECT id, token_hash, name, avatar_hash, created_at FROM members WHERE token_hash = $1
SELECT id, name, avatar_hash, created_at, username, password_hash, email 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) {
@@ -41,38 +138,102 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
)
return i, err
}
const memberHasSession = `-- name: MemberHasSession :one
SELECT EXISTS (SELECT FROM member_sessions WHERE token_hash = $1 AND member_id = $2)
`
type MemberHasSessionParams struct {
TokenHash []byte
MemberID string
}
func (q *Queries) MemberHasSession(ctx context.Context, arg MemberHasSessionParams) (bool, error) {
row := q.db.QueryRow(ctx, memberHasSession, arg.TokenHash, arg.MemberID)
var exists bool
err := row.Scan(&exists)
return exists, 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, token_hash, name, avatar_hash, created_at
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END,
email = CASE WHEN username IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
WHERE id = $4
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
`
type UpdateMemberParams struct {
Name *string
AvatarHash *string
Email *string
ID string
}
// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。
// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Member, error) {
row := q.db.QueryRow(ctx, updateMember, arg.Name, arg.AvatarHash, arg.ID)
row := q.db.QueryRow(ctx, updateMember,
arg.Name,
arg.AvatarHash,
arg.Email,
arg.ID,
)
var i Member
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Name,
&i.AvatarHash,
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
)
return i, err
}
@@ -9,11 +9,13 @@ import (
)
type Member struct {
ID string
TokenHash []byte
Name string
AvatarHash *string
CreatedAt time.Time
ID string
Name string
AvatarHash *string
CreatedAt time.Time
Username *string
PasswordHash *string
Email *string
}
type Room struct {
+137 -4
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"
@@ -15,11 +16,20 @@ import (
var _ port.Members = (*Store)(nil)
func (s *Store) CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error) {
row, err := s.q.CreateMember(ctx, query.CreateMemberParams{TokenHash: tokenHash, Name: name})
var m member.Member
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
row, err := q.CreateMember(ctx, name)
if err != nil {
return err
}
m = memberOf(row)
return q.CreateSession(ctx, query.CreateSessionParams{TokenHash: tokenHash, MemberID: row.ID})
})
if err != nil {
return member.Member{}, fmt.Errorf("创建成员:%w", err)
}
return memberOf(row), nil
return m, nil
}
func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error) {
@@ -33,8 +43,8 @@ func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Mem
return memberOf(row), nil
}
func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error) {
row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash})
func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error) {
row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash, Email: email})
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
@@ -47,3 +57,126 @@ 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) AccountProfile(ctx context.Context, memberID string) (port.AccountProfile, error) {
row, err := s.q.MemberAccountProfile(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return port.AccountProfile{}, fmt.Errorf("读取成员 %s 的账号资料:%w", memberID, err)
}
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email)}, 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), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}, nil
}
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
current, err := q.LockMemberPassword(ctx, memberID)
if err != nil {
return err
}
if current != passwordHash {
return port.ErrAccountPasswordChanged
}
n, err := q.DeleteSession(ctx, oldTokenHash)
if err != nil {
return err
}
if n == 0 {
return port.ErrSessionNotFound
}
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) SetPassword(ctx context.Context, memberID, passwordHash string) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
return setPassword(ctx, s.q.WithTx(tx), memberID, passwordHash, nil)
})
if err != nil {
return fmt.Errorf("修改成员 %s 的密码:%w", memberID, err)
}
return nil
}
func (s *Store) ChangePassword(ctx context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) error {
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
// 先锁住成员行,再用新的语句查会话:并发的修改或重置提交之后才开始查,才能看到它删掉的会话。
current, err := q.LockAccountPassword(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrMemberNotFound
}
if err != nil {
return err
}
kept, err := q.MemberHasSession(ctx, query.MemberHasSessionParams{TokenHash: keepTokenHash, MemberID: memberID})
if err != nil {
return err
}
if !kept {
return port.ErrSessionNotFound
}
if current != currentPasswordHash {
return port.ErrAccountPasswordChanged
}
return setPassword(ctx, q, memberID, passwordHash, keepTokenHash)
})
if err != nil {
return fmt.Errorf("修改成员 %s 的密码:%w", memberID, err)
}
return nil
}
func setPassword(ctx context.Context, q *query.Queries, memberID, passwordHash string, keepTokenHash []byte) error {
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})
}
+345 -5
View File
@@ -5,6 +5,9 @@ import (
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
@@ -59,13 +62,13 @@ func TestMemberAvatarIsNullWhenUnset(t *testing.T) {
if hash := avatar(); hash != "NULL" {
t.Errorf("新成员的 avatar_hash = %q,期望 NULL", hash)
}
if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346")); err != nil {
if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil); err != nil {
t.Fatal(err)
}
if hash := avatar(); hash != "0bc83cb571cd1c50ba6f3e8a78ef1346" {
t.Errorf("设置头像之后 avatar_hash = %q", hash)
}
got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""))
got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""), nil)
if err != nil {
t.Fatal(err)
}
@@ -78,14 +81,14 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
store, _ := newStore(t)
m := createMember(t, store, "alice", "游客-1a2b")
got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil)
got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil, nil)
if err != nil {
t.Fatal(err)
}
if want := (member.Member{ID: m.ID, Name: "甲"}); got != want {
t.Errorf("只改昵称 = %#v,期望 %#v", got, want)
}
got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"))
got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil)
if err != nil {
t.Fatal(err)
}
@@ -96,7 +99,344 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
t.Errorf("修改后按 token 查到 %#v,期望 %#v", found, got)
}
if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil); !errors.Is(err, port.ErrMemberNotFound) {
if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil, nil); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("修改不存在的成员,错误 = %v,期望 ErrMemberNotFound", err)
}
}
func TestUpdateMemberEmail(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
guest := createMember(t, store, "guest", "游客-3c4d")
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil {
t.Fatal(err)
}
column := func(id string) string {
t.Helper()
var email string
if err := pool.QueryRow(t.Context(), "SELECT coalesce(email, 'NULL') FROM members WHERE id = $1", id).Scan(&email); err != nil {
t.Fatal(err)
}
return email
}
profile := func() port.AccountProfile {
t.Helper()
p, err := store.AccountProfile(t.Context(), alice.ID)
if err != nil {
t.Fatal(err)
}
return p
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("Alice@Example.COM")); err != nil {
t.Fatal(err)
}
if got := profile(); got != (port.AccountProfile{Username: "alice", Email: "Alice@Example.COM"}) {
t.Errorf("保存邮箱之后 AccountProfile = %#v", got)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.Email != "Alice@Example.COM" {
t.Errorf("AccountByUsername 的邮箱 = %q, %v", account.Email, err)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, ptr("甲"), nil, nil); err != nil {
t.Fatal(err)
}
if got := profile().Email; got != "Alice@Example.COM" {
t.Errorf("邮箱参数为 nil 时应保持原值,得到 %q", got)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("b@example.com")); err != nil {
t.Fatal(err)
}
if got := profile().Email; got != "b@example.com" {
t.Errorf("更新邮箱之后 = %q", got)
}
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("")); err != nil {
t.Fatal(err)
}
if got := column(alice.ID); got != "NULL" {
t.Errorf("清除邮箱之后 email 列 = %q,期望 NULL", got)
}
if _, err := store.UpdateMember(t.Context(), guest.ID, nil, nil, ptr("g@example.com")); err != nil {
t.Fatal(err)
}
if got := column(guest.ID); got != "NULL" {
t.Errorf("游客传入邮箱之后 email 列 = %q,期望 NULL", got)
}
if p, err := store.AccountProfile(t.Context(), guest.ID); err != nil || p != (port.AccountProfile{}) {
t.Errorf("游客的 AccountProfile = %#v, %v", p, 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 profile, err := store.AccountProfile(t.Context(), alice.ID); err != nil || profile != (port.AccountProfile{}) {
t.Fatalf("游客的 AccountProfile = %#v, %v,期望两个字段都为空", profile, err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil {
t.Fatal(err)
}
if profile, err := store.AccountProfile(t.Context(), alice.ID); err != nil || profile != (port.AccountProfile{Username: "alice"}) {
t.Errorf("注册之后的 AccountProfile = %#v, %v,期望只有用户名 alice", profile, 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.AccountProfile(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.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
t.Fatal(err)
}
if err := store.ChangePassword(t.Context(), alice.ID, "old", "new", 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("ChangePassword 应只留下发出请求的会话")
}
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"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("给游客设置密码 = %v,期望 ErrMemberNotFound", err)
}
if err := store.ChangePassword(t.Context(), guest.ID, "", "hash", tokenHash("guest")); !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"), "old"); err != nil {
t.Fatal(err)
}
if err := store.ChangePassword(t.Context(), alice.ID, "old", "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"); err != nil {
t.Fatal(err)
}
if got := ownerOf(t, store, "alice-1"); got != "" {
t.Errorf("SetPassword 应删除全部会话,alice-1 仍属于 %s", got)
}
}
func TestChangePasswordChecksSessionAndPassword(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
createMember(t, store, "bob", "游客-5e6f")
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1"); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, current, keep string
want error
}{
{"发出请求的会话已经不存在", "h1", "gone", port.ErrSessionNotFound},
{"会话属于别的成员", "h1", "bob", port.ErrSessionNotFound},
{"密码哈希已经变了", "h0", "alice-1", port.ErrAccountPasswordChanged},
} {
if err := store.ChangePassword(t.Context(), alice.ID, tc.current, "h2", tokenHash(tc.keep)); !errors.Is(err, tc.want) {
t.Errorf("%s时 = %v,期望 %v", tc.name, err, tc.want)
}
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "h1" {
t.Errorf("失败之后的密码哈希 = %q, %v,期望不变的 h1", account.PasswordHash, err)
}
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != alice.ID {
t.Error("失败时不应删除任何会话")
}
}
// 模拟 SetPassword 或另一次 ChangePassword 的事务,先不提交:改密码哈希,删除 keep 以外的会话,keep 为 nil 时全部删除。
func changePasswordMeanwhile(t *testing.T, store *Store, pool *pgxpool.Pool, memberID string, keep []byte, want error) {
t.Helper()
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "UPDATE members SET password_hash = 'h2' WHERE id = $1", memberID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE member_id = $1 AND token_hash IS DISTINCT FROM $2", memberID, keep); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
result <- store.ChangePassword(t.Context(), memberID, "h1", "h3", tokenHash("alice-1"))
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if err := <-result; !errors.Is(err, want) {
t.Errorf("另一次修改提交之后,ChangePassword = %v,期望 %v", err, want)
}
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.PasswordHash != "h2" {
t.Errorf("密码哈希 = %q, %v,期望先提交的那次写入的 h2", account.PasswordHash, err)
}
}
func TestChangePasswordWaitsForReset(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
changePasswordMeanwhile(t, store, pool, alice.ID, nil, port.ErrSessionNotFound)
}
func TestChangePasswordWaitsForSameSessionChange(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
changePasswordMeanwhile(t, store, pool, alice.ID, tokenHash("alice-1"), port.ErrAccountPasswordChanged)
if ownerOf(t, store, "alice-1") != alice.ID {
t.Error("同一个会话先改过密码,这个会话应仍然有效")
}
}
func TestReplaceSessionChecksGuestSessionAndPassword(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
if err := store.ReplaceSession(t.Context(), tokenHash("gone"), alice.ID, tokenHash("alice-2"), "h1"); !errors.Is(err, port.ErrSessionNotFound) {
t.Errorf("游客的会话已经删除时 = %v,期望 ErrSessionNotFound", err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h0"); !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Errorf("密码哈希已经变了时 = %v,期望 ErrAccountPasswordChanged", err)
}
if ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "guest") == "" {
t.Error("失败时不应创建新会话,也不应删除游客的会话")
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1"); err != nil {
t.Fatal(err)
}
if err := store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-3"), "h1"); !errors.Is(err, port.ErrSessionNotFound) {
t.Errorf("同一个游客会话再登录一次 = %v,期望 ErrSessionNotFound", err)
}
if ownerOf(t, store, "alice-3") != "" {
t.Error("同一个游客会话只能换出一个新会话")
}
}
func TestReplaceSessionWaitsForPasswordChange(t *testing.T) {
store, pool := newStore(t)
alice := createMember(t, store, "alice-1", "游客-1a2b")
if err := store.Register(t.Context(), alice.ID, "alice", "h1"); err != nil {
t.Fatal(err)
}
createMember(t, store, "guest", "游客-3c4d")
// 模拟 SetPassword 的事务,先不提交。
tx, err := pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if _, err := tx.Exec(t.Context(), "UPDATE members SET password_hash = 'h2' WHERE id = $1", alice.ID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE member_id = $1", alice.ID); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
result <- store.ReplaceSession(t.Context(), tokenHash("guest"), alice.ID, tokenHash("alice-2"), "h1")
}()
waitForLockWait(t, pool, 1)
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
if err := <-result; !errors.Is(err, port.ErrAccountPasswordChanged) {
t.Errorf("改密码提交之后,登录的错误 = %v,期望 ErrAccountPasswordChanged", err)
}
if got := ownerOf(t, store, "alice-2"); got != "" {
t.Errorf("改密码之后还留下了按旧密码签发的会话,属于 %s", got)
}
}
func TestOnlyAccountsHavePasswordOrEmail(t *testing.T) {
store, pool := newStore(t)
guest := createMember(t, store, "guest", "游客-1a2b")
for _, c := range []struct{ set, constraint string }{
{"password_hash = 'hash'", "members_password_check"},
{"email = 'guest@example.com'", "members_email_check"},
} {
_, err := pool.Exec(t.Context(), "UPDATE members SET "+c.set+" WHERE id = $1", guest.ID)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23514" || pgErr.ConstraintName != c.constraint {
t.Errorf("给没有用户名的游客设置 %s,错误 = %v,期望违反 %s", c.set, err, c.constraint)
}
}
}
@@ -11,6 +11,11 @@ import (
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"github.com/dihemoe/alisten/db/migrations"
"github.com/dihemoe/alisten/internal/app/port"
)
func migrate(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) error {
@@ -128,3 +133,49 @@ func TestMigrationLogsKeepFixedFields(t *testing.T) {
}
}
}
func TestMemberSessionsMigrationKeepsMemberTokens(t *testing.T) {
ctx := t.Context()
name := fmt.Sprintf("test_%d", databases.Add(1))
if _, err := admin.Exec(ctx, "CREATE DATABASE "+name); err != nil {
t.Fatal(err)
}
cfg := server.Copy()
cfg.ConnConfig.Database = name
pool := connect(t, cfg)
db := stdlib.OpenDBFromPool(pool)
t.Cleanup(func() { _ = db.Close() })
provider, err := goose.NewProvider(goose.DialectPostgres, db, migrations.FS)
if err != nil {
t.Fatal(err)
}
if _, err := provider.UpTo(ctx, 2); err != nil {
t.Fatal(err)
}
created := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
var guest, bot string
if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name, created_at) VALUES ($1, '游客-1a2b', $2) RETURNING id", tokenHash("guest"), created).Scan(&guest); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name) VALUES ($1, 'bot') RETURNING id", tokenHash("bot")).Scan(&bot); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "INSERT INTO rooms (kind, name, description, bot_member_id) VALUES ('persistent', '大厅', '', $1)", bot); err != nil {
t.Fatal(err)
}
if _, err := provider.Up(ctx); err != nil {
t.Fatal(err)
}
store := New(pool)
if m, err := store.MemberByToken(ctx, tokenHash("guest")); err != nil || m.ID != guest {
t.Errorf("迁移之后按原来的 token 找到 %#v, %v,期望成员 %s", m, err, guest)
}
var sessionCreated time.Time
if err := pool.QueryRow(ctx, "SELECT created_at FROM member_sessions WHERE member_id = $1", guest).Scan(&sessionCreated); err != nil || !sessionCreated.Equal(created) {
t.Errorf("会话的创建时间 = %v, %v,期望沿用成员的创建时间 %v", sessionCreated, err, created)
}
if _, err := store.MemberByToken(ctx, tokenHash("bot")); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("bot 成员原来的 token 在迁移之后 = %v,期望不再有效", err)
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ func addTicket(t *testing.T, pool *pgxpool.Pool, roomID string) {
t.Helper()
_, err := pool.Exec(t.Context(), `
WITH m AS (
INSERT INTO members (token_hash, name) VALUES (sha256(gen_random_uuid()::text::bytea), '甲')
INSERT INTO members (name) VALUES ('甲')
RETURNING id
)
INSERT INTO room_tickets (token_hash, room_id, member_id) SELECT sha256(gen_random_uuid()::text::bytea), $1, id FROM m`,
@@ -24,7 +24,7 @@ func TestTicketHolder(t *testing.T) {
t.Errorf("TicketHolder = %#v, %v,期望 %#v", got, err, want)
}
renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil)
renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil, nil)
if err != nil {
t.Fatal(err)
}
+2 -4
View File
@@ -74,7 +74,7 @@ func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile)
hash := avatarHash(email)
avatar = &hash
}
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar)
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar, nil)
if err != nil {
return member.Member{}, err
}
@@ -140,9 +140,7 @@ func (s *Service) EnterRoom(ctx context.Context, roomID, ticket string) (*realti
return s.gateway.Join(logctx.WithMember(logctx.WithRoom(ctx, r.Info.ID), holder.Member.ID), r.Info.ID, holder.Member)
}
// 每次都准备好 bot 成员的 token:房间还没有 bot 成员时由存储用它创建,这个随机值不交给任何人。
func NewAPIKey() (string, port.NewAPIKey) {
key, hash := newSecret()
_, botHash := newSecret()
return key, port.NewAPIKey{TokenHash: hash, BotTokenHash: botHash}
return key, port.NewAPIKey{TokenHash: hash}
}
-2
View File
@@ -8,8 +8,6 @@ import (
// 常驻房间的 API Key 是房间 bot 成员的票据。
type NewAPIKey struct {
TokenHash []byte
// 房间还没有 bot 成员时用它创建:对应的随机值不交给任何人,bot 因此没有可用的成员 token。
BotTokenHash []byte
}
type APIKeyStatus struct {
+39 -4
View File
@@ -7,12 +7,47 @@ import (
"github.com/dihemoe/alisten/internal/domain/member"
)
var ErrMemberNotFound = errors.New("成员不存在")
var (
ErrMemberNotFound = errors.New("成员不存在")
ErrUsernameTaken = errors.New("用户名已被占用")
ErrAlreadyRegistered = errors.New("已经注册过了")
ErrSessionNotFound = errors.New("会话不存在")
ErrAccountPasswordChanged = errors.New("账号密码已修改")
)
// 成员 token 只以 SHA-256 哈希的形式出现在端口中。
// 设置了用户名的成员。
type Account struct {
Member member.Member
Username string
Email string
PasswordHash string
}
// 游客的两个字段都为空。
type AccountProfile struct {
Username string
Email 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)
// name、avatarHash 或 email 为 nil 时保持原值,email 为空字符串表示清除。游客的 email 不保存。
UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error)
AccountProfile(ctx context.Context, memberID string) (AccountProfile, error)
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。
Register(ctx context.Context, memberID, username, passwordHash string) error
AccountByUsername(ctx context.Context, username string) (Account, error)
// 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。oldTokenHash 的会话已经不存在时返回
// ErrSessionNotFound;memberID 的密码哈希已经不是 passwordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改过,
// 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。
ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error
DeleteSession(ctx context.Context, tokenHash []byte) error
// 在同一个事务中修改已注册成员的密码哈希,并删除该成员的全部会话。成员是游客时返回 ErrMemberNotFound。
SetPassword(ctx context.Context, memberID, passwordHash string) error
// 与 SetPassword 相同,但保留发出请求的 keepTokenHash 的会话。这个会话已经不存在时返回 ErrSessionNotFound;密码哈希已经不是
// 校验当前密码时读到的 currentPasswordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改或重置过。两种情况都不做任何修改。
ChangePassword(ctx context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) error
}
+145 -21
View File
@@ -23,9 +23,11 @@ type Store struct {
mu sync.Mutex
ids int
members []storedMember
rooms []port.RoomRecord
states map[string]*roomState
tickets map[string]storedTicket
// 会话的 token 哈希到成员 ID。
sessions map[string]string
rooms []port.RoomRecord
states map[string]*roomState
tickets map[string]storedTicket
// 房间 ID 到 bot 成员 ID。
bots map[string]string
cookies map[string]string
@@ -33,8 +35,10 @@ type Store struct {
}
type storedMember struct {
tokenHash string
member member.Member
member member.Member
username string
email string
passwordHash string
}
type storedTicket struct {
@@ -54,10 +58,11 @@ var (
func New() *Store {
return &Store{
states: map[string]*roomState{},
tickets: map[string]storedTicket{},
bots: map[string]string{},
cookies: map[string]string{},
states: map[string]*roomState{},
sessions: map[string]string{},
tickets: map[string]storedTicket{},
bots: map[string]string{},
cookies: map[string]string{},
}
}
@@ -84,35 +89,48 @@ func (s *Store) CreateMember(_ context.Context, tokenHash []byte, name string) (
}
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if m.tokenHash == string(tokenHash) {
return member.Member{}, errors.New("token 哈希重复")
}
if _, ok := s.sessions[string(tokenHash)]; ok {
return member.Member{}, errors.New("token 哈希重复")
}
m := member.Member{ID: s.newID(), Name: name}
s.members = append(s.members, storedMember{tokenHash: string(tokenHash), member: m})
s.members = append(s.members, storedMember{member: m})
s.sessions[string(tokenHash)] = m.ID
return m, nil
}
func (s *Store) MemberByToken(_ context.Context, tokenHash []byte) (member.Member, error) {
s.mu.Lock()
defer s.mu.Unlock()
for _, m := range s.members {
if m.tokenHash == string(tokenHash) {
return m.member, nil
}
if m := s.member(s.sessions[string(tokenHash)]); m != nil {
return m.member, nil
}
return member.Member{}, port.ErrMemberNotFound
}
func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *string) (member.Member, error) {
// 调用方持有 s.mu。
func (s *Store) member(id string) *storedMember {
for i := range s.members {
if s.members[i].member.ID == id {
return &s.members[i]
}
}
return nil
}
func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash, email *string) (member.Member, error) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.members {
m := &s.members[i].member
stored := &s.members[i]
m := &stored.member
if m.ID != id {
continue
}
if email != nil {
if err := checkText(*email); err != nil {
return member.Member{}, err
}
}
if name != nil {
if err := checkText(*name); err != nil {
return member.Member{}, err
@@ -122,11 +140,117 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *str
if avatarHash != nil {
m.AvatarHash = *avatarHash
}
if email != nil && stored.username != "" {
stored.email = *email
}
return *m, nil
}
return member.Member{}, port.ErrMemberNotFound
}
func (s *Store) AccountProfile(_ context.Context, memberID string) (port.AccountProfile, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return port.AccountProfile{}, port.ErrMemberNotFound
}
return port.AccountProfile{Username: m.username, Email: m.email}, 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, Email: m.email, PasswordHash: m.passwordHash}, nil
}
}
return port.Account{}, port.ErrMemberNotFound
}
func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return fmt.Errorf("成员 %s 不存在", memberID)
}
if m.passwordHash != passwordHash {
return port.ErrAccountPasswordChanged
}
if _, ok := s.sessions[string(oldTokenHash)]; !ok {
return port.ErrSessionNotFound
}
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) SetPassword(_ context.Context, memberID, passwordHash string) 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, nil)
return nil
}
func (s *Store) ChangePassword(_ context.Context, memberID, currentPasswordHash, passwordHash string, keepTokenHash []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil || m.username == "" {
return port.ErrMemberNotFound
}
if owner, ok := s.sessions[string(keepTokenHash)]; !ok || owner != memberID {
return port.ErrSessionNotFound
}
if m.passwordHash != currentPasswordHash {
return port.ErrAccountPasswordChanged
}
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()
@@ -194,7 +318,7 @@ func (s *Store) ResetRoomAPIKey(_ context.Context, roomID string, k port.NewAPIK
maps.DeleteFunc(s.tickets, func(_ string, t storedTicket) bool { return t.roomID == r.Info.ID && t.memberID == bot })
} else {
bot = s.newID()
s.members = append(s.members, storedMember{tokenHash: string(k.BotTokenHash), member: member.Member{ID: bot, Name: "bot"}})
s.members = append(s.members, storedMember{member: member.Member{ID: bot, Name: "bot"}})
s.bots[r.Info.ID] = bot
}
s.tickets[string(k.TokenHash)] = storedTicket{roomID: r.Info.ID, memberID: bot, createdAt: time.Now()}
+15
View File
@@ -0,0 +1,15 @@
package porttest
import "testing"
func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
if err != nil {
t.Fatal(err)
}
bad := "a\x00@example.com"
if _, err := store.UpdateMember(t.Context(), m.ID, nil, nil, &bad); err == nil {
t.Error("含 NUL 的邮箱应报错")
}
}