Secure Azure AD User File Upload with Azure AD Storage and ASP.NET Core

This post shows how files can be uploaded to Azure blob storage using ASP.NET Core in a secure way using OAuth and Open ID Connect. Azure AD is used to authenticate the users. The uploaded file descriptions are saved to an SQL database using EF Core so that listing or searching files can be implemented easily.

Azure AD user accounts are used to authorize the user uploads and downloads to the Azure storage container. A user access token is used to access the Azure storage container and so each user, or group which the user is assigned to, muss have the correct role to access the files. This makes it possible to implement authorization for the users with Azure roles and role assignments.

Code: https://github.com/damienbod/AspNetCoreAzureAdAzureStorage

Creating the Azure AD Blob Storage and Container

An Azure storage was created in Azure. The Azure storage requires role assignments for the application users or the groups which user this.

Add new role assignments for the admin user, yourself and all the users or groups that will upload or download blobs (files) to the storage container. The role “Storage Blob Data Contributor” is used for what we require.

You must create a new container in the Azure storage and set the Authentication method to Azure AD User Account. In this mode, the Azure AD user access tokens are used to access the container. If you do not have the rights for this, assign yourself the correct role in the parent Azure storage.

The uploaded and downloaded files are for private access only. We do not want a public URL which can be used to download the blobs, or upload these. Only requests with the correct access token can use the Azure storage container. In the configuration of the Azure storage, set the “Allow Blob public access” to disabled.

It is important that public access is disabled. This is per default enabled. Upload a file to the container and get the URL for this blob. Try to access the blob using a different browser with unauthorized access. A forbidden response must be returned.

Now the Azure storage is ready to be used along with the container and the correct RBAC for the required users. Azure AD user access tokens are used to access the container.

Creating the Azure AD App registration

An Azure App registration is required for the ASP.NET Core authentication. A trusted web application is created and a secret is required to use the Azure App registration. This is not a public client. A version 2 client is used, so set this in the manifest.

In the API permissions, the delegated access to the Azure storage is added. Add a permission and select the delegated user_impersonation access from the Azure storage.

The Azure storage delegated permission should be set up.

The rest of the Azure App registration is standard like in the Microsoft.Identity.Web documentation.

ASP.NET Core File Upload

An ASP.NET Core Razor page is used to upload and download the files. The files are saved to the Azure storage blob and the file descriptions are added to an SQL database. A multipart/form-data form is used with an input of type file and some additional data to select the files is added with the uploaded files.

@page
@model AspNetCoreAzureStorage.Pages.AzStorageFilesModel
@{
    ViewData["Title"] = "Azure Storage Files";
    Layout = "~/Pages/Shared/_Layout.cshtml";
}

<div class="card">
    <div class="card-header">Uploaded Files</div>
    <div class="card-body">
        <form enctype="multipart/form-data" asp-page="/AzStorageFiles" id="ajaxUploadForm" novalidate="novalidate">

            <fieldset>

                <div class="col-xs-12" style="padding: 10px;">
                    <div class="col-xs-4">
                        <label>Description</label>
                    </div>
                    <div class="col-xs-7">
                        <textarea rows="2" placeholder="Description" class="form-control" asp-for="FileDescriptionShort.Description"></textarea>
                    </div>
                </div>

                <div class="col-xs-12" style="padding: 10px;">
                    <div class="col-xs-4">
                        <label>Upload</label>
                    </div>
                    <div class="col-xs-7">
                        <input type="file" asp-for="FileDescriptionShort.File" multiple>
                    </div>
                </div>

                <div class="col-xs-12" style="padding: 10px;">
                    <div class="col-xs-4">
                        <input type="submit" value="Upload" id="ajaxUploadButton" class="btn btn-primary col-sm-12">
                    </div>
                    <div class="col-xs-7">

                    </div>
                </div>

            </fieldset>

        </form>
    </div>
</div>

The attribute AuthorizeForScopes from the Microsoft.Identity.Web is used to validate that the correct scopes are required to used the page. The Azure storage https://storage.azure.com/user_impersonation scope is required to used this service.

[AuthorizeForScopes(Scopes = new string[] 
 { "https://storage.azure.com/user_impersonation" })]
