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

Jwt claims + SAS tokens for Blobs #10

Merged
merged 7 commits into from
May 9, 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
1 change: 1 addition & 0 deletions Core/Options/AzureBlobStorageOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ public class AzureBlobStorageOptions
public string ConnectionString { get; set; } = null!;
public string PlantImagesContainer { get; set; } = null!;
public string UserProfileImagesContainer { get; set; } = null!;
public string DefaultPlantImageUrl { get; set; } = null!;
}
75 changes: 75 additions & 0 deletions Core/Services/External/BlobStorage/BlobStorageService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Net;
using Azure.Storage.Blobs;
using Azure.Storage.Sas;
using Core.Options;
using Microsoft.Extensions.Options;
using Shared.Exceptions;

namespace Core.Services.External.BlobStorage;

public class BlobStorageService(IOptions<AzureBlobStorageOptions> azureBlobStorageOptions) : IBlobStorageService
{
public async Task<string> SaveImageToBlobStorage(string base64Image, string userEmail, string? blobUrl = null)
{
var imageBytes = Convert.FromBase64String(base64Image);
var blobName = blobUrl is not null
? GetBlobNameFromUrl(blobUrl)
: userEmail + "_" + Guid.NewGuid();

var blobClient = new BlobClient(azureBlobStorageOptions.Value.ConnectionString, azureBlobStorageOptions.Value.PlantImagesContainer, blobName);
var binaryData = new BinaryData(imageBytes);
await blobClient.UploadAsync(binaryData, true);
return WebUtility.UrlDecode(blobClient.Uri.ToString());
}

public async Task<bool> DeleteImageFromBlobStorage(string imageUrl)
{
var blobClient = new BlobClient(new Uri(imageUrl));
return await blobClient.DeleteIfExistsAsync();
}

public async Task<string> GetImageFromBlobStorage(string imageUrl)
{
if (string.IsNullOrEmpty(imageUrl)) return "";

var blobClient = new BlobClient(new Uri(imageUrl));
if (await blobClient.ExistsAsync() == false) throw new NotFoundException("Image not found");

using var memoryStream = new MemoryStream();
await blobClient.DownloadToAsync(memoryStream);
var imageBytes = memoryStream.ToArray();
return Convert.ToBase64String(imageBytes);
}

public string GenerateSasUri(string blobUrl)
{
var blobServiceClient = new BlobServiceClient(azureBlobStorageOptions.Value.ConnectionString);
var blobContainerClient = blobServiceClient.GetBlobContainerClient(azureBlobStorageOptions.Value.PlantImagesContainer);
var blobClient = blobContainerClient.GetBlobClient(GetBlobNameFromUrl(blobUrl));

var blobSasBuilder = new BlobSasBuilder
{
BlobContainerName = blobContainerClient.Name,
BlobName = blobClient.Name,
Resource = "b",
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresOn = DateTimeOffset.UtcNow.AddHours(1),
};
blobSasBuilder.SetPermissions(BlobSasPermissions.Read);
return blobClient.GenerateSasUri(blobSasBuilder).ToString();
}

public string GetBlobUrlFromSasUri(string sasUri)
{
var blobUriBuilder = new BlobUriBuilder(new Uri(sasUri))
{
Query = string.Empty
};
return blobUriBuilder.ToString();
}

private string GetBlobNameFromUrl(string blobUrl)
{
return WebUtility.UrlDecode(new Uri(blobUrl).AbsolutePath.Substring(azureBlobStorageOptions.Value.PlantImagesContainer.Length + 2));
}
}
38 changes: 0 additions & 38 deletions Core/Services/External/BlobStorage/BlobStorageServiceService.cs

This file was deleted.

4 changes: 4 additions & 0 deletions Core/Services/External/BlobStorage/IBlobStorageService.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
using Azure.Storage.Blobs;

namespace Core.Services.External.BlobStorage;

public interface IBlobStorageService
{
public Task<string> SaveImageToBlobStorage(string base64Image, string userEmail, string? blobUrl = null);
public Task<bool> DeleteImageFromBlobStorage(string imageUrl);
public Task<string> GetImageFromBlobStorage(string imageUrl);
public string GenerateSasUri(string blobUrl);
public string GetBlobUrlFromSasUri(string sasUri);
}
10 changes: 10 additions & 0 deletions Core/Services/External/BlobStorage/MockBlobStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,14 @@ public Task<string> GetImageFromBlobStorage(string imageUrl)
{
return Task.FromResult("base64Image");
}

public string GenerateSasUri(string blobUrl)
{
return "https://www.example.com";
}

public string GetBlobUrlFromSasUri(string sasUri)
{
return "https://www.example.com";
}
}
8 changes: 7 additions & 1 deletion Core/Services/JwtService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public string IssueJwt(User user)
}
}

public Dictionary<string, string> ValidateJwtAndReturnClaims(string jwt)
private Dictionary<string, string> GetClaimsFromJwt(string jwt)
{
try
{
Expand All @@ -54,6 +54,12 @@ public Dictionary<string, string> ValidateJwtAndReturnClaims(string jwt)
}
}

