Quantcast
Channel: MVC – Software Engineering
Viewing all 96 articles
Browse latest View live

Building production ready Angular apps with Visual Studio and ASP.NET Core

$
0
0

This article shows how Angular SPA apps can be built using Visual Studio and ASP.NET Core which can be used in production. Lots of articles, blogs templates exist for ASP.NET Core and Angular but very few support Angular production builds.

Although Angular 2 is not so old, many different seeds and build templates already exist, so care should be taken when choosing the infrastructure for the Angular application. Any Angular template, seed which does not support AoT or treeshaking should NOT be used, and also any third party Angular component which does not support AoT should not be used.

This example uses webpack 2 to build and bundle the Angular application. In the package.json, npm scripts are used to configure the different builds and can be used inside Visual Studio using the npm task runner.

Code:

VS2015: https://github.com/damienbod/Angular2WebpackVisualStudio

VS2017: https://github.com/damienbod/Angular2WebpackVisualStudio/tree/VisualStudio2017

Short introduction to AoT and treeshaking

AoT

AoT stands for Ahead of Time compilation. As per definition from the Angular docs:

“With AOT, the browser downloads a pre-compiled version of the application. The browser loads executable code so it can render the application immediately, without waiting to compile the app first.”

With AoT, you have smaller packages sizes, fewer asynchronous requests and better security. All is explained very well in the Angular Docs:

https://angular.io/docs/ts/latest/cookbook/aot-compiler.html

The AoT uses the platformBrowser to bootstrap and not platformBrowserDynamic which is used for JIT, Just in Time.

// Entry point for AoT compilation.
export * from './polyfills';

import { platformBrowser } from '@angular/platform-browser';
import { enableProdMode } from '@angular/core';
import { AppModuleNgFactory } from '../aot/angular2App/app/app.module.ngfactory';

enableProdMode();

platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);

treeshaking

Treeshaking removes the unused portions of the libraries from the application, reducing the size of the application.

https://angular.io/docs/ts/latest/cookbook/aot-compiler.html

npm task runner

npm scripts can be used easily inside Visual Studio by using the npm task runner. Once installed, this needs to be configured correctly.

VS2015: Go to Tools –> Options –> Projects and Solutions –> External Web Tools and select all the checkboxes. More infomation can be found here.

In VS2017, this is slightly different:

Go to Tools –> Options –> Projects and Solutions –> Web Package Management –> External Web Tools and select all checkboxes:

vs_angular_build_01

npm scripts

ngc

ngc is the angular compiler which is used to do the AoT build using the tsconfig-aot.json configuration.

"ngc": "ngc -p ./tsconfig-aot.json",

The tsconfig-aot.json file builds to the aot folder.

{
  "compilerOptions": {
    "target": "es5",
    "module": "es2015",
    "moduleResolution": "node",
    "sourceMap": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "removeComments": true,
    "noImplicitAny": true,
    "suppressImplicitAnyIndexErrors": true,
    "skipLibCheck": true,
    "lib": [
      "es2015",
      "dom"
    ]
  },
  "files": [
    "angular2App/app/app.module.ts",
    "angular2App/main-aot.ts"
  ],
  "angularCompilerOptions": {
    "genDir": "aot",
    "skipMetadataEmit": true
  },
  "compileOnSave": false,
  "buildOnSave": false
}

build-production

The build-production npm script is used for the production build and can be used for the publish or the CI as required. The npm script used the ngc script and the webpack-production build.

"build-production": "npm run ngc && npm run webpack-production",

webpack-production npm script:

"webpack-production": "set NODE_ENV=production&& webpack",

watch-webpack-dev

The watch build monitors the source files and builds if any file changes.

"watch-webpack-dev": "set NODE_ENV=development&& webpack --watch --color",

start (webpack-dev-server)

The start script runs the webpack-dev-server client application and also the ASPNET Core server application.

"start": "concurrently \"webpack-dev-server --inline --progress --port 8080\" \"dotnet run\" ",

Any of these npm scripts can be run from the npm task runner.

vs_angular_build_02

Deployment

When deploying the application for an IIS, the build-production needs to be run, then the dotnet publish command, and then the contents can be copied to the IIS server. The publish-for-iis npm script can be used to publish. The command can be started from a build server without problem.

"publish-for-iis": "npm run build-production && dotnet publish -c Release"

vs_angular_build_02

https://docs.microsoft.com/en-us/dotnet/articles/core/tools/dotnet-publish

When deploying to an IIS, you need to install the DotNetCore.1.1.0-WindowsHosting.exe for the IIS. Setting up the server IIS docs:

https://docs.microsoft.com/en-us/aspnet/core/publishing/iis

Why not webpack task runner?

The Webpack task runner cannot be used for Webpack Angular applications because it does not support the required commands for Angular Webpack builds, either dev or production. The webpack -d build causes map errors in IE and the ngc compiler cannot be used, hence no production builds can be started from the Webpack Task Runner. For Angular Webpack projects, do not use the Webpack Task Runner, use the npm task runner.

Full package.json

{
  "version": "1.0.0",
  "description": "",
  "main": "wwwroot/index.html",
  "author": "",
  "license": "ISC",
  "scripts": {
    "ngc": "ngc -p ./tsconfig-aot.json",
    "start": "concurrently \"webpack-dev-server --inline --progress --port 8080\" \"dotnet run\" ",
    "webpack-dev": "set NODE_ENV=development&& webpack",
    "webpack-production": "set NODE_ENV=production&& webpack",
    "build-dev": "npm run webpack-dev",
    "build-production": "npm run ngc && npm run webpack-production",
    "watch-webpack-dev": "set NODE_ENV=development&& webpack --watch --color",
    "watch-webpack-production": "npm run build-production --watch --color"
  },
  "dependencies": {
    "@angular/common": "~2.4.1",
    "@angular/compiler": "~2.4.1",
    "@angular/core": "~2.4.1",
    "@angular/forms": "~2.4.1",
    "@angular/http": "~2.4.1",
    "@angular/platform-browser": "~2.4.1",
    "@angular/platform-browser-dynamic": "~2.4.1",
    "@angular/router": "~3.4.1",
    "@angular/upgrade": "~2.4.1",
    "angular-in-memory-web-api": "~0.2.3",
    "core-js": "^2.4.1",
    "reflect-metadata": "^0.1.8",
    "rxjs": "5.0.1",
    "zone.js": "^0.7.4",
    "@angular/compiler-cli": "2.4.1",
    "@angular/platform-server": "~2.4.1",
    "bootstrap": "^3.3.7",
    "ie-shim": "^0.1.0"
  },
  "devDependencies": {
    "@types/node": "^6.0.52",
    "angular2-template-loader": "^0.5.0",
    "awesome-typescript-loader": "^2.2.4",
    "clean-webpack-plugin": "^0.1.9",
    "concurrently": "^3.1.0",
    "copy-webpack-plugin": "^2.1.3",
    "css-loader": "^0.23.0",
    "file-loader": "^0.8.4",
    "html-webpack-plugin": "^2.8.1",
    "jquery": "^2.2.0",
    "json-loader": "^0.5.3",
    "node-sass": "^3.10.1",
    "raw-loader": "^0.5.1",
    "rimraf": "^2.5.2",
    "sass-loader": "^4.0.2",
    "source-map-loader": "^0.1.5",
    "style-loader": "^0.13.0",
    "ts-helpers": "^1.1.1",
    "typescript": "2.0.3",
    "url-loader": "^0.5.6",
    "webpack": "^2.2.0-rc.3",
    "webpack-dev-server": "^1.16.2"
  },
  "-vs-binding": { "ProjectOpened": [ "watch-webpack-dev" ] }
}

Full webpack.prod.js

var path = require('path');

var webpack = require('webpack');

var HtmlWebpackPlugin = require('html-webpack-plugin');
var CopyWebpackPlugin = require('copy-webpack-plugin');
var CleanWebpackPlugin = require('clean-webpack-plugin');
var helpers = require('./webpack.helpers');

console.log("@@@@@@@@@ USING PRODUCTION @@@@@@@@@@@@@@@");

module.exports = {

    entry: {
        'vendor': './angular2App/vendor.ts',
        'app': './angular2App/main-aot.ts' // AoT compilation
    },

    output: {
        path: "./wwwroot/",
        filename: 'dist/[name].[hash].bundle.js',
        publicPath: "/"
    },

    resolve: {
        extensions: ['.ts', '.js', '.json', '.css', '.scss', '.html']
    },

    devServer: {
        historyApiFallback: true,
        stats: 'minimal',
        outputPath: path.join(__dirname, 'wwwroot/')
    },

    module: {
        rules: [
            {
                test: /\.ts$/,
                loaders: [
                    'awesome-typescript-loader'
                ]
            },
            {
                test: /\.(png|jpg|gif|woff|woff2|ttf|svg|eot)$/,
                loader: "file-loader?name=assets/[name]-[hash:6].[ext]",
            },
            {
                test: /favicon.ico$/,
                loader: "file-loader?name=/[name].[ext]",
            },
            {
                test: /\.css$/,
                loader: "style-loader!css-loader"
            },
            {
                test: /\.scss$/,
                exclude: /node_modules/,
                loaders: ["style-loader", "css-loader", "sass-loader"]
            },
            {
                test: /\.html$/,
                loader: 'raw-loader'
            }
        ],
        exprContextCritical: false
    },

    plugins: [
        new CleanWebpackPlugin(
            [
                './wwwroot/dist',
                './wwwroot/assets'
            ]
        ),
        new webpack.NoErrorsPlugin(),
        new webpack.optimize.UglifyJsPlugin({
            compress: {
                warnings: false
            },
            output: {
                comments: false
            },
            sourceMap: false
        }),
        new webpack.optimize.CommonsChunkPlugin(
            {
                name: ['vendor']
            }),

        new HtmlWebpackPlugin({
            filename: 'index.html',
            inject: 'body',
            chunksSortMode: helpers.packageSort(['vendor', 'app']),
            template: 'angular2App/index.html'
        }),

        new CopyWebpackPlugin([
            { from: './angular2App/images/*.*', to: "assets/", flatten: true }
        ])
    ]
};


Links:

https://damienbod.com/2016/06/12/asp-net-core-angular2-with-webpack-and-visual-studio/

https://github.com/preboot/angular2-webpack

https://webpack.github.io/docs/

https://github.com/jtangelder/sass-loader

https://github.com/petehunt/webpack-howto/blob/master/README.md

https://blogs.msdn.microsoft.com/webdev/2015/03/19/customize-external-web-tools-in-visual-studio-2015/

https://marketplace.visualstudio.com/items?itemName=MadsKristensen.NPMTaskRunner

http://sass-lang.com/

http://blog.thoughtram.io/angular/2016/06/08/component-relative-paths-in-angular-2.html

https://angular.io/docs/ts/latest/guide/webpack.html

http://blog.mgechev.com/2016/06/26/tree-shaking-angular2-production-build-rollup-javascript/

https://angular.io/docs/ts/latest/tutorial/toh-pt5.html

http://angularjs.blogspot.ch/2016/06/improvements-coming-for-routing-in.html?platform=hootsuite

https://angular.io/docs/ts/latest/cookbook/aot-compiler.html

https://docs.microsoft.com/en-us/aspnet/core/publishing/iis

https://weblog.west-wind.com/posts/2016/Jun/06/Publishing-and-Running-ASPNET-Core-Applications-with-IIS



Angular 2 Lazy Loading with Webpack 2

$
0
0

This article shows how Angular 2 lazy loading can be supported using Webpack 2 for both JIT and AOT builds. The Webpack loader angular-router-loader from Brandon Roberts is used to implement this.

A big thanks to Roberto Simonetti for his help in this.

Code: Visual Studio 2015 project | Visual Studio 2017 project

Blogs in this series:

2017.01.18: Updated to webpack 2.2.0

First create an Angular 2 module

In this example, the about module will be lazy loaded when the user clicks on the about tab. The about.module.ts is the entry point for this feature. The module has its own component and routing.
The app will now be setup to lazy load the AboutModule.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';

import { AboutRoutes } from './about.routes';
import { AboutComponent } from './components/about.component';

@NgModule({
    imports: [
        CommonModule,
        AboutRoutes
    ],

    declarations: [
        AboutComponent
    ],

})

export class AboutModule { }

Add the angular-router-loader Webpack loader to the packages.json file

To add lazy loading to the app, the angular-router-loader npm package needs to be added to the packages.json npm file in the devDependencies.

