What I'm trying to do is very simple, at least so it seemed at first.
I need to protect static HTML files (and possibly other static resources) behind simple Cookie Authentication. Any request to these files from user agent without carrying authentication cookie should be rejected with return HTTP 401 status code.
And I need to do this on top of Owin because of project restriction. I'm dealing with legacy project which have not yet migrate to ASP.NET Core.
Here's my Project directory structure.
OwinWebProject
|- wwwroot
| |- index.html
| |- login.html
|- Startup.cs
|- Web.config
Below is my Owin Startup class. What I do here is handling these paths:
/login which simply establish wrap some simple claims in an identity, and then tells the Authentication Manager to set the authentication state if that identity as 'logged in'. No actual identity checks here for simplicity
/logout does the opposite of /login. It clears the authentication state.
/secret to test whether /login and /logout works
/hello just for testing if Owin middlewares are properly configured.
I also use Microsoft.Owin.StaticFiles.FileServerExtensions to serve my static files.
// using statements omitted
[assembly: OwinStartup(typeof(OwinWebProject.Startup))]
namespace OwinWebProject
{
public class Startup
{
public void Configuration(IAppBuilder app)
{
// BEGIN Configure Cookie Authentication
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
CookieName = "MyApp",
AuthenticationType = CookieAuthenticationDefaults.AuthenticationType,
SlidingExpiration = true,
});
// END Configure Cookie Authentication
// BEGIN Configure File Server to serve static resources
var wwwRootFS = new PhysicalFileSystem(#".\wwwroot");
var fileServerOpts = new FileServerOptions
{
EnableDefaultFiles = true,
FileSystem = wwwRootFS,
};
fileServerOpts.DefaultFilesOptions.DefaultFileNames = new[] { "index.html" };
fileServerOpts.StaticFileOptions.ServeUnknownFileTypes = true;
app.UseFileServer(fileServerOpts);
// END Configure File Server
// BEGIN create route handlers for
// setting the auth state (i.e logging in),
// clearing the auth state (i.e logging out),
// and for testing authentication state
// this route is unguarded
app.Map("/hello", (config) =>
config.Run(context => {
return context.Response.WriteAsync("It works!");
})
);
// this route is for testing authentication state
app.Map("/secret", (config) =>
config.Run(context => {
if (context.Request.User == null || !context.Request.User.Identity.IsAuthenticated)
{
context.Response.StatusCode = 401;
return Task.FromResult(0);
}
return context.Response.WriteAsync("If you can read this, it means you have logged in");
})
);
// this route is for setting the authentication state
app.Map("/login", (config) =>
config.Run(context => {
var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationType);
identity.AddClaim(new Claim(ClaimTypes.Name, "fakeuser"));
var authManager = context.Authentication;
authManager.SignIn(identity);
context.Response.Redirect("/index.html");
return Task.FromResult(0);
})
);
// this route is for clearing the authentication state
app.Map("/logout", (config) =>
config.Run(context => {
var authManager = context.Authentication;
authManager.SignOut(CookieAuthenticationDefaults.AuthenticationType);
context.Response.Redirect("/");
return Task.FromResult(0);
})
);
// END create route handlers
}
}
}
Here is index.html.
<html>
<body>
If you can see this that mean you are authenticated
<p>Logout</p>
</body>
</html>
Here is login.html
<html>
<body>
<p>Click here to log in</p>
</body>
</html>
Requesting '/secret' without first requesting '/login' will give me HTTP 401 Unauthorized because I can easily check for authentication state in the path handler and respond accordingly. This means my Cookie Authentication setting works.
If I want, I can even configure CookieAuthenticationOptions to automatically replace the HTTP 401 with HTTP redirects to login page, but this is not my current problem.
My problem is, is there a way to also enforce the same authentication for all static contents under wwwroot?
To add, another further question is how to exclude just some static resources. Because obviously from above example, login.html should not be protected by authentication.
Related
I'm using Azure Active Directory to provide authentication to the Backoffice on my website running Umbraco version 11.0.
This is working nicely and I can log in but I want to improve the experience by using app roles within Azure to manage the user's group within Umbraco.
My Azure setup
I've created an App Registration within Azure with the following configuration:
Added a Redirection URI:
URI: https://localhost:44391/umbraco-signin-microsoft/
Enabled Access tokens (used for implicit flows)
Enabled ID tokens (used for implicit and hybrid flows)
Supported account types: Accounts in this organizational directory only (Example only - Single tenant)
Added App Roles
Administrator
Editor
In Enterprise Applications, I've also added the App Roles above to my users:
My code
Login Provider
namespace Example.Api.Features.Authentication.Extensions;
public static class UmbracoBuilderExtensions
{
public static IUmbracoBuilder ConfigureAuthentication(this IUmbracoBuilder builder)
{
builder.Services.ConfigureOptions<OpenIdConnectBackOfficeExternalLoginProviderOptions>();
builder.AddBackOfficeExternalLogins(logins =>
{
const string schema = MicrosoftAccountDefaults.AuthenticationScheme;
logins.AddBackOfficeLogin(
backOfficeAuthenticationBuilder =>
{
backOfficeAuthenticationBuilder.AddMicrosoftAccount(
// the scheme must be set with this method to work for the back office
backOfficeAuthenticationBuilder.SchemeForBackOffice(OpenIdConnectBackOfficeExternalLoginProviderOptions.SchemeName) ?? string.Empty,
options =>
{
//By default this is '/signin-microsoft' but it needs to be changed to this
options.CallbackPath = "/umbraco-signin-microsoft/";
//Obtained from the AZURE AD B2C WEB APP
options.ClientId = "CLIENT_ID";
//Obtained from the AZURE AD B2C WEB APP
options.ClientSecret = "CLIENT_SECRET";
options.TokenEndpoint = $"https://login.microsoftonline.com/TENANT/oauth2/v2.0/token";
options.AuthorizationEndpoint = $"https://login.microsoftonline.com/TENANT/oauth2/v2.0/authorize";
});
});
});
return builder;
}
}
Auto-linking accounts
namespace Example.Api.Features.Configuration;
public class OpenIdConnectBackOfficeExternalLoginProviderOptions : IConfigureNamedOptions<BackOfficeExternalLoginProviderOptions>
{
public const string SchemeName = "OpenIdConnect";
public void Configure(string name, BackOfficeExternalLoginProviderOptions options)
{
if (name != "Umbraco." + SchemeName)
{
return;
}
Configure(options);
}
public void Configure(BackOfficeExternalLoginProviderOptions options)
{
options.AutoLinkOptions = new ExternalSignInAutoLinkOptions(
// must be true for auto-linking to be enabled
autoLinkExternalAccount: true,
// Optionally specify default user group, else
// assign in the OnAutoLinking callback
// (default is editor)
defaultUserGroups: new[] { Constants.Security.EditorGroupAlias },
// Optionally you can disable the ability to link/unlink
// manually from within the back office. Set this to false
// if you don't want the user to unlink from this external
// provider.
allowManualLinking: false
)
{
// Optional callback
OnAutoLinking = (autoLinkUser, loginInfo) =>
{
// You can customize the user before it's linked.
// i.e. Modify the user's groups based on the Claims returned
// in the externalLogin info
autoLinkUser.IsApproved = true;
},
OnExternalLogin = (user, loginInfo) =>
{
// You can customize the user before it's saved whenever they have
// logged in with the external provider.
// i.e. Sync the user's name based on the Claims returned
// in the externalLogin info
return true; //returns a boolean indicating if sign in should continue or not.
}
};
// Optionally you can disable the ability for users
// to login with a username/password. If this is set
// to true, it will disable username/password login
// even if there are other external login providers installed.
options.DenyLocalLogin = true;
// Optionally choose to automatically redirect to the
// external login provider so the user doesn't have
// to click the login button. This is
options.AutoRedirectLoginToExternalProvider = true;
}
}
In this file, I'd ideally do as the comment says and i.e. Modify the user's groups based on the Claims returned in the externalLogin info.
Also registered in my Startup file
services.AddUmbraco(_env, _config)
.AddBackOffice()
.AddWebsite()
.AddComposers()
.ConfigureAuthentication()
.Build();
I've attempted to give the following permissions to the application, with no luck:
Current state of play is that I can login just fine but if I debug externalInfo, there's nothing in there about the users having either the Administrator or Editor App Role as configured above.
My gut feeling is that I'm missing something with the Azure Active Directory setup but I've tried a few different configurations and can't seem to get the App Roles to come back.
Thanks,
Ben
EDIT - 15.02.2023:
I can see that the roles come back when I hit the https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token endpoint using client_credentials as the grant_type. It looks like the .NET application using authorization_code instead. I've decoded the token retrieved from this and it doesn't contain the roles.
I wonder if there's some kind of configuration on the .NET application that allows me to add the roles.
To get App Roles in token claims, you can use client credentials flow to generate access tokens by granting admin consent.
I tried to reproduce the same in my environment via Postman and got the below results:
I registered one Azure AD web application and created App roles like below:
Now I assigned these App roles to users under it's Enterprise application like below:
Add these App roles in API permissions of application like below:
You can see App roles under Application permissions like below:
Make sure to grant admin consent to above permissions like this:
While generating access token, scope should be your Application ID URI ending with /.default
Now, I generated access token using client credentials flow via Postman with below parameters:
POST https://login.microsoftonline.com/<tenantID>/oauth2/v2.0/token
client_id: <appID>
grant_type:client_credentials
scope: api://<appID>/.default
client_secret: secret
Response:
When I decoded the above token in jwt.ms, I got App roles in roles claim successfully like below:
Note that App roles are Application permissions that will work only with flows like client credentials which do not involve user interaction.
So, if you use delegated flows like authorization code flow, username password flow, etc..., you won't get App roles in token claims.
UPDATE:
You can use below c# code in getting access token from client credentials flow like this:
using Microsoft.Identity.Client;
var clientID = "bbb739ad-98a4-4566-8408-dxxxxxxxx3b";
var clientSecret = "K.k8Q~hwtxxxxxxxxxxxxxxxU";
var tenantID = "fb134080-e4d2-45f4-9562-xxxxxx";
var authority = $"https://login.microsoftonline.com/{tenantID}";
var clientApplication = ConfidentialClientApplicationBuilder.Create(clientID)
.WithClientSecret(clientSecret)
.WithAuthority(authority)
.Build();
var scopes = new string[] { "api://bbb739ad-98a4-4566-8408-xxxxxx/.default" };
var authenticationResult = await clientApplication.AcquireTokenForClient(scopes)
.ExecuteAsync()
.ConfigureAwait(false);
var accesstoken = authenticationResult.AccessToken;
Console.WriteLine(accesstoken);
Response:
When I decoded the above token, it has roles claim with App roles like below:
To solve this, I ended up swapping out the AddMicrosoftAccount AuthenticationBuilder in favour of AddOpenIdConnect. This appears to respect the claims in the tokens.
This is the code I am now using in the ConfigureAuthentication method.
public static IUmbracoBuilder ConfigureAuthentication(this IUmbracoBuilder builder)
{
// Register OpenIdConnectBackOfficeExternalLoginProviderOptions here rather than require it in startup
builder.Services.ConfigureOptions<OpenIdConnectBackOfficeExternalLoginProviderOptions>();
builder.AddBackOfficeExternalLogins(logins =>
{
logins.AddBackOfficeLogin(
backOfficeAuthenticationBuilder =>
{
backOfficeAuthenticationBuilder.AddOpenIdConnect(
// The scheme must be set with this method to work for the back office
backOfficeAuthenticationBuilder.SchemeForBackOffice(OpenIdConnectBackOfficeExternalLoginProviderOptions.SchemeName),
options =>
{
options.CallbackPath = "/umbraco-signin-microsoft/";
// use cookies
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
// pass configured options along
options.Authority = "https://login.microsoftonline.com/{tenantId}/v2.0";
options.ClientId = "{clientId}";
options.ClientSecret = "{clientSecret}";
// Use the authorization code flow
options.ResponseType = OpenIdConnectResponseType.Code;
options.AuthenticationMethod = OpenIdConnectRedirectBehavior.RedirectGet;
// map claims
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "role";
options.RequireHttpsMetadata = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
options.UsePkce = true;
options.Scope.Add("email");
});
});
});
return builder;
}
I have 2 web applications that both share the same main level domain as mentioned below so I can share cookies. Web.conifg in both the projects have same machine key and validation key. Since, I want to use Identity and NOT forms authenticaiton, I do not have an node in either of my web.config file. I am able to create the Auth cookie successully from SSO and can view the authorized pages in SSO but I am still redirected to SSO login when I try to access an authorized view in MVC project.
sso.domain.com - MVC Project
mvc.domain.com - MVC Project
I have a startup.cs file in my SSO and MVC project like below:
public partial class Startup
{
public void Configuration(IAppBuilder app)
{
ConfigureAuth(app);
}
// For more information on configuring authentication, please visit http://go.microsoft.com/fwlink/?LinkId=301864
public void ConfigureAuth(IAppBuilder app)
{
app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
// Enable the application to use a cookie to store information for the signed in user
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
ExpireTimeSpan = TimeSpan.FromMinutes(3),
LoginPath = new PathString("/Login"),
CookieName = "MyCookieName",
CookieDomain = ".domain.com"
});
app.UseTwoFactorRememberBrowserCookie(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie);
AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.NameIdentifier;
}
}
Below is the code I have so far in the SSO project under the AccountController.cs. I call the IdentitySignin function below on validating the user against the database which creates the cookie:
private void IdentitySignin(string userId, string name, string providerKey = null, bool isPersistent = false)
{
var claims = new List<Claim>();
// create *required* claims
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId));
claims.Add(new Claim(ClaimTypes.Name, name));
var identity = new ClaimsIdentity(claims, DefaultAuthenticationTypes.ApplicationCookie);
//get the expiry minutes from config or use the default value of 30 minutes
double expiryMinutes;
expiryMinutes = double.TryParse(ConfigurationManager.AppSettings["AuthCookieExpiryMinutes"], out expiryMinutes) ? expiryMinutes : 30;
// add to user here!
AuthenticationManager.SignIn(new AuthenticationProperties()
{
AllowRefresh = true,
IsPersistent = isPersistent,
ExpiresUtc = DateTime.UtcNow.AddMinutes(expiryMinutes),
IssuedUtc = DateTime.UtcNow
}, identity);
}
private void IdentitySignout()
{
AuthenticationManager.SignOut(DefaultAuthenticationTypes.ApplicationCookie, DefaultAuthenticationTypes.ExternalCookie);
}
private IAuthenticationManager AuthenticationManager
{
get
{
return HttpContext.GetOwinContext().Authentication;
}
}
private async Task<string> GetVerifiedUserIdAsync()
{
var result = await AuthenticationManager.AuthenticateAsync(
DefaultAuthenticationTypes.ApplicationCookie);
if (result != null && result.Identity != null
&& !String.IsNullOrEmpty(result.Identity.GetUserId()))
{
return result.Identity.GetUserId();
}
return null;
}
So, I figured out the reason why Single Sign On wasn't working between 2 MVC applications in-spite of both of them sharing the same machine and validation keys.
My SSO MVC application and the other MVC application were both using different version of OWIN and ASP.NET Identity DLLs. I used the Nuget to update the DLLS in one project but did not do the update in the other one.
I hope this helps someone who runs into this problem.
Just as an FYI, to share ASP.NET Identity Authentication between more than 1 application, make sure you have below things in EACH APP:
Same Machine Keys and Validation Keys in the web.config file
Same versions of OWIN and ASP.NET IDENTITY DLLs
SAME COOKIE NAME AND COOKIE DOMAIN in the Startup.cs
Both app needs to be on same domain to share the auth cookie
Most likely, you haven't set a shared machine key. The auth cookie is encrypted, and unless both sites share the same machine key, one cannot decrypt what the other encrypted. Add the following to both project's Web.config:
<sytem.web>
...
<machineKey validation="HMACSHA256" validationKey="[validationKey]" decryptionKey="[decryptionKey]" compatibilityMode="Framework45" />
To generate the keys, in IIS, click on the server in the left pane, and then the Machine Key control panel item. Choose your validation method (above, I've used HMACSHA256). I don't recommend using the default of SHA1 as that's ridiculously easy to crack. Then, on the right "Actions" panel, click "Generate Keys". Copy the two text box values to the appropriate attributes of this config element, and make sure they are the same for all projects that need to share the auth cookie.
I've asked a question before and the answer that was given was correct but the farther I go down this rabbit hole the more I realize; I don't think I was asking the right question.
Let me just explain this in the most simple terms I can... I have a AngularJS single page app (client), that points at an asp.net webapi (OWIN) site (Resource server?), and a separate asp.net "authorization/authentiation" server.
The auth server will provide authentication and authorization for multiple applications. I need to be able to use the Authorize attribute in the resource server, as well as get a token from from angular. I also need to use windows authentication (integrated) for everything, no usernames or passwords. The claims information is stored in a database and needs to be added to the token.
I've done a SSO style claims authoriztion implementation in asp.net core using openiddict with JwtBearerToken and 'password flow?' And wanted to try to do something similar (token, etc). I have a basic understanding of how that works from my previous implmentation, but I am completely lost trying to figure out how to get JWT working with Windows Auth. The answer to my previous question provided some good suggestions but I am having a hard time seeing how that applies in this scenario.
Currently I have been trying to get IdentityServer3 to do this, using the WindowsAuthentication extensions, mainly pulled from the samples. But I am really struggling to tie this together with the client and actually get something working. The current client and server code is below, mind you I really don't know if this is even close to the correct solution.
Client:
app.UseIdentityServerBearerTokenAuthentication(new IdentityServerBearerTokenAuthenticationOptions
{
AuthenticationMode = AuthenticationMode.Passive,
AuthenticationType = "windows",
Authority = "http://localhost:21989",
ClientId = "mvc.owin.implicit",
ClientSecret = "api-secret",
RequiredScopes = new[] { "api" }
});
AuthServer:
app.Map("/windows", ConfigureWindowsTokenProvider);
app.Use(async (context, next) =>
{
if (context.Request.Uri.AbsolutePath.EndsWith("/token", StringComparison.OrdinalIgnoreCase))
{
if (context.Authentication.User == null ||
!context.Authentication.User.Identity.IsAuthenticated)
{
context.Response.StatusCode = 401;
return;
}
}
await next();
});
var factory = new IdentityServerServiceFactory()
.UseInMemoryClients(Clients.Get())
.UseInMemoryScopes(Scopes.Get());
var options = new IdentityServerOptions
{
SigningCertificate = Certificate.Load(),
Factory = factory,
AuthenticationOptions = new AuthenticationOptions
{
EnableLocalLogin = false,
IdentityProviders = ConfigureIdentityProviders
},
RequireSsl = false
};
app.UseIdentityServer(options);
private static void ConfigureWindowsTokenProvider(IAppBuilder app)
{
app.UseWindowsAuthenticationService(new WindowsAuthenticationOptions
{
IdpReplyUrl = "http://localhost:21989",
SigningCertificate = Certificate.Load(),
EnableOAuth2Endpoint = false
});
}
private void ConfigureIdentityProviders(IAppBuilder app, string signInAsType)
{
var wsFederation = new WsFederationAuthenticationOptions
{
AuthenticationType = "windows",
Caption = "Windows",
SignInAsAuthenticationType = signInAsType,
MetadataAddress = "http://localhost:21989",
Wtrealm = "urn:idsrv3"
};
app.UseWsFederationAuthentication(wsFederation);
}
EDIT: I see the auth endpoints requests for "/.well-known/openid-configuration" as well as "/.well-known/jwks" and I have the Authorize attribute on a controller action which is being called, but I dont see anything else happening on the auth side. I also added a ICustomClaimsProvider implmentation to the usewindowsauthservice WindowsAuthenticationOptions but that doesnt even get called.
I've done a SSO style claims authoriztion implementation in asp.net core using openiddict with JwtBearerToken and 'password flow?'
If you were to use OpenIddict with Windows authentication, it would be quite easy to implement using the OAuth2/OpenID Connect implicit flow (which is the most appropriate flow for a JS app), without needing any WS-Federation proxy:
Startup configuration:
public void ConfigureServices(IServiceCollection services)
{
// Register the OpenIddict services.
services.AddOpenIddict(options =>
{
// Register the Entity Framework stores.
options.AddEntityFrameworkCoreStores<ApplicationDbContext>();
// Register the ASP.NET Core MVC binder used by OpenIddict.
// Note: if you don't call this method, you won't be able to
// bind OpenIdConnectRequest or OpenIdConnectResponse parameters.
options.AddMvcBinders();
// Enable the authorization endpoint.
options.EnableAuthorizationEndpoint("/connect/authorize");
// Enable the implicit flow.
options.AllowImplicitFlow();
// During development, you can disable the HTTPS requirement.
options.DisableHttpsRequirement();
// Register a new ephemeral key, that is discarded when the application
// shuts down. Tokens signed using this key are automatically invalidated.
// This method should only be used during development.
options.AddEphemeralSigningKey();
});
// Note: when using WebListener instead of IIS/Kestrel, the following lines must be uncommented:
//
// services.Configure<WebListenerOptions>(options =>
// {
// options.ListenerSettings.Authentication.AllowAnonymous = true;
// options.ListenerSettings.Authentication.Schemes = AuthenticationSchemes.Negotiate;
// });
}
Authorization controller:
public class AuthorizationController : Controller
{
// Warning: extreme caution must be taken to ensure the authorization endpoint is not included in a CORS policy
// that would allow an attacker to force a victim to silently authenticate with his Windows credentials
// and retrieve an access token using a cross-domain AJAX call. Avoiding CORS is strongly recommended.
[HttpGet("~/connect/authorize")]
public async Task<IActionResult> Authorize(OpenIdConnectRequest request)
{
// Retrieve the Windows principal: if a null value is returned, apply an HTTP challenge
// to allow IIS/WebListener to initiate the unmanaged integrated authentication dance.
var principal = await HttpContext.Authentication.AuthenticateAsync(IISDefaults.Negotiate);
if (principal == null)
{
return Challenge(IISDefaults.Negotiate);
}
// Note: while the principal is always a WindowsPrincipal object when using Kestrel behind IIS,
// a WindowsPrincipal instance must be manually created from the WindowsIdentity with WebListener.
var ticket = CreateTicket(request, principal as WindowsPrincipal ?? new WindowsPrincipal((WindowsIdentity) principal.Identity));
// Immediately return an authorization response without displaying a consent screen.
return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
}
private AuthenticationTicket CreateTicket(OpenIdConnectRequest request, WindowsPrincipal principal)
{
// Create a new ClaimsIdentity containing the claims that
// will be used to create an id_token, a token or a code.
var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme);
// Note: the JWT/OIDC "sub" claim is required by OpenIddict
// but is not automatically added to the Windows principal, so
// the primary security identifier is used as a fallback value.
identity.AddClaim(OpenIdConnectConstants.Claims.Subject, principal.GetClaim(ClaimTypes.PrimarySid));
// Note: by default, claims are NOT automatically included in the access and identity tokens.
// To allow OpenIddict to serialize them, you must attach them a destination, that specifies
// whether they should be included in access tokens, in identity tokens or in both.
foreach (var claim in principal.Claims)
{
// In this sample, every claim is serialized in both the access and the identity tokens.
// In a real world application, you'd probably want to exclude confidential claims
// or apply a claims policy based on the scopes requested by the client application.
claim.SetDestinations(OpenIdConnectConstants.Destinations.AccessToken,
OpenIdConnectConstants.Destinations.IdentityToken);
// Copy the claim from the Windows principal to the new identity.
identity.AddClaim(claim);
}
// Create a new authentication ticket holding the user identity.
return new AuthenticationTicket(
new ClaimsPrincipal(identity),
new AuthenticationProperties(),
OpenIdConnectServerDefaults.AuthenticationScheme);
}
}
A similar scenario can be implemented in legacy ASP.NET apps using the OWIN/Katana version of ASOS, the OpenID Connect server middleware behind OpenIddict:
public class Startup
{
public void Configuration(IAppBuilder app)
{
app.UseOpenIdConnectServer(options =>
{
// Register a new ephemeral key, that is discarded when the application
// shuts down. Tokens signed using this key are automatically invalidated.
// This method should only be used during development.
options.SigningCredentials.AddEphemeralKey();
// Enable the authorization endpoint.
options.AuthorizationEndpointPath = new PathString("/connect/authorize");
// During development, you can disable the HTTPS requirement.
options.AllowInsecureHttp = true;
// Implement the ValidateAuthorizationRequest event to validate the response_type,
// the client_id and the redirect_uri provided by the client application.
options.Provider.OnValidateAuthorizationRequest = context =>
{
if (!context.Request.IsImplicitFlow())
{
context.Reject(
error: OpenIdConnectConstants.Errors.UnsupportedResponseType,
description: "The provided response_type is invalid.");
return Task.FromResult(0);
}
if (!string.Equals(context.ClientId, "spa-application", StringComparison.Ordinal))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidClient,
description: "The provided client_id is invalid.");
return Task.FromResult(0);
}
if (!string.Equals(context.RedirectUri, "http://spa-app.com/redirect_uri", StringComparison.Ordinal))
{
context.Reject(
error: OpenIdConnectConstants.Errors.InvalidClient,
description: "The provided redirect_uri is invalid.");
return Task.FromResult(0);
}
context.Validate();
return Task.FromResult(0);
};
// Implement the HandleAuthorizationRequest event to return an implicit authorization response.
options.Provider.OnHandleAuthorizationRequest = context =>
{
// Retrieve the Windows principal: if a null value is returned, apply an HTTP challenge
// to allow IIS/SystemWeb to initiate the unmanaged integrated authentication dance.
var principal = context.OwinContext.Authentication.User as WindowsPrincipal;
if (principal == null)
{
context.OwinContext.Authentication.Challenge();
return Task.FromResult(0);
}
// Create a new ClaimsIdentity containing the claims that
// will be used to create an id_token, a token or a code.
var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationType);
// Note: the JWT/OIDC "sub" claim is required by OpenIddict
// but is not automatically added to the Windows principal, so
// the primary security identifier is used as a fallback value.
identity.AddClaim(OpenIdConnectConstants.Claims.Subject, principal.GetClaim(ClaimTypes.PrimarySid));
// Note: by default, claims are NOT automatically included in the access and identity tokens.
// To allow OpenIddict to serialize them, you must attach them a destination, that specifies
// whether they should be included in access tokens, in identity tokens or in both.
foreach (var claim in principal.Claims)
{
// In this sample, every claim is serialized in both the access and the identity tokens.
// In a real world application, you'd probably want to exclude confidential claims
// or apply a claims policy based on the scopes requested by the client application.
claim.SetDestinations(OpenIdConnectConstants.Destinations.AccessToken,
OpenIdConnectConstants.Destinations.IdentityToken);
// Copy the claim from the Windows principal to the new identity.
identity.AddClaim(claim);
}
context.Validate(identity);
return Task.FromResult(0);
};
});
}
}
The client-side code shouldn't be different from any other JS application using the implicit flow. You can take a look at this sample to see how you can implement it with the oidc-client JS library: https://github.com/openiddict/openiddict-samples/tree/master/samples/ImplicitFlow/AureliaApp
So ultimately the whole point here was to augment claims on the existing ClaimsPrincipal with claims from the database and hopefully be able to use JWT's in the javascript. I was unable to get that to work using IdentityServer3. I ended up rolling my own rudimentary solution by implementing IAuthenticationFilter and IAuthorizationFilter using an attribute on the actions to supply the claim name.
First the authorize attribute does nothing but take the name of the claim that the user should have to access the action.
public class AuthorizeClaimAttribute : Attribute
{
public string ClaimValue;
public AuthorizeClaimAttribute(string value)
{
ClaimValue = value;
}
}
Then the Authorize filter which does nothing but check to see if the user has the claim from the attribute.
public class AuthorizeClaimFilter : AuthorizeAttribute, IAuthorizationFilter
{
private readonly string _claimValue;
public AuthorizeClaimFilter(string claimValue)
{
_claimValue = claimValue;
}
public override async Task OnAuthorizationAsync(HttpActionContext actionContext, CancellationToken cancellationToken)
{
var p = actionContext.RequestContext.Principal as ClaimsPrincipal;
if(!p.HasClaim("process", _claimValue))
HandleUnauthorizedRequest(actionContext);
await Task.FromResult(0);
}
protected override void HandleUnauthorizedRequest(HttpActionContext actionContext)
{
actionContext.Response = new HttpResponseMessage(HttpStatusCode.Forbidden);
}
}
The Authentication filter which calls the webapi endpoint (which is using windows authentication) to get the users list of custom "claims" from the database. The WebAPI is just a standard webapi instance, nothing special at all.
public class ClaimAuthenticationFilter : ActionFilterAttribute, IAuthenticationFilter
{
public ClaimAuthenticationFilter()
{
}
public async Task AuthenticateAsync(HttpAuthenticationContext context, CancellationToken cancellationToken)
{
if (context.Principal != null && context.Principal.Identity.IsAuthenticated)
{
var windowsPrincipal = context.Principal as WindowsPrincipal;
var handler = new HttpClientHandler()
{
UseDefaultCredentials = true
};
HttpClient client = new HttpClient(handler);
client.BaseAddress = new Uri("http://localhost:21989");// to be stored in config
var response = await client.GetAsync("/Security");
var contents = await response.Content.ReadAsStringAsync();
var claimsmodel = JsonConvert.DeserializeObject<List<ClaimsModel>>(contents);
if (windowsPrincipal != null)
{
var name = windowsPrincipal.Identity.Name;
var identity = new ClaimsIdentity();
foreach (var claim in claimsmodel)
{
identity.AddClaim(new Claim("process", claim.ClaimName));
}
var claimsPrincipal = new ClaimsPrincipal(identity);
context.Principal = claimsPrincipal;
}
}
await Task.FromResult(0);
}
public async Task ChallengeAsync(HttpAuthenticationChallengeContext context, CancellationToken cancellationToken)
{
var challenge = new AuthenticationHeaderValue("Negotiate");
context.Result = new ResultWithChallenge(challenge, context.Result);
await Task.FromResult(0);
}
}
The filters are bound to the attribute using my DI framework (ninject in this case).
this.BindHttpFilter<AuthorizeClaimFilter>(FilterScope.Action)
.WhenActionMethodHas<AuthorizeClaimAttribute>()
.WithConstructorArgumentFromActionAttribute<AuthorizeClaimAttribute>("claimValue", o => o.ClaimValue);
This works for my purposes, and the web api endpoint consumable both in the WebAPI instance and in the AngularJS app. However it is obviously NOT ideal. I really would have preferred to use 'real' authentication/authorization processes. I hesitate to say this is the answer to the question, but it is the only solution I could come up with the time that I had to make something work.
My ASP.NET 5 (MVC 6 + beta7) web application (MVC + WebAPI) is required to get back an access_token from WebAPI login calls.
So far, from googling, I have created the following code for startup.cs:
app.UseOAuthBearerAuthentication(options => {
options.AutomaticAuthentication = true;
options.Audience = "http://localhost:62100/";
options.Authority = "http://localhost:62100/";
});
My client side is:
var login = function ()
{
var url = "http://localhost:62100/";
var data = $("#userData").serialize();
data = data + "&grant_type=password";
$.post(url, data)
.success(saveAccessToken)
.always(showResponse);
return false;
};
Is it required to use UseOpenIdConnectServer? If so, how do I use SigningCredentials so that I get a token (e.g. MVC5 ApplicationOAuthProvider)?
Please note that my site is simple demo HTTP site and I do not need any SSL.
Is it required to use UseOpenIdConnectServer?
Using AspNet.Security.OpenIdConnect.Server is not "required". You're - of course - free to opt for another server (like IdentityServer) or for a custom solution.
Being the main developer behind aspnet-contrib, I'm not really objective, so I'll necessarily suggest going with app.UseOpenIdConnectServer().
If so, how do I use SigningCredentials so that I get a token (e.g. MVC5 ApplicationOAuthProvider)?
When implementing the password and using the default token type, registering a signing key/certificate is not mandatory.
Here's how you can get started:
ASP.NET Core 1.x:
Startup.cs
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication();
}
public void Configure(IApplicationBuilder app)
{
// Add a new middleware validating the encrypted
// access tokens issued by the OIDC server.
app.UseOAuthValidation();
// Add a new middleware issuing tokens.
app.UseOpenIdConnectServer(options =>
{
options.TokenEndpointPath = "/connect/token";
// Override OnValidateTokenRequest to skip client authentication.
options.Provider.OnValidateTokenRequest = context =>
{
// Reject the token requests that don't use
// grant_type=password or grant_type=refresh_token.
if (!context.Request.IsPasswordGrantType() &&
!context.Request.IsRefreshTokenGrantType())
{
context.Reject(
error: OpenIdConnectConstants.Errors.UnsupportedGrantType,
description: "Only grant_type=password and refresh_token " +
"requests are accepted by this server.");
return Task.FromResult(0);
}
// Since there's only one application and since it's a public client
// (i.e a client that cannot keep its credentials private),
// call Skip() to inform the server the request should be
// accepted without enforcing client authentication.
context.Skip();
return Task.FromResult(0);
};
// Override OnHandleTokenRequest to support
// grant_type=password token requests.
options.Provider.OnHandleTokenRequest = context =>
{
// Only handle grant_type=password token requests and let the
// OpenID Connect server middleware handle the other grant types.
if (context.Request.IsPasswordGrantType())
{
// Do your credentials validation here.
// Note: you can call Reject() with a message
// to indicate that authentication failed.
var identity = new ClaimsIdentity(context.Options.AuthenticationScheme);
identity.AddClaim(OpenIdConnectConstants.Claims.Subject, "[unique id]");
// By default, claims are not serialized
// in the access and identity tokens.
// Use the overload taking a "destinations"
// parameter to make sure your claims
// are correctly inserted in the appropriate tokens.
identity.AddClaim("urn:customclaim", "value",
OpenIdConnectConstants.Destinations.AccessToken,
OpenIdConnectConstants.Destinations.IdentityToken);
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(identity),
new AuthenticationProperties(),
context.Options.AuthenticationScheme);
// Call SetScopes with the list of scopes you want to grant
// (specify offline_access to issue a refresh token).
ticket.SetScopes("profile", "offline_access");
context.Validate(ticket);
}
return Task.FromResult(0);
};
});
}
}
.csproj
<ItemGroup>
<PackageReference Include="AspNet.Security.OpenIdConnect.Server" Version="1.0.2" />
</ItemGroup>
ASP.NET Core 2.x:
Startup.cs
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication()
// Add a new middleware validating the encrypted
// access tokens issued by the OIDC server.
.AddOAuthValidation()
// Add a new middleware issuing tokens.
.AddOpenIdConnectServer(options =>
{
options.TokenEndpointPath = "/connect/token";
// Override OnValidateTokenRequest to skip client authentication.
options.Provider.OnValidateTokenRequest = context =>
{
// Reject the token requests that don't use
// grant_type=password or grant_type=refresh_token.
if (!context.Request.IsPasswordGrantType() &&
!context.Request.IsRefreshTokenGrantType())
{
context.Reject(
error: OpenIdConnectConstants.Errors.UnsupportedGrantType,
description: "Only grant_type=password and refresh_token " +
"requests are accepted by this server.");
return Task.CompletedTask;
}
// Since there's only one application and since it's a public client
// (i.e a client that cannot keep its credentials private),
// call Skip() to inform the server the request should be
// accepted without enforcing client authentication.
context.Skip();
return Task.CompletedTask;
};
// Override OnHandleTokenRequest to support
// grant_type=password token requests.
options.Provider.OnHandleTokenRequest = context =>
{
// Only handle grant_type=password token requests and let the
// OpenID Connect server middleware handle the other grant types.
if (context.Request.IsPasswordGrantType())
{
// Do your credentials validation here.
// Note: you can call Reject() with a message
// to indicate that authentication failed.
var identity = new ClaimsIdentity(context.Scheme.Name);
identity.AddClaim(OpenIdConnectConstants.Claims.Subject, "[unique id]");
// By default, claims are not serialized
// in the access and identity tokens.
// Use the overload taking a "destinations"
// parameter to make sure your claims
// are correctly inserted in the appropriate tokens.
identity.AddClaim("urn:customclaim", "value",
OpenIdConnectConstants.Destinations.AccessToken,
OpenIdConnectConstants.Destinations.IdentityToken);
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(identity),
new AuthenticationProperties(),
context.Scheme.Name);
// Call SetScopes with the list of scopes you want to grant
// (specify offline_access to issue a refresh token).
ticket.SetScopes("profile", "offline_access");
context.Validate(ticket);
}
return Task.CompletedTask;
};
});
}
}
.csproj
<ItemGroup>
<PackageReference Include="AspNet.Security.OpenIdConnect.Server" Version="2.0.0-*" />
</ItemGroup>
You can also read this blog post, that explains how to implement the resource owner password grant: http://kevinchalet.com/2016/07/13/creating-your-own-openid-connect-server-with-asos-implementing-the-resource-owner-password-credentials-grant/
I have implemented a Basic Authentication Middleware for Katana (Code below).
(My client is hosted on a cross domain then the actually API).
The browser can skip the preflight request if the following conditions
are true:
The request method is GET, HEAD, or POST, and The application does not
set any request headers other than Accept, Accept-Language,
Content-Language, Content-Type, or Last-Event-ID, and The Content-Type
header (if set) is one of the following:
application/x-www-form-urlencoded multipart/form-data text/plain
In javascript I set the authentication header( with jquery, beforeSend) on all requests for the server to accept the requests. This means that above will send the Options request on all requests. I dont want that.
function make_base_auth(user, password) {
var tok = user + ':' + password;
var hash = Base64.encode(tok);
return "Basic " + hash;
}
What would I do to get around this? My idea would be to have the user information stored in a cookie when he has been authenticated.
I also saw in the katana project that are a Microsoft.Owin.Security.Cookies - is this maybe what i want instead of my own basic authentication?
BasicAuthenticationMiddleware.cs
using Microsoft.Owin;
using Microsoft.Owin.Logging;
using Microsoft.Owin.Security.Infrastructure;
using Owin;
namespace Composite.WindowsAzure.Management.Owin
{
public class BasicAuthenticationMiddleware : AuthenticationMiddleware<BasicAuthenticationOptions>
{
private readonly ILogger _logger;
public BasicAuthenticationMiddleware(
OwinMiddleware next,
IAppBuilder app,
BasicAuthenticationOptions options)
: base(next, options)
{
_logger = app.CreateLogger<BasicAuthenticationMiddleware>();
}
protected override AuthenticationHandler<BasicAuthenticationOptions> CreateHandler()
{
return new BasicAuthenticationHandler(_logger);
}
}
}
BasicAuthenticationHandler.cs
using Microsoft.Owin.Logging;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Infrastructure;
using System;
using System.Text;
using System.Threading.Tasks;
namespace Composite.WindowsAzure.Management.Owin
{
public class BasicAuthenticationHandler : AuthenticationHandler<BasicAuthenticationOptions>
{
private readonly ILogger _logger;
public BasicAuthenticationHandler(ILogger logger)
{
_logger = logger;
}
protected override Task ApplyResponseChallengeAsync()
{
_logger.WriteVerbose("ApplyResponseChallenge");
if (Response.StatusCode != 401)
{
return Task.FromResult<object>(null);
}
AuthenticationResponseChallenge challenge = Helper.LookupChallenge(Options.AuthenticationType, Options.AuthenticationMode);
if (challenge != null)
{
Response.Headers.Set("WWW-Authenticate", "Basic");
}
return Task.FromResult<object>(null);
}
protected override async Task<AuthenticationTicket> AuthenticateCoreAsync()
{
_logger.WriteVerbose("AuthenticateCore");
AuthenticationProperties properties = null;
var header = Request.Headers["Authorization"];
if (!String.IsNullOrWhiteSpace(header))
{
var authHeader = System.Net.Http.Headers.AuthenticationHeaderValue.Parse(header);
if ("Basic".Equals(authHeader.Scheme, StringComparison.OrdinalIgnoreCase))
{
string parameter = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader.Parameter));
var parts = parameter.Split(':');
if (parts.Length != 2)
return null;
var identity = await Options.Provider.AuthenticateAsync(userName: parts[0], password: parts[1], cancellationToken: Request.CallCancelled);
return new AuthenticationTicket(identity, properties);
}
}
return null;
}
}
}
Options.Provider.AuthenticateAsync validated the username/password and return the identity if authenticated.
Specifications
What I am trying to solve is: I have a Owin Hosted WebAPI deployed with N Azure Cloud Services. Each of them are linked to a storage account that holds a list of username/hashed passwords.
From my client I am adding any of these N services to the client and can then communicate with them by their webapis. They are locked down with authentication. The first step is to validate the users over basic authentication scheme with the list provided above. After that, I hope its easy to add other authentication schemes very easy as of the Owin, UseWindowsAzureAuthentication ect, or UseFacebookAuthentication. (I do have a challenge here, as the webapi do not have web frontend other then the cross domain site that adds the services).
If your good at Katana and want to work alittle with me on this, feel free to drop me a mail at pks#s-innovations.net. I will provide the answer here at the end also.
Update
Based on answer I have done the following:
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = "Application",
AuthenticationMode = AuthenticationMode.Active,
LoginPath = "/Login",
LogoutPath = "/Logout",
Provider = new CookieAuthenticationProvider
{
OnValidateIdentity = context =>
{
// context.RejectIdentity();
return Task.FromResult<object>(null);
},
OnResponseSignIn = context =>
{
}
}
});
app.SetDefaultSignInAsAuthenticationType("Application");
I assume that it has to be in AuthenticationMode = Active, else the Authorize attributes wont work?
What exactly needs to be in my webapi controller to do the exchange for a cookie?
public async Task<HttpResponseMessage> Get()
{
var context = Request.GetOwinContext();
//Validate Username and password
context.Authentication.SignIn(new AuthenticationProperties()
{
IsPersistent = true
},
new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "MyUserName") }, "Application"));
return Request.CreateResponse(HttpStatusCode.OK);
}
Is above okay?
Current Solution
I have added my BasicAuthenticationMiddleware as the active one, added the above CookieMiddleware as passive.
Then in the AuthenticateCoreAsync i do a check if I can login with the Cookie,
var authContext = await Context.Authentication.AuthenticateAsync("Application");
if (authContext != null)
return new AuthenticationTicket(authContext.Identity, authContext.Properties);
So I can now exchange from webapi controller a username/pass to a cookie and i can also use the Basic Scheme directly for a setup that dont use cookies.
If web api and javascript file are from different origins and you have to add authorization header or cookie header to the request, you cannot prevent browser from sending preflight request. Otherwise it will cause CSRF attack to any protected web api.
You can use OWIN Cors package or Web API Cors package to enable CORS scenario, which can handle the preflight request for you.
OWIN cookie middleware is responsible for setting auth cookie and verify it. It seems to be what you want.
BTW, Basic auth challenge can cause browser to pop up browser auth dialog, which is not expected in most of the web application. Not sure if it's what you want. Instead, using form post to send user name and password and exchange them with cookie is what common web app does.
If you have VS 2013 RC or VWD 2013 RC installed on your machine, you can create an MVC project with Individual auth enabled. The template uses cookie middleware and form post login. Although it's MVC controller, you can simply convert the code to Web API.
[Update]
Regarding preflight request, it will be sent even with cookie header according to the spec. You may consider to add Max Age header to make it be cached on the browser.
JSONP is another option which doesn't require preflight.
[Update2] In order to set cookie by owin middleware, please use the following sample code.
var identity = new ClaimsIdentity(CookieAuthenticationDefaults.ApplicationAuthenticationType);
identity.AddClaim(new Claim(ClaimTypes.Name, "Test"));
AuthenticationManager.AuthenticationResponseGrant = new AuthenticationResponseGrant(identity, new AuthenticationProperties()
{
IsPersistent = true
});