I have an ASP.NET Web API 2.0 application which I have connected up to an Ionic app which uses my API for logon, registration and so on.
I am using token-based authentication so that when a user registers an account and logs in they will be granted an access token which is passed in the header of each subsequent request and used to authenticate the user. That works fine.
Now I want to allow a user to register an account by logging into a social account such as Facebook or Google.
For now, I am taking a stab at integrating Google authentication, and so in my Startup.Auth.cs file I have enabled it like so:
app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions
{
ClientId = "###",
ClientSecret = "###",
});
I also have the standard AccountController methods, so from my Ionic application I am able to make a GET request to the 'RegisterExternal' method which looks something like this:
/api/Account/ExternalLogin?provider=Google&response_type=token&client_id=self&redirect_uri=###
As I understand it, this method returns a redirect URI for which I should navigate to within my app to allow a user to login. I imagine I would open a new window in my app to allow a user to enter their details?
However, I don't think this is the approach I want to take. For most apps these days I can simply press a 'Login with Google' button and it does all the magic under the hood without any redirects or entering any info.
I was taking a look at the Cordova GooglePlus plugin and this appears to be what I need, as it allow a user to login client-side. A success callback also returns the following:
obj.email // 'eddyverbruggen@gmail.com'
obj.userId // user id
obj.displayName // 'Eddy Verbruggen'
obj.familyName // 'Verbruggen'
obj.givenName // 'Eddy'
obj.imageUrl // 'http://link-to-my-profilepic.google.com'
obj.idToken // idToken that can be exchanged to verify user identity.
obj.serverAuthCode // Auth code that can be exchanged for an access token and refresh token for offline access
obj.accessToken // OAuth2 access token
So my question is, can I use this information to pass to the account service of my ASP.NET service to authenticate the user and create an account for them if they don't have one already?
I read here that if you use Google Sign-In with an app that communicates with a backend server, you can identify the currently signed-in user on the server by sending the user's ID token to my server to validate it and create an account if the user isn't already in my database.
This suggests I should be able to use this plugin to send the information I need to my server. If this is possible, which endpoint do I need to hit and what do I need to do?
I have an AccountController.cs which has all the standard stuff, e.g.
- AddExternalLogin
- GetExternalLogin
- RegisterExternal
and so on. Would any of these help me?
Since you already have the access token from your prefered social auth, you can pass that to ASP.NET. However, it does not have a way to process that, which you can add by following this answer also elaborated at the blog here.
Which will return an auth token you can use with auth header
P.S. Don't know if I should copy all code here as well? It's too big.
Using some code gleaned from here, I've come up with a rough implementation.
Here is a short summary of what is happening:
- I use the Cordova GooglePlus plugin to log the user in at the client-side. This will supply us with an OAuth access token.
- I have a new method on my AccountController which I have called 'RegisterExternalToken'. I make a call to this function from my mobile application, and I supply the access token.
- The 'RegisterExternalToken' method will validate the access token by calling the following endpoint: https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=XYZ123
- The tokeninfo endpoint returns a HTTP 200 response containing the details of the user profile. I check this then add an identity claim.
- I check with the ASP.Net Identity UserManager to see if the user is registered already. If not, I register and create a new account. Otherwise, I just sign the user in.
- Much like the existing ASP.NET Identity 'GrantResourceOwnerCredentials' method on the /Token endpoint, I then generate a new access token and return it in a JSON response object which mirrors the object that gets returned via the ASP.NET /token endpoint.
At the client-side, I parse the JSON to retrieve the access token the same way I do for a normal non-external login and supply this access token as the bearer token in the header of all subsequent authenticated requests. I also needed to decorate each of my API controllers with the following attributes:
[HostAuthentication(DefaultAuthenticationTypes.ExternalBearer)]
[HostAuthentication(DefaultAuthenticationTypes.ApplicationCookie)]
AccountController.cs
// POST /api/Account/RegisterExternalToken
[OverrideAuthentication]
[AllowAnonymous]
[Route("RegisterExternalToken")]
public async Task<IHttpActionResult> RegisterExternalToken(RegisterExternalTokenBindingModel model)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var externalLogin = await ExternalLoginData.FromToken(model.Provider, model.Token);
if (externalLogin == null) return InternalServerError();
if (externalLogin.LoginProvider != model.Provider)
{
Authentication.SignOut(DefaultAuthenticationTypes.ExternalCookie);
return InternalServerError();
}
var user = await UserManager.FindAsync(new UserLoginInfo(externalLogin.LoginProvider,
externalLogin.ProviderKey));
var hasRegistered = user != null;
ClaimsIdentity identity;
if (hasRegistered)
{
identity = await UserManager.CreateIdentityAsync(user, OAuthDefaults.AuthenticationType);
var claims = externalLogin.GetClaims();
identity.AddClaims(claims);
Authentication.SignIn(identity);
}
else
{
user = new ApplicationUser
{
Id = Guid.NewGuid().ToString(),
UserName = model.Email,
Email = model.Email
};
var result = await UserManager.CreateAsync(user);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
// Specific to my own app, I am generating a new customer account for a newly registered user
await CreateCustomer(user);
var info = new ExternalLoginInfo
{
DefaultUserName = model.Email,
Login = new UserLoginInfo(model.Provider, externalLogin.ProviderKey)
};
result = await UserManager.AddLoginAsync(user.Id, info.Login);
if (!result.Succeeded)
{
return GetErrorResult(result);
}
identity = await UserManager.CreateIdentityAsync(user, OAuthDefaults.AuthenticationType);
var claims = externalLogin.GetClaims();
identity.AddClaims(claims);
Authentication.SignIn(identity);
}
var authenticationProperties = ApplicationOAuthProvider.CreateProperties(model.Email);
var authenticationTicket = new AuthenticationTicket(identity, authenticationProperties);
var currentUtc = new SystemClock().UtcNow;
authenticationTicket.Properties.IssuedUtc = currentUtc;
authenticationTicket.Properties.ExpiresUtc = currentUtc.Add(TimeSpan.FromDays(365));
var accessToken = Startup.OAuthOptions.AccessTokenFormat.Protect(authenticationTicket);
Request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
// Generate JSON response object
var token = new JObject(
new JProperty("userName", user.UserName),
new JProperty("id", user.Id),
new JProperty("access_token", accessToken),
new JProperty("token_type", "bearer"),
new JProperty("expires_in", TimeSpan.FromDays(365).TotalSeconds.ToString()),
new JProperty(".issued", currentUtc.ToString("ddd, dd MMM yyyy HH':'mm':'ss 'GMT'")),
new JProperty(".expires", currentUtc.Add(TimeSpan.FromDays(365)).ToString("ddd, dd MMM yyyy HH:mm:ss 'GMT'"))
);
return Ok(token);
}
catch (Exception e)
{
return BadRequest("Unable to login due to unspecified error.");
}
ExternalLoginData.cs - (I moved the original version of this from the AccountController.cs to it's own separate file)
public 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)
{
var providerKeyClaim = identity?.FindFirst(ClaimTypes.NameIdentifier);
if (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)
};
}
public static async Task<ExternalLoginData> FromToken(string provider, string accessToken)
{
string verifyTokenEndPoint = "";
string verifyAppEndPoint = "";
if (provider == "Google")
{
verifyTokenEndPoint = $"https://www.googleapis.com/oauth2/v3/tokeninfo?access_token={accessToken}";
}
else
{
return null;
}
var client = new HttpClient();
var uri = new Uri(verifyTokenEndPoint);
var response = await client.GetAsync(uri);
ClaimsIdentity identity = null;
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
dynamic verifyAppJsonObject = (JObject) JsonConvert.DeserializeObject(content);
identity = new ClaimsIdentity(OAuthDefaults.AuthenticationType);
if (provider == "Google")
{
// TODO: Verify contents of verifyAppJsonObject
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, Startup.GoogleClientId, ClaimValueTypes.String, "Google", "Google"));
}
}
var providerKeyClaim = identity?.FindFirst(ClaimTypes.NameIdentifier);
if (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)
};
}
}
In the above code, Startup.GoogleClientId is just the string value of the Google Client ID used here:
app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions
{
ClientId = GoogleClientId,
ClientSecret = "####"
});
Client side in my Ionic app I am calling the method like so:
loginWithGoogle(socialLogin : RegisterExternalTokenBindingModel){
return new Promise((resolve, reject) => {
this.http.post(
`${this.baseUrl}/api/Account/RegisterExternalToken`,
socialLogin,
new RequestOptions()
).subscribe(
result => {
resolve(result.json());
},
error => {
console.log("Login error: "+ error.text());
}
)
})
}
Here I just parse the access token and set the value in my UserAccountService class and save it to localStorage as well:
loginWithGoogle(socialLogin : RegisterExternalTokenBindingModel){
return this.apiService.loginWithGoogle(socialLogin)
.then(
success => {
let accessToken = JsonPath.query(success, 'access_token');
this.accessToken = accessToken;
this.storage.set(this.storageAccessToken, this.accessToken);
return new LoginResult(true, accessToken);
},
failure => {
// TODO: Error handling
}
);
}