"devDependencies": {
    "@types/node": "7.0.0",
    "angular2-template-loader": "^0.6.0",
    "angular-router-loader": "^0.5.0",

Configure the Angular 2 routing

The lazy loading routing can be added to the app.routes.ts file. The loadChildren defines the module and the class name of the module which can be lazy loaded. It is also possible to pre-load lazy load modules if required.

import { Routes, RouterModule } from '@angular/router';

export const routes: Routes = [
    { path: '', redirectTo: 'home', pathMatch: 'full' },
    {
        path: 'about', loadChildren: './modules/about/about.module#AboutModule',
    }
];

export const AppRoutes = RouterModule.forRoot(routes);

Update the tsconfig-aot.json and tsconfig.json files

Now the tsconfig.json for development JIT builds and the tsconfig-aot.json for AOT production builds need to be configured to load the AboutModule module.

AOT production build

The files property contains all the module entry points as well as the app entry file. The angularCompilerOptions property defines the folder where the AOT will be built into. This must match the configuration in the Webpack production config file.

{
  "compilerOptions": {
    "target": "es5",
    "module": "es2015",
    "moduleResolution": "node",
    "sourceMap": false,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "removeComments": true,
    "noImplicitAny": true,
    "suppressImplicitAnyIndexErrors": true,
    "skipLibCheck": true,
    "lib": [
      "es2015",
      "dom"
    ]
  },
  "files": [
    "angular2App/app/app.module.ts",
    "angular2App/app/modules/about/about.module.ts",
    "angular2App/main-aot.ts"
  ],
  "angularCompilerOptions": {
    "genDir": "aot",
    "skipMetadataEmit": true
  },
  "compileOnSave": false,
  "buildOnSave": false
}

JIT development build

The modules and entry points are also defined for the JIT build.

{
  "compilerOptions": {
    "target": "es5",
    "module": "es2015",
    "moduleResolution": "node",
    "sourceMap": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "removeComments": true,
    "noImplicitAny": true,
    "skipLibCheck": true,
    "lib": [
      "es2015",
      "dom"
    ],
    "types": [
      "node"
    ]
  },
  "files": [
    "angular2App/app/app.module.ts",
    "angular2App/app/modules/about/about.module.ts",
    "angular2App/main.ts"
  ],
  "awesomeTypescriptLoaderOptions": {
    "useWebpackText": true
  },
  "compileOnSave": false,
  "buildOnSave": false
}

Configure Webpack to chunk and use the router lazy loading

Now the webpack configuration needs to be updated for the lazy loading.

AOT production build

The webpack.prod.js file requires that the chunkFilename property is set in the output, so that webpack chunks the lazy load modules.

output: {
        path: './wwwroot/',
        filename: 'dist/[name].[hash].bundle.js',
        chunkFilename: 'dist/[id].[hash].chunk.js',
        publicPath: '/'
},

The angular-router-loader is added to the loaders. The genDir folder defined here must match the definition in tsconfig-aot.json.

 module: {
  rules: [
    {
        test: /\.ts$/,
        loaders: [
            'awesome-typescript-loader',
            'angular-router-loader?aot=true&genDir=aot/'
        ]
    },

JIT development build

The webpack.dev.js file requires that the chunkFilename property is set in the output, so that webpack chunks the lazy load modules.

output: {
        path: './wwwroot/',
        filename: 'dist/[name].bundle.js',
        chunkFilename: 'dist/[id].chunk.js',
        publicPath: '/'
},

The angular-router-loader is added to the loaders.

 module: {
  rules: [
    {
        test: /\.ts$/,
        loaders: [
            'awesome-typescript-loader',
            'angular-router-loader',
            'angular2-template-loader',
            'source-map-loader',
            'tslint-loader'
        ]
    },

Build and run

Now the application can be built using the npm build scripts and the dotnet command tool.

Open a command line in the root of the src files. Install the npm packages:

npm install

Now build the production build. The build-production does a ngc build, and then a webpack production build.

npm run build-production

You can see that Webpack creates an extra chunked file for the About Module.

lazyloadingwebpack_01

Then start the application. The server is implemented using ASP.NET Core 1.1.

dotnet run

When the application is started, the AboutModule is not loaded.

lazyloadingwebpack_02

When the about tab is clicked, the chunked AboutModule is loaded.

lazyloadingwebpack_03

Absolutely fantastic. You could also pre-load the modules if required. See this blog.

Links:

https://github.com/brandonroberts/angular-router-loader

https://www.npmjs.com/package/angular-router-loader

https://github.com/robisim74/angular2localization/tree/gh-pages

https://vsavkin.com/angular-router-preloading-modules-ba3c75e424cb

https://webpack.github.io/docs/


Implementing an Audit Trail using ASP.NET Core and Elasticsearch with NEST

$
0
0

This article shows how an audit trail can be implemented in ASP.NET Core which saves the audit documents to Elasticsearch using NEST.

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

Should I just use a logger?

Depends. If you just need to save requests, responses and application events, then a logger would be a better solution for this use case. I would use NLog as it provides everything you need, or could need, when working with ASP.NET Core.

If you only need to save business events/data of the application in the audit trail, then this solution could fit.

Using the Audit Trail

The audit trail is implemented so that it can be used easily. In the Startup class of the ASP.NET Core application, it is added to the application in the ConfigureServices method. The class library provides an extension method, AddAuditTrail, which can be configured as required. It takes 2 parameters, a bool parameter which defines if a new index is created per day or per month to save the audit trail documents, and a second int parameter which defines how many of the previous indices are included in the alias used to select the audit trail items. If this is 0, all indices are included for the search.

Because the audit trail documents are grouped into different indices per day or per month, the amount of documents can be controlled in each index. Usually the application user requires only the last n days, or last 2 months of the audit trails, and so the search does not need to search through all audit trails documents since the application began. This makes it possible to optimize the data as required, or even remove, archive old unused audit trail indices.

public void ConfigureServices(IServiceCollection services)
{
	var indexPerMonth = false;
	var amountOfPreviousIndicesUsedInAlias = 3;
	services.AddAuditTrail<CustomAuditTrailLog>(options =>
		options.UseSettings(indexPerMonth, amountOfPreviousIndicesUsedInAlias)
	);

	services.AddMvc();
}

The AddAuditTrail extension method requires a model definition which will be used to save or retrieve the documents in Elasticsearch. The model must implement the IAuditTrailLog interface. This interface just forces you to implement the property Timestamp which is required for the audit logs.

The model can then be designed, defined as required. NEST attributes can be used for each of the properties in the model. Use the keyword attribute, if the text field should not be analyzed. If you must use enums, then save the string value and NOT the integer value to the persistent layer. If integer values are saved for the enums, then it cannot be used without the knowledge of what each integer value represents, making it dependent on the code.

using AuditTrail.Model;
using Nest;
using System;

namespace AspNetCoreElasticsearchNestAuditTrail
{
    public class CustomAuditTrailLog : IAuditTrailLog
    {
        public CustomAuditTrailLog()
        {
            Timestamp = DateTime.UtcNow;
        }

        public DateTime Timestamp { get; set; }

        [Keyword]
        public string Action { get; set; }

        public string Log { get; set; }

        public string Origin { get; set; }

        public string User { get; set; }

        public string Extra { get; set; }
    }
}

The audit trail can then be used anywhere in the application. The IAuditTrailProvider can be added in the constructor of the class and an audit document can be created using the AddLog method.

private readonly IAuditTrailProvider<CustomAuditTrailLog> _auditTrailProvider;

public HomeController(IAuditTrailProvider<CustomAuditTrailLog> auditTrailProvider)
{
	_auditTrailProvider = auditTrailProvider;
}

public IActionResult Index()
{
	var auditTrailLog = new CustomAuditTrailLog()
	{
		User = User.ToString(),
		Origin = "HomeController:Index",
		Action = "Home GET",
		Log = "home page called doing something important enough to be added to the audit log.",
		Extra = "yep"
	};

	_auditTrailProvider.AddLog(auditTrailLog);
	return View();
}

The audit trail documents can be viewed using QueryAuditLogs which supports paging and uses a simple query search which accepts wildcards. The AuditTrailSearch method returns a MVC view with the audit trail items in the model.

public IActionResult AuditTrailSearch(string searchString, int skip, int amount)
{

	var auditTrailViewModel = new AuditTrailViewModel
	{
		Filter = searchString,
		Skip = skip,
		Size = amount
	};

	if (skip > 0 || amount > 0)
	{
		var paging = new AuditTrailPaging
		{
			Size = amount,
			Skip = skip
		};

		auditTrailViewModel.AuditTrailLogs = _auditTrailProvider.QueryAuditLogs(searchString, paging).ToList();

		return View(auditTrailViewModel);
	}

	auditTrailViewModel.AuditTrailLogs = _auditTrailProvider.QueryAuditLogs(searchString).ToList();
	return View(auditTrailViewModel);
}

How is the Audit Trail implemented?

The AuditTrailExtensions class implements the extension methods used to initialize the audit trail implementations. This class accepts the options and registers the interfaces, classes with the IoC used by ASP.NET Core.

Generics are used so that any model class can be used to save the audit trail data. This changes always with each project, application. The type T must implement the interface IAuditTrailLog.

using System;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Localization;
using AuditTrail;
using AuditTrail.Model;

namespace Microsoft.Extensions.DependencyInjection
{
    public static class AuditTrailExtensions
    {
        public static IServiceCollection AddAuditTrail<T>(this IServiceCollection services) where T : class, IAuditTrailLog
        {
            if (services == null)
            {
                throw new ArgumentNullException(nameof(services));
            }

            return AddAuditTrail<T>(services, setupAction: null);
        }

        public static IServiceCollection AddAuditTrail<T>(
            this IServiceCollection services,
            Action<AuditTrailOptions> setupAction) where T : class, IAuditTrailLog
        {
            if (services == null)
            {
                throw new ArgumentNullException(nameof(services));
            }

            services.TryAdd(new ServiceDescriptor(
                typeof(IAuditTrailProvider<T>),
                typeof(AuditTrailProvider<T>),
                ServiceLifetime.Transient));

            if (setupAction != null)
            {
                services.Configure(setupAction);
            }
            return services;
        }
    }
}

When a new audit trail log is added, it uses the index defined in the _indexName field.

public void AddLog(T auditTrailLog)
{
	var index = new IndexName()
	{
		Name = _indexName
	};

	var indexRequest = new IndexRequest<T>(auditTrailLog, index);

	var response = _elasticClient.Index(indexRequest);
	if (!response.IsValid)
	{
		throw new ElasticsearchClientException("Add auditlog disaster!");
	}
}

The _indexName field is defined using the date pattern, either days or months depending on your options.

private const string _alias = "auditlog";
private string _indexName = $"{_alias}-{DateTime.UtcNow.ToString("yyyy-MM-dd")}";

index definition per month:

if(_options.Value.IndexPerMonth)
{
	_indexName = $"{_alias}-{DateTime.UtcNow.ToString("yyyy-MM")}";
}

When quering the audit trail logs, a simple query search query is used to find, select the audit trial documents required for the view. This is used so that wildcards can be used. The method accepts a query filter and paging options. If you search without any filter, all documents are returned which are defined in the alias (used indices). By using the simple query, the filter can accept options like AND, OR for the search.

public IEnumerable<T> QueryAuditLogs(string filter = "*", AuditTrailPaging auditTrailPaging = null)
{
	var from = 0;
	var size = 10;
	EnsureAlias();
	if(auditTrailPaging != null)
	{
		from = auditTrailPaging.Skip;
		size = auditTrailPaging.Size;
		if(size > 1000)
		{
			// max limit 1000 items
			size = 1000;
		}
	}
	var searchRequest = new SearchRequest<T>(Indices.Parse(_alias))
	{
		Size = size,
		From = from,
		Query = new QueryContainer(
			new SimpleQueryStringQuery
			{
				Query = filter
			}
		),
		Sort = new List<ISort>
			{
				new SortField { Field = TimestampField, Order = SortOrder.Descending }
			}
	};

	var searchResponse = _elasticClient.Search<T>(searchRequest);

	return searchResponse.Documents;
}

The alias is also updated in the search query, if required. Depending on you configuration, the alias uses all the audit trail indices or just the last n days, or n months. This check uses a static field. If the alias needs to be updated, the new alias is created, which also deletes the old one.

private void EnsureAlias()
{
	if (_options.Value.IndexPerMonth)
	{
		if (aliasUpdated.Date < DateTime.UtcNow.AddMonths(-1).Date)
		{
			aliasUpdated = DateTime.UtcNow;
			CreateAlias();
		}
	}
	else
	{
		if (aliasUpdated.Date < DateTime.UtcNow.AddDays(-1).Date)
		{
			aliasUpdated = DateTime.UtcNow;
			CreateAlias();
		}
	}
}

Here’s how the alias is created for all indices of the audit trail.

private void CreateAliasForAllIndices()
{
	var response = _elasticClient.AliasExists(new AliasExistsRequest(new Names(new List<string> { _alias })));
	if (!response.IsValid)
	{
		throw response.OriginalException;
	}

	if (response.Exists)
	{
		_elasticClient.DeleteAlias(new DeleteAliasRequest(Indices.Parse($"{_alias}-*"), _alias));
	}

	var responseCreateIndex = _elasticClient.PutAlias(new PutAliasRequest(Indices.Parse($"{_alias}-*"), _alias));
	if (!responseCreateIndex.IsValid)
	{
		throw response.OriginalException;
	}
}

The full AuditTrailProvider class which implements the audit trail.

using AuditTrail.Model;
using Elasticsearch.Net;
using Microsoft.Extensions.Options;
using Nest;
using Newtonsoft.Json.Converters;
using System;
using System.Collections.Generic;
using System.Linq;

namespace AuditTrail
{
    public class AuditTrailProvider<T> : IAuditTrailProvider<T> where T : class
    {
        private const string _alias = "auditlog";
        private string _indexName = $"{_alias}-{DateTime.UtcNow.ToString("yyyy-MM-dd")}";
        private static Field TimestampField = new Field("timestamp");
        private readonly IOptions<AuditTrailOptions> _options;

        private ElasticClient _elasticClient { get; }

        public AuditTrailProvider(
           IOptions<AuditTrailOptions> auditTrailOptions)
        {
            _options = auditTrailOptions ?? throw new ArgumentNullException(nameof(auditTrailOptions));

            if(_options.Value.IndexPerMonth)
            {
                _indexName = $"{_alias}-{DateTime.UtcNow.ToString("yyyy-MM")}";
            }

            var pool = new StaticConnectionPool(new List<Uri> { new Uri("http://localhost:9200") });
            var connectionSettings = new ConnectionSettings(
                pool,
                new HttpConnection(),
                new SerializerFactory((jsonSettings, nestSettings) => jsonSettings.Converters.Add(new StringEnumConverter())))
              .DisableDirectStreaming();

            _elasticClient = new ElasticClient(connectionSettings);
        }

        public void AddLog(T auditTrailLog)
        {
            var index = new IndexName()
            {
                Name = _indexName
            };

            var indexRequest = new IndexRequest<T>(auditTrailLog, index);

            var response = _elasticClient.Index(indexRequest);
            if (!response.IsValid)
            {
                throw new ElasticsearchClientException("Add auditlog disaster!");
            }
        }

        public long Count(string filter = "*")
        {
            EnsureAlias();
            var searchRequest = new SearchRequest<T>(Indices.Parse(_alias))
            {
                Size = 0,
                Query = new QueryContainer(
                    new SimpleQueryStringQuery
                    {
                        Query = filter
                    }
                ),
                Sort = new List<ISort>
                    {
                        new SortField { Field = TimestampField, Order = SortOrder.Descending }
                    }
            };

            var searchResponse = _elasticClient.Search<AuditTrailLog>(searchRequest);

            return searchResponse.Total;
        }

        public IEnumerable<T> QueryAuditLogs(string filter = "*", AuditTrailPaging auditTrailPaging = null)
        {
            var from = 0;
            var size = 10;
            EnsureAlias();
            if(auditTrailPaging != null)
            {
                from = auditTrailPaging.Skip;
                size = auditTrailPaging.Size;
                if(size > 1000)
                {
                    // max limit 1000 items
                    size = 1000;
                }
            }
            var searchRequest = new SearchRequest<T>(Indices.Parse(_alias))
            {
                Size = size,
                From = from,
                Query = new QueryContainer(
                    new SimpleQueryStringQuery
                    {
                        Query = filter
                    }
                ),
                Sort = new List<ISort>
                    {
                        new SortField { Field = TimestampField, Order = SortOrder.Descending }
                    }
            };

            var searchResponse = _elasticClient.Search<T>(searchRequest);

            return searchResponse.Documents;
        }

        private void CreateAliasForAllIndices()
        {
            var response = _elasticClient.AliasExists(new AliasExistsRequest(new Names(new List<string> { _alias })));
            if (!response.IsValid)
            {
                throw response.OriginalException;
            }

            if (response.Exists)
            {
                _elasticClient.DeleteAlias(new DeleteAliasRequest(Indices.Parse($"{_alias}-*"), _alias));
            }

            var responseCreateIndex = _elasticClient.PutAlias(new PutAliasRequest(Indices.Parse($"{_alias}-*"), _alias));
            if (!responseCreateIndex.IsValid)
            {
                throw response.OriginalException;
            }
        }

        private void CreateAlias()
        {
            if (_options.Value.AmountOfPreviousIndicesUsedInAlias > 0)
            {
                CreateAliasForLastNIndices(_options.Value.AmountOfPreviousIndicesUsedInAlias);
            }
            else
            {
                CreateAliasForAllIndices();
            }
        }

        private void CreateAliasForLastNIndices(int amount)
        {
            var responseCatIndices = _elasticClient.CatIndices(new CatIndicesRequest(Indices.Parse($"{_alias}-*")));
            var records = responseCatIndices.Records.ToList();
            List<string> indicesToAddToAlias = new List<string>();
            for(int i = amount;i>0;i--)
            {
                if (_options.Value.IndexPerMonth)
                {
                    var indexName = $"{_alias}-{DateTime.UtcNow.AddMonths(-i + 1).ToString("yyyy-MM")}";
                    if(records.Exists(t => t.Index == indexName))
                    {
                        indicesToAddToAlias.Add(indexName);
                    }
                }
                else
                {
                    var indexName = $"{_alias}-{DateTime.UtcNow.AddDays(-i + 1).ToString("yyyy-MM-dd")}";
                    if (records.Exists(t => t.Index == indexName))
                    {
                        indicesToAddToAlias.Add(indexName);
                    }
                }
            }

            var response = _elasticClient.AliasExists(new AliasExistsRequest(new Names(new List<string> { _alias })));
            if (!response.IsValid)
            {
                throw response.OriginalException;
            }

            if (response.Exists)
            {
                _elasticClient.DeleteAlias(new DeleteAliasRequest(Indices.Parse($"{_alias}-*"), _alias));
            }

            Indices multipleIndicesFromStringArray = indicesToAddToAlias.ToArray();
            var responseCreateIndex = _elasticClient.PutAlias(new PutAliasRequest(multipleIndicesFromStringArray, _alias));
            if (!responseCreateIndex.IsValid)
            {
                throw responseCreateIndex.OriginalException;
            }
        }

        private static DateTime aliasUpdated = DateTime.UtcNow.AddYears(-50);

        private void EnsureAlias()
        {
            if (_options.Value.IndexPerMonth)
            {
                if (aliasUpdated.Date < DateTime.UtcNow.AddMonths(-1).Date)
                {
                    aliasUpdated = DateTime.UtcNow;
                    CreateAlias();
                }
            }
            else
            {
                if (aliasUpdated.Date < DateTime.UtcNow.AddDays(-1).Date)
                {
                    aliasUpdated = DateTime.UtcNow;
                    CreateAlias();
                }
            }
        }
    }
}

Testing the audit log

The created audit trails can be checked using the following HTTP GET requests:

Counts all the audit trail entries in the alias.
http://localhost:9200/auditlog/_count

Shows all the audit trail indices. You can count all the documents from the indices used in the alias and it must match the count from the alias.
http://localhost:9200/_cat/indices/auditlog*

You can also start the application and the AuditTrail logs can be displayed in the Audit Trail logs MVC view.

01_audittrailview

This view is just a quick test, if implementing properly, you would have to localize the timestamp display and add proper paging in the view.

Notes, improvements

If lots of audit trail documents are written at once, maybe a bulk insert could be used to add the documents in batches, like most of the loggers implement this. You should also define a strategy on how the old audit trails, indices should be cleaned up, archived or whatever. The creating of the alias could be optimized depending on you audit trail data, and how you clean up old audit trail indices.

Links:

https://www.elastic.co/guide/en/elasticsearch/reference/5.2/indices-aliases.html

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html

https://docs.microsoft.com/en-us/aspnet/core/

https://www.elastic.co/products/elasticsearch

https://github.com/elastic/elasticsearch-net

https://www.nuget.org/packages/NLog.Web.AspNetCore/


Implementing OpenID Implicit Flow using OpenIddict and Angular

$
0
0

This article shows how to implement the OpenID Connect Implicit Flow using OpenIddict hosted in an ASP.NET Core application, an ASP.NET Core web API and an Angular application as the client.

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

Three different projects are used to implement the application. The OpenIddict Implicit Flow Server is used to authenticate and authorise, the resource server is used to provide the API, and the Angular application implements the UI.

OpenIddict Server implementing the Implicit Flow

To use the OpenIddict NuGet packages to implement an OpenID Connect server, you need to use the myget server. You can add a NuGet.config file to your project to configure this, or add it to the package sources in Visual Studio 2017.

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="NuGet" value="https://api.nuget.org/v3/index.json" />
    <add key="aspnet-contrib" value="https://www.myget.org/F/aspnet-contrib/api/v3/index.json" />
  </packageSources>
</configuration>

Then you can use the NuGet package manager to download the required packages. You need to select the key for the correct source in the drop down on the right hand side, and select the required pre-release packages.

Or you can just add them directly to the csproj file.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>netcoreapp1.1</TargetFramework>
    <PreserveCompilationContext>true</PreserveCompilationContext>
    <OutputType>Exe</OutputType>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="AspNet.Security.OAuth.Validation" Version="1.0.0-rtm-0241" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="1.1.1" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="1.1.1" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.Twitter" Version="1.1.1" />
    <PackageReference Include="Microsoft.AspNetCore.Diagnostics" Version="1.1.1" />
    <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="1.1.1" />
    <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="1.1.2" />
    <PackageReference Include="Microsoft.AspNetCore.Server.IISIntegration" Version="1.1.1" />
    <PackageReference Include="Microsoft.AspNetCore.Server.Kestrel" Version="1.1.1" />
    <PackageReference Include="Microsoft.AspNetCore.Cors" Version="1.1.1" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="1.1.0" />
    <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="1.1.1" />
    <PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="1.1.1" />
    <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="1.1.1" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="1.1.1" />
    <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="1.1.1" />
    <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="1.1.1" />
    <PackageReference Include="Openiddict" Version="1.0.0-beta2-0598" />
    <PackageReference Include="OpenIddict.EntityFrameworkCore" Version="1.0.0-beta2-0598" />
    <PackageReference Include="OpenIddict.Mvc" Version="1.0.0-beta2-0598" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="1.1.1" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Design" Version="1.1.1" />
  </ItemGroup>

  <ItemGroup>
    <DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="1.0.0" />
    <DotNetCliToolReference Include="Microsoft.DotNet.Watcher.Tools" Version="1.0.0" />
  </ItemGroup>

  <ItemGroup>
    <None Update="damienbodserver.pfx">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

</Project>

The OpenIddict packages are configured in the ConfigureServices and the Configure methods in the Startup class. The following code configures the OpenID Connect Implicit Flow with a SQLite database using Entity Framework Core. The required endpoints are enabled, and Json Web tokens are used.

public void ConfigureServices(IServiceCollection services)
{
	services.AddDbContext<ApplicationDbContext>(options =>
	{
		options.UseSqlite(Configuration.GetConnectionString("DefaultConnection"));
		options.UseOpenIddict();
	});

	services.AddIdentity<ApplicationUser, IdentityRole>()
		.AddEntityFrameworkStores<ApplicationDbContext>();

	services.Configure<IdentityOptions>(options =>
	{
		options.ClaimsIdentity.UserNameClaimType = OpenIdConnectConstants.Claims.Name;
		options.ClaimsIdentity.UserIdClaimType = OpenIdConnectConstants.Claims.Subject;
		options.ClaimsIdentity.RoleClaimType = OpenIdConnectConstants.Claims.Role;
	});

	services.AddOpenIddict(options =>
	{
		options.AddEntityFrameworkCoreStores<ApplicationDbContext>();
		options.AddMvcBinders();
		options.EnableAuthorizationEndpoint("/connect/authorize")
			   .EnableLogoutEndpoint("/connect/logout")
			   .EnableIntrospectionEndpoint("/connect/introspect")
			   .EnableUserinfoEndpoint("/api/userinfo");

		options.AllowImplicitFlow();
		options.AddSigningCertificate(_cert);
		options.UseJsonWebTokens();
	});

	var policy = new Microsoft.AspNetCore.Cors.Infrastructure.CorsPolicy();

	policy.Headers.Add("*");
	policy.Methods.Add("*");
	policy.Origins.Add("*");
	policy.SupportsCredentials = true;

	services.AddCors(x => x.AddPolicy("corsGlobalPolicy", policy));

	services.AddMvc();

	services.AddTransient<IEmailSender, AuthMessageSender>();
	services.AddTransient<ISmsSender, AuthMessageSender>();
}

The Configure method defines JwtBearerAuthentication so the userinfo API can be used, or any other authorisered API. The OpenIddict middlware is also added. The commented out method InitializeAsync is used to add OpenIddict data to the existing database. The database was created using Entity Framework Core migrations from the command line.

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

	if (env.IsDevelopment())
	{
		app.UseDeveloperExceptionPage();
		app.UseDatabaseErrorPage();
	}
	else
	{
		app.UseExceptionHandler("/Home/Error");
	}

	app.UseCors("corsGlobalPolicy");

	JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
	JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear();

	var jwtOptions = new JwtBearerOptions()
	{
		AutomaticAuthenticate = true,
		AutomaticChallenge = true,
		RequireHttpsMetadata = true,
		Audience = "dataEventRecords",
		ClaimsIssuer = "https://localhost:44319/",
		TokenValidationParameters = new TokenValidationParameters
		{
			NameClaimType = OpenIdConnectConstants.Claims.Name,
			RoleClaimType = OpenIdConnectConstants.Claims.Role
		}
	};

	jwtOptions.TokenValidationParameters.ValidAudience = "dataEventRecords";
	jwtOptions.TokenValidationParameters.ValidIssuer = "https://localhost:44319/";
	jwtOptions.TokenValidationParameters.IssuerSigningKey = new RsaSecurityKey(_cert.GetRSAPrivateKey().ExportParameters(false));
	app.UseJwtBearerAuthentication(jwtOptions);

	app.UseIdentity();

	app.UseOpenIddict();

	app.UseMvcWithDefaultRoute();

	// Seed the database with the sample applications.
	// Note: in a real world application, this step should be part of a setup script.
	// InitializeAsync(app.ApplicationServices, CancellationToken.None).GetAwaiter().GetResult();
}

Entity Framework Core database migrations:

> dotnet ef migrations add test
> dotnet ef database update test

The UserinfoController controller is used to return user data to the client. The API requires a token which is validated using the JWT Bearer token validation, configured in the Startup class.
The required claims need to be added here, as the application requires. This example adds some extra role claims which are used in the Angular SPA.

using System.Threading.Tasks;
using AspNet.Security.OAuth.Validation;
using AspNet.Security.OpenIdConnect.Primitives;
using OpeniddictServer.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;

namespace OpeniddictServer.Controllers
{
    [Route("api")]
    public class UserinfoController : Controller
    {
        private readonly UserManager<ApplicationUser> _userManager;

        public UserinfoController(UserManager<ApplicationUser> userManager)
        {
            _userManager = userManager;
        }

        //
        // GET: /api/userinfo
        [Authorize(ActiveAuthenticationSchemes = OAuthValidationDefaults.AuthenticationScheme)]
        [HttpGet("userinfo"), Produces("application/json")]
        public async Task<IActionResult> Userinfo()
        {
            var user = await _userManager.GetUserAsync(User);
            if (user == null)
            {
                return BadRequest(new OpenIdConnectResponse
                {
                    Error = OpenIdConnectConstants.Errors.InvalidGrant,
                    ErrorDescription = "The user profile is no longer available."
                });
            }

            var claims = new JObject();
            claims[OpenIdConnectConstants.Claims.Subject] = await _userManager.GetUserIdAsync(user);

            if (User.HasClaim(OpenIdConnectConstants.Claims.Scope, OpenIdConnectConstants.Scopes.Email))
            {
                claims[OpenIdConnectConstants.Claims.Email] = await _userManager.GetEmailAsync(user);
                claims[OpenIdConnectConstants.Claims.EmailVerified] = await _userManager.IsEmailConfirmedAsync(user);
            }

            if (User.HasClaim(OpenIdConnectConstants.Claims.Scope, OpenIdConnectConstants.Scopes.Phone))
            {
                claims[OpenIdConnectConstants.Claims.PhoneNumber] = await _userManager.GetPhoneNumberAsync(user);
                claims[OpenIdConnectConstants.Claims.PhoneNumberVerified] = await _userManager.IsPhoneNumberConfirmedAsync(user);
            }

            List<string> roles = new List<string> { "dataEventRecords", "dataEventRecords.admin", "admin", "dataEventRecords.user" };
            claims["role"] = JArray.FromObject(roles);

            return Json(claims);
        }
    }
}

The AuthorizationController controller implements the CreateTicketAsync method where the claims can be added to the tokens as required. The Implict Flow in this example requires both the id_token and the access_token and extra claims are added to the access_token. These are the claims used by the resource server to set the policies.

private async Task<AuthenticationTicket> CreateTicketAsync(OpenIdConnectRequest request, ApplicationUser user)
{
	var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme);

	var principal = await _signInManager.CreateUserPrincipalAsync(user);
	foreach (var claim in principal.Claims)
	{
		if (claim.Type == _identityOptions.Value.ClaimsIdentity.SecurityStampClaimType)
		{
			continue;
		}

		var destinations = new List<string>
		{
			OpenIdConnectConstants.Destinations.AccessToken
		};

		if ((claim.Type == OpenIdConnectConstants.Claims.Name) ||
			(claim.Type == OpenIdConnectConstants.Claims.Email) ||
			(claim.Type == OpenIdConnectConstants.Claims.Role)  )
		{
			destinations.Add(OpenIdConnectConstants.Destinations.IdentityToken);
		}

		claim.SetDestinations(destinations);

		identity.AddClaim(claim);
	}

	// Add custom claims
	var claimdataEventRecordsAdmin = new Claim("role", "dataEventRecords.admin");
	claimdataEventRecordsAdmin.SetDestinations(OpenIdConnectConstants.Destinations.AccessToken);

	var claimAdmin = new Claim("role", "admin");
	claimAdmin.SetDestinations(OpenIdConnectConstants.Destinations.AccessToken);

	var claimUser = new Claim("role", "dataEventRecords.user");
	claimUser.SetDestinations(OpenIdConnectConstants.Destinations.AccessToken);

	identity.AddClaim(claimdataEventRecordsAdmin);
	identity.AddClaim(claimAdmin);
	identity.AddClaim(claimUser);

	// Create a new authentication ticket holding the user identity.
	var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity),
	new AuthenticationProperties(),
	OpenIdConnectServerDefaults.AuthenticationScheme);

	// Set the list of scopes granted to the client application.
	ticket.SetScopes(new[]
	{
		OpenIdConnectConstants.Scopes.OpenId,
		OpenIdConnectConstants.Scopes.Email,
		OpenIdConnectConstants.Scopes.Profile,
		"role",
		"dataEventRecords"
	}.Intersect(request.GetScopes()));

	ticket.SetResources("dataEventRecords");

	return ticket;
}

If you require more examples, or different flows, refer to the excellent openiddict-samples .

Angular Implicit Flow client

The Angular application uses the AuthConfiguration class to set the options required for the OpenID Connect Implicit Flow. The ‘id_token token’ is defined as the response type so that an access_token is returned as well as the id_token. The jwks_url is required so that the client can ge the signiture from the server to validate the token. The userinfo_url and the logoutEndSession_url are used to define the user data url and the logout url. These could be removed and the data from the jwks_url could be ued to get these parameters. The configuration here has to match the configuration on the server.

import { Injectable } from '@angular/core';

@Injectable()
export class AuthConfiguration {

    // The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) MUST exactly match the value of the iss (issuer) Claim.
    public iss = 'https://localhost:44319/';

    public server = 'https://localhost:44319';

    public redirect_url = 'https://localhost:44308';

    // This is required to get the signing keys so that the signiture of the Jwt can be validated.
    public jwks_url = 'https://localhost:44319/.well-known/jwks';

    public userinfo_url = 'https://localhost:44319/api/userinfo';

    public logoutEndSession_url = 'https://localhost:44319/connect/logout';

    // The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer identified by the iss (issuer) Claim as an audience.
    // The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, or if it contains additional audiences not trusted by the Client.
    public client_id = 'angular4client';

    public response_type = 'id_token token';

    public scope = 'dataEventRecords openid';

    public post_logout_redirect_uri = 'https://localhost:44308/Unauthorized';
}

The OidcSecurityService is used to send the login request to the server and also handle the callback which validates the tokens. This class also persists the token data to the local storage.

import { Injectable } from '@angular/core';
import { Http, Response, Headers } from '@angular/http';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import { Observable } from 'rxjs/Rx';
import { Router } from '@angular/router';
import { AuthConfiguration } from '../auth.configuration';
import { OidcSecurityValidation } from './oidc.security.validation';
import { JwtKeys } from './jwtkeys';

@Injectable()
export class OidcSecurityService {

    public HasAdminRole: boolean;
    public HasUserAdminRole: boolean;
    public UserData: any;

    private _isAuthorized: boolean;
    private actionUrl: string;
    private headers: Headers;
    private storage: any;
    private oidcSecurityValidation: OidcSecurityValidation;

    private errorMessage: string;
    private jwtKeys: JwtKeys;

    constructor(private _http: Http, private _configuration: AuthConfiguration, private _router: Router) {

        this.actionUrl = _configuration.server + 'api/DataEventRecords/';
        this.oidcSecurityValidation = new OidcSecurityValidation();

        this.headers = new Headers();
        this.headers.append('Content-Type', 'application/json');
        this.headers.append('Accept', 'application/json');
        this.storage = sessionStorage; //localStorage;

        if (this.retrieve('_isAuthorized') !== '') {
            this.HasAdminRole = this.retrieve('HasAdminRole');
            this._isAuthorized = this.retrieve('_isAuthorized');
        }
    }

    public IsAuthorized(): boolean {
        if (this._isAuthorized) {
            if (this.oidcSecurityValidation.IsTokenExpired(this.retrieve('authorizationDataIdToken'))) {
                console.log('IsAuthorized: isTokenExpired');
                this.ResetAuthorizationData();
                return false;
            }

            return true;
        }

        return false;
    }

    public GetToken(): any {
        return this.retrieve('authorizationData');
    }

    public ResetAuthorizationData() {
        this.store('authorizationData', '');
        this.store('authorizationDataIdToken', '');

        this._isAuthorized = false;
        this.HasAdminRole = false;
        this.store('HasAdminRole', false);
        this.store('_isAuthorized', false);
    }

    public SetAuthorizationData(token: any, id_token: any) {
        if (this.retrieve('authorizationData') !== '') {
            this.store('authorizationData', '');
        }

        console.log(token);
        console.log(id_token);
        console.log('storing to storage, getting the roles');
        this.store('authorizationData', token);
        this.store('authorizationDataIdToken', id_token);
        this._isAuthorized = true;
        this.store('_isAuthorized', true);

        this.getUserData()
            .subscribe(data => this.UserData = data,
            error => this.HandleError(error),
            () => {
                for (let i = 0; i < this.UserData.role.length; i++) {
                    console.log(this.UserData.role[i]);
                    if (this.UserData.role[i] === 'dataEventRecords.admin') {
                        this.HasAdminRole = true;
                        this.store('HasAdminRole', true);
                    }
                    if (this.UserData.role[i] === 'admin') {
                        this.HasUserAdminRole = true;
                        this.store('HasUserAdminRole', true);
                    }
                }
            });
    }

    public Authorize() {
        this.ResetAuthorizationData();

        console.log('BEGIN Authorize, no auth data');

        let authorizationUrl = this._configuration.server + '/connect/authorize';
        let client_id = this._configuration.client_id;
        let redirect_uri = this._configuration.redirect_url;
        let response_type = this._configuration.response_type;
        let scope = this._configuration.scope;
        let nonce = 'N' + Math.random() + '' + Date.now();
        let state = Date.now() + '' + Math.random();

        this.store('authStateControl', state);
        this.store('authNonce', nonce);
        console.log('AuthorizedController created. adding myautostate: ' + this.retrieve('authStateControl'));

        let url =
            authorizationUrl + '?' +
            'response_type=' + encodeURI(response_type) + '&' +
            'client_id=' + encodeURI(client_id) + '&' +
            'redirect_uri=' + encodeURI(redirect_uri) + '&' +
            'scope=' + encodeURI(scope) + '&' +
            'nonce=' + encodeURI(nonce) + '&' +
            'state=' + encodeURI(state);

        window.location.href = url;
    }

    public AuthorizedCallback() {
        console.log('BEGIN AuthorizedCallback, no auth data');
        this.ResetAuthorizationData();

        let hash = window.location.hash.substr(1);

        let result: any = hash.split('&').reduce(function (result: any, item: string) {
            let parts = item.split('=');
            result[parts[0]] = parts[1];
            return result;
        }, {});

        console.log(result);
        console.log('AuthorizedCallback created, begin token validation');

        let token = '';
        let id_token = '';
        let authResponseIsValid = false;

        this.getSigningKeys()
            .subscribe(jwtKeys => {
                this.jwtKeys = jwtKeys;

                if (!result.error) {

                    // validate state
                    if (this.oidcSecurityValidation.ValidateStateFromHashCallback(result.state, this.retrieve('authStateControl'))) {
                        token = result.access_token;
                        id_token = result.id_token;
                        let decoded: any;
                        let headerDecoded;
                        decoded = this.oidcSecurityValidation.GetPayloadFromToken(id_token, false);
                        headerDecoded = this.oidcSecurityValidation.GetHeaderFromToken(id_token, false);

                        // validate jwt signature
                        if (this.oidcSecurityValidation.Validate_signature_id_token(id_token, this.jwtKeys)) {
                            // validate nonce
                            if (this.oidcSecurityValidation.Validate_id_token_nonce(decoded, this.retrieve('authNonce'))) {
                                // validate iss
                                if (this.oidcSecurityValidation.Validate_id_token_iss(decoded, this._configuration.iss)) {
                                    // validate aud
                                    if (this.oidcSecurityValidation.Validate_id_token_aud(decoded, this._configuration.client_id)) {
                                        // valiadate at_hash and access_token
                                        if (this.oidcSecurityValidation.Validate_id_token_at_hash(token, decoded.at_hash) || !token) {
                                            this.store('authNonce', '');
                                            this.store('authStateControl', '');

                                            authResponseIsValid = true;
                                            console.log('AuthorizedCallback state, nonce, iss, aud, signature validated, returning token');
                                        } else {
                                            console.log('AuthorizedCallback incorrect aud');
                                        }
                                    } else {
                                        console.log('AuthorizedCallback incorrect aud');
                                    }
                                } else {
                                    console.log('AuthorizedCallback incorrect iss');
                                }
                            } else {
                                console.log('AuthorizedCallback incorrect nonce');
                            }
                        } else {
                            console.log('AuthorizedCallback incorrect Signature id_token');
                        }
                    } else {
                        console.log('AuthorizedCallback incorrect state');
                    }
                }

                if (authResponseIsValid) {
                    this.SetAuthorizationData(token, id_token);
                    console.log(this.retrieve('authorizationData'));

                    // router navigate to DataEventRecordsList
                    this._router.navigate(['/dataeventrecords/list']);
                } else {
                    this.ResetAuthorizationData();
                    this._router.navigate(['/Unauthorized']);
                }
            });
    }

    public Logoff() {
        // /connect/endsession?id_token_hint=...&post_logout_redirect_uri=https://myapp.com
        console.log('BEGIN Authorize, no auth data');

        let authorizationEndsessionUrl = this._configuration.logoutEndSession_url;

        let id_token_hint = this.retrieve('authorizationDataIdToken');
        let post_logout_redirect_uri = this._configuration.post_logout_redirect_uri;

        let url =
            authorizationEndsessionUrl + '?' +
            'id_token_hint=' + encodeURI(id_token_hint) + '&' +
            'post_logout_redirect_uri=' + encodeURI(post_logout_redirect_uri);

        this.ResetAuthorizationData();

        window.location.href = url;
    }

    private runGetSigningKeys() {
        this.getSigningKeys()
            .subscribe(
            jwtKeys => this.jwtKeys = jwtKeys,
            error => this.errorMessage = <any>error);
    }

    private getSigningKeys(): Observable<JwtKeys> {
        return this._http.get(this._configuration.jwks_url)
            .map(this.extractData)
            .catch(this.handleError);
    }

    private extractData(res: Response) {
        let body = res.json();
        return body;
    }

    private handleError(error: Response | any) {
        // In a real world app, you might use a remote logging infrastructure
        let errMsg: string;
        if (error instanceof Response) {
            const body = error.json() || '';
            const err = body.error || JSON.stringify(body);
            errMsg = `${error.status} - ${error.statusText || ''} ${err}`;
        } else {
            errMsg = error.message ? error.message : error.toString();
        }
        console.error(errMsg);
        return Observable.throw(errMsg);
    }

    public HandleError(error: any) {
        console.log(error);
        if (error.status == 403) {
            this._router.navigate(['/Forbidden']);
        } else if (error.status == 401) {
            this.ResetAuthorizationData();
            this._router.navigate(['/Unauthorized']);
        }
    }

    private retrieve(key: string): any {
        let item = this.storage.getItem(key);

        if (item && item !== 'undefined') {
            return JSON.parse(this.storage.getItem(key));
        }

        return;
    }

    private store(key: string, value: any) {
        this.storage.setItem(key, JSON.stringify(value));
    }

    private getUserData = (): Observable<string[]> => {
        this.setHeaders();
        return this._http.get(this._configuration.userinfo_url, {
            headers: this.headers,
            body: ''
        }).map(res => res.json());
    }

    private setHeaders() {
        this.headers = new Headers();
        this.headers.append('Content-Type', 'application/json');
        this.headers.append('Accept', 'application/json');

        let token = this.GetToken();

        if (token !== '') {
            this.headers.append('Authorization', 'Bearer ' + token);
        }
    }
}

The OidcSecurityValidation class defines the functions used to validate the tokens defined in the OpenID Connect specification for the Implicit Flow.

import { Injectable } from '@angular/core';

// from jsrasiign
declare var KJUR: any;
declare var KEYUTIL: any;
declare var hextob64u: any;

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

// id_token
//// id_token C1: The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) MUST exactly match the value of the iss (issuer) Claim.
//// id_token C2: The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer identified by the iss (issuer) Claim as an audience.The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, or if it contains additional audiences not trusted by the Client.
// id_token C3: If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
// id_token C4: If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id is the Claim Value.
//// id_token C5: The Client MUST validate the signature of the ID Token according to JWS [JWS] using the algorithm specified in the alg Header Parameter of the JOSE Header. The Client MUST use the keys provided by the Issuer.
//// id_token C6: The alg value SHOULD be RS256. Validation of tokens using other signing algorithms is described in the OpenID Connect Core 1.0 [OpenID.Core] specification.
//// id_token C7: The current time MUST be before the time represented by the exp Claim (possibly allowing for some small leeway to account for clock skew).
// id_token C8: The iat Claim can be used to reject tokens that were issued too far away from the current time, limiting the amount of time that nonces need to be stored to prevent attacks.The acceptable range is Client specific.
//// id_token C9: The value of the nonce Claim MUST be checked to verify that it is the same value as the one that was sent in the Authentication Request.The Client SHOULD check the nonce value for replay attacks.The precise method for detecting replay attacks is Client specific.
// id_token C10: If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.The meaning and processing of acr Claim Values is out of scope for this document.
// id_token C11: When a max_age request is made, the Client SHOULD check the auth_time Claim value and request re- authentication if it determines too much time has elapsed since the last End- User authentication.

//// Access Token Validation
//// access_token C1: Hash the octets of the ASCII representation of the access_token with the hash algorithm specified in JWA[JWA] for the alg Header Parameter of the ID Token's JOSE Header. For instance, if the alg is RS256, the hash algorithm used is SHA-256.
//// access_token C2: Take the left- most half of the hash and base64url- encode it.
//// access_token C3: The value of at_hash in the ID Token MUST match the value produced in the previous step if at_hash is present in the ID Token.

@Injectable()
export class OidcSecurityValidation {

    // id_token C7: The current time MUST be before the time represented by the exp Claim (possibly allowing for some small leeway to account for clock skew).
    public IsTokenExpired(token: string, offsetSeconds?: number): boolean {

        let decoded: any;
        decoded = this.GetPayloadFromToken(token, false);

        let tokenExpirationDate = this.getTokenExpirationDate(decoded);
        offsetSeconds = offsetSeconds || 0;

        if (tokenExpirationDate == null) {
            return false;
        }

        // Token expired?
        return !(tokenExpirationDate.valueOf() > (new Date().valueOf() + (offsetSeconds * 1000)));
    }

    // id_token C9: The value of the nonce Claim MUST be checked to verify that it is the same value as the one that was sent in the Authentication Request.The Client SHOULD check the nonce value for replay attacks.The precise method for detecting replay attacks is Client specific.
    public Validate_id_token_nonce(dataIdToken: any, local_nonce: any): boolean {
        if (dataIdToken.nonce !== local_nonce) {
            console.log('Validate_id_token_nonce failed');
            return false;
        }

        return true;
    }

    // id_token C1: The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery) MUST exactly match the value of the iss (issuer) Claim.
    public Validate_id_token_iss(dataIdToken: any, client_id: any): boolean {
        if (dataIdToken.iss !== client_id) {
            console.log('Validate_id_token_iss failed');
            return false;
        }

        return true;
    }

    // id_token C2: The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer identified by the iss (issuer) Claim as an audience.
    // The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, or if it contains additional audiences not trusted by the Client.
    public Validate_id_token_aud(dataIdToken: any, aud: any): boolean {
        if (dataIdToken.aud !== aud) {
            console.log('Validate_id_token_aud failed');
            return false;
        }

        return true;
    }

    public ValidateStateFromHashCallback(state: any, local_state: any): boolean {
        if (state !== local_state) {
            console.log('ValidateStateFromHashCallback failed');
            return false;
        }

        return true;
    }

    public GetPayloadFromToken(token: any, encode: boolean) {
        let data = {};
        if (typeof token !== 'undefined') {
            let encoded = token.split('.')[1];
            if (encode) {
                return encoded;
            }
            data = JSON.parse(this.urlBase64Decode(encoded));
        }

        return data;
    }

    public GetHeaderFromToken(token: any, encode: boolean) {
        let data = {};
        if (typeof token !== 'undefined') {
            let encoded = token.split('.')[0];
            if (encode) {
                return encoded;
            }
            data = JSON.parse(this.urlBase64Decode(encoded));
        }

        return data;
    }

    public GetSignatureFromToken(token: any, encode: boolean) {
        let data = {};
        if (typeof token !== 'undefined') {
            let encoded = token.split('.')[2];
            if (encode) {
                return encoded;
            }
            data = JSON.parse(this.urlBase64Decode(encoded));
        }

        return data;
    }

    // id_token C5: The Client MUST validate the signature of the ID Token according to JWS [JWS] using the algorithm specified in the alg Header Parameter of the JOSE Header. The Client MUST use the keys provided by the Issuer.
    // id_token C6: The alg value SHOULD be RS256. Validation of tokens using other signing algorithms is described in the OpenID Connect Core 1.0 [OpenID.Core] specification.
    public Validate_signature_id_token(id_token: any, jwtkeys: any): boolean {

        if (!jwtkeys || !jwtkeys.keys) {
            return false;
        }

        let header_data = this.GetHeaderFromToken(id_token, false);
        let kid = header_data.kid;
        let alg = header_data.alg;

        if ('RS256' != alg) {
            console.log('Only RS256 supported');
            return false;
        }

        let isValid = false;

        for (let key of jwtkeys.keys) {
            if (key.kid === kid) {
                let publickey = KEYUTIL.getKey(key);
                isValid = KJUR.jws.JWS.verify(id_token, publickey, ['RS256']);
                return isValid;
            }
        }

        return isValid;
    }

    // Access Token Validation
    // access_token C1: Hash the octets of the ASCII representation of the access_token with the hash algorithm specified in JWA[JWA] for the alg Header Parameter of the ID Token's JOSE Header. For instance, if the alg is RS256, the hash algorithm used is SHA-256.
    // access_token C2: Take the left- most half of the hash and base64url- encode it.
    // access_token C3: The value of at_hash in the ID Token MUST match the value produced in the previous step if at_hash is present in the ID Token.
    public Validate_id_token_at_hash(access_token: any, at_hash: any): boolean {

        let hash = KJUR.crypto.Util.hashString(access_token, 'sha256');
        let first128bits = hash.substr(0, hash.length / 2);
        let testdata = hextob64u(first128bits);

        if (testdata === at_hash) {
            return true; // isValid;
        }

        return false;
    }

    private getTokenExpirationDate(dataIdToken: any): Date {
        if (!dataIdToken.hasOwnProperty('exp')) {
            return null;
        }

        let date = new Date(0); // The 0 here is the key, which sets the date to the epoch
        date.setUTCSeconds(dataIdToken.exp);

        return date;
    }


    private urlBase64Decode(str: string) {
        let output = str.replace('-', '+').replace('_', '/');
        switch (output.length % 4) {
            case 0:
                break;
            case 2:
                output += '==';
                break;
            case 3:
                output += '=';
                break;
            default:
                throw 'Illegal base64url string!';
        }

        return window.atob(output);
    }
}

The jsrsasign is used to validate the token signature and is added to the html file as a link.

!doctype html>
<html>
<head>
    <base href="./">
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ASP.NET Core 1.0 Angular IdentityServer4 Client</title>
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />

	<script src="assets/jsrsasign.min.js"></script>
</head>
<body>
    <my-app>Loading...</my-app>
</body>
</html>

Once logged into the application, the access_token is added to the header of each request and sent to the resource server or the required APIs on the OpenIddict server.

 private setHeaders() {

        console.log('setHeaders started');

        this.headers = new Headers();
        this.headers.append('Content-Type', 'application/json');
        this.headers.append('Accept', 'application/json');
        this.headers.append('Cache-Control', 'no-cache');

        let token = this._securityService.GetToken();
        if (token !== '') {
            let tokenValue = 'Bearer ' + token;
            console.log('tokenValue:' + tokenValue);
            this.headers.append('Authorization', tokenValue);
        }
    }

ASP.NET Core Resource Server API

The resource server provides an API protected by security policies, dataEventRecordsUser and dataEventRecordsAdmin.

using AspNet5SQLite.Model;
using AspNet5SQLite.Repositories;

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace AspNet5SQLite.Controllers
{
    [Authorize]
    [Route("api/[controller]")]
    public class DataEventRecordsController : Controller
    {
        private readonly IDataEventRecordRepository _dataEventRecordRepository;

        public DataEventRecordsController(IDataEventRecordRepository dataEventRecordRepository)
        {
            _dataEventRecordRepository = dataEventRecordRepository;
        }

        [Authorize("dataEventRecordsUser")]
        [HttpGet]
        public IActionResult Get()
        {
            return Ok(_dataEventRecordRepository.GetAll());
        }

        [Authorize("dataEventRecordsAdmin")]
        [HttpGet("{id}")]
        public IActionResult Get(long id)
        {
            return Ok(_dataEventRecordRepository.Get(id));
        }

        [Authorize("dataEventRecordsAdmin")]
        [HttpPost]
        public void Post([FromBody]DataEventRecord value)
        {
            _dataEventRecordRepository.Post(value);
        }

        [Authorize("dataEventRecordsAdmin")]
        [HttpPut("{id}")]
        public void Put(long id, [FromBody]DataEventRecord value)
        {
            _dataEventRecordRepository.Put(id, value);
        }

        [Authorize("dataEventRecordsAdmin")]
        [HttpDelete("{id}")]
        public void Delete(long id)
        {
            _dataEventRecordRepository.Delete(id);
        }
    }
}

The policies are implemented in the Startup class and are implemented using the role claims dataEventRecords.user, dataEventRecords.admin and the scope dataEventRecords.

var guestPolicy = new AuthorizationPolicyBuilder()
	.RequireAuthenticatedUser()
	.RequireClaim("scope", "dataEventRecords")
	.Build();

services.AddAuthorization(options =>
{
	options.AddPolicy("dataEventRecordsAdmin", policyAdmin =>
	{
		policyAdmin.RequireClaim("role", "dataEventRecords.admin");
	});
	options.AddPolicy("dataEventRecordsUser", policyUser =>
	{
		policyUser.RequireClaim("role",  "dataEventRecords.user");
	});

});

Jwt Bearer Authentication is used to validate the API HTTP requests.

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear();

app.UseJwtBearerAuthentication(new JwtBearerOptions
{
	Authority = "https://localhost:44319/",
	Audience = "dataEventRecords",
	RequireHttpsMetadata = true,
	TokenValidationParameters = new TokenValidationParameters
	{
		NameClaimType = OpenIdConnectConstants.Claims.Subject,
		RoleClaimType = OpenIdConnectConstants.Claims.Role
	}
});

Running the application

When the application is started, all 3 applications are run, using the Visual Studio 2017 multiple project start option.

After the user clicks the login button, the user is redirected to the OpenIddict server to login.

After a successful login, the user is redirected back to the Angular application.

Links:

https://github.com/openiddict/openiddict-core

http://kevinchalet.com/2016/07/13/creating-your-own-openid-connect-server-with-asos-implementing-the-authorization-code-and-implicit-flows/

https://github.com/openiddict/openiddict-core/issues/49

https://github.com/openiddict/openiddict-samples

https://blogs.msdn.microsoft.com/webdev/2017/01/23/asp-net-core-authentication-with-identityserver4/

https://blogs.msdn.microsoft.com/webdev/2016/10/27/bearer-token-authentication-in-asp-net-core/

https://blogs.msdn.microsoft.com/webdev/2017/04/06/jwt-validation-and-authorization-in-asp-net-core/

https://jwt.io/

https://www.scottbrady91.com/OpenID-Connect/OpenID-Connect-Flows


Secure ASP.NET Core MVC with Angular using IdentityServer4 OpenID Connect Hybrid Flow

$
0
0

This article shows how an ASP.NET Core MVC application using Angular in the razor views can be secured using IdentityServer4 and the OpenID Connect Hybrid Flow. The user interface uses server side rendering for the MVC views and the Angular app is then implemented in the razor view. The required security features can be added to the application easily using ASP.NET Core, which makes it safe to use the OpenID Connect Hybrid flow, which once authenticated and authorised, saves the token in a secure cookie. This is not an SPA application, it is an ASP.NET Core MVC application with Angular in the razor view. If you are implementing an SPA application, you should use the OpenID Connect Implicit Flow.

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

IdentityServer4 configuration for OpenID Connect Hybrid Flow

IdentityServer4 is implemented using ASP.NET Core Identity with SQLite. The application implements the OpenID Connect Hybrid flow. The client is configured to allow the required scopes, for example the ‘openid’ scope must be added and also the RedirectUris property which implements the URL which is implemented on the client using the ASP.NET Core OpenID middleware.

using IdentityServer4;
using IdentityServer4.Models;
using System.Collections.Generic;

namespace QuickstartIdentityServer
{
    public class Config
    {
        public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new List<IdentityResource>
            {
                new IdentityResources.OpenId(),
                new IdentityResources.Profile(),
                new IdentityResources.Email(),
                new IdentityResource("thingsscope",new []{ "role", "admin", "user", "thingsapi" } )
            };
        }

        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                new ApiResource("thingsscope")
                {
                    ApiSecrets =
                    {
                        new Secret("thingsscopeSecret".Sha256())
                    },
                    Scopes =
                    {
                        new Scope
                        {
                            Name = "thingsscope",
                            DisplayName = "Scope for the thingsscope ApiResource"
                        }
                    },
                    UserClaims = { "role", "admin", "user", "thingsapi" }
                }
            };
        }

        // clients want to access resources (aka scopes)
        public static IEnumerable<Client> GetClients()
        {
            // client credentials client
            return new List<Client>
            {
                new Client
                {
                    ClientName = "angularmvcmixedclient",
                    ClientId = "angularmvcmixedclient",
                    ClientSecrets = {new Secret("thingsscopeSecret".Sha256()) },
                    AllowedGrantTypes = GrantTypes.Hybrid,
                    AllowOfflineAccess = true,
                    RedirectUris = { "https://localhost:44341/signin-oidc" },
                    PostLogoutRedirectUris = { "https://localhost:44341/signout-callback-oidc" },
                    AllowedCorsOrigins = new List<string>
                    {
                        "https://localhost:44341/"
                    },
                    AllowedScopes = new List<string>
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.OfflineAccess,
                        "thingsscope",
                        "role"

                    }
                }
            };
        }
    }
}

