483 lines
16 KiB
Go
483 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang/mock/gomock"
|
|
"github.com/tinode/chat/server/auth"
|
|
"github.com/tinode/chat/server/auth/mock_auth"
|
|
"github.com/tinode/chat/server/store"
|
|
"github.com/tinode/chat/server/store/mock_store"
|
|
"github.com/tinode/chat/server/store/types"
|
|
)
|
|
|
|
func TestConsumeProductVerificationCred(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
|
|
oldUsers := store.Users
|
|
oldVerifications := store.Verifications
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.Verifications = oldVerifications
|
|
}()
|
|
|
|
makeTestAPIKey(t)
|
|
token := "verified-token"
|
|
tokenHash := hashIMSecret(7, "register", "challenge-1", "phone-hash", token)
|
|
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
|
|
TenantID: 7,
|
|
ChallengeID: "challenge-1",
|
|
Scene: "register",
|
|
PhoneHash: "phone-hash",
|
|
VerificationTokenHash: &tokenHash,
|
|
State: types.VerificationStateVerified,
|
|
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
|
}}
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
users.EXPECT().UpsertCred(types.TenantID(7), gomock.AssignableToTypeOf(&types.Credential{})).
|
|
DoAndReturn(func(_ types.TenantID, cred *types.Credential) (bool, error) {
|
|
if cred.User != types.Uid(42).String() || cred.Method != "tel" || cred.Value != "+8613800138000" || !cred.Done {
|
|
t.Fatalf("unexpected credential: %#v", cred)
|
|
}
|
|
return true, nil
|
|
})
|
|
store.Users = users
|
|
|
|
value, ok, err := consumeProductVerificationCred(7, 42, &MsgCredClient{
|
|
Method: "tel",
|
|
Value: "+8613800138000",
|
|
Response: token,
|
|
Params: map[string]any{"challenge_id": "challenge-1", "countryCode": "CN"},
|
|
}, func(value string, _ map[string]any) (string, error) {
|
|
return "tel:" + value, nil
|
|
})
|
|
if err != nil || !ok || value != "+8613800138000" {
|
|
t.Fatalf("consumeProductVerificationCred = value %q ok %v err %v", value, ok, err)
|
|
}
|
|
if !store.Verifications.(*testVerificationStore).consumed {
|
|
t.Fatal("expected verification token to be consumed")
|
|
}
|
|
}
|
|
|
|
func TestConsumeProductVerificationCredReturnsDuplicateCredential(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
|
|
oldUsers := store.Users
|
|
oldVerifications := store.Verifications
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.Verifications = oldVerifications
|
|
}()
|
|
|
|
makeTestAPIKey(t)
|
|
token := "verified-token"
|
|
tokenHash := hashIMSecret(7, "register", "challenge-duplicate", "phone-hash", token)
|
|
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
|
|
TenantID: 7,
|
|
ChallengeID: "challenge-duplicate",
|
|
Scene: "register",
|
|
PhoneHash: "phone-hash",
|
|
VerificationTokenHash: &tokenHash,
|
|
State: types.VerificationStateVerified,
|
|
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
|
}}
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
users.EXPECT().UpsertCred(types.TenantID(7), gomock.Any()).Return(true, types.ErrDuplicate)
|
|
store.Users = users
|
|
|
|
_, ok, err := consumeProductVerificationCred(7, 42, &MsgCredClient{
|
|
Method: "tel",
|
|
Value: "+8613800138000",
|
|
Response: token,
|
|
Params: map[string]any{"challenge_id": "challenge-duplicate"},
|
|
}, func(value string, _ map[string]any) (string, error) {
|
|
return "tel:" + value, nil
|
|
})
|
|
if !ok || !errors.Is(err, types.ErrDuplicate) {
|
|
t.Fatalf("duplicate phone: ok=%v err=%v", ok, err)
|
|
}
|
|
reply := decodeStoreError(err, "acc-1", time.Now(), nil)
|
|
if reply.Ctrl == nil || reply.Ctrl.Code != http.StatusConflict || reply.Ctrl.Text != "duplicate credential" {
|
|
t.Fatalf("unexpected duplicate reply: %#v", reply)
|
|
}
|
|
}
|
|
|
|
func TestConsumeProductVerificationCredRejectsBadToken(t *testing.T) {
|
|
oldUsers := store.Users
|
|
oldVerifications := store.Verifications
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.Verifications = oldVerifications
|
|
}()
|
|
|
|
makeTestAPIKey(t)
|
|
tokenHash := hashIMSecret(7, "register", "challenge-1", "phone-hash", "good-token")
|
|
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
|
|
TenantID: 7,
|
|
ChallengeID: "challenge-1",
|
|
Scene: "register",
|
|
PhoneHash: "phone-hash",
|
|
VerificationTokenHash: &tokenHash,
|
|
State: types.VerificationStateVerified,
|
|
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
|
}}
|
|
|
|
_, ok, err := consumeProductVerificationCred(7, 42, &MsgCredClient{
|
|
Method: "tel",
|
|
Value: "+8613800138000",
|
|
Response: "bad-token",
|
|
Params: map[string]any{"challenge_id": "challenge-1"},
|
|
}, func(value string, _ map[string]any) (string, error) {
|
|
return "tel:" + value, nil
|
|
})
|
|
if !ok || err != types.ErrInvalidResponse {
|
|
t.Fatalf("consumeProductVerificationCred bad token ok %v err %v", ok, err)
|
|
}
|
|
if store.Verifications.(*testVerificationStore).consumed {
|
|
t.Fatal("bad token must not be consumed")
|
|
}
|
|
}
|
|
|
|
func TestServeIMPasswordResetVerify(t *testing.T) {
|
|
oldTenants := store.Tenants
|
|
oldVerifications := store.Verifications
|
|
defer func() {
|
|
store.Tenants = oldTenants
|
|
store.Verifications = oldVerifications
|
|
}()
|
|
|
|
makeTestAPIKey(t)
|
|
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", Name: "Acme", State: types.TenantStateActive}}
|
|
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
|
|
TenantID: 7,
|
|
ChallengeID: "challenge-1",
|
|
Scene: "reset_password",
|
|
PhoneHash: "phone-hash",
|
|
CodeHash: hashIMSecret(7, "reset_password", "challenge-1", "phone-hash", "123456"),
|
|
State: types.VerificationStatePending,
|
|
MaxAttempts: 5,
|
|
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
|
}}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/password-reset/challenges/challenge-1/verify",
|
|
bytes.NewBufferString(`{"code":"123456"}`))
|
|
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
|
req.Header.Set("X-Tenant-Code", "acme")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMPasswordResetVerify(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
resp := decodeIMHTTPResponse(t, rec)
|
|
data := resp.Data.(map[string]any)
|
|
token, _ := data["reset_token"].(string)
|
|
if !strings.HasPrefix(token, "challenge-1.") {
|
|
t.Fatalf("unexpected reset token: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMPasswordResetConfirm(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
|
|
oldTenants := store.Tenants
|
|
oldVerifications := store.Verifications
|
|
oldUsers := store.Users
|
|
oldStore := store.Store
|
|
oldSecurity := store.Security
|
|
defer func() {
|
|
store.Tenants = oldTenants
|
|
store.Verifications = oldVerifications
|
|
store.Users = oldUsers
|
|
store.Store = oldStore
|
|
store.Security = oldSecurity
|
|
}()
|
|
|
|
makeTestAPIKey(t)
|
|
token := "reset-secret"
|
|
tokenHash := hashIMSecret(7, "reset_password", "challenge-1", "phone-hash", token)
|
|
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", Name: "Acme", State: types.TenantStateActive}}
|
|
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
|
|
TenantID: 7,
|
|
ChallengeID: "challenge-1",
|
|
Scene: "reset_password",
|
|
PhoneHash: "phone-hash",
|
|
TargetUser: 42,
|
|
VerificationTokenHash: &tokenHash,
|
|
State: types.VerificationStateVerified,
|
|
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
|
}}
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42)).
|
|
Return("alice", auth.LevelAuth, []byte("old"), time.Time{}, nil)
|
|
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).
|
|
Return(&types.User{Phone: "+8613800138000"}, nil)
|
|
users.EXPECT().UpsertCred(types.TenantID(7), gomock.AssignableToTypeOf(&types.Credential{})).
|
|
DoAndReturn(func(_ types.TenantID, cred *types.Credential) (bool, error) {
|
|
if cred.Method != "tel" || cred.Value != "+8613800138000" || !cred.Done {
|
|
t.Fatalf("unexpected reset credential: %#v", cred)
|
|
}
|
|
return true, nil
|
|
})
|
|
store.Users = users
|
|
basicAuth := mock_auth.NewMockAuthHandler(ctrl)
|
|
basicAuth.EXPECT().IsInitialized().Return(true)
|
|
basicAuth.EXPECT().UpdateRecord(auth.AuthContext{TenantID: 7}, gomock.Any(), []byte(":new-password")).
|
|
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil)
|
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
|
mockStore.EXPECT().GetLogicalAuthHandler("basic").Return(basicAuth)
|
|
store.Store = mockStore
|
|
security := &testSecurityStore{}
|
|
store.Security = security
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/password-reset/confirm",
|
|
bytes.NewBufferString(`{"reset_token":"challenge-1.`+token+`","new_password":"new-password"}`))
|
|
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
|
req.Header.Set("X-Tenant-Code", "acme")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMPasswordResetConfirm(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
if !store.Verifications.(*testVerificationStore).consumed {
|
|
t.Fatal("expected reset token to be consumed")
|
|
}
|
|
if !security.bumped || security.bumpedUser != types.Uid(42) {
|
|
t.Fatalf("expected auth version bump, got %#v", security)
|
|
}
|
|
}
|
|
|
|
type testSecurityStore struct {
|
|
version int
|
|
err error
|
|
bumped bool
|
|
bumpedUser types.Uid
|
|
|
|
realName *types.RealNameVerification
|
|
realNameAppeal *types.RealNameAppeal
|
|
actionToken *types.ActionToken
|
|
consumedAction string
|
|
consumedTarget string
|
|
independentPassword *types.IndependentPassword
|
|
youthMode *types.YouthModeSettings
|
|
youthModeReads int
|
|
attempts int
|
|
lockedUntil *time.Time
|
|
notifications []types.SystemNotification
|
|
unreadNotifications int
|
|
readNotificationID string
|
|
realNameTenant types.TenantID
|
|
realNameUser types.Uid
|
|
listTenant types.TenantID
|
|
listUser types.Uid
|
|
listCursor string
|
|
listLimit int
|
|
nextCursor string
|
|
unreadTenant types.TenantID
|
|
unreadUser types.Uid
|
|
unreadEventPrefix string
|
|
readTenant types.TenantID
|
|
readUser types.Uid
|
|
}
|
|
|
|
func (s *testSecurityStore) GetAuthVersion(types.TenantID, types.Uid) (int, error) {
|
|
if s.version == 0 {
|
|
return 1, s.err
|
|
}
|
|
return s.version, s.err
|
|
}
|
|
|
|
func (s *testSecurityStore) BumpAuthVersion(_ types.TenantID, uid types.Uid, _ time.Time) (int, error) {
|
|
s.bumped = true
|
|
s.bumpedUser = uid
|
|
if s.version == 0 {
|
|
s.version = 1
|
|
}
|
|
s.version++
|
|
return s.version, s.err
|
|
}
|
|
|
|
func (s *testSecurityStore) GetRealName(tenantID types.TenantID, uid types.Uid) (*types.RealNameVerification, error) {
|
|
s.realNameTenant = tenantID
|
|
s.realNameUser = uid
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
if s.realName == nil {
|
|
return nil, types.ErrNotFound
|
|
}
|
|
return s.realName, nil
|
|
}
|
|
|
|
func (s *testSecurityStore) CreateRealNameSubmission(_ types.TenantID, rv *types.RealNameVerification) error {
|
|
s.realName = rv
|
|
return s.err
|
|
}
|
|
|
|
func (s *testSecurityStore) CreateRealNameAppeal(_ types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error) {
|
|
s.realNameAppeal = appeal
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
appeal.CreatedAt = now
|
|
appeal.UpdatedAt = now
|
|
return appeal, nil
|
|
}
|
|
|
|
func (s *testSecurityStore) CreateActionToken(_ types.TenantID, token *types.ActionToken) error {
|
|
s.actionToken = token
|
|
return s.err
|
|
}
|
|
|
|
func (s *testSecurityStore) ConsumeActionToken(_ types.TenantID, _ types.Uid, _ string, action string, target string, _ time.Time) (*types.ActionToken, error) {
|
|
s.consumedAction = action
|
|
s.consumedTarget = target
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
if s.actionToken == nil {
|
|
return &types.ActionToken{Action: action, Target: target}, nil
|
|
}
|
|
return s.actionToken, nil
|
|
}
|
|
|
|
func (s *testSecurityStore) GetIndependentPassword(types.TenantID, types.Uid, string) (*types.IndependentPassword, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
if s.independentPassword == nil {
|
|
return nil, types.ErrNotFound
|
|
}
|
|
return s.independentPassword, nil
|
|
}
|
|
|
|
func (s *testSecurityStore) UpsertIndependentPassword(_ types.TenantID, pass *types.IndependentPassword) error {
|
|
s.independentPassword = pass
|
|
return s.err
|
|
}
|
|
|
|
func (s *testSecurityStore) UpdateIndependentPasswordAttempts(_ types.TenantID, _ types.Uid, _ string, attempts int, lockedUntil *time.Time, _ time.Time) error {
|
|
s.attempts = attempts
|
|
s.lockedUntil = lockedUntil
|
|
if s.independentPassword != nil {
|
|
s.independentPassword.FailedAttempts = attempts
|
|
s.independentPassword.LockedUntil = lockedUntil
|
|
}
|
|
return s.err
|
|
}
|
|
|
|
func (s *testSecurityStore) GetYouthMode(_ types.TenantID, uid types.Uid) (*types.YouthModeSettings, error) {
|
|
s.youthModeReads++
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
if s.youthMode == nil {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
s.youthMode = &types.YouthModeSettings{
|
|
UserID: uid,
|
|
Enabled: false,
|
|
PolicyVersion: 1,
|
|
Restrictions: []string{"night_quiet", "content_filter", "payment_guard"},
|
|
PasswordSet: s.independentPassword != nil,
|
|
CanEnable: s.independentPassword != nil,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
return s.youthMode, nil
|
|
}
|
|
|
|
func (s *testSecurityStore) SetYouthMode(_ types.TenantID, uid types.Uid, enabled bool, policyVersion int, now time.Time) (*types.YouthModeSettings, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
if s.youthMode == nil {
|
|
s.youthMode = &types.YouthModeSettings{UserID: uid, Restrictions: []string{"night_quiet", "content_filter", "payment_guard"}}
|
|
}
|
|
s.youthMode.Enabled = enabled
|
|
s.youthMode.PolicyVersion = policyVersion
|
|
s.youthMode.PasswordSet = true
|
|
s.youthMode.CanEnable = !enabled
|
|
s.youthMode.UpdatedAt = now
|
|
if enabled {
|
|
s.youthMode.EnabledAt = &now
|
|
} else {
|
|
s.youthMode.EnabledAt = nil
|
|
}
|
|
return s.youthMode, nil
|
|
}
|
|
|
|
func (s *testSecurityStore) CreateSystemNotification(_ types.TenantID, notification *types.SystemNotification) error {
|
|
if notification != nil {
|
|
s.notifications = append(s.notifications, *notification)
|
|
}
|
|
return s.err
|
|
}
|
|
|
|
func (s *testSecurityStore) ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error) {
|
|
s.listTenant = tenantID
|
|
s.listUser = uid
|
|
s.listCursor = cursor
|
|
s.listLimit = limit
|
|
return s.notifications, s.nextCursor, s.err
|
|
}
|
|
|
|
func (s *testSecurityStore) CountUnreadSystemNotifications(tenantID types.TenantID, uid types.Uid) (int, error) {
|
|
s.unreadTenant = tenantID
|
|
s.unreadUser = uid
|
|
if s.unreadNotifications != 0 {
|
|
return s.unreadNotifications, s.err
|
|
}
|
|
count := 0
|
|
for _, notification := range s.notifications {
|
|
if notification.ReadAt == nil {
|
|
count++
|
|
}
|
|
}
|
|
return count, s.err
|
|
}
|
|
|
|
func (s *testSecurityStore) CountUnreadSystemNotificationsByEventPrefix(tenantID types.TenantID, uid types.Uid, eventPrefix string) (int, error) {
|
|
s.unreadTenant = tenantID
|
|
s.unreadUser = uid
|
|
s.unreadEventPrefix = eventPrefix
|
|
count := 0
|
|
for _, notification := range s.notifications {
|
|
if notification.ReadAt == nil && strings.HasPrefix(notification.EventType, eventPrefix) {
|
|
count++
|
|
}
|
|
}
|
|
return count, s.err
|
|
}
|
|
|
|
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error) {
|
|
s.readTenant = tenantID
|
|
s.readUser = uid
|
|
s.readNotificationID = eventID
|
|
for i := range s.notifications {
|
|
if s.notifications[i].EventID == eventID {
|
|
if s.notifications[i].ReadAt == nil {
|
|
s.notifications[i].ReadAt = &readAt
|
|
}
|
|
return &s.notifications[i], s.err
|
|
}
|
|
}
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
return nil, types.ErrNotFound
|
|
}
|