Skip to content

Commit de03d59

Browse files
authored
Track previously used usernames (#1048)
When renaming a user, their old username will now be tracked using a DB table. New users won't be able to register with that name, and only the user who initially owned that name may be renamed back to it.
2 parents f3b3520 + 89a34d8 commit de03d59

10 files changed

Lines changed: 251 additions & 8 deletions

File tree

Refresh.Database/GameDatabaseContext.Registration.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,20 @@ public bool IsEmailQueued(string emailAddress)
9696
return this.QueuedRegistrations.Any(r => r.EmailAddress == emailAddress);
9797
}
9898

99-
public bool IsUsernameTaken(string username)
99+
public bool IsUsernameTaken(string username, GameUser? userToName = null)
100100
{
101-
return this.GameUsers.Any(u => u.Username == username) ||
102-
this.QueuedRegistrations.Any(r => r.Username == username);
101+
if (this.GameUsers.Any(u => u.Username == username)) return true;
102+
if (this.QueuedRegistrations.Any(r => r.Username == username)) return true;
103+
104+
PreviousUsername? previous = this.PreviousUsernames.FirstOrDefault(p => p.Username == username);
105+
// no one has ever had this name before
106+
if (previous == null) return false;
107+
// this is not the initial owner of the name (only previous owners may be renamed back)
108+
if (userToName == null || userToName.UserId != previous.UserId) return true;
109+
110+
return false;
103111
}
104-
112+
105113
public bool IsEmailTaken(string emailAddress)
106114
{
107115
return this.GameUsers.Any(u => u.EmailAddress == emailAddress) ||

Refresh.Database/GameDatabaseContext.Users.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,14 +356,21 @@ public void RenameUser(GameUser user, string newUsername, bool force = false)
356356
throw new ArgumentException("Username is invalid!", nameof(newUsername));
357357
}
358358

359-
if (this.IsUsernameTaken(newUsername))
359+
if (this.IsUsernameTaken(newUsername, user))
360360
{
361361
throw new ArgumentException("Username is already taken!", nameof(newUsername));
362362
}
363363
}
364364

365365
string oldUsername = user.Username;
366366
user.Username = newUsername;
367+
368+
this.PreviousUsernames.Add(new()
369+
{
370+
Username = oldUsername,
371+
User = user,
372+
ReplacedAt = this._time.Now,
373+
});
367374
this.GameUsers.Update(user);
368375
this.SaveChanges();
369376

Refresh.Database/GameDatabaseContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public partial class GameDatabaseContext : DbContext, IDatabaseContext
3434
private readonly Logger _logger;
3535

3636
internal DbSet<GameUser> GameUsers { get; set; }
37+
internal DbSet<PreviousUsername> PreviousUsernames { get; set; }
3738
internal DbSet<GameUserStatistics> GameUserStatistics { get; set; }
3839
internal DbSet<Token> Tokens { get; set; }
3940
internal DbSet<GameLevel> GameLevels { get; set; }
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System;
2+
using Microsoft.EntityFrameworkCore.Infrastructure;
3+
using Microsoft.EntityFrameworkCore.Migrations;
4+
5+
#nullable disable
6+
7+
namespace Refresh.Database.Migrations
8+
{
9+
/// <inheritdoc />
10+
[DbContext(typeof(GameDatabaseContext))]
11+
[Migration("20260312104531_TrackUsernameChanges")]
12+
public partial class TrackUsernameChanges : Migration
13+
{
14+
/// <inheritdoc />
15+
protected override void Up(MigrationBuilder migrationBuilder)
16+
{
17+
migrationBuilder.CreateTable(
18+
name: "PreviousUsernames",
19+
columns: table => new
20+
{
21+
Username = table.Column<string>(type: "text", nullable: false),
22+
ReplacedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
23+
UserId = table.Column<string>(type: "text", nullable: false)
24+
},
25+
constraints: table =>
26+
{
27+
table.PrimaryKey("PK_PreviousUsernames", x => new { x.Username, x.ReplacedAt });
28+
table.ForeignKey(
29+
name: "FK_PreviousUsernames_GameUsers_UserId",
30+
column: x => x.UserId,
31+
principalTable: "GameUsers",
32+
principalColumn: "UserId",
33+
onDelete: ReferentialAction.Cascade);
34+
});
35+
36+
migrationBuilder.CreateIndex(
37+
name: "IX_PreviousUsernames_UserId",
38+
table: "PreviousUsernames",
39+
column: "UserId");
40+
}
41+
42+
/// <inheritdoc />
43+
protected override void Down(MigrationBuilder migrationBuilder)
44+
{
45+
migrationBuilder.DropTable(
46+
name: "PreviousUsernames");
47+
}
48+
}
49+
}

