feat: register, log in and manage member accounts

This commit is contained in:
DiheChen committed 2026-11-08 21:49:50 +08:00
1 parent 4f056e91d4
commit d8a79e8bc8
4 files changed
+474 -2

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
package identity
import (
"context"
"errors"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
)
var (
ErrRegistrationClosed = errors.New("注册已关闭")
ErrAlreadyLoggedIn = errors.New("已经登录了账号,先退出再登录其他账号")
ErrInvalidCredentials = errors.New("用户名或密码错误")
ErrWrongCurrentPassword = errors.New("当前密码错误")
ErrNotRegistered = errors.New("还没有注册账号")
ErrTooManyAttempts = errors.New("尝试次数太多,请稍后再试。")
ErrAccountNotFound = errors.New("没有这个用户名的账号")
)
// 注册、登录和修改密码都要做一次 argon2id 计算:额度既防止猜密码,也防止耗尽计算槽位。
const (
registerBurst = 5
registerRefill = 10 * time.Minute
loginIPBurst = 10
loginIPRefill = time.Minute
loginUserBurst = 5
loginUserRefill = time.Minute
passwordBurst = 5
passwordRefill = time.Minute
)
type Account struct {
Username string
}
type Accounts struct {
members port.Members
site port.Site
quota port.AccountQuota
}
func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota) *Accounts {
return &Accounts{members: members, site: site, quota: quota}
}
// 游客没有账号,ok 为 false。
func (a *Accounts) Account(ctx context.Context, memberID string) (account Account, ok bool, err error) {
username, err := a.members.Username(ctx, memberID)
if err != nil {
return Account{}, false, err
}
return Account{Username: username}, username != "", nil
}
// 给游客设置用户名和密码,成员和它的会话都不变。username 和 password 已经按协议校验过格式。
func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, password string) (Account, error) {
settings, err := a.site.Settings(ctx)
if err != nil {
return Account{}, err
}
if !settings.RegistrationOpen {
return Account{}, ErrRegistrationClosed
}
if err := a.take(ctx, "register:ip:"+clientIP, registerBurst, registerRefill); err != nil {
return Account{}, err
}
hash, err := HashPassword(ctx, password)
if err != nil {
return Account{}, err
}
if err := a.members.Register(ctx, memberID, username, hash); err != nil {
return Account{}, err
}
return Account{Username: username}, nil
}
// current 是发出请求的成员,currentToken 是它的成员 token。只有游客能登录:成功后签发账号的新会话,游客的这个会话随即失效。
func (a *Accounts) Login(ctx context.Context, current member.Member, currentToken, clientIP, username, password string) (Session, Account, error) {
if _, registered, err := a.Account(ctx, current.ID); err != nil {
return Session{}, Account{}, err
} else if registered {
return Session{}, Account{}, ErrAlreadyLoggedIn
}
// 按 IP 限制一个来源的尝试次数,按用户名限制一个账号被尝试的次数:换着 IP 猜同一个账号也会受限。
if err := a.take(ctx, "login:ip:"+clientIP, loginIPBurst, loginIPRefill); err != nil {
return Session{}, Account{}, err
}
if err := a.take(ctx, "login:user:"+username, loginUserBurst, loginUserRefill); err != nil {
return Session{}, Account{}, err
}
account, err := a.verify(ctx, username, password)
if err != nil {
return Session{}, Account{}, err
}
token, hash := newSecret()
if err := a.members.ReplaceSession(ctx, hashSecret(currentToken), account.Member.ID, hash); err != nil {
return Session{}, Account{}, err
}
return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username}, nil
}
func (a *Accounts) verify(ctx context.Context, username, password string) (port.Account, error) {
account, err := a.members.AccountByUsername(ctx, username)
if errors.Is(err, port.ErrMemberNotFound) {
return port.Account{}, ErrInvalidCredentials
}
if err != nil {
return port.Account{}, err
}
ok, err := checkPassword(ctx, account.PasswordHash, password)
if err != nil {
return port.Account{}, err
}
if !ok {
return port.Account{}, ErrInvalidCredentials
}
return account, nil
}
func (a *Accounts) Logout(ctx context.Context, token string) error {
return a.members.DeleteSession(ctx, hashSecret(token))
}
func (a *Accounts) LogoutOthers(ctx context.Context, memberID, token string) error {
return a.members.DeleteSessions(ctx, memberID, hashSecret(token))
}
// 修改成功后,账号除了发出请求的这个会话,其他会话都失效。
func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentPassword, newPassword string) error {
username, err := a.members.Username(ctx, memberID)
if err != nil {
return err
}
if username == "" {
return ErrNotRegistered
}
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
return err
}
if _, err := a.verify(ctx, username, currentPassword); errors.Is(err, ErrInvalidCredentials) {
return ErrWrongCurrentPassword
} else if err != nil {
return err
}
hash, err := HashPassword(ctx, newPassword)
if err != nil {
return err
}
return a.members.SetPassword(ctx, memberID, hash, hashSecret(token))
}
// Find 和 ResetPassword 只供后台使用,调用方要先校验管理 token。
func (a *Accounts) Find(ctx context.Context, username string) (port.Account, error) {
account, err := a.members.AccountByUsername(ctx, username)
if errors.Is(err, port.ErrMemberNotFound) {
return port.Account{}, ErrAccountNotFound
}
return account, err
}
// 生成随机的新密码,账号在所有设备上的会话随即失效。新密码只出现在返回值中。
func (a *Accounts) ResetPassword(ctx context.Context, username string) (string, error) {
account, err := a.Find(ctx, username)
if err != nil {
return "", err
}
password := newPassword()
hash, err := HashPassword(ctx, password)
if err != nil {
return "", err
}
if err := a.members.SetPassword(ctx, account.Member.ID, hash, nil); err != nil {
return "", err
}
return password, nil
}
func (a *Accounts) take(ctx context.Context, key string, burst int, refill time.Duration) error {
wait, err := a.quota.Take(ctx, key, burst, refill)
if err != nil {
return err
}
if wait > 0 {
return ErrTooManyAttempts
}
return nil
}
+276
View File
@@ -0,0 +1,276 @@
package identity
import (
"context"
"errors"
"fmt"
"regexp"
"slices"
"sync"
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/member"
)
const clientIP = "203.0.113.7"
type fakeQuota struct {
mu sync.Mutex
taken []string
exhausted map[string]bool
}
func (q *fakeQuota) Take(_ context.Context, key string, burst int, refill time.Duration) (time.Duration, error) {
q.mu.Lock()
defer q.mu.Unlock()
if burst <= 0 || refill <= 0 {
return 0, fmt.Errorf("额度参数不合理:burst=%d refill=%v", burst, refill)
}
q.taken = append(q.taken, key)
if q.exhausted[key] {
return time.Minute, nil
}
return 0, nil
}
func newAccounts(t *testing.T) (*Accounts, *Service, *porttest.Store, *fakeQuota) {
t.Helper()
s, store := newService(t)
q := &fakeQuota{exhausted: map[string]bool{}}
return NewAccounts(store, store, q), s, store, q
}
// 返回 token 当前所属的成员;token 已经失效时 ok 为 false。
func tokenOwner(t *testing.T, store *porttest.Store, token string) (m member.Member, ok bool) {
t.Helper()
m, err := store.MemberByToken(t.Context(), hashSecret(token))
if errors.Is(err, port.ErrMemberNotFound) {
return member.Member{}, false
}
if err != nil {
t.Fatal(err)
}
return m, true
}
func registerAccount(t *testing.T, a *Accounts, s *Service, username, password string) Session {
t.Helper()
session := createMember(t, s)
if _, err := a.Register(t.Context(), session.Member.ID, clientIP, username, password); err != nil {
t.Fatal(err)
}
return session
}
func TestRegister(t *testing.T) {
a, s, store, q := newAccounts(t)
guest := createMember(t, s)
account, err := a.Register(t.Context(), guest.Member.ID, clientIP, "alice", "correct horse")
if err != nil || account != (Account{Username: "alice"}) {
t.Fatalf("Register = %#v, %v", account, err)
}
if got, ok, err := a.Account(t.Context(), guest.Member.ID); err != nil || !ok || got != account {
t.Errorf("注册之后 Account = %#v, %v, %v", got, ok, err)
}
if m, ok := tokenOwner(t, store, guest.MemberToken); !ok || m != guest.Member {
t.Error("注册不更换会话:原来的成员 token 应仍然有效")
}
stored, err := store.AccountByUsername(t.Context(), "alice")
if err != nil {
t.Fatal(err)
}
if ok, err := checkPassword(t.Context(), stored.PasswordHash, "correct horse"); err != nil || !ok {
t.Errorf("存储中应是密码的 argon2id 哈希:%v, %v", ok, err)
}
if want := []string{"register:ip:" + clientIP}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
}
func TestGuestHasNoAccount(t *testing.T) {
a, s, _, _ := newAccounts(t)
if _, ok, err := a.Account(t.Context(), createMember(t, s).Member.ID); err != nil || ok {
t.Errorf("游客的 Account = %v, %v,期望没有账号", ok, err)
}
}
func TestRegisterRejected(t *testing.T) {
a, s, store, q := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
if _, err := a.Register(t.Context(), createMember(t, s).Member.ID, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) {
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
}
if _, err := a.Register(t.Context(), alice.Member.ID, clientIP, "alice2", "another horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
t.Errorf("已经注册过时 = %v,期望 ErrAlreadyRegistered", err)
}
guest := createMember(t, s)
q.exhausted["register:ip:"+clientIP] = true
if _, err := a.Register(t.Context(), guest.Member.ID, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("额度用完时 = %v,期望 ErrTooManyAttempts", err)
}
if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok {
t.Error("额度用完时不应注册")
}
if err := store.SetRegistrationOpen(t.Context(), false); err != nil {
t.Fatal(err)
}
q.taken = nil
if _, err := a.Register(t.Context(), guest.Member.ID, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
t.Errorf("关闭注册时 = %v,期望 ErrRegistrationClosed", err)
}
if len(q.taken) != 0 {
t.Errorf("关闭注册时不应消耗额度:%v", q.taken)
}
}
func TestLogin(t *testing.T) {
a, s, store, q := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
q.taken = nil
session, account, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse")
if err != nil {
t.Fatal(err)
}
if session.Member != alice.Member || account != (Account{Username: "alice"}) {
t.Errorf("Login = %#v, %#v,期望登录 alice", session.Member, account)
}
if session.MemberToken == alice.MemberToken || session.MemberToken == guest.MemberToken {
t.Error("登录应签发新的成员 token")
}
if m, ok := tokenOwner(t, store, session.MemberToken); !ok || m != alice.Member {
t.Errorf("新 token 属于 %#v,期望 alice", m)
}
if _, ok := tokenOwner(t, store, guest.MemberToken); ok {
t.Error("登录之后游客原来的 token 应失效")
}
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
t.Error("登录不影响账号在其他设备上的会话")
}
if want := []string{"login:ip:" + clientIP, "login:user:alice"}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
}
func TestLoginRejected(t *testing.T) {
a, s, store, q := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
for _, tc := range []struct{ name, username, password string }{
{"密码错误", "alice", "wrong horse"},
{"没有这个用户名", "nobody", "correct horse"},
} {
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, tc.username, tc.password); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("%s:Login = %v,期望 ErrInvalidCredentials", tc.name, err)
}
}
if _, ok := tokenOwner(t, store, guest.MemberToken); !ok {
t.Error("登录失败时游客的 token 应仍然有效")
}
if _, _, err := a.Login(t.Context(), alice.Member, alice.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrAlreadyLoggedIn) {
t.Errorf("已注册的成员登录 = %v,期望 ErrAlreadyLoggedIn", err)
}
q.exhausted["login:user:alice"] = true
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, "198.51.100.1", "alice", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("用户名的额度用完时换一个 IP = %v,期望 ErrTooManyAttempts", err)
}
q.exhausted["login:ip:"+clientIP] = true
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
t.Errorf("IP 的额度用完时换一个用户名 = %v,期望 ErrTooManyAttempts", err)
}
}
func TestLogout(t *testing.T) {
a, s, store, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
other, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse")
if err != nil {
t.Fatal(err)
}
if err := a.LogoutOthers(t.Context(), alice.Member.ID, alice.MemberToken); err != nil {
t.Fatal(err)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
t.Error("退出其他设备不影响发出请求的会话")
}
if _, ok := tokenOwner(t, store, other.MemberToken); ok {
t.Error("退出其他设备之后其他会话应失效")
}
if err := a.Logout(t.Context(), alice.MemberToken); err != nil {
t.Fatal(err)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); ok {
t.Error("退出登录之后 token 应失效")
}
}
func TestChangePassword(t *testing.T) {
a, s, store, q := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
guest := createMember(t, s)
other, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse")
if err != nil {
t.Fatal(err)
}
q.taken = nil
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "wrong horse", "battery staple"); !errors.Is(err, ErrWrongCurrentPassword) {
t.Errorf("当前密码错误时 = %v,期望 ErrWrongCurrentPassword", err)
}
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); err != nil {
t.Fatal(err)
}
if want := []string{"password:member:" + alice.Member.ID, "password:member:" + alice.Member.ID}; !slices.Equal(q.taken, want) {
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
t.Error("修改密码不影响发出请求的会话")
}
if _, ok := tokenOwner(t, store, other.MemberToken); ok {
t.Error("修改密码之后其他会话应失效")
}
next := createMember(t, s)
if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("用旧密码登录 = %v,期望 ErrInvalidCredentials", err)
}
if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "battery staple"); err != nil {
t.Errorf("用新密码登录 = %v", err)
}
stranger := createMember(t, s)
if err := a.ChangePassword(t.Context(), stranger.Member.ID, stranger.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客修改密码 = %v,期望 ErrNotRegistered", err)
}
}
func TestResetPassword(t *testing.T) {
a, s, store, _ := newAccounts(t)
alice := registerAccount(t, a, s, "alice", "correct horse")
if _, err := a.Find(t.Context(), "nobody"); !errors.Is(err, ErrAccountNotFound) {
t.Errorf("Find 不存在的用户名 = %v,期望 ErrAccountNotFound", err)
}
if _, err := a.ResetPassword(t.Context(), "nobody"); !errors.Is(err, ErrAccountNotFound) {
t.Errorf("ResetPassword 不存在的用户名 = %v,期望 ErrAccountNotFound", err)
}
if found, err := a.Find(t.Context(), "alice"); err != nil || found.Member != alice.Member || found.Username != "alice" {
t.Errorf("Find = %#v, %v", found, err)
}
password, err := a.ResetPassword(t.Context(), "alice")
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^[A-Za-z0-9_-]{16}$`).MatchString(password) {
t.Errorf("新密码 %q 应是 16 个 base64url 字符", password)
}
if _, ok := tokenOwner(t, store, alice.MemberToken); ok {
t.Error("重置密码之后账号的所有会话应失效")
}
guest := createMember(t, s)
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", password); err != nil {
t.Errorf("用重置后的密码登录 = %v", err)
}
}
+2 -2
View File
@@ -24,9 +24,9 @@ const (
var passwordPrefix = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$", argon2.Version, argonMemory, argonTime, argonThreads)
var errMalformedPasswordHash = errors.New("房间密码哈希的格式不正确")
var errMalformedPasswordHash = errors.New("密码哈希的格式不正确")
// 每次计算都要分配 argonMemory 的内存,而创建临时房间和换取票据任何人都能请求:不限制并发时,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。
// 每次计算都要分配 argonMemory 的内存,而创建临时房间、换取票据和登录任何人都能请求:不限制并发时,几十个请求就能耗尽内存。argon2 是纯计算,并发超过 GOMAXPROCS 也不会更快。
var argonSlots = make(chan struct{}, runtime.GOMAXPROCS(0))
func idKey(ctx context.Context, password string, salt []byte) ([]byte, error) {
+7
View File
@@ -38,3 +38,10 @@ func avatarHash(email string) string {
sum := md5.Sum([]byte(email))
return hex.EncodeToString(sum[:])
}
// 后台重置密码时生成:96 位随机值的 base64url 编码,共 16 个字符,方便转告用户。
func newPassword() string {
b := make([]byte, 12)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}