How to allow basic authentication on a single WebAPI endpoint in an MVC 5 app employing OpenID (ADFS) - asp.net-mvc

I have a web application, hosted on our organization's web servers, which uses OpenID Connect (OIDC) in the shape of Active Directory Federated Services (ADFS) for authentication. It previously used forms authentication, but our organization is migrating all web apps to ADFS for improved security. The web app in general works as expected.
However, the app, written in MVC 5 targeting .NET Framework 4.7.2, also has a single WebAPI endpoint which is used for allowing users to subscribe, in Outlook, to an iCalendar feed exposed by the application (it's an appointment booking system). Before implementing ADFS, I made this work by using a custom AuthorizationFilterAttribute on the WebAPI endpoint which required username and password in the request header - basic authentication, in other words. It's far from ideal, but as far as I can tell it's the only kind of authentication Outlook supports.
Since implementing ADFS, though, the endpoint appears to be no longer accessible by Outlook (crucially, I'm not prompted for credentials when I add the calendar subscription). Adding it in the full Outlook app fails with no errors or feedback and doing so in the web client (an idea prompted by this article)
appears to work but again, I'm not prompted for credentials and no events ever appear in the calendar.
When I call it in a web browser I am redirected to the ADFS login page. When I call it in Postman with no Authorization header I similarly get the ADFS login page in response, but if I specify Basic Auth with the request and pass in my username and password, I get the correct response with the iCal feed.
As you can see below in my HandleUnathorized method I'm adding a WWW-Authenticate header which, as I understood it, was supposed to cause the client to prompt for login credentials when it received a 401 response, but this isn't happening.
My WebAPI controller looks like this:
namespace AppointmentBooking.Web.API.Controllers
{
public class AppointmentController : ApiController
{
[CustomBasicAuthentication] // Use a custom authentication filter for this method to support Outlook
[HttpGet]
[Route("ical/{username}")]
public HttpResponseMessage GetStaffICalendar(string username)
{
if (HttpContext.Current.User.Identity.IsAuthenticated == true)
{
string currentUsername = HttpContext.Current.User.Identity.Name;
if (username == currentUsername)
{
string calendarString = ICalendarService.GetICalendarForHost(currentUsername);
HttpResponseMessage response = Request.CreateResponse(HttpStatusCode.OK);
response.Content = new StringContent(calendarString, new UTF8Encoding(false), "text/calendar");
return response;
}
return Request.CreateResponse(HttpStatusCode.Unauthorized, "You may only open your own iCalendar, not that of another user");
}
return Request.CreateResponse(HttpStatusCode.Unauthorized);
}
}
}
My custom AuthorizationFilterAttribute is defined as follows:
namespace AppointmentBooking.Web.Api.Filters
{
public class CustomBasicAuthentication : AuthorizationFilterAttribute
{
public override void OnAuthorization(HttpActionContext actionContext)
{
var authHeader = actionContext.Request.Headers.Authorization;
if (authHeader != null)
{
var authenticationToken = actionContext.Request.Headers.Authorization.Parameter;
var decodedAuthenticationToken = Encoding.UTF8.GetString(Convert.FromBase64String(authenticationToken));
var usernamePasswordArray = decodedAuthenticationToken.Split(':');
var userName = usernamePasswordArray[0];
var password = usernamePasswordArray[1];
var isValid = Membership.ValidateUser(userName, password);
if (isValid)
{
var principal = new GenericPrincipal(new GenericIdentity(userName), null);
actionContext.RequestContext.Principal = principal;
Thread.CurrentPrincipal = principal;
System.Web.HttpContext.Current.User = principal;
return;
}
}
HandleUnathorized(actionContext);
}
private static void HandleUnathorized(HttpActionContext actionContext)
{
actionContext.Response = actionContext.Request.CreateResponse(HttpStatusCode.Unauthorized);
actionContext.Response.Headers.Add("WWW-Authenticate", "Basic Scheme='Data' realm='Access your iCalendar");
}
}
}
And, in case it's helpful, the app's WebApiConfig.cs file is like this:
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
// Web API routes
config.MapHttpAttributeRoutes();
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{action}/{id}",
defaults: new { id = RouteParameter.Optional }
);
// WebAPI when dealing with JSON & JavaScript!
// Setup json serialization to serialize classes to camel (std. Json format)
var formatter = GlobalConfiguration.Configuration.Formatters.JsonFormatter;
formatter.SerializerSettings.ContractResolver =
new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver();
}
}
}
How can I fix this endpoint so that it causes clients (particularly Outlook) to prompt for login credentials, rather than redirecting to the ADFS login page when I call it?

