This article shows how an Angular SPA client can download files using an access token without passing it to the resource server in the URL. The access token is only used in the HTTP Header.
If the access token is sent in the URL, this will be saved in server logs, routing logs, browser history, or copy/pasted by users and sent to other users in emails etc. If the user does not log out after using the application, the access token will still be valid until a token timeout. Due to this, it is better to not send an access token in the URL.
The article shows how this could be implemented without using cookies and without sending the access token in the URL. The application is implemented using OpenID Connect Implicit Flow with IdentityServer4 with ASP.NET Core.
Code: https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow
History:
2019-09-20: Updated ASP.NET Core 3.0, Angular 8.2.6
2019-02-04: Updated ASP.NET Core 2.2, Angular 7.2.0, ASP.NET Core Identity 2.2
Full history:
https://github.com/damienbod/AspNet5IdentityServerAngularImplicitFlow#history
Posts in this series:
- Authorization Policies and Data Protection with IdentityServer4 in ASP.NET Core
- Angular OpenID Connect Implicit Flow with IdentityServer4
- 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
Angular Client
The Angular application uses the SecureFileService to download the files securely. The SecurityService is injected in this service using dependency injection and the access token can be accessed through this service. The DownloadFile function implements the download service. The access token is added to the HTTP request headers using the Headers from the ‘@angular/common/http’ component. This is then added in the setHeaders function.
The service calls GenerateOneTimeAccessToken with the file id. The HTTP request returns an access id which can be used once within 30 seconds. This access id is then used in a second HTTP request in the URL which downloads the required file. It does not matter if this is copied as it cannot be reused.
import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Configuration } from '../app.constants'; import { OidcSecurityService } from '../auth/services/oidc.security.service'; @Injectable() export class SecureFileService { private actionUrl: string; private fileExplorerUrl: string; private headers: HttpHeaders = new HttpHeaders(); constructor(private http: HttpClient, _configuration: Configuration, private oidcSecurityService: OidcSecurityService) { this.actionUrl = `${_configuration.FileServer}api/Download/`; this.fileExplorerUrl = `${_configuration.FileServer }api/FileExplorer/`; } public DownloadFile(id: string) { this.setHeaders(); let oneTimeAccessToken = ''; this.http.get(`${this.actionUrl}GenerateOneTimeAccessToken/${id}`, { headers: this.headers }).subscribe( (data: any) => { oneTimeAccessToken = data.oneTimeToken; }, error => this.oidcSecurityService.handleError(error), () => { console.log(`open DownloadFile for file ${id}: ${this.actionUrl}${oneTimeAccessToken}/${id}`); window.open(`${this.actionUrl}${oneTimeAccessToken}/${id}`); }); } public GetListOfFiles = (): Observable<string[]> => { this.setHeaders(); return this.http.get<string[]>(this.fileExplorerUrl, { headers: this.headers }); } private setHeaders() { this.headers = new HttpHeaders(); this.headers = this.headers.set('Content-Type', 'application/json'); this.headers = this.headers.set('Accept', 'application/json'); const token = this.oidcSecurityService.getToken(); if (token !== '') { const tokenValue = 'Bearer ' + token; this.headers = this.headers.set('Authorization', tokenValue); } } }
The DownloadFileById function in the SecureFilesComponent component is used to call the service DownloadFile(id) function which downloads the file as explained above.
import { Component, OnInit, OnDestroy } from '@angular/core'; import { Subscription } from 'rxjs'; import { SecureFileService } from './SecureFileService'; import { OidcSecurityService } from '../auth/services/oidc.security.service'; @Component({ selector: 'app-securefiles', templateUrl: 'securefiles.component.html', providers: [SecureFileService] }) export class SecureFilesComponent implements OnInit, OnDestroy { public message: string; public Files: string[] = []; isAuthorizedSubscription: Subscription | undefined; isAuthorized = false; constructor(private _secureFileService: SecureFileService, public oidcSecurityService: OidcSecurityService) { this.message = 'Secure Files download'; } ngOnInit() { this.isAuthorizedSubscription = this.oidcSecurityService.getIsAuthorized().subscribe( (isAuthorized: boolean) => { this.isAuthorized = isAuthorized; if (isAuthorized) { this.getData(); } }); } ngOnDestroy(): void { if (this.isAuthorizedSubscription) { this.isAuthorizedSubscription.unsubscribe(); } } public DownloadFileById(id: any) { this._secureFileService.DownloadFile(id); } private getData() { this._secureFileService.GetListOfFiles() .subscribe(data => this.Files = data, error => this.oidcSecurityService.handleError(error), () => console.log('getData for secure files, get all completed')); } }
The component HTML template creates a list of files which can be download by the current authorized user and adds these in the browser as links.
<div class="col-md-12" *ngIf="securityService.IsAuthorized()" > <div class="panel panel-default"> <div class="panel-heading"> <h3 class="panel-title">{{message}}</h3> </div> <div class="panel-body"> <table class="table"> <thead> <tr> <th>Name</th> </tr> </thead> <tbody> <tr style="height:20px;" *ngFor="#file of Files" > <td><a (click)="DownloadFileById(file)">Download {{file}}</a></td> </tr> </tbody> </table> </div> </div> </div>
The SecurityService is used to login and get the access token and the token id from Identity Server 4. This is explained in the previous post Angular OpenID Connect Implicit Flow with IdentityServer4.
Implementing the File Server
The server API implements a GenerateOneTimeAccessToken method to start the download. This method authorizes using policies and checks if the file id exists. A HttpNotFound is returned, if the file id does not exist. It then validates, if the file exists on the file server. The method also checks, if the user is an administrator, and uses this to validate that the user is authorized to access the file. The AddFileIdForUseOnceAccessId method is then used to generate a use once access id for this file.
[Authorize] [HttpGet("GenerateOneTimeAccessToken/{id}")] public IActionResult GenerateOneTimeAccessToken(string id) { if (!_securedFileProvider.FileIdExists(id)) { return NotFound($"File id does not exist: {id}"); } var filePath = $"{_appEnvironment.ContentRootPath}/SecuredFileShare/{id}"; if (!System.IO.File.Exists(filePath)) { return NotFound($"File does not exist: {id}"); } var adminClaim = User.Claims.FirstOrDefault(x => x.Type == "role" && x.Value == "securedFiles.admin"); if (_securedFileProvider.HasUserClaimToAccessFile(id, adminClaim != null)) { // TODO generate a one time access token var oneTimeToken = _securedFileProvider.AddFileIdForUseOnceAccessId(filePath); return Ok(new DownloadToken { OneTimeToken = oneTimeToken }); } // returning a HTTP Forbidden result. return new StatusCodeResult(403); }
The download file API can be used with the use once access id parameter. This method uses the access id to retrieve the file path using the GetFileIdForUseOnceAccessId method. If the access id is valid, the file can be downloaded using the FileContentResult.
[AllowAnonymous] [HttpGet("{accessId}")] public IActionResult Get(string accessId) { var filePath = _securedFileProvider.GetFileIdForUseOnceAccessId(accessId); if(!string.IsNullOrEmpty(filePath)) { var fileContents = System.IO.File.ReadAllBytes(filePath); return new FileContentResult(fileContents, "application/octet-stream"); } // returning a HTTP Forbidden result. return new HttpStatusCodeResult(401); }
The UseOnceAccessIdService is responsible for generating the access id and validating it when using it. The AddFileIdForUseOnceAccessId method creates a new UseOnceAccessId object which creates a random string which is used for the download access id. The object saves the time stamp as a property and also the file path which will be available for download for 30 seconds. This object is then saved to a in-memory list. The UseOnceAccessIdService service is registered as a singleton in the startup class.
The GetFileIdForUseOnceAccessId removes any objects which are older than 30 seconds. It then retrieves the UseOnceAccessId object if it still exists and returns the file path from this. The object is then deleted. This prevents that the file can be downloaded twice using the same key.
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace ResourceFileServer.Providers { public class UseOnceAccessIdService { /// <summary> /// One time tokens live for a max of 30 seconds /// </summary> private double _timeToLive = 30.0; private static object lockObject = new object(); private List<UseOnceAccessId> _useOnceAccessIds = new List<UseOnceAccessId>(); public string GetFileIdForUseOnceAccessId(string useOnceAccessId) { var fileId = string.Empty; lock(lockObject) { // Max 30 seconds to start download after requesting one time token. _useOnceAccessIds.RemoveAll(t => t.Created < DateTime.UtcNow.AddSeconds(-_timeToLive)); var item = _useOnceAccessIds.FirstOrDefault(t => t.AccessId == useOnceAccessId); if (item != null) { fileId = item.FileId; _useOnceAccessIds.Remove(item); } } return fileId; } public string AddFileIdForUseOnceAccessId(string filePath) { var useOnceAccessId = new UseOnceAccessId(filePath); lock (lockObject) { _useOnceAccessIds.Add(useOnceAccessId); } return useOnceAccessId.AccessId; } } }
The UseOnceAccessId object is used to save a request for a download and generates the random access id string in the constructor.
using System; namespace ResourceFileServer.Providers { internal class UseOnceAccessId { public UseOnceAccessId(string fileId) { Created = DateTime.UtcNow; AccessId = CreateAccessId(); FileId = fileId; } public DateTime Created { get; } public string AccessId { get; } public string FileId { get; } private string CreateAccessId() { SecureRandom secureRandom = new SecureRandom(); return secureRandom.Next() + Guid.NewGuid().ToString(); } } }
Now the files can be downloaded from the resource file server without using the access token in the URL or without using cookies.
Notes: Thanks for Alistair for pointing this out in the comments of the previous post. Maybe it would be nice, if Identity Server 4 could support this using ‘use once tokens’, then the standard authorization middleware could be used on the resource server.
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
[…] Angular2 secure file download without using an access token in URL or cookies – Damian Bowden takes a look at how you can perform secure file downloads using Angular2 […]
As always Damien, you are the MAN!
Keep up the amazing work. Every-time I see your blog it just gets better and better!
Cheers
thanks
Great article, you are the best ..
Thanks for the info!
Thanks for the info!
Excellent work keep up the good work i used it in my project
can i suggest changing the font of the post it will be looking good
cool, thanks
Question: Any reason to not use the IMemoryCache if it’s already in use in the project?