diff --git a/src/GZCTF.Integration.Test/Base/GZCTFApplicationFactory.cs b/src/GZCTF.Integration.Test/Base/GZCTFApplicationFactory.cs
index b9847a2c9..4df3f54dc 100644
--- a/src/GZCTF.Integration.Test/Base/GZCTFApplicationFactory.cs
+++ b/src/GZCTF.Integration.Test/Base/GZCTFApplicationFactory.cs
@@ -1,3 +1,4 @@
+using System.Net.Http.Json;
using GZCTF.Models;
using GZCTF.Services.Container.Manager;
using GZCTF.Services.Container.Provider;
@@ -262,6 +263,25 @@ public async Task InitializeAsync()
await context.Database.MigrateAsync();
}
+ ///
+ /// Create an authenticated HTTP client for the given user
+ ///
+ public HttpClient CreateAuthenticatedClient(TestDataSeeder.SeededUser user)
+ {
+ var client = CreateClient();
+
+ // Login the user
+ var loginResponse = client.PostAsJsonAsync("/api/Account/LogIn", new
+ {
+ UserName = user.UserName,
+ Password = user.Password
+ }).Result;
+
+ loginResponse.EnsureSuccessStatusCode();
+
+ return client;
+ }
+
public new async Task DisposeAsync()
{
// Dispose containers
diff --git a/src/GZCTF.Integration.Test/Base/TestDataSeeder.cs b/src/GZCTF.Integration.Test/Base/TestDataSeeder.cs
index 845fca833..5c611b55c 100644
--- a/src/GZCTF.Integration.Test/Base/TestDataSeeder.cs
+++ b/src/GZCTF.Integration.Test/Base/TestDataSeeder.cs
@@ -68,11 +68,11 @@ public static async Task GetOrCreateInviteGameAsync(IServiceProvider servic
// Set game invite code
using var scope = services.CreateScope();
var gameRepo = scope.ServiceProvider.GetRequiredService();
- var gameEntity = await gameRepo.GetGameById(game.Id, default);
+ var gameEntity = await gameRepo.GetGameById(game.Id);
if (gameEntity != null)
{
gameEntity.InviteCode = "SHARED_INVITE_2025";
- await gameRepo.SaveAsync(default);
+ await gameRepo.SaveAsync();
}
_sharedInviteGameId = game.Id;
@@ -398,6 +398,23 @@ private static string NormalizeTeamName(string? teamName)
return trimmed;
}
+ ///
+ /// Create a user with specific role
+ ///
+ public static async Task<(SeededUser user, string password)> CreateUserWithRoleAsync(
+ IServiceProvider services,
+ Role role = Role.User,
+ CancellationToken token = default)
+ {
+ var password = $"Test{role}Pass123!";
+ var userName = RandomName();
+ var email = $"{userName}@test.com";
+
+ var user = await CreateUserAsync(services, userName, password, email, role, token);
+
+ return (user, password);
+ }
+
public record SeededUser(Guid Id, string UserName, string Email, string Password, Role Role);
public record SeededTeam(int Id, string Name, Guid OwnerId);
diff --git a/src/GZCTF.Integration.Test/Tests/Api/AdvancedGameMechanicsTests.cs b/src/GZCTF.Integration.Test/Tests/Api/AdvancedGameMechanicsTests.cs
index 14459ad48..f353ccb41 100644
--- a/src/GZCTF.Integration.Test/Tests/Api/AdvancedGameMechanicsTests.cs
+++ b/src/GZCTF.Integration.Test/Tests/Api/AdvancedGameMechanicsTests.cs
@@ -50,7 +50,7 @@ public async Task ChallengeSubmissionLimit_ShouldPreventExcessiveSubmissions()
Content = "Challenge with submission limit",
Category = ChallengeCategory.Misc,
Type = ChallengeType.StaticAttachment,
- Hints = new List(),
+ Hints = [],
IsEnabled = true,
SubmissionLimit = 3, // Limit to 3 submissions
OriginalScore = 100,
@@ -131,7 +131,7 @@ public async Task ChallengeDeadline_ShouldPreventSubmissionsAfterDeadline()
Content = "Challenge with past deadline",
Category = ChallengeCategory.Misc,
Type = ChallengeType.StaticAttachment,
- Hints = new List(),
+ Hints = [],
IsEnabled = true,
SubmissionLimit = 0, // No submission limit
DeadlineUtc = DateTimeOffset.UtcNow.AddMinutes(-5), // Deadline 5 minutes ago
@@ -375,7 +375,7 @@ public async Task ChallengeDisabling_ShouldUpdateScoreboard()
Content = "Challenge to toggle",
Category = ChallengeCategory.Misc,
Type = ChallengeType.StaticAttachment,
- Hints = new List(),
+ Hints = [],
IsEnabled = true,
SubmissionLimit = 0,
OriginalScore = 200,
@@ -565,7 +565,7 @@ public async Task ChallengeReEnabling_ShouldRestoreChallengeAvailability()
Content = "Challenge to re-enable",
Category = ChallengeCategory.Misc,
Type = ChallengeType.StaticAttachment,
- Hints = new List(),
+ Hints = [],
IsEnabled = true,
SubmissionLimit = 0,
OriginalScore = 150,
diff --git a/src/GZCTF.Integration.Test/Tests/Api/OAuthIntegrationTests.cs b/src/GZCTF.Integration.Test/Tests/Api/OAuthIntegrationTests.cs
new file mode 100644
index 000000000..8ff6b0e3a
--- /dev/null
+++ b/src/GZCTF.Integration.Test/Tests/Api/OAuthIntegrationTests.cs
@@ -0,0 +1,452 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using GZCTF.Integration.Test.Base;
+using GZCTF.Models;
+using GZCTF.Models.Data;
+using GZCTF.Models.Internal;
+using GZCTF.Repositories.Interface;
+using GZCTF.Services.OAuth;
+using GZCTF.Utils;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+using Xunit.Abstractions;
+using UserMetadataField = GZCTF.Models.Internal.UserMetadataField;
+
+namespace GZCTF.Integration.Test.Tests.Api;
+
+///
+/// Tests for OAuth authentication flow
+///
+[Collection(nameof(IntegrationTestCollection))]
+public class OAuthIntegrationTests(GZCTFApplicationFactory factory, ITestOutputHelper output)
+{
+ [Fact]
+ public async Task Admin_CreateOAuthProvider_Succeeds()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ using var client = factory.CreateAuthenticatedClient(admin);
+
+ var providers = new Dictionary
+ {
+ ["testprovider"] = new()
+ {
+ Enabled = true,
+ ClientId = "test-client-id",
+ ClientSecret = "test-client-secret",
+ AuthorizationEndpoint = "https://test.example.com/oauth/authorize",
+ TokenEndpoint = "https://test.example.com/oauth/token",
+ UserInformationEndpoint = "https://test.example.com/oauth/userinfo",
+ DisplayName = "Test Provider",
+ Scopes = ["openid", "profile", "email"],
+ FieldMapping = new Dictionary
+ {
+ { "sub", "userId" }, { "email", "email" }, { "name", "displayName" }
+ }
+ }
+ };
+
+ // Act
+ var response = await client.PutAsJsonAsync("/api/Admin/OAuth", providers);
+ output.WriteLine($"Status: {response.StatusCode}");
+
+ // Assert
+ response.EnsureSuccessStatusCode();
+
+ // Verify provider was created
+ var getResponse = await client.GetAsync("/api/Admin/OAuth");
+ getResponse.EnsureSuccessStatusCode();
+ var retrievedProviders = await getResponse.Content.ReadFromJsonAsync>();
+
+ Assert.NotNull(retrievedProviders);
+ Assert.True(retrievedProviders.ContainsKey("testprovider"));
+ Assert.Equal("Test Provider", retrievedProviders["testprovider"].DisplayName);
+ Assert.Equal(3, retrievedProviders["testprovider"].FieldMapping.Count);
+ }
+
+ [Fact]
+ public async Task User_GetOAuthProviders_ReturnsEnabledProviders()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ using var adminClient = factory.CreateAuthenticatedClient(admin);
+
+ // Create OAuth providers
+ var providers = new Dictionary
+ {
+ ["enabled"] =
+ new()
+ {
+ Enabled = true,
+ ClientId = "test",
+ ClientSecret = "secret",
+ AuthorizationEndpoint = "https://test.com/auth",
+ TokenEndpoint = "https://test.com/token",
+ UserInformationEndpoint = "https://test.com/user",
+ DisplayName = "Enabled Provider",
+ Scopes = ["email"]
+ },
+ ["disabled"] = new()
+ {
+ Enabled = false,
+ ClientId = "test2",
+ ClientSecret = "secret2",
+ AuthorizationEndpoint = "https://test2.com/auth",
+ TokenEndpoint = "https://test2.com/token",
+ UserInformationEndpoint = "https://test2.com/user",
+ DisplayName = "Disabled Provider",
+ Scopes = ["email"]
+ }
+ };
+ await adminClient.PutAsJsonAsync("/api/Admin/OAuth", providers);
+
+ // Act - get as unauthenticated user
+ using var publicClient = factory.CreateClient();
+ var response = await publicClient.GetAsync("/api/Account/OAuth/Providers");
+
+ // Debug output
+ output.WriteLine($"Response status: {response.StatusCode}");
+ output.WriteLine($"Response content type: {response.Content.Headers.ContentType}");
+ var content = await response.Content.ReadAsStringAsync();
+ output.WriteLine($"Response content (first 500 chars): {content[..Math.Min(500, content.Length)]}");
+
+ // Assert
+ response.EnsureSuccessStatusCode();
+ var availableProviders = await response.Content.ReadFromJsonAsync>();
+
+ Assert.NotNull(availableProviders);
+ Assert.Contains("enabled", availableProviders.Keys);
+ Assert.DoesNotContain("disabled", availableProviders.Keys);
+ Assert.Equal("Enabled Provider", availableProviders["enabled"]);
+ }
+
+ [Fact]
+ public async Task OAuth_LoginInitiation_ReturnsAuthorizationUrl()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ using var adminClient = factory.CreateAuthenticatedClient(admin);
+
+ // Create OAuth provider
+ var providers = new Dictionary
+ {
+ ["github"] = new()
+ {
+ Enabled = true,
+ ClientId = "github-client-id",
+ ClientSecret = "github-client-secret",
+ AuthorizationEndpoint = "https://github.com/login/oauth/authorize",
+ TokenEndpoint = "https://github.com/login/oauth/access_token",
+ UserInformationEndpoint = "https://api.github.com/user",
+ DisplayName = "GitHub",
+ Scopes = ["user:email"],
+ FieldMapping = new Dictionary()
+ }
+ };
+ await adminClient.PutAsJsonAsync("/api/Admin/OAuth", providers);
+
+ // Act
+ using var publicClient = factory.CreateClient();
+ var response = await publicClient.GetAsync($"/api/Account/OAuth/Login/github");
+
+ // Assert
+ response.EnsureSuccessStatusCode();
+ var result = await response.Content.ReadFromJsonAsync>();
+
+ Assert.NotNull(result);
+ Assert.NotNull(result.Data);
+ Assert.Contains("github.com/login/oauth/authorize", result.Data);
+ Assert.Contains("client_id=github-client-id", result.Data);
+ Assert.Contains("state=", result.Data);
+ output.WriteLine($"Authorization URL: {result.Data}");
+ }
+
+ [Fact]
+ public async Task OAuth_LoginWithDisabledProvider_ReturnsBadRequest()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ using var adminClient = factory.CreateAuthenticatedClient(admin);
+
+ // Create disabled OAuth provider
+ var providers = new Dictionary
+ {
+ ["disabled"] = new()
+ {
+ Enabled = false,
+ ClientId = "test",
+ ClientSecret = "secret",
+ AuthorizationEndpoint = "https://test.com/auth",
+ TokenEndpoint = "https://test.com/token",
+ UserInformationEndpoint = "https://test.com/user",
+ Scopes = []
+ }
+ };
+ await adminClient.PutAsJsonAsync("/api/Admin/OAuth", providers);
+
+ using var publicClient = factory.CreateClient();
+ var response = await publicClient.GetAsync("/api/Account/OAuth/Login/disabled");
+
+ // Assert
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task OAuthService_GetOrCreateUser_CreatesNewUser()
+ {
+ // Arrange
+ await ConfigureMetadataFieldsAsync(
+ new UserMetadataField
+ {
+ Key = "department",
+ DisplayName = "Department",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true
+ },
+ new UserMetadataField
+ {
+ Key = "role",
+ DisplayName = "Role",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true
+ });
+
+ using var scope = factory.Services.CreateScope();
+ var oauthService = scope.ServiceProvider.GetRequiredService();
+
+ var oauthUser = new OAuthUserInfo
+ {
+ ProviderId = "testprovider",
+ ProviderUserId = "12345",
+ Email = $"oauth-{Guid.NewGuid():N}@example.com",
+ UserName = "oauthuser",
+ MappedFields = new Dictionary
+ {
+ { "department", "Engineering" }, { "role", "Developer" }
+ }
+ };
+
+ // Act
+ var provider = await GetProviderEntityAsync("testprovider", createIfMissing: true);
+ var (user, isNewUser) = await oauthService.GetOrCreateUserFromOAuthAsync(provider, oauthUser);
+
+ // Assert
+ Assert.True(isNewUser);
+ Assert.Equal(oauthUser.Email, user.Email);
+ Assert.True(user.EmailConfirmed); // OAuth users have confirmed emails
+ Assert.Equal("Engineering", user.UserMetadata["department"]);
+ Assert.Equal("Developer", user.UserMetadata["role"]);
+ output.WriteLine($"Created user: {user.UserName} ({user.Email})");
+ }
+
+ [Fact]
+ public async Task OAuthService_GetOrCreateUser_UpdatesExistingUser()
+ {
+ // Arrange
+ await ConfigureMetadataFieldsAsync(
+ new UserMetadataField
+ {
+ Key = "company",
+ DisplayName = "Company",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true
+ },
+ new UserMetadataField
+ {
+ Key = "location",
+ DisplayName = "Location",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true
+ });
+
+ var email = $"existing-{Guid.NewGuid():N}@example.com";
+ var (existingUser, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services);
+ var provider = await GetProviderEntityAsync("testprovider", createIfMissing: true);
+
+ // Update user email to match OAuth email
+ using var scope1 = factory.Services.CreateScope();
+ var userManager = scope1.ServiceProvider.GetRequiredService>();
+ var user = await userManager.FindByIdAsync(existingUser.Id.ToString());
+ Assert.NotNull(user);
+ user.Email = email;
+ user.OAuthProviderId = provider.Id;
+ await userManager.UpdateAsync(user);
+
+ // Create OAuth user with same email
+ using var scope2 = factory.Services.CreateScope();
+ var oauthService = scope2.ServiceProvider.GetRequiredService();
+
+ var oauthUser = new OAuthUserInfo
+ {
+ ProviderId = "testprovider",
+ ProviderUserId = "67890",
+ Email = email,
+ UserName = "oauthuser2",
+ MappedFields = new Dictionary { { "company", "TestCorp" }, { "location", "Remote" } }
+ };
+
+ // Act
+ var (updatedUser, isNewUser) = await oauthService.GetOrCreateUserFromOAuthAsync(provider, oauthUser);
+
+ // Assert
+ Assert.False(isNewUser);
+ Assert.Equal(existingUser.Id, updatedUser.Id);
+ Assert.Equal(email, updatedUser.Email);
+ Assert.Equal("TestCorp", updatedUser.UserMetadata["company"]);
+ Assert.Equal("Remote", updatedUser.UserMetadata["location"]);
+ output.WriteLine($"Updated existing user: {updatedUser.UserName}");
+ }
+
+ [Fact]
+ public async Task OAuthService_HandlesUsernameConflicts()
+ {
+ // Arrange
+ // Create user with specific username
+ var userName = $"testuser_{Guid.NewGuid():N}";
+ await TestDataSeeder.CreateUserAsync(factory.Services, userName, "Password123!", $"{userName}@test.com");
+
+ // Try to create OAuth user with same username
+ using var scope = factory.Services.CreateScope();
+ var oauthService = scope.ServiceProvider.GetRequiredService();
+
+ var oauthUser = new OAuthUserInfo
+ {
+ ProviderId = "testprovider",
+ ProviderUserId = "99999",
+ Email = $"different-{Guid.NewGuid():N}@example.com",
+ UserName = userName, // Same username as existing user
+ MappedFields = new Dictionary()
+ };
+
+ // Act
+ var provider = await GetProviderEntityAsync("testprovider", createIfMissing: true);
+ var (user, isNewUser) = await oauthService.GetOrCreateUserFromOAuthAsync(provider, oauthUser);
+
+ // Assert
+ Assert.True(isNewUser);
+ Assert.NotEqual(userName, user.UserName); // Should have different username
+ // Username should be truncated if needed and have a conflict resolution suffix
+ Assert.True(user.UserName!.Length <= 16, $"Username '{user.UserName}' exceeds 16 characters");
+ Assert.Matches(@"^testuser_[a-f0-9]+$", user.UserName); // Pattern: testuser_
+ output.WriteLine($"Resolved username conflict: {userName} -> {user.UserName}");
+ }
+
+ [Fact]
+ public async Task OAuth_FieldMapping_AppliesCorrectly()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ using var adminClient = factory.CreateAuthenticatedClient(admin);
+
+ // Create metadata fields
+ var fields = new List
+ {
+ new()
+ {
+ Key = "githubUsername",
+ DisplayName = "GitHub Username",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true
+ },
+ new()
+ {
+ Key = "fullName",
+ DisplayName = "Full Name",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true
+ }
+ };
+ await adminClient.PutAsJsonAsync("/api/Admin/UserMetadata", fields);
+
+ // Create OAuth provider with field mapping
+ var providers = new Dictionary
+ {
+ ["github"] = new()
+ {
+ Enabled = true,
+ ClientId = "test",
+ ClientSecret = "secret",
+ AuthorizationEndpoint = "https://github.com/login/oauth/authorize",
+ TokenEndpoint = "https://github.com/login/oauth/access_token",
+ UserInformationEndpoint = "https://api.github.com/user",
+ Scopes = ["user:email"],
+ FieldMapping = new Dictionary
+ {
+ { "login", "githubUsername" }, { "name", "fullName" }
+ }
+ }
+ };
+ await adminClient.PutAsJsonAsync("/api/Admin/OAuth", providers);
+
+ // Create OAuth user
+ using var scope = factory.Services.CreateScope();
+ var oauthService = scope.ServiceProvider.GetRequiredService();
+
+ var oauthUser = new OAuthUserInfo
+ {
+ ProviderId = "github",
+ ProviderUserId = "111",
+ Email = $"gh-{Guid.NewGuid():N}@example.com",
+ UserName = "octocat",
+ MappedFields = new Dictionary
+ {
+ { "githubUsername", "octocat" }, { "fullName", "The Octocat" }
+ }
+ };
+
+ // Act
+ var provider = await GetProviderEntityAsync("github");
+ var (user, _) = await oauthService.GetOrCreateUserFromOAuthAsync(provider, oauthUser);
+
+ // Assert
+ Assert.Equal("octocat", user.UserMetadata["githubUsername"]);
+ Assert.Equal("The Octocat", user.UserMetadata["fullName"]);
+ output.WriteLine($"User metadata: {JsonSerializer.Serialize(user.UserMetadata)}");
+ }
+
+ private async Task ConfigureMetadataFieldsAsync(params UserMetadataField[] fields)
+ {
+ using var scope = factory.Services.CreateScope();
+ var repository = scope.ServiceProvider.GetRequiredService();
+ await repository.UpdateMetadataFieldsAsync(fields.ToList(), CancellationToken.None);
+ }
+
+ private async Task GetProviderEntityAsync(string key, bool createIfMissing = false)
+ {
+ using var scope = factory.Services.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+
+ var query = createIfMissing ? context.OAuthProviders : context.OAuthProviders.AsNoTracking();
+ OAuthProvider.ValidateKey(key);
+ var provider = await query.FirstOrDefaultAsync(p => p.Key == key);
+
+ if (provider is not null)
+ return provider;
+
+ if (!createIfMissing)
+ throw new InvalidOperationException($"OAuth provider '{key}' not found.");
+
+ provider = new OAuthProvider
+ {
+ Key = key,
+ Enabled = true,
+ AuthorizationEndpoint = $"https://{key}.example.com/oauth/authorize",
+ TokenEndpoint = $"https://{key}.example.com/oauth/token",
+ UserInformationEndpoint = $"https://{key}.example.com/oauth/userinfo"
+ };
+
+ context.OAuthProviders.Add(provider);
+ await context.SaveChangesAsync();
+
+ return provider;
+ }
+}
diff --git a/src/GZCTF.Integration.Test/Tests/Api/PostControllerTests.cs b/src/GZCTF.Integration.Test/Tests/Api/PostControllerTests.cs
index 5287a1609..1bd765e7f 100644
--- a/src/GZCTF.Integration.Test/Tests/Api/PostControllerTests.cs
+++ b/src/GZCTF.Integration.Test/Tests/Api/PostControllerTests.cs
@@ -3,13 +3,10 @@
using System.Text.Json;
using GZCTF.Integration.Test.Base;
using GZCTF.Models;
-using GZCTF.Models.Data;
using GZCTF.Models.Request.Account;
using GZCTF.Models.Request.Edit;
using GZCTF.Models.Request.Info;
-using GZCTF.Repositories.Interface;
using GZCTF.Utils;
-using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.Abstractions;
@@ -54,7 +51,7 @@ public async Task CreatePost_WithCompleteData_ShouldSucceed()
Title = "Test Post Title",
Summary = "This is a test post summary",
Content = "# Test Content\n\nThis is the full content of the test post.",
- Tags = new List { "test", "integration" }
+ Tags = ["test", "integration"]
// Do NOT include IsPinned during creation - it would trigger pin-only mode
};
@@ -197,7 +194,7 @@ public async Task EditPost_UpdateContentOnPinnedPost_ShouldPreservePinStatus()
Title = "Original Title",
Summary = "Original Summary",
Content = "Original Content",
- Tags = new List { "original" }
+ Tags = ["original"]
};
var createResponse = await client.PostAsJsonAsync("/api/Edit/Posts", createModel);
@@ -216,7 +213,7 @@ public async Task EditPost_UpdateContentOnPinnedPost_ShouldPreservePinStatus()
Title = "Updated Title",
Summary = "Updated Summary",
Content = "Updated Content",
- Tags = new List { "updated", "modified" }
+ Tags = ["updated", "modified"]
// IsPinned is null - pin status should be preserved automatically
};
@@ -262,7 +259,7 @@ public async Task EditPost_TogglePinOnly_ShouldPreserveContent()
Title = "Important Post",
Summary = "This is an important announcement",
Content = "# Important\n\nThis content should not be lost when pinning.",
- Tags = new List { "important", "announcement" }
+ Tags = ["important", "announcement"]
};
var createResponse = await client.PostAsJsonAsync("/api/Edit/Posts", createModel);
@@ -339,7 +336,7 @@ public async Task EditPost_SendIsPinnedWithContent_ShouldOnlyUpdatePin()
Title = "Original Title",
Summary = "Original Summary",
Content = "Original Content",
- Tags = new List { "original" }
+ Tags = ["original"]
};
var createResponse = await client.PostAsJsonAsync("/api/Edit/Posts", createModel);
@@ -353,7 +350,7 @@ public async Task EditPost_SendIsPinnedWithContent_ShouldOnlyUpdatePin()
Title = "This Should Be Ignored",
Summary = "This Should Be Ignored",
Content = "This Should Be Ignored",
- Tags = new List { "ignored" },
+ Tags = ["ignored"],
IsPinned = true // When IsPinned is present, only pin status is updated
};
@@ -397,7 +394,7 @@ public async Task EditPost_UpdateTitleOnly_ShouldPreserveOtherFields()
Title = "Original Title",
Summary = "Original Summary",
Content = "Original Content",
- Tags = new List { "tag1", "tag2" }
+ Tags = ["tag1", "tag2"]
};
var createResponse = await client.PostAsJsonAsync("/api/Edit/Posts", createModel);
@@ -456,7 +453,7 @@ public async Task EditPost_UpdateTagsOnly_ShouldPreserveContentAndPin()
Title = "Tagged Post",
Summary = "Summary",
Content = "Content",
- Tags = new List { "old-tag" }
+ Tags = ["old-tag"]
};
var createResponse = await client.PostAsJsonAsync("/api/Edit/Posts", createModel);
@@ -472,7 +469,7 @@ public async Task EditPost_UpdateTagsOnly_ShouldPreserveContentAndPin()
// Act: Update only tags
var updateModel = new PostEditModel
{
- Tags = new List { "new-tag", "another-tag" }
+ Tags = ["new-tag", "another-tag"]
};
var updateResponse = await client.PutAsJsonAsync($"/api/Edit/Posts/{postId}", updateModel);
@@ -517,7 +514,7 @@ public async Task BugScenario_EditPinnedPostContent_ShouldNotUnpinOrLoseContent(
Title = "Pinned Announcement",
Summary = "Important announcement summary",
Content = "# Important\n\nThis is a pinned announcement with important content.",
- Tags = new List { "announcement", "pinned" }
+ Tags = ["announcement", "pinned"]
};
var createResponse = await client.PostAsJsonAsync("/api/Edit/Posts", createModel);
@@ -539,7 +536,7 @@ public async Task BugScenario_EditPinnedPostContent_ShouldNotUnpinOrLoseContent(
Title = "Updated Pinned Announcement",
Summary = "Updated summary",
Content = "# Updated\n\nThis is the updated content that should not be lost.",
- Tags = new List { "announcement", "pinned", "updated" }
+ Tags = ["announcement", "pinned", "updated"]
// IsPinned is null - this tells the backend to preserve the current pin status
};
diff --git a/src/GZCTF.Integration.Test/Tests/Api/ScoreboardCalculationTests.cs b/src/GZCTF.Integration.Test/Tests/Api/ScoreboardCalculationTests.cs
index 4b9169cb2..04cff93a3 100644
--- a/src/GZCTF.Integration.Test/Tests/Api/ScoreboardCalculationTests.cs
+++ b/src/GZCTF.Integration.Test/Tests/Api/ScoreboardCalculationTests.cs
@@ -1,7 +1,6 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
-using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using GZCTF.Integration.Test.Base;
diff --git a/src/GZCTF.Integration.Test/Tests/Api/UserMetadataTests.cs b/src/GZCTF.Integration.Test/Tests/Api/UserMetadataTests.cs
new file mode 100644
index 000000000..e3146095f
--- /dev/null
+++ b/src/GZCTF.Integration.Test/Tests/Api/UserMetadataTests.cs
@@ -0,0 +1,340 @@
+using System.Net;
+using System.Net.Http.Json;
+using GZCTF.Integration.Test.Base;
+using GZCTF.Models.Internal;
+using GZCTF.Models.Request.Account;
+using GZCTF.Utils;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace GZCTF.Integration.Test.Tests.Api;
+
+///
+/// Tests for user metadata field configuration and profile management
+///
+[Collection(nameof(IntegrationTestCollection))]
+public class UserMetadataTests(GZCTFApplicationFactory factory, ITestOutputHelper output)
+{
+ [Fact]
+ public async Task Admin_GetUserMetadataFields_ReturnsFields()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ using var client = factory.CreateAuthenticatedClient(admin);
+
+ // Act
+ var response = await client.GetAsync("/api/Admin/UserMetadata");
+
+ // Assert
+ response.EnsureSuccessStatusCode();
+ var fields = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(fields);
+ // Database may or may not be empty depending on test execution order
+ output.WriteLine($"Retrieved {fields.Count} metadata fields");
+ }
+
+ [Fact]
+ public async Task Admin_CreateUserMetadataFields_Succeeds()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ using var client = factory.CreateAuthenticatedClient(admin);
+
+ var fields = new List
+ {
+ new()
+ {
+ Key = "department",
+ DisplayName = "Department",
+ Type = UserMetadataFieldType.Select,
+ Required = true,
+ Visible = true,
+ Options = ["Engineering", "Marketing", "Sales"]
+ },
+ new()
+ {
+ Key = "studentId",
+ DisplayName = "Student ID",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true,
+ MaxLength = 20
+ }
+ };
+
+ // Act
+ var response = await client.PutAsJsonAsync("/api/Admin/UserMetadata", fields);
+ output.WriteLine($"Status: {response.StatusCode}");
+
+ // Assert
+ response.EnsureSuccessStatusCode();
+
+ // Verify fields were created
+ var getResponse = await client.GetAsync("/api/Admin/UserMetadata");
+ getResponse.EnsureSuccessStatusCode();
+ var retrievedFields = await getResponse.Content.ReadFromJsonAsync>();
+ Assert.NotNull(retrievedFields);
+ Assert.Equal(2, retrievedFields.Count);
+ Assert.Contains(retrievedFields, f => f.Key == "department");
+ Assert.Contains(retrievedFields, f => f.Key == "studentId");
+ }
+
+ [Fact]
+ public async Task Admin_UpdateUserMetadataFields_Succeeds()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ using var client = factory.CreateAuthenticatedClient(admin);
+
+ // Create initial fields
+ var initialFields = new List
+ {
+ new()
+ {
+ Key = "organization",
+ DisplayName = "Organization",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true
+ }
+ };
+ await client.PutAsJsonAsync("/api/Admin/UserMetadata", initialFields);
+
+ // Update fields
+ var updatedFields = new List
+ {
+ new()
+ {
+ Key = "organization",
+ DisplayName = "Organization Name",
+ Type = UserMetadataFieldType.Text,
+ Required = true,
+ Visible = true,
+ MaxLength = 100
+ },
+ new()
+ {
+ Key = "role",
+ DisplayName = "Role",
+ Type = UserMetadataFieldType.Select,
+ Required = true,
+ Visible = true,
+ Options = ["Developer", "Manager", "Analyst"]
+ }
+ };
+
+ // Act
+ var response = await client.PutAsJsonAsync("/api/Admin/UserMetadata", updatedFields);
+
+ // Assert
+ response.EnsureSuccessStatusCode();
+
+ // Verify update
+ var getResponse = await client.GetAsync("/api/Admin/UserMetadata");
+ var retrievedFields = await getResponse.Content.ReadFromJsonAsync>();
+ Assert.NotNull(retrievedFields);
+ Assert.Equal(2, retrievedFields.Count);
+
+ var orgField = retrievedFields.FirstOrDefault(f => f.Key == "organization");
+ Assert.NotNull(orgField);
+ Assert.Equal("Organization Name", orgField.DisplayName);
+ Assert.True(orgField.Required);
+ Assert.Equal(100, orgField.MaxLength);
+ }
+
+ [Fact]
+ public async Task Admin_DeleteAllUserMetadataFields_Succeeds()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ using var client = factory.CreateAuthenticatedClient(admin);
+
+ // Create initial fields
+ var fields = new List
+ {
+ new()
+ {
+ Key = "tempField",
+ DisplayName = "Temporary Field",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true
+ }
+ };
+ await client.PutAsJsonAsync("/api/Admin/UserMetadata", fields);
+
+ // Act - delete by sending empty list
+ var response = await client.PutAsJsonAsync("/api/Admin/UserMetadata", new List());
+
+ // Assert
+ response.EnsureSuccessStatusCode();
+
+ // Verify deletion
+ var getResponse = await client.GetAsync("/api/Admin/UserMetadata");
+ var retrievedFields = await getResponse.Content.ReadFromJsonAsync>();
+ Assert.NotNull(retrievedFields);
+ Assert.Empty(retrievedFields);
+ }
+
+ [Fact]
+ public async Task User_GetMetadataFields_ReturnsConfiguredFields()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ var (user, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services);
+
+ // Create fields as admin
+ using var adminClient = factory.CreateAuthenticatedClient(admin);
+ var fields = new List
+ {
+ new()
+ {
+ Key = "userField",
+ DisplayName = "User Field",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true
+ }
+ };
+ await adminClient.PutAsJsonAsync("/api/Admin/UserMetadata", fields);
+
+ // Act - get as regular user
+ using var userClient = factory.CreateAuthenticatedClient(user);
+ var response = await userClient.GetAsync("/api/Account/MetadataFields");
+
+ // Assert
+ response.EnsureSuccessStatusCode();
+ var retrievedFields = await response.Content.ReadFromJsonAsync>();
+ Assert.NotNull(retrievedFields);
+ Assert.Single(retrievedFields);
+ Assert.Equal("userField", retrievedFields[0].Key);
+ }
+
+ [Fact]
+ public async Task User_UpdateProfile_WithMetadata_Succeeds()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ var (user, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services);
+
+ // Create metadata fields as admin
+ using var adminClient = factory.CreateAuthenticatedClient(admin);
+ var fields = new List
+ {
+ new()
+ {
+ Key = "department",
+ DisplayName = "Department",
+ Type = UserMetadataFieldType.Select,
+ Required = false,
+ Visible = true,
+ Options = ["IT", "HR", "Finance"]
+ },
+ new()
+ {
+ Key = "employeeId",
+ DisplayName = "Employee ID",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true
+ }
+ };
+ await adminClient.PutAsJsonAsync("/api/Admin/UserMetadata", fields);
+
+ // Act - update profile with metadata
+ using var userClient = factory.CreateAuthenticatedClient(user);
+ var metadataResponse = await userClient.PutAsJsonAsync("/api/Account/Metadata", new UserMetadataUpdateModel
+ {
+ Metadata = new Dictionary
+ {
+ { "department", "IT" },
+ { "employeeId", "EMP001" }
+ }
+ });
+ metadataResponse.EnsureSuccessStatusCode();
+
+ var profileUpdate = new ProfileUpdateModel { Bio = "Test bio" };
+ var response = await userClient.PutAsJsonAsync("/api/Account/Update", profileUpdate);
+ output.WriteLine($"Status: {response.StatusCode}");
+
+ response.EnsureSuccessStatusCode();
+
+ // Verify profile update
+ var profileResponse = await userClient.GetAsync("/api/Account/Profile");
+ profileResponse.EnsureSuccessStatusCode();
+ var profile = await profileResponse.Content.ReadFromJsonAsync();
+
+ Assert.NotNull(profile);
+ Assert.Equal("Test bio", profile.Bio);
+ Assert.NotNull(profile.Metadata);
+ Assert.Equal("IT", profile.Metadata["department"]);
+ Assert.Equal("EMP001", profile.Metadata["employeeId"]);
+ }
+
+ [Fact]
+ public async Task User_UpdateProfile_RemoveMetadata_Succeeds()
+ {
+ // Arrange
+ var (admin, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services, Role.Admin);
+ using var adminClient = factory.CreateAuthenticatedClient(admin);
+ await adminClient.PutAsJsonAsync("/api/Admin/UserMetadata", new List
+ {
+ new()
+ {
+ Key = "testField",
+ DisplayName = "Test Field",
+ Type = UserMetadataFieldType.Text,
+ Required = false,
+ Visible = true
+ }
+ });
+
+ var (user, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services);
+ using var client = factory.CreateAuthenticatedClient(user);
+
+ // Add metadata first
+ await client.PutAsJsonAsync("/api/Account/Metadata", new UserMetadataUpdateModel
+ {
+ Metadata = new Dictionary
+ {
+ { "testField", "testValue" }
+ }
+ });
+
+ // Act - remove metadata by setting to empty string
+ var response = await client.PutAsJsonAsync("/api/Account/Metadata", new UserMetadataUpdateModel
+ {
+ Metadata = new Dictionary
+ {
+ { "testField", "" }
+ }
+ });
+
+ // Assert
+ response.EnsureSuccessStatusCode();
+
+ // Verify removal
+ var profileResponse = await client.GetAsync("/api/Account/Profile");
+ var profile = await profileResponse.Content.ReadFromJsonAsync();
+
+ Assert.NotNull(profile);
+ Assert.NotNull(profile.Metadata);
+ Assert.DoesNotContain("testField", profile.Metadata.Keys);
+ }
+
+ [Fact]
+ public async Task NonAdmin_CannotAccessAdminMetadataEndpoints()
+ {
+ // Arrange
+ var (user, _) = await TestDataSeeder.CreateUserWithRoleAsync(factory.Services);
+ using var client = factory.CreateAuthenticatedClient(user);
+
+ // Act
+ var getResponse = await client.GetAsync("/api/Admin/UserMetadata");
+ var putResponse = await client.PutAsJsonAsync("/api/Admin/UserMetadata", new List());
+
+ // Assert
+ Assert.Equal(HttpStatusCode.Forbidden, getResponse.StatusCode);
+ Assert.Equal(HttpStatusCode.Forbidden, putResponse.StatusCode);
+ }
+}
diff --git a/src/GZCTF.Test/UnitTests/Transfer/TransferValidatorTest.cs b/src/GZCTF.Test/UnitTests/Transfer/TransferValidatorTest.cs
index 1c271f49f..f42d3f294 100644
--- a/src/GZCTF.Test/UnitTests/Transfer/TransferValidatorTest.cs
+++ b/src/GZCTF.Test/UnitTests/Transfer/TransferValidatorTest.cs
@@ -1,6 +1,4 @@
using System;
-using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
using GZCTF.Models.Transfer;
using GZCTF.Utils;
using Xunit;
@@ -113,10 +111,7 @@ public void ValidateChallenge_ValidChallenge_ShouldPass()
},
Flags = new FlagsSection
{
- Static = new List
- {
- new() { Value = "flag{test}" }
- }
+ Static = [new() { Value = "flag{test}" }]
}
};
@@ -157,7 +152,7 @@ public void ValidateChallenge_InvalidScoring_ShouldThrow()
},
Flags = new FlagsSection
{
- Static = new List { new() { Value = "flag{test}" } }
+ Static = [new() { Value = "flag{test}" }]
}
};
@@ -182,7 +177,7 @@ public void ValidateChallenge_InvalidMinRate_ShouldThrow()
},
Flags = new FlagsSection
{
- Static = new List { new() { Value = "flag{test}" } }
+ Static = [new() { Value = "flag{test}" }]
}
};
@@ -514,10 +509,7 @@ public void ValidateChallenge_StaticFlagEmptyValue_ShouldThrow()
Type = ChallengeType.StaticAttachment,
Flags = new FlagsSection
{
- Static = new List
- {
- new() { Value = "" } // Empty flag value
- }
+ Static = [new() { Value = "" }]
}
};
@@ -536,10 +528,7 @@ public void ValidateChallenge_StaticFlagTooLong_ShouldThrow()
Type = ChallengeType.StaticAttachment,
Flags = new FlagsSection
{
- Static = new List
- {
- new() { Value = new string('a', 150) } // Exceeds 127 chars
- }
+ Static = [new() { Value = new string('a', 150) }]
}
};
diff --git a/src/GZCTF.Test/UnitTests/UserMetadataServiceTests.cs b/src/GZCTF.Test/UnitTests/UserMetadataServiceTests.cs
new file mode 100644
index 000000000..ebd636972
--- /dev/null
+++ b/src/GZCTF.Test/UnitTests/UserMetadataServiceTests.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GZCTF.Models.Data;
+using GZCTF.Repositories.Interface;
+using GZCTF.Services;
+using Microsoft.EntityFrameworkCore.Storage;
+using Xunit;
+using InternalUserMetadataField = GZCTF.Models.Internal.UserMetadataField;
+using OAuthProviderConfig = GZCTF.Models.Internal.OAuthProviderConfig;
+
+namespace GZCTF.Test.UnitTests;
+
+public class UserMetadataServiceTests
+{
+ [Fact]
+ public async Task ValidateAsync_AllowsUnlockedUpdates()
+ {
+ var service = CreateService([
+ new InternalUserMetadataField { Key = "department", DisplayName = "Department", Required = true }
+ ]);
+
+ var result = await service.ValidateAsync(
+ new Dictionary { { "department", "IT" } },
+ null,
+ allowLockedWrites: false,
+ enforceLockedRequirements: true);
+
+ Assert.True(result.IsValid);
+ Assert.Equal("IT", result.Values["department"]);
+ }
+
+ [Fact]
+ public async Task ValidateAsync_IgnoresLockedWhenNotPermitted()
+ {
+ var service = CreateService([
+ new InternalUserMetadataField
+ {
+ Key = "studentId", DisplayName = "Student Id", Required = true, Locked = true
+ }
+ ]);
+
+ var result = await service.ValidateAsync(
+ new Dictionary { { "studentId", "123" } },
+ null,
+ allowLockedWrites: false,
+ enforceLockedRequirements: false);
+
+ Assert.True(result.IsValid);
+ Assert.False(result.Values.ContainsKey("studentId"));
+ }
+
+ [Fact]
+ public async Task ValidateAsync_FailsWhenRequiredMissing()
+ {
+ var service = CreateService([
+ new InternalUserMetadataField { Key = "role", DisplayName = "Role", Required = true }
+ ]);
+
+ var result = await service.ValidateAsync(
+ new Dictionary(),
+ null,
+ allowLockedWrites: false,
+ enforceLockedRequirements: true);
+
+ Assert.False(result.IsValid);
+ Assert.Single(result.Errors);
+ }
+
+ static IUserMetadataService CreateService(IReadOnlyList fields)
+ => new UserMetadataService(new TestOAuthProviderRepository(fields));
+
+ sealed class TestOAuthProviderRepository(IReadOnlyList fields) : IOAuthProviderRepository
+ {
+ public Task BeginTransactionAsync(CancellationToken token = default)
+ => throw new NotSupportedException();
+
+ public void Add(object item) => throw new NotSupportedException();
+
+ public Task CountAsync(CancellationToken token = default)
+ => Task.FromResult(0);
+
+ public Task SaveAsync(CancellationToken token = default)
+ => Task.CompletedTask;
+
+ public Task FindByKeyAsync(string key, CancellationToken token = default)
+ => Task.FromResult(null);
+
+ public Task> ListAsync(CancellationToken token = default)
+ => Task.FromResult(new List());
+
+ public Task> GetConfigMapAsync(CancellationToken token = default)
+ => Task.FromResult(new Dictionary());
+
+ public Task GetConfigAsync(string key, CancellationToken token = default)
+ => Task.FromResult(null);
+
+ public Task UpsertAsync(string key, OAuthProviderConfig config, CancellationToken token = default)
+ => Task.CompletedTask;
+
+ public Task DeleteAsync(string key, CancellationToken token = default)
+ => Task.CompletedTask;
+
+ public Task> GetMetadataFieldsAsync(CancellationToken token = default)
+ => Task.FromResult(fields.ToList());
+
+ public Task UpdateMetadataFieldsAsync(List fields, CancellationToken token = default)
+ => Task.CompletedTask;
+ }
+}
diff --git a/src/GZCTF.Test/UnitTests/Utils/CodecExtensionsTests.cs b/src/GZCTF.Test/UnitTests/Utils/CodecExtensionsTests.cs
index b324c6388..deb409e8a 100644
--- a/src/GZCTF.Test/UnitTests/Utils/CodecExtensionsTests.cs
+++ b/src/GZCTF.Test/UnitTests/Utils/CodecExtensionsTests.cs
@@ -30,7 +30,7 @@ public void ToValidRFC1123String_ConvertsCorrectly(string input, string expected
public void ToMD5String_CalculatesCorrectHash(string input, string expectedHash)
{
// Act
- var hash = input.ToMD5String(false);
+ var hash = input.ToMD5String();
// Assert
Assert.Equal(expectedHash, hash);
diff --git a/src/GZCTF/ClientApp/src/Api.ts b/src/GZCTF/ClientApp/src/Api.ts
index 3db738b9c..c002b19a7 100644
--- a/src/GZCTF/ClientApp/src/Api.ts
+++ b/src/GZCTF/ClientApp/src/Api.ts
@@ -129,6 +129,18 @@ export enum TaskStatus {
Pending = "Pending",
}
+/** User metadata field type */
+export enum UserMetadataFieldType {
+ Text = "Text",
+ TextArea = "TextArea",
+ Number = "Number",
+ Email = "Email",
+ Url = "Url",
+ Phone = "Phone",
+ Date = "Date",
+ Select = "Select",
+}
+
/** User role enumeration */
export enum Role {
Banned = "Banned",
@@ -187,6 +199,8 @@ export type RegisterModel = ModelWithCaptcha & {
* @minLength 1
*/
email: string;
+ /** Optional metadata values for dynamic fields */
+ metadata?: Record;
};
export interface ModelWithCaptcha {
@@ -279,6 +293,8 @@ export interface ProfileUpdateModel {
* @maxLength 64
*/
stdNumber?: string | null;
+ /** User metadata (dynamic fields) */
+ metadata?: Record;
}
/** Password change */
@@ -318,6 +334,12 @@ export interface MailChangeModel {
newMail: string;
}
+/** Request payload for updating user metadata */
+export interface UserMetadataUpdateModel {
+ /** Metadata values keyed by configured field key */
+ metadata?: Record;
+}
+
/** Basic account information */
export interface ProfileUserInfoModel {
/**
@@ -341,6 +363,64 @@ export interface ProfileUserInfoModel {
stdNumber?: string | null;
/** Avatar URL */
avatar?: string | null;
+ /** User metadata (dynamic fields) */
+ metadata?: Record;
+}
+
+/** User metadata field configuration */
+export interface UserMetadataField {
+ /**
+ * Field key (e.g., "department", "studentId", "organization")
+ * @minLength 1
+ */
+ key: string;
+ /**
+ * Display name for the field
+ * @minLength 1
+ */
+ displayName: string;
+ /** Field type */
+ type?: UserMetadataFieldType;
+ /** Whether this field is required */
+ required?: boolean;
+ /** Whether this field is visible to users */
+ visible?: boolean;
+ /** Whether the field is locked for direct user edits */
+ locked?: boolean;
+ /** Placeholder text for the field */
+ placeholder?: string | null;
+ /**
+ * Maximum length for text fields
+ * @format int32
+ */
+ maxLength?: number | null;
+ /**
+ * Minimum value for number fields
+ * @format int32
+ */
+ minValue?: number | null;
+ /**
+ * Maximum value for number fields
+ * @format int32
+ */
+ maxValue?: number | null;
+ /** Validation pattern (regex) for the field */
+ pattern?: string | null;
+ /** Options for select fields */
+ options?: string[] | null;
+}
+
+/** Request response */
+export interface RequestResponseOfString {
+ /** Response message */
+ title?: string;
+ /** Data */
+ data?: string | null;
+ /**
+ * Status code
+ * @format int32
+ */
+ status?: number;
}
/** Global configuration update */
@@ -786,6 +866,46 @@ export interface LocalFile {
name: string;
}
+/** OAuth provider configuration */
+export interface OAuthProviderConfig {
+ /**
+ * OAuth provider ID
+ * @format int32
+ */
+ id?: number;
+ /** Whether this provider is enabled */
+ enabled?: boolean;
+ /** Client ID */
+ clientId?: string;
+ /** Client Secret */
+ clientSecret?: string;
+ /**
+ * Authorization endpoint
+ * @minLength 1
+ */
+ authorizationEndpoint: string;
+ /**
+ * Token endpoint
+ * @minLength 1
+ */
+ tokenEndpoint: string;
+ /**
+ * User information endpoint
+ * @minLength 1
+ */
+ userInformationEndpoint: string;
+ /** Display name for the provider */
+ displayName?: string | null;
+ /** Scopes to request */
+ scopes?: string[];
+ /**
+ * Field mapping from OAuth provider fields to user metadata fields
+ * Key: OAuth provider field name (e.g., "email", "name", "avatar_url")
+ * Value: User metadata field key
+ */
+ fieldMapping?: Record;
+}
+
/** This record represents the response for an API token request. */
export interface ApiTokenResponse {
token?: string;
@@ -2419,6 +2539,56 @@ export class Api<
...params,
}),
+ /**
+ * @description Use this API to get available OAuth providers for login.
+ *
+ * @tags Account
+ * @name AccountGetOAuthProviders
+ * @summary Get available OAuth providers
+ * @request GET:/api/account/oauth/providers
+ */
+ accountGetOAuthProviders: (params: RequestParams = {}) =>
+ this.request, any>({
+ path: `/api/account/oauth/providers`,
+ method: "GET",
+ format: "json",
+ ...params,
+ }),
+ /**
+ * @description Use this API to get available OAuth providers for login.
+ *
+ * @tags Account
+ * @name AccountGetOAuthProviders
+ * @summary Get available OAuth providers
+ * @request GET:/api/account/oauth/providers
+ */
+ useAccountGetOAuthProviders: (
+ options?: SWRConfiguration,
+ doFetch: boolean = true,
+ ) =>
+ useSWR, any>(
+ doFetch ? `/api/account/oauth/providers` : null,
+ options,
+ ),
+
+ /**
+ * @description Use this API to get available OAuth providers for login.
+ *
+ * @tags Account
+ * @name AccountGetOAuthProviders
+ * @summary Get available OAuth providers
+ * @request GET:/api/account/oauth/providers
+ */
+ mutateAccountGetOAuthProviders: (
+ data?: Record | Promise>,
+ options?: MutatorOptions,
+ ) =>
+ mutate>(
+ `/api/account/oauth/providers`,
+ data,
+ options,
+ ),
+
/**
* @description Use this API to log in to the account.
*
@@ -2471,6 +2641,183 @@ export class Api<
...params,
}),
+ /**
+ * @description Use this API to get configured user metadata fields.
+ *
+ * @tags Account
+ * @name AccountMetadataFields
+ * @summary Get user metadata field configuration
+ * @request GET:/api/account/metadatafields
+ */
+ accountMetadataFields: (params: RequestParams = {}) =>
+ this.request({
+ path: `/api/account/metadatafields`,
+ method: "GET",
+ format: "json",
+ ...params,
+ }),
+ /**
+ * @description Use this API to get configured user metadata fields.
+ *
+ * @tags Account
+ * @name AccountMetadataFields
+ * @summary Get user metadata field configuration
+ * @request GET:/api/account/metadatafields
+ */
+ useAccountMetadataFields: (
+ options?: SWRConfiguration,
+ doFetch: boolean = true,
+ ) =>
+ useSWR(
+ doFetch ? `/api/account/metadatafields` : null,
+ options,
+ ),
+
+ /**
+ * @description Use this API to get configured user metadata fields.
+ *
+ * @tags Account
+ * @name AccountMetadataFields
+ * @summary Get user metadata field configuration
+ * @request GET:/api/account/metadatafields
+ */
+ mutateAccountMetadataFields: (
+ data?: UserMetadataField[] | Promise,
+ options?: MutatorOptions,
+ ) =>
+ mutate(`/api/account/metadatafields`, data, options),
+
+ /**
+ * @description This endpoint handles OAuth callbacks from providers. Do not call directly.
+ *
+ * @tags Account
+ * @name AccountOAuthCallback
+ * @summary OAuth callback endpoint
+ * @request GET:/api/account/oauth/callback/{providerKey}
+ */
+ accountOAuthCallback: (
+ providerKey: string,
+ query?: {
+ /** Authorization code */
+ code?: string | null;
+ /** State parameter */
+ state?: string | null;
+ /** Error returned by provider */
+ error?: string | null;
+ },
+ params: RequestParams = {},
+ ) =>
+ this.request({
+ path: `/api/account/oauth/callback/${providerKey}`,
+ method: "GET",
+ query: query,
+ ...params,
+ }),
+ /**
+ * @description This endpoint handles OAuth callbacks from providers. Do not call directly.
+ *
+ * @tags Account
+ * @name AccountOAuthCallback
+ * @summary OAuth callback endpoint
+ * @request GET:/api/account/oauth/callback/{providerKey}
+ */
+ useAccountOAuthCallback: (
+ providerKey: string,
+ query?: {
+ /** Authorization code */
+ code?: string | null;
+ /** State parameter */
+ state?: string | null;
+ /** Error returned by provider */
+ error?: string | null;
+ },
+ options?: SWRConfiguration,
+ doFetch: boolean = true,
+ ) =>
+ useSWR(
+ doFetch ? [`/api/account/oauth/callback/${providerKey}`, query] : null,
+ options,
+ ),
+
+ /**
+ * @description This endpoint handles OAuth callbacks from providers. Do not call directly.
+ *
+ * @tags Account
+ * @name AccountOAuthCallback
+ * @summary OAuth callback endpoint
+ * @request GET:/api/account/oauth/callback/{providerKey}
+ */
+ mutateAccountOAuthCallback: (
+ providerKey: string,
+ query?: {
+ /** Authorization code */
+ code?: string | null;
+ /** State parameter */
+ state?: string | null;
+ /** Error returned by provider */
+ error?: string | null;
+ },
+ data?: File | Promise,
+ options?: MutatorOptions,
+ ) =>
+ mutate(
+ [`/api/account/oauth/callback/${providerKey}`, query],
+ data,
+ options,
+ ),
+
+ /**
+ * @description Use this API to initiate OAuth login with a provider. Returns the authorization URL.
+ *
+ * @tags Account
+ * @name AccountOAuthLogin
+ * @summary Initiate OAuth login
+ * @request GET:/api/account/oauth/login/{providerKey}
+ */
+ accountOAuthLogin: (providerKey: string, params: RequestParams = {}) =>
+ this.request({
+ path: `/api/account/oauth/login/${providerKey}`,
+ method: "GET",
+ format: "json",
+ ...params,
+ }),
+ /**
+ * @description Use this API to initiate OAuth login with a provider. Returns the authorization URL.
+ *
+ * @tags Account
+ * @name AccountOAuthLogin
+ * @summary Initiate OAuth login
+ * @request GET:/api/account/oauth/login/{providerKey}
+ */
+ useAccountOAuthLogin: (
+ providerKey: string,
+ options?: SWRConfiguration,
+ doFetch: boolean = true,
+ ) =>
+ useSWR(
+ doFetch ? `/api/account/oauth/login/${providerKey}` : null,
+ options,
+ ),
+
+ /**
+ * @description Use this API to initiate OAuth login with a provider. Returns the authorization URL.
+ *
+ * @tags Account
+ * @name AccountOAuthLogin
+ * @summary Initiate OAuth login
+ * @request GET:/api/account/oauth/login/{providerKey}
+ */
+ mutateAccountOAuthLogin: (
+ providerKey: string,
+ data?: RequestResponseOfString | Promise,
+ options?: MutatorOptions,
+ ) =>
+ mutate(
+ `/api/account/oauth/login/${providerKey}`,
+ data,
+ options,
+ ),
+
/**
* @description Use this API to reset the password. Email verification code is required.
*
@@ -2586,6 +2933,26 @@ export class Api<
...params,
}),
+ /**
+ * @description Allows user to edit unlocked metadata fields.
+ *
+ * @tags Account
+ * @name AccountUpdateMetadata
+ * @summary Update user metadata
+ * @request PUT:/api/account/metadata
+ */
+ accountUpdateMetadata: (
+ data: UserMetadataUpdateModel,
+ params: RequestParams = {},
+ ) =>
+ this.request({
+ path: `/api/account/metadata`,
+ method: "PUT",
+ body: data,
+ type: ContentType.Json,
+ ...params,
+ }),
+
/**
* @description Use this API to confirm email using the verification code.
*
@@ -2820,6 +3187,103 @@ export class Api<
options?: MutatorOptions,
) => mutate(`/api/admin/config`, data, options),
+ /**
+ * @description Use this API to get OAuth providers configuration, requires Admin permission
+ *
+ * @tags Admin
+ * @name AdminGetOAuthProviders
+ * @summary Get OAuth providers configuration
+ * @request GET:/api/admin/oauth
+ */
+ adminGetOAuthProviders: (params: RequestParams = {}) =>
+ this.request, RequestResponse>({
+ path: `/api/admin/oauth`,
+ method: "GET",
+ format: "json",
+ ...params,
+ }),
+ /**
+ * @description Use this API to get OAuth providers configuration, requires Admin permission
+ *
+ * @tags Admin
+ * @name AdminGetOAuthProviders
+ * @summary Get OAuth providers configuration
+ * @request GET:/api/admin/oauth
+ */
+ useAdminGetOAuthProviders: (
+ options?: SWRConfiguration,
+ doFetch: boolean = true,
+ ) =>
+ useSWR, RequestResponse>(
+ doFetch ? `/api/admin/oauth` : null,
+ options,
+ ),
+
+ /**
+ * @description Use this API to get OAuth providers configuration, requires Admin permission
+ *
+ * @tags Admin
+ * @name AdminGetOAuthProviders
+ * @summary Get OAuth providers configuration
+ * @request GET:/api/admin/oauth
+ */
+ mutateAdminGetOAuthProviders: (
+ data?:
+ | Record
+ | Promise>,
+ options?: MutatorOptions,
+ ) =>
+ mutate>(
+ `/api/admin/oauth`,
+ data,
+ options,
+ ),
+
+ /**
+ * @description Use this API to get user metadata fields configuration, requires Admin permission
+ *
+ * @tags Admin
+ * @name AdminGetUserMetadataFields
+ * @summary Get user metadata fields configuration
+ * @request GET:/api/admin/usermetadata
+ */
+ adminGetUserMetadataFields: (params: RequestParams = {}) =>
+ this.request({
+ path: `/api/admin/usermetadata`,
+ method: "GET",
+ format: "json",
+ ...params,
+ }),
+ /**
+ * @description Use this API to get user metadata fields configuration, requires Admin permission
+ *
+ * @tags Admin
+ * @name AdminGetUserMetadataFields
+ * @summary Get user metadata fields configuration
+ * @request GET:/api/admin/usermetadata
+ */
+ useAdminGetUserMetadataFields: (
+ options?: SWRConfiguration,
+ doFetch: boolean = true,
+ ) =>
+ useSWR(
+ doFetch ? `/api/admin/usermetadata` : null,
+ options,
+ ),
+
+ /**
+ * @description Use this API to get user metadata fields configuration, requires Admin permission
+ *
+ * @tags Admin
+ * @name AdminGetUserMetadataFields
+ * @summary Get user metadata fields configuration
+ * @request GET:/api/admin/usermetadata
+ */
+ mutateAdminGetUserMetadataFields: (
+ data?: UserMetadataField[] | Promise,
+ options?: MutatorOptions,
+ ) => mutate(`/api/admin/usermetadata`, data, options),
+
/**
* @description Use this API to get all container instances, requires Admin permission
*
@@ -3198,6 +3662,26 @@ export class Api<
...params,
}),
+ /**
+ * @description Use this API to update OAuth providers configuration, requires Admin permission
+ *
+ * @tags Admin
+ * @name AdminUpdateOAuthProviders
+ * @summary Update OAuth providers configuration
+ * @request PUT:/api/admin/oauth
+ */
+ adminUpdateOAuthProviders: (
+ data: Record,
+ params: RequestParams = {},
+ ) =>
+ this.request({
+ path: `/api/admin/oauth`,
+ method: "PUT",
+ body: data,
+ type: ContentType.Json,
+ ...params,
+ }),
+
/**
* @description Use this API to modify team information, requires Admin permission
*
@@ -3240,6 +3724,47 @@ export class Api<
...params,
}),
+ /**
+ * No description
+ *
+ * @tags Admin
+ * @name AdminUpdateUserMetadata
+ * @summary Update metadata for a specific user
+ * @request PUT:/api/admin/users/{userId}/metadata
+ */
+ adminUpdateUserMetadata: (
+ userId: string,
+ data: UserMetadataUpdateModel,
+ params: RequestParams = {},
+ ) =>
+ this.request({
+ path: `/api/admin/users/${userId}/metadata`,
+ method: "PUT",
+ body: data,
+ type: ContentType.Json,
+ ...params,
+ }),
+
+ /**
+ * @description Use this API to update user metadata fields configuration, requires Admin permission
+ *
+ * @tags Admin
+ * @name AdminUpdateUserMetadataFields
+ * @summary Update user metadata fields configuration
+ * @request PUT:/api/admin/usermetadata
+ */
+ adminUpdateUserMetadataFields: (
+ data: UserMetadataField[],
+ params: RequestParams = {},
+ ) =>
+ this.request({
+ path: `/api/admin/usermetadata`,
+ method: "PUT",
+ body: data,
+ type: ContentType.Json,
+ ...params,
+ }),
+
/**
* @description Use this API to get user information, requires Admin permission
*
diff --git a/src/GZCTF/Controllers/AccountController.cs b/src/GZCTF/Controllers/AccountController.cs
index 9678904e2..47c410f4a 100644
--- a/src/GZCTF/Controllers/AccountController.cs
+++ b/src/GZCTF/Controllers/AccountController.cs
@@ -1,16 +1,22 @@
-using System.Net.Mime;
+using System.ComponentModel.DataAnnotations;
+using System.Net.Mime;
using GZCTF.Middlewares;
using GZCTF.Models.Internal;
using GZCTF.Models.Request.Account;
using GZCTF.Repositories.Interface;
using GZCTF.Services;
+using GZCTF.Services.Cache;
using GZCTF.Services.Config;
using GZCTF.Services.Mail;
+using GZCTF.Services.OAuth;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
+using UserMetadataField = GZCTF.Models.Internal.UserMetadataField;
namespace GZCTF.Controllers;
@@ -30,6 +36,10 @@ public class AccountController(
IOptionsSnapshot globalConfig,
UserManager userManager,
SignInManager signInManager,
+ IOAuthProviderRepository oauthProviderRepository,
+ IOAuthService oauthService,
+ CacheHelper cacheHelper,
+ IUserMetadataService userMetadataService,
ILogger logger,
IStringLocalizer localizer) : ControllerBase
{
@@ -63,7 +73,23 @@ public async Task Register([FromBody] RegisterModel model, Cancel
if (string.IsNullOrWhiteSpace(password))
return BadRequest(new RequestResponse(localizer[nameof(Resources.Program.Model_PasswordRequired)]));
- var user = new UserInfo { UserName = model.UserName, Email = model.Email, Role = Role.User };
+ var metadataValidation = await userMetadataService.ValidateAsync(
+ model.Metadata,
+ null,
+ allowLockedWrites: false,
+ enforceLockedRequirements: false,
+ token);
+
+ if (!metadataValidation.IsValid)
+ return BadRequest(new RequestResponse(metadataValidation.Errors.First()));
+
+ var user = new UserInfo
+ {
+ UserName = model.UserName,
+ Email = model.Email,
+ Role = Role.User,
+ UserMetadata = metadataValidation.Values
+ };
user.UpdateByHttpContext(HttpContext);
@@ -343,6 +369,7 @@ public async Task LogOut()
/// Use this API to update username and description. User permissions required.
///
///
+ ///
/// User data updated successfully
/// Validation failed or user data update failed
/// Unauthorized
@@ -350,7 +377,7 @@ public async Task LogOut()
[RequireUser]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)]
- public async Task Update([FromBody] ProfileUpdateModel model)
+ public async Task Update([FromBody] ProfileUpdateModel model, CancellationToken token = default)
{
var user = await userManager.GetUserAsync(User);
@@ -367,6 +394,21 @@ public async Task Update([FromBody] ProfileUpdateModel model)
user, TaskStatus.Success);
}
+ if (model.Metadata is not null)
+ {
+ var metadataResult = await userMetadataService.ValidateAsync(
+ model.Metadata,
+ user!.UserMetadata,
+ allowLockedWrites: false,
+ enforceLockedRequirements: true,
+ token);
+
+ if (!metadataResult.IsValid)
+ return BadRequest(new RequestResponse(metadataResult.Errors.First()));
+
+ user.UserMetadata = metadataResult.Values;
+ }
+
user!.UpdateUserInfo(model);
var result = await userManager.UpdateAsync(user);
@@ -494,6 +536,45 @@ public async Task MailChangeConfirm([FromBody] AccountVerifyModel
return Ok();
}
+ ///
+ /// Update user metadata
+ ///
+ ///
+ /// Allows user to edit unlocked metadata fields.
+ ///
+ /// Metadata updated successfully
+ /// Validation failed
+ /// Unauthorized
+ [HttpPut("/api/Account/Metadata")]
+ [RequireUser]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status401Unauthorized)]
+ public async Task UpdateMetadata(
+ [FromBody] UserMetadataUpdateModel model,
+ CancellationToken token = default)
+ {
+ var user = await userManager.GetUserAsync(User);
+
+ var validation = await userMetadataService.ValidateAsync(
+ model.Metadata,
+ user!.UserMetadata,
+ allowLockedWrites: false,
+ enforceLockedRequirements: true,
+ token);
+
+ if (!validation.IsValid)
+ return BadRequest(new RequestResponse(validation.Errors.First()));
+
+ user.UserMetadata = validation.Values;
+ var result = await userManager.UpdateAsync(user);
+
+ if (!result.Succeeded)
+ return HandleIdentityError(result.Errors);
+
+ return Ok();
+ }
+
///
/// Get user information
///
@@ -560,6 +641,204 @@ public async Task Avatar(IFormFile file, CancellationToken token)
return Ok(avatar.Url());
}
+ ///
+ /// Get user metadata field configuration
+ ///
+ ///
+ /// Use this API to get configured user metadata fields.
+ ///
+ /// User metadata fields configuration retrieved successfully
+ [HttpGet("/api/Account/MetadataFields")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task MetadataFields(CancellationToken token = default)
+ {
+ var fields = await oauthProviderRepository.GetMetadataFieldsAsync(token);
+ return Ok(fields);
+ }
+
+ ///
+ /// Get available OAuth providers
+ ///
+ ///
+ /// Use this API to get available OAuth providers for login.
+ ///
+ /// Available OAuth providers
+ [HttpGet("/api/Account/OAuth/Providers")]
+ [ProducesResponseType(typeof(Dictionary), StatusCodes.Status200OK)]
+ public async Task GetOAuthProviders(CancellationToken token = default)
+ {
+ var providers = await oauthProviderRepository.ListAsync(token);
+
+ var available = providers
+ .Where(p => p.Enabled)
+ .ToDictionary(p => p.Key, p => p.DisplayName ?? p.Key);
+
+ return Ok(available);
+ }
+
+ ///
+ /// Initiate OAuth login
+ ///
+ ///
+ /// Use this API to initiate OAuth login with a provider. Returns the authorization URL.
+ ///
+ /// Provider identifier
+ /// Cancellation token
+ /// Authorization URL returned
+ /// Invalid provider or provider not enabled
+ [HttpGet("/api/Account/OAuth/Login/{providerKey}")]
+ [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)]
+ public async Task OAuthLogin(
+ [RegularExpression("^[a-zA-Z0-9_-]+$")]
+ string providerKey,
+ CancellationToken token = default)
+ {
+ var providerEntity = await oauthProviderRepository.FindByKeyAsync(providerKey, token);
+ if (providerEntity is null || !providerEntity.Enabled)
+ return BadRequest(new RequestResponse(localizer[nameof(Resources.Program.Account_UserNotExist)]));
+
+ // Generate state for CSRF protection
+ var state = Guid.NewGuid().ToString("N");
+ var cacheKey = CacheKey.OAuthState(state);
+
+ // Store state in cache for validation (10 minutes expiry)
+ await cacheHelper.SetStringAsync(
+ cacheKey,
+ providerKey,
+ new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) },
+ token);
+
+ var redirectUri =
+ $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}/api/Account/OAuth/Callback/{providerKey}";
+ var queryParameters = new Dictionary
+ {
+ ["client_id"] = providerEntity.ClientId,
+ ["redirect_uri"] = redirectUri,
+ ["response_type"] = "code",
+ ["state"] = state
+ };
+
+ var scopes = providerEntity.Scopes?.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
+ if (scopes is { Length: > 0 })
+ queryParameters["scope"] = string.Join(" ", scopes);
+
+ var authUrl = QueryHelpers.AddQueryString(providerEntity.AuthorizationEndpoint, queryParameters);
+
+ return Ok(new RequestResponse(
+ "OAuth authorization URL",
+ authUrl,
+ StatusCodes.Status200OK));
+ }
+
+ ///
+ /// OAuth callback endpoint
+ ///
+ ///
+ /// This endpoint handles OAuth callbacks from providers. Do not call directly.
+ ///
+ /// Provider identifier
+ /// Authorization code
+ /// State parameter
+ /// Error returned by provider
+ /// Cancellation token
+ /// Redirects to frontend with result
+ [HttpGet("/api/Account/OAuth/Callback/{providerKey}")]
+ public async Task OAuthCallback(
+ [RegularExpression("^[a-zA-Z0-9_-]+$")]
+ string providerKey,
+ [FromQuery] string? code,
+ [FromQuery] string? state,
+ [FromQuery] string? error,
+ CancellationToken token = default)
+ {
+ var providerEntity = await oauthProviderRepository.FindByKeyAsync(providerKey, token);
+ if (providerEntity is null)
+ return Redirect("/account/login?error=oauth_provider_missing");
+
+ if (string.IsNullOrWhiteSpace(state))
+ {
+ logger.SystemLog(
+ $"OAuth callback missing state for provider {providerEntity.Key}",
+ TaskStatus.Failed,
+ LogLevel.Warning);
+
+ return Redirect("/account/login?error=oauth_state_missing");
+ }
+
+ // Validate state
+ var cacheKey = CacheKey.OAuthState(state);
+ var storedProvider = await cacheHelper.GetStringAsync(cacheKey, token);
+ if (string.IsNullOrEmpty(storedProvider) ||
+ !string.Equals(storedProvider, providerKey, StringComparison.Ordinal))
+ {
+ logger.SystemLog(
+ $"OAuth callback state mismatch for provider {providerEntity.Key}",
+ TaskStatus.Failed,
+ LogLevel.Warning);
+
+ return Redirect("/account/login?error=oauth_state_mismatch");
+ }
+
+ // Clear state
+ await cacheHelper.RemoveAsync(cacheKey, token);
+
+ if (!string.IsNullOrEmpty(error))
+ {
+ logger.SystemLog(
+ $"OAuth error from provider {providerEntity.Key}: {error}",
+ TaskStatus.Failed,
+ LogLevel.Warning);
+
+ return Redirect("/account/login?error=oauth_error");
+ }
+
+ if (string.IsNullOrEmpty(code))
+ return Redirect("/account/login?error=oauth_no_code");
+
+ try
+ {
+ // Exchange code for user info
+ var redirectUri =
+ $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}/api/Account/OAuth/Callback/{providerKey}";
+ var oauthUser = await oauthService.ExchangeCodeForUserInfoAsync(providerEntity, code, redirectUri, token);
+
+ if (oauthUser is null)
+ {
+ logger.SystemLog(
+ $"Failed to exchange OAuth code for provider {providerEntity.Key}",
+ TaskStatus.Failed,
+ LogLevel.Warning);
+
+ return Redirect("/account/login?error=oauth_exchange_failed");
+ }
+
+ // Get or create user
+ var (user, isNewUser) = await oauthService.GetOrCreateUserFromOAuthAsync(providerEntity, oauthUser, token);
+
+ // Sign in the user
+ await signInManager.SignInAsync(user, isPersistent: true);
+
+ logger.SystemLog(
+ $"User {user.Email} {(isNewUser ? "registered and" : "")} logged in via OAuth provider {providerEntity.Key}",
+ TaskStatus.Success,
+ LogLevel.Information);
+
+ // Redirect to appropriate page
+ return Redirect(isNewUser ? "/account/profile?firstLogin=true" : "/");
+ }
+ catch (OAuthLoginException ex)
+ {
+ logger.LogWarning(ex, "OAuth login failed for provider {Provider}", providerEntity.Key);
+ return Redirect($"/account/login?error={ex.QueryCode}");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error processing OAuth callback for provider {Provider}", providerEntity.Key);
+ return Redirect($"/account/login?error=oauth_processing_error");
+ }
+ }
+
string GetEmailLink(string action, string token, string? email)
=> $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}/account/{action}?" +
$"token={token}&email={Codec.Base64.Encode(email)}";
diff --git a/src/GZCTF/Controllers/AdminController.cs b/src/GZCTF/Controllers/AdminController.cs
index a3dc0d50e..c3b679177 100644
--- a/src/GZCTF/Controllers/AdminController.cs
+++ b/src/GZCTF/Controllers/AdminController.cs
@@ -8,6 +8,7 @@
using GZCTF.Models.Request.Admin;
using GZCTF.Models.Request.Info;
using GZCTF.Repositories.Interface;
+using GZCTF.Services;
using GZCTF.Services.Cache;
using GZCTF.Services.Config;
using GZCTF.Storage.Interface;
@@ -16,6 +17,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
+using UserMetadataField = GZCTF.Models.Internal.UserMetadataField;
namespace GZCTF.Controllers;
@@ -40,6 +42,8 @@ public class AdminController(
IContainerRepository containerRepository,
IServiceProvider serviceProvider,
IParticipationRepository participationRepository,
+ IOAuthProviderRepository oauthProviderRepository,
+ IUserMetadataService metadataService,
IStringLocalizer localizer) : ControllerBase
{
///
@@ -691,6 +695,156 @@ public async Task Files([FromQuery][Range(0, 500)] int count = 50
CancellationToken token = default) =>
Ok(new ArrayResponse(await blobService.GetBlobs(count, skip, token)));
+ ///
+ /// Get user metadata fields configuration
+ ///
+ ///
+ /// Use this API to get user metadata fields configuration, requires Admin permission
+ ///
+ /// User metadata fields configuration
+ /// Unauthorized user
+ /// Forbidden
+ [HttpGet("UserMetadata")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task GetUserMetadataFields(
+ CancellationToken token = default)
+ {
+ var fields = await oauthProviderRepository.GetMetadataFieldsAsync(token);
+ return Ok(fields);
+ }
+
+ ///
+ /// Update user metadata fields configuration
+ ///
+ ///
+ /// Use this API to update user metadata fields configuration, requires Admin permission
+ ///
+ /// User metadata fields
+ /// Cancellation token
+ /// User metadata fields updated successfully
+ /// Invalid request
+ /// Unauthorized user
+ /// Forbidden
+ [HttpPut("UserMetadata")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)]
+ public async Task UpdateUserMetadataFields(
+ [FromBody] List fields,
+ CancellationToken token = default)
+ {
+ await oauthProviderRepository.UpdateMetadataFieldsAsync(fields, token);
+
+ logger.SystemLog(
+ "User metadata fields updated",
+ TaskStatus.Success,
+ LogLevel.Information);
+
+ return Ok();
+ }
+
+ ///
+ /// Update metadata for a specific user
+ ///
+ [HttpPut("Users/{userId:guid}/Metadata")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)]
+ public async Task UpdateUserMetadata(
+ Guid userId,
+ [FromBody] UserMetadataUpdateModel model,
+ CancellationToken token = default)
+ {
+ var user = await userManager.FindByIdAsync(userId.ToString());
+ if (user is null)
+ return NotFound(new RequestResponse(localizer[nameof(Resources.Program.Account_UserNotExist)]));
+
+ var validation = await metadataService.ValidateAsync(
+ model.Metadata,
+ user.UserMetadata,
+ allowLockedWrites: true,
+ enforceLockedRequirements: true,
+ token);
+
+ if (!validation.IsValid)
+ return BadRequest(new RequestResponse(validation.Errors.First()));
+
+ user.UserMetadata = validation.Values;
+ var result = await userManager.UpdateAsync(user);
+
+ if (!result.Succeeded)
+ return HandleIdentityError(result.Errors);
+
+ logger.SystemLog(
+ $"User metadata updated for {user.Email}",
+ TaskStatus.Success,
+ LogLevel.Information);
+
+ return Ok();
+ }
+
+ ///
+ /// Get OAuth providers configuration
+ ///
+ ///
+ /// Use this API to get OAuth providers configuration, requires Admin permission
+ ///
+ /// OAuth providers configuration
+ /// Unauthorized user
+ /// Forbidden
+ [HttpGet("OAuth")]
+ [ProducesResponseType(typeof(Dictionary), StatusCodes.Status200OK)]
+ public async Task GetOAuthProviders(
+ CancellationToken token = default)
+ {
+ var providers = await oauthProviderRepository.GetConfigMapAsync(token);
+ return Ok(providers);
+ }
+
+ ///
+ /// Update OAuth providers configuration
+ ///
+ ///
+ /// Use this API to update OAuth providers configuration, requires Admin permission
+ ///
+ /// OAuth providers configuration
+ /// Cancellation token
+ /// OAuth providers updated successfully
+ /// Invalid request
+ /// Unauthorized user
+ /// Forbidden
+ [HttpPut("OAuth")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)]
+ public async Task UpdateOAuthProviders(
+ [FromBody] Dictionary providers,
+ CancellationToken token = default)
+ {
+ foreach (var (key, config) in providers)
+ {
+ try
+ {
+ await oauthProviderRepository.UpsertAsync(key, config, token);
+ }
+ catch (ValidationException ex)
+ {
+ logger.LogWarning(ex, "Invalid OAuth provider configuration supplied: {Key}", key);
+ return BadRequest(new RequestResponse(ex.Message, StatusCodes.Status400BadRequest));
+ }
+ catch (ArgumentException ex)
+ {
+ logger.LogWarning(ex, "Invalid OAuth provider key supplied: {Key}", key);
+ return BadRequest(new RequestResponse(ex.Message, StatusCodes.Status400BadRequest));
+ }
+ }
+
+ logger.SystemLog(
+ "OAuth providers updated",
+ TaskStatus.Success,
+ LogLevel.Information);
+
+ return Ok();
+ }
+
IActionResult HandleIdentityError(IEnumerable errors) =>
BadRequest(new RequestResponse(errors.FirstOrDefault()?.Description ??
localizer[nameof(Resources.Program.Identity_UnknownError)]));
diff --git a/src/GZCTF/Controllers/AssetsController.cs b/src/GZCTF/Controllers/AssetsController.cs
index 451b52fff..0131bc741 100644
--- a/src/GZCTF/Controllers/AssetsController.cs
+++ b/src/GZCTF/Controllers/AssetsController.cs
@@ -2,7 +2,6 @@
using System.Net.Mime;
using GZCTF.Middlewares;
using GZCTF.Repositories.Interface;
-using GZCTF.Storage;
using GZCTF.Storage.Interface;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
diff --git a/src/GZCTF/Controllers/GameController.cs b/src/GZCTF/Controllers/GameController.cs
index 2835cc19e..fba99fea6 100644
--- a/src/GZCTF/Controllers/GameController.cs
+++ b/src/GZCTF/Controllers/GameController.cs
@@ -814,7 +814,7 @@ public async Task Participations([FromRoute] int id, Cancellation
/// Downloads the game scoreboard; requires Monitor permission
///
/// Game ID
- ///
+ /// Generates scoreboard spreadsheets.
///
/// Successfully downloaded game scoreboard
/// Invalid operation
@@ -862,7 +862,7 @@ public async Task ScoreboardSheet([FromRoute] int id, [FromServic
/// Downloads all submissions of the game; requires Monitor permission
///
/// Game ID
- ///
+ /// Generates submission spreadsheets.
///
/// Successfully downloaded all game submissions
/// Invalid operation
diff --git a/src/GZCTF/Extensions/Startup/IdentityExtension.cs b/src/GZCTF/Extensions/Startup/IdentityExtension.cs
index 1791fc596..d3de17a59 100644
--- a/src/GZCTF/Extensions/Startup/IdentityExtension.cs
+++ b/src/GZCTF/Extensions/Startup/IdentityExtension.cs
@@ -24,6 +24,12 @@ public void ConfigureIdentity()
auth.SlidingExpiration = true;
auth.ExpireTimeSpan = TimeSpan.FromDays(7);
});
+
+ options.ExternalCookie?.Configure(auth =>
+ {
+ auth.Cookie.Name = "GZCTF_External";
+ auth.ExpireTimeSpan = TimeSpan.FromMinutes(10);
+ });
});
builder.Services.AddIdentityCore(options =>
@@ -44,6 +50,9 @@ public void ConfigureIdentity()
builder.Services.Configure(o =>
o.TokenLifespan = TimeSpan.FromHours(3)
);
+
+ // Configure OAuth support
+ builder.ConfigureOAuth();
}
}
}
diff --git a/src/GZCTF/Extensions/Startup/OAuthExtension.cs b/src/GZCTF/Extensions/Startup/OAuthExtension.cs
new file mode 100644
index 000000000..b33bbb9cd
--- /dev/null
+++ b/src/GZCTF/Extensions/Startup/OAuthExtension.cs
@@ -0,0 +1,11 @@
+using GZCTF.Services.OAuth;
+
+namespace GZCTF.Extensions.Startup;
+
+static class OAuthExtension
+{
+ public static void ConfigureOAuth(this WebApplicationBuilder builder)
+ {
+ builder.Services.AddScoped();
+ }
+}
diff --git a/src/GZCTF/Extensions/Startup/ServicesExtension.cs b/src/GZCTF/Extensions/Startup/ServicesExtension.cs
index 8c5e187d0..bcbc73297 100644
--- a/src/GZCTF/Extensions/Startup/ServicesExtension.cs
+++ b/src/GZCTF/Extensions/Startup/ServicesExtension.cs
@@ -80,6 +80,8 @@ internal void AddCustomServices()
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+ builder.Services.AddScoped();
+ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/src/GZCTF/Middlewares/PrivilegeAuthentication.cs b/src/GZCTF/Middlewares/PrivilegeAuthentication.cs
index c9ab0b3e2..ff4904f37 100644
--- a/src/GZCTF/Middlewares/PrivilegeAuthentication.cs
+++ b/src/GZCTF/Middlewares/PrivilegeAuthentication.cs
@@ -1,5 +1,4 @@
using System.Security.Claims;
-using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
diff --git a/src/GZCTF/Migrations/20251126152404_AddOAuthSupport.Designer.cs b/src/GZCTF/Migrations/20251126152404_AddOAuthSupport.Designer.cs
new file mode 100644
index 000000000..437e741f5
--- /dev/null
+++ b/src/GZCTF/Migrations/20251126152404_AddOAuthSupport.Designer.cs
@@ -0,0 +1,1975 @@
+//
+using System;
+using System.Net;
+using GZCTF.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace GZCTF.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20251126152404_AddOAuthSupport")]
+ partial class AddOAuthSupport
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("GZCTF.Models.Data.ApiToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasComment("The unique identifier for the token.");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("The timestamp when the token was created.");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasComment("The ID of the user who created the token.");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("The timestamp when the token expires. A null value means it never expires.");
+
+ b.Property("IsRevoked")
+ .HasColumnType("boolean")
+ .HasComment("Indicates whether the token has been revoked.");
+
+ b.Property("LastUsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasComment("The timestamp when the token was last used.");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasComment("A user-friendly name for the token.");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatorId");
+
+ b.ToTable("ApiTokens", t =>
+ {
+ t.HasComment("Stores API tokens for programmatic access.");
+ });
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.Attachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("LocalFileId")
+ .HasColumnType("integer");
+
+ b.Property("RemoteUrl")
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LocalFileId");
+
+ b.ToTable("Attachments");
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.CheatInfo", b =>
+ {
+ b.Property("SubmissionId")
+ .HasColumnType("integer");
+
+ b.Property("GameId")
+ .HasColumnType("integer");
+
+ b.Property("SourceTeamId")
+ .HasColumnType("integer");
+
+ b.Property("SubmitTeamId")
+ .HasColumnType("integer");
+
+ b.HasKey("SubmissionId");
+
+ b.HasIndex("GameId");
+
+ b.HasIndex("SourceTeamId");
+
+ b.HasIndex("SubmissionId")
+ .IsUnique();
+
+ b.HasIndex("SubmitTeamId");
+
+ b.ToTable("CheatInfo");
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.Config", b =>
+ {
+ b.Property("ConfigKey")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("ConfigKey");
+
+ b.ToTable("Configs");
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.Container", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ContainerId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ExerciseInstanceId")
+ .HasColumnType("integer");
+
+ b.Property("ExpectStopAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GameInstanceId")
+ .HasColumnType("integer");
+
+ b.Property("IP")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Image")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsProxy")
+ .HasColumnType("boolean");
+
+ b.Property("Port")
+ .HasColumnType("integer");
+
+ b.Property("PublicIP")
+ .HasColumnType("text");
+
+ b.Property("PublicPort")
+ .HasColumnType("integer");
+
+ b.Property("StartedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExerciseInstanceId")
+ .IsUnique();
+
+ b.HasIndex("GameInstanceId")
+ .IsUnique();
+
+ b.ToTable("Containers");
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.Division", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DefaultPermissions")
+ .HasColumnType("integer");
+
+ b.Property("GameId")
+ .HasColumnType("integer");
+
+ b.Property("InviteCode")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(31)
+ .HasColumnType("character varying(31)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameId");
+
+ b.ToTable("Divisions");
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.DivisionChallengeConfig", b =>
+ {
+ b.Property("ChallengeId")
+ .HasColumnType("integer");
+
+ b.Property("DivisionId")
+ .HasColumnType("integer");
+
+ b.Property("Permissions")
+ .HasColumnType("integer");
+
+ b.HasKey("ChallengeId", "DivisionId");
+
+ b.HasIndex("DivisionId");
+
+ b.ToTable("DivisionChallengeConfig");
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.ExerciseChallenge", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AttachmentId")
+ .HasColumnType("integer");
+
+ b.Property("CPUCount")
+ .HasColumnType("integer");
+
+ b.Property("Category")
+ .HasColumnType("smallint");
+
+ b.Property("ConcurrencyToken")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("ContainerExposePort")
+ .HasColumnType("integer");
+
+ b.Property("ContainerImage")
+ .HasColumnType("text");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Credit")
+ .HasColumnType("boolean");
+
+ b.Property("DeadlineUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Difficulty")
+ .HasColumnType("smallint");
+
+ b.Property("FileName")
+ .HasColumnType("text");
+
+ b.Property("FlagTemplate")
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("Hints")
+ .HasColumnType("text");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("MemoryLimit")
+ .HasColumnType("integer");
+
+ b.Property("StorageLimit")
+ .HasColumnType("integer");
+
+ b.Property("SubmissionLimit")
+ .HasColumnType("integer");
+
+ b.Property("Tags")
+ .HasColumnType("text");
+
+ b.Property("TestContainerId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AttachmentId");
+
+ b.HasIndex("TestContainerId");
+
+ b.ToTable("ExerciseChallenges");
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.ExerciseDependency", b =>
+ {
+ b.Property("SourceId")
+ .HasColumnType("integer");
+
+ b.Property("TargetId")
+ .HasColumnType("integer");
+
+ b.HasKey("SourceId", "TargetId");
+
+ b.HasIndex("SourceId");
+
+ b.HasIndex("TargetId");
+
+ b.ToTable("ExerciseDependencies");
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.ExerciseInstance", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("ExerciseId")
+ .HasColumnType("integer");
+
+ b.Property("ConcurrencyToken")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("ContainerId")
+ .HasColumnType("uuid");
+
+ b.Property("FlagId")
+ .HasColumnType("integer");
+
+ b.Property("IsLoaded")
+ .HasColumnType("boolean");
+
+ b.Property("LastContainerOperation")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SolveTimeUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("UserId", "ExerciseId");
+
+ b.HasIndex("ContainerId")
+ .IsUnique();
+
+ b.HasIndex("ExerciseId");
+
+ b.HasIndex("FlagId");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ExerciseInstances");
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.FirstSolve", b =>
+ {
+ b.Property("ParticipationId")
+ .HasColumnType("integer");
+
+ b.Property("ChallengeId")
+ .HasColumnType("integer");
+
+ b.Property("SubmissionId")
+ .HasColumnType("integer");
+
+ b.HasKey("ParticipationId", "ChallengeId");
+
+ b.HasIndex("ChallengeId");
+
+ b.HasIndex("SubmissionId")
+ .IsUnique();
+
+ b.ToTable("FirstSolves");
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.FlagContext", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AttachmentId")
+ .HasColumnType("integer");
+
+ b.Property("ChallengeId")
+ .HasColumnType("integer");
+
+ b.Property("ExerciseId")
+ .HasColumnType("integer");
+
+ b.Property("Flag")
+ .IsRequired()
+ .HasMaxLength(127)
+ .HasColumnType("character varying(127)");
+
+ b.Property("IsOccupied")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AttachmentId");
+
+ b.HasIndex("ChallengeId");
+
+ b.HasIndex("ExerciseId");
+
+ b.ToTable("FlagContexts");
+ });
+
+ modelBuilder.Entity("GZCTF.Models.Data.Game", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property