MVC Angular Client Configuration

The ASP.NET Core MVC application with Angular is implemented as shown in this post: Using Angular in an ASP.NET Core View with Webpack

The cookie authentication middleware is used to store the access token in a cookie, once authorised and authenticated. The OpenIdConnectAuthentication middleware is used to redirect the user to the STS server, if the user is not authenticated. The SaveTokens property is set, so that the token is persisted in the secure cookie.

app.UseCookieAuthentication(new CookieAuthenticationOptions
{
	AuthenticationScheme = "Cookies"
});

app.UseOpenIdConnectAuthentication(new OpenIdConnectOptions
{
	AuthenticationScheme = "oidc",
	SignInScheme = "Cookies",

	Authority = "https://localhost:44348",
	RequireHttpsMetadata = true,

	ClientId = "angularmvcmixedclient",
	ClientSecret = "thingsscopeSecret",

	ResponseType = "code id_token",
	Scope = { "openid", "profile", "thingsscope" },

	GetClaimsFromUserInfoEndpoint = true,
	SaveTokens = true
});

The Authorize attribute is used to secure the MVC controller or API.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;

namespace AspNetCoreMvcAngular.Controllers
{
    [Authorize]
    public class HomeController : Microsoft.AspNetCore.Mvc.Controller
    {
        public IActionResult Index()
        {
            return View();
        }

