Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/develop' into chore/openid-connect
Browse files Browse the repository at this point in the history
  • Loading branch information
myieye committed May 28, 2024
2 parents d4940a6 + b4e4061 commit c475d7b
Show file tree
Hide file tree
Showing 49 changed files with 639 additions and 172 deletions.
7 changes: 4 additions & 3 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,12 @@ jobs:
curl -s --head "$TARGET" > response.txt
# get version from response, trim off the header and fix the line endings
versionHeader=$((grep "lexbox-version" response.txt || echo VersionNotFound) | cut -d' ' -f 2 | tr -d '[:space:]')
if [[ "$versionHeader" == "$EXPECTED_VERSION" ]]; then
echo "Version is correct"
status_code=$(grep -oP "HTTP\/\d(\.\d)? \K\d+" response.txt)
if [[ "$versionHeader" == "$EXPECTED_VERSION" && "$status_code" == "200" ]]; then
echo "Version and status code are correct"
exit 0
else
echo "Version '$versionHeader' is incorrect, expected '$EXPECTED_VERSION'"
echo "Health check failed, Version '$versionHeader', expected '$EXPECTED_VERSION', status code '$status_code'"
n=$((n+1))
sleep $((DelayMultiplier * n))
fi
Expand Down
82 changes: 64 additions & 18 deletions backend/LexBoxApi/Controllers/UserController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using LexBoxApi.Auth;
using LexBoxApi.Auth.Attributes;
using LexBoxApi.Models;
using LexBoxApi.Otel;
using LexBoxApi.Services;
Expand Down Expand Up @@ -65,27 +66,51 @@ public async Task<ActionResult<LexAuthUser>> RegisterAccount(RegisterAccountInpu
return ValidationProblem(ModelState);
}

var jwtUser = _loggedInContext.MaybeUser;
var emailVerified = jwtUser?.Email == accountInput.Email;
var userEntity = CreateUserEntity(accountInput, emailVerified: false);
registerActivity?.AddTag("app.user.id", userEntity.Id);
_lexBoxDbContext.Users.Add(userEntity);
await _lexBoxDbContext.SaveChangesAsync();

var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes));
var userEntity = new User
var user = new LexAuthUser(userEntity);
await HttpContext.SignInAsync(user.GetPrincipal("Registration"),
new AuthenticationProperties { IsPersistent = true });

await _emailService.SendVerifyAddressEmail(userEntity);
return Ok(user);
}

[HttpPost("acceptInvitation")]
[RequireAudience(LexboxAudience.RegisterAccount, true)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesErrorResponseType(typeof(Dictionary<string, string[]>))]
[ProducesDefaultResponseType]
public async Task<ActionResult<LexAuthUser>> AcceptEmailInvitation(RegisterAccountInput accountInput)
{
using var acceptActivity = LexBoxActivitySource.Get().StartActivity("AcceptInvitation");
var validToken = await _turnstileService.IsTokenValid(accountInput.TurnstileToken, accountInput.Email);
acceptActivity?.AddTag("app.turnstile_token_valid", validToken);
if (!validToken)
{
Id = Guid.NewGuid(),
Name = accountInput.Name,
Email = accountInput.Email,
LocalizationCode = accountInput.Locale,
Salt = salt,
PasswordHash = PasswordHashing.HashPassword(accountInput.PasswordHash, salt, true),
PasswordStrength = UserService.ClampPasswordStrength(accountInput.PasswordStrength),
IsAdmin = false,
EmailVerified = emailVerified,
Locked = false,
CanCreateProjects = false
};
registerActivity?.AddTag("app.user.id", userEntity.Id);
ModelState.AddModelError<RegisterAccountInput>(r => r.TurnstileToken, "token invalid");
return ValidationProblem(ModelState);
}

var jwtUser = _loggedInContext.User;

var hasExistingUser = await _lexBoxDbContext.Users.FilterByEmailOrUsername(accountInput.Email).AnyAsync();
acceptActivity?.AddTag("app.email_available", !hasExistingUser);
if (hasExistingUser)
{
ModelState.AddModelError<RegisterAccountInput>(r => r.Email, "email already in use");
return ValidationProblem(ModelState);
}

