Secure file download using IdentityServer4, Angular2 and ASP.NET Core

This article shows how a secure file download can be implemented using Angular 2 with an OpenID Connect Implicit Flow using IdentityServer4. The resource server needs to process the access token in the query string and the NuGet package IdentityServer4.AccessTokenValidation makes it very easy to support this. The default security implementation jwtBearerHandler reads the token from the header.

Code: https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow/tree/secureDownloadWithAccessTokenInURL

Note: This branch and post will not be updated. Use the follow up post and code for this requirement.

Other posts in this series:

The Secure File Resource Server

The required packages for the resource server are defined in the project.json file in the dependencies. The authorization packages and the IdentityServer4.AccessTokenValidation package need to be added.

"dependencies": {
	"Microsoft.AspNet.IISPlatformHandler": "1.0.0-rc1-final",
	"Microsoft.AspNet.Mvc": "6.0.0-rc1-final",
	"Microsoft.AspNet.Server.Kestrel": "1.0.0-rc1-final",
	"Microsoft.AspNet.StaticFiles": "1.0.0-rc1-final",
	"Microsoft.Extensions.Configuration.FileProviderExtensions": "1.0.0-rc1-final",
	"Microsoft.Extensions.Configuration.Json": "1.0.0-rc1-final",
	"Microsoft.Extensions.Logging": "1.0.0-rc1-final",
	"Microsoft.Extensions.Logging.Console": "1.0.0-rc1-final",
	"Microsoft.Extensions.Logging.Debug": "1.0.0-rc1-final",

	"Microsoft.AspNet.Authorization": "1.0.0-rc1-final",
	"Microsoft.AspNet.Authentication.JwtBearer": "1.0.0-rc1-final",
	"Microsoft.AspNet.Cors": "6.0.0-rc1-final",
	"Microsoft.AspNet.Diagnostics": "1.0.0-rc1-final",
	"IdentityServer4.AccessTokenValidation": "1.0.0-beta3"
},

The UseIdentityServerAuthentication extension from the NuGet IdentityServer4.AccessTokenValidation package can be used to read the access token from the query string. Normally this is done in the HTTP headers, but for file upload, this is not so easy, if your not using cookies. As the application uses OpenID Connect Implicit Flow, tokens are being used. The options.TokenRetriever = TokenRetrieval.FromQueryString() is used to configure the ASP.NET Core middleware to authenticate and authorize using the access token in the query string.

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
	loggerFactory.AddConsole(Configuration.GetSection("Logging"));
	loggerFactory.AddDebug();

	app.UseIISPlatformHandler();

	app.UseCors("corsGlobalPolicy");

	app.UseStaticFiles();

	JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
	app.UseIdentityServerAuthentication(options =>
	{
		options.Authority = "https://localhost:44345/";
		options.ScopeName = "securedFiles";
		options.ScopeSecret = "securedFilesSecret";

		options.AutomaticAuthenticate = true;
		// required if you want to return a 403 and not a 401 for forbidden responses
		options.AutomaticChallenge = true;
		options.TokenRetriever = TokenRetrieval.FromQueryString();
	});
	app.UseMvc();
}

An AuthorizeFilter is used to validate if the requesting access token has the scope “securedFiles”. The “securedFilesUser” policy is used to validate that the requesting token has the role “securedFiles.user”. The two policies are used in the MVC6 controllers as attributes.

var securedFilesPolicy = new AuthorizationPolicyBuilder()
	.RequireAuthenticatedUser()
	.RequireClaim("scope", "securedFiles")
	.Build();

services.AddAuthorization(options =>
{
	options.AddPolicy("securedFilesUser", policyUser =>
	{
		policyUser.RequireClaim("role", "securedFiles.user");
	});
});

The FileExplorerController is used to return the possible secure files which can be downloaded. The authorization policies which were defined in the startup class are used here. If the requesting token has the claim “securedFiles.admin”, all files will be returned in the payload of the HTTP GET.

using System.Linq;
using Microsoft.AspNet.Mvc;
using Microsoft.AspNet.Authorization;
using Microsoft.Extensions.PlatformAbstractions;
using ResourceFileServer.Providers;

namespace ResourceFileServer.Controllers
{
    [Authorize]
    [Route("api/[controller]")]
    public class FileExplorerController : Controller
    {
        private readonly IApplicationEnvironment _appEnvironment;
        private readonly ISecuredFileProvider _securedFileProvider;

        public FileExplorerController(ISecuredFileProvider securedFileProvider, IApplicationEnvironment appEnvironment)
        {
            _securedFileProvider = securedFileProvider;
            _appEnvironment = appEnvironment;
        }

        [Authorize("securedFilesUser")]
        [HttpGet]
        public IActionResult Get()
        {
            var adminClaim = User.Claims.FirstOrDefault(x => x.Type == "role" && x.Value == "securedFiles.admin");
            var files = _securedFileProvider.GetFilesForUser(adminClaim != null);

            return Ok(files);
        }
    }
}

The DownloadController is used for file download requests. The requesting token must have the claim of type scope and value “securedFiles” and also the claim of type role and the value “securedFiles.user”. In the demo application, one file requires the claim with type role and value “securedFiles.admin”.

