mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
feat(auth): external sign-in for the website and admin panel
Players sign in to the website, and admins to the admin panel, with an OpenID Connect provider (DorkNet:SignIn:*). The first sign-in links the provider identity (issuer + subject) to a DorkNet account with that account's password; after that the provider sign-in is enough. - Cookie sessions per host, keys persisted in the database so redeploys keep people signed in; unlink, deletion or a ban ends the session. - Link attempts limited to 5 failures per 15 minutes per identity and per username. - Admin panel: "Sign in with ..." exchanges the session for the usual admin tokens via GET /api/admin/v1/sso (IsAdmin still required). - Downloads: optional DorkNet:Downloads:RequireSignIn serves the manifest and files only to signed-in players; the Downloads and Quest pages offer sign-in instead. - Website: sign-in in the header, /link and /account pages. The game client's login is unchanged.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Website / admin-panel sign-in with an external OpenID Connect provider.
|
||||
/// The provider round-trip can't run in-process, so each test starts where the
|
||||
/// provider callback leaves off: a <c>DorkNet.External</c> cookie holding the
|
||||
/// provider identity, encrypted with the app's own cookie format. Everything
|
||||
/// from there (link, session, admin SSO, unlink, limits) is the real code.
|
||||
/// </summary>
|
||||
public sealed class ExternalSignInTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
private const string Issuer = "https://idp.example.com";
|
||||
private readonly DorkNetServerFactory _factory;
|
||||
|
||||
public ExternalSignInTests(DorkNetServerFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Linking_with_the_account_password_signs_in_and_the_next_sign_in_needs_no_password()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
var subject = Guid.NewGuid().ToString("N");
|
||||
|
||||
using var site = Client();
|
||||
var link = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(subject, "player@example.com"));
|
||||
Assert.Equal(HttpStatusCode.OK, link.StatusCode);
|
||||
var session = SessionCookie(link);
|
||||
Assert.NotNull(session);
|
||||
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.True(me.GetProperty("signedIn").GetBoolean());
|
||||
Assert.Equal(player.Username, me.GetProperty("player").GetProperty("username").GetString());
|
||||
Assert.Equal("player@example.com", me.GetProperty("player").GetProperty("email").GetString());
|
||||
|
||||
// Next time the provider identity alone is enough.
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var signIn = scope.ServiceProvider.GetRequiredService<ExternalSignInService>();
|
||||
var stored = await signIn.FindAsync(Issuer, subject);
|
||||
Assert.Equal(player.Id, stored?.PlayerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_passwords_are_refused_then_rate_limited()
|
||||
{
|
||||
var (player, _) = await PlayerAsync();
|
||||
var external = ExternalCookie(Guid.NewGuid().ToString("N"), "guess@example.com");
|
||||
using var site = Client();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
using var r = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password = "wrong-" + i }, external);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, r.StatusCode);
|
||||
}
|
||||
using var blocked = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password = "still-wrong" }, external);
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, blocked.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_identity_linked_to_one_account_cannot_be_linked_to_another()
|
||||
{
|
||||
var (first, firstPassword) = await PlayerAsync();
|
||||
var (second, secondPassword) = await PlayerAsync();
|
||||
var subject = Guid.NewGuid().ToString("N");
|
||||
using var site = Client();
|
||||
|
||||
using var ok = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = first.Username, password = firstPassword }, ExternalCookie(subject, "a@example.com"));
|
||||
Assert.Equal(HttpStatusCode.OK, ok.StatusCode);
|
||||
using var refused = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = second.Username, password = secondPassword }, ExternalCookie(subject, "a@example.com"));
|
||||
Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_sso_issues_tokens_only_to_admins()
|
||||
{
|
||||
var (admin, adminPassword) = await PlayerAsync(isAdmin: true);
|
||||
var (regular, regularPassword) = await PlayerAsync();
|
||||
using var admins = Client("admin");
|
||||
|
||||
var adminSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
|
||||
new { username = admin.Username, password = adminPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
using var tokens = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", adminSession);
|
||||
Assert.Equal(HttpStatusCode.OK, tokens.StatusCode);
|
||||
var body = JsonDocument.Parse(await tokens.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.False(string.IsNullOrEmpty(body.GetProperty("access_token").GetString()));
|
||||
Assert.Equal(admin.Id, body.GetProperty("account_id").GetInt64());
|
||||
|
||||
var regularSession = SessionCookie(await PostJsonAsync(admins, "/api/site/v1/account/link",
|
||||
new { username = regular.Username, password = regularPassword }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
using var refused = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", regularSession);
|
||||
Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode);
|
||||
|
||||
using var anonymous = await SendAsync(admins, HttpMethod.Get, "/api/admin/v1/sso", null);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlinking_ends_the_session()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
using var site = Client();
|
||||
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
|
||||
using var unlink = await SendAsync(site, HttpMethod.Post, "/api/site/v1/account/unlink", session);
|
||||
Assert.Equal(HttpStatusCode.OK, unlink.StatusCode);
|
||||
|
||||
// The old cookie is still in the browser, but the link behind it is gone.
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.False(me.GetProperty("signedIn").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_banned_player_cannot_link_and_loses_an_existing_session()
|
||||
{
|
||||
var (player, password) = await PlayerAsync();
|
||||
using var site = Client();
|
||||
var session = SessionCookie(await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), "")));
|
||||
|
||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
await db.Players.Where(p => p.Id == player.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.BannedUntil, DateTime.UtcNow.AddDays(1)));
|
||||
}
|
||||
|
||||
var me = await GetJsonAsync(site, "/api/site/v1/account/me", session);
|
||||
Assert.False(me.GetProperty("signedIn").GetBoolean());
|
||||
using var relink = await PostJsonAsync(site, "/api/site/v1/account/link",
|
||||
new { username = player.Username, password }, ExternalCookie(Guid.NewGuid().ToString("N"), ""));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, relink.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Downloads_can_require_sign_in()
|
||||
{
|
||||
await using var factory = _factory.WithWebHostBuilder(b =>
|
||||
{
|
||||
b.UseSetting("DorkNet:Downloads:BaseUrl", "http://127.0.0.1:9");
|
||||
b.UseSetting("DorkNet:Downloads:RequireSignIn", "true");
|
||||
});
|
||||
using var site = factory.CreateClient(new() { BaseAddress = new Uri($"http://{_factory.ApexDomain}") });
|
||||
using var manifest = await site.GetAsync("/api/site/v1/downloads");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, manifest.StatusCode);
|
||||
using var file = await site.GetAsync("/api/site/v1/downloads/files/2023.03/DorkNet-2023.03-Quest.xapk");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, file.StatusCode);
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(PlayerEntity Player, string Password)> PlayerAsync(bool isAdmin = false)
|
||||
{
|
||||
var password = "pw-" + Guid.NewGuid().ToString("N")[..12];
|
||||
var player = new PlayerEntity
|
||||
{
|
||||
Id = 9_400_000 + Random.Shared.Next(1, 99_999),
|
||||
Username = "sso" + Guid.NewGuid().ToString("N")[..10],
|
||||
DisplayName = "SSO test",
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
||||
IsAdmin = isAdmin,
|
||||
};
|
||||
await using var scope = _factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||
db.Players.Add(player);
|
||||
await db.SaveChangesAsync();
|
||||
return (player, password);
|
||||
}
|
||||
|
||||
/// <summary>The cookie the provider callback leaves behind: the provider
|
||||
/// identity in the DorkNet.External scheme, in the app's cookie format.</summary>
|
||||
private string ExternalCookie(string subject, string email)
|
||||
{
|
||||
var options = _factory.Services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
|
||||
.Get(ExternalSignIn.ExternalScheme);
|
||||
var identity = new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim("iss", Issuer), new Claim("sub", subject), new Claim("email", email),
|
||||
}, "test");
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), ExternalSignIn.ExternalScheme);
|
||||
return $"{options.Cookie.Name}={options.TicketDataFormat.Protect(ticket)}";
|
||||
}
|
||||
|
||||
private static string? SessionCookie(HttpResponseMessage response) =>
|
||||
response.Headers.TryGetValues("Set-Cookie", out var values)
|
||||
? values.Select(v => v.Split(';')[0]).FirstOrDefault(v => v.StartsWith("dorknet_session=") && v.Length > "dorknet_session=".Length)
|
||||
: null;
|
||||
|
||||
private HttpClient Client(string? subdomain = null)
|
||||
{
|
||||
var host = subdomain is null ? _factory.ApexDomain : $"{subdomain}.{_factory.ApexDomain}";
|
||||
return _factory.CreateClient(new() { BaseAddress = new Uri($"http://{host}"), HandleCookies = false });
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> SendAsync(HttpClient client, HttpMethod method, string path, string? cookie)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, path);
|
||||
if (cookie is not null) request.Headers.Add("Cookie", cookie);
|
||||
return client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static Task<HttpResponseMessage> PostJsonAsync(HttpClient client, string path, object body, string? cookie)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = JsonContent.Create(body) };
|
||||
if (cookie is not null) request.Headers.Add("Cookie", cookie);
|
||||
return client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path, string? cookie)
|
||||
{
|
||||
using var response = await SendAsync(client, HttpMethod.Get, path, cookie);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.True(response.IsSuccessStatusCode, $"GET {path} -> {(int)response.StatusCode}: {body}");
|
||||
return JsonDocument.Parse(body).RootElement.Clone();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user