        public IActionResult Error()
        {
            return View();
        }
    }
}

CSP: Content Security Policy in the HTTP Headers

Content Security Policy helps you reduce XSS risks. The really brilliant NWebSec middleware can be used to implement this as required. Thanks to André N. Klingsheim for this excellent library. The middleware adds the headers to the HTTP responses.

https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

In this configuration, mixed content is not allowed and the scripts can only be used from a local source. Unsafe inline styles are allowed.

app.UseCsp(opts => opts
	.BlockAllMixedContent()
	.ScriptSources(s => s.Self())
	.StyleSources(s => s.UnsafeInline())
);

Set the Referrer-Policy in the HTTP Header

This allows us to restrict the amount of information being passed on to other sites when referring to other sites.

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy

Scott Helme write a really good post on this:
https://scotthelme.co.uk/a-new-security-header-referrer-policy/

Again NWebSec middleware is used to implement this.

app.UseReferrerPolicy(opts => opts.NoReferrer());

Secure Cookies

Only secure cookies should be used to store the session information. The anti-forgery cookie is an exception to this.

You can check this in the Chrome browser:

XFO: X-Frame-Options

The X-Frame-Options Headers can be used to prevent an IFrame from being used from within the UI. This helps protect against click jacking.

https://developer.mozilla.org/de/docs/Web/HTTP/Headers/X-Frame-Options

app.UseXfo(xfo => xfo.Deny());

Configuring HSTS: Http Strict Transport Security

The HTTP Header tells the browser to force HTTPS for a length of time.

app.UseHsts(hsts => hsts.MaxAge(365).IncludeSubdomains());

TOFU (Trust on first use) or first time loading.

Once you have a proper cert and a fixed URL, you can configure that the browser to preload HSTS settings for your website.

https://hstspreload.org/

https://www.owasp.org/index.php/HTTP_Strict_Transport_Security_Cheat_Sheet

X-Xss-Protection NWebSec

Adds a middleware to the ASP.NET Core pipeline that sets the X-Xss-Protection (Docs from NWebSec)

 app.UseXXssProtection(options => options.EnabledWithBlockMode());

CORS

Only the allowed CORS should be enabled when implementing this. Disabled this as much as possible.

Validating the security Headers

Once you start the application, you can check that all the security headers are added as required:

Here’s the Configure method with all the NWebsec app settings as well as the authentication middleware for the client MVC application.

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

	//Registered before static files to always set header
	app.UseHsts(hsts => hsts.MaxAge(365).IncludeSubdomains());
	app.UseXContentTypeOptions();
	app.UseReferrerPolicy(opts => opts.NoReferrer());

	app.UseCsp(opts => opts
		.BlockAllMixedContent()
		.ScriptSources(s => s.Self())
		.StyleSources(s => s.UnsafeInline())
	);

	JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

	if (env.IsDevelopment())
	{
		app.UseDeveloperExceptionPage();
	}
	else
	{
		app.UseExceptionHandler("/Home/Error");
	}

	app.UseCookieAuthentication(new CookieAuthenticationOptions
	{
		AuthenticationScheme = "Cookies"
	});

	app.UseOpenIdConnectAuthentication(new OpenIdConnectOptions
	{
		AuthenticationScheme = "oidc",
		SignInScheme = "Cookies",

		Authority = "https://localhost:44348",
		RequireHttpsMetadata = true,

		ClientId = "angularmvcmixedclient",
		ClientSecret = "thingsscopeSecret",

		ResponseType = "code id_token",
		Scope = { "openid", "profile", "thingsscope" },

		GetClaimsFromUserInfoEndpoint = true,
		SaveTokens = true
	});

	var angularRoutes = new[] {
		 "/default",
		 "/about"
	 };

	app.Use(async (context, next) =>
	{
		if (context.Request.Path.HasValue && null != angularRoutes.FirstOrDefault(
			(ar) => context.Request.Path.Value.StartsWith(ar, StringComparison.OrdinalIgnoreCase)))
		{
			context.Request.Path = new PathString("/");
		}

		await next();
	});

	app.UseDefaultFiles();
	app.UseStaticFiles();

	if (env.IsDevelopment())
	{
		app.UseDeveloperExceptionPage();
	}
	else
	{
		app.UseExceptionHandler("/Home/Error");
	}

	app.UseStaticFiles();

	//Registered after static files, to set headers for dynamic content.
	app.UseXfo(xfo => xfo.Deny());
	app.UseRedirectValidation(); //Register this earlier if there's middleware that might redirect.
	app.UseXXssProtection(options => options.EnabledWithBlockMode());

	app.UseMvc(routes =>
	{
		routes.MapRoute(
			name: "default",
			template: "{controller=Home}/{action=Index}/{id?}");
	});
}

Links:

https://www.scottbrady91.com/OpenID-Connect/OpenID-Connect-Flows

https://docs.nwebsec.com/en/latest/index.html

https://www.nwebsec.com/

https://github.com/NWebsec/NWebsec

https://content-security-policy.com/

https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy

https://scotthelme.co.uk/a-new-security-header-referrer-policy/

https://developer.mozilla.org/de/docs/Web/HTTP/Headers/X-Frame-Options

https://www.owasp.org/index.php/HTTP_Strict_Transport_Security_Cheat_Sheet

https://gun.io/blog/tofu-web-security/

https://en.wikipedia.org/wiki/Trust_on_first_use

http://www.dotnetnoob.com/2013/07/ramping-up-aspnet-session-security.html

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

https://www.ssllabs.com/


Implementing Two-factor authentication with IdentityServer4 and Twilio

$
0
0

This article shows how to implement two factor authentication using Twilio and IdentityServer4 using Identity. On the Microsoft’s Two-factor authentication with SMS documentation, Twilio and ASPSMS are promoted, but any SMS provider can be used.

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

Setting up Twilio

Create an account and login to https://www.twilio.com/

Now create a new phone number and use the Twilio documentation to set up your account to send SMS messages. You need the Account SID, Auth Token and the Phone number which are required in the application.

The phone number can be configured here:
https://www.twilio.com/console/phone-numbers/incoming

Adding the SMS support to IdentityServer4

Add the Twilio Nuget package to the IdentityServer4 project.

<PackageReference Include="Twilio" Version="5.5.2" />

The Twilio settings should be a secret, so these configuration properties are added to the app.settings.json file with dummy values. These can then be used for the deployments.

"TwilioSettings": {
  "Sid": "dummy",
  "Token": "dummy",
  "From": "dummy"
}

A configuration class is then created so that the settings can be added to the DI.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace IdentityServerWithAspNetIdentity.Services
{
    public class TwilioSettings
    {
        public string Sid { get; set; }
        public string Token { get; set; }
        public string From { get; set; }
    }
}

Now the user secrets configuration needs to be setup on your dev PC. Right click the IdentityServer4 project and add the user secrets with the proper values which you can get from your Twilio account.

{
  "MicrosoftClientId": "your_secret..",
  "MircosoftClientSecret":  "your_secret..",
  "TwilioSettings": {
    "Sid": "your_secret..",
    "Token": "your_secret..",
    "From": "your_secret..",
  }
}

The configuration class is then added to the DI in the Startup class ConfigureServices method.

var twilioSettings = Configuration.GetSection("TwilioSettings");
services.Configure<TwilioSettings>(twilioSettings);

Now the TwilioSettings can be added to the AuthMessageSender class which is defined in the MessageServices file, if using the IdentityServer4 samples.

private readonly TwilioSettings _twilioSettings;

public AuthMessageSender(ILogger<AuthMessageSender> logger, IOptions<TwilioSettings> twilioSettings)
{
	_logger = logger;
	_twilioSettings = twilioSettings.Value;
}

This class is also added to the DI in the startup class.

services.AddTransient<ISmsSender, AuthMessageSender>();

Now the TwilioClient can be setup to send the SMS in the SendSmsAsync method.

public Task SendSmsAsync(string number, string message)
{
	// Plug in your SMS service here to send a text message.
	_logger.LogInformation("SMS: {number}, Message: {message}", number, message);
	var sid = _twilioSettings.Sid;
	var token = _twilioSettings.Token;
	var from = _twilioSettings.From;
	TwilioClient.Init(sid, token);
	MessageResource.CreateAsync(new PhoneNumber(number),
		from: new PhoneNumber(from),
		body: message);
	return Task.FromResult(0);
}

The SendCode.cshtml view can now be changed to send the SMS with the style, layout you prefer.

<form asp-controller="Account" asp-action="SendCode" asp-route-returnurl="@Model.ReturnUrl" method="post" class="form-horizontal">
    <input asp-for="RememberMe" type="hidden" />
    <input asp-for="SelectedProvider" type="hidden" value="Phone" />
    <input asp-for="ReturnUrl" type="hidden" value="@Model.ReturnUrl" />
    <div class="row">
        <div class="col-md-8">
            <button type="submit" class="btn btn-default">Send a verification code using SMS</button>
        </div>
    </div>
</form>

In the VerifyCode.cshtml, the ReturnUrl from the model property must be added to the form as a hidden item, otherwise your client will not be redirected back to the calling app.

<form asp-controller="Account" asp-action="VerifyCode" asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" class="form-horizontal">
    <div asp-validation-summary="All" class="text-danger"></div>
    <input asp-for="Provider" type="hidden" />
    <input asp-for="RememberMe" type="hidden" />
    <input asp-for="ReturnUrl" type="hidden" value="@Model.ReturnUrl" />
    <h4>@ViewData["Status"]</h4>
    <hr />
    <div class="form-group">
        <label asp-for="Code" class="col-md-2 control-label"></label>
        <div class="col-md-10">
            <input asp-for="Code" class="form-control" />
            <span asp-validation-for="Code" class="text-danger"></span>
        </div>
    </div>
    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <div class="checkbox">
                <input asp-for="RememberBrowser" />
                <label asp-for="RememberBrowser"></label>
            </div>
        </div>
    </div>
    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <button type="submit" class="btn btn-default">Submit</button>
        </div>
    </div>
</form>

Testing the application

If using an existing client, you need to update the Identity in the database. Each user requires that the TwoFactoredEnabled field is set to true and a mobile phone needs to be set in the phone number field, (Or any phone which can accept SMS)

Now login with this user:

The user is redirected to the send SMS page. Click the send SMS button. This sends a SMS to the phone number defined in the Identity for the user trying to authenticate.

You should recieve an SMS. Enter the code in the verify view. If no SMS was sent, check your Twilio account logs.

After a successful code validation, the user is redirected back to the consent page for the client application. If not redirected, the return url was not set in the model.

Links:

https://docs.microsoft.com/en-us/aspnet/core/security/authentication/2fa

https://www.twilio.com/

http://docs.identityserver.io/en/release/

https://www.twilio.com/use-cases/two-factor-authentication


Shared Localization in ASP.NET Core MVC

$
0
0

This article shows how ASP.NET Core MVC razor views and view models can use localized strings from a shared resource. This saves you creating many different files and duplicating translations for the different views and models. This makes it much easier to manage your translations, and also reduces the effort required to export, import the translations.

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

A default ASP.NET Core MVC application with Individual user accounts authentication is used to create the application.

A LocService class is used, which takes the IStringLocalizerFactory interface as a dependency using construction injection. The factory is then used, to create an IStringLocalizer instance using the type from the SharedResource class.

using Microsoft.Extensions.Localization;
using System.Reflection;

namespace AspNetCoreMvcSharedLocalization.Resources
{
    public class LocService
    {
        private readonly IStringLocalizer _localizer;

        public LocService(IStringLocalizerFactory factory)
        {
            var type = typeof(SharedResource);
            var assemblyName = new AssemblyName(type.GetTypeInfo().Assembly.FullName);
            _localizer = factory.Create("SharedResource", assemblyName.Name);
        }

        public LocalizedString GetLocalizedHtmlString(string key)
        {
            return _localizer[key];
        }
    }
}

The dummy SharedResource is required to create the IStringLocalizer instance using the type from the class.

namespace AspNetCoreMvcSharedLocalization.Resources
{
    /// <summary>
    /// Dummy class to group shared resources
    /// </summary>
    public class SharedResource
    {
    }
}

The resx resource files are added with the name, which matches the IStringLocalizer definition. This example uses SharedResource.de-CH.resx and the other localizations as required. One of the biggest problems with ASP.NET Core localization, if the name of the resx does not match the name/type of the class, view using the resource, it will not be found and so not localized. It will then use the default string, which is the name of the resource. This is also a problem as we programme in english, but the default language is german or french. Some programmers don’t understand german. It is bad to have german strings throughout the english code base.

The localization setup is then added to the startup class. This application uses de-CH, it-CH, fr-CH and en-US. The QueryStringRequestCultureProvider is used to set the request localization.

public void ConfigureServices(IServiceCollection services)
{
	...

	services.AddSingleton<LocService>();
	services.AddLocalization(options => options.ResourcesPath = "Resources");

	services.AddMvc()
		.AddViewLocalization()
		.AddDataAnnotationsLocalization(options =>
		{
			options.DataAnnotationLocalizerProvider = (type, factory) =>
			{
				var assemblyName = new AssemblyName(typeof(SharedResource).GetTypeInfo().Assembly.FullName);
				return factory.Create("SharedResource", assemblyName.Name);
			};
		});

	services.Configure<RequestLocalizationOptions>(
		options =>
		{
			var supportedCultures = new List<CultureInfo>
				{
					new CultureInfo("en-US"),
					new CultureInfo("de-CH"),
					new CultureInfo("fr-CH"),
					new CultureInfo("it-CH")
				};

			options.DefaultRequestCulture = new RequestCulture(culture: "de-CH", uiCulture: "de-CH");
			options.SupportedCultures = supportedCultures;
			options.SupportedUICultures = supportedCultures;

			options.RequestCultureProviders.Insert(0, new QueryStringRequestCultureProvider());
		});

	services.AddMvc();
}

The localization is then added as a middleware.

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
	...

	var locOptions = app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>();
	app.UseRequestLocalization(locOptions.Value);

	app.UseStaticFiles();

	app.UseAuthentication();

	app.UseMvc(routes =>
	{
		routes.MapRoute(
			name: "default",
			template: "{controller=Home}/{action=Index}/{id?}");
	});
}

Razor Views

The razor views use the shared resource localization by injecting the LocService. This was registered in the IoC in the startup class. The localized strings can then be used as required.

@model RegisterViewModel
@using AspNetCoreMvcSharedLocalization.Resources

@inject LocService SharedLocalizer

@{
    ViewData["Title"] = @SharedLocalizer.GetLocalizedHtmlString("register");
}
<h2>@ViewData["Title"]</h2>
<form asp-controller="Account" asp-action="Register" asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" class="form-horizontal">
    <h4>@SharedLocalizer.GetLocalizedHtmlString("createNewAccount")</h4>
    <hr />
    <div asp-validation-summary="All" class="text-danger"></div>
    <div class="form-group">
        <label class="col-md-2 control-label">@SharedLocalizer.GetLocalizedHtmlString("email")</label>
        <div class="col-md-10">
            <input asp-for="Email" class="form-control" />
            <span asp-validation-for="Email" class="text-danger"></span>
        </div>
    </div>
    <div class="form-group">
        <label class="col-md-2 control-label">@SharedLocalizer.GetLocalizedHtmlString("password")</label>
        <div class="col-md-10">
            <input asp-for="Password" class="form-control" />
            <span asp-validation-for="Password" class="text-danger"></span>
        </div>
    </div>
    <div class="form-group">
        <label class="col-md-2 control-label">@SharedLocalizer.GetLocalizedHtmlString("confirmPassword")</label>
        <div class="col-md-10">
            <input asp-for="ConfirmPassword" class="form-control" />
            <span asp-validation-for="ConfirmPassword" class="text-danger"></span>
        </div>
    </div>
    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <button type="submit" class="btn btn-default">@SharedLocalizer.GetLocalizedHtmlString("register")</button>
        </div>
    </div>
</form>
@section Scripts {
    @{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}

View Model

The models validation messages are also localized. The ErrorMessage of the attributes are used to get the localized strings.

using System.ComponentModel.DataAnnotations;

namespace AspNetCoreMvcSharedLocalization.Models.AccountViewModels
{
    public class RegisterViewModel
    {
        [Required(ErrorMessage = "emailRequired")]
        [EmailAddress]
        [Display(Name = "Email")]
        public string Email { get; set; }

        [Required(ErrorMessage = "passwordRequired")]
        [StringLength(100, ErrorMessage = "passwordStringLength", MinimumLength = 8)]
        [DataType(DataType.Password)]
        [Display(Name = "Password")]
        public string Password { get; set; }

        [DataType(DataType.Password)]
        [Display(Name = "Confirm password")]
        [Compare("Password", ErrorMessage = "confirmPasswordNotMatching")]
        public string ConfirmPassword { get; set; }
    }
}

The AddDataAnnotationsLocalization DataAnnotationLocalizerProvider is setup to always use the SharedResource resx files for all of the models. This prevents duplicating the localizations for each of the different models.

.AddDataAnnotationsLocalization(options =>
{
	options.DataAnnotationLocalizerProvider = (type, factory) =>
	{
		var assemblyName = new AssemblyName(typeof(SharedResource).GetTypeInfo().Assembly.FullName);
		return factory.Create("SharedResource", assemblyName.Name);
	};
});

The localization can be tested using the following requests:

https://localhost:44371/Account/Register?culure=de-CH&ui-culture=de-CH
https://localhost:44371/Account/Register?culure=it-CH&ui-culture=it-CH
https://localhost:44371/Account/Register?culure=fr-CH&ui-culture=fr-CH
https://localhost:44371/Account/Register?culure=en-US&ui-culture=en-US

The QueryStringRequestCultureProvider reads the culture and the ui-culture from the parameters. You could also use headers or cookies to send the required localization in the request, but this needs to be configured in the Startup class.

Links:

https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization

IdentityServer4 Localization with the OIDC Implicit Flow

$
0
0

This post shows how to implement localization in IdentityServer4 when using the Implicit Flow with an Angular client.

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

The problem

When the oidc implicit client calls the endpoint /connect/authorize to authenticate and authorize the client and the identity, the user is redirected to the AccountController login method using the IdentityServer4 package. If the culture and the ui-culture is set using the query string or using the default localization filter, it gets ignored in the host. By using a localization cookie, which is set from the client SPA application, it is possible to use this culture in IdentityServer4 and it’s host.

Part 2 IdentityServer4 Localization using ui_locales and the query string

IdentityServer 4 Localization

The ASP.NET Core localization is configured in the startup method of the IdentityServer4 host. The localization service, the resource paths and the RequestCultureProviders are configured here. A custom LocalizationCookieProvider is added to handle the localization cookie. The MVC middleware is then configured to use the localization.

public void ConfigureServices(IServiceCollection services)
{
	...

	services.AddSingleton<LocService>();
	services.AddLocalization(options => options.ResourcesPath = "Resources");

	services.AddAuthentication();

	services.AddIdentity<ApplicationUser, IdentityRole>()
	.AddEntityFrameworkStores<ApplicationDbContext>();

	services.Configure<RequestLocalizationOptions>(
		options =>
		{
			var supportedCultures = new List<CultureInfo>
				{
					new CultureInfo("en-US"),
					new CultureInfo("de-CH"),
					new CultureInfo("fr-CH"),
					new CultureInfo("it-CH")
				};

			options.DefaultRequestCulture = new RequestCulture(culture: "de-CH", uiCulture: "de-CH");
			options.SupportedCultures = supportedCultures;
			options.SupportedUICultures = supportedCultures;

			options.RequestCultureProviders.Clear();
			var provider = new LocalizationCookieProvider
			{
				CookieName = "defaultLocale"
			};
			options.RequestCultureProviders.Insert(0, provider);
		});

	services.AddMvc()
	 .AddViewLocalization()
	 .AddDataAnnotationsLocalization(options =>
	 {
		 options.DataAnnotationLocalizerProvider = (type, factory) =>
		 {
			 var assemblyName = new AssemblyName(typeof(SharedResource).GetTypeInfo().Assembly.FullName);
			 return factory.Create("SharedResource", assemblyName.Name);
		 };
	 });

	...

	services.AddIdentityServer()
		.AddSigningCredential(cert)
		.AddInMemoryIdentityResources(Config.GetIdentityResources())
		.AddInMemoryApiResources(Config.GetApiResources())
		.AddInMemoryClients(Config.GetClients())
		.AddAspNetIdentity<ApplicationUser>()
		.AddProfileService<IdentityWithAdditionalClaimsProfileService>();
}

The localization is added to the pipe in the Configure method.

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
	...

	var locOptions = app.ApplicationServices.GetService<IOptions<RequestLocalizationOptions>>();
	app.UseRequestLocalization(locOptions.Value);

	app.UseStaticFiles();

	app.UseIdentityServer();

	app.UseMvc(routes =>
	{
		routes.MapRoute(
			name: "default",
			template: "{controller=Home}/{action=Index}/{id?}");
	});
}

