Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Nullinside.MySql.EntityFrameworkCore" Version="9.0.3"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
<PackageReference Include="System.Text.Json" Version="9.0.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3"/>
<PackageReference Include="System.Text.Json" Version="9.0.7"/>
</ItemGroup>

<ItemGroup>
Expand Down
4 changes: 2 additions & 2 deletions src/Nullinside.Api.Model/Nullinside.Api.Model.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Nullinside.MySql.EntityFrameworkCore" Version="9.0.3"/>
<PackageReference Include="System.Text.Json" Version="9.0.7" />
<PackageReference Include="System.Text.Json" Version="9.0.7"/>
</ItemGroup>

<ItemGroup>
Expand Down
4 changes: 2 additions & 2 deletions src/Nullinside.Api.Tests/Nullinside.Api.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.7"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="Moq" Version="4.20.72"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Nullinside.Api.Controllers;
using Nullinside.Api.Model;
using Nullinside.Api.Model.Ddl;
using Nullinside.Api.Shared;
using Nullinside.Api.Shared.Json;

namespace Nullinside.Api.Tests.Nullinside.Api.Controllers;
Expand All @@ -31,6 +32,11 @@ public class UserControllerTests : UnitTestBase {
/// </summary>
private Mock<ITwitchApiProxy> _twitchApi;

/// <summary>
/// The web socket persister.
/// </summary>
private Mock<IWebSocketPersister> _webSocketPersister;

/// <inheritdoc />
public override void Setup() {
base.Setup();
Expand All @@ -45,6 +51,7 @@ public override void Setup() {
.Build();

_twitchApi = new Mock<ITwitchApiProxy>();
_webSocketPersister = new Mock<IWebSocketPersister>();
}

/// <summary>
Expand All @@ -61,7 +68,7 @@ public async Task PerformGoogleLoginExisting() {
await _db.SaveChangesAsync();

// Make the call and ensure it's successful.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
controller.Email = "hi";
RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });

Expand All @@ -81,7 +88,7 @@ public async Task PerformGoogleLoginExisting() {
[Test]
public async Task PerformGoogleLoginNewUser() {
// Make the call and ensure it's successful.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
controller.Email = "hi";
RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });

Expand All @@ -103,7 +110,7 @@ public async Task GoToErrorOnDbException() {
_db.Users = null!;

// Make the call and ensure it's successful.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
controller.Email = "hi";
RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });

