Skip to content

Commit 8f90b59

Browse files
Merge pull request #2 from SculptTechProject/feature/auth/refresh_logout
Add refresh token support
2 parents 846e91c + aa3dbae commit 8f90b59

File tree

11 files changed

+371
-12
lines changed

11 files changed

+371
-12
lines changed

Migrations/20251114234534_RefreshToken.Designer.cs

Lines changed: 116 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System;
2+
using Microsoft.EntityFrameworkCore.Migrations;
3+
4+
#nullable disable
5+
6+
namespace sparkly_server.Migrations
7+
{
8+
/// <inheritdoc />
9+
public partial class RefreshToken : Migration
10+
{
11+
/// <inheritdoc />
12+
protected override void Up(MigrationBuilder migrationBuilder)
13+
{
14+
migrationBuilder.CreateTable(
15+
name: "refresh_tokens",
16+
columns: table => new
17+
{
18+
Id = table.Column<Guid>(type: "uuid", nullable: false),
19+
Token = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
20+
UserId = table.Column<Guid>(type: "uuid", nullable: false),
21+
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
22+
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
23+
RevokedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
24+
RevokedByIp = table.Column<string>(type: "text", nullable: true),
25+
ReplacedByToken = table.Column<string>(type: "text", nullable: true)
26+
},
27+
constraints: table =>
28+
{
29+
table.PrimaryKey("PK_refresh_tokens", x => x.Id);
30+
table.ForeignKey(
31+
name: "FK_refresh_tokens_users_UserId",
32+
column: x => x.UserId,
33+
principalTable: "users",
34+
principalColumn: "Id",
35+
onDelete: ReferentialAction.Cascade);
36+
});
37+
38+
migrationBuilder.CreateIndex(
39+
name: "IX_refresh_tokens_UserId",
40+
table: "refresh_tokens",
41+
column: "UserId");
42+
}
43+
44+
/// <inheritdoc />
45+
protected override void Down(MigrationBuilder migrationBuilder)
46+
{
47+
migrationBuilder.DropTable(
48+
name: "refresh_tokens");
49+
}
50+
}
51+
}

Migrations/AppDbContextModelSnapshot.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,42 @@ protected override void BuildModel(ModelBuilder modelBuilder)
2222

2323
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
2424

25+
modelBuilder.Entity("sparkly_server.Domain.Auth.RefreshToken", b =>
26+
{
27+
b.Property<Guid>("Id")
28+
.ValueGeneratedOnAdd()
29+
.HasColumnType("uuid");
30+
31+
b.Property<DateTime>("CreatedAt")
32+
.HasColumnType("timestamp with time zone");
33+
34+
b.Property<DateTime>("ExpiresAt")
35+
.HasColumnType("timestamp with time zone");
36+
37+
b.Property<string>("ReplacedByToken")
38+
.HasColumnType("text");
39+
40+
b.Property<DateTime?>("RevokedAt")
41+
.HasColumnType("timestamp with time zone");
42+
43+
b.Property<string>("RevokedByIp")
44+
.HasColumnType("text");
45+
46+
b.Property<string>("Token")
47+
.IsRequired()
48+
.HasMaxLength(512)
49+
.HasColumnType("character varying(512)");
50+
51+
b.Property<Guid>("UserId")
52+
.HasColumnType("uuid");
53+
54+
b.HasKey("Id");
55+
56+
b.HasIndex("UserId");
57+
58+
b.ToTable("refresh_tokens", (string)null);
59+
});
60+
2561
modelBuilder.Entity("sparkly_server.Domain.User", b =>
2662
{
2763
b.Property<Guid>("Id")
@@ -55,6 +91,22 @@ protected override void BuildModel(ModelBuilder modelBuilder)
5591

5692
b.ToTable("users", (string)null);
5793
});
94+
95+
modelBuilder.Entity("sparkly_server.Domain.Auth.RefreshToken", b =>
96+
{
97+
b.HasOne("sparkly_server.Domain.User", "User")
98+
.WithMany("RefreshTokens")
99+
.HasForeignKey("UserId")
100+
.OnDelete(DeleteBehavior.Cascade)
101+
.IsRequired();
102+
103+
b.Navigation("User");
104+
});
105+
106+
modelBuilder.Entity("sparkly_server.Domain.User", b =>
107+
{
108+
b.Navigation("RefreshTokens");
109+
});
58110
#pragma warning restore 612, 618
59111
}
60112
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
2+
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APrincipalExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fcc1ad596b4fde937674ff6c832655e53b7c6cc97b1b7a38893ad352a788057_003FPrincipalExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>