Related

External Login without using identity asp.net core 2.0

I'm trying to create an external login scheme for facebook, google and linkedin without using identity framework. I have an api that stores all users and do some authentication stuffs. Right now I'm kind of lost on how to get the information from the external login.
I'm issuing a challenge like this.
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult ExternalLogin(string provider)
{
//Issue a challenge to external login middleware to trigger sign in process
return new ChallengeResult(provider);
}
This works well, it redirects me to either google, facebook or linkedinn authentication.
Now on this part:
public async Task<IActionResult> ExternalLoginCallback()
{
//Extract info from externa; login
return Redirect("/");
}
All I want is to get the information that was provided by the external login.
I have tried what I found from my research,
var result = await HttpContext.AuthenticateAsync(provider);
if (result?.Succeeded != true)
{
return Redirect("/");
}
var externalUser = result.Principal;
var claims = externalUser.Claims.ToList();
First of all I I'm not sure if a simple ?provider=Google on my callback string will pass the provider name I specify so it can be used to check the sign in scheme. I guess this is incorrect. Secondly, I tried hard coding await HttpContext.AuthenticateAsync("Google") and when it reach this code, the debug stops. I'm not sure why.
I've seen the generated code when creating a project with single authentication.
var info = await _signInManager.GetExternalLoginInfoAsync();
Sadly, I'm won't be able to use identity since I don't have a user store and my application will be consuming an API.
First you need to create a custom cookie handler. I myself had problems with:
No IAuthenticationSignInHandler is configured to handle sign in for
the scheme: Bearer
I had to add a cookie handler that will temporarily store the outcome of the external authentication, e.g. the claims that got sent by the external provider. This is necessary, since there are typically a couple of redirects involved until you are done with the external authentication process.
Startup
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(o =>
{
o.TokenValidationParameters = tokenValidationParameters;
})
.AddCookie("YourCustomScheme")
.AddGoogle(googleOptions =>
{
googleOptions.SignInScheme = "YourCustomScheme";
googleOptions.ClientId = "x";//Configuration["Authentication:Google:ClientId"];
googleOptions.ClientSecret = "x";//Configuration["Authentication:Google:ClientSecret"];
//googleOptions.CallbackPath = "/api/authentication/externalauthentication/signin-google";
});
The important part here is "YourCustomScheme".
Now it's time to retrieve the user information from the claims provided by the external authentication in the callback action.
Controller
[AllowAnonymous]
[HttpPost(nameof(ExternalLogin))]
public IActionResult ExternalLogin(ExternalLoginModel model)
{
if (model == null || !ModelState.IsValid)
{
return null;
}
var properties = new AuthenticationProperties { RedirectUri = _authenticationAppSettings.External.RedirectUri };
return Challenge(properties, model.Provider);
}
[AllowAnonymous]
[HttpGet(nameof(ExternalLoginCallback))]
public async Task<IActionResult> ExternalLoginCallback(string returnUrl = null, string remoteError = null)
{
//Here we can retrieve the claims
var result = await HttpContext.AuthenticateAsync("YourCustomScheme");
return null;
}
VoilĂ ! We now have some user information to work with!
Helpful link
http://docs.identityserver.io/en/release/topics/signin_external_providers.html
I too had this issue and see if the below code works for you.
I wanted to extract the full name after Google/FB authentication.
var info = await _signInManager.GetExternalLoginInfoAsync();
TempData["fullname"] = info.Principal.FindFirstValue(ClaimTypes.Name);

How do I convert an external OAuth Identity into a local identity in Umbraco?

