feat: store accounts and per-device sessions for members
This commit is contained in:
1 parent
90a3b26c77
commit
a61df45c32
17 files changed
+988
-73
No files matched your search
@@ -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 CHECK (password_hash IS NULL OR username IS NOT NULL),
|
||||
ADD COLUMN email text,
|
||||
ADD CHECK (email IS NULL OR username IS NOT NULL);
|
||||
+40
-4
@@ -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
|
||||
-- FOR SHARE 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
|
||||
SELECT coalesce(password_hash, '')::text AS password_hash FROM members WHERE id = @id FOR SHARE;
|
||||
|
||||
-- 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;
|
||||
@@ -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("连接中断")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 SHARE
|
||||
`
|
||||
|
||||
// FOR SHARE 让修改密码等到这次登录提交之后,它随后删除会话时会删掉这次登录签发的会话。
|
||||
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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
)
|
||||
@@ -59,13 +61,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 +80,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 +98,329 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
// 常驻房间的 API Key 是房间 bot 成员的票据。
|
||||
type NewAPIKey struct {
|
||||
TokenHash []byte
|
||||
// 房间还没有 bot 成员时用它创建:对应的随机值不交给任何人,bot 因此没有可用的成员 token。
|
||||
BotTokenHash []byte
|
||||
}
|
||||
|
||||
type APIKeyStatus struct {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 的邮箱应报错")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user