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("AcceptWithoutReview") + .HasColumnType("boolean"); + + b.Property("BloodBonusValue") + .HasColumnType("bigint") + .HasColumnName("BloodBonus"); + + b.Property("ContainerCountLimit") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndTimeUtc") + .HasColumnType("timestamp with time zone") + .HasJsonPropertyName("end"); + + b.Property("Hidden") + .HasColumnType("boolean"); + + b.Property("InviteCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PosterHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PracticeMode") + .HasColumnType("boolean"); + + b.Property("PrivateKey") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone") + .HasJsonPropertyName("start"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TeamMemberCountLimit") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("WriteupDeadline") + .HasColumnType("timestamp with time zone"); + + b.Property("WriteupNote") + .IsRequired() + .HasColumnType("text"); + + b.Property("WriteupRequired") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.GameChallenge", 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("DeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Difficulty") + .HasColumnType("double precision"); + + b.Property("DisableBloodBonus") + .HasColumnType("boolean"); + + b.Property("EnableTrafficCapture") + .HasColumnType("boolean"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FlagTemplate") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("Hints") + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("MemoryLimit") + .HasColumnType("integer"); + + b.Property("MinScoreRate") + .HasColumnType("double precision"); + + b.Property("OriginalScore") + .HasColumnType("integer"); + + b.Property("StorageLimit") + .HasColumnType("integer"); + + b.Property("SubmissionLimit") + .HasColumnType("integer"); + + b.Property("TestContainerId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("AttachmentId"); + + b.HasIndex("GameId"); + + b.HasIndex("TestContainerId"); + + b.ToTable("GameChallenges"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.GameEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("PublishTimeUtc") + .HasColumnType("timestamp with time zone") + .HasJsonPropertyName("time"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Values") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("GameEvents"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.GameInstance", b => + { + b.Property("ChallengeId") + .HasColumnType("integer"); + + b.Property("ParticipationId") + .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.HasKey("ChallengeId", "ParticipationId"); + + b.HasIndex("ContainerId") + .IsUnique(); + + b.HasIndex("FlagId"); + + b.HasIndex("ParticipationId"); + + b.ToTable("GameInstances"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.GameNotice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("PublishTimeUtc") + .HasColumnType("timestamp with time zone") + .HasJsonPropertyName("time"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("Values") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("GameNotices"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.LocalFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceCount") + .HasColumnType("bigint"); + + b.Property("UploadTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Hash"); + + b.ToTable("Files"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.LogModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.Property("Logger") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RemoteIP") + .HasColumnType("inet"); + + b.Property("Status") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("TimeUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.HasKey("Id"); + + b.ToTable("Logs"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.OAuthProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorizationEndpoint") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("ClientSecret") + .IsRequired() + .HasMaxLength(800) + .HasColumnType("character varying(800)"); + + b.Property("DisplayName") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("FieldMapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(31) + .HasColumnType("character varying(31)"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TokenEndpoint") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserInformationEndpoint") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("OAuthProviders"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Participation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DivisionId") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("WriteupId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DivisionId"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("WriteupId"); + + b.HasIndex("TeamId", "GameId"); + + b.ToTable("Participations"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Post", b => + { + b.Property("Id") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsPinned") + .HasColumnType("boolean"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdateTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("ChallengeId") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("ParticipationId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmitTimeUtc") + .HasColumnType("timestamp with time zone") + .HasJsonPropertyName("time"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("GameId"); + + b.HasIndex("ParticipationId"); + + b.HasIndex("UserId"); + + b.HasIndex("TeamId", "ChallengeId", "GameId"); + + b.ToTable("Submissions"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Bio") + .HasMaxLength(72) + .HasColumnType("character varying(72)"); + + b.Property("CaptainId") + .HasColumnType("uuid"); + + b.Property("InviteToken") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Locked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("CaptainId"); + + b.ToTable("Teams"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.UserInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AvatarHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("ExerciseVisible") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IP") + .IsRequired() + .HasColumnType("inet"); + + b.Property("LastSignedInUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastVisitedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OAuthProviderId") + .HasColumnType("integer"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RealName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RegisterTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("StdNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserMetadata") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("UserName") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("OAuthProviderId"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("GZCTF.Models.Data.UserMetadataField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Locked") + .HasColumnType("boolean"); + + b.Property("MaxLength") + .HasColumnType("integer"); + + b.Property("MaxValue") + .HasColumnType("integer"); + + b.Property("MinValue") + .HasColumnType("integer"); + + b.Property("Options") + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Pattern") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Placeholder") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Visible") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("UserMetadataFields"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.UserParticipation", b => + { + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ParticipationId") + .HasColumnType("integer"); + + b.HasKey("GameId", "TeamId", "UserId"); + + b.HasIndex("ParticipationId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId", "GameId") + .IsUnique(); + + b.ToTable("UserParticipations"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeamUserInfo", b => + { + b.Property("MembersId") + .HasColumnType("uuid"); + + b.Property("TeamsId") + .HasColumnType("integer"); + + b.HasKey("MembersId", "TeamsId"); + + b.HasIndex("TeamsId"); + + b.ToTable("TeamUserInfo"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.ApiToken", b => + { + b.HasOne("GZCTF.Models.Data.UserInfo", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Attachment", b => + { + b.HasOne("GZCTF.Models.Data.LocalFile", "LocalFile") + .WithMany() + .HasForeignKey("LocalFileId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("LocalFile"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.CheatInfo", b => + { + b.HasOne("GZCTF.Models.Data.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Participation", "SourceTeam") + .WithMany() + .HasForeignKey("SourceTeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Submission", "Submission") + .WithMany() + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Participation", "SubmitTeam") + .WithMany() + .HasForeignKey("SubmitTeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("SourceTeam"); + + b.Navigation("Submission"); + + b.Navigation("SubmitTeam"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Division", b => + { + b.HasOne("GZCTF.Models.Data.Game", "Game") + .WithMany("Divisions") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.DivisionChallengeConfig", b => + { + b.HasOne("GZCTF.Models.Data.GameChallenge", "Challenge") + .WithMany("DivisionConfigs") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Division", "Division") + .WithMany("ChallengeConfigs") + .HasForeignKey("DivisionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + + b.Navigation("Division"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.ExerciseChallenge", b => + { + b.HasOne("GZCTF.Models.Data.Attachment", "Attachment") + .WithMany() + .HasForeignKey("AttachmentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.Data.Container", "TestContainer") + .WithMany() + .HasForeignKey("TestContainerId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Attachment"); + + b.Navigation("TestContainer"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.ExerciseDependency", b => + { + b.HasOne("GZCTF.Models.Data.ExerciseChallenge", "Source") + .WithMany() + .HasForeignKey("SourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.ExerciseChallenge", "Target") + .WithMany() + .HasForeignKey("TargetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Source"); + + b.Navigation("Target"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.ExerciseInstance", b => + { + b.HasOne("GZCTF.Models.Data.Container", "Container") + .WithOne("ExerciseInstance") + .HasForeignKey("GZCTF.Models.Data.ExerciseInstance", "ContainerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.Data.ExerciseChallenge", "Exercise") + .WithMany() + .HasForeignKey("ExerciseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.FlagContext", "FlagContext") + .WithMany() + .HasForeignKey("FlagId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.Data.UserInfo", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Container"); + + b.Navigation("Exercise"); + + b.Navigation("FlagContext"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.FirstSolve", b => + { + b.HasOne("GZCTF.Models.Data.GameChallenge", "Challenge") + .WithMany("FirstSolves") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Participation", "Participation") + .WithMany("FirstSolves") + .HasForeignKey("ParticipationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Submission", "Submission") + .WithMany() + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + + b.Navigation("Participation"); + + b.Navigation("Submission"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.FlagContext", b => + { + b.HasOne("GZCTF.Models.Data.Attachment", "Attachment") + .WithMany() + .HasForeignKey("AttachmentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.Data.GameChallenge", "Challenge") + .WithMany("Flags") + .HasForeignKey("ChallengeId"); + + b.HasOne("GZCTF.Models.Data.ExerciseChallenge", "Exercise") + .WithMany("Flags") + .HasForeignKey("ExerciseId"); + + b.Navigation("Attachment"); + + b.Navigation("Challenge"); + + b.Navigation("Exercise"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.GameChallenge", b => + { + b.HasOne("GZCTF.Models.Data.Attachment", "Attachment") + .WithMany() + .HasForeignKey("AttachmentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.Data.Game", "Game") + .WithMany("Challenges") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Container", "TestContainer") + .WithMany() + .HasForeignKey("TestContainerId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Attachment"); + + b.Navigation("Game"); + + b.Navigation("TestContainer"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.GameEvent", b => + { + b.HasOne("GZCTF.Models.Data.Game", "Game") + .WithMany("GameEvents") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.UserInfo", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Game"); + + b.Navigation("Team"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.GameInstance", b => + { + b.HasOne("GZCTF.Models.Data.GameChallenge", "Challenge") + .WithMany("Instances") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Container", "Container") + .WithOne("GameInstance") + .HasForeignKey("GZCTF.Models.Data.GameInstance", "ContainerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.Data.FlagContext", "FlagContext") + .WithMany() + .HasForeignKey("FlagId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.Data.Participation", "Participation") + .WithMany("Instances") + .HasForeignKey("ParticipationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + + b.Navigation("Container"); + + b.Navigation("FlagContext"); + + b.Navigation("Participation"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.GameNotice", b => + { + b.HasOne("GZCTF.Models.Data.Game", "Game") + .WithMany("GameNotices") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Participation", b => + { + b.HasOne("GZCTF.Models.Data.Division", "Division") + .WithMany() + .HasForeignKey("DivisionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.Data.Game", "Game") + .WithMany("Participations") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Team", "Team") + .WithMany("Participations") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.LocalFile", "Writeup") + .WithMany() + .HasForeignKey("WriteupId"); + + b.Navigation("Division"); + + b.Navigation("Game"); + + b.Navigation("Team"); + + b.Navigation("Writeup"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Post", b => + { + b.HasOne("GZCTF.Models.Data.UserInfo", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Submission", b => + { + b.HasOne("GZCTF.Models.Data.GameChallenge", "GameChallenge") + .WithMany("Submissions") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Game", "Game") + .WithMany("Submissions") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Participation", "Participation") + .WithMany("Submissions") + .HasForeignKey("ParticipationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.UserInfo", "User") + .WithMany("Submissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Game"); + + b.Navigation("GameChallenge"); + + b.Navigation("Participation"); + + b.Navigation("Team"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Team", b => + { + b.HasOne("GZCTF.Models.Data.UserInfo", "Captain") + .WithMany() + .HasForeignKey("CaptainId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Captain"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.UserInfo", b => + { + b.HasOne("GZCTF.Models.Data.OAuthProvider", "OAuthProvider") + .WithMany() + .HasForeignKey("OAuthProviderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("OAuthProvider"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.UserParticipation", b => + { + b.HasOne("GZCTF.Models.Data.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Participation", "Participation") + .WithMany("Members") + .HasForeignKey("ParticipationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.UserInfo", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("Participation"); + + b.Navigation("Team"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("GZCTF.Models.Data.UserInfo", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("GZCTF.Models.Data.UserInfo", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.UserInfo", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("GZCTF.Models.Data.UserInfo", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeamUserInfo", b => + { + b.HasOne("GZCTF.Models.Data.UserInfo", null) + .WithMany() + .HasForeignKey("MembersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Data.Team", null) + .WithMany() + .HasForeignKey("TeamsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Container", b => + { + b.Navigation("ExerciseInstance"); + + b.Navigation("GameInstance"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Division", b => + { + b.Navigation("ChallengeConfigs"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.ExerciseChallenge", b => + { + b.Navigation("Flags"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("Divisions"); + + b.Navigation("GameEvents"); + + b.Navigation("GameNotices"); + + b.Navigation("Participations"); + + b.Navigation("Submissions"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.GameChallenge", b => + { + b.Navigation("DivisionConfigs"); + + b.Navigation("FirstSolves"); + + b.Navigation("Flags"); + + b.Navigation("Instances"); + + b.Navigation("Submissions"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Participation", b => + { + b.Navigation("FirstSolves"); + + b.Navigation("Instances"); + + b.Navigation("Members"); + + b.Navigation("Submissions"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Team", b => + { + b.Navigation("Participations"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.UserInfo", b => + { + b.Navigation("Submissions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GZCTF/Migrations/20251126152404_AddOAuthSupport.cs b/src/GZCTF/Migrations/20251126152404_AddOAuthSupport.cs new file mode 100644 index 000000000..8b6477745 --- /dev/null +++ b/src/GZCTF/Migrations/20251126152404_AddOAuthSupport.cs @@ -0,0 +1,128 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GZCTF.Migrations +{ + /// + public partial class AddOAuthSupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OAuthProviderId", + table: "AspNetUsers", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "UserMetadata", + table: "AspNetUsers", + type: "jsonb", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "OAuthProviders", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Key = table.Column(type: "character varying(31)", maxLength: 31, nullable: false), + Enabled = table.Column(type: "boolean", nullable: false), + ClientId = table.Column(type: "character varying(400)", maxLength: 400, nullable: false), + ClientSecret = table.Column(type: "character varying(800)", maxLength: 800, nullable: false), + AuthorizationEndpoint = table.Column(type: "character varying(400)", maxLength: 400, nullable: false), + TokenEndpoint = table.Column(type: "character varying(400)", maxLength: 400, nullable: false), + UserInformationEndpoint = table.Column(type: "character varying(400)", maxLength: 400, nullable: false), + DisplayName = table.Column(type: "character varying(80)", maxLength: 80, nullable: true), + Scopes = table.Column(type: "jsonb", nullable: false), + FieldMapping = table.Column(type: "jsonb", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OAuthProviders", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UserMetadataFields", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Key = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + DisplayName = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + Type = table.Column(type: "integer", nullable: false), + Required = table.Column(type: "boolean", nullable: false), + Visible = table.Column(type: "boolean", nullable: false), + Locked = table.Column(type: "boolean", nullable: false), + Placeholder = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + MaxLength = table.Column(type: "integer", nullable: true), + MinValue = table.Column(type: "integer", nullable: true), + MaxValue = table.Column(type: "integer", nullable: true), + Pattern = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Options = table.Column(type: "jsonb", nullable: true), + Order = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserMetadataFields", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUsers_OAuthProviderId", + table: "AspNetUsers", + column: "OAuthProviderId"); + + migrationBuilder.CreateIndex( + name: "IX_OAuthProviders_Key", + table: "OAuthProviders", + column: "Key", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UserMetadataFields_Key", + table: "UserMetadataFields", + column: "Key", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_AspNetUsers_OAuthProviders_OAuthProviderId", + table: "AspNetUsers", + column: "OAuthProviderId", + principalTable: "OAuthProviders", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AspNetUsers_OAuthProviders_OAuthProviderId", + table: "AspNetUsers"); + + migrationBuilder.DropTable( + name: "OAuthProviders"); + + migrationBuilder.DropTable( + name: "UserMetadataFields"); + + migrationBuilder.DropIndex( + name: "IX_AspNetUsers_OAuthProviderId", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "OAuthProviderId", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "UserMetadata", + table: "AspNetUsers"); + } + } +} diff --git a/src/GZCTF/Migrations/AppDbContextModelSnapshot.cs b/src/GZCTF/Migrations/AppDbContextModelSnapshot.cs index cc3194daa..c686e08ce 100644 --- a/src/GZCTF/Migrations/AppDbContextModelSnapshot.cs +++ b/src/GZCTF/Migrations/AppDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("ProductVersion", "10.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -462,7 +462,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EndTimeUtc") .HasColumnType("timestamp with time zone") - .HasAnnotation("Relational:JsonPropertyName", "end"); + .HasJsonPropertyName("end"); b.Property("Hidden") .HasColumnType("boolean"); @@ -490,7 +490,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("StartTimeUtc") .HasColumnType("timestamp with time zone") - .HasAnnotation("Relational:JsonPropertyName", "start"); + .HasJsonPropertyName("start"); b.Property("Summary") .IsRequired() @@ -628,7 +628,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PublishTimeUtc") .HasColumnType("timestamp with time zone") - .HasAnnotation("Relational:JsonPropertyName", "time"); + .HasJsonPropertyName("time"); b.Property("TeamId") .HasColumnType("integer"); @@ -705,7 +705,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PublishTimeUtc") .HasColumnType("timestamp with time zone") - .HasAnnotation("Relational:JsonPropertyName", "time"); + .HasJsonPropertyName("time"); b.Property("Type") .HasColumnType("smallint"); @@ -798,6 +798,70 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Logs"); }); + modelBuilder.Entity("GZCTF.Models.Data.OAuthProvider", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorizationEndpoint") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("ClientSecret") + .IsRequired() + .HasMaxLength(800) + .HasColumnType("character varying(800)"); + + b.Property("DisplayName") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("FieldMapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(31) + .HasColumnType("character varying(31)"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TokenEndpoint") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserInformationEndpoint") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("OAuthProviders"); + }); + modelBuilder.Entity("GZCTF.Models.Data.Participation", b => { b.Property("Id") @@ -906,7 +970,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SubmitTimeUtc") .HasColumnType("timestamp with time zone") - .HasAnnotation("Relational:JsonPropertyName", "time"); + .HasJsonPropertyName("time"); b.Property("TeamId") .HasColumnType("integer"); @@ -1026,6 +1090,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(256) .HasColumnType("character varying(256)"); + b.Property("OAuthProviderId") + .HasColumnType("integer"); + b.Property("PasswordHash") .HasColumnType("text"); @@ -1057,6 +1124,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("TwoFactorEnabled") .HasColumnType("boolean"); + b.Property("UserMetadata") + .IsRequired() + .HasColumnType("jsonb"); + b.Property("UserName") .HasMaxLength(16) .HasColumnType("character varying(16)"); @@ -1070,9 +1141,72 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsUnique() .HasDatabaseName("UserNameIndex"); + b.HasIndex("OAuthProviderId"); + b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("GZCTF.Models.Data.UserMetadataField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Locked") + .HasColumnType("boolean"); + + b.Property("MaxLength") + .HasColumnType("integer"); + + b.Property("MaxValue") + .HasColumnType("integer"); + + b.Property("MinValue") + .HasColumnType("integer"); + + b.Property("Options") + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("Pattern") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Placeholder") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("Visible") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("UserMetadataFields"); + }); + modelBuilder.Entity("GZCTF.Models.Data.UserParticipation", b => { b.Property("GameId") @@ -1656,6 +1790,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Captain"); }); + modelBuilder.Entity("GZCTF.Models.Data.UserInfo", b => + { + b.HasOne("GZCTF.Models.Data.OAuthProvider", "OAuthProvider") + .WithMany() + .HasForeignKey("OAuthProviderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("OAuthProvider"); + }); + modelBuilder.Entity("GZCTF.Models.Data.UserParticipation", b => { b.HasOne("GZCTF.Models.Data.Game", "Game") diff --git a/src/GZCTF/Models/AppDbContext.cs b/src/GZCTF/Models/AppDbContext.cs index fa87013ff..788dbc501 100644 --- a/src/GZCTF/Models/AppDbContext.cs +++ b/src/GZCTF/Models/AppDbContext.cs @@ -41,6 +41,8 @@ public class AppDbContext(DbContextOptions options) : public DbSet ExerciseDependencies { get; set; } = null!; public DbSet DataProtectionKeys { get; set; } = null!; public DbSet ApiTokens { get; set; } = null!; + public DbSet OAuthProviders { get; set; } = null!; + public DbSet UserMetadataFields { get; set; } = null!; static ValueConverter GetJsonConverter() where T : class, new() => new( @@ -48,6 +50,12 @@ public class AppDbContext(DbContextOptions options) : v => JsonSerializer.Deserialize(v, JsonOptions) ); + static ValueConverter GetJsonConverterNonNull() where T : class, new() => + new( + v => JsonSerializer.Serialize(v, JsonOptions), + v => JsonSerializer.Deserialize(v, JsonOptions) ?? new() + ); + static ValueComparer GetEnumerableComparer() where T : notnull where TList : IEnumerable, new() => @@ -60,9 +68,10 @@ protected override void OnModelCreating(ModelBuilder builder) base.OnModelCreating(builder); var listConverter = GetJsonConverter>(); - var setConverter = GetJsonConverter>(); + var listConverterNonNull = GetJsonConverterNonNull>(); var listComparer = GetEnumerableComparer, string>(); - var setComparer = GetEnumerableComparer, string>(); + var metadataConverter = GetJsonConverterNonNull>(); + var metadataComparer = GetEnumerableComparer, KeyValuePair>(); builder.Entity(entity => { @@ -75,6 +84,18 @@ protected override void OnModelCreating(ModelBuilder builder) entity.Property(e => e.ExerciseVisible) .HasDefaultValue(true); + entity.Property(e => e.UserMetadata) + .HasColumnType("jsonb") + .HasConversion(metadataConverter) + .Metadata + .SetValueComparer(metadataComparer); + + entity.HasOne(e => e.OAuthProvider) + .WithMany() + .HasPrincipalKey(p => p.Id) + .HasForeignKey(e => e.OAuthProviderId) + .OnDelete(DeleteBehavior.SetNull); + entity.HasMany(e => e.Submissions) .WithOne(e => e.User) .HasForeignKey(e => e.UserId) @@ -439,5 +460,38 @@ protected override void OnModelCreating(ModelBuilder builder) .HasConversion() .HasMaxLength(Limits.MaxLogStatusLength); }); + + builder.Entity(entity => + { + entity.Property(e => e.Scopes) + .HasColumnType("jsonb") + .HasConversion(listConverterNonNull) + .Metadata + .SetValueComparer(listComparer); + + entity.Property(e => e.FieldMapping) + .HasColumnType("jsonb") + .HasConversion(metadataConverter) + .Metadata + .SetValueComparer(metadataComparer); + + entity.HasIndex(e => e.Key) + .IsUnique(); + }); + + builder.Entity(entity => + { + entity.Property(e => e.Type) + .HasConversion(); + + entity.Property(e => e.Options) + .HasColumnType("jsonb") + .HasConversion( + v => JsonSerializer.Serialize(v ?? new List(), JsonOptions), + v => JsonSerializer.Deserialize>(v, JsonOptions)); + + entity.HasIndex(e => e.Key) + .IsUnique(); + }); } } diff --git a/src/GZCTF/Models/Data/Container.cs b/src/GZCTF/Models/Data/Container.cs index 507bfb90c..2c9c12619 100644 --- a/src/GZCTF/Models/Data/Container.cs +++ b/src/GZCTF/Models/Data/Container.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json; -using GZCTF.Storage; using GZCTF.Storage.Interface; using Microsoft.EntityFrameworkCore; diff --git a/src/GZCTF/Models/Data/Division.cs b/src/GZCTF/Models/Data/Division.cs index f0f2dd1bc..afde40d5c 100644 --- a/src/GZCTF/Models/Data/Division.cs +++ b/src/GZCTF/Models/Data/Division.cs @@ -25,7 +25,7 @@ public partial class Division /// The name of the division. /// [Required] - [MaxLength(Limits.MaxDivisionNameLength)] + [MaxLength(Limits.MaxShortIdLength)] public string Name { get; set; } = string.Empty; /// diff --git a/src/GZCTF/Models/Data/OAuthProvider.cs b/src/GZCTF/Models/Data/OAuthProvider.cs new file mode 100644 index 000000000..9cc0faadf --- /dev/null +++ b/src/GZCTF/Models/Data/OAuthProvider.cs @@ -0,0 +1,122 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using GZCTF.Models.Internal; +using Microsoft.EntityFrameworkCore; + +namespace GZCTF.Models.Data; + +/// +/// OAuth provider configuration stored in database +/// +[Index(nameof(Key), IsUnique = true)] +public class OAuthProvider +{ + /// + /// Unique ID + /// + [Key] + public int Id { get; set; } + + /// + /// Provider key (google, github, microsoft, etc.) + /// + [Required] + [MaxLength(Limits.MaxShortIdLength)] + [RegularExpression("^[a-zA-Z0-9_-]+$")] + public string Key { get; set; } = string.Empty; + + /// + /// Whether this provider is enabled + /// + public bool Enabled { get; set; } + + /// + /// Client ID + /// + [MaxLength(Limits.MaxOAuthClientIdLength)] + public string ClientId { get; set; } = string.Empty; + + /// + /// Client Secret (encrypted) + /// + [MaxLength(Limits.MaxOAuthClientSecretLength)] + public string ClientSecret { get; set; } = string.Empty; + + /// + /// Authorization endpoint + /// + [Required] + [MaxLength(Limits.MaxUrlLength)] + public string AuthorizationEndpoint { get; set; } = string.Empty; + + /// + /// Token endpoint + /// + [Required] + [MaxLength(Limits.MaxUrlLength)] + public string TokenEndpoint { get; set; } = string.Empty; + + /// + /// User information endpoint + /// + [Required] + [MaxLength(Limits.MaxUrlLength)] + public string UserInformationEndpoint { get; set; } = string.Empty; + + /// + /// Display name for the provider + /// + [MaxLength(Limits.MaxDisplayNameLength)] + public string? DisplayName { get; set; } + + /// + /// Scopes to request (stored as JSON) + /// + [Column(TypeName = "jsonb")] + public List Scopes { get; set; } = []; + + /// + /// Field mapping from OAuth provider fields to user metadata fields (stored as JSON) + /// Key: OAuth provider field name, Value: User metadata field key + /// + [Column(TypeName = "jsonb")] + public Dictionary FieldMapping { get; set; } = new(); + + /// + /// Last updated time + /// + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + internal OAuthProviderConfig ToConfig() => new() + { + Id = Id, + Enabled = Enabled, + ClientId = ClientId, + ClientSecret = ClientSecret, + AuthorizationEndpoint = AuthorizationEndpoint, + TokenEndpoint = TokenEndpoint, + UserInformationEndpoint = UserInformationEndpoint, + DisplayName = DisplayName, + Scopes = Scopes, + FieldMapping = FieldMapping + }; + + internal void UpdateFromConfig(OAuthProviderConfig config) + { + Enabled = config.Enabled; + ClientId = config.ClientId; + ClientSecret = config.ClientSecret; + AuthorizationEndpoint = config.AuthorizationEndpoint; + TokenEndpoint = config.TokenEndpoint; + UserInformationEndpoint = config.UserInformationEndpoint; + DisplayName = config.DisplayName; + Scopes = config.Scopes; + FieldMapping = config.FieldMapping; + UpdatedAt = DateTimeOffset.UtcNow; + } + + private static readonly ValidationContext KeyValidationContext = + new(new OAuthProvider()) { MemberName = nameof(Key) }; + + public static void ValidateKey(string key) => Validator.ValidateProperty(key, KeyValidationContext); +} diff --git a/src/GZCTF/Models/Data/UserInfo.cs b/src/GZCTF/Models/Data/UserInfo.cs index 5a1822f03..cf7c1673c 100644 --- a/src/GZCTF/Models/Data/UserInfo.cs +++ b/src/GZCTF/Models/Data/UserInfo.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Net; -using System.Text.Json.Serialization; using GZCTF.Models.Request.Account; using GZCTF.Models.Request.Admin; using MemoryPack; @@ -70,6 +69,18 @@ public partial class UserInfo : IdentityUser /// public bool ExerciseVisible { get; set; } = true; + /// + /// Associated OAuth provider identifier if account created via OAuth + /// + public int? OAuthProviderId { get; set; } + + /// + /// User metadata stored as JSON (flexible user fields) + /// + [Column(TypeName = "jsonb")] + [MemoryPackIgnore] + public Dictionary UserMetadata { get; set; } = new(); + [NotMapped] [MemoryPackIgnore] public string? AvatarUrl => AvatarHash is null ? null : $"/assets/{AvatarHash}/avatar"; @@ -122,6 +133,7 @@ internal void UpdateUserInfo(ProfileUpdateModel model) PhoneNumber = model.Phone ?? PhoneNumber; RealName = model.RealName ?? RealName; StdNumber = model.StdNumber ?? StdNumber; + } #region Db Relationship @@ -132,6 +144,13 @@ internal void UpdateUserInfo(ProfileUpdateModel model) [MaxLength(Limits.FileHashLength)] public string? AvatarHash { get; set; } + /// + /// Navigation reference to the OAuth provider linked with this user + /// + [MemoryPackIgnore] + public OAuthProvider? OAuthProvider { get; set; } + + /// /// Personal submission records /// diff --git a/src/GZCTF/Models/Data/UserMetadataField.cs b/src/GZCTF/Models/Data/UserMetadataField.cs new file mode 100644 index 000000000..eb4dab861 --- /dev/null +++ b/src/GZCTF/Models/Data/UserMetadataField.cs @@ -0,0 +1,121 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using GZCTF.Models.Internal; + +namespace GZCTF.Models.Data; + +/// +/// User metadata field configuration +/// +public class UserMetadataField +{ + /// + /// Unique ID + /// + [Key] + public int Id { get; set; } + + /// + /// Field key (e.g., "department", "studentId", "organization") + /// + [Required] + [MaxLength(Limits.MaxUserMetadataKeyLength)] + public string Key { get; set; } = string.Empty; + + /// + /// Display name for the field + /// + [Required] + [MaxLength(Limits.MaxDisplayNameLength)] + public string DisplayName { get; set; } = string.Empty; + + /// + /// Field type + /// + public UserMetadataFieldType Type { get; set; } = UserMetadataFieldType.Text; + + /// + /// Whether this field is required + /// + public bool Required { get; set; } + + /// + /// Whether this field is visible to users + /// + public bool Visible { get; set; } = true; + + /// + /// Whether this field can only be edited by privileged flows (admin/provider) + /// + public bool Locked { get; set; } + + /// + /// Placeholder text for the field + /// + [MaxLength(Limits.MaxUserMetadataPlaceholderLength)] + public string? Placeholder { get; set; } + + /// + /// Maximum length for text fields + /// + public int? MaxLength { get; set; } + + /// + /// Minimum value for number fields + /// + public int? MinValue { get; set; } + + /// + /// Maximum value for number fields + /// + public int? MaxValue { get; set; } + + /// + /// Validation pattern (regex) for the field + /// + [MaxLength(Limits.MaxRegexPatternLength)] + public string? Pattern { get; set; } + + /// + /// Options for select fields (stored as JSON) + /// + [Column(TypeName = "jsonb")] + public List? Options { get; set; } + + /// + /// Display order + /// + public int Order { get; set; } + + internal Internal.UserMetadataField ToField() => new() + { + Key = Key, + DisplayName = DisplayName, + Type = Type, + Required = Required, + Visible = Visible, + Locked = Locked, + Placeholder = Placeholder, + MaxLength = MaxLength, + MinValue = MinValue, + MaxValue = MaxValue, + Pattern = Pattern, + Options = Options + }; + + internal void UpdateFromField(Internal.UserMetadataField field) + { + Key = field.Key; + DisplayName = field.DisplayName; + Type = field.Type; + Required = field.Required; + Visible = field.Visible; + Locked = field.Locked; + Placeholder = field.Placeholder; + MaxLength = field.MaxLength; + MinValue = field.MinValue; + MaxValue = field.MaxValue; + Pattern = field.Pattern; + Options = field.Options; + } +} diff --git a/src/GZCTF/Models/Internal/OAuthConfig.cs b/src/GZCTF/Models/Internal/OAuthConfig.cs new file mode 100644 index 000000000..0c21aee1f --- /dev/null +++ b/src/GZCTF/Models/Internal/OAuthConfig.cs @@ -0,0 +1,180 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace GZCTF.Models.Internal; + +/// +/// User metadata field type +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UserMetadataFieldType +{ + /// + /// Single-line text input + /// + Text, + + /// + /// Multi-line text input + /// + TextArea, + + /// + /// Number input + /// + Number, + + /// + /// Email input + /// + Email, + + /// + /// URL input + /// + Url, + + /// + /// Phone number input + /// + Phone, + + /// + /// Date input + /// + Date, + + /// + /// Dropdown select + /// + Select +} + +/// +/// User metadata field configuration +/// +public class UserMetadataField +{ + /// + /// Field key (e.g., "department", "studentId", "organization") + /// + [Required] + public string Key { get; set; } = string.Empty; + + /// + /// Display name for the field + /// + [Required] + public string DisplayName { get; set; } = string.Empty; + + /// + /// Field type + /// + public UserMetadataFieldType Type { get; set; } = UserMetadataFieldType.Text; + + /// + /// Whether this field is required + /// + public bool Required { get; set; } + + /// + /// Whether this field is visible to users + /// + public bool Visible { get; set; } = true; + + /// + /// Whether the field is locked for direct user edits + /// + public bool Locked { get; set; } + + /// + /// Placeholder text for the field + /// + public string? Placeholder { get; set; } + + /// + /// Maximum length for text fields + /// + public int? MaxLength { get; set; } + + /// + /// Minimum value for number fields + /// + public int? MinValue { get; set; } + + /// + /// Maximum value for number fields + /// + public int? MaxValue { get; set; } + + /// + /// Validation pattern (regex) for the field + /// + public string? Pattern { get; set; } + + /// + /// Options for select fields + /// + public List? Options { get; set; } +} + +/// +/// OAuth provider configuration +/// +public class OAuthProviderConfig +{ + /// + /// OAuth provider ID + /// + public int Id { get; set; } + + /// + /// Whether this provider is enabled + /// + public bool Enabled { get; set; } + + /// + /// Client ID + /// + public string ClientId { get; set; } = string.Empty; + + /// + /// Client Secret + /// + public string ClientSecret { get; set; } = string.Empty; + + /// + /// Authorization endpoint + /// + [Required] + public string AuthorizationEndpoint { get; set; } = string.Empty; + + /// + /// Token endpoint + /// + [Required] + public string TokenEndpoint { get; set; } = string.Empty; + + /// + /// User information endpoint + /// + [Required] + public string UserInformationEndpoint { get; set; } = string.Empty; + + /// + /// Display name for the provider + /// + public string? DisplayName { get; set; } + + /// + /// Scopes to request + /// + public List Scopes { get; set; } = []; + + /// + /// 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 + /// + public Dictionary FieldMapping { get; set; } = new(); +} diff --git a/src/GZCTF/Models/Limits.cs b/src/GZCTF/Models/Limits.cs index 9e8035a7d..e288a15e1 100644 --- a/src/GZCTF/Models/Limits.cs +++ b/src/GZCTF/Models/Limits.cs @@ -3,92 +3,127 @@ namespace GZCTF.Models; public static class Limits { /// - /// Flag 最大长度 + /// Max length of flag /// public const int MaxFlagLength = 127; /// - /// Flag 模板最大长度, 为替换操作预留空间 + /// Max length of flag template, reserved for replacement /// public const int MaxFlagTemplateLength = 120; /// - /// 队伍名称最大长度 + /// Max length of team name /// public const int MaxTeamNameLength = 20; /// - /// 队伍签名最大长度(前端展示原因) + /// Max length of team bio (for frontend display) /// public const int MaxTeamBioLength = 72; /// - /// 个人数据存储最大长度(签名与真实姓名) + /// Max length of user data (bio and real name) /// public const int MaxUserDataLength = 128; /// - /// 学工号最大长度 + /// Max length of student number /// public const int MaxStdNumberLength = 64; /// - /// 用户名最小长度 + /// Min length of username /// public const int MinUserNameLength = 3; /// - /// 用户名最大长度 + /// Max length of username /// public const int MaxUserNameLength = 15; /// - /// 密码最小长度 + /// Min length of password /// public const int MinPasswordLength = 6; /// - /// 文件哈希长度 + /// Length of file hash /// public const int FileHashLength = 64; /// - /// 比赛公私钥长度 + /// Length of game public/private key /// public const int GameKeyLength = 63; /// - /// 邀请 Token 长度 + /// Length of invite token /// public const int InviteTokenLength = 32; /// - /// 最大 IP 长度 + /// Max length of IP address /// public const int MaxIPLength = 40; /// - /// 最大标题长度 + /// Max length of post title /// public const int MaxPostTitleLength = 50; /// - /// 最大日志等级长度 + /// Max length of log level /// public const int MaxLogLevelLength = 15; /// - /// 最大日志记录源长度 + /// Max length of logger source /// public const int MaxLoggerLength = 250; /// - /// 最大日志状态长度 + /// Max length of log status /// public const int MaxLogStatusLength = 10; /// - /// 分组名称最大长度 + /// Max length of short identifier (e.g. OAuth provider key, Division name) /// - public const int MaxDivisionNameLength = 31; + public const int MaxShortIdLength = 31; + + /// + /// Max length of URL + /// + public const int MaxUrlLength = 400; + + /// + /// Max length of OAuth client ID + /// + public const int MaxOAuthClientIdLength = 400; + + /// + /// Max length of OAuth client secret + /// + public const int MaxOAuthClientSecretLength = 800; + + /// + /// Max length of display name + /// + public const int MaxDisplayNameLength = 80; + + /// + /// Max length of user metadata key + /// + public const int MaxUserMetadataKeyLength = 40; + + /// + /// Max length of user metadata placeholder + /// + public const int MaxUserMetadataPlaceholderLength = 200; + + /// + /// Max length of regex pattern + /// + public const int MaxRegexPatternLength = 400; } diff --git a/src/GZCTF/Models/Request/Account/OAuthLinkModel.cs b/src/GZCTF/Models/Request/Account/OAuthLinkModel.cs new file mode 100644 index 000000000..3ab43d043 --- /dev/null +++ b/src/GZCTF/Models/Request/Account/OAuthLinkModel.cs @@ -0,0 +1,38 @@ +namespace GZCTF.Models.Request.Account; + +/// +/// OAuth provider linking request +/// +public class OAuthLinkModel +{ + /// + /// Provider name (google, github, microsoft, etc.) + /// + public string Provider { get; set; } = string.Empty; +} + +/// +/// OAuth callback model +/// +public class OAuthCallbackModel +{ + /// + /// Authorization code + /// + public string Code { get; set; } = string.Empty; + + /// + /// State parameter for CSRF protection + /// + public string State { get; set; } = string.Empty; + + /// + /// Error from OAuth provider + /// + public string? Error { get; set; } + + /// + /// Error description from OAuth provider + /// + public string? ErrorDescription { get; set; } +} diff --git a/src/GZCTF/Models/Request/Account/ProfileUpdateModel.cs b/src/GZCTF/Models/Request/Account/ProfileUpdateModel.cs index 6f7f97aef..279d1cac3 100644 --- a/src/GZCTF/Models/Request/Account/ProfileUpdateModel.cs +++ b/src/GZCTF/Models/Request/Account/ProfileUpdateModel.cs @@ -43,4 +43,9 @@ public class ProfileUpdateModel [MaxLength(Limits.MaxStdNumberLength, ErrorMessageResourceName = nameof(Resources.Program.Model_StdNumberTooLong), ErrorMessageResourceType = typeof(Resources.Program))] public string? StdNumber { get; set; } + + /// + /// User metadata (dynamic fields) + /// + public Dictionary? Metadata { get; set; } } diff --git a/src/GZCTF/Models/Request/Account/ProfileUserInfoModel.cs b/src/GZCTF/Models/Request/Account/ProfileUserInfoModel.cs index de4e2245a..cc7a16c02 100644 --- a/src/GZCTF/Models/Request/Account/ProfileUserInfoModel.cs +++ b/src/GZCTF/Models/Request/Account/ProfileUserInfoModel.cs @@ -1,6 +1,4 @@ -using System.ComponentModel.DataAnnotations; - -namespace GZCTF.Models.Request.Account; +namespace GZCTF.Models.Request.Account; /// /// Basic account information @@ -52,6 +50,11 @@ public class ProfileUserInfoModel /// public string? Avatar { get; set; } + /// + /// User metadata (dynamic fields) + /// + public Dictionary? Metadata { get; set; } + internal static ProfileUserInfoModel FromUserInfo(UserInfo user) => new() { @@ -63,6 +66,7 @@ internal static ProfileUserInfoModel FromUserInfo(UserInfo user) => Phone = user.PhoneNumber, Avatar = user.AvatarUrl, StdNumber = user.StdNumber, - Role = user.Role + Role = user.Role, + Metadata = user.UserMetadata }; } diff --git a/src/GZCTF/Models/Request/Account/RegisterModel.cs b/src/GZCTF/Models/Request/Account/RegisterModel.cs index 3a78cb20b..ddfb403d4 100644 --- a/src/GZCTF/Models/Request/Account/RegisterModel.cs +++ b/src/GZCTF/Models/Request/Account/RegisterModel.cs @@ -34,4 +34,9 @@ public class RegisterModel : ModelWithCaptcha [EmailAddress(ErrorMessageResourceName = nameof(Resources.Program.Model_EmailMalformed), ErrorMessageResourceType = typeof(Resources.Program))] public string Email { get; set; } = string.Empty; + + /// + /// Optional metadata values for dynamic fields + /// + public Dictionary? Metadata { get; set; } } diff --git a/src/GZCTF/Models/Request/Account/UserMetadataUpdateModel.cs b/src/GZCTF/Models/Request/Account/UserMetadataUpdateModel.cs new file mode 100644 index 000000000..e35a6d40f --- /dev/null +++ b/src/GZCTF/Models/Request/Account/UserMetadataUpdateModel.cs @@ -0,0 +1,12 @@ +namespace GZCTF.Models.Request.Account; + +/// +/// Request payload for updating user metadata +/// +public class UserMetadataUpdateModel +{ + /// + /// Metadata values keyed by configured field key + /// + public Dictionary Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/GZCTF/Models/Request/Admin/OAuthConfigEditModel.cs b/src/GZCTF/Models/Request/Admin/OAuthConfigEditModel.cs new file mode 100644 index 000000000..d39f5c7b1 --- /dev/null +++ b/src/GZCTF/Models/Request/Admin/OAuthConfigEditModel.cs @@ -0,0 +1,52 @@ +using GZCTF.Models.Internal; +using UserMetadataField = GZCTF.Models.Internal.UserMetadataField; + +namespace GZCTF.Models.Request.Admin; + +/// +/// OAuth provider configuration model for admin +/// +public class OAuthProviderEditModel +{ + /// + /// Provider key (google, github, microsoft, etc.) + /// + public string Key { get; set; } = string.Empty; + + /// + /// Provider configuration + /// + public OAuthProviderConfig Config { get; set; } = new(); +} + +/// +/// User metadata field configuration model for admin +/// +public class UserMetadataFieldEditModel +{ + /// + /// Metadata fields configuration + /// + public List Fields { get; set; } = []; +} + +/// +/// OAuth configuration model for admin +/// +public class OAuthConfigEditModel +{ + /// + /// OAuth providers configuration + /// + public Dictionary? Providers { get; set; } + + /// + /// User metadata fields configuration + /// + public List? UserMetadataFields { get; set; } + + /// + /// Whether to allow users to link multiple OAuth accounts + /// + public bool? AllowMultipleProviders { get; set; } +} diff --git a/src/GZCTF/Models/Request/Admin/TeamWithDetailedUserInfo.cs b/src/GZCTF/Models/Request/Admin/TeamWithDetailedUserInfo.cs index 17ede72d8..017934c31 100644 --- a/src/GZCTF/Models/Request/Admin/TeamWithDetailedUserInfo.cs +++ b/src/GZCTF/Models/Request/Admin/TeamWithDetailedUserInfo.cs @@ -1,5 +1,4 @@ -using System.ComponentModel.DataAnnotations; -using GZCTF.Models.Request.Account; +using GZCTF.Models.Request.Account; namespace GZCTF.Models.Request.Admin; diff --git a/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs b/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs index ee29577fc..2b580c610 100644 --- a/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs +++ b/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.Linq; using GZCTF.Models.Request.Game; namespace GZCTF.Models.Request.Edit; diff --git a/src/GZCTF/Models/Request/Edit/DivisionEditModel.cs b/src/GZCTF/Models/Request/Edit/DivisionEditModel.cs index bcc2ae1be..0904cc118 100644 --- a/src/GZCTF/Models/Request/Edit/DivisionEditModel.cs +++ b/src/GZCTF/Models/Request/Edit/DivisionEditModel.cs @@ -8,7 +8,7 @@ public class DivisionCreateModel /// The name of the division. /// [Required] - [MaxLength(Limits.MaxDivisionNameLength)] + [MaxLength(Limits.MaxShortIdLength)] public string Name { get; set; } = string.Empty; /// @@ -33,7 +33,7 @@ public class DivisionEditModel /// /// The name of the division. /// - [MaxLength(Limits.MaxDivisionNameLength)] + [MaxLength(Limits.MaxShortIdLength)] public string? Name { get; set; } = string.Empty; /// diff --git a/src/GZCTF/Models/Request/Exercise/ExerciseDetailModel.cs b/src/GZCTF/Models/Request/Exercise/ExerciseDetailModel.cs index 304bf78c4..4a96c9908 100644 --- a/src/GZCTF/Models/Request/Exercise/ExerciseDetailModel.cs +++ b/src/GZCTF/Models/Request/Exercise/ExerciseDetailModel.cs @@ -42,7 +42,7 @@ public class ExerciseDetailModel /// /// Additional tags for the exercise /// - public List? Tags { get; set; } = new(); + public List? Tags { get; set; } = []; /// /// Exercise type diff --git a/src/GZCTF/Models/Request/Exercise/ExerciseInfoModel.cs b/src/GZCTF/Models/Request/Exercise/ExerciseInfoModel.cs index e00ac4707..0e4f7e0ac 100644 --- a/src/GZCTF/Models/Request/Exercise/ExerciseInfoModel.cs +++ b/src/GZCTF/Models/Request/Exercise/ExerciseInfoModel.cs @@ -28,7 +28,7 @@ public class ExerciseInfoModel /// /// Additional tags for the exercise /// - public List? Tags { get; set; } = new(); + public List? Tags { get; set; } = []; /// /// Exercise points diff --git a/src/GZCTF/Models/Request/Game/ChallengeTrafficModel.cs b/src/GZCTF/Models/Request/Game/ChallengeTrafficModel.cs index 3088df64f..0eabfb410 100644 --- a/src/GZCTF/Models/Request/Game/ChallengeTrafficModel.cs +++ b/src/GZCTF/Models/Request/Game/ChallengeTrafficModel.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations; -using GZCTF.Storage; using GZCTF.Storage.Interface; namespace GZCTF.Models.Request.Game; diff --git a/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs index 1034bb81e..7d965da0b 100644 --- a/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs +++ b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs @@ -1,4 +1,3 @@ -using GZCTF.Storage; using GZCTF.Storage.Interface; namespace GZCTF.Models.Request.Game; diff --git a/src/GZCTF/Models/Request/Info/UserMetadataFieldsModel.cs b/src/GZCTF/Models/Request/Info/UserMetadataFieldsModel.cs new file mode 100644 index 000000000..6f1ea0e34 --- /dev/null +++ b/src/GZCTF/Models/Request/Info/UserMetadataFieldsModel.cs @@ -0,0 +1,14 @@ +using UserMetadataField = GZCTF.Models.Internal.UserMetadataField; + +namespace GZCTF.Models.Request.Info; + +/// +/// User metadata fields configuration for client +/// +public class UserMetadataFieldsModel +{ + /// + /// Available metadata fields + /// + public List Fields { get; set; } = []; +} diff --git a/src/GZCTF/Models/Transfer/TransferDivision.cs b/src/GZCTF/Models/Transfer/TransferDivision.cs index efeaf649a..39efc24cc 100644 --- a/src/GZCTF/Models/Transfer/TransferDivision.cs +++ b/src/GZCTF/Models/Transfer/TransferDivision.cs @@ -12,7 +12,7 @@ public class TransferDivision /// [Required(ErrorMessage = "Division name is required")] [MinLength(1, ErrorMessage = "Division name cannot be empty")] - [MaxLength(Limits.MaxDivisionNameLength, ErrorMessage = "Division name is too long")] + [MaxLength(Limits.MaxShortIdLength, ErrorMessage = "Division name is too long")] public string Name { get; set; } = string.Empty; /// diff --git a/src/GZCTF/Program.cs b/src/GZCTF/Program.cs index e151a17de..70b64d8e4 100644 --- a/src/GZCTF/Program.cs +++ b/src/GZCTF/Program.cs @@ -38,6 +38,7 @@ builder.ConfigureStorage(); builder.ConfigureCacheAndSignalR(); builder.ConfigureIdentity(); +builder.ConfigureOAuth(); builder.ConfigureTelemetry(); builder.AddServiceConfigurations(); diff --git a/src/GZCTF/Repositories/BlobRepository.cs b/src/GZCTF/Repositories/BlobRepository.cs index d3bc8f09a..5791d0eca 100644 --- a/src/GZCTF/Repositories/BlobRepository.cs +++ b/src/GZCTF/Repositories/BlobRepository.cs @@ -1,6 +1,5 @@ using System.Security.Cryptography; using GZCTF.Repositories.Interface; -using GZCTF.Storage; using GZCTF.Storage.Interface; using Microsoft.EntityFrameworkCore; using SixLabors.ImageSharp; diff --git a/src/GZCTF/Repositories/GameNoticeRepository.cs b/src/GZCTF/Repositories/GameNoticeRepository.cs index 319ccb198..78c7f035e 100644 --- a/src/GZCTF/Repositories/GameNoticeRepository.cs +++ b/src/GZCTF/Repositories/GameNoticeRepository.cs @@ -1,6 +1,5 @@ using GZCTF.Hubs; using GZCTF.Hubs.Clients; -using GZCTF.Models.Request.Game; using GZCTF.Repositories.Interface; using GZCTF.Services.Cache; using Microsoft.AspNetCore.SignalR; diff --git a/src/GZCTF/Repositories/Interface/IGameNoticeRepository.cs b/src/GZCTF/Repositories/Interface/IGameNoticeRepository.cs index c6bba3ce1..5e1dfcfa7 100644 --- a/src/GZCTF/Repositories/Interface/IGameNoticeRepository.cs +++ b/src/GZCTF/Repositories/Interface/IGameNoticeRepository.cs @@ -1,6 +1,4 @@ -using GZCTF.Models.Request.Game; - -namespace GZCTF.Repositories.Interface; +namespace GZCTF.Repositories.Interface; public interface IGameNoticeRepository : IRepository { diff --git a/src/GZCTF/Repositories/Interface/IOAuthProviderRepository.cs b/src/GZCTF/Repositories/Interface/IOAuthProviderRepository.cs new file mode 100644 index 000000000..27c482f2b --- /dev/null +++ b/src/GZCTF/Repositories/Interface/IOAuthProviderRepository.cs @@ -0,0 +1,69 @@ +using GZCTF.Models.Internal; +using InternalUserMetadataField = GZCTF.Models.Internal.UserMetadataField; + +namespace GZCTF.Repositories.Interface; + +/// +/// Provides data-access helpers for OAuth providers and user metadata field definitions stored in the database. +/// +public interface IOAuthProviderRepository : IRepository +{ + /// + /// Finds an OAuth provider entity by its unique key, or returns null when it is missing. + /// + /// Provider key enforced by . + /// Cancellation token for the underlying EF Core query. + /// The matching provider entity or null. + Task FindByKeyAsync(string key, CancellationToken token = default); + + /// + /// Retrieves all OAuth providers without tracking for display or administrative operations. + /// + /// Cancellation token for the query. + /// List of provider entities ordered as stored. + Task> ListAsync(CancellationToken token = default); + + /// + /// Returns an immutable map of provider keys to their configuration snapshots. + /// + /// Cancellation token for the query. + /// Dictionary keyed by provider identifier with serialized configs. + Task> GetConfigMapAsync(CancellationToken token = default); + + /// + /// Loads a single provider configuration by key, enabling controllers to hydrate DTOs without EF entities. + /// + /// Provider identifier to lookup. + /// Cancellation token for the query. + /// The provider configuration or null. + Task GetConfigAsync(string key, CancellationToken token = default); + + /// + /// Creates or updates a provider definition with the supplied configuration payload. + /// + /// Provider identifier to insert or update. + /// Configuration payload sent from the admin UI. + /// Cancellation token for the write operation. + Task UpsertAsync(string key, OAuthProviderConfig config, CancellationToken token = default); + + /// + /// Deletes the provider identified by the given key when it exists. + /// + /// Provider identifier to remove. + /// Cancellation token for the delete operation. + Task DeleteAsync(string key, CancellationToken token = default); + + /// + /// Fetches the ordered list of user metadata field definitions consumed by OAuth registration flows. + /// + /// Cancellation token for the query. + /// List of metadata descriptors sorted by their configured order. + Task> GetMetadataFieldsAsync(CancellationToken token = default); + + /// + /// Replaces the persisted metadata field definitions, preserving the provided ordering. + /// + /// New metadata field collection in desired order. + /// Cancellation token for the write operation. + Task UpdateMetadataFieldsAsync(List fields, CancellationToken token = default); +} diff --git a/src/GZCTF/Repositories/OAuthProviderRepository.cs b/src/GZCTF/Repositories/OAuthProviderRepository.cs new file mode 100644 index 000000000..4e32b0da6 --- /dev/null +++ b/src/GZCTF/Repositories/OAuthProviderRepository.cs @@ -0,0 +1,85 @@ +using System.ComponentModel.DataAnnotations; +using GZCTF.Models.Internal; +using GZCTF.Repositories.Interface; +using Microsoft.EntityFrameworkCore; +using DataUserMetadataField = GZCTF.Models.Data.UserMetadataField; +using InternalUserMetadataField = GZCTF.Models.Internal.UserMetadataField; + +namespace GZCTF.Repositories; + +public class OAuthProviderRepository(AppDbContext context) : RepositoryBase(context), IOAuthProviderRepository +{ + public Task FindByKeyAsync(string key, CancellationToken token = default) => + Context.OAuthProviders.AsNoTracking().FirstOrDefaultAsync(p => p.Key == key, token); + + public Task> ListAsync(CancellationToken token = default) => + Context.OAuthProviders.AsNoTracking().ToListAsync(token); + + public async Task> GetConfigMapAsync(CancellationToken token = default) + { + var providers = await Context.OAuthProviders.AsNoTracking().ToListAsync(token); + return providers.ToDictionary(p => p.Key, p => p.ToConfig()); + } + + public async Task GetConfigAsync(string key, CancellationToken token = default) => + await Context.OAuthProviders.AsNoTracking() + .Where(p => p.Key == key) + .Select(p => p.ToConfig()) + .FirstOrDefaultAsync(token); + + public async Task UpsertAsync(string key, OAuthProviderConfig config, CancellationToken token = default) + { + OAuthProvider.ValidateKey(key); + + var provider = await Context.OAuthProviders.FirstOrDefaultAsync(p => p.Key == key, token); + + if (provider is null) + { + provider = new OAuthProvider { Key = key }; + await Context.OAuthProviders.AddAsync(provider, token); + } + + provider.UpdateFromConfig(config); + + Validator.ValidateObject(provider, new ValidationContext(provider), validateAllProperties: true); + + await Context.SaveChangesAsync(token); + } + + public async Task DeleteAsync(string key, CancellationToken token = default) + { + var provider = await Context.OAuthProviders.FirstOrDefaultAsync(p => p.Key == key, token); + + if (provider is null) + return; + + Context.OAuthProviders.Remove(provider); + await Context.SaveChangesAsync(token); + } + + public async Task> GetMetadataFieldsAsync(CancellationToken token = default) + { + var fields = await Context.UserMetadataFields + .AsNoTracking() + .OrderBy(f => f.Order) + .ToListAsync(token); + + return fields.Select(f => f.ToField()).ToList(); + } + + public async Task UpdateMetadataFieldsAsync(List fields, + CancellationToken token = default) + { + var existingFields = await Context.UserMetadataFields.ToListAsync(token); + Context.UserMetadataFields.RemoveRange(existingFields); + + for (var i = 0; i < fields.Count; i++) + { + var entity = new DataUserMetadataField { Order = i }; + entity.UpdateFromField(fields[i]); + await Context.UserMetadataFields.AddAsync(entity, token); + } + + await Context.SaveChangesAsync(token); + } +} diff --git a/src/GZCTF/Services/Cache/CacheHelper.cs b/src/GZCTF/Services/Cache/CacheHelper.cs index 61e731c1c..48ccf66b9 100644 --- a/src/GZCTF/Services/Cache/CacheHelper.cs +++ b/src/GZCTF/Services/Cache/CacheHelper.cs @@ -270,4 +270,9 @@ public static class CacheKey /// HashPow cache /// public static string HashPow(string key) => $"_HP_{key}"; + + /// + /// OAuth state cache + /// + public static string OAuthState(string state) => $"_OAuthState_{state}"; } diff --git a/src/GZCTF/Services/Cache/Handlers/GameListCacheHandler.cs b/src/GZCTF/Services/Cache/Handlers/GameListCacheHandler.cs index 75e75a478..5a3397640 100644 --- a/src/GZCTF/Services/Cache/Handlers/GameListCacheHandler.cs +++ b/src/GZCTF/Services/Cache/Handlers/GameListCacheHandler.cs @@ -1,4 +1,3 @@ -using GZCTF.Models.Request.Game; using GZCTF.Repositories.Interface; using MemoryPack; diff --git a/src/GZCTF/Services/HealthCheck/StorageHealthCheck.cs b/src/GZCTF/Services/HealthCheck/StorageHealthCheck.cs index 61b9d295c..d8ee463f8 100644 --- a/src/GZCTF/Services/HealthCheck/StorageHealthCheck.cs +++ b/src/GZCTF/Services/HealthCheck/StorageHealthCheck.cs @@ -1,5 +1,4 @@ -using GZCTF.Storage; -using GZCTF.Storage.Interface; +using GZCTF.Storage.Interface; using Microsoft.Extensions.Diagnostics.HealthChecks; namespace GZCTF.Services.HealthCheck; diff --git a/src/GZCTF/Services/OAuth/OAuthLoginException.cs b/src/GZCTF/Services/OAuth/OAuthLoginException.cs new file mode 100644 index 000000000..cc4bc30ba --- /dev/null +++ b/src/GZCTF/Services/OAuth/OAuthLoginException.cs @@ -0,0 +1,23 @@ +namespace GZCTF.Services.OAuth; + +public enum OAuthLoginError +{ + EmailInUse, + ProviderMismatch, + MetadataInvalid, + ProviderMissing +} + +public class OAuthLoginException(OAuthLoginError errorCode, string message) : Exception(message) +{ + public OAuthLoginError ErrorCode { get; } = errorCode; + + public string QueryCode => ErrorCode switch + { + OAuthLoginError.EmailInUse => "oauth_email_in_use", + OAuthLoginError.ProviderMismatch => "oauth_provider_mismatch", + OAuthLoginError.MetadataInvalid => "oauth_metadata_invalid", + OAuthLoginError.ProviderMissing => "oauth_provider_missing", + _ => "oauth_error" + }; +} diff --git a/src/GZCTF/Services/OAuth/OAuthService.cs b/src/GZCTF/Services/OAuth/OAuthService.cs new file mode 100644 index 000000000..76ef7a0cb --- /dev/null +++ b/src/GZCTF/Services/OAuth/OAuthService.cs @@ -0,0 +1,279 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.Identity; + +namespace GZCTF.Services.OAuth; + +/// +/// Defines the contract for exchanging OAuth authorization codes and provisioning application users from the +/// resulting identity payloads. +/// +public interface IOAuthService +{ + /// + /// Exchanges an authorization code for the upstream user's profile data using the supplied provider configuration. + /// + /// Persisted provider metadata (client credentials, endpoints, etc.). + /// Authorization code received from the OAuth callback. + /// The exact redirect URI registered with the provider for validation. + /// Cancellation token for the outbound HTTP work. + /// The normalized user information payload, or null when the exchange fails. + Task ExchangeCodeForUserInfoAsync(OAuthProvider provider, string code, string redirectUri, + CancellationToken token = default); + + /// + /// Finds or creates a local based on the OAuth user payload, enforcing metadata rules. + /// + /// Provider responsible for the sign-in attempt. + /// Normalized user information retrieved from the provider. + /// Cancellation token for repository operations. + /// The resolved user and a flag indicating whether the user was newly created. + Task<(UserInfo user, bool isNewUser)> GetOrCreateUserFromOAuthAsync(OAuthProvider provider, OAuthUserInfo oauthUser, + CancellationToken token = default); +} + +public class OAuthService( + IUserMetadataService metadataService, + UserManager userManager, + IHttpClientFactory httpClientFactory, + ILogger logger) : IOAuthService +{ + public async Task ExchangeCodeForUserInfoAsync( + OAuthProvider provider, + string code, + string redirectUri, + CancellationToken token = default) + { + if (!provider.Enabled) + { + logger.LogWarning("OAuth provider {Provider} not found or not enabled", provider.Key); + return null; + } + + var providerConfig = provider.ToConfig(); + + try + { + // Exchange code for access token + using var httpClient = httpClientFactory.CreateClient(); + + var tokenRequest = new Dictionary + { + { "grant_type", "authorization_code" }, + { "code", code }, + { "redirect_uri", redirectUri }, + { "client_id", providerConfig.ClientId }, + { "client_secret", providerConfig.ClientSecret } + }; + + var tokenResponse = await httpClient.PostAsync( + providerConfig.TokenEndpoint, + new FormUrlEncodedContent(tokenRequest), + token); + + if (!tokenResponse.IsSuccessStatusCode) + { + var errorContent = await tokenResponse.Content.ReadAsStringAsync(token); + logger.LogWarning("Failed to exchange OAuth code for token: {StatusCode} - {Content}", + tokenResponse.StatusCode, errorContent); + return null; + } + + var tokenData = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken: token); + var accessToken = tokenData.GetProperty("access_token").GetString(); + + if (string.IsNullOrEmpty(accessToken)) + { + logger.LogWarning("No access token received from OAuth provider {Provider}", provider); + return null; + } + + // Get user info from provider + var userInfoRequest = new HttpRequestMessage(HttpMethod.Get, providerConfig.UserInformationEndpoint); + userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + var userInfoResponse = await httpClient.SendAsync(userInfoRequest, token); + + if (!userInfoResponse.IsSuccessStatusCode) + { + var errorContent = await userInfoResponse.Content.ReadAsStringAsync(token); + logger.LogWarning("Failed to get user info from OAuth provider: {StatusCode} - {Content}", + userInfoResponse.StatusCode, errorContent); + return null; + } + + var userInfoData = await userInfoResponse.Content.ReadFromJsonAsync(cancellationToken: token); + + // Map fields based on provider configuration + var oauthUser = new OAuthUserInfo + { + ProviderId = provider.Key, + ProviderUserId = userInfoData.TryGetProperty("id", out var id) + ? id.ToString() + : userInfoData.TryGetProperty("sub", out var sub) + ? sub.ToString() + : null, + Email = GetFieldValue(userInfoData, "email"), + UserName = GetFieldValue(userInfoData, "login") + ?? GetFieldValue(userInfoData, "username") + ?? GetFieldValue(userInfoData, "preferred_username"), + RawData = userInfoData, + MappedFields = new Dictionary(StringComparer.OrdinalIgnoreCase) + }; + + foreach (var (sourceField, targetField) in providerConfig.FieldMapping) + { + var value = GetFieldValue(userInfoData, sourceField); + if (!string.IsNullOrEmpty(value)) + { + oauthUser.MappedFields[targetField] = value; + } + } + + return oauthUser; + } + catch (Exception ex) + { + logger.LogError(ex, "Error during OAuth code exchange for provider {Provider}", provider.Key); + return null; + } + } + + public async Task<(UserInfo user, bool isNewUser)> GetOrCreateUserFromOAuthAsync( + OAuthProvider provider, + OAuthUserInfo oauthUser, + CancellationToken token = default) + { + if (string.IsNullOrEmpty(oauthUser.Email)) + { + throw new InvalidOperationException("OAuth user must have an email address"); + } + + // Try to find existing user by email + var existingUser = await userManager.FindByEmailAsync(oauthUser.Email); + + if (existingUser is not null) + { + if (existingUser.OAuthProviderId is null) + throw new OAuthLoginException(OAuthLoginError.EmailInUse, + "Email already registered by another method"); + + if (existingUser.OAuthProviderId != provider.Id) + throw new OAuthLoginException(OAuthLoginError.ProviderMismatch, + "Email already linked to another OAuth provider"); + + var validation = await metadataService.ValidateAsync( + oauthUser.MappedFields, + existingUser.UserMetadata, + allowLockedWrites: true, + enforceLockedRequirements: true, + token); + + if (!validation.IsValid) + throw new OAuthLoginException(OAuthLoginError.MetadataInvalid, validation.Errors.First()); + + existingUser.UserMetadata = validation.Values; + await userManager.UpdateAsync(existingUser); + + logger.LogInformation("User {Email} logged in via OAuth provider {Provider}", oauthUser.Email, provider); + return (existingUser, false); + } + + // Create new user + var userName = oauthUser.UserName ?? oauthUser.Email.Split('@')[0]; + + // Truncate username if too long (max 16 characters, leave room for counter) + const int maxUsernameLength = 16; + if (userName.Length > maxUsernameLength - 3) // Reserve 3 chars for potential counter (e.g., "123") + { + userName = userName[..(maxUsernameLength - 3)]; + } + + // Ensure username is unique + var baseUserName = userName; + var counter = 1; + while (await userManager.FindByNameAsync(userName) is not null) + { + var suffix = counter.ToString(); + var maxBaseLength = maxUsernameLength - suffix.Length; + userName = baseUserName.Length > maxBaseLength + ? $"{baseUserName[..maxBaseLength]}{suffix}" + : $"{baseUserName}{suffix}"; + counter++; + } + + var newMetadata = await metadataService.ValidateAsync( + oauthUser.MappedFields, + null, + allowLockedWrites: true, + enforceLockedRequirements: true, + token); + + if (!newMetadata.IsValid) + throw new OAuthLoginException(OAuthLoginError.MetadataInvalid, newMetadata.Errors.First()); + + var newUser = new UserInfo + { + UserName = userName, + Email = oauthUser.Email, + EmailConfirmed = true, // OAuth providers verify emails + RegisterTimeUtc = DateTimeOffset.UtcNow, + OAuthProviderId = provider.Id, + UserMetadata = newMetadata.Values + }; + + var result = await userManager.CreateAsync(newUser); + + if (!result.Succeeded) + { + var errors = string.Join(", ", result.Errors.Select(e => e.Description)); + throw new InvalidOperationException($"Failed to create user from OAuth: {errors}"); + } + + logger.LogInformation("Created new user {Email} from OAuth provider {Provider}", oauthUser.Email, provider.Key); + return (newUser, true); + } + + private static string? GetFieldValue(JsonElement data, string fieldName) + { + if (data.TryGetProperty(fieldName, out var value)) + { + return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(); + } + + return null; + } +} + +public class OAuthUserInfo +{ + /// + /// Provider key (matching ) the user originates from. + /// + public required string ProviderId { get; set; } + + /// + /// Identifier issued by the upstream provider for this user, if provided. + /// + public string? ProviderUserId { get; set; } + + /// + /// Email reported by the provider; required for linking/creating accounts. + /// + public string? Email { get; set; } + + /// + /// Preferred username or handle supplied by the provider. + /// + public string? UserName { get; set; } + + /// + /// Normalized field mapping results keyed by target metadata field names. + /// + public Dictionary MappedFields { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Raw JSON response from the provider for downstream auditing or debugging. + /// + public JsonElement RawData { get; set; } +} diff --git a/src/GZCTF/Services/UserMetadata/IUserMetadataService.cs b/src/GZCTF/Services/UserMetadata/IUserMetadataService.cs new file mode 100644 index 000000000..feba834f8 --- /dev/null +++ b/src/GZCTF/Services/UserMetadata/IUserMetadataService.cs @@ -0,0 +1,32 @@ +using InternalUserMetadataField = GZCTF.Models.Internal.UserMetadataField; + +namespace GZCTF.Services; + +/// +/// Provides validation and metadata field discovery services for user-defined profile attributes. +/// +public interface IUserMetadataService +{ + /// + /// Validates the provided metadata values against the configured schema and merges them with existing values. + /// + /// The raw metadata dictionary coming from the client. Keys are case-insensitive. + /// The currently stored metadata values, if any. + /// When , locked fields can be written by the caller. + /// When , required locked fields must still be supplied. + /// Cancellation token propagated to downstream storage operations. + /// A validation result containing normalized metadata values or validation errors. + Task ValidateAsync( + IDictionary? incoming, + IDictionary? existing, + bool allowLockedWrites, + bool enforceLockedRequirements, + CancellationToken token = default); + + /// + /// Retrieves the list of metadata fields defined in the system, including validation constraints. + /// + /// Cancellation token propagated to downstream storage operations. + /// The metadata field descriptor collection. + Task> GetFieldsAsync(CancellationToken token = default); +} diff --git a/src/GZCTF/Services/UserMetadata/UserMetadataService.cs b/src/GZCTF/Services/UserMetadata/UserMetadataService.cs new file mode 100644 index 000000000..337c228f8 --- /dev/null +++ b/src/GZCTF/Services/UserMetadata/UserMetadataService.cs @@ -0,0 +1,170 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; +using GZCTF.Models.Internal; +using GZCTF.Repositories.Interface; +using InternalUserMetadataField = GZCTF.Models.Internal.UserMetadataField; + +namespace GZCTF.Services; + +public sealed class UserMetadataValidationResult +{ + public bool IsValid => Errors.Count == 0; + public List Errors { get; } = []; + public Dictionary Values { get; } = new(StringComparer.OrdinalIgnoreCase); +} + +public class UserMetadataService( + IOAuthProviderRepository oauthProviderRepository) : IUserMetadataService +{ + static readonly EmailAddressAttribute EmailAttribute = new(); + static readonly PhoneAttribute PhoneAttribute = new(); + + public async Task ValidateAsync( + IDictionary? incoming, + IDictionary? existing, + bool allowLockedWrites, + bool enforceLockedRequirements, + CancellationToken token = default) + { + var fields = await oauthProviderRepository.GetMetadataFieldsAsync(token); + var result = new UserMetadataValidationResult(); + var source = incoming ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + var current = existing is null + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new Dictionary(existing, StringComparer.OrdinalIgnoreCase); + + foreach (var field in fields) + { + var hasIncoming = source.TryGetValue(field.Key, out var providedValue); + var canWriteField = !field.Locked || allowLockedWrites; + string? candidate = hasIncoming && canWriteField ? providedValue : current.GetValueOrDefault(field.Key); + + var enforceRequirement = field.Required && (!field.Locked || enforceLockedRequirements); + + if (!canWriteField && hasIncoming) + // Ignore attempts to set locked fields when not permitted + candidate = current.GetValueOrDefault(field.Key); + + var validation = ValidateField(field, candidate); + + if (!validation.IsValid) + { + if (enforceRequirement || !string.IsNullOrWhiteSpace(candidate)) + result.Errors.Add(validation.ErrorMessage); + continue; + } + + if (string.IsNullOrWhiteSpace(validation.NormalizedValue)) + { + if (enforceRequirement) + result.Errors.Add($"Field '{field.DisplayName}' is required."); + + continue; + } + + result.Values[field.Key] = validation.NormalizedValue!; + } + + foreach (var (key, value) in current) + { + if (result.Values.ContainsKey(key)) + continue; + + if (fields.Any(f => f.Key == key)) + continue; + + if (!string.IsNullOrWhiteSpace(value)) + result.Values[key] = value; + } + + return result; + } + + public async Task> GetFieldsAsync(CancellationToken token = default) + { + var fields = await oauthProviderRepository.GetMetadataFieldsAsync(token); + return fields; + } + + static FieldValidationResult ValidateField(InternalUserMetadataField field, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return FieldValidationResult.Success(null); + + var trimmed = value.Trim(); + + if (field.MaxLength is > 0 && trimmed.Length > field.MaxLength) + return FieldValidationResult.Failure($"Field '{field.DisplayName}' exceeds max length {field.MaxLength}."); + + if (!string.IsNullOrWhiteSpace(field.Pattern)) + { + var regex = new Regex(field.Pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant); + if (!regex.IsMatch(trimmed)) + return FieldValidationResult.Failure($"Field '{field.DisplayName}' does not match required pattern."); + } + + return field.Type switch + { + UserMetadataFieldType.Number => ValidateNumber(field, trimmed), + UserMetadataFieldType.Email => ValidateEmail(field, trimmed), + UserMetadataFieldType.Url => ValidateUrl(field, trimmed), + UserMetadataFieldType.Phone => ValidatePhone(field, trimmed), + UserMetadataFieldType.Date => ValidateDate(field, trimmed), + UserMetadataFieldType.Select => ValidateSelect(field, trimmed), + _ => FieldValidationResult.Success(trimmed) + }; + } + + static FieldValidationResult ValidateNumber(InternalUserMetadataField field, string value) + { + if (!int.TryParse(value, out var number)) + return FieldValidationResult.Failure($"Field '{field.DisplayName}' must be a number."); + + if (field.MinValue.HasValue && number < field.MinValue.Value) + return FieldValidationResult.Failure($"Field '{field.DisplayName}' must be >= {field.MinValue}."); + + if (field.MaxValue.HasValue && number > field.MaxValue.Value) + return FieldValidationResult.Failure($"Field '{field.DisplayName}' must be <= {field.MaxValue}."); + + return FieldValidationResult.Success(number.ToString()); + } + + static FieldValidationResult ValidateEmail(InternalUserMetadataField field, string value) + => EmailAttribute.IsValid(value) + ? FieldValidationResult.Success(value) + : FieldValidationResult.Failure($"Field '{field.DisplayName}' must be a valid email."); + + static FieldValidationResult ValidateUrl(InternalUserMetadataField field, string value) + => Uri.TryCreate(value, UriKind.Absolute, out _) + ? FieldValidationResult.Success(value) + : FieldValidationResult.Failure($"Field '{field.DisplayName}' must be a valid URL."); + + static FieldValidationResult ValidatePhone(InternalUserMetadataField field, string value) + => PhoneAttribute.IsValid(value) + ? FieldValidationResult.Success(value) + : FieldValidationResult.Failure($"Field '{field.DisplayName}' must be a valid phone number."); + + static FieldValidationResult ValidateDate(InternalUserMetadataField field, string value) + => DateOnly.TryParse(value, out var parsed) + ? FieldValidationResult.Success(parsed.ToString("O")) + : FieldValidationResult.Failure($"Field '{field.DisplayName}' must be a valid date."); + + static FieldValidationResult ValidateSelect(InternalUserMetadataField field, string value) + { + if (field.Options is null || field.Options.Count == 0) + return FieldValidationResult.Failure($"Field '{field.DisplayName}' has no options configured."); + + return field.Options.Contains(value) + ? FieldValidationResult.Success(value) + : FieldValidationResult.Failure($"Field '{field.DisplayName}' must be one of the provided options."); + } + + private sealed record FieldValidationResult(bool IsValid, string? NormalizedValue, string ErrorMessage) + { + public static FieldValidationResult Success(string? normalizedValue) => + new(true, normalizedValue, string.Empty); + + public static FieldValidationResult Failure(string errorMessage) => + new(false, null, errorMessage); + } +} diff --git a/src/GZCTF/Utils/TarHelper.cs b/src/GZCTF/Utils/TarHelper.cs index f10184f66..01fb04f3a 100644 --- a/src/GZCTF/Utils/TarHelper.cs +++ b/src/GZCTF/Utils/TarHelper.cs @@ -1,7 +1,6 @@ using System.Formats.Tar; using System.IO.Compression; using System.Web; -using GZCTF.Storage; using GZCTF.Storage.Interface; using Microsoft.AspNetCore.Mvc;