public class AzStorageFilesModel : PageModel
{

The OnPostAsync method of the Razor page is used to validate the form HTTP post request and call the services to upload the files to the Azure storage blob container. The IFormFile data is passed onto the Azure storage service. Each file from the multiple file upload is passed in a separate request. The file descriptions are also used in the database provider to save the data to the SQL database.

private readonly AzureStorageProvider _azureStorageService;
private readonly FileDescriptionProvider _fileDescriptionProvider;

[BindProperty]
public FileDescriptionUpload FileDescriptionShort { get; set; }

public AzStorageFilesModel(AzureStorageProvider azureStorageService,
 FileDescriptionProvider fileDescriptionProvider)
{
 _azureStorageService = azureStorageService;
 _fileDescriptionProvider = fileDescriptionProvider;
}

public void OnGet()
{
 FileDescriptionShort = new FileDescriptionUpload
 {
  Description = "enter description"
 };
}

public async Task<IActionResult> OnPostAsync()
{
 var fileInfos = new List<(string FileName, string ContentType)>();
 if (ModelState.IsValid)
 {
  if (!IsMultipartContentType(HttpContext.Request.ContentType))
  {
   ModelState.AddModelError("FileDescriptionShort.File", "not a MultipartContentType");
   return Page();
  }

  foreach (var file in FileDescriptionShort.File)
  {
   if (file.Length > 0)
   {
    var fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.ToString().Trim('"');

    fileInfos.Add((fileName, file.ContentType));

    await _azureStorageService.AddNewFile(new BlobFileUpload
    {
     Name = fileName,
     Description = FileDescriptionShort.Description,
     UploadedBy = HttpContext.User.Identity.Name
    },
    file);
   }
  }
 }

 var files = new UploadedFileResult
 {
  FileInfos = fileInfos,
  Description = FileDescriptionShort.Description,
  UploadedBy = HttpContext.User.Identity.Name,
  CreatedTimestamp = DateTime.UtcNow,
  UpdatedTimestamp = DateTime.UtcNow,
 };

 await _fileDescriptionProvider.AddFileDescriptionsAsync(files);

 return Page();
}


private static bool IsMultipartContentType(string contentType)
{
 return !string.IsNullOrEmpty(contentType) && contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0;
}

The AzureStorageProvider class uses the Azure.Storage.Blobs nuget package to upload and download the blobs to the Azure storage blob container. The files are persisted to Azure storage using the BlobClient. Metadata is added to the upload using the BlobUploadOptions and the stream is uploaded with the UploadAsync method.

public class AzureStorageProvider
{
 private readonly TokenAcquisitionTokenCredential _tokenAcquisitionTokenCredential;
 private readonly IConfiguration _configuration;

 public AzureStorageProvider(TokenAcquisitionTokenCredential tokenAcquisitionTokenCredential,
  IConfiguration configuration)
 {
  _tokenAcquisitionTokenCredential = tokenAcquisitionTokenCredential;
  _configuration = configuration;
 }

 [AuthorizeForScopes(Scopes = new string[] { "https://storage.azure.com/user_impersonation" })]
 public async Task<string> AddNewFile(BlobFileUpload blobFileUpload, IFormFile file)
 {
  try
  {
   return await PersistFileToAzureStorage(blobFileUpload, file);
  }
  catch (Exception e)
  {
   throw new ApplicationException($"Exception {e}");
  }
 }

 private async Task<string> PersistFileToAzureStorage(
  BlobFileUpload blobFileUpload,
  IFormFile formFile,
  CancellationToken cancellationToken = default)
 {
  var storage = _configuration.GetValue<string>("AzureStorage:StorageAndContainerName");
  var fileFullName = $"{storage}{blobFileUpload.Name}";
  var blobUri = new Uri(fileFullName);

  var blobUploadOptions = new BlobUploadOptions
  {
   Metadata = new Dictionary<string, string>
   {
    { "uploadedBy", blobFileUpload.UploadedBy },
    { "description", blobFileUpload.Description }
   }
  };

  var blobClient = new BlobClient(blobUri, _tokenAcquisitionTokenCredential);

  var inputStream = formFile.OpenReadStream();
  await blobClient.UploadAsync(inputStream, blobUploadOptions, cancellationToken);

  return $"{blobFileUpload.Name} successfully saved to Azure Storage Container";
 }
}

The TokenAcquisitionTokenCredential class which implements the TokenCredential class is required by the BlobClient to upload or download the blobs. The class uses the ITokenAcquisition interface to get the access token from the Micosoft.Identity.Web nuget package. The required scope is read from the configuration.

public class TokenAcquisitionTokenCredential : TokenCredential
{
 private ITokenAcquisition _tokenAcquisition;
 private readonly IConfiguration _configuration;

 public TokenAcquisitionTokenCredential(ITokenAcquisition tokenAcquisition,
  IConfiguration configuration)
 {
  _tokenAcquisition = tokenAcquisition;
  _configuration = configuration;
 }

 public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
 {
  throw new System.NotImplementedException();
 }

 public override async ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
 {
  // requestContext.Scopes "https://storage.azure.com/.default"
  string[] scopes = _configuration["AzureStorage:ScopeForAccessToken"]?.Split(' ');

  AuthenticationResult result = await _tokenAcquisition
   .GetAuthenticationResultForUserAsync(scopes)
   .ConfigureAwait(false);

  return new AccessToken(result.AccessToken, result.ExpiresOn);
 }
}

SQL Database

Entity Framework Core is used to persist the file descriptions to a Microsoft SQL database. The file context has just one DbSet, ie a single table which can be selected of searched as required. If you were making this for an application which has a lot of data, maybe a search engine, search persistence would be the better choice.

public class FileContext : DbContext
{
 public FileContext(DbContextOptions<FileContext> options) : base(options)
 { }