public string GetEmailFromJwt(string jwt)
{
var claims = GetClaimsFromJwt(jwt);
return claims["email"];
}

public bool IsJwtTokenValid(string jwt)
{
try
Expand Down
23 changes: 16 additions & 7 deletions Core/Services/PlantService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Azure.Storage.Blobs;
using Core.Options;
using Core.Options;
using Core.Services.External.BlobStorage;
using Infrastructure.Repositories;
using Microsoft.Extensions.Options;
Expand All @@ -12,16 +11,20 @@ namespace Core.Services;
public class PlantService(
PlantRepository plantRepository,
RequirementService requirementService,
IBlobStorageService blobStorageService)
IBlobStorageService blobStorageService,
IOptions<AzureBlobStorageOptions> azureBlobStorageOptions)
{
public async Task<Plant> CreatePlant(CreatePlantDto createPlantDto)

public async Task<Plant> CreatePlant(CreatePlantDto createPlantDto, string loggedInUserEmail)
{
if (loggedInUserEmail != createPlantDto.UserEmail) throw new NoAccessException("You can't create a plant for another user");

if (string.IsNullOrEmpty(createPlantDto.Nickname))
{
createPlantDto.Nickname = GenerateRandomNickname();
}

string? ímageUrl = null;
var ímageUrl = azureBlobStorageOptions.Value.DefaultPlantImageUrl;
if (createPlantDto.Base64Image is not null)
{
ímageUrl = await blobStorageService.SaveImageToBlobStorage(createPlantDto.Base64Image, createPlantDto.UserEmail);
Expand All @@ -34,7 +37,7 @@ public async Task<Plant> CreatePlant(CreatePlantDto createPlantDto)
UserEmail = createPlantDto.UserEmail,
// CollectionId = Guid.Empty, // TODO: fix when collections are implemented
Nickname = createPlantDto.Nickname,
ImageUrl = ímageUrl ?? "",
ImageUrl = ímageUrl,
DeviceId = createPlantDto.DeviceId
};

Expand All @@ -44,18 +47,21 @@ public async Task<Plant> CreatePlant(CreatePlantDto createPlantDto)
var requirementsDto = createPlantDto.CreateRequirementsDto;
requirementsDto.PlantId = plant.PlantId;
plant.Requirements = await requirementService.CreateRequirements(requirementsDto);
plant.ImageUrl = blobStorageService.GenerateSasUri(plant.ImageUrl);
return plant;
}

public async Task<Plant> GetPlantById(Guid id, string requesterEmail)
{
var plant = await VerifyPlantExistsAndUserHasAccess(id, requesterEmail);
plant.ImageUrl = blobStorageService.GenerateSasUri(plant.ImageUrl);
return plant;
}

public async Task<List<Plant>> GetPlantsForUser(string userEmail, int pageNumber, int pageSize)
{
var plants = await plantRepository.GetPlantsForUser(userEmail, pageNumber, pageSize);
plants.ForEach(plant => plant.ImageUrl = blobStorageService.GenerateSasUri(plant.ImageUrl)); // Otherwise the client can't access the image
return plants;
}

Expand All @@ -70,7 +76,8 @@ public async Task<Plant> UpdatePlant(UpdatePlantDto updatePlantDto, string reque
requirements = await requirementService.UpdateRequirements(updatePlantDto.UpdateRequirementDto, plant.PlantId);
}

var imageUrl = plant.ImageUrl;
// The urls coming from the client are SAS urls, so we need to convert them to normal urls
var imageUrl = blobStorageService.GetBlobUrlFromSasUri(plant.ImageUrl);
if (updatePlantDto.Base64Image is not null)
{
imageUrl = await blobStorageService.SaveImageToBlobStorage(updatePlantDto.Base64Image, requesterEmail, plant.ImageUrl);
Expand All @@ -87,6 +94,8 @@ public async Task<Plant> UpdatePlant(UpdatePlantDto updatePlantDto, string reque
Requirements = requirements,
ConditionsLogs = plant.ConditionsLogs
};

plant.ImageUrl = blobStorageService.GenerateSasUri(plant.ImageUrl);
return await plantRepository.UpdatePlant(plant);
}

Expand Down
8 changes: 5 additions & 3 deletions Core/Services/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public async Task CreateUser(RegisterUserDto registerUserDto)

if (registerUserDto.Base64Image != null)
{
registerUserDto.BlobUrl = await blobStorageService.SaveImageToBlobStorage(registerUserDto.Base64Image, registerUserDto.Email, null);
registerUserDto.BlobUrl = await blobStorageService.SaveImageToBlobStorage(registerUserDto.Base64Image, registerUserDto.Email);
}

await userRepository.CreateUser(registerUserDto);
Expand All @@ -29,9 +29,11 @@ public async Task CreateUser(RegisterUserDto registerUserDto)
return user == null ? null : jwtService.IssueJwt(user);
}