The LocalizationCookieProvider class implements the RequestCultureProvider to handle the localization sent from the Angular client as a cookie. The class uses the defaultLocale cookie to set the culture. This was configured in the startup class previously.

using Microsoft.AspNetCore.Localization;
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

namespace IdentityServerWithIdentitySQLite
{
    public class LocalizationCookieProvider : RequestCultureProvider
    {
        public static readonly string DefaultCookieName = ".AspNetCore.Culture";

        public string CookieName { get; set; } = DefaultCookieName;

        /// <inheritdoc />
        public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext)
        {
            if (httpContext == null)
            {
                throw new ArgumentNullException(nameof(httpContext));
            }

            var cookie = httpContext.Request.Cookies[CookieName];

            if (string.IsNullOrEmpty(cookie))
            {
                return NullProviderCultureResult;
            }

            var providerResultCulture = ParseCookieValue(cookie);

            return Task.FromResult(providerResultCulture);
        }

        public static ProviderCultureResult ParseCookieValue(string value)
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                return null;
            }

            var cultureName = value;
            var uiCultureName = value;

            if (cultureName == null && uiCultureName == null)
            {
                // No values specified for either so no match
                return null;
            }

            if (cultureName != null && uiCultureName == null)
            {
                uiCultureName = cultureName;
            }

            if (cultureName == null && uiCultureName != null)
            {
                cultureName = uiCultureName;
            }

            return new ProviderCultureResult(cultureName, uiCultureName);
        }
    }
}

The Account login view uses the localization to translate the different texts into one of the supported cultures.

@using System.Globalization
@using IdentityServerWithAspNetIdentity.Resources
@model IdentityServer4.Quickstart.UI.Models.LoginViewModel
@inject SignInManager<ApplicationUser> SignInManager

@inject LocService SharedLocalizer

@{
    ViewData["Title"] = @SharedLocalizer.GetLocalizedHtmlString("login");
}

<h2>@ViewData["Title"]</h2>
<div class="row">
    <div class="col-md-8">
        <section>
            <form asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model.ReturnUrl" method="post" class="form-horizontal">
                <h4>@CultureInfo.CurrentCulture</h4>
                <hr />
                <div asp-validation-summary="All" class="text-danger"></div>
                <div class="form-group">
                    <label class="col-md-4 control-label">@SharedLocalizer.GetLocalizedHtmlString("email")</label>
                    <div class="col-md-8">
                        <input asp-for="Email" class="form-control" />
                        <span asp-validation-for="Email" class="text-danger"></span>
                    </div>
                </div>
                <div class="form-group">
                    <label class="col-md-4 control-label">@SharedLocalizer.GetLocalizedHtmlString("password")</label>
                    <div class="col-md-8">
                        <input asp-for="Password" class="form-control" type="password" />
                        <span asp-validation-for="Password" class="text-danger"></span>
                    </div>
                </div>
                <div class="form-group">
                    <label class="col-md-4 control-label">@SharedLocalizer.GetLocalizedHtmlString("rememberMe")</label>
                    <div class="checkbox col-md-8">
                        <input asp-for="RememberLogin" />
                    </div>
                </div>
                <div class="form-group">
                    <div class="col-md-offset-4 col-md-8">
                        <button type="submit" class="btn btn-default">@SharedLocalizer.GetLocalizedHtmlString("login")</button>
                    </div>
                </div>
                <p>
                    <a asp-action="Register" asp-route-returnurl="@Model.ReturnUrl">@SharedLocalizer.GetLocalizedHtmlString("registerAsNewUser")</a>
                </p>
                <p>
                    <a asp-action="ForgotPassword">@SharedLocalizer.GetLocalizedHtmlString("forgotYourPassword")</a>
                </p>
            </form>
        </section>
    </div>
</div>

@section Scripts {
    @{ await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
}

The LocService uses the IStringLocalizerFactory interface to configure a shared resource for the resources.

using Microsoft.Extensions.Localization;
using System.Reflection;

namespace IdentityServerWithAspNetIdentity.Resources
{
    public class LocService
    {
        private readonly IStringLocalizer _localizer;

        public LocService(IStringLocalizerFactory factory)
        {
            var type = typeof(SharedResource);
            var assemblyName = new AssemblyName(type.GetTypeInfo().Assembly.FullName);
            _localizer = factory.Create("SharedResource", assemblyName.Name);
        }

        public LocalizedString GetLocalizedHtmlString(string key)
        {
            return _localizer[key];
        }
    }
}

Client Localization

The Angular SPA client uses the angular-l10n the localize the application.

 "dependencies": {
    "angular-l10n": "^4.0.0",

the angular-l10n is configured in the app module and is configured to save the current culture in a cookie called defaultLocale. This cookie matches what was configured on the server.

...

import { L10nConfig, L10nLoader, TranslationModule, StorageStrategy, ProviderType } from 'angular-l10n';

const l10nConfig: L10nConfig = {
    locale: {
        languages: [
            { code: 'en', dir: 'ltr' },
            { code: 'it', dir: 'ltr' },
            { code: 'fr', dir: 'ltr' },
            { code: 'de', dir: 'ltr' }
        ],
        language: 'en',
        storage: StorageStrategy.Cookie
    },
    translation: {
        providers: [
            { type: ProviderType.Static, prefix: './i18n/locale-' }
        ],
        caching: true,
        missingValue: 'No key'
    }
};

@NgModule({
    imports: [
        BrowserModule,
        FormsModule,
        routing,
        HttpClientModule,
        TranslationModule.forRoot(l10nConfig),
		DataEventRecordsModule,
        AuthModule.forRoot(),
    ],
    declarations: [
        AppComponent,
        ForbiddenComponent,
        HomeComponent,
        UnauthorizedComponent,
        SecureFilesComponent
    ],
    providers: [
        OidcSecurityService,
        SecureFileService,
        Configuration
    ],
    bootstrap:    [AppComponent],
})

export class AppModule {

    clientConfiguration: any;

    constructor(
        public oidcSecurityService: OidcSecurityService,
        private http: HttpClient,
        configuration: Configuration,
        public l10nLoader: L10nLoader
    ) {
        this.l10nLoader.load();

        console.log('APP STARTING');
        this.configClient().subscribe((config: any) => {
            this.clientConfiguration = config;

            let openIDImplicitFlowConfiguration = new OpenIDImplicitFlowConfiguration();
            openIDImplicitFlowConfiguration.stsServer = this.clientConfiguration.stsServer;
            openIDImplicitFlowConfiguration.redirect_url = this.clientConfiguration.redirect_url;
            // The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer identified by the iss (issuer) Claim as an audience.
            // The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience, or if it contains additional audiences not trusted by the Client.
            openIDImplicitFlowConfiguration.client_id = this.clientConfiguration.client_id;
            openIDImplicitFlowConfiguration.response_type = this.clientConfiguration.response_type;
            openIDImplicitFlowConfiguration.scope = this.clientConfiguration.scope;
            openIDImplicitFlowConfiguration.post_logout_redirect_uri = this.clientConfiguration.post_logout_redirect_uri;
            openIDImplicitFlowConfiguration.start_checksession = this.clientConfiguration.start_checksession;
            openIDImplicitFlowConfiguration.silent_renew = this.clientConfiguration.silent_renew;
            openIDImplicitFlowConfiguration.post_login_route = this.clientConfiguration.startup_route;
            // HTTP 403
            openIDImplicitFlowConfiguration.forbidden_route = this.clientConfiguration.forbidden_route;
            // HTTP 401
            openIDImplicitFlowConfiguration.unauthorized_route = this.clientConfiguration.unauthorized_route;
            openIDImplicitFlowConfiguration.log_console_warning_active = this.clientConfiguration.log_console_warning_active;
            openIDImplicitFlowConfiguration.log_console_debug_active = this.clientConfiguration.log_console_debug_active;
            // id_token C8: The iat Claim can be used to reject tokens that were issued too far away from the current time,
            // limiting the amount of time that nonces need to be stored to prevent attacks.The acceptable range is Client specific.
            openIDImplicitFlowConfiguration.max_id_token_iat_offset_allowed_in_seconds = this.clientConfiguration.max_id_token_iat_offset_allowed_in_seconds;

            configuration.FileServer = this.clientConfiguration.apiFileServer;
            configuration.Server = this.clientConfiguration.apiServer;

            this.oidcSecurityService.setupModule(openIDImplicitFlowConfiguration);

            // if you need custom parameters
            // this.oidcSecurityService.setCustomRequestParameters({ 'culture': 'fr-CH', 'ui-culture': 'fr-CH', 'ui_locales': 'fr-CH' });
        });
    }

    configClient() {

        console.log('window.location', window.location);
        console.log('window.location.href', window.location.href);
        console.log('window.location.origin', window.location.origin);
        console.log(`${window.location.origin}/api/ClientAppSettings`);

        return this.http.get(`${window.location.origin}/api/ClientAppSettings`);
    }
}

When the applications are started, the user can select a culture and login.

And the login view is localized correctly in de-CH

Or in french, if the culture is fr-CH

Links:

https://damienbod.com/2017/11/11/identityserver4-localization-using-ui_locales-and-the-query-string/

https://damienbod.com/2017/11/01/shared-localization-in-asp-net-core-mvc/

https://github.com/IdentityServer/IdentityServer4

https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization

https://github.com/robisim74/angular-l10n


Creating specific themes for OIDC clients using razor views with IdentityServer4

$
0
0

This post shows how to use specific themes in an ASPNET Core STS application using IdentityServer4. For each OpenId Connect (OIDC) client, a separate theme is used. The theme is implemented using Razor, based on the examples, code from Ben Foster. Thanks for these. The themes can then be customized as required.

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

Setup

The applications are setup using 2 OIDC Implicit Flow clients which get the tokens and login using a single IdentityServer4 application. The client id is sent which each authorize request. The client id is used to select, switch the theme.

An instance of the ClientSelector class is used per request to set, save the selected client id. The class is registered as a scoped instance.

namespace IdentityServerWithIdentitySQLite
{
    public class ClientSelector
    {
        public string SelectedClient = "";
    }
}

The ClientIdFilter Action Filter is used to read the client id from the authorize request and saves this to the ClientSelector instance of the request. The client id is read from the requesturl parameter.

using System;
using Microsoft.Extensions.Primitives;
using Microsoft.AspNetCore.WebUtilities;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Filters;

namespace IdentityServerWithIdentitySQLite
{
    public class ClientIdFilter : IActionFilter
    {
        public ClientIdFilter(ClientSelector clientSelector)
        {
            _clientSelector = clientSelector;
        }

        public string Client_id = "none";
        private readonly ClientSelector _clientSelector;

        public void OnActionExecuted(ActionExecutedContext context)
        {
            var query = context.HttpContext.Request.Query;
            var exists = query.TryGetValue("client_id", out StringValues culture);

            if (!exists)
            {
                exists = query.TryGetValue("returnUrl", out StringValues requesturl);

                if (exists)
                {
                    var request = requesturl.ToArray()[0];
                    Uri uri = new Uri("http://faketopreventexception" + request);
                    var query1 = QueryHelpers.ParseQuery(uri.Query);
                    var client_id = query1.FirstOrDefault(t => t.Key == "client_id").Value;

                    _clientSelector.SelectedClient = client_id.ToString();
                }
            }
        }

        public void OnActionExecuting(ActionExecutingContext context)
        {
            
        }
    }
}

Now that we have a ClientSelector instance which can be injected into the different views as required, we also want to use different razor templates for each theme.

The IViewLocationExpander interface is implemented and sets the locations for the different themes. For a request, the client_id is read from the authorize request. For a logout, the client_id is not available in the URL. The selectedClient is set in the logout action method, and this can be read then when rendering the views.

using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Primitives;
using System;
using System.Collections.Generic;
using System.Linq;

public class ClientViewLocationExpander : IViewLocationExpander
{
    private const string THEME_KEY = "theme";

    public void PopulateValues(ViewLocationExpanderContext context)
    {
        var query = context.ActionContext.HttpContext.Request.Query;
        var exists = query.TryGetValue("client_id", out StringValues culture);

        if (!exists)
        {
            exists = query.TryGetValue("returnUrl", out StringValues requesturl);

            if (exists)
            {
                var request = requesturl.ToArray()[0];
                Uri uri = new Uri("http://faketopreventexception" + request);
                var query1 = QueryHelpers.ParseQuery(uri.Query);
                var client_id = query1.FirstOrDefault(t => t.Key == "client_id").Value;

                context.Values[THEME_KEY] = client_id.ToString();
            }
        }
    }

    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
    {
        // add the themes to the view location if one of the theme layouts are required. 
        if (context.ViewName.Contains("_Layout") 
            && context.ActionContext.HttpContext.Request.Path.ToString().Contains("logout"))
        {
            string themeValue = context.ViewName.Replace("_Layout", "");
            context.Values[THEME_KEY] = themeValue;
        }

        string theme = null;
        if (context.Values.TryGetValue(THEME_KEY, out theme))
        {
            viewLocations = new[] {
                $"/Themes/{theme}/{{1}}/{{0}}.cshtml",
                $"/Themes/{theme}/Shared/{{0}}.cshtml",
            }
            .Concat(viewLocations);
        }

        return viewLocations;
    }
}

The logout method in the account controller sets the theme and opens the correct themed view.

public async Task<IActionResult> Logout(LogoutViewModel model)
{
	...
	
	// get context information (client name, post logout redirect URI and iframe for federated signout)
	var logout = await _interaction.GetLogoutContextAsync(model.LogoutId);

	var vm = new LoggedOutViewModel
	{
		PostLogoutRedirectUri = logout?.PostLogoutRedirectUri,
		ClientName = logout?.ClientId,
		SignOutIframeUrl = logout?.SignOutIFrameUrl
	};
	_clientSelector.SelectedClient = logout?.ClientId;
	await _persistedGrantService.RemoveAllGrantsAsync(subjectId, logout?.ClientId);
	return View($"~/Themes/{logout?.ClientId}/Account/LoggedOut.cshtml", vm);
}

In the startup class, the classes are registered with the IoC, and the ClientViewLocationExpander is added.

public void ConfigureServices(IServiceCollection services)
{
	...
	
	services.AddScoped<ClientIdFilter>();
	services.AddScoped<ClientSelector>();
	services.AddAuthentication();

	services.Configure<RazorViewEngineOptions>(options =>
	{
		options.ViewLocationExpanders.Add(new ClientViewLocationExpander());
	});

In the Views folder, all the default views are implemented like before. The _ViewStart.cshtml was changed to select the correct layout using the injected service _clientSelector.

@using System.Globalization
@using IdentityServerWithAspNetIdentity.Resources
@inject LocService SharedLocalizer
@inject IdentityServerWithIdentitySQLite.ClientSelector _clientSelector
@{
    Layout = $"_Layout{_clientSelector.SelectedClient}";
}

Then the layout from the corresponding theme for the client is used and can be styled, changed as required for each client. Each themed Razor template which uses other views, should call the themed view. For example the ClientOne theme _Layout Razor view uses the _LoginPartial themed cshtml and not the default one.

@await Html.PartialAsync("~/Themes/ClientOne/Shared/_LoginPartial.cshtml")

The required themed views can then be implemented as required.

Client One themed view:

Client Two themed view:

Logout themed view for Client Two:

Links:

http://benfoster.io/blog/asp-net-core-themes-and-multi-tenancy

http://docs.identityserver.io/en/release/

https://docs.microsoft.com/en-us/ef/core/

https://docs.microsoft.com/en-us/aspnet/core/

https://getmdl.io/started/

Using the dotnet Angular template with Azure AD OIDC Implicit Flow

$
0
0

This article shows how to use Azure AD with an Angular application implemented using the Microsoft dotnet template and the angular-auth-oidc-client npm package to implement the OpenID Implicit Flow. The Angular app uses bootstrap 4 and Angular CLI.

Code: https://github.com/damienbod/dotnet-template-angular

Setting up Azure AD

Log into https://portal.azure.com and click the Azure Active Directory button

Click App registrations and then the New application registration

Add an application name and set the URL to match the application URL. Click the create button.

Open the new application.

Click the Manifest button.

Set the oauth2AllowImplicitFlow to true.

Click the settings button and add the API Access required permissions as needed.

Now the Azure AD is ready to go. You will need to add your users which you want to login with and add them as admins if required. For example, I have add damien@damienbod.onmicrosoft.com as an owner.

dotnet Angular template from Microsoft.

Install the latest version and create a new project.

Installation:
https://docs.microsoft.com/en-gb/aspnet/core/spa/index#installation

Docs:
https://docs.microsoft.com/en-gb/aspnet/core/spa/angular?tabs=visual-studio

The dotnet template uses Angular CLI and can be found in the ClientApp folder.

Update all the npm packages including the Angular-CLI, and do a npm install, or use yarn to update the packages.

Add the angular-auth-oidc-client which implements the OIDC Implicit Flow for Angular applications.

{
  "name": "dotnet_angular",
  "version": "0.0.0",
  "license": "MIT",
  "scripts": {
    "ng": "ng",
    "start": "ng serve --extract-css",
    "build": "ng build --extract-css",
    "build:ssr": "npm run build -- --app=ssr --output-hashing=media",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e"
  },
  "private": true,
  "dependencies": {
    "@angular-devkit/core": "0.0.28",
    "@angular/animations": "^5.2.1",
    "@angular/common": "^5.2.1",
    "@angular/compiler": "^5.2.1",
    "@angular/core": "^5.2.1",
    "@angular/forms": "^5.2.1",
    "@angular/http": "^5.2.1",
    "@angular/platform-browser": "^5.2.1",
    "@angular/platform-browser-dynamic": "^5.2.1",
    "@angular/platform-server": "^5.2.1",
    "@angular/router": "^5.2.1",
    "@nguniversal/module-map-ngfactory-loader": "^5.0.0-beta.5",
    "angular-auth-oidc-client": "4.0.0",
    "aspnet-prerendering": "^3.0.1",
    "bootstrap": "^4.0.0",
    "core-js": "^2.5.3",
    "es6-promise": "^4.2.2",
    "rxjs": "^5.5.6",
    "zone.js": "^0.8.20"
  },
  "devDependencies": {
    "@angular/cli": "1.6.5",
    "@angular/compiler-cli": "^5.2.1",
    "@angular/language-service": "^5.2.1",
    "@types/jasmine": "~2.8.4",
    "@types/jasminewd2": "~2.0.3",
    "@types/node": "~9.3.0",
    "codelyzer": "^4.1.0",
    "jasmine-core": "~2.9.1",
    "jasmine-spec-reporter": "~4.2.1",
    "karma": "~2.0.0",
    "karma-chrome-launcher": "~2.2.0",
    "karma-cli": "~1.0.1",
    "karma-coverage-istanbul-reporter": "^1.3.3",
    "karma-jasmine": "~1.1.1",
    "karma-jasmine-html-reporter": "^0.2.2",
    "protractor": "~5.2.2",
    "ts-node": "~4.1.0",
    "tslint": "~5.9.1",
    "typescript": "~2.6.2"
  }
}

Azure AD does not support CORS, so you have to GET the .well-known/openid-configuration with your tenant and add them to your application as a Json file.

https://login.microsoftonline.com/damienbod.onmicrosoft.com/.well-known/openid-configuration

Do the same for the jwt keys
https://login.microsoftonline.com/common/discovery/keys

Now change the URL in the well-known/openid-configuration json file to use the downloaded version of the keys.

{
  "authorization_endpoint": "https://login.microsoftonline.com/a0958f45-195b-4036-9259-de2f7e594db6/oauth2/authorize",
  "token_endpoint": "https://login.microsoftonline.com/a0958f45-195b-4036-9259-de2f7e594db6/oauth2/token",
  "token_endpoint_auth_methods_supported": [ "client_secret_post", "private_key_jwt", "client_secret_basic" ],
  "jwks_uri": "https://localhost:44347/jwks.json",
  "response_modes_supported": [ "query", "fragment", "form_post" ],
  "subject_types_supported": [ "pairwise" ],
  "id_token_signing_alg_values_supported": [ "RS256" ],
  "http_logout_supported": true,
  "frontchannel_logout_supported": true,
  "end_session_endpoint": "https://login.microsoftonline.com/a0958f45-195b-4036-9259-de2f7e594db6/oauth2/logout",
  "response_types_supported": [ "code", "id_token", "code id_token", "token id_token", "token" ],
  "scopes_supported": [ "openid" ],
  "issuer": "https://sts.windows.net/a0958f45-195b-4036-9259-de2f7e594db6/",
  "claims_supported": [ "sub", "iss", "cloud_instance_name", "cloud_instance_host_name", "cloud_graph_host_name", "msgraph_host", "aud", "exp", "iat", "auth_time", "acr", "amr", "nonce", "email", "given_name", "family_name", "nickname" ],
  "microsoft_multi_refresh_token": true,
  "check_session_iframe": "https://login.microsoftonline.com/a0958f45-195b-4036-9259-de2f7e594db6/oauth2/checksession",
  "userinfo_endpoint": "https://login.microsoftonline.com/a0958f45-195b-4036-9259-de2f7e594db6/openid/userinfo",
  "tenant_region_scope": "NA",
  "cloud_instance_name": "microsoftonline.com",
  "cloud_graph_host_name": "graph.windows.net",
  "msgraph_host": "graph.microsoft.com"
}

This can now be used in the APP_INITIALIZER of the app.module. In the OIDC configuration, set the OpenIDImplicitFlowConfiguration object to match the Azure AD application which was configured before.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule, APP_INITIALIZER } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { RouterModule } from '@angular/router';

import { AppComponent } from './app.component';
import { NavMenuComponent } from './nav-menu/nav-menu.component';
import { HomeComponent } from './home/home.component';

import {
  AuthModule,
  OidcSecurityService,
  OpenIDImplicitFlowConfiguration,
  OidcConfigService,
  AuthWellKnownEndpoints
} from 'angular-auth-oidc-client';
import { AutoLoginComponent } from './auto-login/auto-login.component';
import { routing } from './app.routes';
import { ForbiddenComponent } from './forbidden/forbidden.component';
import { UnauthorizedComponent } from './unauthorized/unauthorized.component';
import { ProtectedComponent } from './protected/protected.component';
import { AuthorizationGuard } from './authorization.guard';
import { environment } from '../environments/environment';

export function loadConfig(oidcConfigService: OidcConfigService) {
  console.log('APP_INITIALIZER STARTING');
  // https://login.microsoftonline.com/damienbod.onmicrosoft.com/.well-known/openid-configuration
  // jwt keys: https://login.microsoftonline.com/common/discovery/keys
  // Azure AD does not support CORS, so you need to download the OIDC configuration, and use these from the application.
  // The jwt keys needs to be configured in the well-known-openid-configuration.json
  return () => oidcConfigService.load_using_custom_stsServer('https://localhost:44347/well-known-openid-configuration.json');
}

@NgModule({
  declarations: [
    AppComponent,
    NavMenuComponent,
    HomeComponent,
    AutoLoginComponent,
    ForbiddenComponent,
    UnauthorizedComponent,
    ProtectedComponent
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'ng-cli-universal' }),
    HttpClientModule,
    AuthModule.forRoot(),
    FormsModule,
    routing,
  ],
  providers: [
	  OidcSecurityService,
	  OidcConfigService,
	  {
		  provide: APP_INITIALIZER,
		  useFactory: loadConfig,
		  deps: [OidcConfigService],
		  multi: true
    },
    AuthorizationGuard
	],
  bootstrap: [AppComponent]
})

