Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Open with FLEx button #442

Merged
merged 2 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/LexBoxApi/Auth/JwtOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public class JwtOptions
Lifetime = TimeSpan.FromMinutes(1),
RefreshLifetime = TimeSpan.FromMinutes(1),
EmailJwtLifetime = TimeSpan.FromMinutes(1),
SendReceiveJwtLifetime = TimeSpan.FromHours(12),
SendReceiveRefreshJwtLifetime = TimeSpan.FromDays(90),
Secret = "this is only a test but must be long",
ClockSkew = TimeSpan.Zero
};
Expand All @@ -20,6 +22,10 @@ public class JwtOptions
public required TimeSpan Lifetime { get; init; }
[Required]
public required TimeSpan EmailJwtLifetime { get; init; }
[Required]
public required TimeSpan SendReceiveJwtLifetime { get; init; }
[Required]
public required TimeSpan SendReceiveRefreshJwtLifetime { get; init; }

[Required]
public required TimeSpan RefreshLifetime { get; init; }
Expand Down
16 changes: 12 additions & 4 deletions backend/LexBoxApi/Auth/LexAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,22 @@ await context.SignInAsync(jwtUser.GetPrincipal("Refresh"),
return (user == null ? null : new LexAuthUser(user), user);
}

public (string token, TimeSpan tokenLifetime) GenerateJwt(LexAuthUser user,
public (string token, DateTime expiresAt) GenerateJwt(LexAuthUser user,
LexboxAudience audience = LexboxAudience.LexboxApi,
bool useEmailLifetime = false)
{
var options = _userOptions.Value;
return GenerateToken(user, audience, useEmailLifetime ? options.EmailJwtLifetime : options.Lifetime);
var lifetime = (audience, useEmailLifetime) switch
{
(_, true) => options.EmailJwtLifetime,
(LexboxAudience.SendAndReceive, _) => options.SendReceiveJwtLifetime,
(LexboxAudience.SendAndReceiveRefresh, _) => options.SendReceiveRefreshJwtLifetime,
_ => options.Lifetime
};
return GenerateToken(user, audience, lifetime);
}

private (string token, TimeSpan tokenLifetime) GenerateToken(LexAuthUser user,
private (string token, DateTime expiresAt) GenerateToken(LexAuthUser user,
LexboxAudience audience,
TimeSpan tokenLifetime)
{
Expand All @@ -142,6 +149,7 @@ await context.SignInAsync(jwtUser.GetPrincipal("Refresh"),
);
JwtTicketDataFormat.FixUpArrayClaims(jwt);
var token = handler.WriteToken(jwt);
return (token, tokenLifetime);

return (token, jwt.ValidTo.ToUniversalTime());
}
}
79 changes: 79 additions & 0 deletions backend/LexBoxApi/Controllers/IntegrationController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using LexBoxApi.Auth;
using LexBoxApi.Auth.Attributes;
using LexBoxApi.Services;
using LexCore.Auth;
using LexCore.Config;
using LexCore.ServiceInterfaces;
using LexData;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;

namespace LexBoxApi.Controllers;

[ApiController]
[Route("/api/integration")]
public class IntegrationController(
LexBoxDbContext lexBoxDbContext,
LexAuthService authService,
LoggedInContext loggedInContext,
IHgService hgService,
IPermissionService permissionService,
IOptions<HgConfig> hgConfigOptions,
IHostEnvironment hostEnvironment,
ProjectService projectService)
: ControllerBase
{
private readonly string _protocol = hostEnvironment.IsDevelopment() ? "http" : "https";
private readonly HgConfig _hgConfig = hgConfigOptions.Value;

[HttpGet("openWithFlex")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status302Found)]
public async Task<ActionResult> OpenWithFlex(Guid projectId)
{
if (!permissionService.CanAccessProject(projectId)) return Unauthorized();
var project = await lexBoxDbContext.Projects.FirstOrDefaultAsync(p => p.Id == projectId);
if (project is null) return NotFound();
var repoId = await hgService.GetRepositoryIdentifier(project);
var (projectToken, _, flexToken, _) = GetRefreshResponse(projectId);
var projectUri = $"{_protocol}://{_hgConfig.SendReceiveDomain}/{project.Code}";
var queryString = new QueryString()
.Add("db", project.Code)
.Add("user", "bearer")
.Add("password", projectToken)
.Add("flexRefreshToken", flexToken)
.Add("projectUri", projectUri)
//could be empty if the repo hasn't been pushed to yet
.Add("repositoryIdentifier", repoId ?? "");
return Redirect($"silfw://localhost/link{queryString.Value}");
}

[HttpGet("getProjectToken")]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
//todo make exclusive to prevent calling with normal jwt, currently used for testing
[RequireAudience(LexboxAudience.SendAndReceiveRefresh)]
public async Task<ActionResult<RefreshResponse>> GetProjectToken(string projectCode)
{
var projectId = await projectService.LookupProjectId(projectCode);
if (projectId == default) return NotFound();
return GetRefreshResponse(projectId);
}

private RefreshResponse GetRefreshResponse(Guid projectId)
{
var user = loggedInContext.User;
//generates a short lived token only useful for S&R of this one project
var (projectToken, projectTokenExpiresAt) = authService.GenerateJwt(
user with { Projects = user.Projects.Where(p => p.ProjectId == projectId).ToArray() },
LexboxAudience.SendAndReceive);

//refresh long lived token used to get new tokens
var (flexToken, flexTokenExpiresAt) =
authService.GenerateJwt(user with { Projects = [] }, LexboxAudience.SendAndReceiveRefresh);
return new RefreshResponse(projectToken, projectTokenExpiresAt, flexToken, flexTokenExpiresAt);
}
}
69 changes: 28 additions & 41 deletions backend/LexBoxApi/Controllers/LoginController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using LexCore.Auth;
using LexData;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
Expand All @@ -18,30 +19,16 @@ namespace LexBoxApi.Controllers;