public async Task<User?> GetUserByEmail(string email)
public async Task<User> GetUserByEmail(string email)
{
return await userRepository.GetUserByEmail(email);
var user = await userRepository.GetUserByEmail(email);
if (user == null) throw new NotFoundException();
return user;
}

public async Task<GetUserDto?> UpdateUser(UpdateUserDto updateUserDto)
Expand Down
2 changes: 1 addition & 1 deletion Shared/Models/BaseDtoWithJwt.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ namespace Shared.Models;

public class BaseDtoWithJwt : BaseDto
{
public string Jwt { get; set; }
public string? Jwt { get; init; }
}
2 changes: 1 addition & 1 deletion Tests/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ await ws.DoAndAssert(new ClientWantsToLogInDto { LoginDto = loginDto }, received
return receivedMessages.Count(e => e.eventType == nameof(ServerAuthenticatesUser)) == 1;
});

await ws.DoAndAssert(new ClientWantsToLogOutDto { UserEmail = registerUserDto.Email }, receivedMessages =>
await ws.DoAndAssert(new ClientWantsToLogOutDto(), receivedMessages =>
{
return receivedMessages.Count(e => e.eventType == nameof(ServerLogsOutUser)) == 1;
});
Expand Down
1 change: 0 additions & 1 deletion Tests/PlantTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ await webSocketTestClient.DoAndAssert(new ClientWantsToCreatePlantDto { CreatePl

await webSocketTestClient.DoAndAssert(new ClientWantsAllPlantsDto
{
UserEmail = email,
Jwt = jwt,
PageNumber = 1,
PageSize = 10
Expand Down
26 changes: 26 additions & 0 deletions api/Events/Auth/Client/ClientWantsToCheckJwtValidity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using api.Events.Auth.Server;
using api.Extensions;
using Fleck;
using lib;
using Shared.Models;

namespace api.Events.Auth.Client;

public class ClientWantsToCheckJwtValidityDto : BaseDtoWithJwt;

/// <summary>
/// The token's validation is actually checked in Program.cs, but this event is used to request the validation.
/// If the token is not valid, an exception will be thrown, and the GlobalExceptionHandler will catch it, and send a
/// corresponding message to the client.
/// </summary>
public class ClientWantsToCheckJwtValidity : BaseEventHandler<ClientWantsToCheckJwtValidityDto>
{
public override Task Handle(ClientWantsToCheckJwtValidityDto dto, IWebSocketConnection socket)
{
socket.SendDto(new ServerAuthenticatesUser
{
Jwt = dto.Jwt,
});
return Task.CompletedTask;
}
}
10 changes: 2 additions & 8 deletions api/Events/Auth/Client/ClientWantsToLogIn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
using Fleck;
using lib;
using Shared.Dtos;
using Shared.Dtos.FromClient;
using Shared.Dtos.FromClient.Identity;
using Shared.Exceptions;
using Shared.Models;

namespace api.Events.Auth.Client;

Expand All @@ -16,7 +14,7 @@ public class ClientWantsToLogInDto : BaseDto
public LoginDto LoginDto { get; set; } = null!;
}

public class ClientWantsToLogIn(WebSocketConnectionService connectionService, UserService userService)
public class ClientWantsToLogIn(UserService userService)
: BaseEventHandler<ClientWantsToLogInDto>
{
public override async Task Handle(ClientWantsToLogInDto dto, IWebSocketConnection socket)
Expand All @@ -25,9 +23,6 @@ public override async Task Handle(ClientWantsToLogInDto dto, IWebSocketConnectio
if (jwt == null) throw new InvalidCredentialsException();

var user = await userService.GetUserByEmail(dto.LoginDto.Email);
if (user == null) throw new NotFoundException();

connectionService.AuthenticateConnection(socket.ConnectionInfo.Id, user);

var getUserDto = new GetUserDto
{
Expand All @@ -42,5 +37,4 @@ public override async Task Handle(ClientWantsToLogInDto dto, IWebSocketConnectio
GetUserDto = getUserDto
});
}
}

}
5 changes: 1 addition & 4 deletions api/Events/Auth/Client/ClientWantsToLogOut.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@

namespace api.Events.Auth.Client;

public class ClientWantsToLogOutDto : BaseDto
{
public string UserEmail { get; set; } = null!;
}
public class ClientWantsToLogOutDto : BaseDto;

public class ClientWantsToLogOut(WebSocketConnectionService connectionService)
: BaseEventHandler<ClientWantsToLogOutDto>
Expand Down
4 changes: 2 additions & 2 deletions api/Events/Auth/Server/ServerAuthenticatesUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ namespace api.Events.Auth.Server;

public class ServerAuthenticatesUser : BaseDto
{
public string? Jwt { get; set; }
public GetUserDto GetUserDto { get; set; } = null!;
public required string Jwt { get; init; }
public GetUserDto GetUserDto { get; init; }
}
Loading
Loading