Files
im_server/server/user_verification_test.go
T

416 lines
13 KiB
Go

package main
import (
"bytes"
"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 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
independentPassword *types.IndependentPassword
youthMode *types.YouthModeSettings
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
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) {
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) {
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) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
s.readTenant = tenantID
s.readUser = uid
s.readNotificationID = eventID
for i := range s.notifications {
if s.notifications[i].EventID == eventID {
s.notifications[i].ReadAt = &readAt
return s.err
}
}
if s.err != nil {
return s.err
}
return types.ErrNotFound
}