feat: add im auth verification flows
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
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-Tinode-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), "basic").
|
||||
Return("alice", auth.LevelAuth, []byte("old"), time.Time{}, 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-Tinode-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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user