The Get method checks if the file id exists on the server. If the id does not exist, a not found is returned. This protects against file sniffing. Thanks to Imran Baloch and Filip Ekberg for pointing this out. Here’s a great post about this:

http://www.filipekberg.se/2013/07/12/are-you-serving-files-insecurely-in-asp-net/

The GET method also checks if the file exists. If it does not exist, a 400 response is returned. It then checks, if the requesting token has the authorization to access the file. If this is ok, the file is returned as an “application/octet-stream” response.

using System.Linq;
using Microsoft.AspNet.Mvc;
using Microsoft.AspNet.Authorization;
using Microsoft.Extensions.PlatformAbstractions;
using ResourceFileServer.Providers;

namespace ResourceFileServer.Controllers
{
    [Authorize]
    [Route("api/[controller]")]
    public class DownloadController : Controller
    {
        private readonly IApplicationEnvironment _appEnvironment;
        private readonly ISecuredFileProvider _securedFileProvider;

        public DownloadController(ISecuredFileProvider securedFileProvider, IApplicationEnvironment appEnvironment)
        {
            _securedFileProvider = securedFileProvider;
            _appEnvironment = appEnvironment;
        }

        [Authorize("securedFilesUser")]
        [HttpGet("{id}")]
        public IActionResult Get(string id)
        {
            if(!_securedFileProvider.FileIdExists(id))
            {
                return HttpNotFound($"File Id does not exist: {id}");
            }

            var filePath = $"{_appEnvironment.ApplicationBasePath}/SecuredFileShare/{id}";
            if(!System.IO.File.Exists(filePath))
            {
                 return HttpNotFound($"File does not exist: {id}");
            }

            var adminClaim = User.Claims.FirstOrDefault(x => x.Type == "role" && x.Value == "securedFiles.admin");
            if(_securedFileProvider.HasUserClaimToAccessFile(id, adminClaim != null))
            {
                var fileContents = System.IO.File.ReadAllBytes(filePath);
                return new FileContentResult(fileContents, "application/octet-stream");
            }

            return HttpUnauthorized();
        }
    }
}

Angular 2 client

The Angular 2 application uses the SecureFileService to access the server APIs. The access_token parameter is added to the query string for the file download resource server. This is different to the standard way of adding the access token to the header. The GetDownloadfileUrl method is used to create the URL for the download link.

import { Injectable } from 'angular2/core';
import { Http, Response, Headers } from 'angular2/http';
import 'rxjs/add/operator/map'
import { Observable } from 'rxjs/Observable';
import { Configuration } from '../app.constants';
import { SecurityService } from '../services/SecurityService';

@Injectable()
export class SecureFileService {

    private actionUrl: string;
    private fileExplorerUrl: string;

    constructor(private _http: Http, private _configuration: Configuration, private _securityService: SecurityService) {
        this.actionUrl = _configuration.FileServer + 'api/Download/'; 
        this.fileExplorerUrl = _configuration.FileServer + 'api/FileExplorer/';    
    }

    public GetDownloadfileUrl(id: string): string {
        var token = this._securityService.GetToken();
        return this.actionUrl + id + "?access_token=" + token;
    }

    public GetListOfFiles = (): Observable<string[]> => {
        var token = this._securityService.GetToken();

        return this._http.get(this.fileExplorerUrl + "?access_token=" + token, {
        }).map(res => res.json());
    }

}

The SecureFilesComponent is used to open a new window and get the secure file from the server using the URL created in the SecureFileService GetDownloadfileUrl method.

import { Component, OnInit } from 'angular2/core';
import { CORE_DIRECTIVES } from 'angular2/common';
import { SecureFileService } from '../services/SecureFileService';
import { SecurityService } from '../services/SecurityService';
import { Observable }       from 'rxjs/Observable';
import { Router } from 'angular2/router';

@Component({
    selector: 'securefiles',
    templateUrl: 'app/securefiles/securefiles.component.html',
    directives: [CORE_DIRECTIVES],
    providers: [SecureFileService]
})

export class SecureFilesComponent implements OnInit {

    public message: string;
    public Files: string[];
   
    constructor(private _secureFileService: SecureFileService, public securityService: SecurityService, private _router: Router) {
        this.message = "Secure Files download";
    }

    ngOnInit() {
      this.getData();
    }

    public GetFileById(id: any) {
        window.open(this._secureFileService.GetDownloadfileUrl(id));
    }

    private getData() {
        this._secureFileService.GetListOfFiles()
            .subscribe(data => this.Files = data,
            error => this.securityService.HandleError(error),
            () => console.log('Get all completed'));
    }
}

After a successful login, the available files are displayed in a HTML table.
secureFile_download_01

The file can be downloaded using the access token. If a non-authorized user tries to download a file, a 403 will be returned or if an incorrect access token or no access token is used in the HTTP request, a 401 will be returned.

secureFile_download_02
Notes:

Using IdentityServer4.AccessTokenValidation, support for access tokens in the query string is very easy to implement in an ASP.NET Core application. One problem, is when support for tokens in both the request header and the query string needs to be supported in one web application.