Refresh.Database/Migrations/GameDatabaseContextModelSnapshot.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1715,6 +1715,25 @@ protected override void BuildModel(ModelBuilder modelBuilder)
17151715
b.ToTable("GameUsers");
17161716
});
17171717

1718+
modelBuilder.Entity("Refresh.Database.Models.Users.PreviousUsername", b =>
1719+
{
1720+
b.Property<string>("Username")
1721+
.HasColumnType("text");
1722+
1723+
b.Property<DateTimeOffset>("ReplacedAt")
1724+
.HasColumnType("timestamp with time zone");
1725+
1726+
b.Property<string>("UserId")
1727+
.IsRequired()
1728+
.HasColumnType("text");
1729+
1730+
b.HasKey("Username", "ReplacedAt");
1731+
1732+
b.HasIndex("UserId");
1733+
1734+
b.ToTable("PreviousUsernames");
1735+
});
1736+
17181737
modelBuilder.Entity("Refresh.Database.Models.Users.QueuedRegistration", b =>
17191738
{
17201739
b.Property<string>("RegistrationId")
@@ -2080,7 +2099,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
20802099
modelBuilder.Entity("Refresh.Database.Models.Photos.GamePhotoSubject", b =>
20812100
{
20822101
b.HasOne("Refresh.Database.Models.Photos.GamePhoto", "Photo")
2083-
.WithMany()
2102+
.WithMany("Subjects")
20842103
.HasForeignKey("PhotoId")
20852104
.OnDelete(DeleteBehavior.Cascade)
20862105
.IsRequired();
@@ -2464,6 +2483,22 @@ protected override void BuildModel(ModelBuilder modelBuilder)
24642483
b.Navigation("Statistics");
24652484
});
24662485

2486+
modelBuilder.Entity("Refresh.Database.Models.Users.PreviousUsername", b =>
2487+
{
2488+
b.HasOne("Refresh.Database.Models.Users.GameUser", "User")
2489+
.WithMany()
2490+
.HasForeignKey("UserId")
2491+
.OnDelete(DeleteBehavior.Cascade)
2492+
.IsRequired();
2493+
2494+
b.Navigation("User");
2495+
});
2496+
2497+
modelBuilder.Entity("Refresh.Database.Models.Photos.GamePhoto", b =>
2498+
{
2499+
b.Navigation("Subjects");
2500+
});
2501+
24672502
modelBuilder.Entity("Refresh.Database.Models.Reports.GriefReport", b =>
24682503
{
24692504
b.Navigation("Players");
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using MongoDB.Bson;
2+
3+
namespace Refresh.Database.Models.Users;
4+
5+
#nullable disable
6+
7+
[PrimaryKey(nameof(Username), nameof(ReplacedAt))]
8+
public partial class PreviousUsername
9+
{
10+
public string Username { get; set; }
11+
12+
[Required]
13+
public ObjectId UserId { get; set; }
14+
15+
[Required, ForeignKey(nameof(UserId))]
16+
public GameUser User { get; set; }
17+
18+
/// <summary>
19+
/// When the user's name was changed to no longer use this username
20+
/// </summary>
21+
public DateTimeOffset ReplacedAt { get; set; }
22+
}

Refresh.Interfaces.APIv3/Endpoints/Admin/AdminUserApiEndpoints.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ public ApiResponse<ApiExtendedGameUserResponse> UpdateUser(RequestContext contex
173173
return new ApiValidationError(ApiValidationError.InvalidUsernameErrorWhen
174174
+ " Are you sure you used a PSN/RPCN username, or prepended it with ! if it's a fake user?");
175175

176-
if (database.IsUsernameTaken(body.Username))
176+
if (database.IsUsernameTaken(body.Username, targetUser))
177177
return ApiValidationError.UsernameTakenError;
178178

179179
database.RenameUser(targetUser, body.Username);

Refresh.Interfaces.APIv3/Endpoints/ContestApiEndpoints.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public class ContestApiEndpoints : EndpointGroup
2727
public ApiListResponse<ApiContestResponse> GetAllContests(RequestContext context, GameDatabaseContext database,
2828
DataContext dataContext)
2929
{
30-
return new ApiListResponse<ApiContestResponse>(ApiContestResponse.FromOldList(database.GetAllContests(), dataContext));
30+
return new ApiListResponse<ApiContestResponse>(ApiContestResponse.FromOldList(database.GetAllContests().ToArray(), dataContext));
3131
}
3232

3333
[ApiV3Endpoint("contests/{id}"), Authentication(false)]

RefreshTests.GameServer/Tests/ApiV3/AdminUserEditApiTests.cs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,102 @@ public void CannotRenameToTakenUsername()
268268
Assert.That(updated!.Username, Is.Not.EqualTo(takenUsername));
269269
}
270270

271+
[Test]
272+
public void CannotRenameToOtherUsersPreviousName()
273+
{
274+
using TestContext context = this.GetServer();
275+
276+
GameUser mod = context.CreateUser(null, GameUserRole.Moderator);
277+
GameUser owner = context.CreateUser("original", GameUserRole.User);
278+
GameUser target = context.CreateUser("stinker", GameUserRole.User);
279+
280+
context.Database.RenameUser(owner, "original_2");
281+
GameUser? modifiedOwner = context.Database.GetUserByObjectId(owner.UserId);
282+
Assert.That(modifiedOwner, Is.Not.Null);
283+
Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2"));
284+
285+
HttpClient client = context.GetAuthenticatedClient(TokenType.Api, mod);
286+
ApiAdminUpdateUserRequest request = new()
287+
{
288+
Username = "original"
289+
};
290+
291+
ApiResponse<ApiExtendedGameUserResponse>? response = client.PatchData<ApiExtendedGameUserResponse>($"/api/v3/admin/users/uuid/{target.UserId}", request, false, true);
292+
Assert.That(response?.Error, Is.Not.Null);
293+
Assert.That(response!.Error!.StatusCode, Is.EqualTo(BadRequest));
294+
295+
context.Database.Refresh();
296+
297+
GameUser? modifiedTarget = context.Database.GetUserByObjectId(target.UserId);
298+
Assert.That(modifiedTarget, Is.Not.Null);
299+
Assert.That(modifiedTarget!.Username, Is.EqualTo("stinker"));
300+
}
301+
302+
[Test]
303+
public void CanRenameUserBackToTheirPreviousName()
304+
{
305+
using TestContext context = this.GetServer();
306+
307+
GameUser mod = context.CreateUser(null, GameUserRole.Moderator);
308+
GameUser owner = context.CreateUser("original", GameUserRole.User);
309+
310+
context.Database.RenameUser(owner, "original_2");
311+
GameUser? modifiedOwner = context.Database.GetUserByObjectId(owner.UserId);
312+
Assert.That(modifiedOwner, Is.Not.Null);
313+
Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2"));
314+
315+
HttpClient client = context.GetAuthenticatedClient(TokenType.Api, mod);
316+
ApiAdminUpdateUserRequest request = new()
317+
{
318+
Username = "original"
319+
};
320+
321+
ApiResponse<ApiExtendedGameUserResponse>? response = client.PatchData<ApiExtendedGameUserResponse>($"/api/v3/admin/users/uuid/{owner.UserId}", request);
322+
Assert.That(response?.Data, Is.Not.Null);
323+
Assert.That(response!.Data!.Username == "original");
324+
325+
context.Database.Refresh();
326+
327+
GameUser? modifiedOwner2 = context.Database.GetUserByObjectId(owner.UserId);
328+
Assert.That(modifiedOwner2, Is.Not.Null);
329+
Assert.That(modifiedOwner2!.Username, Is.EqualTo("original"));
330+
}
331+
332+
[Test]
333+
public void CanRenameUserBackAndForth()
334+
{
335+
using TestContext context = this.GetServer();
336+
337+
GameUser mod = context.CreateUser(null, GameUserRole.Moderator);
338+
GameUser owner = context.CreateUser("original", GameUserRole.User);
339+
340+
context.Database.RenameUser(owner, "original_2");
341+
GameUser? modifiedOwner = context.Database.GetUserByObjectId(owner.UserId);
342+
Assert.That(modifiedOwner, Is.Not.Null);
343+
Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2"));
344+
345+
context.Time.TimestampMilliseconds += 1000;
346+
347+
context.Database.RenameUser(owner, "original");
348+
modifiedOwner = context.Database.GetUserByObjectId(owner.UserId);
349+
Assert.That(modifiedOwner, Is.Not.Null);
350+
Assert.That(modifiedOwner!.Username, Is.EqualTo("original"));
351+
352+
context.Time.TimestampMilliseconds += 1000;
353+
354+
context.Database.RenameUser(owner, "original_2");
355+
modifiedOwner = context.Database.GetUserByObjectId(owner.UserId);
356+
Assert.That(modifiedOwner, Is.Not.Null);
357+
Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2"));
358+
359+
context.Time.TimestampMilliseconds += 1000;
360+
361+
context.Database.RenameUser(owner, "original");
362+
modifiedOwner = context.Database.GetUserByObjectId(owner.UserId);
363+
Assert.That(modifiedOwner, Is.Not.Null);
364+
Assert.That(modifiedOwner!.Username, Is.EqualTo("original"));
365+
}
366+
271367
[Test]
272368
[TestCase("!jeff", true)]
273369
[TestCase("dddd", true)]

RefreshTests.GameServer/Tests/ApiV3/UserApiTests.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,31 @@ public void CannotRegisterAccountWithDisallowedUsername()
6868
context.Database.Refresh();
6969
Assert.That(context.Database.GetUserByUsername(username), Is.EqualTo(null));
7070
}
71+
72+
[Test]
73+
public void CannotRegisterAccountWithPreviouslyTakenUsername()
74+
{
75+
using TestContext context = this.GetServer();
76+
GameUser owner = context.CreateUser("original", GameUserRole.User);
77+
78+
context.Database.RenameUser(owner, "original_2");
79+
GameUser? modifiedOwner = context.Database.GetUserByObjectId(owner.UserId);
80+
Assert.That(modifiedOwner, Is.Not.Null);
81+
Assert.That(modifiedOwner!.Username, Is.EqualTo("original_2"));
82+
83+
ApiResponse<ApiAuthenticationResponse>? response = context.Http.PostData<ApiAuthenticationResponse>("/api/v3/register", new ApiRegisterRequest
84+
{
85+
Username = "original",
86+
EmailAddress = "guy@lil.com",
87+
PasswordSha512 = "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff",
88+
}, false, true);
89+
Assert.That(response, Is.Not.Null);
90+
Assert.That(response!.Error, Is.Not.EqualTo(null));
91+
Assert.That(response.Error!.Name, Is.EqualTo("ApiAuthenticationError"));
92+
93+
context.Database.Refresh();
94+
Assert.That(context.Database.GetTotalUserCount(), Is.EqualTo(1));
95+
}
7196

7297
[TestCase("4")]
7398
[TestCase("44444444444444444444444444444444444444")]

0 commit comments

Comments
 (0)