var emailVerified = jwtUser.Email == accountInput.Email;
var userEntity = CreateUserEntity(accountInput, emailVerified);
acceptActivity?.AddTag("app.user.id", userEntity.Id);
_lexBoxDbContext.Users.Add(userEntity);
if (jwtUser is not null && jwtUser.Projects.Length > 0)
// This audience check is redundant now because of [RequireAudience(LexboxAudience.RegisterAccount, true)], but let's leave it in for safety
if (jwtUser.Audience == LexboxAudience.RegisterAccount && jwtUser.Projects.Length > 0)
{
userEntity.Projects = jwtUser.Projects.Select(p => new ProjectUsers { Role = p.Role, ProjectId = p.ProjectId }).ToList();
}
Expand All @@ -99,6 +124,27 @@ await HttpContext.SignInAsync(user.GetPrincipal("Registration"),
return Ok(user);
}

private User CreateUserEntity(RegisterAccountInput input, bool emailVerified, Guid? creatorId = null)
{
var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes));
var userEntity = new User
{
Id = Guid.NewGuid(),
Name = input.Name,
Email = input.Email,
LocalizationCode = input.Locale,
Salt = salt,
PasswordHash = PasswordHashing.HashPassword(input.PasswordHash, salt, true),
PasswordStrength = UserService.ClampPasswordStrength(input.PasswordStrength),
IsAdmin = false,
EmailVerified = emailVerified,
CreatedById = creatorId,
Locked = false,
CanCreateProjects = false
};
return userEntity;
}

[HttpPost("sendVerificationEmail")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand Down
21 changes: 16 additions & 5 deletions backend/LexBoxApi/GraphQL/ProjectMutations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,11 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
BulkAddProjectMembersInput input,
LexBoxDbContext dbContext)
{
var project = await dbContext.Projects.FindAsync(input.ProjectId);
if (project is null) throw new NotFoundException("Project not found", "project");
if (input.ProjectId.HasValue)
{
var projectExists = await dbContext.Projects.AnyAsync(p => p.Id == input.ProjectId.Value);
if (!projectExists) throw new NotFoundException("Project not found", "project");
}
List<UserProjectRole> AddedMembers = [];
List<UserProjectRole> CreatedMembers = [];
List<UserProjectRole> ExistingMembers = [];
Expand Down Expand Up @@ -154,10 +157,13 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
CanCreateProjects = false
};
CreatedMembers.Add(new UserProjectRole(usernameOrEmail, input.Role));
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId, UserId = user.Id });
if (input.ProjectId.HasValue)
{
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id });
}
dbContext.Add(user);
}
else
else if (input.ProjectId.HasValue)
{
var userProject = user.Projects.FirstOrDefault(p => p.ProjectId == input.ProjectId);
if (userProject is not null)
Expand All @@ -168,9 +174,14 @@ public async Task<BulkAddProjectMembersResult> BulkAddProjectMembers(
{
AddedMembers.Add(new UserProjectRole(user.Username ?? user.Email!, input.Role));
// Not yet a member, so add a membership. We don't want to touch existing memberships, which might have other roles
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId, UserId = user.Id });
user.Projects.Add(new ProjectUsers { Role = input.Role, ProjectId = input.ProjectId.Value, UserId = user.Id });
}
}
else
{
// No project ID specified, user already exists. This is probably part of bulk-adding through the admin dashboard or org page.
ExistingMembers.Add(new UserProjectRole(user.Username ?? user.Email!, ProjectRole.Unknown));
}
}
await dbContext.SaveChangesAsync();
return new BulkAddProjectMembersResult(AddedMembers, CreatedMembers, ExistingMembers);
Expand Down
60 changes: 60 additions & 0 deletions backend/LexBoxApi/GraphQL/UserMutations.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using LexBoxApi.Auth;
using LexBoxApi.Auth.Attributes;
using LexBoxApi.GraphQL.CustomTypes;
using LexBoxApi.Models.Project;
using LexBoxApi.Otel;
using LexBoxApi.Services;
using LexCore;
using LexCore.Auth;
using LexCore.Entities;
using LexCore.Exceptions;
using LexCore.ServiceInterfaces;
using LexData;
using LexData.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
Expand All @@ -23,6 +27,13 @@ public record ChangeUserAccountBySelfInput(Guid UserId, string? Email, string Na
: ChangeUserAccountDataInput(UserId, Email, Name);
public record ChangeUserAccountByAdminInput(Guid UserId, string? Email, string Name, UserRole Role)
: ChangeUserAccountDataInput(UserId, Email, Name);
public record CreateGuestUserByAdminInput(
string? Email,
string Name,
string? Username,
string Locale,
string PasswordHash,
int PasswordStrength);

[Error<NotFoundException>]
[Error<DbError>]
Expand Down Expand Up @@ -63,6 +74,55 @@ EmailService emailService
return UpdateUser(loggedInContext, permissionService, input, dbContext, emailService);
}

