Angular secure file download without using an access token in URL or cookies

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:

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.

secureNoAccessInUrl_01

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

9 comments

  1. […] 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 […]

  2. 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

  3. Mohammad Taraby · · Reply

    Great article, you are the best ..

  4. Thanks for the info!

  5. perponcher · · Reply

    Thanks for the info!

  6. mahmoudalaskalany · · Reply

    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

  7. Corey Tollerud · · Reply

    Question: Any reason to not use the IMemoryCache if it’s already in use in the project?

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: