Skip to content

Commit

Permalink
Merge pull request #10 from Team-Wilhelm/jwt-claims
Browse files Browse the repository at this point in the history
Jwt claims + SAS tokens for Blobs
  • Loading branch information
mariaruth1 authored May 9, 2024
2 parents 898c9af + 39a3f14 commit 92dc09d
Show file tree
Hide file tree
Showing 24 changed files with 171 additions and 130 deletions.
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

0 comments on commit 92dc09d

Please sign in to comment.