[Error<NotFoundException>]
[Error<DbError>]
[Error<UniqueValueException>]
[Error<RequiredException>]
[AdminRequired]
public async Task<LexAuthUser> CreateGuestUserByAdmin(
LoggedInContext loggedInContext,
CreateGuestUserByAdminInput input,
LexBoxDbContext dbContext,
EmailService emailService
)
{
using var createGuestUserActivity = LexBoxActivitySource.Get().StartActivity("CreateGuestUser");

var hasExistingUser = input.Email is null && input.Username is null
? throw new RequiredException("Guest users must have either an email or a username")
: await dbContext.Users.FilterByEmailOrUsername(input.Email ?? input.Username!).AnyAsync();
createGuestUserActivity?.AddTag("app.email_available", !hasExistingUser);
if (hasExistingUser) throw new UniqueValueException("Email");

var admin = loggedInContext.User;

var salt = Convert.ToHexString(RandomNumberGenerator.GetBytes(SHA1.HashSizeInBytes));
var userEntity = new User
{
Id = Guid.NewGuid(),
Name = input.Name,
Email = input.Email,
Username = input.Username,
LocalizationCode = input.Locale,
Salt = salt,
PasswordHash = PasswordHashing.HashPassword(input.PasswordHash, salt, true),
PasswordStrength = UserService.ClampPasswordStrength(input.PasswordStrength),
IsAdmin = false,
EmailVerified = false,
CreatedById = admin.Id,
Locked = false,
CanCreateProjects = false
};
createGuestUserActivity?.AddTag("app.user.id", userEntity.Id);
dbContext.Users.Add(userEntity);
await dbContext.SaveChangesAsync();
if (!string.IsNullOrEmpty(input.Email))
{
await emailService.SendVerifyAddressEmail(userEntity);
}
return new LexAuthUser(userEntity);
}