Links

http://openid.net/specs/openid-connect-core-1_0.html

http://openid.net/specs/openid-connect-implicit-1_0.html

https://github.com/aspnet/Security

https://github.com/IdentityServer/IdentityServer4.AccessTokenValidation

http://www.filipekberg.se/2013/07/12/are-you-serving-files-insecurely-in-asp-net/

Announcing IdentityServer for ASP.NET 5 and .NET Core

https://github.com/IdentityServer/IdentityServer4

https://github.com/IdentityServer/IdentityServer4.Samples

The State of Security in ASP.NET 5 and MVC 6: OAuth 2.0, OpenID Connect and IdentityServer

http://connect2id.com/learn/openid-connect

https://github.com/FabianGosebrink/Angular2-ASPNETCore-SignalR-Demo

Getting Started with ASP NET Core 1 and Angular 2 in Visual Studio 2015

http://benjii.me/2016/01/angular2-routing-with-asp-net-core-1/

http://tattoocoder.azurewebsites.net/angular2-aspnet5-spa-template/

Cross-platform Single Page Applications with ASP.NET Core 1.0, Angular 2 & TypeScript

12 comments

  1. Hi,

    Couple of remarks.

    1) Return a 400 Bad Request when a file is not found? Isn’t that what the 404 Not Found is for?
    2) Returning a 403 Forbidden when the user doesn’t have the right to download sounds good at first thought, but is a security leak. You’re basically admitting that the file exists. I would consider returning a 404 instead: that this resource does not exist in the subset of resources available to the authenticated user. It depends on whether you want to provide a link to the resource in the first place (i.e. when the user searches for all available files)

  2. Hi Dave, thanks.

    1) I’ll fix this, thanks
    2) Maybe. I’m not certain which is more correct, maybe it depends on the requirements. I don’t supply the links for “forbidden” files, (The user can only see files for which access is allowed) you have to change the URL directly in the browser, so yes I agree with you.

    Thanks and greetings Damien

    1. Point one is fixed

      Thanks Dave for pointing this out.

      Greetings Damien

  3. Is it safe to place access tokens inside the URL in a request? What happens if it is cached somewhere, wouldn’t it be possible for other users to re-download the file shortly after the original user did?

    1. If the user logs out on the server, it is safe. (This will not work till sometime after ASP.NET core RC2 release, I’ll add an example for this.)

      Thanks for pointing this out.

      Greetings Damien

  4. Alistair · · Reply

    We approached this in a different way:
    – The user clicked on a link to a document. A request is sent to the API checking the user has permission to view the file.
    – If the user has permissions a one time token is created, stored in the database and returned.to the client.
    – The client then makes another request with the token and filename in the query string to a different end point.
    – The API checks to make sure the token exists, has not expired and is for that file. The file is then downloaded. The token is then deleted.

    All this was done in an Angular directive so all the user did was click on the link. This is a slightly longer process but gets round the problem of the token appearing in the URL and being logged. If someone tries to use the same token it doesn’t matter as it no longer exists.

    1. Hi Alistair, nice solution, thanks
      Greetings Damien

  5. kallol · · Reply

    We have a solution with following projects –
    1. MVC6 (ASPNET 5 RC1) for building web service using following nugget pkg –
    “IdentityServer4.AccessTokenValidation”: “1.0.0-beta3”

    2. A .NET 4.5.1 class library containing Entity Framework 6 for SQL stored proc calls

    The issue is web service project does not compile with the class library

    Following is project.json –

    “frameworks”: {
    “dnx451”: {
    “dependencies”: {
    “.Data”: “1.0.0-*”,
    “IdentityServer4.AccessTokenValidation”: “1.0.0-beta3”
    }
    },
    “net451”: {
    “dependencies”: {
    “.Data”: “1.0.0-*”
    }
    }
    },

    Here are the compile errors –

    Startup.cs(7,14,7,27): .NET Framework 4.5.1 error CS0234: The type or namespace name ‘IdentityModel’ does not exist in the namespace ‘System’ (are you missing an assembly reference?)

    Startup.cs(48,13,48,36): .NET Framework 4.5.1 error CS0103: The name ‘JwtSecurityTokenHandler’ does not exist in the current context

    Startup.cs(49,17,49,48): .NET Framework 4.5.1 error CS1061: ‘IApplicationBuilder’ does not contain a definition for ‘UseIdentityServerAuthentication’ and no extension method ‘UseIdentityServerAuthentication’ accepting a first argument of type ‘IApplicationBuilder’ could be found (are you missing a using directive or an assembly reference?)

    Does this look similar to any problem you have seen or heard?

  6. […] Secure file download using IdentityServer4, Angular2 and … – Secure file download using IdentityServer4, … Secure file download using IdentityServer4, Angular2 and ASP.NET Core; Angular2 Secure File Download … […]

  7. The link to the code above is not working. Also, it seems the code on this page is somewhat out dated as I no longer see the options.AutomaticAuthenticate and options.AutomaticChallenge properties on the IdentityServerAuthenticationOptions type.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: