Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #215: the two GeoIP detectors are now called when a sign-in completes, so the databases an admin configures actually produce the alerts the README promises.
SuspiciousLoginDetectorandImpossibleTravelDetectorwere complete and registered, and nothing ever calledObserveAsync. This adds aSignInObserverthat hands a finished sign-in to both, calls it from every place the plugin already records a successful sign-in, and adds one Diagnostics row so an admin can tell "nothing suspicious happened" from "nothing is calling the detectors". Neither detector changes; both keep guarding their own preconditions.Type of change
A user who configured the databases gets alerts that never arrived before, which is the fix, not a new feature. Anyone who never configured them sees no change: both detectors still return early when no database is open, and the new Diagnostics row says so.
Related issues
Fixes #215. The detectors date back to the first commit of the published history (cc80f91, v2.2.2);
git log -S ObserveAsync -- srcshows no call site was ever added or removed. The comment onNotificationService.NotifySuspiciousLoginAsyncalready describes the call this adds: "Fired by SuspiciousLoginDetector before the challenge response is returned, fire-and-forget at the call site".How was this tested?
Server tested against: Jellyfin 12.0.0, official Docker image, with the Jellyfin 12 package built from this branch by
build.sh. Clients:curl, driving the same endpoints the web client and a native client use.The controller paths. Fresh server, three DB-IP Lite databases loaded (
Okin Diagnostics), impossible travel at the default 900 km/h, webhook receiver listening, one account with TOTP:The paths that never reach the controller. Same setup, but the second sign-in is an app password (a native client signing in through
/Users/AuthenticateByName, which the provider and the session handler serve, neverVerify):The user record after both, which stayed empty before this change:
And the Diagnostics row, before any sign-in and after:
No
[ERR]from the plugin in any run; the only[WRN]is the impossible-travel line itself.Local. With the .NET SDK 9.0.316 that CI installs:
dotnet build JellyfinSecurity.sln -c Releasehas 0 errors,dotnet test -c Releaseis 527 passed and 0 failed (511 before this branch),node --test tests/oidc-bridge.test.mjsis 8 passed. With the .NET SDK 10.0.400,dotnet build src/... -p:JellyfinVersion=12.0.0 -c Releasehas 0 warnings and 0 errors.The wiring tests bite. I broke the code on purpose and re-ran them: removing any one call site fails the test for that file, and swapping the resolved address for the raw peer (
challenge.RemoteIpin the controller,info.RemoteEndPointin the handler) fails it too. All four mutations reverted afterwards.What changed
Services/SignInObserver.cs(new):Observe(userId, username, ip)returns immediately and runs the two detectors in order on a background task, so the two writes to the same user record never race, and a failure in either is logged rather than surfaced to the caller.ShouldObserveholds the "is this worth resolving" decision so it can be tested on its own.LastObservedAtfeeds the Diagnostics row.PluginServiceRegistrator.cs: registers it as a singleton, next to the detectors.Api/TwoFactorAuthController.cs: four calls, at the points that already write anAuditResult.Successrow:Verify(TOTP or recovery code),Verify/Passkey/Finish,Enroll/Totp/Confirm(forced enrolment ends in a signed-in session) andAuthenticate(trusted-device and password-only sessions).Services/AuthenticationEventHandler.cs: five calls, for the sign-ins that never reach the controller: device pre-verified, QuickConnect, app password, paired device and bypass. The reconnect path is deliberately left out, because a websocket reconnect on an already-verified token is not a fresh sign-in, and so is the challenge path, where no sign-in has happened yet.Services/BypassEvaluator.cs:ResolveClientIpgains an overload taking the peer and the forwarded header, and theHttpContextversion now delegates to it. The handler has noHttpContexton its background task; it already snapshotsX-Forwarded-ForinOnSessionStartedfor exactly this reason, and now that snapshot resolves the address the same way every other caller does.Services/DiagnosticsService.cs: one row,signin_observation, saying when a sign-in last reached the detectors. AlwaysOk, because every state it reports is legitimate: a server that just restarted has none yet, one with no database loaded is not expected to resolve anything. The value is the timestamp. An admin who sees the GeoIP rowsOkand this row stuck on "no sign-in observed" while people are signing in is looking at the bug this row was added for.ShouldObservedecision; that a server with no GeoIP database is left untouched and the caller sees no exception; that a skipped sign-in is not counted as observed; the four controller call sites and the five handler call sites, each passing the resolved address, with the reconnect path asserted to have none; the newResolveClientIpoverload's fallback; and the three states of the Diagnostics row.Every call site passes the proxy-walked address, never the peer. That distinction is not cosmetic. In the app-password run above, the plugin's own audit row for that session recorded
172.18.0.1, the Docker gateway, while the detectors correctly saw200.160.2.3; feeding them the peer would make every remote user look like one address and impossible travel would never fire behind a reverse proxy.Checklist
dotnet formatif unsure)SeenContextsandLastLocationalready exist inUserTwoFactorDataand start getting written; nothing is read that was not written beforedotnet build+dotnet testgreen)Additional notes
A web sign-in with 2FA is observed twice: once by
Verifyand once by the device-pre-verified branch of the session handler that follows it. That is harmless and deliberate. The second observation carries the same address, so the context is no longer novel and the distance is zero, and no second alert fires. I kept it rather than skipping that branch because an app-password sign-in can also land there (the provider arms both the device pre-verify and the user-scoped one-shot, and whichever matches first wins), and skipping it would silently drop those.Two things I noticed while testing and deliberately did not change, so this stays a wiring fix. Both are one-line calls for you; say which way you want them and I will send either as its own PR:
NotifySuspiciousLoginAsync, so it arrives titled "Sign-in from a new location" with the travel detail packed into theasnOrgfield ("BR via Impossible travel: 10386km ..."). It reads oddly next to the genuine new-location alert that fires beside it. A dedicatedNotifyImpossibleTravelAsyncwith its own title and animpossible_travelevent name would let webhook consumers tell them apart.LastLocation.Asnis 0 becauseResolveCityreads it from the City database, which carries no ASN field in DB-IP Lite or in GeoLite2-City. The ASN is available from the ASN database the other detector already has open.