[ApiController]
[Route("/api/login")]
public class LoginController : ControllerBase
public class LoginController(
LexAuthService lexAuthService,
LexBoxDbContext lexBoxDbContext,
LoggedInContext loggedInContext,
EmailService emailService,
UserService userService,
TurnstileService turnstileService,
ProjectService projectService)
: ControllerBase
{
private readonly LexAuthService _lexAuthService;
private readonly LexBoxDbContext _lexBoxDbContext;
private readonly LoggedInContext _loggedInContext;
private readonly EmailService _emailService;
private readonly UserService _userService;
private readonly TurnstileService _turnstileService;

public LoginController(LexAuthService lexAuthService,
LexBoxDbContext lexBoxDbContext,
LoggedInContext loggedInContext,
EmailService emailService,
UserService userService,
TurnstileService turnstileService)
{
_lexAuthService = lexAuthService;
_lexBoxDbContext = lexBoxDbContext;
_loggedInContext = loggedInContext;
_emailService = emailService;
_userService = userService;
_turnstileService = turnstileService;
}

/// <summary>
/// this endpoint is called when we can only pass a jwt in the query string. It redirects to the requested path
/// and logs in using that jwt with a cookie
Expand All @@ -53,8 +40,8 @@ public async Task<ActionResult> 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);
var user = loggedInContext.User;
var userUpdatedDate = await userService.GetUserUpdatedDate(user.Id);
if (userUpdatedDate != user.UpdatedDate)
{
return await EmailLinkExpired();
Expand All @@ -78,26 +65,26 @@ public async Task<ActionResult<LexAuthUser>> VerifyEmail(
string jwt, // This is required because auth looks for a jwt in the query string
string returnTo)
{
if (_loggedInContext.User.EmailVerificationRequired == true)
if (loggedInContext.User.EmailVerificationRequired == true)
{
return Unauthorized();
}

var userId = _loggedInContext.User.Id;
var user = await _lexBoxDbContext.Users.FindAsync(userId);
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)
if (user.Email != loggedInContext.User.Email &&
user.UpdatedDate.ToUnixTimeSeconds() != loggedInContext.User.UpdatedDate)
{
return await EmailLinkExpired();
}

user.Email = _loggedInContext.User.Email;
user.Email = loggedInContext.User.Email;
user.EmailVerified = true;
user.UpdateUpdatedDate();
await _lexBoxDbContext.SaveChangesAsync();
await lexBoxDbContext.SaveChangesAsync();
await RefreshJwt();
return Redirect(returnTo);
}
Expand All @@ -108,9 +95,9 @@ public async Task<ActionResult<LexAuthUser>> VerifyEmail(
[AllowAnonymous]
public async Task<ActionResult<LexAuthUser>> Login(LoginRequest loginRequest)
{
var user = await _lexAuthService.Login(loginRequest);
var user = await lexAuthService.Login(loginRequest);
if (user == null) return Unauthorized();
await _userService.UpdateUserLastActive(user.Id);
await userService.UpdateUserLastActive(user.Id);
await HttpContext.SignInAsync(user.GetPrincipal("Password"),
new AuthenticationProperties { IsPersistent = true });
return user;
Expand All @@ -121,7 +108,7 @@ await HttpContext.SignInAsync(user.GetPrincipal("Password"),
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<LexAuthUser>> RefreshJwt()
{
var user = await _lexAuthService.RefreshUser(_loggedInContext.User.Id);
var user = await lexAuthService.RefreshUser(loggedInContext.User.Id);
if (user == null) return Unauthorized();
return user;
}
Expand All @@ -141,15 +128,15 @@ public async Task<ActionResult> Logout()
public async Task<ActionResult> ForgotPassword(ForgotPasswordInput input)
{
using var activity = LexBoxActivitySource.Get().StartActivity();
var validToken = await _turnstileService.IsTokenValid(input.TurnstileToken, input.Email);
var validToken = await turnstileService.IsTokenValid(input.TurnstileToken, input.Email);
activity?.AddTag("app.turnstile_token_valid", validToken);
if (!validToken)
{
ModelState.AddModelError<ForgotPasswordInput>(r => r.TurnstileToken, "token invalid");
return ValidationProblem(ModelState);
}

await _userService.ForgotPassword(input.Email);
await userService.ForgotPassword(input.Email);
return Ok();
}

Expand All @@ -164,13 +151,13 @@ public record ResetPasswordRequest([Required(AllowEmptyStrings = false)] string
public async Task<ActionResult> ResetPassword(ResetPasswordRequest request)
{
var passwordHash = request.PasswordHash;
var lexAuthUser = _loggedInContext.User;
var user = await _lexBoxDbContext.Users.FindAsync(lexAuthUser.Id);
var lexAuthUser = loggedInContext.User;
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);
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
if (lexAuthUser.Audience == LexboxAudience.ForgotPassword)
await RefreshJwt();
Expand Down
21 changes: 17 additions & 4 deletions backend/LexBoxApi/Services/HgService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,13 +265,16 @@ private static void SetPermissionsRecursively(DirectoryInfo rootDir)
}
}

public async Task<string?> GetRepositoryIdentifier(Project project)
{
var json = await GetCommit(project.Code, project.MigrationStatus, "0");
return json?["entries"]?.AsArray().FirstOrDefault()?["node"].Deserialize<string>();
}

public async Task<DateTimeOffset?> GetLastCommitTimeFromHg(string projectCode,
ProjectMigrationStatus migrationStatus)
{
var response = await GetClient(migrationStatus, projectCode)
.GetAsync($"{projectCode}/log?style=json-lex&rev=tip");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonObject>();
var json = await GetCommit(projectCode, migrationStatus, "tip");
//format is this: [1678687688, offset] offset is
var dateArray = json?["entries"]?[0]?["date"].Deserialize<decimal[]>();
if (dateArray is null || dateArray.Length != 2 || dateArray[0] <= 0)
Expand All @@ -284,6 +287,16 @@ private static void SetPermissionsRecursively(DirectoryInfo rootDir)
return date.ToUniversalTime();
}

private async Task<JsonObject?> GetCommit(string projectCode,
ProjectMigrationStatus migrationStatus,
string rev)
{
var response = await GetClient(migrationStatus, projectCode)
.GetAsync($"{projectCode}/log?style=json-lex&rev={rev}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<JsonObject>();
}

public async Task<Changeset[]> GetChangesets(string projectCode, ProjectMigrationStatus migrationStatus)
{
#if DEBUG
Expand Down
36 changes: 7 additions & 29 deletions backend/LexBoxApi/Services/PermissionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,21 @@
using LexCore.Auth;
using LexCore.Entities;
using LexCore.ServiceInterfaces;
using LexData;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;

namespace LexBoxApi.Services;

public class PermissionService : IPermissionService
public class PermissionService(
LoggedInContext loggedInContext,
ProjectService projectService)
: IPermissionService
{
private readonly LoggedInContext _loggedInContext;
private readonly LexBoxDbContext _dbContext;
private readonly IMemoryCache _memoryCache;
private LexAuthUser? User => _loggedInContext.MaybeUser;

public PermissionService(LoggedInContext loggedInContext,
LexBoxDbContext dbContext,
IMemoryCache memoryCache)
{
_loggedInContext = loggedInContext;
_dbContext = dbContext;
_memoryCache = memoryCache;
}
private LexAuthUser? User => loggedInContext.MaybeUser;

public async ValueTask<bool> CanAccessProject(string projectCode)
{
if (User is null) return false;
if (User.Role == UserRole.admin) return true;
return CanAccessProject(await LookupProjectId(projectCode));
return CanAccessProject(await projectService.LookupProjectId(projectCode));
}

public bool CanAccessProject(Guid projectId)
Expand Down Expand Up @@ -63,17 +51,7 @@ public void AssertCanManageProjectMemberRole(Guid projectId, Guid userId)
throw new UnauthorizedAccessException("Not allowed to change own project role.");
}

private async ValueTask<Guid> LookupProjectId(string projectCode)
{
var cacheKey = $"ProjectIdForCode:{projectCode}";
if (_memoryCache.TryGetValue(cacheKey, out Guid projectId)) return projectId;
projectId = await _dbContext.Projects
.Where(p => p.Code == projectCode)
.Select(p => p.Id)
.FirstOrDefaultAsync();
_memoryCache.Set(cacheKey, projectId, TimeSpan.FromHours(1));
return projectId;
}


public void AssertIsAdmin()
{
Expand Down
Loading
Loading