export class AppModule {

  constructor(
    private oidcSecurityService: OidcSecurityService,
    private oidcConfigService: OidcConfigService,
  ) {
    this.oidcConfigService.onConfigurationLoaded.subscribe(() => {

      const openIDImplicitFlowConfiguration = new OpenIDImplicitFlowConfiguration();
      openIDImplicitFlowConfiguration.stsServer = 'https://login.microsoftonline.com/damienbod.onmicrosoft.com';
      openIDImplicitFlowConfiguration.redirect_url = 'https://localhost:44347';
      openIDImplicitFlowConfiguration.client_id = 'fd87184a-00c2-4aee-bc72-c7c1dd468e8f';
      openIDImplicitFlowConfiguration.response_type = 'id_token token';
      openIDImplicitFlowConfiguration.scope = 'openid profile email ';
      openIDImplicitFlowConfiguration.post_logout_redirect_uri = 'https://localhost:44347';
      openIDImplicitFlowConfiguration.post_login_route = '/home';
      openIDImplicitFlowConfiguration.forbidden_route = '/home';
      openIDImplicitFlowConfiguration.unauthorized_route = '/home';
      openIDImplicitFlowConfiguration.auto_userinfo = false;
      openIDImplicitFlowConfiguration.log_console_warning_active = true;
      openIDImplicitFlowConfiguration.log_console_debug_active = !environment.production;
      openIDImplicitFlowConfiguration.max_id_token_iat_offset_allowed_in_seconds = 600;

      const authWellKnownEndpoints = new AuthWellKnownEndpoints();
      authWellKnownEndpoints.setWellKnownEndpoints(this.oidcConfigService.wellKnownEndpoints);

      this.oidcSecurityService.setupModule(openIDImplicitFlowConfiguration, authWellKnownEndpoints);
      this.oidcSecurityService.setCustomRequestParameters({ 'prompt': 'admin_consent', 'resource': 'https://graph.windows.net'});
    });

    console.log('APP STARTING');
  }
}

Now an Auth Guard can be added to protect the protected routes.

import { Injectable } from '@angular/core';
import { Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { map } from 'rxjs/operators';
import { OidcSecurityService } from 'angular-auth-oidc-client';

@Injectable()
export class AuthorizationGuard implements CanActivate {

  constructor(
    private router: Router,
    private oidcSecurityService: OidcSecurityService
  ) { }

  public canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | boolean {
    console.log(route + '' + state);
    console.log('AuthorizationGuard, canActivate');

    return this.oidcSecurityService.getIsAuthorized().pipe(
      map((isAuthorized: boolean) => {
        console.log('AuthorizationGuard, canActivate isAuthorized: ' + isAuthorized);

        if (isAuthorized) {
          return true;
        }

        this.router.navigate(['/unauthorized']);
        return false;
      })
    );
  }
}

You can then add an app.routes and protect what you require.

import { Routes, RouterModule } from '@angular/router';

import { ForbiddenComponent } from './forbidden/forbidden.component';
import { HomeComponent } from './home/home.component';
import { UnauthorizedComponent } from './unauthorized/unauthorized.component';
import { AutoLoginComponent } from './auto-login/auto-login.component';
import { ProtectedComponent } from './protected/protected.component';
import { AuthorizationGuard } from './authorization.guard';

const appRoutes: Routes = [
  { path: '', component: HomeComponent, pathMatch: 'full' },
  { path: 'home', component: HomeComponent },
  { path: 'autologin', component: AutoLoginComponent },
  { path: 'forbidden', component: ForbiddenComponent },
  { path: 'unauthorized', component: UnauthorizedComponent },
  { path: 'protected', component: ProtectedComponent, canActivate: [AuthorizationGuard] }
];

export const routing = RouterModule.forRoot(appRoutes);

The NavMenuComponent component is then updated to add the login, logout.

import { Component } from '@angular/core';
import { Subscription } from 'rxjs/Subscription';
import { OidcSecurityService } from 'angular-auth-oidc-client';

@Component({
  selector: 'app-nav-menu',
  templateUrl: './nav-menu.component.html',
  styleUrls: ['./nav-menu.component.css']
})
export class NavMenuComponent {
  isExpanded = false;
  isAuthorizedSubscription: Subscription;
  isAuthorized: boolean;

  constructor(public oidcSecurityService: OidcSecurityService) {
  }

  ngOnInit() {
    this.isAuthorizedSubscription = this.oidcSecurityService.getIsAuthorized().subscribe(
      (isAuthorized: boolean) => {
        this.isAuthorized = isAuthorized;
      });
  }

  ngOnDestroy(): void {
    this.isAuthorizedSubscription.unsubscribe();
  }

  login() {
    this.oidcSecurityService.authorize();
  }

  refreshSession() {
    this.oidcSecurityService.authorize();
  }

  logout() {
    this.oidcSecurityService.logoff();
  }
  collapse() {
    this.isExpanded = false;
  }

  toggle() {
    this.isExpanded = !this.isExpanded;
  }
}

Start the application and click login

Enter your user which is defined in Azure AD

Consent page:

And you are redircted back to the application.

Notes:

If you don’t use any Microsoft API use the id_token flow, and not the id_token token flow. The resource of the API needs to be defined in both the request and also the Azure AD app definitions.

Links:

https://docs.microsoft.com/en-gb/aspnet/core/spa/angular?tabs=visual-studio

https://portal.azure.com

Securing an ASP.NET Core MVC application which uses a secure API

$
0
0

The article shows how an ASP.NET Core MVC application can implement security when using an API to retrieve data. The OpenID Connect Hybrid flow is used to secure the ASP.NET Core MVC application. The application uses tokens stored in a cookie. This cookie is not used to access the API. The API is protected using a bearer token.

To access the API, the code running on the server of the ASP.NET Core MVC application, implements the OAuth2 client credentials resource owner flow to get the access token for the API and can then return the data to the razor views.

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

History

2018-05-07 Updated to .NET Core 2.1 preview 2, new Identity Views, 2FA Authenticator, IHttpClientFactory, bootstrap 4.1.0

Setup

IdentityServer4 and OpenID connect flow configuration

Two client configurations are setup in the IdentityServer4 configuration class. The OpenID Connect Hybrid Flow client is used for the ASP.NET Core MVC application. This flow, after a successful login, will return a cookie to the client part of the application which contains the tokens. The second client is used for the API. This is a service to service communication between two trusted applications. This usually happens in a protected zone. The client API uses a secret to connect to the API. The secret should be a secret and different for each deployment.

public static IEnumerable<Client> GetClients()
{
	return new List<Client>
	{
		new Client
		{
			ClientName = "hybridclient",
			ClientId = "hybridclient",
			ClientSecrets = {new Secret("hybrid_flow_secret".Sha256()) },
			AllowedGrantTypes = GrantTypes.Hybrid,
			AllowOfflineAccess = true,
			RedirectUris = { "https://localhost:44329/signin-oidc" },
			PostLogoutRedirectUris = { "https://localhost:44329/signout-callback-oidc" },
			AllowedCorsOrigins = new List<string>
			{
				"https://localhost:44329/"
			},
			AllowedScopes = new List<string>
			{
				IdentityServerConstants.StandardScopes.OpenId,
				IdentityServerConstants.StandardScopes.Profile,
				IdentityServerConstants.StandardScopes.OfflineAccess,
				"scope_used_for_hybrid_flow",
				"role"
			}
		},
		new Client
		{
			ClientId = "ProtectedApi",
			ClientName = "ProtectedApi",
			ClientSecrets = new List<Secret> { new Secret { Value = "api_in_protected_zone_secret".Sha256() } },
			AllowedGrantTypes = GrantTypes.ClientCredentials,
			AllowedScopes = new List<string> { "scope_used_for_api_in_protected_zone" }
		}
	};
}

The GetApiResources defines the scopes and the APIs for the different resources. I usually define one scope per API resource.

public static IEnumerable<ApiResource> GetApiResources()
{
	return new List<ApiResource>
	{
		new ApiResource("scope_used_for_hybrid_flow")
		{
			ApiSecrets =
			{
				new Secret("hybrid_flow_secret".Sha256())
			},
			UserClaims = { "role", "admin", "user", "some_api" }
		},
		new ApiResource("ProtectedApi")
		{
			DisplayName = "API protected",
			ApiSecrets =
			{
				new Secret("api_in_protected_zone_secret".Sha256())
			},
			Scopes =
			{
				new Scope
				{
					Name = "scope_used_for_api_in_protected_zone",
					ShowInDiscoveryDocument = false
				}
			},
			UserClaims = { "role", "admin", "user", "safe_zone_api" }
		}
	};
}

Securing the Resource API

The protected API uses the IdentityServer4.AccessTokenValidation Nuget package to validate the access token. This uses the introspection endpoint to validate the token. The scope is also validated in this example using authorization policies from ASP.NET Core.

public void ConfigureServices(IServiceCollection services)
{
	services.AddAuthentication(IdentityServerAuthenticationDefaults.AuthenticationScheme)
	  .AddIdentityServerAuthentication(options =>
	  {
		  options.Authority = "https://localhost:44352";
		  options.ApiName = "ProtectedApi";
		  options.ApiSecret = "api_in_protected_zone_secret";
		  options.RequireHttpsMetadata = true;
	  });

	services.AddAuthorization(options =>
		options.AddPolicy("protectedScope", policy =>
		{
			policy.RequireClaim("scope", "scope_used_for_api_in_protected_zone");
		})
	);

	services.AddMvc();
}

The API is protected using the Authorize attribute and checks the defined policy. If this is ok, the data can be returned to the server part of the MVC application.

[Authorize(Policy = "protectedScope")]
[Route("api/[controller]")]
public class ValuesController : Controller
{
	[HttpGet]
	public IEnumerable<string> Get()
	{
		return new string[] { "data 1 from the second api", "data 2 from the second api" };
	}
}

Securing the ASP.NET Core MVC application

The ASP.NET Core MVC application uses OpenID Connect to validate the user and the application and saves the result in a cookie. If the identity is ok, the tokens are returned in the cookie from the server side of the application. See the OpenID Connect specification, for more information concerning the OpenID Connect Hybrid flow.

public void ConfigureServices(IServiceCollection services)
{
	services.AddAuthentication(options =>
	{
		options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
		options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
	})
	.AddCookie()
	.AddOpenIdConnect(options =>
	{
		options.SignInScheme = "Cookies";
		options.Authority = "https://localhost:44352";
		options.RequireHttpsMetadata = true;
		options.ClientId = "hybridclient";
		options.ClientSecret = "hybrid_flow_secret";
		options.ResponseType = "code id_token";
		options.Scope.Add("scope_used_for_hybrid_flow");
		options.Scope.Add("profile");
		options.SaveTokens = true;
	});

	services.AddAuthorization();

	services.AddMvc();
}

The Configure method adds the authentication to the MVC middleware using the UseAuthentication extension method.

public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
	...

	app.UseStaticFiles();

	app.UseAuthentication();

	app.UseMvc(routes =>
	{
		routes.MapRoute(
			name: "default",
			template: "{controller=Home}/{action=Index}/{id?}");
	});
}

The home controller is protected using the authorize attribute, and the index method gets the data from the API using the api service.

[Authorize]
public class HomeController : Controller
{
	private readonly ApiService _apiService;

	public HomeController(ApiService apiService)
	{
		_apiService = apiService;
	}

	public async System.Threading.Tasks.Task<IActionResult> Index()
	{
		var result = await _apiService.GetApiDataAsync();

		ViewData["data"] = result.ToString();
		return View();
	}

	public IActionResult Error()
	{
		return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
	}
}

Calling the protected API from the ASP.NET Core MVC app

The API service implements the HTTP request using the TokenClient from IdentiyModel. This can be downloaded as a Nuget package. First the access token is acquired from the server, then the token is used to request the data from the API.

Use the IHttpClientFactory in the service via dependency injection. You also need to add this to the Startup services. (AddHttpClient)

private readonly IHttpClientFactory _clientFactory;

public ApiService(
     IOptions<AuthConfigurations> authConfigurations, 
     IHttpClientFactory clientFactory)
{
   _authConfigurations = authConfigurations;
   _clientFactory = clientFactory;
}

And the HttpClient can be used to access the protected API.

var discoClient = new DiscoveryClient(_authConfigurations.Value.StsServer);
var disco = await discoClient.GetAsync();
if (disco.IsError)
{
	throw new ApplicationException($"Status code: {disco.IsError}, Error: {disco.Error}");
}

var tokenClient = new TokenClient(disco.TokenEndpoint, "ProtectedApi", "api_in_protected_zone_secret");
var tokenResponse = await tokenClient.RequestClientCredentialsAsync("scope_used_for_api_in_protected_zone");

if (tokenResponse.IsError)
{
	throw new ApplicationException($"Status code: {tokenResponse.IsError}, Error: {tokenResponse.Error}");
}

var client = _clientFactory.CreateClient();

client.BaseAddress = new Uri(_authConfigurations.Value.ProtectedApiUrl);
client.SetBearerToken(tokenResponse.AccessToken);

var response = await client.GetAsync("api/values");
if (response.IsSuccessStatusCode)
{
	var responseContent = await response.Content.ReadAsStringAsync();
	var data = JArray.Parse(responseContent);

	return data;
}

throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");

Authentication and Authorization in the API

The ASP.NET Core MVC application calls the API using a service to service trusted association in the protected zone. Due to this, the identity which made the original request cannot be validated using the access token on the API. If authorization is required for the original identity, this should be sent in the URL of the API HTTP request, which can then be validated as required using an authorization filter. Maybe it is enough to validate that the service token is authenticated, and authorized. Care should be taken when sending user data, GDPR requirements, or user information which the IT admins should not have access to.

Should I use the same token as the access token returned to the MVC client?

This depends 🙂 If the API is a public API, then this is fine, if you have no problem re-using the same token for different applications. If the API is in the protected zone, for example behind a WAF, then a separate token would be better. Only tokens issued for the trusted app can be used to access the protected API. This can be validated by using separate scopes, secrets, etc. The tokens issued for the MVC app and the user, will not work, these were issued for a single purpose only, and not multiple applications. The token used for the protected API never leaves the trusted zone.

Links

https://docs.microsoft.com/en-gb/aspnet/core/mvc/overview

https://docs.microsoft.com/en-gb/aspnet/core/security/anti-request-forgery

https://docs.microsoft.com/en-gb/aspnet/core/security/

http://openid.net/

https://www.owasp.org/images/b/b0/Best_Practices_WAF_v105.en.pdf

https://tools.ietf.org/html/rfc7662

http://docs.identityserver.io/en/release/quickstarts/5_hybrid_and_api_access.html

https://github.com/aspnet/Security

https://elanderson.net/2017/07/identity-server-from-implicit-to-hybrid-flow/

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

Adding HTTP Headers to improve Security in an ASP.NET MVC Core application

$
0
0

This article shows how to add headers in a HTTPS response for an ASP.NET Core MVC application. The HTTP headers help protect against some of the attacks which can be executed against a website. securityheaders.io is used to test and validate the HTTP headers as well as F12 in the browser. NWebSec is used to add most of the HTTP headers which improve security for the MVC application. Thanks to Scott Helme for creating securityheaders.io, and André N. Klingsheim for creating NWebSec.

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

History

2018-05-07 Updated to .NET Core 2.1 preview 2, new Identity Views, 2FA Authenticator, IHttpClientFactory, bootstrap 4.1.0

2018-02-09: Updated, added feedback from different sources, removing extra headers, add form actions to the CSP configuration, adding info about CAA.

A simple ASP.NET Core MVC application was created and deployed to Azure. securityheaders.io can be used to validate the headers in the application. The deployed application used in this post can be found here: https://webhybridclient20180206091626.azurewebsites.net/status/test

Testing the default application using securityheaders.io gives the following results with some room for improvement.

Fixing this in ASP.NET Core is pretty easy due to NWebSec. Add the NuGet package to the project.

<PackageReference Include="NWebsec.AspNetCore.Middleware" Version="2.0.0" />

Or using the NuGet Package Manager in Visual Studio

Add the Strict-Transport-Security Header

By using HSTS, you can force that all communication is done using HTTPS. If you want to force HTTPS on the first request from the browser, you can use the HSTS preload: https://hstspreload.appspot.com

app.UseHsts(hsts => hsts.MaxAge(365).IncludeSubdomains());

https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security

Add the X-Content-Type-Options Header

The X-Content-Type-Options can be set to no-sniff to prevent content sniffing.

app.UseXContentTypeOptions();

https://www.keycdn.com/support/what-is-mime-sniffing/

https://en.wikipedia.org/wiki/Content_sniffing

Add the Referrer Policy Header

This allows us to restrict the amount of information being passed on to other sites when referring to other sites. This is set to no referrer.

app.UseReferrerPolicy(opts => opts.NoReferrer());

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy

Scott Helme write a really good post on this:
https://scotthelme.co.uk/a-new-security-header-referrer-policy/

Add the X-XSS-Protection Header

The HTTP X-XSS-Protection response header is a feature of Internet Explorer, Chrome and Safari that stops pages from loading when they detect reflected cross-site scripting (XSS) attacks. (Text copied from here)

app.UseXXssProtection(options => options.EnabledWithBlockMode());

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection

Add the X-Frame-Options Header

You can use the X-frame-options Header to block iframes and prevent click jacking attacks.

app.UseXfo(options => options.Deny());

Add the Content-Security-Policy Header

Content Security Policy can be used to prevent all sort of attacks, XSS, click-jacking attacks, or prevent mixed mode (HTTPS and HTTP). The following configuration works for ASP.NET Core MVC applications, the mixed mode is activated, styles can be read from unsafe inline, due to the razor controls, or tag helpers, and everything can only be loaded from the same origin.

app.UseCsp(opts => opts
	.BlockAllMixedContent()
	.StyleSources(s => s.Self())
	.StyleSources(s => s.UnsafeInline())
	.FontSources(s => s.Self())
	.FormActions(s => s.Self())
	.FrameAncestors(s => s.Self())
	.ImageSources(s => s.Self())
	.ScriptSources(s => s.Self())
);

Due to this CSP configuration, the public CDNs need to be removed from the MVC application which are per default included in the dotnet template for an ASP.NET Core MVC application.

https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

NWebSec configuration in the Startup

//Registered before static files to always set header
app.UseHsts(hsts => hsts.MaxAge(365).IncludeSubdomains());
app.UseXContentTypeOptions();
app.UseReferrerPolicy(opts => opts.NoReferrer());
app.UseXXssProtection(options => options.EnabledWithBlockMode());
app.UseXfo(options => options.Deny());

app.UseCsp(opts => opts
	.BlockAllMixedContent()
	.StyleSources(s => s.Self())
	.StyleSources(s => s.UnsafeInline())
	.FontSources(s => s.Self())
	.FormActions(s => s.Self())
	.FrameAncestors(s => s.Self())
	.ImageSources(s => s.Self())
	.ScriptSources(s => s.Self())
);

app.UseStaticFiles();

When the application is tested again, things look much better.

Or view the headers in the browser, for example F12 in Chrome, and then the network view:

Here’s the securityheaders.io test results for this demo.

https://securityheaders.io/?q=https%3A%2F%2Fwebhybridclient20180206091626.azurewebsites.net%2Fstatus%2Ftest&followRedirects=on

Removing the extra infomation from the Headers

You could also remove the extra information from the HTTPS headers, for example X-Powered-By, or Server, so that less information is sent to the client.

Remove the server headers from the kestrel server, by using the UseKestrel extension method.

.UseKestrel(c => c.AddServerHeader = false)