src/Controllers/AuthController.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,35 @@ public async Task<IActionResult> Login([FromBody] LoginRequest request, Cancella
4545

4646
return Ok(response);
4747
}
48+
49+
[Authorize]
50+
[HttpPost("logout")]
51+
public async Task<IActionResult> Logout([FromBody] LogoutRequest request, CancellationToken ct)
52+
{
53+
await _authService.LogoutAsync(request.RefreshToken, ct);
54+
return NoContent();
55+
}
56+
57+
[HttpPost("refresh")]
58+
[AllowAnonymous]
59+
public async Task<IActionResult> Refresh(
60+
[FromBody] RefreshRequest request,
61+
CancellationToken ct)
62+
{
63+
if (string.IsNullOrWhiteSpace(request.RefreshToken))
64+
{
65+
return BadRequest(new { message = "Refresh token is required." });
66+
}
67+
68+
var result = await _authService.RefreshAsync(request.RefreshToken, ct);
69+
70+
if (result is null)
71+
{
72+
return Unauthorized(new { message = "Invalid or expired refresh token." });
73+
}
74+
75+
return Ok(result);
76+
}
77+
4878
}
4979
}

src/DTO/Auth.cs renamed to src/DTO/Auth/Auth.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ namespace sparkly_server.DTO
33
public record RegisterRequest(string Username, string Email, string Password);
44
public record LoginRequest(string Identifier, string Password);
55
public record AuthResponse(string AccessToken, string RefreshToken);
6+
public record LogoutRequest(string RefreshToken);
7+
public record RefreshRequest(string RefreshToken);
68
}

src/Domain/Auth/RefreshToken.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,37 @@ public class RefreshToken
44
{
55
public Guid Id { get; private set; } = Guid.NewGuid();
66
public string Token { get; private set; } = default!;
7+
8+
public Guid UserId { get; private set; }
9+
public User User { get; private set; } = null!;
10+
711
public DateTime ExpiresAt { get; private set; }
812
public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
9-
public bool Revoked { get; private set; }
1013

11-
public RefreshToken(string token, DateTime expiresAt)
14+
public DateTime? RevokedAt { get; private set; }
15+
public string? RevokedByIp { get; private set; }
16+
public string? ReplacedByToken { get; private set; }
17+
18+
public bool IsActive =>
19+
RevokedAt is null && DateTime.UtcNow <= ExpiresAt;
20+
21+
public RefreshToken(Guid userId, string token, DateTime expiresAt)
1222
{
23+
UserId = userId;
1324
Token = token;
1425
ExpiresAt = expiresAt;
1526
}
1627

17-
public void Revoke() => Revoked = true;
28+
public void Revoke(string? ip = null, string? replacedByToken = null)
29+
{
30+
if (RevokedAt is not null)
31+
{
32+
return; // already revoked
33+
}
34+
35+
RevokedAt = DateTime.UtcNow;
36+
RevokedByIp = ip;
37+
ReplacedByToken = replacedByToken;
38+
}
1839
}
1940
}

src/Domain/Users/User.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public class User
99
public string UserName { get; set; } = default!;
1010
public string PasswordHash { get; private set; } = default!;
1111
public string Role { get; private set; } = "User";
12+
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
1213
public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
1314

1415
private User() { }

src/Infrastructure/AppDbContext.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
using Microsoft.EntityFrameworkCore;
22
using sparkly_server.Domain;
3+
using sparkly_server.Domain.Auth;
34

45
namespace sparkly_server.Infrastructure
56
{
67
public class AppDbContext : DbContext
78
{
89
public DbSet<User> Users => Set<User>();
10+
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
911

1012
public AppDbContext(DbContextOptions<AppDbContext> options)
1113
: base(options)
@@ -16,6 +18,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
1618
{
1719
base.OnModelCreating(modelBuilder);
1820

21+
// Users
1922
modelBuilder.Entity<User>(cfg =>
2023
{
2124
cfg.ToTable("users");
@@ -34,6 +37,29 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
3437

3538
cfg.Property(u => u.PasswordHash)
3639
.IsRequired();
40+
41+
cfg.HasMany(u => u.RefreshTokens)
42+
.WithOne(rt => rt.User)
43+
.HasForeignKey(rt => rt.UserId)
44+
.OnDelete(DeleteBehavior.Cascade);
45+
});
46+
47+
// Refresh Tokens
48+
modelBuilder.Entity<RefreshToken>(cfg =>
49+
{
50+
cfg.ToTable("refresh_tokens");
51+
52+
cfg.HasKey(rt => rt.Id);
53+
54+
cfg.Property(rt => rt.Token)
55+
.IsRequired()
56+
.HasMaxLength(512);
57+
58+
cfg.Property(rt => rt.CreatedAt)
59+
.IsRequired();
60+
61+
cfg.Property(rt => rt.ExpiresAt)
62+
.IsRequired();
3763
});
3864
}
3965
}

0 commit comments

Comments
 (0)