diff --git a/api/Migrations/20260322214843_AddRefreshTokens.Designer.cs b/api/Migrations/20260322214843_AddRefreshTokens.Designer.cs
new file mode 100644
index 0000000..488c465
--- /dev/null
+++ b/api/Migrations/20260322214843_AddRefreshTokens.Designer.cs
@@ -0,0 +1,336 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace agologum_api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260322214843_AddRefreshTokens")]
+ partial class AddRefreshTokens
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.5")
+ .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.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("RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsRevoked")
+ .HasColumnType("boolean");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("RefreshTokens");
+ });
+
+ modelBuilder.Entity("agologumApi.Models.Item", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("LastEditedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Items");
+ });
+
+ modelBuilder.Entity("agologumApi.Models.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("integer");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ 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.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("agologumApi.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("agologumApi.Models.User", 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("agologumApi.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("agologumApi.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/api/Migrations/20260322214843_AddRefreshTokens.cs b/api/Migrations/20260322214843_AddRefreshTokens.cs
new file mode 100644
index 0000000..f347d1d
--- /dev/null
+++ b/api/Migrations/20260322214843_AddRefreshTokens.cs
@@ -0,0 +1,40 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace agologum_api.Migrations
+{
+ ///
+ public partial class AddRefreshTokens : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "RefreshTokens",
+ columns: table => new
+ {
+ Id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ Token = table.Column(type: "text", nullable: false),
+ UserId = table.Column(type: "text", nullable: false),
+ CreatedAt = table.Column(type: "timestamp with time zone", nullable: false),
+ ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false),
+ IsRevoked = table.Column(type: "boolean", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_RefreshTokens", x => x.Id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "RefreshTokens");
+ }
+ }
+}
diff --git a/api/Migrations/AppDbContextModelSnapshot.cs b/api/Migrations/AppDbContextModelSnapshot.cs
index aa5eef9..0d019e7 100644
--- a/api/Migrations/AppDbContextModelSnapshot.cs
+++ b/api/Migrations/AppDbContextModelSnapshot.cs
@@ -153,6 +153,36 @@ namespace agologum_api.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
+ modelBuilder.Entity("RefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsRevoked")
+ .HasColumnType("boolean");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("RefreshTokens");
+ });
+
modelBuilder.Entity("agologumApi.Models.Item", b =>
{
b.Property("Id")
diff --git a/api/src/Controllers/AuthController.cs b/api/src/Controllers/AuthController.cs
index a28fe7e..d3e09ee 100644
--- a/api/src/Controllers/AuthController.cs
+++ b/api/src/Controllers/AuthController.cs
@@ -51,9 +51,18 @@ public class AuthController : ControllerBase {
if(!result.Succeeded) return Unauthorized();
- var token = jwt_.GenerateJwt(user);
+ var accessToken = jwt_.GenerateJwt(user);
+ var refreshToken = jwt_.GenerateRefreshToken();
+ RefreshToken newTokenObject = new RefreshToken {
+ Token = refreshToken,
+ UserId = user.Id,
+ CreatedAt = DateTime.UtcNow,
+ ExpiresAt = DateTime.UtcNow.AddDays(30),
+ IsRevoked = false
+ };
+ await jwt_.AddRefreshToken(newTokenObject);
- return Ok(new { token });
+ return Ok(new { accessToken, refreshToken });
}
[Authorize] // authorize is handled by middleware
@@ -65,6 +74,37 @@ public class AuthController : ControllerBase {
return Ok();
}
+ [HttpPost("refresh")] // allow-anonymous by default
+ public async Task Refresh(TokenDto request) {
+
+ RefreshToken? storedToken = await jwt_.GetRefreshToken(request.RefreshToken);
+ if (storedToken == null) return Unauthorized();
+ bool valid = (storedToken.IsRevoked) ||
+ (storedToken.ExpiresAt < DateTime.UtcNow);
+ if(!valid) return Unauthorized(); // TODO: delete the invalid token
+
+ User? user = await jwt_.GetUser(storedToken.UserId);
+ if(user == null) return NotFound();
+ string? newAccessToken = jwt_.GenerateJwt(user);
+ if(newAccessToken == null) return NotFound();
+ string newRefreshToken = jwt_.GenerateRefreshToken();
+
+ storedToken.IsRevoked = true;
+ RefreshToken newTokenObject = new RefreshToken {
+ Token = newRefreshToken,
+ UserId = storedToken.UserId,
+ CreatedAt = DateTime.UtcNow,
+ ExpiresAt = DateTime.UtcNow.AddDays(30),
+ IsRevoked = false
+ };
+
+ await jwt_.AddRefreshToken(newTokenObject);
+
+ return Ok(new { accessToken = newAccessToken, refreshToken = newRefreshToken });
+
+
+ }
+
// TODO
// refresh tokens
// email verification
diff --git a/api/src/Data/AppDbContext.cs b/api/src/Data/AppDbContext.cs
index 66e7998..88af273 100644
--- a/api/src/Data/AppDbContext.cs
+++ b/api/src/Data/AppDbContext.cs
@@ -12,5 +12,6 @@ public class AppDbContext : IdentityDbContext {
// Db set for each model besides Users (DbSet is already defined in IdenityDbContext)
public DbSet- Items { get; set; }
+ public DbSet RefreshTokens { get; set; }
}
\ No newline at end of file
diff --git a/api/src/Models/RefreshToken.cs b/api/src/Models/RefreshToken.cs
new file mode 100644
index 0000000..697f975
--- /dev/null
+++ b/api/src/Models/RefreshToken.cs
@@ -0,0 +1,21 @@
+
+// a refresh token's purpose is to authenticate user's without logging in
+public class RefreshToken {
+
+ public int Id { get; set; }
+
+ public string Token { get; set; } = "";
+
+ public string UserId { get; set; } = ""; // in EF Identity the IdentityUser's id is a GUID string (32 hex digits)
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime ExpiresAt { get; set; }
+ public bool IsRevoked { get; set; }
+
+}
+
+public class TokenDto {
+
+ public string RefreshToken { get; set; } = "";
+
+}
\ No newline at end of file
diff --git a/api/src/Services/JwtService.cs b/api/src/Services/JwtService.cs
index a79188e..775e9c8 100644
--- a/api/src/Services/JwtService.cs
+++ b/api/src/Services/JwtService.cs
@@ -1,17 +1,21 @@
using Microsoft.IdentityModel.Tokens;
+using Microsoft.EntityFrameworkCore;
using System.Text;
using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt;
+using System.Security.Cryptography;
using agologumApi.Models;
public class JwtService {
private readonly IConfiguration config_;
+ private readonly AppDbContext db_;
- public JwtService(IConfiguration config) { // why the heck does c# not have initializer lists ?
+ public JwtService(IConfiguration config, AppDbContext db) { // why the heck does c# not have initializer lists ?
config_ = config;
+ db_ = db;
}
public string? GenerateJwt(User user) {
@@ -42,5 +46,27 @@ public class JwtService {
}
+ public string GenerateRefreshToken() {
+
+ byte[] randomBytes = new byte[64];
+ RandomNumberGenerator.Fill(randomBytes.AsSpan());
+ return Convert.ToBase64String(randomBytes);
+
+ }
+
+ public async Task GetRefreshToken(string refreshTokenString) {
+ return await db_.RefreshTokens.FirstOrDefaultAsync(u => u.Token == refreshTokenString);
+ }
+
+ public async Task AddRefreshToken(RefreshToken refreshToken) {
+ db_.RefreshTokens.Add(refreshToken);
+ await db_.SaveChangesAsync();
+ return refreshToken;
+ }
+
+ // helper to get the User from the id that exists in a refresh token object
+ public async Task GetUser(string id) {
+ return await db_.Users.FindAsync(id);
+ }
}
\ No newline at end of file