Add a web.config to your project with the following settings:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <system.web>
    <httpRuntime enableVersionHeader="false"/>
  </system.web>
  <system.webServer>
    <security>
      <requestFiltering removeServerHeader="true" />
    </security>
    <httpProtocol>
      <customHeaders>
        <remove name="X-Powered-By"/>
      </customHeaders>
    </httpProtocol>
  </system.webServer>
</configuration>

Now by viewing the response in the browser, you can see some unrequired headers have been removed.


Further steps in hardening the application:

Use CAA

You can fix your domain to a selected amount of authorities. You can control the authorities which can issue the certs for your domain. This reduces the risk, that another cert authority produces a cert for your domain to a different person. This can be checked here:

https://toolbox.googleapps.com/apps/dig/

Or configured here:
https://sslmate.com/caa/

Then add it to the hosting provider.

Use a WAF

You could also add a WAF, for example to only expose public URLs and not private ones, or protect against DDoS attacks.

Certificate testing

The certificate should also be tested and validated.

https://www.ssllabs.com is a good test tool.

Here’s the result for the cert used in the demo project.

https://www.ssllabs.com/ssltest/analyze.html?d=webhybridclient20180206091626.azurewebsites.net

I would be grateful for feedback, or suggestions to improve this.

Links:

https://securityheaders.io

https://docs.nwebsec.com/en/latest/

https://github.com/NWebsec/NWebsec

https://www.troyhunt.com/shhh-dont-let-your-response-headers/

https://anthonychu.ca/post/aspnet-core-csp/

https://rehansaeed.com/content-security-policy-for-asp-net-mvc/

https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security

https://www.troyhunt.com/the-6-step-happy-path-to-https/

https://www.troyhunt.com/understanding-http-strict-transport/

https://hstspreload.appspot.com

https://geekflare.com/http-header-implementation/

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options

https://docs.microsoft.com/en-us/aspnet/core/tutorials/publish-to-azure-webapp-using-vs

https://developer.mozilla.org/en-US/docs/Web/HTTP/Public_Key_Pinning

https://toolbox.googleapps.com/apps/dig/

https://sslmate.com/caa/

Securing the CDN links in the ASP.NET Core 2.1 templates

$
0
0

This article uses the the ASP.NET Core 2.1 MVC template and shows how to secure the CDN links using the integrity parameter.

A new ASP.NET Core MVC application was created using the 2.1 template in Visual Studio.

This template uses HTTPS per default and has added some of the required HTTPS headers like HSTS which is required for any application. The template has added the integrity parameter to the javascript CDN links, but on the CSS CDN links, it is missing.

<script src="https://ajax.aspnetcdn.com/ajax/jquery/jquery-2.2.0.min.js"
 asp-fallback-src="~/lib/jquery/dist/jquery.min.js"
 asp-fallback-test="window.jQuery"  
 crossorigin="anonymous"
 integrity="sha384-K+ctZQ+LL8q6tP7I94W+qzQsfRV2a+AfHIi9k8z8l9ggpc8X+Ytst4yBo/hH+8Fk">
</script>

If the value of the integrity is changed, or the CDN script was changed, or for example a bitcoin miner was added to it, the MVC application will not load the script.

To test this, you can change the value of the integrity parameter on the script, and in the production environment, the script will not load and fallback to the localhost deployed script. By changing the value of the integrity parameter, it simulates a changed script on the CDN. The following snapshot shows an example of the possible errors sent to the browser:

Adding the integrity parameter to the CSS link

The template creates a bootstrap link in the _Layout.cshtml as follows:

<link rel="stylesheet" href="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.7/css/bootstrap.min.css"
              asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
              asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute" />

This is missing the integrity parameter. To fix this, the integrity parameter can be added to the link.

<link rel="stylesheet" 
          integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" 
          crossorigin="anonymous"
          href="https://ajax.aspnetcdn.com/ajax/bootstrap/3.3.7/css/bootstrap.min.css"
          asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.min.css"
          asp-fallback-test-class="sr-only"
          asp-fallback-test-property="position" 
          asp-fallback-test-value="absolute" />

The value of the integrity parameter was created using SRI Hash Generator. When creating this, you have to be sure, that the link is safe. By using this CDN, your application trusts the CDN links.

Now if the css file was changed on the CDN server, the application will not load it.

The CSP Header of the application can also be improved. The application should only load from the required CDNs and no where else. This can be forced by adding the following CSP configuration:

content-security-policy: 
script-src 'self' https://ajax.aspnetcdn.com;
style-src 'self' https://ajax.aspnetcdn.com;
img-src 'self';
font-src 'self' https://ajax.aspnetcdn.com;
form-action 'self';
frame-ancestors 'self';
block-all-mixed-content

Or you can use NWebSec and add it to the startup.cs

app.UseCsp(opts => opts
	.BlockAllMixedContent()
	.FontSources(s => s.Self()
		.CustomSources("https://ajax.aspnetcdn.com"))
	.FormActions(s => s.Self())
	.FrameAncestors(s => s.Self())
	.ImageSources(s => s.Self())
	.StyleSources(s => s.Self()
		.CustomSources("https://ajax.aspnetcdn.com"))
	.ScriptSources(s => s.Self()
		.UnsafeInline()
		.CustomSources("https://ajax.aspnetcdn.com"))
);

Links:

https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity

https://www.srihash.org/

https://www.troyhunt.com/protecting-your-embedded-content-with-subresource-integrity-sri/

https://scotthelme.co.uk/tag/cdn/

https://rehansaeed.com/tag/subresource-integrity-sri/

https://rehansaeed.com/subresource-integrity-taghelper-using-asp-net-core/

Using Message Pack with ASP.NET Core SignalR

$
0
0

This post shows how SignalR could be used to send messages between different C# console clients using Message Pack as the protocol. An ASP.NET Core web application is used to host the SignalR Hub.

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

Posts in this series

History

2018-05-08 Updated Microsoft.AspNetCore.SignalR 2.1 rc1

Setting up the Message Pack SignalR server

Add the Microsoft.AspNetCore.SignalR and the Microsoft.AspNetCore.SignalR.MsgPack NuGet packages to the ASP.NET Core server application where the SignalR Hub will be hosted. The Visual Studio NuGet Package Manager can be used for this.

Or just add it directly to the .csproj project file.

<PackageReference 
  Include="Microsoft.AspNetCore.SignalR" 
  Version="1.0.0-rc1-final" />
<PackageReference 
  Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" 
  Version="1.0.0-rc1-final" />

Setup a SignalR Hub as required. This is done by implementing the Hub class.

using Dtos;
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;

namespace AspNetCoreAngularSignalR.SignalRHubs
{
    // Send messages using Message Pack binary formatter
    public class LoopyMessageHub : Hub
    {
        public Task Send(MessageDto data)
        {
            return Clients.All.SendAsync("Send", data);
        }
    }
}

A DTO class is created to send the Message Pack messages. Notice that the class is a plain C# class with no Message Pack attributes, or properties.

using System;

namespace Dtos
{
    public class MessageDto
    {
        public Guid Id { get; set; }

        public string Name { get; set; }

        public int Amount { get; set; }
    }
}

Then add the Message Pack protocol to the SignalR service.

services.AddSignalR()
.AddMessagePackProtocol();

And configure the SignalR Hub in the Startup class Configure method of the ASP.NET Core server application.

app.UseSignalR(routes =>
{
	routes.MapHub<LoopyMessageHub>("/loopymessage");
});

Setting up the Message Pack SignalR client

Add the Microsoft.AspNetCore.SignalR.Client and the Microsoft.AspNetCore.SignalR.Client.MsgPack NuGet packages to the SignalR client console application.

The packages are added to the project file.

<PackageReference 
  Include="Microsoft.AspNetCore.SignalR.Client" 
  Version="1.0.0-rc1-final" />
<PackageReference 
  Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" 
  Version="1.0.0-rc1-final" />

Create a Hub client connection using the Message Pack Protocol. The Url must match the URL configuration on the server.

public static async Task SetupSignalRHubAsync()
{
	_hubConnection = new HubConnectionBuilder()
		 .WithUrl("https://localhost:44324/loopymessage")
		 .AddMessagePackProtocol()
		 .ConfigureLogging(factory =>
		 {
			 factory.AddConsole();
			 factory.AddFilter("Console", level => level >= LogLevel.Trace);
		 }).Build();

	 await _hubConnection.StartAsync();
}

The Hub can then be used to send or receive SignalR messages using the Message Pack as the binary serializer.

using Dtos;
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.SignalR.Protocol;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace ConsoleSignalRMessagePack
{
    class Program
    {
        private static HubConnection _hubConnection;

        public static void Main(string[] args) => MainAsync().GetAwaiter().GetResult();

        static async Task MainAsync()
        {
            await SetupSignalRHubAsync();
            _hubConnection.On<MessageDto>("Send", (message) =>
            {
                Console.WriteLine($"Received Message: {message.Name}");
            });
            Console.WriteLine("Connected to Hub");
            Console.WriteLine("Press ESC to stop");
            do
            {
                while (!Console.KeyAvailable)
                {
                    var message = Console.ReadLine();
                    await _hubConnection.SendAsync("Send", new MessageDto() { Id = Guid.NewGuid(), Name = message, Amount = 7 });
                    Console.WriteLine("SendAsync to Hub");
                }
            }
            while (Console.ReadKey(true).Key != ConsoleKey.Escape);

            await _hubConnection.DisposeAsync();
        }

        public static async Task SetupSignalRHubAsync()
        {
            _hubConnection = new HubConnectionBuilder()
                 .WithUrl("https://localhost:44324/loopymessage")
                 .AddMessagePackProtocol()
                 .ConfigureLogging(factory =>
                 {
                     factory.AddConsole();
                     factory.AddFilter("Console", level => level >= LogLevel.Trace);
                 }).Build();

             await _hubConnection.StartAsync();
        }
    }
}

Testing

Start the server application, and 2 console applications. Then you can send and receive SignalR messages, which use Message Pack as the protocol.


Links:

https://msgpack.org/

https://github.com/aspnet/SignalR

https://github.com/aspnet/SignalR#readme

https://radu-matei.com/blog/signalr-core/

Supporting both Local and Windows Authentication in ASP.NET Core MVC using IdentityServer4

$
0
0

This article shows how to setup an ASP.NET Core MVC application to support both users who can login in with a local login account, solution specific, or use a windows authentication login. The identity created from the windows authentication could then be allowed to do different tasks, for example administration, or a user from the local authentication could be used for guest accounts, etc. To do this, IdentityServer4 is used to handle the authentication. The ASP.NET Core MVC application uses the OpenID Connect Hybrid Flow.

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

Posts in this series:

Setting up the STS using IdentityServer4

The STS is setup using the IdentityServer4 dotnet templates. Once installed, the is4aspid template was used to create the application from the command line.

The windows authentication is activated in the launchSettings.json. To setup the windows authentication for the deployment, refer to the Microsoft Docs.

{
  "iisSettings": {
    "windowsAuthentication": true,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "https://localhost:44364/",
      "sslPort": 44364
    }
  },

The OpenID Connect Hybrid Flow was then configured for the client application.

new Client
{
	ClientId = "hybridclient",
	ClientName = "MVC Client",

	AllowedGrantTypes = GrantTypes.HybridAndClientCredentials,
	ClientSecrets = { new Secret("hybrid_flow_secret".Sha256()) },

	RedirectUris = { "https://localhost:44381/signin-oidc" },
	FrontChannelLogoutUri = "https://localhost:44381/signout-oidc",
	PostLogoutRedirectUris = { "https://localhost:44381/signout-callback-oidc" },

	AllowOfflineAccess = true,
	AllowedScopes = { "openid", "profile", "offline_access",  "scope_used_for_hybrid_flow" }
}

ASP.NET Core MVC Hybrid Client

The ASP.NET Core MVC application is configured to authenticate using the STS server, and to save the tokens in a cookie. The AddOpenIdConnect method configures the OIDC Hybrid client, which must match the settings in the IdentityServer4 application.

The TokenValidationParameters MUST be used, to set the NameClaimType property, otherwise the User.Identity.Name property will be null. This value is returned in the ‘name’ claim, which is not the default.

services.AddAuthentication(options =>
{
	options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
	options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
	options.SignInScheme = "Cookies";
	options.Authority = stsServer;
	options.RequireHttpsMetadata = true;
	options.ClientId = "hybridclient";
	options.ClientSecret = "hybrid_flow_secret";
	options.ResponseType = "code id_token";
	options.GetClaimsFromUserInfoEndpoint = true;
	options.Scope.Add("scope_used_for_hybrid_flow");
	options.Scope.Add("profile");
	options.Scope.Add("offline_access");
	options.SaveTokens = true;
	// Set the correct name claim type
	options.TokenValidationParameters = new TokenValidationParameters
	{
		NameClaimType = "name"
	};
});

Then all controllers can be secured using the Authorize attribute. The anti forgery cookie should also be used, because the application uses cookies to store the tokens.

[Authorize]
public class HomeController : Controller
{

Displaying the login type in the ASP.NET Core Client

Then application then displays the authentication type in the home view. To do this, a requireWindowsProviderPolicy policy is defined, which requires that the identityprovider claim has the value Windows. The policy is added using the AddAuthorization method options.

var requireWindowsProviderPolicy = new AuthorizationPolicyBuilder()
 .RequireClaim("http://schemas.microsoft.com/identity/claims/identityprovider", "Windows")
 .Build();

services.AddAuthorization(options =>
{
	options.AddPolicy(
	  "RequireWindowsProviderPolicy", 
	  requireWindowsProviderPolicy
	);
});

The policy can then be used in the cshtml view.

@using Microsoft.AspNetCore.Authorization
@inject IAuthorizationService AuthorizationService
@{
    ViewData["Title"] = "Home Page";
}

<br />

@if ((await AuthorizationService.AuthorizeAsync(User, "RequireWindowsProviderPolicy")).Succeeded)
{
    <p>Hi Admin, you logged in with an internal Windows account</p>
}
else
{
    <p>Hi local user</p>

}

Both applications can then be started. The client application is redirected to the STS server and the user can login with either the Windows authentication, or a local account.

The text in the client application is displayed depending on the Identity returned.

Identity created for the Windows Authentication:

Local Identity:

Next Steps

The application now works for Windows authentication, or a local account authentication. The authorization now needs to be set, so that the different types have different claims. The identities returned from the Windows Authentication will have different claims, to the identities returned form the local logon, which will be used for guest accounts.

Links:

https://docs.microsoft.com/en-us/aspnet/core/security/authorization/views?view=aspnetcore-2.1&tabs=aspnetcore2x

https://docs.microsoft.com/en-us/aspnet/core/security/authentication/?view=aspnetcore-2.1

https://mva.microsoft.com/en-US/training-courses/introduction-to-identityserver-for-aspnet-core-17945

https://stackoverflow.com/questions/34951713/aspnet5-windows-authentication-get-group-name-from-claims/34955119

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

https://docs.microsoft.com/en-us/iis/configuration/system.webserver/security/authentication/windowsauthentication/


Dynamic CSS in an ASP.NET Core MVC View Component

$
0
0

This post shows how a view with dynamic css styles could be implemented using an MVC view component in ASP.NET Core. The values are changed using a HTML form with ASP.NET Core tag helpers, and passed into the view component which displays the view using css styling. The styles are set at runtime.

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

Creating the View Component

The View Component is a nice way of implementing components in ASP.NET Core MVC. The view component is saved in the \Views\Shared\Components\DynamicDisplay folder which fulfils some of the standard paths which are pre-defined by ASP.NET Core. This can be changed, but I always try to use the defaults where possible.

The DynamicDisplay class implements the ViewComponent class, which has a single async method InvokeAsync that returns a Task with the IViewComponentResult type.

using AspNetCoreMvcDynamicViews.Views.Shared.Components.DynamicDisplay;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;

namespace AspNetCoreMvcDynamicViews.Views.Home.ViewComponents
{
    [ViewComponent(Name = "DynamicDisplay")]
    public class DynamicDisplay : ViewComponent
    {
        public async Task<IViewComponentResult> InvokeAsync(DynamicDisplayModel dynamicDisplayModel)
        {
            return View(await Task.FromResult(dynamicDisplayModel));
        }
    }
}

The view component uses a simple view model with some helper methods to make it easier to use the data in a cshtml view.

namespace AspNetCoreMvcDynamicViews.Views.Shared.Components.DynamicDisplay
{
    public class DynamicDisplayModel
    {
        public int NoOfHoles { get; set; } = 2;

        public int BoxHeight { get; set; } = 100;

        public int NoOfBoxes { get; set; } = 2;

        public int BoxWidth { get; set; } = 200;

        public string GetAsStringWithPx(int value)
        {
            return $"{value}px";
        }

        public string GetDisplayHeight()
        {
            return $"{BoxHeight + 50 }px";
        }

        public string GetDisplayWidth()
        {
            return $"{BoxWidth * NoOfBoxes}px";
        }
    }
}

The cshtml view uses both css classes and styles to do a dynamic display of the data.

@using AspNetCoreMvcDynamicViews.Views.Shared.Components.DynamicDisplay
@model DynamicDisplayModel

<div style="height:@Model.GetDisplayHeight(); width:@Model.GetDisplayWidth()">
    @for (var i = 0; i < Model.NoOfBoxes; i++)
    {
    <div class="box" style="width:@Model.GetAsStringWithPx(Model.BoxWidth);height:@Model.GetAsStringWithPx(Model.BoxHeight);">
        @if (Model.NoOfHoles == 4)
        {
            @await Html.PartialAsync("./FourHolesPartial.cshtml")
        }
        else if (Model.NoOfHoles == 2)
        {
            @await Html.PartialAsync("./TwoHolesPartial.cshtml")
        }
        else if (Model.NoOfHoles == 1)
        {
            <div class="row justify-content-center align-items-center" style="height:100%">
                <span class="dot" style=""></span>
            </div>
        }
    </div>
    }
</div>

Partial views are used inside the view component to display some of the different styles. The partial view is added using the @await Html.PartialAsync call. The box with the four holes is implemented in a partial view.

<div class="row" style="height:50%">
    <div class="col-6">
        <span class="dot" style="float:left;"></span>
    </div>
    <div class="col-6">
        <span class="dot" style="float:right;"></span>
    </div>
</div>

<div class="row align-items-end" style="height:50%">
    <div class="col-6">
        <span class="dot" style="float:left;"></span>
    </div>
    <div class="col-6">
        <span class="dot" style="float:right;"></span>
    </div>
</div>

And CSS classes are used to display the data.

.dot {
	height: 25px;
	width: 25px;
	background-color: #bbb;
	border-radius: 50%;
	display: inline-block;
}

.box {
	float: left;
	height: 100px;
	border: 1px solid gray;
	padding: 5px;
	margin: 5px;
	margin-left: 0;
	margin-right: -1px;
}

Using the View Component

The view component is then used in a cshtml view. This view implements the form which sends the data to the server. The view component is added using the Component.InvokeAsync method which takes only a model as a parameter and then name of the view component.

@using AspNetCoreMvcDynamicViews.Views.Shared.Components.DynamicDisplay
@model MyDisplayModel
@{
    ViewData["Title"] = "Home Page";
}

<div style="padding:20px;"></div>

<form asp-controller="Home" asp-action="Index" method="post">
    <div class="col-md-12">

        @*<div class="form-group row">
            <label  class="col-sm-3 col-form-label font-weight-bold">Circles</label>
            <div class="col-sm-9">
                <select class="form-control" asp-for="mmm" asp-items="mmmItmes"></select>
            </div>
        </div>*@

        <div class="form-group row">
            <label class="col-sm-5 col-form-label font-weight-bold">No of Holes</label>
            <select class="col-sm-5 form-control" asp-for="DynamicDisplayData.NoOfHoles">
                <option value="0" selected>No Holes</option>
                <option value="1">1 Hole</option>
                <option value="2">2 Holes</option>
                <option value="4">4 Holes</option>
            </select>
        </div>

        <div class="form-group row">
            <label class="col-sm-5 col-form-label font-weight-bold">Height in mm</label>
            <input class="col-sm-5 form-control" asp-for="DynamicDisplayData.BoxHeight" type="number" min="65" max="400" />
        </div>

        <div class="form-group row">
            <label class="col-sm-5 col-form-label font-weight-bold">No. of Boxes</label>
            <input class="col-sm-5 form-control" asp-for="DynamicDisplayData.NoOfBoxes" type="number" min="1" max="7" />
        </div>

        <div class="form-group row">
            <label class="col-sm-5 col-form-label font-weight-bold">Box Width</label>
            <input class="col-sm-5 form-control" asp-for="DynamicDisplayData.BoxWidth" type="number" min="65" max="400" />
        </div>

        <div class="form-group row">
            <button class="btn btn-primary col-sm-10" type="submit">Update</button>
        </div>

    </div>

    @await Component.InvokeAsync("DynamicDisplay", Model.DynamicDisplayData)

</form>

The MVC Controller implements two methods, one for the GET, and one for the POST. The form uses the POST to send the data to the server, so this could be saved if required.

public class HomeController : Controller
{
	[HttpGet]
	public IActionResult Index()
	{
		var model = new MyDisplayModel
		{
			DynamicDisplayData = new DynamicDisplayModel()
		};
		return View(model);
	}

