feat: register, log in and manage member accounts
This commit is contained in:
1 parent
7f67df3d41
commit
b50e645d30
6 files changed
+686
-9
No files matched your search
@@ -0,0 +1,222 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"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
|
||||
Email 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) {
|
||||
profile, err := a.members.AccountProfile(ctx, memberID)
|
||||
if err != nil {
|
||||
return Account{}, false, err
|
||||
}
|
||||
return Account{Username: profile.Username, Email: profile.Email}, profile.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:"+quotaAddr(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:"+quotaAddr(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()
|
||||
err = a.members.ReplaceSession(ctx, hashSecret(currentToken), account.Member.ID, hash, account.PasswordHash)
|
||||
switch {
|
||||
case errors.Is(err, port.ErrSessionNotFound):
|
||||
return Session{}, Account{}, ErrInvalidMemberToken
|
||||
case errors.Is(err, port.ErrAccountPasswordChanged):
|
||||
return Session{}, Account{}, ErrInvalidCredentials
|
||||
case err != nil:
|
||||
return Session{}, Account{}, err
|
||||
}
|
||||
return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username, Email: account.Email}, 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) {
|
||||
dummy, err := dummyPasswordHash()
|
||||
if err != nil {
|
||||
return port.Account{}, err
|
||||
}
|
||||
if _, err := checkPassword(ctx, dummy, password); err != nil {
|
||||
return port.Account{}, err
|
||||
}
|
||||
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) ChangePassword(ctx context.Context, memberID, token, currentPassword, newPassword string) error {
|
||||
profile, err := a.members.AccountProfile(ctx, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
username := profile.Username
|
||||
if username == "" {
|
||||
return ErrNotRegistered
|
||||
}
|
||||
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
|
||||
return err
|
||||
}
|
||||
account, err := a.verify(ctx, username, currentPassword)
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
return ErrWrongCurrentPassword
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := HashPassword(ctx, newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 校验之后密码可能被重置或被别的设备改过:发出请求的会话已经被删掉时按 token 失效处理,同一个会话先改过时当前密码已经不对。
|
||||
err = a.members.ChangePassword(ctx, memberID, account.PasswordHash, hash, hashSecret(token))
|
||||
switch {
|
||||
case errors.Is(err, port.ErrSessionNotFound):
|
||||
return ErrInvalidMemberToken
|
||||
case errors.Is(err, port.ErrAccountPasswordChanged):
|
||||
return ErrWrongCurrentPassword
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 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); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
|
||||
// IPv6 客户端通常分到整个 /64,可以在里面随意换地址,所以按 /64 计数。clientIP 由适配器给出,解析不了时原样使用。
|
||||
func quotaAddr(clientIP string) string {
|
||||
addr, err := netip.ParseAddr(clientIP)
|
||||
if err != nil {
|
||||
return clientIP
|
||||
}
|
||||
if addr = addr.Unmap(); addr.Is4() {
|
||||
return addr.String()
|
||||
}
|
||||
return netip.PrefixFrom(addr, 64).Masked().String()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
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 TestAccountKeepsEmail(t *testing.T) {
|
||||
a, s, _, _ := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
guest := createMember(t, s)
|
||||
|
||||
if _, err := s.UpdateProfile(t.Context(), alice.Member.ID, Profile{Email: ptr(" Alice@Example.COM ")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok, err := a.Account(t.Context(), alice.Member.ID); err != nil || !ok || got != (Account{Username: "alice", Email: "Alice@Example.COM"}) {
|
||||
t.Errorf("保存邮箱之后 Account = %#v, %v, %v", got, ok, err)
|
||||
}
|
||||
other := createMember(t, s)
|
||||
_, account, err := a.Login(t.Context(), other.Member, other.MemberToken, clientIP, "alice", "correct horse")
|
||||
if err != nil || account != (Account{Username: "alice", Email: "Alice@Example.COM"}) {
|
||||
t.Errorf("另一台设备登录得到 %#v, %v,期望带邮箱", account, err)
|
||||
}
|
||||
if _, err := s.UpdateProfile(t.Context(), alice.Member.ID, Profile{Email: ptr(" ")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _, _ := a.Account(t.Context(), alice.Member.ID); got.Email != "" {
|
||||
t.Errorf("清除之后邮箱 = %q", got.Email)
|
||||
}
|
||||
|
||||
if _, err := s.UpdateProfile(t.Context(), guest.Member.ID, Profile{Email: ptr("g@example.com")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok {
|
||||
t.Error("游客设置邮箱之后仍然没有账号")
|
||||
}
|
||||
}
|
||||
|
||||
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 TestAccountQuotaCountsIPv6ByNetwork(t *testing.T) {
|
||||
a, s, _, q := newAccounts(t)
|
||||
guest := createMember(t, s)
|
||||
q.exhausted["login:ip:2001:db8:1:2::/64"] = true
|
||||
for _, tc := range []struct {
|
||||
ip, key string
|
||||
want error
|
||||
}{
|
||||
{"2001:db8:1:2::1", "login:ip:2001:db8:1:2::/64", ErrTooManyAttempts},
|
||||
{"2001:db8:1:2:aaaa:bbbb:cccc:dddd", "login:ip:2001:db8:1:2::/64", ErrTooManyAttempts},
|
||||
{"2001:db8:1:3::1", "login:ip:2001:db8:1:3::/64", ErrInvalidCredentials},
|
||||
{"203.0.113.7", "login:ip:203.0.113.7", ErrInvalidCredentials},
|
||||
{"::ffff:203.0.113.7", "login:ip:203.0.113.7", ErrInvalidCredentials},
|
||||
} {
|
||||
q.taken = nil
|
||||
_, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, tc.ip, "nobody", "wrong horse")
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("从 %s 登录 = %v,期望 %v", tc.ip, err, tc.want)
|
||||
}
|
||||
if len(q.taken) == 0 || q.taken[0] != tc.key {
|
||||
t.Errorf("从 %s 登录取的额度 = %v,期望先取 %s", tc.ip, q.taken, tc.key)
|
||||
}
|
||||
}
|
||||
|
||||
q.taken = nil
|
||||
if _, err := a.Register(t.Context(), guest.Member.ID, "2001:db8:1:2:aaaa:bbbb:cccc:dddd", "alice", "correct horse"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"register:ip:2001:db8:1:2::/64"}; !slices.Equal(q.taken, want) {
|
||||
t.Errorf("从 IPv6 地址注册取的额度 = %v,期望 %v", q.taken, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
a, s, store, _ := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 在登录校验密码之后、签发新会话之前修改账号的密码。
|
||||
type passwordChangedMembers struct {
|
||||
*porttest.Store
|
||||
}
|
||||
|
||||
func (s passwordChangedMembers) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
|
||||
if err := s.SetPassword(ctx, memberID, "changed"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Store.ReplaceSession(ctx, oldTokenHash, memberID, newTokenHash, passwordHash)
|
||||
}
|
||||
|
||||
func TestLoginRejectedWhenGuestSessionOrPasswordChangesMeanwhile(t *testing.T) {
|
||||
a, s, store, _ := newAccounts(t)
|
||||
registerAccount(t, a, s, "alice", "correct horse")
|
||||
guest := createMember(t, s)
|
||||
if err := store.DeleteSession(t.Context(), hashSecret(guest.MemberToken)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidMemberToken) {
|
||||
t.Errorf("游客的会话在校验之后失效,Login = %v,期望 ErrInvalidMemberToken", err)
|
||||
}
|
||||
|
||||
changing := NewAccounts(passwordChangedMembers{store}, store, &fakeQuota{exhausted: map[string]bool{}})
|
||||
guest = createMember(t, s)
|
||||
if _, _, err := changing.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Errorf("密码在校验之后被修改,Login = %v,期望 ErrInvalidCredentials", err)
|
||||
}
|
||||
if _, ok := tokenOwner(t, store, guest.MemberToken); !ok {
|
||||
t.Error("登录失败时游客的 token 应仍然有效")
|
||||
}
|
||||
}
|
||||
|
||||
// 校验当前密码读到旧的密码哈希之后,先执行 meanwhile 再继续。
|
||||
type passwordReadMembers struct {
|
||||
*porttest.Store
|
||||
meanwhile func()
|
||||
}
|
||||
|
||||
func (s passwordReadMembers) AccountByUsername(ctx context.Context, username string) (port.Account, error) {
|
||||
account, err := s.Store.AccountByUsername(ctx, username)
|
||||
s.meanwhile()
|
||||
return account, err
|
||||
}
|
||||
|
||||
func TestChangePasswordRejectedWhenPasswordChangesMeanwhile(t *testing.T) {
|
||||
a, s, store, _ := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
racing := func(meanwhile func()) *Accounts {
|
||||
return NewAccounts(passwordReadMembers{store, meanwhile}, store, &fakeQuota{exhausted: map[string]bool{}})
|
||||
}
|
||||
|
||||
var reset string
|
||||
resetting := racing(func() {
|
||||
var err error
|
||||
if reset, err = a.ResetPassword(t.Context(), "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if err := resetting.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrInvalidMemberToken) {
|
||||
t.Errorf("校验当前密码之后后台重置了密码,ChangePassword = %v,期望 ErrInvalidMemberToken", err)
|
||||
}
|
||||
guest := createMember(t, s)
|
||||
session, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", reset)
|
||||
if err != nil {
|
||||
t.Fatalf("用重置出来的密码登录 = %v,期望成功", err)
|
||||
}
|
||||
|
||||
changing := racing(func() {
|
||||
if err := a.ChangePassword(t.Context(), session.Member.ID, session.MemberToken, reset, "first change"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if err := changing.ChangePassword(t.Context(), session.Member.ID, session.MemberToken, reset, "second change"); !errors.Is(err, ErrWrongCurrentPassword) {
|
||||
t.Errorf("校验当前密码之后同一个会话已经改了密码,ChangePassword = %v,期望 ErrWrongCurrentPassword", err)
|
||||
}
|
||||
next := createMember(t, s)
|
||||
if _, _, err := a.Login(t.Context(), next.Member, next.MemberToken, clientIP, "alice", "first change"); err != nil {
|
||||
t.Errorf("用先完成的那次修改的密码登录 = %v,期望成功", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 名额占满、请求已经取消时,要做密码计算的请求以 context.Canceled 结束,不做的直接返回结果。
|
||||
func TestLoginDoesPasswordWorkForUnknownUsername(t *testing.T) {
|
||||
a, s, _, _ := newAccounts(t)
|
||||
registerAccount(t, a, s, "alice", "correct horse")
|
||||
guest := createMember(t, s)
|
||||
if _, err := dummyPasswordHash(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
occupyArgonSlots(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
for _, username := range []string{"alice", "nobody"} {
|
||||
if _, _, err := a.Login(ctx, guest.Member, guest.MemberToken, clientIP, username, "wrong horse"); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("用户名 %s:Login = %v,期望同样等待密码计算的名额", username, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,21 +60,21 @@ func (s *Service) Authenticate(ctx context.Context, memberToken string) (member.
|
||||
|
||||
type Profile struct {
|
||||
Name *string
|
||||
// 只用来计算头像哈希,不保存;空字符串表示清除头像。
|
||||
// 用来计算头像哈希;已注册的账号同时保存它。空字符串表示清除。
|
||||
Email *string
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile) (member.Member, error) {
|
||||
var avatar *string
|
||||
var avatar, email *string
|
||||
if p.Email != nil {
|
||||
email := strings.TrimSpace(*p.Email)
|
||||
if email != "" && !emailPattern.MatchString(email) {
|
||||
trimmed := strings.TrimSpace(*p.Email)
|
||||
if trimmed != "" && !emailPattern.MatchString(trimmed) {
|
||||
return member.Member{}, ErrInvalidEmail
|
||||
}
|
||||
hash := avatarHash(email)
|
||||
avatar = &hash
|
||||
hash := avatarHash(trimmed)
|
||||
avatar, email = &hash, &trimmed
|
||||
}
|
||||
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar, nil)
|
||||
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar, email)
|
||||
if err != nil {
|
||||
return member.Member{}, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
@@ -24,9 +25,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) {
|
||||
@@ -52,6 +53,12 @@ func HashPassword(ctx context.Context, password string) (string, error) {
|
||||
return passwordPrefix + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(key), nil
|
||||
}
|
||||
|
||||
// 用户名不存在时拿它做一次同样的计算,响应时间不暴露用户名是否存在。密码是随机值,不会与任何人输入的密码匹配。
|
||||
var dummyPasswordHash = sync.OnceValues(func() (string, error) {
|
||||
password, _ := newSecret()
|
||||
return HashPassword(context.Background(), password)
|
||||
})
|
||||
|
||||
// 只接受按当前参数生成的哈希:存储中的密码哈希都由 HashPassword 生成。
|
||||
func checkPassword(ctx context.Context, hash, password string) (bool, error) {
|
||||
encoded, ok := strings.CutPrefix(hash, passwordPrefix)
|
||||
|
||||
@@ -87,3 +87,18 @@ func TestPasswordWorkReturnsItsSlot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDummyPasswordHashMatchesNoPassword(t *testing.T) {
|
||||
hash, err := dummyPasswordHash()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again, _ := dummyPasswordHash(); again != hash {
|
||||
t.Error("假哈希应只生成一次")
|
||||
}
|
||||
for _, password := range []string{"", "correct horse", "12345678"} {
|
||||
if ok, err := checkPassword(t.Context(), hash, password); err != nil || ok {
|
||||
t.Errorf("checkPassword(假哈希, %q) = %v, %v,期望能解析且不匹配", password, ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user