I am attempting to develop a proof-of-concept using my company's website as an OAuth authorization server to be consumed by Umbraco via OWIN/Katana. All of the OAuth plumbing appears to be working just fine but Umbraco isn't converting the external identity into a local identity. Instead of being logged into the Umbraco backend, the user lands back on the login page. The only change once the OAuth flow has completed is that Umbraco has created an UMB_EXTLOGIN cookie containing a long encrypted string.
If I login using a local identity directly (i.e. user name and password on the Umbraco backend login page) Umbraco creates 4 cookies: UMB_UCONTEXT, UMB_UPDCHK, XSRF-TOKEN and XSRF-V. I assume I'm missing something that converts the external identity into a local one, but I'm not sure what that is.
Startup.Auth.cs
public partial class Startup
{
public void ConfigureAuth(IAppBuilder app)
{
app.ConfigureBackOfficeMyCompanyAuth(Properties.Settings.Default.ClientId, Properties.Settings.Default.ClientSecret);
}
}
UmbracoMyCompanyAuthExtensions.cs
public static class UmbracoMyCompanyAuthExtensions
{
public static void ConfigureBackOfficeMyCompanyAuth(this IAppBuilder app, string clientId, string clientSecret,
string caption = "My Company", string style = "btn-mycompany", string icon = "fa-rebel")
{
var options = new MyCompanyAuthenticationOptions
{
ClientId = clientId,
ClientSecret = clientSecret,
SignInAsAuthenticationType = Constants.Security.BackOfficeExternalAuthenticationType,
Provider = new MyCompanyAuthenticationProvider(),
CallbackPath = new PathString("/MyCompanySignIn")
};
options.ForUmbracoBackOffice(style, icon);
options.Caption = caption;
app.UseMyCompanyAuthentication(options);
}
}
MyCompanyAuthenticationExtension.cs
public static class MyCompanyAuthenticationExtensions
{
public static IAppBuilder UseMyCompanyAuthentication(this IAppBuilder app, MyCompanyAuthenticationOptions options)
{
if (app == null)
{
throw new ArgumentNullException("app");
}
if (options == null)
{
throw new ArgumentNullException("options");
}
app.Use(typeof(MyCompanyAuthenticationMiddleware), new object[] { app, options });
return app;
}
public static IAppBuilder UseMyCompanyAuthentication(this IAppBuilder app, string clientId, string clientSecret)
{
MyCompanyAuthenticationOptions options = new MyCompanyAuthenticationOptions
{
ClientId = clientId,
ClientSecret = clientSecret
};
return app.UseMyCompanyAuthentication(options);
}
}
My custom implementation of AuthenticationHandler<T>.AuthenticateCoreAsync() returns an AuthenticationTicket with the following claims and properties.
Claims
GivenName = My First Name
FamilyName = My Last Name
Name = My Full Name
Email = My Email Address
Properties
.redirect = /umbraco/
Dont have any code ready at hand but from past experiences, using Facebook OAuth, you will have to wire in your own logic to basically either or both, convert you OAuth object (user) into an umbraco one.
When we done it previously the first time a user does it (checking by email), it creates a new user then every subsequent login get the umbraco user by their email and log them in in code. This was the same for both backend users and front end members.
So after much wheel spinning, I finally figured it out. The resulting ClaimsIdentity didn't contain a NameIdentifier claim. I had my OAuth middleware include that claim using the email address as the value and it started working.
FYI, if you're looking to autolink external and local accounts upon external login, here's a really good example that worked for me.

Changes to cookie domain for outgoing responses ignored for ServiceStack requests

