diff --git a/.github/workflows/release-pipeline.yaml b/.github/workflows/release-pipeline.yaml index bab9609dc..59cbfd116 100644 --- a/.github/workflows/release-pipeline.yaml +++ b/.github/workflows/release-pipeline.yaml @@ -75,5 +75,5 @@ jobs: version: ${{ needs.set-version.outputs.version }} image: 'ghcr.io/sillsdev/lexbox-*' k8s-environment: production - deploy-domain: prod.languagedepot.org + deploy-domain: languagedepot.org diff --git a/.idea/.idea.LexBox/.idea/dataSources.xml b/.idea/.idea.LexBox/.idea/dataSources.xml index db47a63ea..2fbfdd1a0 100644 --- a/.idea/.idea.LexBox/.idea/dataSources.xml +++ b/.idea/.idea.LexBox/.idea/dataSources.xml @@ -5,7 +5,16 @@ postgresql true org.postgresql.Driver - jdbc:postgresql://localhost:5433/lexbox + jdbc:postgresql://localhost/lexbox + + + + + + + + + $ProjectFileDir$ @@ -26,7 +35,13 @@ org.postgresql.Driver jdbc:postgresql://localhost:5434/lexbox - + + + + + + + $ProjectFileDir$ @@ -37,7 +52,13 @@ org.postgresql.Driver jdbc:postgresql://localhost:5435/lexbox - + + + + + + + $ProjectFileDir$ diff --git a/.idea/.idea.LexBox/.idea/indexLayout.xml b/.idea/.idea.LexBox/.idea/indexLayout.xml index d08f0ade8..bfb0056af 100644 --- a/.idea/.idea.LexBox/.idea/indexLayout.xml +++ b/.idea/.idea.LexBox/.idea/indexLayout.xml @@ -7,7 +7,13 @@ hg-web + .github + deployment hg-web + hgresumable + hgweb + otel + proxy frontend/.svelte-kit/output diff --git a/.idea/.idea.LexBox/.idea/kubernetes-settings.xml b/.idea/.idea.LexBox/.idea/kubernetes-settings.xml deleted file mode 100644 index 9c05fcbdc..000000000 --- a/.idea/.idea.LexBox/.idea/kubernetes-settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/backend/LexBoxApi/Auth/AdminRequiredAttribute.cs b/backend/LexBoxApi/Auth/Attributes/AdminRequiredAttribute.cs similarity index 87% rename from backend/LexBoxApi/Auth/AdminRequiredAttribute.cs rename to backend/LexBoxApi/Auth/Attributes/AdminRequiredAttribute.cs index e062e5e9b..856534078 100644 --- a/backend/LexBoxApi/Auth/AdminRequiredAttribute.cs +++ b/backend/LexBoxApi/Auth/Attributes/AdminRequiredAttribute.cs @@ -1,4 +1,4 @@ -namespace LexBoxApi.Auth; +namespace LexBoxApi.Auth.Attributes; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class AdminRequiredAttribute : LexboxAuthAttribute diff --git a/backend/LexBoxApi/Auth/CreateProjectRequiredAttribute.cs b/backend/LexBoxApi/Auth/Attributes/CreateProjectRequiredAttribute.cs similarity index 87% rename from backend/LexBoxApi/Auth/CreateProjectRequiredAttribute.cs rename to backend/LexBoxApi/Auth/Attributes/CreateProjectRequiredAttribute.cs index 25b19aa1d..bed625561 100644 --- a/backend/LexBoxApi/Auth/CreateProjectRequiredAttribute.cs +++ b/backend/LexBoxApi/Auth/Attributes/CreateProjectRequiredAttribute.cs @@ -1,4 +1,4 @@ -namespace LexBoxApi.Auth; +namespace LexBoxApi.Auth.Attributes; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class CreateProjectRequiredAttribute: LexboxAuthAttribute diff --git a/backend/LexBoxApi/Auth/LexboxAuthAttribute.cs b/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs similarity index 95% rename from backend/LexBoxApi/Auth/LexboxAuthAttribute.cs rename to backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs index 5910b6a7a..31cff5996 100644 --- a/backend/LexBoxApi/Auth/LexboxAuthAttribute.cs +++ b/backend/LexBoxApi/Auth/Attributes/LexboxAuthAttribute.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Authorization; using AuthorizeAttribute = HotChocolate.Authorization.AuthorizeAttribute; -namespace LexBoxApi.Auth; +namespace LexBoxApi.Auth.Attributes; public abstract class LexboxAuthAttribute : DescriptorAttribute, IAuthorizeData { diff --git a/backend/LexBoxApi/Auth/Attributes/RequireAudienceAttribute.cs b/backend/LexBoxApi/Auth/Attributes/RequireAudienceAttribute.cs new file mode 100644 index 000000000..215b85860 --- /dev/null +++ b/backend/LexBoxApi/Auth/Attributes/RequireAudienceAttribute.cs @@ -0,0 +1,33 @@ +using LexCore.Auth; +using Microsoft.AspNetCore.Authorization; + +namespace LexBoxApi.Auth.Attributes; + +public class RequireAudienceAttribute(params LexboxAudience[] audiences) + : LexboxAuthAttribute(PolicyName), IAuthorizationRequirement, IAuthorizationRequirementData +{ + public const string PolicyName = "RequireAudiencePolicy"; + /// audience allowed to access this endpoint + /// when false the default audience is also allowed, when true the default audience is not allowed + public RequireAudienceAttribute(LexboxAudience audience, bool exclusive = false) : this(exclusive + ? [audience] + : [audience, LexboxAudience.LexboxApi]) + { + } + + public LexboxAudience[] ValidAudiences { get; } = audiences; + + public IEnumerable GetRequirements() + { + yield return this; + } +} + +public class AllowAnyAudienceAttribute : LexboxAuthAttribute +{ + public const string PolicyName = "AllowAnyAudiencePolicy"; + + public AllowAnyAudienceAttribute() : base(PolicyName) + { + } +} diff --git a/backend/LexBoxApi/Auth/Attributes/RequireCurrentUserInfoAttribute.cs b/backend/LexBoxApi/Auth/Attributes/RequireCurrentUserInfoAttribute.cs new file mode 100644 index 000000000..412a79a8a --- /dev/null +++ b/backend/LexBoxApi/Auth/Attributes/RequireCurrentUserInfoAttribute.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Authorization; + +namespace LexBoxApi.Auth.Attributes; + +/// +/// validates the updated date of the jwt against the database, should be used to make a jwt expire if the user is updated +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class RequireCurrentUserInfoAttribute: Attribute, IAuthorizationRequirement, IAuthorizationRequirementData +{ + public IEnumerable GetRequirements() + { + yield return this; + } +} diff --git a/backend/LexBoxApi/Auth/VerifiedEmailRequiredAttribute.cs b/backend/LexBoxApi/Auth/Attributes/VerifiedEmailRequiredAttribute.cs similarity index 87% rename from backend/LexBoxApi/Auth/VerifiedEmailRequiredAttribute.cs rename to backend/LexBoxApi/Auth/Attributes/VerifiedEmailRequiredAttribute.cs index 5bfd233cb..a3d2ac897 100644 --- a/backend/LexBoxApi/Auth/VerifiedEmailRequiredAttribute.cs +++ b/backend/LexBoxApi/Auth/Attributes/VerifiedEmailRequiredAttribute.cs @@ -1,4 +1,4 @@ -namespace LexBoxApi.Auth; +namespace LexBoxApi.Auth.Attributes; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class VerifiedEmailRequiredAttribute : LexboxAuthAttribute diff --git a/backend/LexBoxApi/Auth/AuthKernel.cs b/backend/LexBoxApi/Auth/AuthKernel.cs index 5ad7cd923..6e0b93de0 100644 --- a/backend/LexBoxApi/Auth/AuthKernel.cs +++ b/backend/LexBoxApi/Auth/AuthKernel.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; using System.Text; +using LexBoxApi.Auth.Attributes; using LexBoxApi.Auth.Requirements; using LexCore.Auth; using Microsoft.AspNetCore.Authentication.Cookies; @@ -25,10 +26,12 @@ public static void AddLexBoxAuth(IServiceCollection services, if (environment.IsDevelopment()) { IdentityModelEventSource.ShowPII = true; + IdentityModelEventSource.LogCompleteSecurityArtifact = true; } services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddAuthorization(options => { //fallback policy is used when there's no auth attribute. @@ -37,24 +40,11 @@ public static void AddLexBoxAuth(IServiceCollection services, options.FallbackPolicy = options.DefaultPolicy = new AuthorizationPolicyBuilder() .RequireDefaultLexboxAuth() .Build(); - foreach (var audience in Enum.GetValues()) - { - if (audience == LexboxAudience.Unknown) continue; - //for exclusive the endpoint is only accessible for the specified audience - options.AddPolicy(audience.PolicyName(true), builder => - { - builder.RequireAuthenticatedUser(); - builder.AddRequirements(new AudienceRequirement(audience)); - }); - //for non exclusive the endpoint is also accessible for the default audience - options.AddPolicy(audience.PolicyName(false), builder => - { - builder.RequireAuthenticatedUser(); - builder.AddRequirements(new AudienceRequirement(audience, LexboxAudience.LexboxApi)); - }); - } + //don't use RequireDefaultLexboxAuth here because that only allows the default audience options.AddPolicy(AllowAnyAudienceAttribute.PolicyName, builder => builder.RequireAuthenticatedUser()); + //we still need this policy, without it the default policy is used which requires the default audience + options.AddPolicy(RequireAudienceAttribute.PolicyName, builder => builder.RequireAuthenticatedUser()); options.AddPolicy(AdminRequiredAttribute.PolicyName, builder => builder.RequireDefaultLexboxAuth() @@ -181,7 +171,7 @@ public static void AddLexBoxAuth(IServiceCollection services, public static AuthorizationPolicyBuilder RequireDefaultLexboxAuth(this AuthorizationPolicyBuilder builder) { return builder.RequireAuthenticatedUser() - .AddRequirements(new AudienceRequirement(LexboxAudience.LexboxApi)); + .AddRequirements(new RequireAudienceAttribute(LexboxAudience.LexboxApi, true)); } public static bool IsJwtRequest(this HttpRequest request) diff --git a/backend/LexBoxApi/Auth/RequireAudienceAttribute.cs b/backend/LexBoxApi/Auth/RequireAudienceAttribute.cs deleted file mode 100644 index 8310692ef..000000000 --- a/backend/LexBoxApi/Auth/RequireAudienceAttribute.cs +++ /dev/null @@ -1,23 +0,0 @@ -using LexCore.Auth; - -namespace LexBoxApi.Auth; - -//todo for now this attribute only supports a single audience, this may be fine for now. -//once we are at dotnet 8 we can support multiple audiences using the new IAuthorizationRequirementData api -//https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-8.0?view=aspnetcore-7.0#iauthorizationrequirementdata -public class RequireAudienceAttribute : LexboxAuthAttribute -{ - /// audience allowed to access this endpoint - /// when false the default audience is also allowed, when true the default audience is not allowed - public RequireAudienceAttribute(LexboxAudience audience, bool exclusive = false) : base(audience.PolicyName(exclusive)) - { - } -} - -public class AllowAnyAudienceAttribute : LexboxAuthAttribute -{ - public const string PolicyName = "AllowAnyAudiencePolicy"; - public AllowAnyAudienceAttribute() : base(PolicyName) - { - } -} diff --git a/backend/LexBoxApi/Auth/Requirements/AudienceRequirementHandler.cs b/backend/LexBoxApi/Auth/Requirements/AudienceRequirementHandler.cs index 20d3b9a0b..2688a6be9 100644 --- a/backend/LexBoxApi/Auth/Requirements/AudienceRequirementHandler.cs +++ b/backend/LexBoxApi/Auth/Requirements/AudienceRequirementHandler.cs @@ -1,22 +1,14 @@ -using LexCore.Auth; +using LexBoxApi.Auth.Attributes; +using LexCore.Auth; using Microsoft.AspNetCore.Authorization; -using Microsoft.IdentityModel.JsonWebTokens; namespace LexBoxApi.Auth.Requirements; -public class AudienceRequirement : IAuthorizationRequirement +public class AudienceRequirementHandler(ILogger logger) : AuthorizationHandler { - public LexboxAudience[] ValidAudiences { get; } - public AudienceRequirement(params LexboxAudience[] validAudiences) - { - ValidAudiences = validAudiences; - } -} - -public class AudienceRequirementHandler : AuthorizationHandler -{ - protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AudienceRequirement requirement) + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, + RequireAudienceAttribute requirement) { var claim = context.User.FindFirst(LexAuthConstants.AudienceClaimType); if (Enum.TryParse(claim?.Value, out var audience) && @@ -26,6 +18,7 @@ protected override Task HandleRequirementAsync(AuthorizationHandlerContext conte } else { + logger.LogInformation("Token does not have the required audience: [{Audience}] not in {ValidAudiences}", claim?.Value, string.Join(',', requirement.ValidAudiences)); context.Fail(new AuthorizationFailureReason(this, $"Token does not have the required audience: {requirement.ValidAudiences}")); } diff --git a/backend/LexBoxApi/Auth/Requirements/ValidateUserUpdatedHandler.cs b/backend/LexBoxApi/Auth/Requirements/ValidateUserUpdatedHandler.cs new file mode 100644 index 000000000..0b42808a6 --- /dev/null +++ b/backend/LexBoxApi/Auth/Requirements/ValidateUserUpdatedHandler.cs @@ -0,0 +1,28 @@ +using LexBoxApi.Auth.Attributes; +using LexBoxApi.Services; +using LexData; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; + +namespace LexBoxApi.Auth.Requirements; + +public class ValidateUserUpdatedHandler(IHttpContextAccessor httpContextAccessor, ILogger logger) : AuthorizationHandler +{ + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, RequireCurrentUserInfoAttribute requirement) + { + var httpContext = httpContextAccessor.HttpContext; + var user = httpContext?.RequestServices.GetRequiredService().MaybeUser; + if (user is null) return; + var userService = httpContext!.RequestServices.GetRequiredService(); + var actualUpdatedDate = await userService.GetUserUpdatedDate(user.Id); + if (actualUpdatedDate != user.UpdatedDate) + { + logger.LogInformation("User has been updated since login, {UpdatedDate} != {ActualUpdatedDate}", user.UpdatedDate, actualUpdatedDate); + context.Fail(new AuthorizationFailureReason(this, "User has been updated since login")); + } + else + { + context.Succeed(requirement); + } + } +} diff --git a/backend/LexBoxApi/Controllers/AdminController.cs b/backend/LexBoxApi/Controllers/AdminController.cs index 087137803..f6dedcf57 100644 --- a/backend/LexBoxApi/Controllers/AdminController.cs +++ b/backend/LexBoxApi/Controllers/AdminController.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexBoxApi.Services; using LexCore; using LexData; @@ -33,9 +34,9 @@ public record ResetPasswordAdminRequest([Required(AllowEmptyStrings = false)] st public async Task ResetPasswordAdmin(ResetPasswordAdminRequest request) { var passwordHash = request.PasswordHash; - var lexAuthUser = _loggedInContext.User; var user = await _lexBoxDbContext.Users.FirstAsync(u => u.Id == request.userId); user.PasswordHash = PasswordHashing.HashPassword(passwordHash, user.Salt, true); + user.UpdateUpdatedDate(); await _lexBoxDbContext.SaveChangesAsync(); await _emailService.SendPasswordChangedEmail(user); return Ok(); diff --git a/backend/LexBoxApi/Controllers/AuthTestingController.cs b/backend/LexBoxApi/Controllers/AuthTestingController.cs index 028f19dc0..908ab6289 100644 --- a/backend/LexBoxApi/Controllers/AuthTestingController.cs +++ b/backend/LexBoxApi/Controllers/AuthTestingController.cs @@ -1,4 +1,5 @@ using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexCore.Auth; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/backend/LexBoxApi/Controllers/LoginController.cs b/backend/LexBoxApi/Controllers/LoginController.cs index c690dd856..36168db3e 100644 --- a/backend/LexBoxApi/Controllers/LoginController.cs +++ b/backend/LexBoxApi/Controllers/LoginController.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexBoxApi.Models; using LexBoxApi.Otel; using LexBoxApi.Services; @@ -52,11 +53,23 @@ public async Task LoginRedirect( string jwt, // This is required because auth looks for a jwt in the query string string returnTo) { + var user = _loggedInContext.User; + var userUpdatedDate = await _userService.GetUserUpdatedDate(user.Id); + if (userUpdatedDate != user.UpdatedDate) + { + return await EmailLinkExpired(); + } await HttpContext.SignInAsync(User, new AuthenticationProperties { IsPersistent = true }); return Redirect(returnTo); } + private async Task EmailLinkExpired() + { + await HttpContext.SignOutAsync(); + return Redirect("/login?message=link_expired"); + } + [HttpGet("verifyEmail")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -73,9 +86,17 @@ public async Task> VerifyEmail( var userId = _loggedInContext.User.Id; var user = await _lexBoxDbContext.Users.FindAsync(userId); if (user == null) return NotFound(); + //users can verify their email even if the updated date is out of sync when not changing their email + //this is to prevent some edge cases where changing their name and then using an old verify email link would fail + if (user.Email != _loggedInContext.User.Email && + user.UpdatedDate.ToUnixTimeSeconds() != _loggedInContext.User.UpdatedDate) + { + return await EmailLinkExpired(); + } user.Email = _loggedInContext.User.Email; user.EmailVerified = true; + user.UpdateUpdatedDate(); await _lexBoxDbContext.SaveChangesAsync(); await RefreshJwt(); return Redirect(returnTo); @@ -136,12 +157,18 @@ public record ResetPasswordRequest([Required(AllowEmptyStrings = false)] string [HttpPost("resetPassword")] [RequireAudience(LexboxAudience.ForgotPassword)] + [RequireCurrentUserInfo] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesDefaultResponseType] public async Task ResetPassword(ResetPasswordRequest request) { var passwordHash = request.PasswordHash; var lexAuthUser = _loggedInContext.User; - var user = await _lexBoxDbContext.Users.FirstAsync(u => u.Id == lexAuthUser.Id); + var user = await _lexBoxDbContext.Users.FindAsync(lexAuthUser.Id); + if (user == null) return NotFound(); user.PasswordHash = PasswordHashing.HashPassword(passwordHash, user.Salt, true); + user.UpdateUpdatedDate(); await _lexBoxDbContext.SaveChangesAsync(); await _emailService.SendPasswordChangedEmail(user); //the old jwt is only valid for calling forgot password endpoints, we need to generate a new one diff --git a/backend/LexBoxApi/Controllers/MigrationController.cs b/backend/LexBoxApi/Controllers/MigrationController.cs index 9af6dcf06..02d6af91f 100644 --- a/backend/LexBoxApi/Controllers/MigrationController.cs +++ b/backend/LexBoxApi/Controllers/MigrationController.cs @@ -1,4 +1,5 @@ using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexBoxApi.Services; using LexCore.Entities; using LexData; diff --git a/backend/LexBoxApi/Controllers/ProjectController.cs b/backend/LexBoxApi/Controllers/ProjectController.cs index 64e8e94e8..aba869eb0 100644 --- a/backend/LexBoxApi/Controllers/ProjectController.cs +++ b/backend/LexBoxApi/Controllers/ProjectController.cs @@ -1,4 +1,5 @@ using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexBoxApi.Services; using LexCore.Entities; using LexCore.ServiceInterfaces; diff --git a/backend/LexBoxApi/GraphQL/CustomFilters/QueryableStringDeterministicInvariantContainsHandler.cs b/backend/LexBoxApi/GraphQL/CustomFilters/QueryableStringDeterministicInvariantContainsHandler.cs index 6747f7439..532d69f04 100644 --- a/backend/LexBoxApi/GraphQL/CustomFilters/QueryableStringDeterministicInvariantContainsHandler.cs +++ b/backend/LexBoxApi/GraphQL/CustomFilters/QueryableStringDeterministicInvariantContainsHandler.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using System.Reflection; +using System.Text.RegularExpressions; using HotChocolate.Data.Filters; using HotChocolate.Data.Filters.Expressions; using HotChocolate.Language; @@ -12,9 +13,10 @@ namespace LexBoxApi.GraphQL.CustomFilters; /// Postgres doesn't support substring comparisons on nondeterministic collations, so we offer a /// case insensitive filter that explicitly uses a deterministic collation (und-x-icu) instead. /// -public class QueryableStringDeterministicInvariantContainsHandler : QueryableStringOperationHandler +public class QueryableStringDeterministicInvariantContainsHandler(InputParser inputParser) + : QueryableStringOperationHandler(inputParser) { - private static readonly MethodInfo Ilike = ((Func)NpgsqlDbFunctionsExtensions.ILike).Method; + private static readonly MethodInfo Ilike = ((Func)NpgsqlDbFunctionsExtensions.ILike).Method; private static readonly MethodInfo Collate = ((Func)RelationalDbFunctionsExtensions.Collate).Method; private static readonly ConstantExpression EfFunctions = Expression.Constant(EF.Functions); @@ -25,22 +27,19 @@ static QueryableStringDeterministicInvariantContainsHandler() } protected override int Operation => CustomFilterOperations.IContains; - - public QueryableStringDeterministicInvariantContainsHandler(InputParser inputParser) - : base(inputParser) - { - } + private static readonly Regex EscapeLikePatternRegex = new(@"([\\_%])", RegexOptions.Compiled); public override Expression HandleOperation( QueryableFilterContext context, IFilterOperationField field, IValueNode value, - object? search) + object? searchObject) { - if (search is not string) - throw new InvalidOperationException($"Expected {nameof(QueryableStringDeterministicInvariantContainsHandler)} to be called with a string, but was {search}."); + if (searchObject is not string search) + throw new InvalidOperationException($"Expected {nameof(QueryableStringDeterministicInvariantContainsHandler)} to be called with a string, but was {searchObject}."); - var pattern = $"%{search}%"; + var escapedString = EscapeLikePatternRegex.Replace(search, @"\$1"); + var pattern = $"%{escapedString}%"; var property = context.GetInstance(); var collatedValueExpression = Expression.Call( @@ -53,6 +52,8 @@ public override Expression HandleOperation( Expression.Constant("und-x-icu") ); + //this is a bit of a hack to make sure that the pattern is interpreted as a query parameter instead of a string constant. This means queries will be cached. + Expression> lambda = () => pattern; // property != null && EF.Functions.ILike(EF.Functions.Collate(property, "und-x-icu"), "%search%") return Expression.AndAlso( Expression.NotEqual(property, Expression.Constant(null, typeof(object))), @@ -61,7 +62,8 @@ public override Expression HandleOperation( Ilike, EfFunctions, collatedValueExpression, - Expression.Constant(pattern) + lambda.Body, + Expression.Constant(@"\") ) ); } diff --git a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs index 0121e0e92..0f79ccb4e 100644 --- a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs +++ b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs @@ -40,17 +40,8 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm descriptor.AddDefaults(); descriptor.AddDeterministicInvariantContainsFilter(); }) - .AddProjections(descriptor => - { - descriptor.Provider(new QueryableProjectionProvider(providerDescriptor => - { - //does not work because hot chocolate wants to make this as the select `p => new project { userCount = p.usercount}` - // which doesn't work when using projectable because the field needs to be write only - //shelving it for now - providerDescriptor.RegisterFieldHandler(); - providerDescriptor.AddDefaults(); - })); - }).SetPagingOptions(new () + .AddProjections() + .SetPagingOptions(new () { DefaultPageSize = 100, MaxPageSize = 1000, diff --git a/backend/LexBoxApi/GraphQL/LexQueries.cs b/backend/LexBoxApi/GraphQL/LexQueries.cs index f51999500..9c705d535 100644 --- a/backend/LexBoxApi/GraphQL/LexQueries.cs +++ b/backend/LexBoxApi/GraphQL/LexQueries.cs @@ -1,4 +1,5 @@ using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexCore.Auth; using LexCore.Entities; using LexCore.ServiceInterfaces; diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs index bf1a0aa5b..bf1f34bf1 100644 --- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs +++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs @@ -1,4 +1,5 @@ using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexBoxApi.GraphQL.CustomTypes; using LexBoxApi.Models.Project; using LexBoxApi.Services; diff --git a/backend/LexBoxApi/GraphQL/TestQueries.cs b/backend/LexBoxApi/GraphQL/TestQueries.cs index ef296ac06..9b2cfc8e3 100644 --- a/backend/LexBoxApi/GraphQL/TestQueries.cs +++ b/backend/LexBoxApi/GraphQL/TestQueries.cs @@ -1,4 +1,5 @@ using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexCore.Auth; namespace LexBoxApi.GraphQL; diff --git a/backend/LexBoxApi/GraphQL/UserMutations.cs b/backend/LexBoxApi/GraphQL/UserMutations.cs index 8696d3832..caeceb7e9 100644 --- a/backend/LexBoxApi/GraphQL/UserMutations.cs +++ b/backend/LexBoxApi/GraphQL/UserMutations.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexBoxApi.GraphQL.CustomTypes; using LexBoxApi.Models.Project; using LexBoxApi.Services; @@ -80,7 +81,7 @@ LexAuthService lexAuthService user.IsAdmin = adminInput.Role == UserRole.admin; } } - + user.UpdateUpdatedDate(); await dbContext.SaveChangesAsync(); if (!input.Email.IsNullOrEmpty() && !input.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase)) diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs index 85f8f4742..753b02dad 100644 --- a/backend/LexBoxApi/Program.cs +++ b/backend/LexBoxApi/Program.cs @@ -1,7 +1,9 @@ using System.Net; +using System.Text.Json; using System.Text.Json.Serialization; using LexBoxApi; using LexBoxApi.Auth; +using LexBoxApi.Auth.Attributes; using LexBoxApi.ErrorHandling; using LexBoxApi.Otel; using LexBoxApi.Services; @@ -10,7 +12,9 @@ using LexSyncReverseProxy; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.HttpLogging; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using tusdotnet; using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork; @@ -50,8 +54,10 @@ options.ModelMetadataDetailsProviders.Add(new SystemTextJsonValidationMetadataProvider()); }).AddJsonOptions(options => { - options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper)); }); +builder.Services.AddSingleton(services => + services.GetRequiredService>().Value.JsonSerializerOptions); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => diff --git a/backend/LexBoxApi/Services/EmailService.cs b/backend/LexBoxApi/Services/EmailService.cs index 59394907f..4bde9d6ce 100644 --- a/backend/LexBoxApi/Services/EmailService.cs +++ b/backend/LexBoxApi/Services/EmailService.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; using LexBoxApi.Config; using LexBoxApi.Models.Project; using LexBoxApi.Otel; @@ -16,16 +18,19 @@ namespace LexBoxApi.Services; public class EmailService { private readonly EmailConfig _emailConfig; + private readonly JsonSerializerOptions _jsonSerializerOptions; private readonly IHttpClientFactory _clientFactory; private readonly LinkGenerator _linkGenerator; private readonly IHttpContextAccessor _httpContextAccessor; public EmailService(IOptions emailConfig, + JsonSerializerOptions jsonSerializerOptions, IHttpClientFactory clientFactory, LexboxLinkGenerator linkGenerator, IHttpContextAccessor httpContextAccessor ) { + _jsonSerializerOptions = jsonSerializerOptions; _clientFactory = clientFactory; _linkGenerator = linkGenerator; _httpContextAccessor = httpContextAccessor; @@ -114,7 +119,7 @@ private async Task RenderEmail(MimeMessage message, T parameters) where T : E var httpClient = _clientFactory.CreateClient(); httpClient.BaseAddress = new Uri("http://" + _emailConfig.EmailRenderHost); parameters.BaseUrl = _emailConfig.BaseUrl; - var response = await httpClient.PostAsJsonAsync("email", parameters); + var response = await httpClient.PostAsJsonAsync("email", parameters, _jsonSerializerOptions); response.EnsureSuccessStatusCode(); var renderResult = await response.Content.ReadFromJsonAsync(); if (renderResult is null) diff --git a/backend/LexBoxApi/Services/UserService.cs b/backend/LexBoxApi/Services/UserService.cs index 00b86755e..48fe5d1e7 100644 --- a/backend/LexBoxApi/Services/UserService.cs +++ b/backend/LexBoxApi/Services/UserService.cs @@ -17,4 +17,11 @@ public async Task UpdateUserLastActive(Guid id) await _dbContext.Users.Where(u => u.Id == id) .ExecuteUpdateAsync(c => c.SetProperty(u => u.LastActive, DateTimeOffset.UtcNow)); } + + public async Task GetUserUpdatedDate(Guid id) + { + return (await _dbContext.Users.Where(u => u.Id == id) + .Select(u => u.UpdatedDate) + .SingleOrDefaultAsync()).ToUnixTimeSeconds(); + } } diff --git a/backend/LexCore/Auth/LexAuthConstants.cs b/backend/LexCore/Auth/LexAuthConstants.cs index bbfa5b325..44c83d292 100644 --- a/backend/LexCore/Auth/LexAuthConstants.cs +++ b/backend/LexCore/Auth/LexAuthConstants.cs @@ -10,6 +10,7 @@ public static class LexAuthConstants public const string ProjectsClaimType = "proj"; public const string EmailUnverifiedClaimType = "unver"; public const string CanCreateProjectClaimType = "mkproj"; + public const string UpdatedDateClaimType = "date"; } /// diff --git a/backend/LexCore/Auth/LexAuthUser.cs b/backend/LexCore/Auth/LexAuthUser.cs index 5671ad507..7467b5ab2 100644 --- a/backend/LexCore/Auth/LexAuthUser.cs +++ b/backend/LexCore/Auth/LexAuthUser.cs @@ -39,6 +39,11 @@ public record LexAuthUser case ClaimValueTypes.Boolean: jsonObject.Add(claim.Type, JsonValue.Create(bool.Parse(claim.Value))); continue; + case ClaimValueTypes.Integer: + case ClaimValueTypes.Integer32: + case ClaimValueTypes.Integer64: + jsonObject.Add(claim.Type, JsonValue.Create(int.Parse(claim.Value))); + continue; default: jsonObject.Add(claim.Type, JsonValue.Create(claim.Value)); continue; @@ -83,6 +88,7 @@ public LexAuthUser(User user) Email = user.Email; Role = user.IsAdmin ? UserRole.admin : UserRole.user; Name = user.Name; + UpdatedDate = user.UpdatedDate.ToUnixTimeSeconds(); Projects = user.IsAdmin ? Array.Empty() // admins have access to all projects, so we don't include them to prevent going over the jwt limit : user.Projects.Select(p => new AuthUserProject(p.Role, p.ProjectId)).ToArray(); @@ -92,7 +98,8 @@ public LexAuthUser(User user) [JsonPropertyName(LexAuthConstants.IdClaimType)] public required Guid Id { get; set; } - + [JsonPropertyName(LexAuthConstants.UpdatedDateClaimType)] + public required long UpdatedDate { get; set; } [JsonPropertyName(LexAuthConstants.AudienceClaimType)] public LexboxAudience Audience { get; set; } = LexboxAudience.LexboxApi; @@ -183,6 +190,9 @@ public IEnumerable GetClaims() break; case JsonValueKind.Null: break; + case JsonValueKind.Number: + yield return new Claim(jsonProperty.Name, jsonProperty.Value.ToString(), ClaimValueTypes.Integer); + break; default: yield return new Claim(jsonProperty.Name, jsonProperty.Value.ToString()); break; diff --git a/backend/LexCore/Auth/LexboxAudience.cs b/backend/LexCore/Auth/LexboxAudience.cs index df9a8f046..a40c40bfe 100644 --- a/backend/LexCore/Auth/LexboxAudience.cs +++ b/backend/LexCore/Auth/LexboxAudience.cs @@ -12,11 +12,3 @@ public enum LexboxAudience ForgotPassword, SendAndReceive, } - -public static class LexboxAudienceHelper -{ - public static string PolicyName(this LexboxAudience audience, bool exclusive) - { - return $"RequireAudience{audience}{(exclusive ? "Exclusive" : "")}Policy"; - } -} diff --git a/backend/LexCore/Entities/EntityBase.cs b/backend/LexCore/Entities/EntityBase.cs index 4d4de8212..a7d268c71 100644 --- a/backend/LexCore/Entities/EntityBase.cs +++ b/backend/LexCore/Entities/EntityBase.cs @@ -5,4 +5,5 @@ public class EntityBase public Guid Id { get; init; } public DateTimeOffset CreatedDate { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedDate { get; set; } = DateTimeOffset.UtcNow; + public void UpdateUpdatedDate() => UpdatedDate = DateTimeOffset.UtcNow; } diff --git a/backend/Testing/ApiTests/ApiTestBase.cs b/backend/Testing/ApiTests/ApiTestBase.cs index 26fb4c8a6..f0ad43998 100644 --- a/backend/Testing/ApiTests/ApiTestBase.cs +++ b/backend/Testing/ApiTests/ApiTestBase.cs @@ -10,7 +10,7 @@ namespace Testing.ApiTests; public class ApiTestBase { - public readonly string BaseUrl = TestingEnvironmentVariables.ServerBaseUrl; + public string BaseUrl => TestingEnvironmentVariables.ServerBaseUrl; private readonly HttpClientHandler _httpClientHandler = new(); public readonly HttpClient HttpClient; diff --git a/backend/Testing/Browser/Base/PageTest.cs b/backend/Testing/Browser/Base/PageTest.cs index 432873744..14fde9f4d 100644 --- a/backend/Testing/Browser/Base/PageTest.cs +++ b/backend/Testing/Browser/Base/PageTest.cs @@ -152,8 +152,11 @@ protected async Task RegisterUser(string name, string ema private async Task GetCurrentUserId() { var userResponse = await Page.APIRequest.GetAsync($"{TestingEnvironmentVariables.ServerBaseUrl}/api/user/currentUser"); - var user = await userResponse.JsonAsync(); - return user.ShouldNotBeNull().Id; + + //can't configure playwright json deserialize options to set enum serialization + //so just use JsonElement and pull out the Id + var user = await userResponse.JsonAsync(); + return user.ShouldNotBeNull().GetProperty(LexAuthConstants.IdClaimType).GetGuid(); } } diff --git a/backend/Testing/Browser/EmailWorkflowTests.cs b/backend/Testing/Browser/EmailWorkflowTests.cs index 5e0ef5575..069527466 100644 --- a/backend/Testing/Browser/EmailWorkflowTests.cs +++ b/backend/Testing/Browser/EmailWorkflowTests.cs @@ -1,4 +1,5 @@ -using Testing.Browser.Base; +using Shouldly; +using Testing.Browser.Base; using Testing.Browser.Page; using Testing.Browser.Page.External; @@ -81,6 +82,8 @@ public async Task ForgotPassword() // Step: Use reset password link var inboxPage = await MailInboxPage.Get(Page, mailinatorId).Goto(); var emailPage = await inboxPage.OpenEmail(); + var resetPasswordUrl = await emailPage.GetFirstLanguageDepotUrl(); + resetPasswordUrl.ShouldNotBeNull().ShouldContain("resetpassword"); var newPage = await Page.Context.RunAndWaitForPageAsync(emailPage.ClickResetPassword); var resetPasswordPage = await new ResetPasswordPage(newPage).WaitFor(); @@ -94,5 +97,10 @@ public async Task ForgotPassword() await loginPage.FillForm(email, newPassword); await loginPage.Submit(); await userDashboardPage.WaitFor(); + + // Step: Verify email link has expired + await Page.GotoAsync(resetPasswordUrl); + loginPage = await new LoginPage(Page).WaitFor(); + await Expect(loginPage.Page.GetByText("The email you clicked has expired")).ToBeVisibleAsync(); } } diff --git a/backend/Testing/Browser/Page/BasePage.cs b/backend/Testing/Browser/Page/BasePage.cs index d1d802172..838a7cb18 100644 --- a/backend/Testing/Browser/Page/BasePage.cs +++ b/backend/Testing/Browser/Page/BasePage.cs @@ -46,13 +46,16 @@ public async Task WaitFor() { if (UrlPattern is not null) { + //assert to get a good error message + await Assertions.Expect(Page).ToHaveURLAsync(UrlPattern); + //still wait to make sure we reach the same state we expect await Page.WaitForURLAsync(UrlPattern, new() { WaitUntil = WaitUntilState.Load }); } else { await Page.WaitForLoadStateAsync(LoadState.Load); } - await Task.WhenAll(TestLocators.Select(l => l.WaitForAsync())); + await Task.WhenAll(TestLocators.Select(l => Assertions.Expect(l).ToBeVisibleAsync())); return (T)this; } } diff --git a/backend/Testing/LexCore/LexAuthUserTests.cs b/backend/Testing/LexCore/LexAuthUserTests.cs index 170c5c618..f5cb33c72 100644 --- a/backend/Testing/LexCore/LexAuthUserTests.cs +++ b/backend/Testing/LexCore/LexAuthUserTests.cs @@ -32,6 +32,7 @@ static LexAuthUserTests() Email = "test@test.com", Role = UserRole.user, Name = "test", + UpdatedDate = DateTimeOffset.Now.ToUnixTimeSeconds(), Projects = new[] { new AuthUserProject(ProjectRole.Manager, Guid.NewGuid()) } }; diff --git a/backend/Testing/Services/TestingEnvironmentVariables.cs b/backend/Testing/Services/TestingEnvironmentVariables.cs index 94558be84..35b85f655 100644 --- a/backend/Testing/Services/TestingEnvironmentVariables.cs +++ b/backend/Testing/Services/TestingEnvironmentVariables.cs @@ -2,7 +2,7 @@ namespace Testing.Services; public static class TestingEnvironmentVariables { - public static readonly string ServerHostname = Environment.GetEnvironmentVariable("TEST_SERVER_HOSTNAME") ?? "localhost"; + public static string ServerHostname = Environment.GetEnvironmentVariable("TEST_SERVER_HOSTNAME") ?? "localhost"; public static readonly bool IsDev = ServerHostname.StartsWith("localhost"); public static string HttpScheme = IsDev ? "http://" : "https://"; public static string ServerBaseUrl => $"{HttpScheme}{ServerHostname}"; diff --git a/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs b/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs index 5c4681c5b..abbdfd50c 100644 --- a/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs +++ b/backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs @@ -75,6 +75,7 @@ public async Task VerifyHgWorking() { string version = await _sendReceiveService.GetHgVersion(); version.ShouldStartWith("Mercurial Distributed SCM"); + _output.WriteLine("Hg version: " + version); HgRunner.Run("hg version", Environment.CurrentDirectory, 5, new XunitStringBuilderProgress(_output) {ShowVerbose = true}); HgRepository.GetEnvironmentReadinessMessage("en").ShouldBeNull(); } diff --git a/deployment/base/hg-deployment.yaml b/deployment/base/hg-deployment.yaml index a16a31b3e..4d1b55ebc 100644 --- a/deployment/base/hg-deployment.yaml +++ b/deployment/base/hg-deployment.yaml @@ -65,6 +65,13 @@ spec: ports: - containerPort: 8088 + startupProbe: + httpGet: + path: / + port: 8088 + failureThreshold: 30 + periodSeconds: 10 + volumeMounts: - name: repos mountPath: /var/hg/repos diff --git a/deployment/production/ingress-config-prod.yaml b/deployment/production/ingress-config-prod.yaml index 2d2c30de9..ae155b2b3 100644 --- a/deployment/production/ingress-config-prod.yaml +++ b/deployment/production/ingress-config-prod.yaml @@ -1,9 +1,9 @@ - op: replace path: /spec/rules/0/host - value: prod.languagedepot.org + value: languagedepot.org - op: replace path: /spec/rules/1/host - value: hg-prod.languageforge.org + value: hg-public.languageforge.org - op: replace path: /spec/rules/2/host value: resumable.languagedepot.org @@ -13,9 +13,7 @@ - op: replace path: /spec/tls/0/hosts value: - - prod.languagedepot.org - languagedepot.org - - hg-prod.languageforge.org - resumable.languagedepot.org - resumable.languageforge.org - hg-public.languagedepot.org @@ -37,11 +35,6 @@ name: lexbox port: name: http -- op: add - path: /spec/rules/- - value: - host: hg-public.languageforge.org - http: *rule - op: add path: /spec/rules/- value: @@ -62,37 +55,3 @@ value: host: admin.languageforge.org http: *rule -- op: add - path: /spec/rules/- - value: - host: languagedepot.org - http: - paths: - - path: /api - pathType: Prefix - backend: - service: - name: lexbox - port: - name: http - - path: /hg - pathType: Prefix - backend: - service: - name: lexbox - port: - name: http - - path: /v1/traces - pathType: Prefix - backend: - service: - name: lexbox - port: - name: otel - - path: / - pathType: Prefix - backend: - service: - name: ui - port: - name: sveltekit diff --git a/deployment/production/lexbox-deployment.patch.yaml b/deployment/production/lexbox-deployment.patch.yaml index 42c22e35d..3f903858e 100644 --- a/deployment/production/lexbox-deployment.patch.yaml +++ b/deployment/production/lexbox-deployment.patch.yaml @@ -24,7 +24,7 @@ spec: # TODO: need to parameterize this value: "Language Depot " - name: Email__BaseUrl - value: "https://prod.languagedepot.org" + value: "https://languagedepot.org" - name: HgConfig__PublicRedmineHgWebUrl value: "https://hg-public-redmine.languagedepot.org/" - name: HgConfig__PrivateRedmineHgWebUrl diff --git a/frontend/schema.graphql b/frontend/schema.graphql index aa8f291fa..72061393d 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -91,6 +91,7 @@ type IsAdminResponse { type LexAuthUser { id: UUID! + updatedDate: DateTime! audience: LexboxAudience! email: String! name: String! diff --git a/frontend/src/lib/components/IconButton.svelte b/frontend/src/lib/components/IconButton.svelte index fc90feb36..ed9b61071 100644 --- a/frontend/src/lib/components/IconButton.svelte +++ b/frontend/src/lib/components/IconButton.svelte @@ -9,6 +9,9 @@ export let active = false; export let join = false; export let style: CssClassList<'btn-success', 'btn-ghost' | 'btn-outline'> = 'btn-outline'; + export let active = false; + export let join = false; +