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.
Note: This branch and post will not be updated. Use the follow up post and code for this requirement.
Other posts in this series:
- OAuth2 Implicit Flow with Angular and ASP.NET Core 1.1 IdentityServer4
- Authorization Policies and Data Protection with IdentityServer4 in ASP.NET Core
- AngularJS OpenID Connect Implicit Flow with IdentityServer4
- Angular OpenID Connect Implicit Flow with IdentityServer4
- Secure file download using IdentityServer4, Angular2 and ASP.NET Core
- Angular Secure File Download without using an access token in URL or cookies
- Full Server logout with IdentityServer4 and OpenID Connect Implicit Flow
- IdentityServer4, WebAPI and Angular in a single ASP.NET Core project
- Extending Identity in IdentityServer4 to manage users in ASP.NET Core
- Implementing a silent token renew in Angular for the OpenID Connect Implicit flow
- OpenID Connect Session Management using an Angular application and IdentityServer4
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.
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.
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
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)
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
Point one is fixed
Thanks Dave for pointing this out.
Greetings Damien
Hey, no problem.
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?
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
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.
Hi Alistair, nice solution, thanks
Greetings Damien
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?
[…] 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 … […]
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.
Yes, this post is no longer maintained. You should use this one
https://damienbod.com/2016/04/02/angular2-secure-file-download-without-using-an-access-token-in-url-or-cookies/
Greeetings Damien