I have a multi-tenant website (e.g. several different sites, each with it's own domain, all in the same project, separated using MVC areas), where the authentication cookie has the domain manually set when the user logs in so that it is available to all subdomains (but not the various other sites in the project, this is not an SSO).
So a user logins a x.foo.com, the cookie domain is set for foo.com, so that it also works at y.foo.com and z.foo.com. However, because of the other domains being served from the same project the auth cookie domain cannot be set in the web.config in the usual manner, instead it is set manually when the user logins in like so:
public HttpCookie GetAuthenticationCookie(string username)
{
var cookieDomain = UrlHelper.GetTopAndSecondLevelDomain();
var authenticationCookie = FormsAuthentication.GetAuthCookie(username, false);
authenticationCookie.Domain = cookieDomain;
return authenticationCookie;
}
This works fine, but of course can cause a problem when the cookie is automatically refreshed for sliding expiration. So we have an HTTP module which is hooked into the PostRequestHandlerExecute event of our MVC app to look for auth cookies that were set into the response during the request, and overriding the domain:
public class AuthenticationCookieDomainInterceptorModule : IHttpModule
{
public void Init(HttpApplication context)
{
context.PostRequestHandlerExecute += UpdateAuthenticationCookieExpiry;
}
private void UpdateAuthenticationCookieExpiry(object sender, EventArgs e)
{
var app = (HttpApplication) sender;
var cookieDomain = UrlHelper.GetTopAndSecondLevelDomain();
var authenticationCookie = GetCookieFromResponse(app.Context.Response, FormsAuthentication.FormsCookieName);
if (authenticationCookie != null)
{
if (authenticationCookie.Domain == null || !string.Equals(authenticationCookie.Domain, cookieDomain, StringComparison.InvariantCultureIgnoreCase))
{
authenticationCookie.Domain = cookieDomain;
}
}
}
private HttpCookie GetCookieFromResponse(HttpResponse response, string cookieName)
{
var cookies = response.Cookies;
for (var i = 0; i < cookies.Count; i++) {
if (cookies[i].Name == cookieName)
{
return cookies[i];
}
}
return null;
}
}
This is also works fine unless the request is to our ServiceStack front end which we use to handle our AJAX requests. In that case the module fires as normal, picks up the cookie if its been set, changes the domain as it should, but when the response is sent back to the client the changes to the cookie are ignored.
Is there any reason why the cookie changes wouldn't be saved to the response in this scenario? My guess would be something to do with the fact ServiceStack uses an HttpHandler to hook in the request cycle in the first place, so we are not going through the normal MVC request life-cycle.

Authenticating users with auth token in query string with ASP.NET MVC

[This question relates to ASP.NET MVC4, and it is about best-practice approach - so please, don't suggest hacks.]
I want to authenticate users using an auth token sent in the request URL. It works similarly to a password reset token, except in this case it does not go to a reset page but instead grants access to some portion of the site. The idea is to send the URL with the auth token to a verified email address of the user. Users can click the link and perform some actions without typing their password.
Out-of-the-box, ASP.NET has the [Authorize] attribute and the SimpleMembershipProvider - these seem to work great, but they do some voodoo magic under the hood (like auto-generating database tables), so I don't know how to extend them to add this link-based auth token.
I don't expect an exact answer, but please do point me to the right direction.
Thanks!
Uf, broad question. But I will try at least to direct you to a right direction.
So first if suggest that you use Forms Authentication as a base, but you will have to customize using of it. And I presume that you do not want to use cookies for the authentication as this is native behaviour of the Forms Authentication.
The most important point you should consider to have it you custom query string token based authentication.
Create a login action and in this action you will authorize the user, if he have granted access you ask FormsAuthentication to create AuthCookie. For the further on you just take the httpCookie.Value as your auth token that you will carry in query string.
You need to implement the Application_BeginRequest in the Global.asax that will handle this query string tokens and translate it into the cookie. With this approach you can leverage all the ASP.NET Forms Authentication infrastructure.
This is quite high level picture w/o code. If you need more detail help I can also provide it to you.
You should just use a regular Action that accepts HttpGet.
Upon receiving the token, immediately invalid it so it can't be used again.
Also, only accept tokens that are within your pre-defined range of time period, like 24 or 72 hours.
Thank you Peter for idea.
If smb need to create JWT token authorization for old ASP.NET MVC5.I wrote small example. I don't serialize cookie to JWT. I create a JWT and after I am checking it in the BeginRequest. If everything is ok, I create a cookie and set it to the httpContext.Request. I used authentication mode="Forms" for application and it require cookies.
For create JWT token:
const string secret = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk";
[AllowAnonymous]
[HttpPost]
public ActionResult LoginJWT(LoginViewModel model)
{
ActionResult response = null;
if (ModelState.IsValid)
{
if (true) //todo: check user login&password
{
var payload = new Dictionary<string, object>
{
{ "iss", "subject" },
{ "sub", "api" },
{ "exp", DateTimeOffset.UtcNow.AddHours(2).ToUnixTimeSeconds()},
{ "iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds()},
{ "jti", Guid.NewGuid() },
{ "uid", "64" } //custom field for identificate user
};
IJwtAlgorithm algorithm = new HMACSHA256Algorithm(); // symmetric
IJsonSerializer serializer = new JsonNetSerializer();
IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
IJwtEncoder encoder = new JwtEncoder(algorithm, serializer, urlEncoder);
var token = encoder.Encode(payload, secret);
response = Content(token);
}
else
{
response = new HttpStatusCodeResult(System.Net.HttpStatusCode.BadRequest, "Login or password are not found");
}
}
else
{
response = new HttpStatusCodeResult(System.Net.HttpStatusCode.BadRequest, "Errors in Model");
}
return response;
}
For check JWT token in Global.asax:
public override void Init()
{
this.BeginRequest += this.BeginRequestHandler;
base.Init();
}
private void BeginRequestHandler(object sender, EventArgs e)
{
var bearerToken = this.Context.Request.Headers["Authorization"];
if (bearerToken != null)
{
var token = bearerToken.StartsWith("Bearer ") ? bearerToken.Substring(7) : bearerToken;
const string secret = "GQDstcKsx0NHjPOuXOYg5MbeJ1XT0uFiwDVvVBrk";
int userId = 0;
try
{
IJsonSerializer serializer = new JsonNetSerializer();
var provider = new UtcDateTimeProvider();
IJwtValidator validator = new JwtValidator(serializer, provider);
IBase64UrlEncoder urlEncoder = new JwtBase64UrlEncoder();
IJwtAlgorithm algorithm = new HMACSHA256Algorithm(); // symmetric
IJwtDecoder decoder = new JwtDecoder(serializer, validator, urlEncoder, algorithm);
var json = decoder.DecodeToObject<IDictionary<string, string>>(token, secret, verify: true);
if (json.TryGetValue("uid", out var uid))
{
userId = Convert.ToInt32(uid);
}
}
catch (TokenExpiredException)
{
Console.WriteLine("Token has expired");
}
catch (SignatureVerificationException)
{
Console.WriteLine("Token has invalid signature");
}
if (userId != 0)
{
// check user by id, if found create cookie.
}
}
}
I used:
jwt-dotnet/jwt library 7.2.1

Serving an iCalendar file in ASPNET MVC with authentication

I'm trying to serve an iCalendar file (.ics) in my MVC application.
So far it's working fine. I have an iPhone subscribing to the URL for the calendar but now I need to serve a personalised calendar to each user.
When subscribing to the calendar on the iPhone I can enter a username and password, but I don't know how to access these in my MVC app.
Where can I find details of how the authentication works, and how to implement it?
It turns out that Basic Authentication is what is required. I half had it working but my IIS configuration got in the way. So, simply returning a 401 response when there is no Authorization header causes the client (e.g. iPhone) to require a username/password to subscribe to the calendar.
On the authorization of the request where there is an Authorization request header, the basic authentication can be processed, retrieving the username and password from the base 64 encoded string.
Here's some useful code for MVC:
public class BasicAuthorizeAttribute : AuthorizeAttribute
{
public override void OnAuthorization(AuthorizationContext filterContext)
{
if (filterContext == null)
{
throw new ArgumentNullException("filterContext");
}
var auth = filterContext.HttpContext.Request.Headers["Authorization"];
if (!String.IsNullOrEmpty(auth))
{
var encodedDataAsBytes = Convert.FromBase64String(auth.Replace("Basic ", ""));
var value = Encoding.ASCII.GetString(encodedDataAsBytes);
var username = value.Substring(0, value.IndexOf(':'));
var password = value.Substring(value.IndexOf(':') + 1);
if (MembershipService.ValidateUser(username, password))
{
filterContext.HttpContext.User = new GenericPrincipal(new GenericIdentity(username), null);
}
else
{
filterContext.Result = new HttpStatusCodeResult(401);
}
}
else
{
if (AuthorizeCore(filterContext.HttpContext))
{
var cachePolicy = filterContext.HttpContext.Response.Cache;
cachePolicy.SetProxyMaxAge(new TimeSpan(0));
cachePolicy.AddValidationCallback(CacheValidateHandler, null);
}
else
{
filterContext.HttpContext.Response.Clear();
filterContext.HttpContext.Response.StatusDescription = "Unauthorized";
filterContext.HttpContext.Response.AddHeader("WWW-Authenticate", "Basic realm=\"Secure Calendar\"");
filterContext.HttpContext.Response.Write("401, please authenticate");
filterContext.HttpContext.Response.StatusCode = 401;
filterContext.Result = new EmptyResult();
filterContext.HttpContext.Response.End();
}
}
}
private void CacheValidateHandler(HttpContext context, object data, ref HttpValidationStatus validationStatus)
{
validationStatus = OnCacheAuthorization(new HttpContextWrapper(context));
}
}
Then, my controller action looks like this:
[BasicAuthorize]
public ActionResult Calendar()
{
var userName = HttpContext.User.Identity.Name;
var appointments = GetAppointments(userName);
return new CalendarResult(appointments, "Appointments.ics");
}
I found this really helpful, but i hit a few problems during the development and i thought i would share some of them to help save other people some time.
I was looking to get data from my web application into the calendar for an android device and i was using discountasp as a hosting service.
The first problem i hit was that the validation did not work when uploaded to the server, stangely enough it was accepting my control panel login for discountasp but not my forms login.
The answer to this was to turn off Basic Authentication in IIS manager. This resolved the issue.
Secondly, the app i used to sync the calendar to the android device was called iCalSync2 - its a nice app and works well. But i found that it only worked properly when the file was delivered as a .ics (duh for some reason i put it as a .ical.. it must have been late) and i also had to choose the webcal option
Lastly i found i had to add webcal:// to the start of my url instead of http://
Also be careful as the code posted above ignores the roles input variable and always passes nothing so you might need to do some role based checks inside your calendar routine or modify the code above to process the roles variable.

Resources