mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
fix(stalker): record credentials in the imported session fingerprint
The import composed the fingerprint from endpoint and identity only, while the runtime computes it from the playlist — which carries the credentials too. So the first `ensureToken()` after importing a login-required portal mismatched the fingerprint the import had just written, refused the token and repeated the whole handshake/profile/do_auth flow, silently defeating reuse for exactly the portals this work exists for. Also refreshes the mock-server instructions the change invalidated: the README still described the client `do_auth` path as dormant and HTTP-only, and two comments still claimed the app sends `auth_second_step=1` on its first profile request — it now sends 0 first and 1 only on the retry after do_auth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
226ffbb9e6
commit
c248f9a615
5 files changed
+56
-14
No files matched your search
@@ -82,7 +82,7 @@ actually get wrong:
|
||||
| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow |
|
||||
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
|
||||
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
|
||||
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work |
|
||||
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app drives this end to end: enter a username and password in the Stalker import dialog and it runs `do_auth`, then retries `get_profile` with `auth_second_step=1`. Covered by `stalker.e2e.ts` (`@stalker full portal authentication`) |
|
||||
| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials |
|
||||
| `00:1A:79:00:00:0A` | **static-channel-cmd** | ITV rows carry a directly playable `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'` — a client honouring the flags must play them without calling `create_link` |
|
||||
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
|
||||
|
||||
@@ -11,10 +11,9 @@ import { handleHandshake } from './handshake.handler';
|
||||
/**
|
||||
* Handler-level coverage for the authentication actions.
|
||||
*
|
||||
* The e2e suite drives the app against this server, but the login-required
|
||||
* flow cannot be reached from the client yet (its `do_auth` path is dormant
|
||||
* and sends empty credentials), so the scenario is pinned down here rather
|
||||
* than only asserted in prose.
|
||||
* The e2e suite drives the app through this flow end to end; these handler
|
||||
* tests pin the SERVER half of the contract independently, so a client-side
|
||||
* regression and a mock-side one cannot mask each other.
|
||||
*
|
||||
* Handlers are called directly instead of through the dispatcher: the
|
||||
* dispatcher pulls in every content handler and therefore the faker-based
|
||||
@@ -82,8 +81,8 @@ describe('stalker mock authentication handlers', () => {
|
||||
})['token'] as string;
|
||||
expect(token).toMatch(/^[0-9A-F]{32}$/);
|
||||
|
||||
// The app sends auth_second_step=1 on its very first profile request,
|
||||
// so that parameter alone must not satisfy the scenario.
|
||||
// A client could mislabel its first profile as the second auth step;
|
||||
// that parameter alone must not satisfy the scenario.
|
||||
expect(
|
||||
invoke(
|
||||
handleGetProfile,
|
||||
|
||||
@@ -33,10 +33,11 @@ export function handleGetProfile(
|
||||
return;
|
||||
}
|
||||
|
||||
// Gate on the actual do_auth completion, not on auth_second_step: the app
|
||||
// sends auth_second_step=1 on its very first get_profile, so a parameter
|
||||
// check would let the login-required flow be bypassed without ever
|
||||
// exercising status 2 -> do_auth -> profile retry.
|
||||
// Gate on the actual do_auth completion, not on auth_second_step. The
|
||||
// client now sends `auth_second_step=0` first and `1` only on the retry
|
||||
// after do_auth, so a parameter check would happen to work — but it would
|
||||
// also silently pass for any client that mislabels its first profile,
|
||||
// which is exactly the protocol mistake this scenario exists to catch.
|
||||
if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) {
|
||||
res.json({
|
||||
js: {
|
||||
|
||||
+37
@@ -3,9 +3,11 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
stalkerSessionFingerprint,
|
||||
StalkerPortalDiscoveryService,
|
||||
StalkerPortalError,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerPortalImportComponent } from './stalker-portal-import.component';
|
||||
|
||||
describe('StalkerPortalImportComponent identity handling', () => {
|
||||
@@ -193,6 +195,41 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
expect(playlist.stalkerSessionIdentity).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('records credentials in the imported session fingerprint', async () => {
|
||||
// Otherwise the first runtime ensureToken() computes a fingerprint
|
||||
// WITH the credentials, mismatches the imported one, and discards the
|
||||
// session the import just established — silently defeating reuse for
|
||||
// exactly the login portals this flow exists for.
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'token-1',
|
||||
});
|
||||
component.form.patchValue({
|
||||
_id: 'playlist-login-fp',
|
||||
title: 'Login Portal',
|
||||
macAddress: '00:1A:79:00:00:08',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
importDate: '2026-05-15T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
const playlist = store.dispatch.mock.calls[0][0].playlist;
|
||||
expect(playlist.stalkerSessionIdentity).toBe(
|
||||
stalkerSessionFingerprint({
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
macAddress: '00:1A:79:00:00:08',
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
} as Playlist)
|
||||
);
|
||||
});
|
||||
|
||||
it('records the effective cadence when the portal advertises none', async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
|
||||
+8
-3
@@ -245,14 +245,19 @@ export class StalkerPortalImportComponent {
|
||||
portalUrl,
|
||||
isFullStalkerPortal,
|
||||
stalkerToken,
|
||||
// The identity this token was negotiated for. Reuse is
|
||||
// refused when it no longer matches, so an edited MAC cannot
|
||||
// inherit the previous session.
|
||||
// What this token was negotiated for: endpoint, identity AND
|
||||
// credentials. Reuse is refused when any of them no longer
|
||||
// matches — and the credentials must be included here, or the
|
||||
// first runtime `ensureToken()` would compute a fingerprint
|
||||
// WITH them, mismatch this one, and throw away the session
|
||||
// the import just established.
|
||||
...(stalkerToken
|
||||
? {
|
||||
stalkerSessionIdentity: stalkerSessionFingerprint({
|
||||
portalUrl,
|
||||
macAddress: formValue.macAddress ?? '',
|
||||
username: formValue.username ?? '',
|
||||
password: formValue.password ?? '',
|
||||
...this.toPlaylistIdentityFields(stalkerIdentity),
|
||||
} as Playlist),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user