private static async Task<User> UpdateUser(
LoggedInContext loggedInContext,
IPermissionService permissionService,
Expand Down
2 changes: 1 addition & 1 deletion backend/LexBoxApi/LexBoxApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,6 @@
</ItemGroup>

<ItemGroup>
<Content Include="Services\HgEmptyRepo\**" CopyToOutputDirectory="Always" />
<Content Include="Services\HgEmptyRepo\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
9 changes: 6 additions & 3 deletions backend/LexBoxApi/LexBoxKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using LexCore.Config;
using LexCore.ServiceInterfaces;
using LexSyncReverseProxy;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Swashbuckle.AspNetCore.Swagger;

namespace LexBoxApi;
Expand All @@ -23,9 +24,9 @@ public static void AddLexBoxApi(this IServiceCollection services,
.ValidateDataAnnotations()
.ValidateOnStart();
// services.AddOptions<HasuraConfig>()
// .BindConfiguration("HasuraConfig")
// .ValidateDataAnnotations()
// .ValidateOnStart();
// .BindConfiguration("HasuraConfig")
// .ValidateDataAnnotations()
// .ValidateOnStart();
services.AddOptions<CloudFlareConfig>()
.BindConfiguration("CloudFlare")
.ValidateDataAnnotations()
Expand Down Expand Up @@ -53,13 +54,15 @@ public static void AddLexBoxApi(this IServiceCollection services,
services.AddScoped<TusService>();
services.AddScoped<TurnstileService>();
services.AddScoped<IHgService, HgService>();
services.AddTransient<HgWebHealthCheck>();
services.AddScoped<IIsLanguageForgeProjectDataLoader, IsLanguageForgeProjectDataLoader>();
services.AddScoped<ILexProxyService, LexProxyService>();
services.AddSingleton<ISendReceiveService, SendReceiveService>();
services.AddSingleton<LexboxLinkGenerator>();
if (environment.IsDevelopment())
services.AddHostedService<SwaggerValidationService>();
services.AddScheduledTasks(configuration);
services.AddHealthChecks().AddCheck<HgWebHealthCheck>("hgweb", HealthStatus.Unhealthy, ["hg"], TimeSpan.FromSeconds(5));
services.AddSyncProxy();
AuthKernel.AddLexBoxAuth(services, configuration, environment);
services.AddLexGraphQL(environment);
Expand Down
2 changes: 1 addition & 1 deletion backend/LexBoxApi/Models/Project/ProjectMemberInputs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ namespace LexBoxApi.Models.Project;

public record AddProjectMemberInput(Guid ProjectId, string UsernameOrEmail, ProjectRole Role);

public record BulkAddProjectMembersInput(Guid ProjectId, string[] Usernames, ProjectRole Role, string PasswordHash);
public record BulkAddProjectMembersInput(Guid? ProjectId, string[] Usernames, ProjectRole Role, string PasswordHash);

public record ChangeProjectMemberRoleInput(Guid ProjectId, Guid UserId, ProjectRole Role);
2 changes: 1 addition & 1 deletion backend/LexBoxApi/Services/EmailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ public async Task SendCreateAccountEmail(string emailAddress,
var httpContext = httpContextAccessor.HttpContext;
ArgumentNullException.ThrowIfNull(httpContext);
var queryString = QueryString.Create("email", emailAddress);
var returnTo = new UriBuilder() { Path = "/register", Query = queryString.Value }.Uri.PathAndQuery;
var returnTo = new UriBuilder() { Path = "/acceptInvitation", Query = queryString.Value }.Uri.PathAndQuery;
var registerLink = _linkGenerator.GetUriByAction(httpContext,
"LoginRedirect",
"Login",
Expand Down
7 changes: 7 additions & 0 deletions backend/LexBoxApi/Services/HgService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,13 @@ public async Task<HttpContent> ExecuteHgRecover(string code, CancellationToken t
return int.TryParse(str, out int result) ? result : null;
}

public async Task<string> HgCommandHealth()
{
var content = await ExecuteHgCommandServerCommand("health", "healthz", default);
var version = await content.ReadAsStringAsync();
return version.Trim();
}

private async Task<HttpContent> ExecuteHgCommandServerCommand(string code, string command, CancellationToken token)
{
var httpClient = _hgClient.Value;
Expand Down
25 changes: 25 additions & 0 deletions backend/LexBoxApi/Services/HgWebHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using LexCore.Config;
using LexCore.ServiceInterfaces;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;

namespace LexBoxApi.Services;

public class HgWebHealthCheck(IHgService hgService, IOptions<HgConfig> hgOptions) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context,
CancellationToken cancellationToken = new())
{
var version = await hgService.HgCommandHealth();
if (string.IsNullOrEmpty(version))
{
return HealthCheckResult.Unhealthy();
}
if (hgOptions.Value.RequireContainerVersionMatch && version != AppVersionService.Version)
{
return HealthCheckResult.Degraded(
$"api version: '{AppVersionService.Version}' hg version: '{version}' mismatch");
}
return HealthCheckResult.Healthy();
}
}
14 changes: 11 additions & 3 deletions backend/LexBoxApi/dev.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ EXPOSE 80
EXPOSE 443
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt update && apt-get --no-install-recommends install -y rsync ssh
apt update && apt-get --no-install-recommends install -y tini
RUN mkdir -p /var/www && chown -R www-data:www-data /var/www
USER www-data:www-data
WORKDIR /src/backend
Expand All @@ -15,7 +15,15 @@ RUN for file in $(ls *.csproj); do dir=${file%.*} mkdir -p ${file%.*}/ && mv $fi

COPY --chown=www-data . .
WORKDIR /src/backend/LexBoxApi
RUN dotnet build #build and restore, should speed up watch run
#build here so that the build is run before container start, need to make sure the property is set both here
#and in the CMD command, otherwise it will rebuild every time the container starts
RUN dotnet build --property:InformationalVersion=dockerDev
RUN mkdir /src/frontend

#ensures the shutdown happens quickly
ENTRYPOINT ["tini", "--"]

# no need to restore because we already restored as part of building the image
CMD dotnet watch run -lp docker --property:InformationalVersion=dockerDev --no-hot-reload --no-restore
ENV ASPNETCORE_ENVIRONMENT=Development
ENV DOTNET_URLS=http://0.0.0.0:5158
CMD dotnet watch --no-hot-reload run --property:InformationalVersion=dockerDev --no-restore
1 change: 1 addition & 0 deletions backend/LexCore/Config/HgConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ public class HgConfig
[Required, Url]
public required string HgResumableUrl { get; init; }
public bool AutoUpdateLexEntryCountOnSendReceive { get; init; } = false;
public bool RequireContainerVersionMatch { get; init; } = true;
}
1 change: 1 addition & 0 deletions backend/LexCore/ServiceInterfaces/IHgService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ public interface IHgService
Task<string?> GetRepositoryIdentifier(Project project);
Task<HttpContent> ExecuteHgRecover(string code, CancellationToken token);
bool HasAbandonedTransactions(string projectCode);
Task<string> HgCommandHealth();
}
Loading

0 comments on commit c475d7b

Please sign in to comment.