Expand All @@ -117,7 +124,7 @@ public async Task GoToErrorOnDbException() {
[Test]
public async Task GoToErrorOnBadGmailResponse() {
// Make the call and ensure it's successful.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
controller.Email = null;
RedirectResult obj = await controller.Login(new GoogleOpenIdToken { credential = "stuff" });

Expand Down Expand Up @@ -147,7 +154,7 @@ public async Task PerformTwitchLoginExisting() {
await _db.SaveChangesAsync();

// Make the call and ensure it's successful.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object);

// We should have been redirected to the successful route.
Expand All @@ -174,7 +181,7 @@ public async Task PerformTwitchLoginNewUser() {
.Returns(() => Task.FromResult<string?>("hi"));

// Make the call and ensure it's successful.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object);

// We should have been redirected to the successful route.
Expand All @@ -197,7 +204,7 @@ public async Task PerformTwitchLoginBadTwitchResponse() {
.Returns(() => Task.FromResult<TwitchAccessToken?>(null));

// Make the call and ensure it's successful.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object);

// We should have gone down the bad route
Expand All @@ -214,7 +221,7 @@ public async Task PerformTwitchLoginWithNoEmailAccount() {
.Returns(() => Task.FromResult<TwitchAccessToken?>(new TwitchAccessToken()));

// Make the call and ensure it's successful.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object);

// We should have gone down the bad route because no email was associated with the twitch account.
Expand All @@ -237,7 +244,7 @@ public async Task PerformTwitchLoginDbFailure() {
.Returns(() => Task.FromResult<string?>("hi"));

// Make the call and ensure it's successful.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
RedirectResult obj = await controller.TwitchLogin("things", _twitchApi.Object);

// We should have been redirected to the error route because of an exception in DB processing.
Expand All @@ -256,7 +263,7 @@ public void GetRoles() {
var identity = new ClaimsIdentity(claims, "icecream");

// Make the call and ensure it's successful.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
controller.ControllerContext = new ControllerContext();
controller.ControllerContext.HttpContext = new DefaultHttpContext();
controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(identity);
Expand All @@ -279,7 +286,7 @@ public async Task ValidateTokenExists() {
await _db.SaveChangesAsync();

// Make the call and ensure it's successful.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
IActionResult obj = await controller.Validate(new AuthToken("123"));
Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(200));

Expand All @@ -293,7 +300,7 @@ public async Task ValidateTokenExists() {
[Test]
public async Task ValidateFailWithoutToken() {
// Make the call and ensure it fails.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
IActionResult obj = await controller.Validate(new AuthToken("123"));
Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(401));
}
Expand All @@ -306,7 +313,7 @@ public async Task ValidateFailOnDbFailure() {
_db.Users = null!;

// Make the call and ensure it fails.
var controller = new TestableUserController(_configuration, _db);
var controller = new TestableUserController(_configuration, _db, _webSocketPersister.Object);
IActionResult obj = await controller.Validate(new AuthToken("123"));
Assert.That((obj as IStatusCodeActionResult)?.StatusCode, Is.EqualTo(500));
}
Expand All @@ -317,7 +324,7 @@ public async Task ValidateFailOnDbFailure() {
/// </summary>
public class TestableUserController : UserController {
/// <inheritdoc />
public TestableUserController(IConfiguration configuration, INullinsideContext dbContext) : base(configuration, dbContext) {
public TestableUserController(IConfiguration configuration, INullinsideContext dbContext, IWebSocketPersister webSocketPersister) : base(configuration, dbContext, webSocketPersister) {
}

/// <summary>
Expand Down
95 changes: 80 additions & 15 deletions src/Nullinside.Api/Controllers/UserController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Net.WebSockets;
using System.Security.Claims;
using System.Text;

using Google.Apis.Auth;

Expand All @@ -8,10 +10,14 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

using Newtonsoft.Json;

using Nullinside.Api.Common.Extensions;
using Nullinside.Api.Common.Twitch;
using Nullinside.Api.Model;
using Nullinside.Api.Model.Ddl;
using Nullinside.Api.Model.Shared;
using Nullinside.Api.Shared;
using Nullinside.Api.Shared.Json;

namespace Nullinside.Api.Controllers;
Expand All @@ -37,14 +43,21 @@ public class UserController : ControllerBase {
/// </summary>
private readonly ILog _logger = LogManager.GetLogger(typeof(UserController));

/// <summary>
/// A collection of web sockets key'd by an id representing the request for the information.
/// </summary>
private readonly IWebSocketPersister _webSockets;

/// <summary>
/// Initializes a new instance of the <see cref="UserController" /> class.
/// </summary>
/// <param name="configuration">The application's configuration file.</param>
/// <param name="dbContext">The nullinside database.</param>
public UserController(IConfiguration configuration, INullinsideContext dbContext) {
/// <param name="webSocketPersister">The web socket persistence service.</param>
public UserController(IConfiguration configuration, INullinsideContext dbContext, IWebSocketPersister webSocketPersister) {
_configuration = configuration;
_dbContext = dbContext;
_webSockets = webSocketPersister;
}

/// <summary>
Expand Down Expand Up @@ -128,6 +141,7 @@ public async Task<RedirectResult> TwitchLogin([FromQuery] string code, [FromServ
/// redirects users back to the nullinside website.
/// </summary>
/// <param name="code">The credentials provided by twitch.</param>
/// <param name="state">An identifier for the request allowing for retrieval of the login information.</param>
/// <param name="api">The twitch api.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>
Expand All @@ -140,14 +154,73 @@ public async Task<RedirectResult> TwitchLogin([FromQuery] string code, [FromServ
[AllowAnonymous]
[HttpGet]
[Route("twitch-login/twitch-streaming-tools")]
public async Task<RedirectResult> TwitchStreamingToolsLogin([FromQuery] string code, [FromServices] ITwitchApiProxy api,
public async Task<RedirectResult> TwitchStreamingToolsLogin([FromQuery] string code, [FromQuery] string state, [FromServices] ITwitchApiProxy api,
CancellationToken token = new()) {
// The first thing we need to do is make sure someone subscribed to a web socket waiting for the answer to the
// credentials question we're being asked.
string? siteUrl = _configuration.GetValue<string>("Api:SiteUrl");
if (!_webSockets.WebSockets.ContainsKey(state)) {
return Redirect($"{siteUrl}/user/login/desktop?error=2");
}

// Since someone already warned us this request was coming, create an oauth token from the code we received.
if (null == await api.CreateAccessToken(code, token)) {
return Redirect($"{siteUrl}/user/login/desktop?error=3");
}

return Redirect($"{siteUrl}/user/login/desktop?bearer={api.OAuth?.AccessToken}&refresh={api.OAuth?.RefreshToken}&expiresUtc={api.OAuth?.ExpiresUtc?.ToString()}");
// The "someone" that warned us this request was coming has been sitting around waiting for an answer on a web
// socket so we will pull up that socket and give them their oauth information.
try {
WebSocket socket = _webSockets.WebSockets[state];
var oAuth = new TwitchAccessToken {
AccessToken = api.OAuth?.AccessToken ?? string.Empty,
RefreshToken = api.OAuth?.RefreshToken ?? string.Empty,
ExpiresUtc = api.OAuth?.ExpiresUtc ?? DateTime.MinValue
};

await socket.SendTextAsync(JsonConvert.SerializeObject(oAuth), token);
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Completed Successfully!", token);
_webSockets.WebSockets.TryRemove(state, out _);
socket.Dispose();
}
catch {
return Redirect($"{siteUrl}/user/login/desktop?error=2");
}

return Redirect($"{siteUrl}/user/login/desktop");
}

/// <summary>
/// A websocket used by clients to wait for their login token after twitch authenticates.
/// </summary>
/// <param name="token">The cancellation token.</param>
[AllowAnonymous]
[HttpGet]
[Route("twitch-login/twitch-streaming-tools/ws")]
public async Task TwitchStreamingToolsRefreshToken(CancellationToken token = new()) {
if (HttpContext.WebSockets.IsWebSocketRequest) {
// Connect with the client
using WebSocket webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();

// The first communication over the web socket is always the id that we will later get from the
// twitch api with the associated credentials.
string id = await webSocket.ReceiveTextAsync(token);
id = id.Trim();

// Add the web socket to web socket persistant service. It will be sitting there until the twitch api calls our
// api later on.
_webSockets.WebSockets.TryAdd(id, webSocket);

// Regardless of whether you have a using statement above, the minute we leave the controller method we will
// lose the connection. That's just the way web sockets are implemented in .NET Core Web APIs. So we have to sit
// here in an await (specifically in an await so we don't mess up the thread pool) until twitch calls us.
while (null == webSocket.CloseStatus) {
await Task.Delay(1000, token);
}
}
else {
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
}
}

/// <summary>
Expand All @@ -156,19 +229,11 @@ public async Task<RedirectResult> TwitchStreamingToolsLogin([FromQuery] string c
/// <param name="refreshToken">The oauth refresh token provided by twitch.</param>
/// <param name="api">The twitch api.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>
/// A redirect to the nullinside website.
/// Errors:
/// 2 = Internal error generating token.
/// 3 = Code was invalid
/// 4 = Twitch account has no email
/// </returns>
[AllowAnonymous]
[HttpPost]
[Route("twitch-login/twitch-streaming-tools")]
public async Task<IActionResult> TwitchStreamingToolsRefreshToken([FromForm] string refreshToken, [FromServices] ITwitchApiProxy api,
CancellationToken token = new()) {
string? siteUrl = _configuration.GetValue<string>("Api:SiteUrl");
api.OAuth = new TwitchAccessToken {
AccessToken = null,
RefreshToken = refreshToken,
Expand All @@ -179,10 +244,10 @@ public async Task<IActionResult> TwitchStreamingToolsRefreshToken([FromForm] str
return BadRequest();
}

return Ok(new {
bearer = api.OAuth.AccessToken,
refresh = api.OAuth.RefreshToken,
expiresUtc = api.OAuth.ExpiresUtc
return Ok(new TwitchAccessToken {
AccessToken = api.OAuth.AccessToken ?? string.Empty,
RefreshToken = api.OAuth.RefreshToken ?? string.Empty,
ExpiresUtc = api.OAuth.ExpiresUtc ?? DateTime.MinValue
});
}

Expand Down
4 changes: 2 additions & 2 deletions src/Nullinside.Api/Nullinside.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
<PackageReference Include="Nullinside.MySql.EntityFrameworkCore" Version="9.0.3"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="SSH.NET" Version="2025.0.0"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
<PackageReference Include="System.Text.Json" Version="9.0.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3"/>
<PackageReference Include="System.Text.Json" Version="9.0.7"/>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
<PackageReference Include="TwitchLib.Api" Version="3.9.0"/>
</ItemGroup>
Expand Down
Loading