	[HttpPost]
	public IActionResult Index(MyDisplayModel myDisplayModel)
	{
		// save data to db...
		return View("Index", myDisplayModel);
	}

Running the demo

When the application is started, the form is displayed, and the default values are displayed.

And when the update button is clicked, the values are visualized inside the view component.

Links:

https://docs.microsoft.com/en-us/aspnet/core/mvc/views/view-components?view=aspnetcore-2.1

https://docs.microsoft.com/en-us/aspnet/core/mvc/views/partial?view=aspnetcore-2.1

https://docs.microsoft.com/en-us/aspnet/core/mvc/views/razor?view=aspnetcore-2.1

An ASP.NET Core Razor Pages Bootstrap 4 Application using Webpack, Typescript, and npm

$
0
0

This article shows how an ASP.NET Core Razor Pages application could be setup to use webpack, Typescript and npm to build, and bundle the client js, CSS for development and production. The application uses Bootstrap 4.

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

The example is setup so that the vendor ( 3rd Party packages ) javascript files are used as part of the application in development, but CDN links are used for the production deployment. The vendor CSS files, (bootstrap 4) are loaded in the same way, locally for development, and CDNs for production. SASS is used to build the application CSS and this is built into the application bundle.

Getting the client packages using npm

A package.json file is added to the root of the project. This file is used to install the required npm packages and to define the scripts used for the client builds. All the packages for the project and the client build packages are added to this file.

{
 "scripts": {
  "build": "webpack --env=development",
  "build-watch": "webpack --env=development --watch",
  "release": "webpack --env=production",
  "publish": "npm run release && dotnet publish -c Release"
 },
 "dependencies": {
  "bootstrap": "4.1.1",
  "jquery": "3.3.1",
  "jquery-validation": "1.17.0",
  "jquery-validation-unobtrusive": "3.2.10",
  "core-js": "2.5.7",
  "zone.js": "0.8.26",
  "es6-promise": "^4.2.4",
  "ie-shim": "0.1.0",
  "isomorphic-fetch": "^2.2.1",
  "rxjs": "6.2.1"
 },
 "devDependencies": {
  "@types/node": "^10.3.4",
  "awesome-typescript-loader": "^5.2.0",
  "clean-webpack-plugin": "~0.1.19",
  "codelyzer": "^4.3.0",
  "concurrently": "^3.6.0",
  "copy-webpack-plugin": "^4.5.1",
  "css-loader": "~0.28.11",
  "file-loader": "^1.1.11",
  "html-webpack-plugin": "~3.2.0",
  "jquery": "^3.3.1",
  "json-loader": "^0.5.7",
  "mini-css-extract-plugin": "~0.4.0",
  "node-sass": "^4.9.0",
  "raw-loader": "^0.5.1",
  "rimraf": "^2.6.2",
  "sass-loader": "^7.0.3",
  "source-map-loader": "^0.2.3",
  "style-loader": "^0.21.0",
  "ts-loader": "~4.4.1",
  "tslint": "^5.10.0",
  "tslint-loader": "^3.6.0",
  "typescript": "~2.9.2",
  "uglifyjs-webpack-plugin": "^1.2.6",
  "url-loader": "^1.0.1",
  "webpack": "~4.12.0",
  "webpack-bundle-analyzer": "^2.13.1",
  "webpack-cli": "~3.0.6"
 }
}

Install nodeJS if not already installed and update npm (npm install -g npm) after installing nodeJS. Then install the packages.

> 
> npm install

A webpack config file is added to the root of the project. The development build or the production build can be started using this file.

/// <binding ProjectOpened='Run - Development' />

module.exports = function(env) {
  return require(`./Client/webpack.${env}.js`)
}

The webpack development build creates 3 files, a polyfills file, the vendor bundle file using the vendor.development.ts file and the app bundle using the main.ts as an entry point. The built files are created in the wwwroot/dist. The assets are copied 1 to 1 to the wwwroot. The CSS for bootstrap 4 is loaded directly to the wwwroot, and not bundled with the application CSS. This is then added in the header of the _Layout.cshtml. The sass files are built into the application directly into the app bundle.

const path = require('path');
const rxPaths = require('rxjs/_esm5/path-mapping');

const webpack = require('webpack');

const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');

const helpers = require('./webpack.helpers');

const ROOT = path.resolve(__dirname, '..');

console.log('@@@@@@@@@ USING DEVELOPMENT @@@@@@@@@@@@@@@');

module.exports = {
  mode: 'development',
  devtool: 'source-map',
  performance: {
    hints: false
  },
  entry: {
    polyfills: './Client/polyfills.ts',
      vendor: './Client/vendor.development.ts',
      app: './Client/main.ts'
  },

  output: {
    path: ROOT + '/wwwroot/',
    filename: 'dist/[name].bundle.js',
    chunkFilename: 'dist/[id].chunk.js',
    publicPath: '/'
  },

  resolve: {
    extensions: ['.ts', '.js', '.json'],
    alias: rxPaths()
  },

  devServer: {
    historyApiFallback: true,
    contentBase: path.join(ROOT, '/wwwroot/'),
    watchOptions: {
      aggregateTimeout: 300,
      poll: 1000
    }
  },

  module: {
    rules: [
      {
        test: /\.ts$/,
        use: [
          'awesome-typescript-loader',
          'source-map-loader'
        ]
      },
      {
        test: /\.(png|jpg|gif|woff|woff2|ttf|svg|eot)$/,
        use: 'file-loader?name=assets/[name]-[hash:6].[ext]'
      },
      {
        test: /favicon.ico$/,
        use: 'file-loader?name=/[name].[ext]'
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader']
      },
      {
        test: /\.scss$/,
          include: path.join(ROOT, 'Client/styles'),
        use: ['style-loader', 'css-loader', 'sass-loader']
      },
      {
        test: /\.scss$/,
          exclude: path.join(ROOT, 'Client/styles'),
        use: ['raw-loader', 'sass-loader']
      },
      {
        test: /\.html$/,
        use: 'raw-loader'
      }
    ],
    exprContextCritical: false
  },
  plugins: [
    function() {
      this.plugin('watch-run', function(watching, callback) {
        console.log(
          '\x1b[33m%s\x1b[0m',
          `Begin compile at ${new Date().toTimeString()}`
        );
        callback();
      });
    },

    new webpack.optimize.ModuleConcatenationPlugin(),

    new webpack.ProvidePlugin({
      $: 'jquery',
      jQuery: 'jquery',
      'window.jQuery': 'jquery'
    }),

    // new webpack.optimize.CommonsChunkPlugin({ name: ['vendor', 'polyfills'] }),

    new CleanWebpackPlugin(['./wwwroot/dist', './wwwroot/assets'], {
      root: ROOT
    }),

    new HtmlWebpackPlugin({
        filename: '../Pages/Shared/_Layout.cshtml',
        inject: 'body',
        template: 'Client/_Layout.cshtml'
    }),

    new CopyWebpackPlugin([
        { from: './Client/assets/*.*', to: 'assets/', flatten: true }
    ]),

    new CopyWebpackPlugin([
        { from: './node_modules/bootstrap/dist/css/*.*', to: 'css/', flatten: true }
    ])
  ]
};

The ASP.NET Core _Layout.cshtml src file is added to the Client folder. When webpack builds, the required bundles are added to the file, and copied to the Shared/Pages required by the Razor Pages.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <environment exclude="Development">
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB" crossorigin="anonymous">
    </environment>

    <environment include="Development">
        <link href="~/css/bootstrap.min.css" rel="stylesheet" />
    </environment>

    <title>@ViewData["Title"] - ASP.NET Core Pages Webpack</title>
</head>
<body>
    <div class="container">
        <nav class="bg-dark mb-4 navbar navbar-dark navbar-expand-md">
            <a asp-page="/Index" class="navbar-brand">
                <em>ASP.NET Core Pages Webpack</em>
            </a>
            <button aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler" data-target="#topNavbarCollapse" data-toggle="collapse" type="button">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="topNavbarCollapse">
                <ul class="mr-auto navbar-nav">
                    <li class="nav-item">
                        <a asp-page="/Index" class="nav-link">Home</a>
                    </li>
                    <li class="nav-item">
                        <a asp-page="/About" class="nav-link">About</a>
                    </li>
                    <li class="nav-item">
                        <a asp-page="/Contact" class="nav-link">Contact</a>
                    </li>
                </ul>
                <ul class="navbar-nav">
                    <li class="nav-item">
                        <a class="nav-link" href="https://twitter.com/damien_bod">
                            <img height="30" src="assets/damienbod.jpg" />
                        </a>
                    </li>
                </ul>
            </div>
        </nav>

    </div>
    
    <partial name="_CookieConsentPartial" />

    <div class="container body-content">
        @RenderBody()
        <hr />
        <footer>
            <p>&copy; 2018 - ASP.NET Core Pages Webpack Bootstrap 4</p>
        </footer>
    </div>

    <environment exclude="Development">
        <!-- Optional JavaScript -->
        <!-- jQuery first, then Popper.js, then Bootstrap JS -->
        <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.min.js" integrity="sha384-smHYKdLADwkXOn1EmN1qk/HfnUcbVRZyYmZ4qpPea6sjB/pTJ0euyQp0Mk8ck+5T" crossorigin="anonymous"></script>
    </environment>

    @RenderSection("Scripts", required: false)
</body>
</html>

Client Production build

The webpack production is is very similar except that most, if not all of the vendor client libraries are removed and loaded using CDNs. You can choose where the production scripts should be read from, depending on the project. The vendor.production.ts in this project is empty.

The main.ts is the entry point for the application scripts. All typescript code can be added here.

import './styles/app.scss';

// Write your ts code here
console.log("My site scripts if needed");

In Visual Studio, the npm Task Runner can be installed and used to do the client builds.

Or from the cmd

>
> npm run build
>

When the application is started, the client bundles are used in the Pages application.

Links:

https://docs.microsoft.com/en-us/aspnet/core/razor-pages/?view=aspnetcore-2.1&tabs=visual-studio

https://getbootstrap.com/

https://webpack.js.org/

https://www.typescriptlang.org/

https://nodejs.org/en/

https://www.npmjs.com/

Updating ASP.NET Core Identity to use Bootstrap 4

$
0
0

This article shows how to update the default Identity Pages template to use Bootstrap 4. You need to scaffold the views into the project, and change the layouts and the views to use the new Bootstrap 4 classes and javascript. The base project is built using Webpack and npm. Bootstrap 4 is loaded from npm, unless using a CDN for production.

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

Create a new web application and add the Individual User accounts.

Scaffold the Identity UI views to the project. This blog explains how:

How to Scaffold Identity UI in ASP.NET Core 2.1

Switch the build to Webpack and npm and import Bootstrap 4.

You could do it like this:

An ASP.NET Core Razor Pages Bootstrap 4 Application using Webpack, Typescript, and npm

Or just import the the Bootstrap 4 stuff directly and remove the Bootstrap 3 stuff from the layout.

Now the Identity views need to be updated to use the Bootstrap 4 classes and scripts.

Change the base Layout in the Areas/Identity/Pages/Account/Manage/_Layout.cshtml file. The default layout must be used here, and not the hidden _Layout from the Identity package.

@{ 
    Layout = "/Pages/Shared/_Layout.cshtml";
}

<h2>Manage your account</h2>

<div>
    <h4>Change your account settings</h4>
    <hr />
    <div class="row">
        <div class="col-md-3">
            <partial name="_ManageNav" />
        </div>
        <div class="col-md-9">
            @RenderBody()
        </div>
    </div>
</div>

@section Scripts {
    @RenderSection("Scripts", required: false)
}

Update all the views to use the new Bootstrap 4 classes. For example the nav component classes have changed.

Here is the changed _ManageNav.cshtml view:

@inject SignInManager<IdentityUser> SignInManager
@{
    var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
}
<nav class="navbar navbar-light">
    <ul class="mr-auto navbar-nav">
        <li class="nav-item @ManageNavPages.IndexNavClass(ViewContext)"><a class="nav-link" asp-page="./Index">Profile</a></li>
        <li class="nav-item @ManageNavPages.ChangePasswordNavClass(ViewContext)"><a class="nav-link" id="change-password" asp-page="./ChangePassword">Password</a></li>
        @if (hasExternalLogins)
        {
            <li class="nav-item @ManageNavPages.ExternalLoginsNavClass(ViewContext)"><a class="nav-link" id="external-login" asp-page="./ExternalLogins">External logins</a></li>
        }
        <li class="nav-item @ManageNavPages.TwoFactorAuthenticationNavClass(ViewContext)"><a class="nav-link" asp-page="./TwoFactorAuthentication">Two-factor authentication</a></li>
        <li class="nav-item @ManageNavPages.PersonalDataNavClass(ViewContext)"><a class="nav-link" asp-page="./PersonalData">Personal data</a></li>
    </ul>
</nav>

Also remove the Bootstrap 3 stuff from the _ValidationScriptsPartial.cshtml in the Identity Area.

And now your set.

Login page:

Manage the Password:

Links:

https://docs.microsoft.com/en-us/aspnet/core/razor-pages/?view=aspnetcore-2.1&tabs=visual-studio

http://www.talkingdotnet.com/how-to-scaffold-identity-ui-in-asp-net-core-2-1/

https://docs.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity?view=aspnetcore-2.1&tabs=visual-studio

https://github.com/aspnet/Identity

https://getbootstrap.com/

https://webpack.js.org/

https://www.typescriptlang.org/

https://nodejs.org/en/

https://www.npmjs.com/

Updating part of an ASP.NET Core MVC View which uses Forms

$
0
0

This article shows how to update part of an ASP.NET Core MVC view which uses forms. Sometimes, within a form, some values depend on other ones, and cannot be updated on the client side. Changes in the form input values sends a partial view update which updates the rest of the dependent values, but not the whole MVC View. This can be implemented using ajax requests. The values can then be used to do a create, or an update request.

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

The Index View used in the MVC routing implements the complete view with a partial view which will be asynchronously updated on certain input value changes. The Javascript code uses jQuery. Because the partial view DOM elements are reloaded after each change, the on function is used to define the change events, so that it will work after a partial update.

When the ajax request returns, it adds the result to the DOM element with the id = ‘partial’. The change events are added to all child elements, with the id = ‘updateevent’. This could be changed, depending on how you want to find the DOM input elements.

The partial view is updated inside a form. The form is used to do a create request, which updates the whole view. The Javascript requests only updates the partial view, with the new model values depending on the business requirements, which are implemented in the server side code.

@using  AspNetCoreMvcDynamicViews.Models
@model ConfigureSectionsModel
@{
    ViewData["Title"] = "Configure View";
}

<div style="padding:20px;"></div>

@section Scripts{
    <script language="javascript">

        $(function () {
            $('#partial').on('change', '.updateevent', function (el) {
                $.ajax({
                    url: window.location.origin + "/Configure/UpdateViewData",
                    type: "post",
                    data: $("#partialform").serialize(), 
                    success: function (result) {
                        $("#partial").html(result);
                    }
                });
            });
        });

    </script>
}

<form id="partialform" asp-controller="Configure" asp-action="Create" method="post">
    <div class="col-md-12">
        <div id="partial">
            <partial name="PartialConfigure" model="Model.ConfigueSectionAGetModel" />
        </div>

        <div class="form-group row">
            <button class="btn btn-primary col-sm-12" type="submit">Create</button>
        </div>
    </div>
</form>

The partial view is a simple ASP.NET Core view. The model values are used here, and the id values are added to the DOM elements for the jQuery Javascript code. This is then reloaded with the correct values on each change of a DOM element with the id = ‘updateevent’.

@using AspNetCoreMvcDynamicViews.Views.Shared.Components.ConfigueSectionA
@model ConfigueSectionAGetModel

<div class="form-group row">
    <label class="col-sm-5 col-form-label font-weight-bold">LengthA</label>
    <input class="col-sm-5 form-control updateevent" asp-for="LengthA" type="number" min="5" max="400" />
</div>

<div class="form-group row">
    <label class="col-sm-5 col-form-label font-weight-bold">@Model.LengthB</label>
    <input class="col-sm-5 form-control updateevent" asp-for="LengthB" type="number" min="5" max="400" />
</div>

<div class="form-group row">
    <label class="col-sm-5 col-form-label font-weight-bold">LengthAB</label>
    <label class="col-sm-5 form-control ">@Model.LengthAB</label>
    <input readonly asp-for="LengthAB" value="@Model.LengthAB"  type="hidden" />
</div>

<div class="form-group row">
    <label class="col-sm-5 col-form-label font-weight-bold">PartType</label>
    <select class="col-sm-5 form-control updateevent" asp-items="Model.PartTypeItems" asp-for="PartType" type="text"></select>
</div>

The MVC controller implements the server code for view requests. The UpdateViewData action method calls the business logic for updating the input values.

/// <summary>
/// async partial update, set your properties here
/// </summary>
/// <param name="configueSectionAGetModel"></param>
/// <returns></returns>
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult UpdateViewData(ConfigueSectionAGetModel configueSectionAGetModel)
{
	_configureService.UpdateLengthA_LengthB(configueSectionAGetModel);
	_configureService.UpdateSelectType(configueSectionAGetModel);
	return PartialView("PartialConfigure", configueSectionAGetModel);
}

The ConfigureController class implements the action methods to support get, update and create requests.

public class ConfigureController : Controller
{
	private readonly ConfigureService _configureService;

	public ConfigureController(ConfigureService configureService)
	{
		_configureService = configureService;
	}

	/// <summary>
	/// Get a new object, used for the create
	/// </summary>
	/// <returns></returns>
	[HttpGet]
	public IActionResult Index()
	{
		return View(_configureService.GetDefaultModel());
	}

	/// <summary>
	/// create a new object
	/// </summary>
	/// <param name="configueSectionAGetModel"></param>
	/// <returns></returns>
	[HttpPost]
	[ValidateAntiForgeryToken]
	public IActionResult Create(ConfigueSectionAGetModel configueSectionAGetModel)
	{
		var model = new ConfigureSectionsModel
		{
			ConfigueSectionAGetModel = configueSectionAGetModel
		};

		if (ModelState.IsValid)
		{
			var id = _configureService.AddConfigueSectionAModel(configueSectionAGetModel);
			return Redirect($"Update/{id}");
		}

		return View("Index", model);
	}

Build and run the application and the example can be called using:

https://localhost:44306/Configure

Links:

https://docs.microsoft.com/en-us/aspnet/core/mvc/overview?view=aspnetcore-2.1

https://dotnetthoughts.net/jquery-unobtrusive-ajax-helpers-in-aspnet-core/

http://www.talkingdotnet.com/handle-ajax-requests-in-asp-net-core-razor-pages/

Is Active Route Tag Helper for ASP.NET MVC Core with Razor Page support

$
0
0

Ben Cull did an excellent tag helper which makes it easy to set the active class element using the route data from an ASP.NET Core MVC application. This blog uses this and extends the implementation with support for Razor Pages.

Original blog: Is Active Route Tag Helper for ASP.NET MVC Core by Ben Cull

The IHttpContextAccessor contextAccessor is added to the existing code from Ben. This is required so that the actual active Page can be read from the URL. The Page property is also added so that the selected page can be read from the HTML element.

The ShouldBeActive method then checks if an ASP.NET Core MVC route is used, or a Razor Page. Depending on this, the URL Path is checked and compared with the Razor Page, or like in the original helper, the MVC routing is used to set, reset the active class.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System;
using System.Collections.Generic;
using System.Linq;

namespace DamienbodTaghelpers
{
    [HtmlTargetElement(Attributes = "is-active-route")]
    public class ActiveRouteTagHelper : TagHelper
    {
        private readonly IHttpContextAccessor _contextAccessor;

        public ActiveRouteTagHelper(IHttpContextAccessor contextAccessor)
        {
            _contextAccessor = contextAccessor;
        }

        private IDictionary<string, string> _routeValues;

        /// <summary>The name of the action method.</summary>
        /// <remarks>Must be <c>null</c> if <see cref="P:Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper.Route" /> is non-<c>null</c>.</remarks>
        [HtmlAttributeName("asp-action")]
        public string Action { get; set; }

        /// <summary>The name of the controller.</summary>
        /// <remarks>Must be <c>null</c> if <see cref="P:Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper.Route" /> is non-<c>null</c>.</remarks>
        [HtmlAttributeName("asp-controller")]
        public string Controller { get; set; }

        [HtmlAttributeName("asp-page")]
        public string Page { get; set; }
        
        /// <summary>Additional parameters for the route.</summary>
        [HtmlAttributeName("asp-all-route-data", DictionaryAttributePrefix = "asp-route-")]
        public IDictionary<string, string> RouteValues
        {
            get
            {
                if (this._routeValues == null)
                    this._routeValues = (IDictionary<string, string>)new Dictionary<string, string>((IEqualityComparer<string>)StringComparer.OrdinalIgnoreCase);
                return this._routeValues;
            }
            set
            {
                this._routeValues = value;
            }
        }

        /// <summary>
        /// Gets or sets the <see cref="T:Microsoft.AspNetCore.Mvc.Rendering.ViewContext" /> for the current request.
        /// </summary>
        [HtmlAttributeNotBound]
        [ViewContext]
        public ViewContext ViewContext { get; set; }

        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            base.Process(context, output);

            if (ShouldBeActive())
            {
                MakeActive(output);
            }

            output.Attributes.RemoveAll("is-active-route");
        }

        private bool ShouldBeActive()
        {
            string currentController = string.Empty;
            string currentAction = string.Empty;

            if (ViewContext.RouteData.Values["Controller"] != null)
            {
                currentController = ViewContext.RouteData.Values["Controller"].ToString();
            }

            if (ViewContext.RouteData.Values["Action"] != null)
            {
                currentAction = ViewContext.RouteData.Values["Action"].ToString();
            }

            if(Controller != null)
            {
                if (!string.IsNullOrWhiteSpace(Controller) && Controller.ToLower() != currentController.ToLower())
                {
                    return false;
                }

                if (!string.IsNullOrWhiteSpace(Action) && Action.ToLower() != currentAction.ToLower())
                {
                    return false;
                }
            }

            if (Page != null)
            {
                if (!string.IsNullOrWhiteSpace(Page) && Page.ToLower() != _contextAccessor.HttpContext.Request.Path.Value.ToLower())
                {
                    return false;
                }
            }

            foreach (KeyValuePair<string, string> routeValue in RouteValues)
            {
                if (!ViewContext.RouteData.Values.ContainsKey(routeValue.Key) ||
                    ViewContext.RouteData.Values[routeValue.Key].ToString() != routeValue.Value)
                {
                    return false;
                }
            }

            return true;
        }

        private void MakeActive(TagHelperOutput output)
        {
            var classAttr = output.Attributes.FirstOrDefault(a => a.Name == "class");
            if (classAttr == null)
            {
                classAttr = new TagHelperAttribute("class", "active");
                output.Attributes.Add(classAttr);
            }
            else if (classAttr.Value == null || classAttr.Value.ToString().IndexOf("active") < 0)
            {
                output.Attributes.SetAttribute("class", classAttr.Value == null
                    ? "active"
                    : classAttr.Value.ToString() + " active");
            }
        }
    }
}

The IHttpContextAccessor needs the be added to the IoC in the Startup class.

public void ConfigureServices(IServiceCollection services)
{
  services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();

In the _viewImports, add the tag helper namespace which matches the namespace used in the class.

@addTagHelper *, taghelpNamespaceUsedInYourProject

Add the tag helper can be used in the razor views which will work for both MVC views and also Razor Page views.

    <ul class="nav nav-pills flex-column">
            <li class="nav-item">
                <a is-active-route class="nav-link" asp-action="Index" asp-controller="A_MVC_Controller">This is a MVC route</a>
            </li>
    
            <li class="nav-item">
                <a is-active-route class="nav-link" asp-page="/MyRazorPage">Some Razor Page</a>
            </li>
    </ul>

Links:

https://benjii.me/2017/01/is-active-route-tag-helper-asp-net-mvc-core/

https://docs.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-2.1

Viewing all 96 articles
Browse latest View live


<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>