From 4cacc03fb888c88892390bf5901169550b15d09b Mon Sep 17 00:00:00 2001 From: James Tombleson Date: Mon, 10 Jul 2023 22:41:39 -0700 Subject: [PATCH 1/2] Getting things setup to support token refresh --- .../Controllers/AccountController.cs | 33 +- .../Requests/UserRefreshTokenRequest.cs | 7 + .../Domain/Response/AuthSuccessfulResponse.cs | 1 + .../Domain/Results/AuthenticationResult.cs | 1 + Newsbot.Collector.Api/Program.cs | 23 +- .../Services/IdentityService.cs | 133 ++++- Newsbot.Collector.Database/DatabaseContext.cs | 3 +- ...58_add_jwt_token_refresh_table.Designer.cs | 543 ++++++++++++++++++ ...30711033558_add_jwt_token_refresh_table.cs | 49 ++ .../DatabaseContextModelSnapshot.cs | 39 ++ .../Repositories/RefreshTokenTable.cs | 42 ++ .../Entities/RefreshTokenEntity.cs | 21 + .../Interfaces/IRefreshTokenRepository.cs | 10 + 13 files changed, 876 insertions(+), 29 deletions(-) create mode 100644 Newsbot.Collector.Api/Domain/Requests/UserRefreshTokenRequest.cs create mode 100644 Newsbot.Collector.Database/Migrations/20230711033558_add_jwt_token_refresh_table.Designer.cs create mode 100644 Newsbot.Collector.Database/Migrations/20230711033558_add_jwt_token_refresh_table.cs create mode 100644 Newsbot.Collector.Database/Repositories/RefreshTokenTable.cs create mode 100644 Newsbot.Collector.Domain/Entities/RefreshTokenEntity.cs create mode 100644 Newsbot.Collector.Domain/Interfaces/IRefreshTokenRepository.cs diff --git a/Newsbot.Collector.Api/Controllers/AccountController.cs b/Newsbot.Collector.Api/Controllers/AccountController.cs index b11be6a..c0b2b36 100644 --- a/Newsbot.Collector.Api/Controllers/AccountController.cs +++ b/Newsbot.Collector.Api/Controllers/AccountController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Newsbot.Collector.Api.Domain.Requests; using Newsbot.Collector.Api.Domain.Response; +using Newsbot.Collector.Api.Domain.Results; using Newsbot.Collector.Api.Services; using Newsbot.Collector.Domain.Dto; using Newsbot.Collector.Domain.Entities; @@ -43,19 +44,7 @@ public class AccountController : ControllerBase } var response = _identityService.Register(user.Email, user.Password); - - if (!response.IsSuccessful) - { - return new BadRequestObjectResult( new AuthFailedResponse - { - Errors = response.ErrorMessage - }); - } - - return new OkObjectResult(new AuthSuccessfulResponse - { - Token = response.Token - }); + return CheckIfSuccessful(response); } [HttpPost("login")] @@ -72,18 +61,30 @@ public class AccountController : ControllerBase } var response = _identityService.Login(request.Email, request.Password); + return CheckIfSuccessful(response); + } - if (!response.IsSuccessful) + [HttpPost("refresh")] + public ActionResult RefreshToken([FromBody] UserRefreshTokenRequest request) + { + var response = _identityService.RefreshToken(request.Token ?? "", request.RefreshToken ?? ""); + return CheckIfSuccessful(response); + } + + private ActionResult CheckIfSuccessful(AuthenticationResult result) + { + if (!result.IsSuccessful) { return new BadRequestObjectResult( new AuthFailedResponse { - Errors = response.ErrorMessage + Errors = result.ErrorMessage }); } return new OkObjectResult(new AuthSuccessfulResponse { - Token = response.Token + Token = result.Token, + RefreshToken = result.RefreshToken }); } } \ No newline at end of file diff --git a/Newsbot.Collector.Api/Domain/Requests/UserRefreshTokenRequest.cs b/Newsbot.Collector.Api/Domain/Requests/UserRefreshTokenRequest.cs new file mode 100644 index 0000000..9556e49 --- /dev/null +++ b/Newsbot.Collector.Api/Domain/Requests/UserRefreshTokenRequest.cs @@ -0,0 +1,7 @@ +namespace Newsbot.Collector.Api.Domain.Requests; + +public class UserRefreshTokenRequest +{ + public string? Token { get; set; } + public string? RefreshToken { get; set; } +} \ No newline at end of file diff --git a/Newsbot.Collector.Api/Domain/Response/AuthSuccessfulResponse.cs b/Newsbot.Collector.Api/Domain/Response/AuthSuccessfulResponse.cs index 48e5009..dcb42ef 100644 --- a/Newsbot.Collector.Api/Domain/Response/AuthSuccessfulResponse.cs +++ b/Newsbot.Collector.Api/Domain/Response/AuthSuccessfulResponse.cs @@ -5,4 +5,5 @@ public class AuthSuccessfulResponse // might want to validate the user before we return the token public string? Token { get; set; } + public string? RefreshToken { get; set; } } \ No newline at end of file diff --git a/Newsbot.Collector.Api/Domain/Results/AuthenticationResult.cs b/Newsbot.Collector.Api/Domain/Results/AuthenticationResult.cs index dfbb63b..4cd4516 100644 --- a/Newsbot.Collector.Api/Domain/Results/AuthenticationResult.cs +++ b/Newsbot.Collector.Api/Domain/Results/AuthenticationResult.cs @@ -5,6 +5,7 @@ namespace Newsbot.Collector.Api.Domain.Results; public class AuthenticationResult { public string? Token { get; set; } + public string? RefreshToken { get; set; } public bool IsSuccessful { get; set; } public IEnumerable? ErrorMessage { get; set; } diff --git a/Newsbot.Collector.Api/Program.cs b/Newsbot.Collector.Api/Program.cs index a0f05c9..e82802f 100644 --- a/Newsbot.Collector.Api/Program.cs +++ b/Newsbot.Collector.Api/Program.cs @@ -50,6 +50,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Configure Identity builder.Services.AddScoped(); @@ -81,6 +82,16 @@ var jwtSettings = new JwtSettings(); config.Bind(nameof(jwtSettings), jwtSettings); builder.Services.AddSingleton(jwtSettings); +var tokenValidationParameters = new TokenValidationParameters +{ + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtSettings.Secret ?? "")), + ValidateIssuer = false, + ValidateAudience = false, + RequireExpirationTime = false, + ValidateLifetime = true +}; +builder.Services.AddSingleton(tokenValidationParameters); builder.Services.AddAuthentication(x => { @@ -90,17 +101,9 @@ builder.Services.AddAuthentication(x => }).AddJwtBearer(x => { x.SaveToken = true; - x.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtSettings.Secret ?? "")), - ValidateIssuer = false, - ValidateAudience = false, - RequireExpirationTime = false, - ValidateLifetime = true - }; + x.TokenValidationParameters = tokenValidationParameters; }); - + builder.Services.AddSwaggerGen(cfg => { diff --git a/Newsbot.Collector.Api/Services/IdentityService.cs b/Newsbot.Collector.Api/Services/IdentityService.cs index 2955479..4a48d0e 100644 --- a/Newsbot.Collector.Api/Services/IdentityService.cs +++ b/Newsbot.Collector.Api/Services/IdentityService.cs @@ -4,6 +4,9 @@ using System.Text; using Microsoft.AspNetCore.Identity; using Microsoft.IdentityModel.Tokens; using Newsbot.Collector.Api.Domain.Results; +using Newsbot.Collector.Database; +using Newsbot.Collector.Domain.Entities; +using Newsbot.Collector.Domain.Interfaces; using Newsbot.Collector.Domain.Models.Config; namespace Newsbot.Collector.Api.Services; @@ -12,17 +15,22 @@ public interface IIdentityService { AuthenticationResult Register(string email, string password); AuthenticationResult Login(string email, string password); + AuthenticationResult RefreshToken(string token, string refreshToken); } public class IdentityService : IIdentityService { private readonly UserManager _userManager; private readonly JwtSettings _jwtSettings; + private readonly TokenValidationParameters _tokenValidationParameters; + private readonly IRefreshTokenRepository _refreshTokenRepository; - public IdentityService(UserManager userManager, JwtSettings jwtSettings) + public IdentityService(UserManager userManager, JwtSettings jwtSettings, TokenValidationParameters tokenValidationParameters, IRefreshTokenRepository refreshTokenRepository) { _userManager = userManager; _jwtSettings = jwtSettings; + _tokenValidationParameters = tokenValidationParameters; + _refreshTokenRepository = refreshTokenRepository; } public AuthenticationResult Register(string email, string password) @@ -85,6 +93,116 @@ public class IdentityService : IIdentityService return GenerateJwtToken(user.Result ?? new IdentityUser()); } + public AuthenticationResult RefreshToken(string token, string refreshToken) + { + var validatedToken = CheckTokenSigner(token); + if (validatedToken is null) + { + return new AuthenticationResult + { + ErrorMessage = new List { "Invalid Token" } + }; + } + + // Get the expire datetime of the token + var expiryDateUnix = long.Parse(validatedToken.Claims.Single(x => x.Type == JwtRegisteredClaimNames.Exp).Value); + + // generate the unix epoc, add expiry time + var expiryDateTimeUtc = new DateTime(1970, 0, 0, 0, 0, 0, DateTimeKind.Utc) + .AddSeconds(expiryDateUnix); + + // if it expires in the future + if (expiryDateTimeUtc > DateTime.Now) + { + return new AuthenticationResult + { + ErrorMessage = new List { "The token has not expired yet" } + }; + } + + var jti = validatedToken.Claims.Single(x => x.Type == JwtRegisteredClaimNames.Jti).Value; + + var storedToken = _refreshTokenRepository.Get(token); + if (storedToken is null) + { + return new AuthenticationResult + { + ErrorMessage = new List { "The refresh token does not exist" } + }; + } + + if (DateTime.UtcNow > storedToken.ExpiryDate) + { + return new AuthenticationResult + { + ErrorMessage = new List { "The refresh token has expired" } + }; + } + + if (storedToken.Invalidated) + { + return new AuthenticationResult + { + ErrorMessage = new List { "The token is not valid" } + }; + } + + if (storedToken.Used) + { + return new AuthenticationResult + { + ErrorMessage = new List { "The token has been used" } + }; + } + + if (storedToken.JwtId != jti) + { + return new AuthenticationResult + { + ErrorMessage = new List { "The token does not match this JWT" } + }; + } + + _refreshTokenRepository.UpdateTokenIsUsed(token); + + var user = _userManager.FindByIdAsync(validatedToken.Claims.Single(x => x.Type == "id").Value); + user.Wait(); + if (user.Result is null) + { + return new AuthenticationResult + { + ErrorMessage = new List { "Unable to find user" } + }; + } + + return GenerateJwtToken(user.Result); + } + + private ClaimsPrincipal? CheckTokenSigner(string token) + { + var tokenHandler = new JwtSecurityTokenHandler(); + + try + { + var principal = tokenHandler.ValidateToken(token, _tokenValidationParameters, out var validatedToken); + if (IsSecurityTokenValidSecurity(validatedToken)) + { + return null; + } + + return principal; + } + catch + { + return null; + } + } + + private bool IsSecurityTokenValidSecurity(SecurityToken token) + { + return (token is JwtSecurityToken jwtSecurityToken) && jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha512, StringComparison.InvariantCultureIgnoreCase); + } + private AuthenticationResult GenerateJwtToken(IdentityUser user) { var tokenHandler = new JwtSecurityTokenHandler(); @@ -105,10 +223,21 @@ public class IdentityService : IIdentityService var token = tokenHandler.CreateToken(tokenDescriptor); + var refreshToken = new RefreshTokenEntity + { + JwtId = token.Id, + UserId = user.Id, + CreatedDate = DateTime.UtcNow, + ExpiryDate = DateTime.UtcNow.AddMonths(6) + }; + + _refreshTokenRepository.Add(refreshToken); + return new AuthenticationResult { IsSuccessful = true, - Token = tokenHandler.WriteToken(token) + Token = tokenHandler.WriteToken(token), + RefreshToken = refreshToken.Token }; } } \ No newline at end of file diff --git a/Newsbot.Collector.Database/DatabaseContext.cs b/Newsbot.Collector.Database/DatabaseContext.cs index b2cdc0d..a3e0fdc 100644 --- a/Newsbot.Collector.Database/DatabaseContext.cs +++ b/Newsbot.Collector.Database/DatabaseContext.cs @@ -10,7 +10,6 @@ namespace Newsbot.Collector.Database; public class DatabaseContext : IdentityDbContext { public DbSet Articles { get; set; } = null!; - public DbSet DiscordNotification { get; set; } = null!; public DbSet DiscordQueue { get; set; } = null!; public DbSet DiscordWebhooks { get; set; } = null!; @@ -18,6 +17,8 @@ public class DatabaseContext : IdentityDbContext public DbSet Sources { get; set; } = null!; public DbSet UserSourceSubscription { get; set; } = null!; + + public DbSet RefreshTokens { get; set; } private string ConnectionString { get; set; } = ""; diff --git a/Newsbot.Collector.Database/Migrations/20230711033558_add_jwt_token_refresh_table.Designer.cs b/Newsbot.Collector.Database/Migrations/20230711033558_add_jwt_token_refresh_table.Designer.cs new file mode 100644 index 0000000..9d01304 --- /dev/null +++ b/Newsbot.Collector.Database/Migrations/20230711033558_add_jwt_token_refresh_table.Designer.cs @@ -0,0 +1,543 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Newsbot.Collector.Database; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Newsbot.Collector.Database.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20230711033558_add_jwt_token_refresh_table")] + partial class add_jwt_token_refresh_table + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.ArticlesEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorImage") + .HasColumnType("text"); + + b.Property("AuthorName") + .IsRequired() + .HasColumnType("text"); + + b.Property("CodeIsCommit") + .HasColumnType("boolean"); + + b.Property("CodeIsRelease") + .HasColumnType("boolean"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("PubDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text"); + + b.Property("Thumbnail") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("Video") + .IsRequired() + .HasColumnType("text"); + + b.Property("VideoHeight") + .HasColumnType("integer"); + + b.Property("VideoWidth") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Articles"); + }); + + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.DiscordNotificationEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CodeAllowCommits") + .HasColumnType("boolean"); + + b.Property("CodeAllowReleases") + .HasColumnType("boolean"); + + b.Property("DiscordWebHookId") + .HasColumnType("uuid"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DiscordNotification"); + }); + + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.DiscordQueueEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArticleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("DiscordQueue"); + }); + + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.DiscordWebhookEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Channel") + .IsRequired() + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Server") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DiscordWebhooks"); + }); + + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.IconEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Site") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Icons"); + }); + + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.RefreshTokenEntity", b => + { + b.Property("Token") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Invalidated") + .HasColumnType("boolean"); + + b.Property("JwtId") + .HasColumnType("text"); + + b.Property("Used") + .HasColumnType("boolean"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.SourceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Deleted") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Site") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("YoutubeId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sources"); + }); + + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.UserSourceSubscriptionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DateAdded") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSourceSubscription"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.RefreshTokenEntity", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.UserSourceSubscriptionEntity", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Newsbot.Collector.Database/Migrations/20230711033558_add_jwt_token_refresh_table.cs b/Newsbot.Collector.Database/Migrations/20230711033558_add_jwt_token_refresh_table.cs new file mode 100644 index 0000000..c1eaa0c --- /dev/null +++ b/Newsbot.Collector.Database/Migrations/20230711033558_add_jwt_token_refresh_table.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Newsbot.Collector.Database.Migrations +{ + /// + public partial class add_jwt_token_refresh_table : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RefreshTokens", + columns: table => new + { + Token = table.Column(type: "text", nullable: false), + JwtId = table.Column(type: "text", nullable: true), + CreatedDate = table.Column(type: "timestamp with time zone", nullable: false), + ExpiryDate = table.Column(type: "timestamp with time zone", nullable: false), + Used = table.Column(type: "boolean", nullable: false), + Invalidated = table.Column(type: "boolean", nullable: false), + UserId = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Token); + table.ForeignKey( + name: "FK_RefreshTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + table: "RefreshTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RefreshTokens"); + } + } +} diff --git a/Newsbot.Collector.Database/Migrations/DatabaseContextModelSnapshot.cs b/Newsbot.Collector.Database/Migrations/DatabaseContextModelSnapshot.cs index 61c9b8b..4462cdc 100644 --- a/Newsbot.Collector.Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Newsbot.Collector.Database/Migrations/DatabaseContextModelSnapshot.cs @@ -365,6 +365,36 @@ namespace Newsbot.Collector.Database.Migrations b.ToTable("Icons"); }); + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.RefreshTokenEntity", b => + { + b.Property("Token") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Invalidated") + .HasColumnType("boolean"); + + b.Property("JwtId") + .HasColumnType("text"); + + b.Property("Used") + .HasColumnType("boolean"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Token"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.SourceEntity", b => { b.Property("Id") @@ -487,6 +517,15 @@ namespace Newsbot.Collector.Database.Migrations .IsRequired(); }); + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.RefreshTokenEntity", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Newsbot.Collector.Domain.Entities.UserSourceSubscriptionEntity", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", "User") diff --git a/Newsbot.Collector.Database/Repositories/RefreshTokenTable.cs b/Newsbot.Collector.Database/Repositories/RefreshTokenTable.cs new file mode 100644 index 0000000..b990434 --- /dev/null +++ b/Newsbot.Collector.Database/Repositories/RefreshTokenTable.cs @@ -0,0 +1,42 @@ +using Newsbot.Collector.Domain.Entities; +using Newsbot.Collector.Domain.Interfaces; + +namespace Newsbot.Collector.Database.Repositories; + +public class RefreshTokenTable : IRefreshTokenRepository +{ + private readonly DatabaseContext _context; + + public RefreshTokenTable(string connectionString) + { + _context = new DatabaseContext(connectionString); + } + + public RefreshTokenTable(DatabaseContext context) + { + _context = context; + } + + public void Add(RefreshTokenEntity entity) + { + _context.RefreshTokens.Add(entity); + _context.SaveChanges(); + } + + public RefreshTokenEntity? Get(string token) + { + return _context.RefreshTokens.SingleOrDefault(x => x.Token == token); + } + + public void UpdateTokenIsUsed(string token) + { + var entity = Get(token); + if (entity is null) + { + return; + } + entity.Used = true; + _context.RefreshTokens.Update(entity); + _context.SaveChanges(); + } +} \ No newline at end of file diff --git a/Newsbot.Collector.Domain/Entities/RefreshTokenEntity.cs b/Newsbot.Collector.Domain/Entities/RefreshTokenEntity.cs new file mode 100644 index 0000000..aa236fd --- /dev/null +++ b/Newsbot.Collector.Domain/Entities/RefreshTokenEntity.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.AspNetCore.Identity; + +namespace Newsbot.Collector.Domain.Entities; + +public class RefreshTokenEntity +{ + + [Key] + public string? Token { get; set; } + public string? JwtId { get; set; } + public DateTime CreatedDate { get; set; } + public DateTime ExpiryDate { get; set; } + public bool Used { get; set; } + public bool Invalidated { get; set; } + + public string? UserId { get; set; } + [ForeignKey(nameof(UserId))] + public IdentityUser? User { get; set; } +} \ No newline at end of file diff --git a/Newsbot.Collector.Domain/Interfaces/IRefreshTokenRepository.cs b/Newsbot.Collector.Domain/Interfaces/IRefreshTokenRepository.cs new file mode 100644 index 0000000..6eb3531 --- /dev/null +++ b/Newsbot.Collector.Domain/Interfaces/IRefreshTokenRepository.cs @@ -0,0 +1,10 @@ +using Newsbot.Collector.Domain.Entities; + +namespace Newsbot.Collector.Domain.Interfaces; + +public interface IRefreshTokenRepository +{ + void Add(RefreshTokenEntity entity); + public RefreshTokenEntity? Get(string token); + void UpdateTokenIsUsed(string token); +} \ No newline at end of file From 558f9d352ce6527147bf8ed615fb7b5f4079ee68 Mon Sep 17 00:00:00 2001 From: James Tombleson Date: Fri, 14 Jul 2023 22:01:13 -0700 Subject: [PATCH 2/2] Updating CI to not build latest on a pr. --- .drone.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.drone.yml b/.drone.yml index 0702e67..f5d1c1c 100644 --- a/.drone.yml +++ b/.drone.yml @@ -16,6 +16,9 @@ trigger: include: - main + event: + exclude: + - pull_request --- kind: pipeline type: docker