I have a custom implementation of the ASP.NET Identity base, using Dapper instead of Entity Framework largely from the tutorial here: http://blog.markjohnson.io/exorcising-entity-framework-from-asp-net-identity/.
Everything is fine with signing users in and out with my AuthenticationManager. However, as soon as I redirect anywhere after logging the user in, the httpcontext is basically null and the user is no longer authenticated. If I use the [Authorize] attribute as well, then the user is automatically declared as Unauthorized, throwing a 401 error.
Here are parts of my AccountController:
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Login(Login login, string redundant)
{
var master = new MasterModel();
if (ModelState.IsValid && (!string.IsNullOrEmpty(login.Email) && !string.IsNullOrEmpty(login.PasswordHash)))
{
var user = await Models.User.FetchUserByEmail(login.Email);
if (user != null)
{
await SignInAsync(user, true);
master.User = user; // User is now signed in - No problem
return RedirectToAction("Overview", "Account", master);
}
}
TempData["Message"] = "Your username or password was not recognised. Please try again.";
return View(master);
}
[HttpGet]
//[Authorize(Roles = "admin,subscriber")] // 403 when uncommented
public ActionResult Overview(MasterModel master = null)
{
// master is just a blank new MasterModel ??
if (!HttpContext.User.Identity.IsAuthenticated)
{
// User is always null/blank identity
TempData["Message"] = "Please log in to view this content";
return RedirectToAction("Login", "Account", master);
}
var userName = string.IsNullOrEmpty(HttpContext.User.Identity.Name)
? TempData["UserName"].ToString()
: HttpContext.User.Identity.Name;
var user = Models.User.FetchUserByEmail(userName).Result;
if (master == null) master = new MasterModel();
master.User = user;
return View(master);
}
My UserStore implements the following interfaces:
public class UserStore : IUserStore<User>, IUserPasswordStore<User>, IUserSecurityStampStore<User>, IQueryableUserStore<User>, IUserRoleStore<User>
My RoleStore just implements IQueryableRoleStore<Role>
User and Role simply implement IUser and IRole respectively
What am I missing?
Update1:
Here's part of the AuthenticatonManager:
public IAuthenticationManager AuthenticationManager
{
get
{
return HttpContext.GetOwinContext().Authentication;
}
}
private async Task SignInAsync(User user, bool isPersistent)
{
AuthenticationManager.SignOut(DefaultAuthenticationTypes.ExternalCookie);
var identity = await UserManager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie);
AuthenticationManager.SignIn(new AuthenticationProperties() { IsPersistent = isPersistent }, identity);
}
Thanks to #WiktorZychla for pointing out the answer.
Turns out I was missing a fundamental step of adding the cookie authentication to IAppBuilder.
Here's how the OwinStartup.cs now looks for reference:
using Microsoft.AspNet.Identity;
using Microsoft.Owin;
using Microsoft.Owin.Security.Cookies;
using Owin;
[assembly: OwinStartup(typeof(appNamespace.OwinStartup))]
namespace appNamespace
{
public class OwinStartup
{
public void Configuration(IAppBuilder app)
{
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
LoginPath = new PathString("/Account/Login")
});
}
}
}
Hopefully this will save someone else from tearing their hair out!
Related
Edit 2:
Finally figured out. Check out the accepted answer below.
Edit:
Full source code with my attempt as instructed by #Jason Pan.
https://github.com/affableashish/blazor-server-auth/tree/feature/AddClaimsDuringLogin
Added Claims during Login (in Login.cshtml.cs file) and accessed those claims from Razor Component.
Unfortunately, it didn't work. I only get null as the claim value. 😔
Original Question
I've seen some similar questions like this and this, but they weren't helpful for my scenario.
My application is a Blazor Server project where I have added Identity following the steps mentioned here.
Now this is what I want to achieve:
User enters their credentials.
If the username is valid (in our Active Directory), I retrieve a field known as EmployeeId from the Active Directory.
Authenticate the user using SignInManager.PasswordSignInAsync.
Add EmployeeId as a claim to the ClaimsPrincipal. (So that I can use EmployeeId from Razor Components like this).
I'm struggling to figure out how to add this EmployeeId as claims during the login process.
My OnPostAsync method in Login.cshtml.cs looks like this:
public class LoginModel : PageModel
{
private readonly SignInManager<MMTUser> _signInManager;
private readonly ILogger<LoginModel> _logger;
public LoginModel(SignInManager<MMTUser> signInManager, ILogger<LoginModel> logger)
{
_signInManager = signInManager;
_logger = logger;
}
[BindProperty]
public InputModel Input { get; set; }
public string ReturnUrl { get; set; }
[TempData]
public string ErrorMessage { get; set; }
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
returnUrl ??= Url.Content("~/");
if (ModelState.IsValid)
{
// Step 1: Check if this user exists in our AD
// If YES: Grab the Employee Id and go to next step
// If NO: Terminate the process
var adLookupResult = ADHelper.ADLookup(Input.Username);
if (adLookupResult == null || string.IsNullOrEmpty(adLookupResult.EmployeeId))
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return Page();
}
// Step 2: SignIn the user
var result = await _signInManager.PasswordSignInAsync(Input.Username, Input.Password, isPersistent: Input.RememberMe, lockoutOnFailure: false);
// Step 3: How do I add adLookupResult.EmployeeId to the ClaimsPrincipal?
if (result.Succeeded)
{
_logger.LogInformation("User logged in.");
return LocalRedirect(returnUrl);
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return Page();
}
}
// If we got this far, something failed, redisplay form
return Page();
}
public class InputModel
{
[Required]
public string Username { get; set; }
[Required]
[DataType(DataType.Password)]
public string Password { get; set; }
[Display(Name = "Remember me?")]
public bool RememberMe { get; set; }
}
}
UPDATE
The previous solution could be a workaround if put in middleware. That very terrible.
We should use CustomClaimsPrincipalFactory to fix the issue.
CustomClaimsPrincipalFactory.cs
using HMT.Web.Server.Areas.Identity;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using System.Security.Claims;
namespace HMT.Web.Server
{
public class CustomClaimsPrincipalFactory :
UserClaimsPrincipalFactory<HMTUser>
{
public CustomClaimsPrincipalFactory(
UserManager<HMTUser> userManager,
IOptions<IdentityOptions> optionsAccessor)
: base(userManager, optionsAccessor)
{
}
// This method is called only when login. It means that "the drawback
// of calling the database with each HTTP request" never happen.
public async override Task<ClaimsPrincipal> CreateAsync(HMTUser user)
{
var principal = await base.CreateAsync(user);
if (principal.Identity != null)
{
//((ClaimsIdentity)principal.Identity).AddClaims(
// new[] { new Claim("EmpId", user.EmpId) });
((ClaimsIdentity)principal.Identity).AddClaims(
new[] { new Claim("EmpId", "EmpId123") });
}
return principal;
}
}
}
Program.cs or Startup.cs
// Learned the hard way that this needs to be added after setting up Blazor (i.e. AddRazorPages, AddServerSideBlazor) - AshishK
builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<HMTUser>>();
builder.Services.AddScoped<IUserClaimsPrincipalFactory<HMTUser>, CustomClaimsPrincipalFactory>();
For more details, you can check the link.
When you login uscessfully, you can use HttpContext.User.AddIdentity to achieve that. Sample like below:
// Step 3: Add the EmployeeId to ClaimsPrincipal
if (result.Succeeded)
{
HttpContext.User.AddIdentity(new ClaimsIdentity(new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, adLookupResult.EmployeeId)
}));
_logger.LogInformation("User logged in.");
return LocalRedirect(returnUrl);
}
Finally figured this out by asking a question in aspnetcore github repo.
https://github.com/dotnet/aspnetcore/issues/46558
Much thanks to #Piotr Stola and #Jason Pan for their help!
Step 1:
In OnPostAsync method in Login.cshtml.cs, use _signInManager.SignInWithClaimsAsync instead of _signInManager.PasswordSignInAsync.
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
returnUrl ??= Url.Content("~/");
if (ModelState.IsValid)
{
// Step 1: Check if this user exists in our AD
// If YES: Grab the Employee Id and go to next step
// If NO: Terminate the process
var adLookupResult = // Call Active Directory and get EmployeeId of this user here
if (adLookupResult == null || string.IsNullOrEmpty(adLookupResult.EmployeeId))
{
ModelState.AddModelError(string.Empty, "User doesn't exist.");
return Page();
}
// Step 2: Check if the user exists in our Identity database (AspNetUsers table)
var user = await _signInManager.UserManager.FindByNameAsync(Input.Email);
if (user == null)
{
ModelState.AddModelError(string.Empty, "User doesn't exist.");
return Page();
}
// Step 3: Check the credentials of this user.
var result = await _signInManager.CheckPasswordSignInAsync(user, Input.Password, lockoutOnFailure: false);
if (result.Succeeded)
{
// Access 'EmployeeId' from Areas/Identity/Components/TakeABreak.razor
// For eg: Emp123 is adLookupResult.EmployeeId that I retrieved from Active Directory in Step 1.
var customClaims = new[] { new Claim("EmployeeId", "Emp123") };
await _signInManager.SignInWithClaimsAsync(user, Input.RememberMe, customClaims);
_logger.LogInformation("User logged in.");
return LocalRedirect(returnUrl);
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return Page();
}
}
// If we got this far, something failed, redisplay form
return Page();
}
Step 2:
Access this from any Razor component. For eg: I'm accessing it from TakeABreak.razor component (Line Number 15 in the attached picture).
Full Source Code
https://github.com/affableashish/blazor-server-auth/tree/feature/AddClaimsDuringLogin
I'm trying to handle google-signin to my existing ASP.NET MVC application. It has a forms authentication developped customely and it works fine for username and password.
I want to add Google-sign-in to this project. I currently added the button, created the app in google developper console got my id and secret and set up the urls.
I can see the button in login page, click, i see my account and picture, select account.
At this point google posts me some data. I received in an Action method an string array object called "credential" which has a string in first position. But I don't know what to do from here on...
Can somebody help me with this? Which document I should use?
I'm reading this: https://developers.google.com/identity/gsi/web till now but i'm stuck.
What I dont want is this:
https://learn.microsoft.com/en-us/aspnet/mvc/overview/security/create-an-aspnet-mvc-5-app-with-facebook-and-google-oauth2-and-openid-sign-on
I want to handle the requests needed myself (with cookies, database checks and keeping track of tokens) and get the user information google provides by myself in my controller's action methods.
Here is a part of the razor view code
<div id="g_id_onload"
data-client_id="my id is here"
data-login_uri="#Url.Action("LoginWithGoogle","Login")"
data-auto_prompt="false">
</div>
<div class="g_id_signin"
data-type="standard"
data-size="large"
data-theme="outline"
data-text="sign_in_with"
data-shape="rectangular"
data-logo_alignment="left"
data-auto_prompt="true"
>
</div>
I added this script:
<script src="https://accounts.google.com/gsi/client" async defer></script>
This Action method can catch the string:
[HttpPost]
public ActionResult LoginWithGoogle(string[] credential)
{
ViewBag.Data = credential;
///I will do necessary stuff here.
return View();
}
Notes:
-Identity is not installed and will not be used (if unless impossible without using it).
-My .Net Framework version: 4.7.2
Thanks for the help.
I don't use "full" default auth code too, here is how I handle Google/Yandex/Discord/OAuth response:
[HttpPost]
[AllowAnonymous]
public IActionResult ExternalLogin(string provider, string? returnUrl = null)
{
var redirectUrl = Url.Action(nameof(ExternalLoginCallback), null, new { returnUrl });
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return new ChallengeResult(provider, properties);
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> ExternalLoginCallback(string? returnUrl = null)
{
var info = await signInManager.GetExternalLoginInfoAsync();
if (info == null)
{
return this.Redirect(LoginPath);
}
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
var name = info.Principal.FindFirstValue(ClaimTypes.Name) ?? info.Principal.Identity.Name;
// your own actions & checks with email, name etc
This still required some "default" preparations in Startup:
services.AddIdentity<User, UserStoreService.UserRole>()
.AddUserStore<UserStoreService>()
.AddRoleStore<UserStoreService>()
.AddDefaultTokenProviders();
services.AddAuthentication().AddGoogle(...)
But here User is my own class, UserRole is own (end empty) class, and UserStoreService is my own implementation of IDisposable, IUserStore<User>, IUserEmailStore<User>, IUserClaimStore<User>, IUserSecurityStampStore<User>, IRoleStore<UserStoreService.UserRole> (you may modify this list according to your needs)
This is example how to process external authentication using owin. First of all, you have to setup your startup.cs and update configuration with something like this. Of course you have to import required packages into your project.
public void Configuration(IAppBuilder app)
{
....
app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions()
{
ClientId = "YourGoogleClientId",
ClientSecret = "YourGoogleClientSecret",
});
}
Then in your account controller (example name) create method that will handle your login with google button. For example:
public ActionResult ExternalLogin(string provider)
{
var returnUrl = Request.Url.GetLeftPart(UriPartial.Authority) + "/Account/ExternalLoginCallback";
return new ChallengeResult(provider, returnUrl);
}
Create your ChallengeResult method:
internal class ChallengeResult : HttpUnauthorizedResult
{
public ChallengeResult(string provider, string redirectUrl)
{
LoginProvider = provider;
RedirectUri = redirectUrl;
}
public string LoginProvider { get; set; }
public string RedirectUri { get; set; }
public override void ExecuteResult(ControllerContext context)
{
var properties = new AuthenticationProperties { RedirectUri = RedirectUri };
var owin = context.HttpContext.GetOwinContext();
owin.Authentication.Challenge(properties, LoginProvider);
}
}
Add your new authentication method in your account controller. For example:
public async Task<ActionResult> ExternalLoginCallback()
{
try
{
var loginInfo = await AuthenticationManager.GetExternalLoginInfoAsync();
if (loginInfo == null || string.IsNullOrEmpty(loginInfo.Email))
{
return RedirectToAction("ExternalLoginFailed", "WebAccount");
}
else
{
// handle external login, means process user login in your system
}
}
catch (Exception ex)
{
// handle possible exceptions
}
return RedirectToAction("ExternalLoginFailed", "WebAccount");
}
private IAuthenticationManager AuthenticationManager
{
get { return HttpContext.GetOwinContext().Authentication; }
}
I want to authorize user in WebApi using ClaimsIdentity. In my AccountController which inherits ApiController class I have my two methods to test user authentication. One is a proper method used to receive user's data from other app based on his AD name and authenticates him saving his data as a Claim. The other one is a test method which I call after the previous one to check if the user is authenticated and has claims set.
Unfortunately the login method doesn't seem to set his Identity correctly even though the cookie is generated. The second method than works as if the user wasn't even authenticated and doesn't have any claims.
I have tried some various combination of creating his Identity but nothing seems to work.
Maybe you can see what I am missing.
AccountController.cs
[HttpGet]
[Route("account/login/{userActDirName}/{realmId}")]
public async Task<IHttpActionResult> Login(string userActDirName, long realmId)
{
//getting user data
var user = await UserManager.FindAsync(userActDirName, "1");
if (user == null)
{
user = new ApplicationUser() { UserName = userActDirName };
IdentityResult result = await UserManager.CreateAsync(user, "1");
if (!result.Succeeded)
{
...
}
user = await UserManager.FindAsync(userActDirName, "1");
}
Authentication.SignOut();
ClaimsIdentity cookieIdentity = UserManager.CreateIdentity(user, DefaultAuthenticationTypes.ApplicationCookie);
cookieIdentity.AddClaim(new Claim(ClaimTypes.Name, userActDirName));
cookieIdentity.AddClaim(new Claim("User", JsonConvert.SerializeObject(userData)));
Authentication.SignIn(new AuthenticationProperties() { IsPersistent = false }, cookieIdentity);
}
private ApplicationUserManager _userManager;
private IAuthenticationManager Authentication
{
get { return HttpContext.Current.GetOwinContext().Authentication; }
}
public ApplicationUserManager UserManager
{
get
{
return _userManager ?? HttpContext.Current.GetOwinContext().GetUserManager<ApplicationUserManager>();
}
private set
{
_userManager = value;
}
}
IdentityConfig.cs
public class ApplicationUserManager : UserManager<ApplicationUser>
{
public ApplicationUserManager(IUserStore<ApplicationUser> store)
: base(store)
{
}
public static ApplicationUserManager Create(IdentityFactoryOptions<ApplicationUserManager> options, IOwinContext context)
{
var manager = new ApplicationUserManager(new UserStore<ApplicationUser>(context.Get<ApplicationDbContext>()));
// Configure validation logic for usernames
manager.UserValidator = new UserValidator<ApplicationUser>(manager)
{
AllowOnlyAlphanumericUserNames = false,
RequireUniqueEmail = false
};
// Configure validation logic for passwords
manager.PasswordValidator = new PasswordValidator
{
RequiredLength = -1,
RequireNonLetterOrDigit = false,
RequireDigit = false,
RequireLowercase = false,
RequireUppercase = false,
};
var dataProtectionProvider = options.DataProtectionProvider;
if (dataProtectionProvider != null)
{
manager.UserTokenProvider = new DataProtectorTokenProvider<ApplicationUser>(dataProtectionProvider.Create("ASP.NET Identity"));
}
return manager;
}
}
Startup.cs
[assembly: OwinStartup(typeof(Api.Startup))]
namespace Api
{
public partial class Startup
{
public void Configuration(IAppBuilder app)
{
ConfigureAuth(app);
}
}
}
Startup.Auth.cs
public void ConfigureAuth(IAppBuilder app)
{
System.Web.Helpers.AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.Name;
// Configure the db context and user manager to use a single instance per request
app.CreatePerOwinContext(ApplicationDbContext.Create);
app.CreatePerOwinContext<ApplicationUserManager>(ApplicationUserManager.Create);
// Enable the application to use a cookie to store information for the signed in user
// and to use a cookie to temporarily store information about a user logging in with a third party login provider
app.UseCookieAuthentication(new CookieAuthenticationOptions()
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
LoginPath = new PathString("/Account/Login")
});
}
Since you used the string "User" while creating claim for complete user object as JSON, using the following code :
cookieIdentity.AddClaim(new Claim("User", JsonConvert.SerializeObject(userData)));
Therefore when checking if the user is authenticated or not, use the follwoing code to check if the above mentioned claim exists or not. It will also give you full JSON that you stored while adding "User" Claim.
Remember the type casting below is very important
also use the following namespace
using System.Security.Claims;
before using the following code
var user = "";
var claims =
((ClaimsIdentity)filterContext.RequestContext.Principal.Identity).Claims;
foreach (var c in claims)
{
if (c.Type == "User")
user = c.Value;
}
I have used this code in a custom "AuthorizationFilterAttribute". Therefore I have
filterContext object
you can get
RequestContext object
easily in any WebAPI-Method e.g.
this.RequestContext.Principal.Identity
therefore,
var claims =
((ClaimsIdentity)this.RequestContext.Principal.Identity).Claims;
will work in any web api controller.
I've stumbled upon an issue where inconsistently the application redirects the user to Account/AccessDenied/ upon adding a social media authentication to the current logged in user. It seems to work the first time the user is logged in, then by trying to add another authentication method it returns the user to Account/AccessDenied?ReturnUrl=%2Fmanage%2Flinklogincallback.
My guess is that something is going wrong with the [Authorize] attribute, but only the second time I try adding external authentication method.
ManageController
[Authorize]
public class ManageController : Controller
{
//
// POST: /Manage/LinkLogin
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult LinkLogin(string provider)
{
// Request a redirect to the external login provider to link a login for the current user
var redirectUrl = Url.Action("LinkLoginCallback", "Manage");
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User));
return Challenge(properties, provider);
}
//
// GET: /Manage/LinkLoginCallback
[HttpGet]
public async Task<ActionResult> LinkLoginCallback()
{
var user = await GetCurrentUserAsync();
if (user == null)
{
return View("Error");
}
var info = await _signInManager.GetExternalLoginInfoAsync(await _userManager.GetUserIdAsync(user));
if (info == null)
{
return RedirectToAction(nameof(ManageLogins), new { Message = ManageMessageId.Error });
}
var result = await _userManager.AddLoginAsync(user, info);
var message = result.Succeeded ? ManageMessageId.AddLoginSuccess : ManageMessageId.Error;
return RedirectToAction(nameof(ManageLogins), new { Message = message });
}
}
Could it be the order of how startup.cs is arranged?
This is the request/response
#Rovdjuret's workaround helped me until it’s resolved by asp.net team. Here is my controller Login action:
public IActionResult Login(string returnUrl = null)
{
if (_signInManager.IsSignedIn(User))
{
// redirect to user profile page
return RedirectToAction(nameof(HomeFileController.Index), "HomeFile");
}
else
{
// clear Identity.External cookie
if (Request.Cookies["Identity.External"] != null)
{
Response.Cookies.Delete("Identity.External");
}
return View(new LoginViewModel{ ReturnUrl = returnUrl, RememberMe = true });
}
}
Update: In the latest version (as of May 2017) the cookies have prefix ".AspNetCore.". So cookie name should be ".AspNetCore.Identity.External"
I too faced the same issue. I was using the code from IdentityServer4 QuickStart sample from here
app.UseGoogleAuthentication(new GoogleOptions
{
AuthenticationScheme = "Google",
DisplayName = "Google",
SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme,
ClientId = "xxx.apps.googleusercontent.com",
ClientSecret = "xxxx-Xxxxxxx"
});
I had to change the code to the following to fix the issue.
var CookieScheme= app.ApplicationServices.GetRequiredService<IOptions<IdentityOptions>>().Value.Cookies.ExternalCookieAuthenticationScheme;
app.UseGoogleAuthentication(new GoogleOptions
{
AuthenticationScheme = "Google",
DisplayName = "Google",
SignInScheme = CookieScheme,
ClientId = "xxx.apps.googleusercontent.com",
ClientSecret = "xxxx-Xxxxxxx"
});
Instead of just using the constant 'external' from the IdentityServerConstants.ExternalAUthenticationScheme I had to obtain the scheme used to identify external authentication cookies from the cookie options of the current identity system used by the app. That is what fixed the issue for me.
I've got confirmed by aspnet team working on Security repo that this is a bug (see this issue) and resolved until next release.
A temporary workaround is to set a cookie named
Identity.External
to null, which is created upon adding external login to your account.
if (Request.Cookies["Identity.External"] != null)
{
Response.Cookies.Delete("Identity.External");
}
workaround helped me until it’s resolved by asp.net team
// GET: /Account/AccessDenied
[HttpGet]
[AllowAnonymous]
public IActionResult AccessDenied(string returnUrl = null)
{
// workaround
if (Request.Cookies["Identity.External"] != null)
{
return RedirectToAction(nameof(ExternalLoginCallback), returnUrl);
}
return RedirectToAction(nameof(Login));
}
If you've set config.SignIn.RequireConfirmedEmail = true in Startup.cs and the EmailConfirmed field is false for an externally authenticated user (e.g. Facebook login), on subsequent logins, you'll be directed to the Account/AccessDenied/ action method.
My question is kind of complex so bear with me as I try to lay it out nicely what I am struggling with.
Goal
Have an ASP.NET website that lets users register & sign-in via Username/Password or Social (Facebook, Twitter, Google, etc) that also has an API. This API needs to be locked down with [Authorize]. The API needs to be able to be accessed by mobile clients (Android, iOS, etc) that can be signed in via Username/Password or Social (Facebook, Twitter, Google, etc).
Background
So I have done sites that can do one or two things from my goal but not all together. There are great examples online and built in examples into VS projects that show how to let the user register and sign-in via social apps but they are only for the website and not for mobile. I have done a website that an Android app uses Username/Password to authenticate with that API, but nothing with OAuth or Social credentials.
I started out using this page as a reference but I have no clue how to take that and make it work for my website logging in and for my mobile app logging in.
This guy makes it sound so easy but doesn't show any code for this.
Question
Is there a tutorial or GitHub example somewhere that can get me to my goal? I basically want a website where people can register a username/password or use their social account AND also let the user do the same (register & login) via a mobile device. The mobile device will basically just use the API to push/pull data, but I am unsure how to incorporate social logins with my API. I assume I need to use OAuth and go that route but I cannot find any good examples that show how to do this for both web and mobile.
Or maybe is the right solution is to have the webpage be all cookie auth and the API be a separate "web site" and be all token auth and they both tie to the same database?
I've successfully done this very task within my own ASP.NET MVC application using ASP.NET Identity, but then hit the issue you mention: I need this to work using Web API as well so that my mobile app can interact natively.
I was unfamiliar with the article you linked, but after reading through it, I noticed that a lot of the work and code their is not necessary and complicates functionality that already exists within ASP.NET Identity.
Here are my recommendations, and I am assuming you are using ASP.NET Identity V2 which is equivalent to the packages surrounding MVC5 (not the new MVC6 vNext). This will allow both your website AND mobile application via API to authenticate both with a local login (username/password) and an external OAuth provider both from MVC web views on your website and through Web API calls from your mobile application:
Step 1. When creating your project, insure you have both the required packages for MVC and Web API included. In the ASP.NET Project Selection dialog you will have the option to select the checkboxes, insure MVC and Web API are both checked. If you didn't already do this when you created your project, I would recommend creating a new project and migrating your existing code over versus searching and manually adding the dependencies and template code.
Step 2. Inside your Startup.Auth.cs file, you will need code to tell OWIN to use cookie authentication, allow external sign in cookies, and support OAuth bearer tokens (This is how Web API calls will authenticate). These are relevant excerpts from my working project codebase:
Startup.Auth.cs
// Enable the application to use a cookie to store information for the signed in user
// and to use a cookie to temporarily store information about a user logging in with a third party login provider
app.UseCookieAuthentication(new CookieAuthenticationOptions
{
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
LoginPath = new PathString("/account/login"),
Provider = new CookieAuthenticationProvider
{
// Enables the application to validate the security stamp when the user logs in.
// This is a security feature which is used when you change a password or add an external login to your account.
OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
validateInterval: TimeSpan.FromMinutes(30),
regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager))
}
});
app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
// Configure the application for OAuth based flow
PublicClientId = "self";
OAuthOptions = new OAuthAuthorizationServerOptions
{
TokenEndpointPath = new PathString("/token"),
Provider = new ApplicationOAuthProvider(PublicClientId),
AuthorizeEndpointPath = new PathString("/api/account/externallogin"),
AccessTokenExpireTimeSpan = TimeSpan.FromDays(14),
//AllowInsecureHttp = false
};
// Enable the application to use bearer tokens to authenticate users
app.UseOAuthBearerTokens(OAuthOptions);
app.UseTwitterAuthentication(
consumerKey: "Twitter API Key",
consumerSecret: "Twitter API Secret");
app.UseFacebookAuthentication(
appId: "Facebook AppId",
appSecret: "Facebook AppSecret");
In the above code I currently support Twitter and Facebook as external authentication providers; however, you can add additional external providers with the app.UserXYZProvider calls and additional libraries and they will plug and play with the code I provide here.
Step 3. Inside your WebApiConfig.cs file, you must configure the HttpConfiguration to supress default host authentication and support OAuth bearer tokens. To explain, this tells your application to differentiate authentication types between MVC and Web API, this way you can use the typical cookie flow for the website, meanwhile your application will accept bearer tokens in the form of OAuth from the Web API without complaining or other issues.
WebApiConfig.cs
// Web API configuration and services
// Configure Web API to use only bearer token authentication.
config.SuppressDefaultHostAuthentication();
config.Filters.Add(new HostAuthenticationFilter(OAuthDefaults.AuthenticationType));
Step 4. You need an AccountController (or equivalently purposed controller) for both MVC and Web API. In my project I have two AccountController files, one MVC controller inheriting from the base Controller class, and another AccountController inheriting from ApiController that is in a Controllers.API namespace to keep things clean. I am using the standard template AccountController code from the Web API and MVC projects. Here is the API version of the Account Controller:
AccountController.cs (Controllers.API namespace)
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Threading.Tasks;
using System.Web;
using System.Web.Http;
using System.Web.Http.ModelBinding;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OAuth;
using Disco.Models.API;
using Disco.Providers;
using Disco.Results;
using Schloss.AspNet.Identity.Neo4j;
using Disco.Results.API;
namespace Disco.Controllers.API
{
[Authorize]
[RoutePrefix("api/account")]
public class AccountController : ApiController
{
private const string LocalLoginProvider = "Local";
private ApplicationUserManager _userManager;
public AccountController()
{
}
public AccountController(ApplicationUserManager userManager,
ISecureDataFormat<AuthenticationTicket> accessTokenFormat)
{
UserManager = userManager;
AccessTokenFormat = accessTokenFormat;
}
public ApplicationUserManager UserManager
{
get
{
return _userManager ?? Request.GetOwinContext().GetUserManager<ApplicationUserManager>();
}
private set
{
_userManager = value;
}
}
public ISecureDataFormat<AuthenticationTicket> AccessTokenFormat { get; private set; }
// GET account/UserInfo
[HostAuthentication(DefaultAuthenticationTypes.ExternalBearer)]
[Route("userinfo")]
public UserInfoViewModel GetUserInfo()
{
ExternalLoginData externalLogin = ExternalLoginData.FromIdentity(User.Identity as ClaimsIdentity);
return new UserInfoViewModel
{
Email = User.Identity.GetUserName(),
HasRegistered = externalLogin == null,
LoginProvider = externalLogin != null ? externalLogin.LoginProvider : null
};
}
// POST account/Logout
[Route("logout")]
public IHttpActionResult Logout()
{
Authentication.SignOut(CookieAuthenticationDefaults.AuthenticationType);
return Ok();
}
// GET account/ManageInfo?returnUrl=%2F&generateState=true
[Route("manageinfo")]
public async Task<ManageInfoViewModel> GetManageInfo(string returnUrl, bool generateState = false)
{
IdentityUser user = await UserManager.FindByIdAsync(User.Identity.GetUserId());
if (user == null)
{
return null;
}
List<UserLoginInfoViewModel> logins = new List<UserLoginInfoViewModel>();
foreach (UserLoginInfo linkedAccount in await UserManager.GetLoginsAsync(User.Identity.GetUserId()))
{
logins.Add(new UserLoginInfoViewModel
{
LoginProvider = linkedAccount.LoginProvider,
ProviderKey = linkedAccount.ProviderKey
});
}
if (user.PasswordHash != null)
{
logins.Add(new UserLoginInfoViewModel
{
LoginProvider = LocalLoginProvider,
ProviderKey = user.UserName,
});
}
return new ManageInfoViewModel
{
LocalLoginProvider = LocalLoginProvider,
Email = user.UserName,
Logins = logins,
ExternalLoginProviders = GetExternalLogins(returnUrl, generateState)
};
}
// POST account/ChangePassword
[Route("changepassword")]
public async Task<IHttpActionResult> ChangePassword(ChangePasswordBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
IdentityResult result = await UserManager.ChangePasswordAsync(User.Identity.GetUserId(), model.OldPassword,
model.NewPassword);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
// POST account/SetPassword
[Route("setpassword")]
public async Task<IHttpActionResult> SetPassword(SetPasswordBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
IdentityResult result = await UserManager.AddPasswordAsync(User.Identity.GetUserId(), model.NewPassword);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
// POST account/AddExternalLogin
[Route("addexternallogin")]
public async Task<IHttpActionResult> AddExternalLogin(AddExternalLoginBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie);
AuthenticationTicket ticket = AccessTokenFormat.Unprotect(model.ExternalAccessToken);
if (ticket == null || ticket.Identity == null || (ticket.Properties != null
&& ticket.Properties.ExpiresUtc.HasValue
&& ticket.Properties.ExpiresUtc.Value < DateTimeOffset.UtcNow))
{
return BadRequest("External login failure.");
}
ExternalLoginData externalData = ExternalLoginData.FromIdentity(ticket.Identity);
if (externalData == null)
{
return BadRequest("The external login is already associated with an account.");
}
IdentityResult result = await UserManager.AddLoginAsync(User.Identity.GetUserId(),
new UserLoginInfo(externalData.LoginProvider, externalData.ProviderKey));
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
// POST account/RemoveLogin
[Route("removelogin")]
public async Task<IHttpActionResult> RemoveLogin(RemoveLoginBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
IdentityResult result;
if (model.LoginProvider == LocalLoginProvider)
{
result = await UserManager.RemovePasswordAsync(User.Identity.GetUserId());
}
else
{
result = await UserManager.RemoveLoginAsync(User.Identity.GetUserId(),
new UserLoginInfo(model.LoginProvider, model.ProviderKey));
}
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
// GET account/ExternalLogin
[OverrideAuthentication]
[HostAuthentication(DefaultAuthenticationTypes.ExternalCookie)]
[AllowAnonymous]
[Route("externallogin", Name = "ExternalLoginAPI")]
public async Task<IHttpActionResult> GetExternalLogin(string provider, string error = null)
{
if (error != null)
{
return Redirect(Url.Content("~/") + "#error=" + Uri.EscapeDataString(error));
}
if (!User.Identity.IsAuthenticated)
{
return new ChallengeResult(provider, this);
}
ExternalLoginData externalLogin = ExternalLoginData.FromIdentity(User.Identity as ClaimsIdentity);
if (externalLogin == null)
{
return InternalServerError();
}
if (externalLogin.LoginProvider != provider)
{
Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie);
return new ChallengeResult(provider, this);
}
ApplicationUser user = await UserManager.FindAsync(new UserLoginInfo(externalLogin.LoginProvider,
externalLogin.ProviderKey));
bool hasRegistered = user != null;
if (hasRegistered)
{
Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie);
ClaimsIdentity oAuthIdentity = await user.GenerateUserIdentityAsync(UserManager,
OAuthDefaults.AuthenticationType);
ClaimsIdentity cookieIdentity = await user.GenerateUserIdentityAsync(UserManager,
CookieAuthenticationDefaults.AuthenticationType);
AuthenticationProperties properties = ApplicationOAuthProvider.CreateProperties(user.UserName);
Authentication.SignIn(properties, oAuthIdentity, cookieIdentity);
}
else
{
IEnumerable<Claim> claims = externalLogin.GetClaims();
ClaimsIdentity identity = new ClaimsIdentity(claims, OAuthDefaults.AuthenticationType);
Authentication.SignIn(identity);
}
return Ok();
}
// GET account/ExternalLogins?returnUrl=%2F&generateState=true
[AllowAnonymous]
[Route("externallogins")]
public IEnumerable<ExternalLoginViewModel> GetExternalLogins(string returnUrl, bool generateState = false)
{
IEnumerable<AuthenticationDescription> descriptions = Authentication.GetExternalAuthenticationTypes();
List<ExternalLoginViewModel> logins = new List<ExternalLoginViewModel>();
string state;
if (generateState)
{
const int strengthInBits = 256;
state = RandomOAuthStateGenerator.Generate(strengthInBits);
}
else
{
state = null;
}
foreach (AuthenticationDescription description in descriptions)
{
ExternalLoginViewModel login = new ExternalLoginViewModel
{
Name = description.Caption,
Url = Url.Route("ExternalLogin", new
{
provider = description.AuthenticationType,
response_type = "token",
client_id = Startup.PublicClientId,
redirect_uri = new Uri(Request.RequestUri, returnUrl).AbsoluteUri,
state = state
}),
State = state
};
logins.Add(login);
}
return logins;
}
// POST account/Register
[AllowAnonymous]
[Route("register")]
public async Task<IHttpActionResult> Register(RegisterBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var user = new ApplicationUser() { UserName = model.Email, Email = model.Email };
IdentityResult result = await UserManager.CreateAsync(user, model.Password);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
// POST account/RegisterExternal
[OverrideAuthentication]
[HostAuthentication(DefaultAuthenticationTypes.ExternalBearer)]
[Route("registerexternal")]
public async Task<IHttpActionResult> RegisterExternal(RegisterExternalBindingModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var info = await Authentication.GetExternalLoginInfoAsync();
if (info == null)
{
return InternalServerError();
}
var user = new ApplicationUser() { UserName = model.Email, Email = model.Email };
IdentityResult result = await UserManager.CreateAsync(user);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
result = await UserManager.AddLoginAsync(user.Id, info.Login);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
return Ok();
}
protected override void Dispose(bool disposing)
{
if (disposing && _userManager != null)
{
_userManager.Dispose();
_userManager = null;
}
base.Dispose(disposing);
}
#region Helpers
private IAuthenticationManager Authentication
{
get { return Request.GetOwinContext().Authentication; }
}
private IHttpActionResult GetErrorResult(IdentityResult result)
{
if (result == null)
{
return InternalServerError();
}
if (!result.Succeeded)
{
if (result.Errors != null)
{
foreach (string error in result.Errors)
{
ModelState.AddModelError("", error);
}
}
if (ModelState.IsValid)
{
// No ModelState errors are available to send, so just return an empty BadRequest.
return BadRequest();
}
return BadRequest(ModelState);
}
return null;
}
private class ExternalLoginData
{
public string LoginProvider { get; set; }
public string ProviderKey { get; set; }
public string UserName { get; set; }
public IList<Claim> GetClaims()
{
IList<Claim> claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, ProviderKey, null, LoginProvider));
if (UserName != null)
{
claims.Add(new Claim(ClaimTypes.Name, UserName, null, LoginProvider));
}
return claims;
}
public static ExternalLoginData FromIdentity(ClaimsIdentity identity)
{
if (identity == null)
{
return null;
}
Claim providerKeyClaim = identity.FindFirst(ClaimTypes.NameIdentifier);
if (providerKeyClaim == null || String.IsNullOrEmpty(providerKeyClaim.Issuer)
|| String.IsNullOrEmpty(providerKeyClaim.Value))
{
return null;
}
if (providerKeyClaim.Issuer == ClaimsIdentity.DefaultIssuer)
{
return null;
}
return new ExternalLoginData
{
LoginProvider = providerKeyClaim.Issuer,
ProviderKey = providerKeyClaim.Value,
UserName = identity.FindFirstValue(ClaimTypes.Name)
};
}
}
private static class RandomOAuthStateGenerator
{
private static RandomNumberGenerator _random = new RNGCryptoServiceProvider();
public static string Generate(int strengthInBits)
{
const int bitsPerByte = 8;
if (strengthInBits % bitsPerByte != 0)
{
throw new ArgumentException("strengthInBits must be evenly divisible by 8.", "strengthInBits");
}
int strengthInBytes = strengthInBits / bitsPerByte;
byte[] data = new byte[strengthInBytes];
_random.GetBytes(data);
return HttpServerUtility.UrlTokenEncode(data);
}
}
#endregion
}
}
Step 5. You also need to create an ApplicationOAuthProvider so the server can generate and validate OAuth tokens. This is provided in the WebAPI sample project. This is my version of the file:
ApplicationOAuthProvider.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OAuth;
using Butler.Models;
using Schloss.AspNet.Identity.Neo4j;
namespace Butler.Providers
{
public class ApplicationOAuthProvider : OAuthAuthorizationServerProvider
{
private readonly string _publicClientId;
public ApplicationOAuthProvider(string publicClientId)
{
if (publicClientId == null)
{
throw new ArgumentNullException("publicClientId");
}
_publicClientId = publicClientId;
}
public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{
var userManager = context.OwinContext.GetUserManager<ApplicationUserManager>();
ApplicationUser user = await userManager.FindAsync(context.UserName, context.Password);
if (user == null)
{
context.SetError("invalid_grant", "The user name or password is incorrect.");
return;
}
ClaimsIdentity oAuthIdentity = await user.GenerateUserIdentityAsync(userManager,
OAuthDefaults.AuthenticationType);
ClaimsIdentity cookiesIdentity = await user.GenerateUserIdentityAsync(userManager,
CookieAuthenticationDefaults.AuthenticationType);
AuthenticationProperties properties = CreateProperties(user.UserName);
AuthenticationTicket ticket = new AuthenticationTicket(oAuthIdentity, properties);
context.Validated(ticket);
context.Request.Context.Authentication.SignIn(cookiesIdentity);
}
public override Task TokenEndpoint(OAuthTokenEndpointContext context)
{
foreach (KeyValuePair<string, string> property in context.Properties.Dictionary)
{
context.AdditionalResponseParameters.Add(property.Key, property.Value);
}
return Task.FromResult<object>(null);
}
public override Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
{
// Resource owner password credentials does not provide a client ID.
if (context.ClientId == null)
{
context.Validated();
}
return Task.FromResult<object>(null);
}
public override Task ValidateClientRedirectUri(OAuthValidateClientRedirectUriContext context)
{
if (context.ClientId == _publicClientId)
{
//Uri expectedRootUri = new Uri(context.Request.Uri, "/");
//if (expectedRootUri.AbsoluteUri == context.RedirectUri)
//{
context.Validated();
//}
}
return Task.FromResult<object>(null);
}
public static AuthenticationProperties CreateProperties(string userName)
{
IDictionary<string, string> data = new Dictionary<string, string>
{
{ "userName", userName }
};
return new AuthenticationProperties(data);
}
}
}
Also included is the ChallengeResult, which the Web API arm of your application will use to handle challenges provided by the external login providers to authenticate your user:
ChallengeResult.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Http;
namespace Butler.Results
{
public class ChallengeResult : IHttpActionResult
{
public ChallengeResult(string loginProvider, ApiController controller)
{
LoginProvider = loginProvider;
Request = controller.Request;
}
public string LoginProvider { get; set; }
public HttpRequestMessage Request { get; set; }
public Task<HttpResponseMessage> ExecuteAsync(CancellationToken cancellationToken)
{
Request.GetOwinContext().Authentication.Challenge(LoginProvider);
HttpResponseMessage response = new HttpResponseMessage(HttpStatusCode.Unauthorized);
response.RequestMessage = Request;
return Task.FromResult(response);
}
}
}
With that set of code, you will be able to HTTP GET and HTTP POST the routes on the API version of the AccountController to register a user, login using username and password to receive a Bearer token, add/remove external logins, manage external logins, and most importantly for your issue, authenticate by passing in an external login token in exchange for an OAuth bearer token for you application.
You may want to have a look at this series of articles to see whether it covers your goal:
Token Based Authentication using ASP.NET Web API 2, Owin, and Identity
by Taiseer Joudeh (who also frequently answers questions on SO)
The articles are about creating a token based authentication service using OWIN and one of the parts cover using external logins (such as Facebook and Google+). The examples are primarily centered around a web application as consumer of the web service, but it should work on mobile applications as well. The articles have a GitHub project associated and a very active comment section, where hardly any question goes unanswered.
Hope this may lead you to your goal.
I am adding this as a seperate answer to the second portion of your question to say that YES you can have two separate projects tied to the same database and simply have the MVC/Web Forms website project use all cookie authentication and then have a separate Web API project that is all token authentication.
In my longer answer with source code examples what I have basically done is combine the two separate projects into one project so as to avoid redundant model code and controller code. In my case this made more sense for me; however, I am inclined to say that it is up to personal preference and the needs of your project to dictate whether to maintain two separate projects, one website and one web API endpoint, or to combine them.
ASP.NET was designed to be very flexible and plug and play as a middleware and I can attest that my project has existed and functioned exactly as intended with the code in two separate projects and now as one combined project.