 public DbSet<FileDescription> FileDescriptions { get; set; }

 protected override void OnModelCreating(ModelBuilder builder)
 {
  builder.Entity<FileDescription>().HasKey(m => m.Id);
  base.OnModelCreating(builder);
 }
}

The AddFileDescriptionsAsync method takes the data from the ASP.NET Core Razor page and saves the data to the table. This can be used to select the uploaded files from the Azure blob container.

public async Task AddFileDescriptionsAsync(UploadedFileResult uploadedFileResult)
{
 foreach (var (FileName, ContentType) in uploadedFileResult.FileInfos)
 {
  _context.FileDescriptions.Add(new FileDescription
  {
   FileName = FileName,
   ContentType = ContentType,
   Description = uploadedFileResult.Description,
   UploadedBy = uploadedFileResult.UploadedBy,
   CreatedTimestamp = uploadedFileResult.CreatedTimestamp,
   UpdatedTimestamp = uploadedFileResult.UpdatedTimestamp
  });
 }

 await _context.SaveChangesAsync();

}

ASP.NET Core Startup

The ASP.NET Core Startup class adds the authentication using the Microsoft.Identity.Web nuget package. This is a server rendered application which can be trusted and so uses a secret to authenticate the client application. The Azure App registration was setup to require a secret to use the registration. The secret is saved to the user secrets in Visual Studio and would be read from Azure Key vault in a deployment, or something like this. The required services are added to the IoC and the Razor pages are setup to require an authenticated user. All access requirs an authenticated user, all the way down to the Azure storage blob container.

If the user is authenticated, the identity can use the application. This does not mean the user has access. The Azure AD user must be assigned a role to access the Azure storage blob container. In this demo the role “Storage Blob Data Contributor” is used.

public void ConfigureServices(IServiceCollection services)
{
 services.AddTransient<AzureStorageProvider>();
 services.AddTransient<TokenAcquisitionTokenCredential>();
 services.AddDbContext<FileContext>(options =>
  options.UseSqlServer(Configuration
   .GetConnectionString("DefaultConnection")));
 services.AddTransient<FileDescriptionProvider>();

 services.AddHttpClient();
 services.AddOptions();

 string[] initialScopes = Configuration.GetValue<string>
  ("AzureStorage:ScopeForAccessToken")?.Split(' ');

 services.AddMicrosoftIdentityWebAppAuthentication(Configuration)
  .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
  .AddInMemoryTokenCaches();

 services.AddRazorPages().AddMvcOptions(options =>
 {
  var policy = new AuthorizationPolicyBuilder()
   .RequireAuthenticatedUser()
   .Build();
  options.Filters.Add(new AuthorizeFilter(policy));
 }).AddMicrosoftIdentityUI();
}

ASP.NET Core File Download

The files can be downloaded using a link from the list of files selected from the SQL database. When the authenticated user clicks a link, the OnGetDownloadFile method is called. This method uses the DownloadFile method to get the blob from the Azure storage.

public async Task<ActionResult> OnGetDownloadFile(string fileName)
{
 var file = await _azureStorageService.DownloadFile(fileName);

 return File(file.Value.Content, file.Value.ContentType, fileName);
}

The BlobClient is used to access the Azure storage. The DownloadAsync method gets the blob and returns the stream.

The stream is then returned as a file to the user. This will only work is the user has the correct role in Azure AD.

[AuthorizeForScopes(Scopes = new string[] { "https://storage.azure.com/user_impersonation" })]
public async Task<Azure.Response<BlobDownloadInfo>> DownloadFile(string fileName)
{
 try
 {
  var storage = _configuration.GetValue<string>("AzureStorage:StorageAndContainerName");
  var fileFullName = $"{storage}{fileName}";
  var blobUri = new Uri(fileFullName);
  var blobClient = new BlobClient(blobUri, _tokenAcquisitionTokenCredential);
  return await blobClient.DownloadAsync();
 }
 catch (Exception e)
 {
  throw new ApplicationException($"Exception {e}");
 }
}

When the application is started, the authenticated user can upload files and add a description. This will be added to the blob meta data.

Once uploaded, this can be checked in the Azure portal using the Azure storage container.

If the files menu is open, the user can download a file by clicking a link. The file is returned and can be saved to your browser downloads.

If the user is authenticated but does not have the correct role to access the files, an exception will be thrown.

Conclusion

Using Azure AD to access to blob storage works well and provides many possibilities to implement the authorization as wished. The next step would be to implement this authorization in the ASP.NET Core application.

Links:

https://github.com/Azure-Samples/storage-dotnet-azure-ad-msal

https://winsmarts.com/access-azure-blob-storage-with-standards-based-oauth-authentication-b10d201cbd15

https://stackoverflow.com/questions/45956935/azure-ad-roles-claims-missing-in-access-token

https://github.com/AzureAD/microsoft-identity-web/wiki

https://docs.microsoft.com/en-us/azure/storage/blobs/storage-blobs-introduction

This content was originally published here.

Categories: Mobile App
vinova: