From 8929b87e4215a94596ac5078e9873c675e0b5779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=E2=96=88=E2=96=88=E2=96=88=E2=96=88=E2=96=88?= Date: Tue, 30 Sep 2025 18:46:43 -0400 Subject: [PATCH] feat: adding refresh token and expiring for oauth I wanted to use the .Net Core implementation of this stuff but it wants an authority server and all kinds of nonsense I'm not going to comb through on a side project. --- .../Middleware/BasicAuthenticationHandler.cs | 3 +- src/Nullinside.Api.Common/Auth/AuthUtils.cs | 8 +- src/Nullinside.Api.Model/Ddl/User.cs | 12 + ...250930224533_OAuthRefreshToken.Designer.cs | 321 ++++++++++++++++++ .../20250930224533_OAuthRefreshToken.cs | 40 +++ .../NullinsideContextModelSnapshot.cs | 9 +- .../Shared/UserHelpers.cs | 26 +- .../Shared/UserHelpersTests.cs | 6 +- src/Nullinside.Api/Constants.cs | 11 + .../Controllers/UserController.cs | 4 +- 10 files changed, 418 insertions(+), 22 deletions(-) create mode 100644 src/Nullinside.Api.Model/Migrations/20250930224533_OAuthRefreshToken.Designer.cs create mode 100644 src/Nullinside.Api.Model/Migrations/20250930224533_OAuthRefreshToken.cs create mode 100644 src/Nullinside.Api/Constants.cs diff --git a/src/Nullinside.Api.Common.AspNetCore/Middleware/BasicAuthenticationHandler.cs b/src/Nullinside.Api.Common.AspNetCore/Middleware/BasicAuthenticationHandler.cs index dc91dc5..c3c16db 100644 --- a/src/Nullinside.Api.Common.AspNetCore/Middleware/BasicAuthenticationHandler.cs +++ b/src/Nullinside.Api.Common.AspNetCore/Middleware/BasicAuthenticationHandler.cs @@ -61,7 +61,8 @@ protected override async Task HandleAuthenticateAsync() { .AsNoTracking() .FirstOrDefaultAsync(u => !string.IsNullOrWhiteSpace(u.Token) && u.Token == token && - !u.IsBanned).ConfigureAwait(false); + !u.IsBanned) + .ConfigureAwait(false); if (null == dbUser) { return AuthenticateResult.Fail("Invalid token"); diff --git a/src/Nullinside.Api.Common/Auth/AuthUtils.cs b/src/Nullinside.Api.Common/Auth/AuthUtils.cs index f5c343a..5fef8f0 100644 --- a/src/Nullinside.Api.Common/Auth/AuthUtils.cs +++ b/src/Nullinside.Api.Common/Auth/AuthUtils.cs @@ -7,12 +7,10 @@ namespace Nullinside.Api.Common.Auth; /// public static class AuthUtils { /// - /// Generates a new unique bearer token. + /// Generates a new unique token. /// - /// A bearer token. - public static string GenerateBearerToken() { - // This method is trash but it doesn't matter. We should be doing real OAuth tokens with expirations and - // renewals. Right now nothing that exists on the site requires this level of sophistication. + /// A token. + public static string GenerateToken() { string allowed = "ABCDEFGHIJKLMONOPQRSTUVWXYZabcdefghijklmonopqrstuvwxyz0123456789"; int strlen = 255; // Or whatever char[] randomChars = new char[strlen]; diff --git a/src/Nullinside.Api.Model/Ddl/User.cs b/src/Nullinside.Api.Model/Ddl/User.cs index c5d4c00..8227484 100644 --- a/src/Nullinside.Api.Model/Ddl/User.cs +++ b/src/Nullinside.Api.Model/Ddl/User.cs @@ -27,6 +27,16 @@ public class User : ITableModel { /// The user's auth token for interacting with the site's API. /// public string? Token { get; set; } + + /// + /// The user's auth token for interacting with the site's API. + /// + public string? RefreshToken { get; set; } + + /// + /// The user's auth token for interacting with the site's API. + /// + public DateTime? TokenExpires { get; set; } /// /// The id of the user on twitch. @@ -95,6 +105,8 @@ public void OnModelCreating(ModelBuilder modelBuilder) { .HasMaxLength(255); entity.Property(e => e.Token) .HasMaxLength(255); + entity.Property(e => e.RefreshToken) + .HasMaxLength(255); entity.Property(e => e.UpdatedOn) .IsRowVersion(); // TODO: Add the other strings in this file with lengths diff --git a/src/Nullinside.Api.Model/Migrations/20250930224533_OAuthRefreshToken.Designer.cs b/src/Nullinside.Api.Model/Migrations/20250930224533_OAuthRefreshToken.Designer.cs new file mode 100644 index 0000000..04d72e3 --- /dev/null +++ b/src/Nullinside.Api.Model/Migrations/20250930224533_OAuthRefreshToken.Designer.cs @@ -0,0 +1,321 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nullinside.Api.Model; + +#nullable disable + +namespace Nullinside.Api.Model.Migrations +{ + [DbContext(typeof(NullinsideContext))] + [Migration("20250930224533_OAuthRefreshToken")] + partial class OAuthRefreshToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.DockerDeployments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("IsDockerComposeProject") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("Notes") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ServerDir") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("DisplayName"); + + b.HasIndex("IsDockerComposeProject", "Name"); + + b.ToTable("DockerDeployments"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.FeatureToggle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Feature") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.ToTable("FeatureToggle"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchBan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BannedUserTwitchId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("TwitchBan"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("TwitchId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("TwitchUsername") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.HasIndex("TwitchId") + .IsUnique(); + + b.ToTable("TwitchUser"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserBannedOutsideOfBotLogs", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Channel") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Reason") + .HasColumnType("longtext"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("TwitchId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("TwitchUsername") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.ToTable("TwitchUserBannedOutsideOfBotLogs"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserChatLogs", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Channel") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Message") + .HasColumnType("longtext"); + + b.Property("Timestamp") + .HasColumnType("datetime(6)"); + + b.Property("TwitchId") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("TwitchUsername") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.HasKey("Id"); + + b.ToTable("TwitchUserChatLogs"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("BanKnownBots") + .HasColumnType("tinyint(1)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("UpdatedOn") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("TwitchUserConfig"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("CreatedOn") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("IsBanned") + .HasColumnType("tinyint(1)"); + + b.Property("RefreshToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("Token") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("TokenExpires") + .HasColumnType("datetime(6)"); + + b.Property("TwitchId") + .HasColumnType("longtext"); + + b.Property("TwitchLastScanned") + .HasColumnType("datetime(6)"); + + b.Property("TwitchRefreshToken") + .HasColumnType("longtext"); + + b.Property("TwitchToken") + .HasColumnType("longtext"); + + b.Property("TwitchTokenExpiration") + .HasColumnType("datetime(6)"); + + b.Property("TwitchUsername") + .HasColumnType("longtext"); + + b.Property("UpdatedOn") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.UserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("RoleAdded") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRoles"); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.TwitchUserConfig", b => + { + b.HasOne("Nullinside.Api.Model.Ddl.User", null) + .WithOne("TwitchConfig") + .HasForeignKey("Nullinside.Api.Model.Ddl.TwitchUserConfig", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.UserRole", b => + { + b.HasOne("Nullinside.Api.Model.Ddl.User", null) + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Nullinside.Api.Model.Ddl.User", b => + { + b.Navigation("Roles"); + + b.Navigation("TwitchConfig"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Nullinside.Api.Model/Migrations/20250930224533_OAuthRefreshToken.cs b/src/Nullinside.Api.Model/Migrations/20250930224533_OAuthRefreshToken.cs new file mode 100644 index 0000000..841a293 --- /dev/null +++ b/src/Nullinside.Api.Model/Migrations/20250930224533_OAuthRefreshToken.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nullinside.Api.Model.Migrations +{ + /// + public partial class OAuthRefreshToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RefreshToken", + table: "Users", + type: "varchar(255)", + maxLength: 255, + nullable: true); + + migrationBuilder.AddColumn( + name: "TokenExpires", + table: "Users", + type: "datetime(6)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RefreshToken", + table: "Users"); + + migrationBuilder.DropColumn( + name: "TokenExpires", + table: "Users"); + } + } +} diff --git a/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs b/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs index 040f4d1..20a522b 100644 --- a/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs +++ b/src/Nullinside.Api.Model/Migrations/NullinsideContextModelSnapshot.cs @@ -16,7 +16,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("ProductVersion", "9.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 64); modelBuilder.Entity("Nullinside.Api.Model.Ddl.DockerDeployments", b => @@ -224,10 +224,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsBanned") .HasColumnType("tinyint(1)"); + b.Property("RefreshToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + b.Property("Token") .HasMaxLength(255) .HasColumnType("varchar(255)"); + b.Property("TokenExpires") + .HasColumnType("datetime(6)"); + b.Property("TwitchId") .HasColumnType("longtext"); diff --git a/src/Nullinside.Api.Model/Shared/UserHelpers.cs b/src/Nullinside.Api.Model/Shared/UserHelpers.cs index dcd24cc..9b75caf 100644 --- a/src/Nullinside.Api.Model/Shared/UserHelpers.cs +++ b/src/Nullinside.Api.Model/Shared/UserHelpers.cs @@ -15,27 +15,31 @@ public static class UserHelpers { /// /// The database context. /// The email address of the user, user will be created if they don't already exist. - /// The cancellation token. + /// The amount of time, from issuing, that the generated token should expire. + /// The cancellation token. /// The authorization token for twitch, if applicable. /// The refresh token for twitch, if applicable. /// The expiration date of the token for twitch, if applicable. /// The username of the user on twitch. /// The id of the user on twitch. /// The bearer token if successful, null otherwise. - public static async Task GenerateTokenAndSaveToDatabase(INullinsideContext dbContext, string email, - CancellationToken token = new(), string? authToken = null, string? refreshToken = null, DateTime? expires = null, - string? twitchUsername = null, string? twitchId = null) { - string bearerToken = AuthUtils.GenerateBearerToken(); + public static async Task GenerateTokenAndSaveToDatabase(INullinsideContext dbContext, string email, + TimeSpan tokenExpires, string? authToken = null, string? refreshToken = null, DateTime? expires = null, + string? twitchUsername = null, string? twitchId = null, CancellationToken cancellationToken = new()) { + string bearerToken = AuthUtils.GenerateToken(); + string bearerRefreshToken = AuthUtils.GenerateToken(); try { - User? existing = await dbContext.Users.FirstOrDefaultAsync(u => u.Email == email && !u.IsBanned, token).ConfigureAwait(false); + User? existing = await dbContext.Users.FirstOrDefaultAsync(u => u.Email == email && !u.IsBanned, cancellationToken).ConfigureAwait(false); if (null == existing && !string.IsNullOrWhiteSpace(twitchUsername)) { - existing = await dbContext.Users.FirstOrDefaultAsync(u => u.TwitchUsername == twitchUsername && !u.IsBanned, token).ConfigureAwait(false); + existing = await dbContext.Users.FirstOrDefaultAsync(u => u.TwitchUsername == twitchUsername && !u.IsBanned, cancellationToken).ConfigureAwait(false); } if (null == existing) { dbContext.Users.Add(new User { Email = email, Token = bearerToken, + RefreshToken = bearerRefreshToken, + TokenExpires = DateTime.UtcNow + tokenExpires, TwitchId = twitchId, TwitchUsername = twitchUsername, TwitchToken = authToken, @@ -45,9 +49,9 @@ public static class UserHelpers { CreatedOn = DateTime.UtcNow }); - await dbContext.SaveChangesAsync(token).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - existing = await dbContext.Users.FirstOrDefaultAsync(u => u.Email == email, token).ConfigureAwait(false); + existing = await dbContext.Users.FirstOrDefaultAsync(u => u.Email == email, cancellationToken).ConfigureAwait(false); if (null == existing) { return null; } @@ -60,6 +64,8 @@ public static class UserHelpers { } else { existing.Token = bearerToken; + existing.RefreshToken = bearerRefreshToken; + existing.TokenExpires = DateTime.UtcNow + tokenExpires; existing.TwitchId = twitchId; existing.TwitchUsername = twitchUsername; existing.TwitchToken = authToken; @@ -68,7 +74,7 @@ public static class UserHelpers { existing.UpdatedOn = DateTime.UtcNow; } - await dbContext.SaveChangesAsync(token).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return bearerToken; } catch { diff --git a/src/Nullinside.Api.Tests/Nullinside.Api.Model/Shared/UserHelpersTests.cs b/src/Nullinside.Api.Tests/Nullinside.Api.Model/Shared/UserHelpersTests.cs index 8f09485..1d3fa58 100644 --- a/src/Nullinside.Api.Tests/Nullinside.Api.Model/Shared/UserHelpersTests.cs +++ b/src/Nullinside.Api.Tests/Nullinside.Api.Model/Shared/UserHelpersTests.cs @@ -23,7 +23,7 @@ public async Task GenerateTokenForExistingUser() { Assert.That(_db.Users.Count(), Is.EqualTo(1)); // Generate a new token - string? token = await UserHelpers.GenerateTokenAndSaveToDatabase(_db, "email").ConfigureAwait(false); + string? token = await UserHelpers.GenerateTokenAndSaveToDatabase(_db, "email", Constants.OAUTH_TOKEN_TIME_LIMIT).ConfigureAwait(false); Assert.That(token, Is.Not.Null); // Verify we still only have one user @@ -48,7 +48,7 @@ public async Task GenerateTokenForNewUser() { Assert.That(_db.Users.Count(), Is.EqualTo(1)); // Generate a new token - string? token = await UserHelpers.GenerateTokenAndSaveToDatabase(_db, "email").ConfigureAwait(false); + string? token = await UserHelpers.GenerateTokenAndSaveToDatabase(_db, "email", Constants.OAUTH_TOKEN_TIME_LIMIT).ConfigureAwait(false); Assert.That(token, Is.Not.Null); // Verify we have a new user @@ -65,7 +65,7 @@ public async Task GenerateTokenForNewUser() { [Test] public async Task HandleUnexpectedErrors() { // Force an error to occur. - string? token = await UserHelpers.GenerateTokenAndSaveToDatabase(null!, "email").ConfigureAwait(false); + string? token = await UserHelpers.GenerateTokenAndSaveToDatabase(null!, "email", Constants.OAUTH_TOKEN_TIME_LIMIT).ConfigureAwait(false); Assert.That(token, Is.Null); } } \ No newline at end of file diff --git a/src/Nullinside.Api/Constants.cs b/src/Nullinside.Api/Constants.cs new file mode 100644 index 0000000..ccd5fc5 --- /dev/null +++ b/src/Nullinside.Api/Constants.cs @@ -0,0 +1,11 @@ +namespace Nullinside.Api; + +/// +/// Constants used throughout the application. +/// +public static class Constants { + /// + /// The amount of time a token is valid for. + /// + public static readonly TimeSpan OAUTH_TOKEN_TIME_LIMIT = TimeSpan.FromHours(1); +} \ No newline at end of file diff --git a/src/Nullinside.Api/Controllers/UserController.cs b/src/Nullinside.Api/Controllers/UserController.cs index 5947b9b..2a15a7e 100644 --- a/src/Nullinside.Api/Controllers/UserController.cs +++ b/src/Nullinside.Api/Controllers/UserController.cs @@ -77,7 +77,7 @@ public UserController(IConfiguration configuration, INullinsideContext dbContext return Redirect($"{siteUrl}/user/login?error=1"); } - string? bearerToken = await UserHelpers.GenerateTokenAndSaveToDatabase(_dbContext, credentials.Email, token).ConfigureAwait(false); + string? bearerToken = await UserHelpers.GenerateTokenAndSaveToDatabase(_dbContext, credentials.Email, Constants.OAUTH_TOKEN_TIME_LIMIT, cancellationToken: token).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(bearerToken)) { return Redirect($"{siteUrl}/user/login?error=2"); } @@ -127,7 +127,7 @@ public async Task TwitchLogin([FromQuery] string code, [FromServ return Redirect($"{siteUrl}/user/login?error=4"); } - string? bearerToken = await UserHelpers.GenerateTokenAndSaveToDatabase(_dbContext, email, token).ConfigureAwait(false); + string? bearerToken = await UserHelpers.GenerateTokenAndSaveToDatabase(_dbContext, email, Constants.OAUTH_TOKEN_TIME_LIMIT, cancellationToken: token).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(bearerToken)) { return Redirect($"{siteUrl}/user/login?error=2"); }