I'm using IdentityServer 4.
Is it possible to access the value of the RememberMe boolean when issuing claims? (named isPersistent in the Microsoft.AspNetCore.Identity)
My idea is to add a claim reflecting the RememberMe value so that other applications can use the value.
Currently I'm adding my Claims in the implementation of the interface IProfileService.GetProfileDataAsync.
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
await Task.Run(() =>
{
try
{
var user = _userManager.GetUserAsync(context.Subject).Result;
var claims = new List<Claim>
{
// I'm adding my current claims here, like so:
new Claim("contact_id", user.ContactId.ToString()),
// etc
// I would like to add RememberMe
new Claim("remember_me", ??? )
};
context.IssuedClaims.AddRange(claims);
// ..
Or can the RememberMe value be accessed by some other method?
You can add additional claims during the user's login. There is an overload for SignInAsync which accepts an array of additional claims.
Here is a code snippet.
public async Task<IActionResult> Login(LoginInputModel model)
...
AuthenticationProperties props = null;
Claim keepMeLoggedIn = null;
if (AccountOptions.AllowRememberLogin && model.RememberLogin)
{
props = new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration)
};
keepMeLoggedIn = new Claim(AccountOptions.KeepLoggedInClaim, true.ToString());
}
await HttpContext.SignInAsync(userId.ToString(), model.Username, props, keepMeLoggedIn);
Please note that to make this solution work it's necessary to insert your claim name to the IdentityClaims table.
Yes , you should add claim to tokens . In standard OIDC specifications, token is the
bond between client and identity provider . The profile service is called whenever IdentityServer needs to return claims about a user to a client applications , and could be used to add your custom claims .
http://docs.identityserver.io/en/latest/reference/profileservice.html
Related
New to Blazor and have been doing a hatchet job to get things working how I want.
I am using Blazor WASM with AAD for Authentication created based on this document MS Doc. I implemented the SecureAccountFactory class from the example and call a db where I get the associated user based on the AAD Guid, then add everything into Claims.
public async override ValueTask<ClaimsPrincipal> CreateUserAsync(SecureUserAccount account,
RemoteAuthenticationUserOptions options)
{
var initialUser = await base.CreateUserAsync(account, options);
if (initialUser.Identity.IsAuthenticated)
{
var userIdentity = (ClaimsIdentity)initialUser.Identity;
var claims = userIdentity.Claims;
var principalId = claims.Where(x => x.Type == "oid").First();
//Get some user info from SQL
var User = await _UserService.Get(principalId.Value);
//Get user Roles from SQL and add to Claims
var UsersInRoles = await _UsersInRoleService.RolesByUserId(principalId.Value);
//Add the ClientId to Claims
userIdentity.AddClaim(new Claim("clientId", User.ClientId.ToString()));
foreach (var userrole in UsersInRoles)
{
userIdentity.AddClaim(new Claim("appRole", userrole.Role.Name));
}
}
return initialUser;
}
I then have a Profile Component that appears on every page as part of the MainLayout which should have some info about the current user, so I made a static class to retrieve this info.
public static class UserHelper
{
public static async Task<CurrentUserClaims> GetCurrentUserClaims(Task<AuthenticationState> authenticationStateTask)
{
AuthenticationState authenticationState;
authenticationState = await authenticationStateTask;
var AuthenticationStateUser = authenticationState.User;
var user = authenticationState.User;
var claims = user.Claims;
var clientClaim = claims.Where(x => x.Type == "clientId").First();
var principalId = claims.Where(x => x.Type == "oid").First();
return new CurrentUserClaims
{
ClientId = Convert.ToInt32(clientClaim.Value),
PrincipalId = Guid.Parse(principalId.Value),
user = user
};
}
}
In my ProfileComponent, I call CascadingParameter and then onParametersSet I query my Static class for the info from the current logged in user
[CascadingParameter]
private Task<AuthenticationState> authenticationStateTask { get; set; }
private string profilePath;
protected override async Task OnParametersSetAsync()
{
CurrentUserClaims UserClaims = await UserHelper.GetCurrentUserClaims(authenticationStateTask);
var principal = UserClaims.PrincipalId;
//... do stuff
}
The above all works, after a Refresh or once I route to any other page. The initial Load, after login on the home page shows that the below line always fails with 'Sequence contains no elements'
var clientClaim = claims.Where(x => x.Type == "clientId").First();
I am using Authorize to protect the pages and I will eventually be using the Roles to determine what to display to the user.
A: Surely there's a better way of doing the above. There are lots and lots of articles on creating a custom Auth which inherits AuthenticationState but every one I've seen adds the Claims manually as a fake user, so I don't see how to access the actual Claims.
B: I'm wondering if just using LocalStorage for the User info might be a simpler way to go but is it considered 'safe' or best practice?
Any pointers to a solution are appreciated.
I have an endpoint that I want public but also perform some actions if the user is logged in. If I add an AuthorizeAttribute, the claims are there, but if I make it AllowAnonymous, the claims are empty.
[HttpGet]
[Authorize("Read")] //I want this to be AllowAnonymous
public async Task<ActionResult<List<string>>> Get()
{
//This only works if I use [Authorize("Read")]
var subject = context.HttpContext.User.Claims.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier);
return Ok(new List<string>() { "A", "B" });
}
I hope I explained that clear enough.
HttpContext.User is not available when you are using AllowAnonymous (it's null). You can however hack around this by getting the ClaimsPrincipal using the AuthenticateAsync() method. Be sure to change the AuthenticationScheme to whatever scheme you are using (this example is JWT).
var auth = await HttpContext.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);
if (auth.Succeeded)
{
var claimsPrincipal = auth.Principal;
var subject = claimsPrincipal.Claims.FirstOrDefault(a => a.Type == ClaimTypes.NameIdentifier);
// use the subject claim as needed (actual value is "subject.Value")
}
I have used this code on Asp.Net Core 2 and 3.1 using JWT, so your mileage may vary if you use something else.
I have an ASP.NET Core app, targeting netcoreapp3.1, set up with ASP.NET Identity and the Sustainsys.Saml2.AspNetCore2 package.
IDP-initiated SAML authentication is working fine, but I can't retrieve custom attributes/claims from the signed-in user after the authentication redirect.
In my ExternalLogin.cshtml.cs class, the custom claims are present on the ExternalLoginInfo.Principal (as var info in the code below), but they are not retrievable from Context.User.Claims after the redirect.
That is, _logger.LogInformation($"PatientId: {info.Principal.FindFirst("PatientId")}"); prints the value passed in the custom PatientId SAML attribute, but #Context.User.FindFirst("PatientId"); is null after the redirect.
public async Task<IActionResult> OnGetCallbackAsync(string returnUrl = null, string remoteError = null)
{
returnUrl = returnUrl ?? Url.Content("~/");
if (remoteError != null)
{
ErrorMessage = $"Error from external provider: {remoteError}";
return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
}
var info = await _CustomSignInManager.GetExternalLoginInfoAsync();
if (info == null)
{
ErrorMessage = "Error loading external login information.";
return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
}
// Sign in the user with this external login provider if the user already has a login.
var result = await _CustomSignInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
if (result.Succeeded)
{
_logger.LogInformation($"{info.Principal.Identity.Name} logged in with {info.LoginProvider} provider.");
_logger.LogInformation($"PatientId: {info.Principal.FindFirst("PatientId")}");
return LocalRedirect(returnUrl);
}
...
}
My conclusion from this answer is that these claims should still be available. Do I need to somehow pass the ExternalLoginInfo.Principal (rather just the LoginProvider and ProviderKey) to the ExternalLoginSignInAsync method?
I will say that I only want to persist the claims for the length of the session, not add them as AspNetUserClaims the database. They will be different on each login.
I figured out something that works. My question at the end there, "Do I need to somehow pass the ExternalLoginInfo.Principal (rather just the LoginProvider and ProviderKey) to the ExternalLoginSignInAsync method?" was on the right track.
As you can see above, the ExternalLoginSignInAsync method is in the CustomSignInManager class (it's a custom implementation for reasons not relevant to this question, but that method was unchanged from the default ASP.NET Identity SignInManager).
Tracing the request path to the "bottom," ExternalLoginSignInAsync calls SignInOrTwoFactorAsync, which calls SignInAsync. In SignInAsync, a new ClaimsIdentity is created and returned. So I simply had to pass them from the ExternalLoginInfo.Principal instance through all of those methods such that I could set them on the new principal.
Potentially helpful minutiae: I chose to create a CustomClaimSet class to store the custom attributes, and then I had to update all the method signatures to accept a CustomClaimSet as an argument.
I have developed an authentication mechanism in Asp.Net Web Api 2 with the feature for granting refresh tokens, based on the tutorial on Taiseer's blog.
Here is my question. Assume the following scenario:
A user logs in using password and get a refresh token and an access token. The access token in fact includes what roles he is in (hence his authorities within the app). In the mean time the system admin will change this person's roles, so once his access token expires and he wants to use the refresh token to obtain a new access token, his new access token must include the newly updated roles for him.
In my "RefreshTokenProvider" class, I am using the following code in "GrantResourceOwnerCredentials" method to get the user roles from the database and add them to the claims:
var roleManager = new RoleManager<IdentityRole>(new RoleStore<IdentityRole>(new ApplicationDbContext()));
var y = roleManager.Roles.ToList();
var id = new ClaimsIdentity(context.Options.AuthenticationType);
id.AddClaim(new Claim(ClaimTypes.Name, context.UserName));
id.AddClaim(new Claim("sub", context.UserName));
var roles2 = UserRoleManagerProvider.RoleManager().Roles.ToList();
foreach (IdentityRole i in roles2)
{
if (roleIds.Contains(i.Id))
id.AddClaim(new Claim(ClaimTypes.Role, i.Name));
}
This piece works fine (even though I believe there should be a nicer way to do it?!)
But the part that is not working properly is in the "GrantRefreshToken" method, where we need to update roles in order to reflect them in the new access token:
var newId = new ClaimsIdentity(context.Ticket.Identity);
// *** Add shit here....
var userId = context.Ticket.Properties.Dictionary["userId"];
IdentityUser user = UserRoleManagerProvider.UserManager().FindById(userId);
foreach (Claim c in newId.Claims)
{
if (c.Type == ClaimTypes.Role) newId.RemoveClaim(c);
}
if (user.Roles.Count > 0)
{
var roleIds = new List<string>();
var roles2 = UserRoleManagerProvider.RoleManager().Roles.ToList();
foreach (IdentityUserRole ir in user.Roles)
{
roleIds.Add(ir.RoleId);
}
foreach (IdentityRole r in roles2)
{
if (roleIds.Contains(r.Id))
newId.AddClaim(new Claim(ClaimTypes.Role, r.Name));
}
}
Again, if there is a nicer way to do it I'd appreciate you guy's help! But mainly, my problem is that the part for removing the Roles that are not in effect anymore, does not work.
Do you by any chance know what is wrong with that piece?!
FYI, in the above code the "UserRoleManagerProvider" is a simple static class I have created which is like this:
public static class UserRoleManagerProvider
{
public static RoleManager<IdentityRole> RoleManager()
{
var roleManager = new RoleManager<IdentityRole>(new RoleStore<IdentityRole>(new ApplicationDbContext()));
return roleManager;
}
public static UserManager<IdentityUser> UserManager()
{
var userManager = new UserManager<IdentityUser>(new UserStore<IdentityUser>(new ApplicationDbContext()));
return userManager;
}
}
It is difficult to answer this question, and since there is a lot that needs to be included, I've tried to seperate some issues.
Claims
There are two ways to add claims to the ClaimsIdentity.
Persist claims in the store (in the database the tables AspNetUserClaims, AspNetRoleClaims). To add claims use UserManager.AddClaim or RoleManager.AddClaim. Roles (AspNetUserRoles) are special, since they are also counted as claims.
Add claims in code. You can add claims from the ApplicationUser class (usefull for extended properties of the IdentityUser) or in the flow.
Please note the difference! While in all cases it is called AddClaim, the first variant adds the claims to the store, while the second variant adds the claims directly to the ClaimsIdentity.
So how are persisted claims added to the ClaimsIdentity? This is done automatically!
As a side note, you can extend the IdentityUser with properties, but you can also add user claims to the store. In both cases the claim will be added to the ClaimsIdentity. The extended property has to be added in ApplicationUser.GenerateUserIdentityAsync:
public class ApplicationUser : IdentityUser
{
public string DisplayName { get; set; }
public async Task<ClaimsIdentity> GenerateUserIdentityAsync(UserManager<ApplicationUser> manager)
{
var userIdentity = await manager.CreateIdentityAsync(this, DefaultAuthenticationTypes.ApplicationCookie);
// Add custom user claims here
userIdentity.AddClaim(new Claim("DisplayName", DisplayName));
return userIdentity;
}
}
Flow
Before issuing a new access_token the server must validate the user. There may be reasons why the server cannot issue a new access_token. Also the changed configuration has to be taken into account. There are two providers for this setup. The access_token provider and the refresh_token provider.
When a clients makes a request to the token endpoint (grant_type = *), AccessTokenProvider.ValidateClientAuthentication is executed first. If you are using client_credentials then you can do something here. But for the current flow we assume context.Validated();
The provider supports various flows. You can read about it here: https://msdn.microsoft.com/en-us/library/microsoft.owin.security.oauth.oauthauthorizationserverprovider(v=vs.113).aspx
The provider is built as opt-in. If you do not override the certain methods, then access is denied.
Access Token
To obtain an access token, credentials have to be sent. For this example I will assume 'grant_type = password'. In AccessTokenProvider.GrantResourceOwnerCredentials the credentials are checked, the ClaimsIdentity is setup and a token is issued.
In order to add a refresh_token to the ticket we need to override AccessTokenProvider.GrantRefreshToken. Here you have two options: reject the token. Because the refresh_token was revoked or for another reason why the user isn't allowed to use the refresh token anymore. Or setup a new ClaimsIdentity to genereate a new access_token for the ticket.
class AccessTokenProvider : OAuthAuthorizationServerProvider
{
public override async Task GrantRefreshToken(OAuthGrantRefreshTokenContext context)
{
// Reject token: context.Rejected(); Or:
// chance to change authentication ticket for refresh token requests
var userManager = context.OwinContext.GetUserManager<ApplicationUserManager>();
var appUser = await userManager.FindByNameAsync(context.Ticket.Identity.Name);
var oAuthIdentity = await appUser.GenerateUserIdentityAsync(userManager);
var newTicket = new AuthenticationTicket(oAuthIdentity, context.Ticket.Properties);
context.Validated(newTicket);
}
public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{
var userManager = context.OwinContext.GetUserManager<ApplicationUserManager>();
var appUser = await userManager.FindAsync(context.UserName, context.Password);
if (appUser == null)
{
context.SetError("invalid_grant", "The user name or password is incorrect.");
return;
}
var propertyDictionary = new Dictionary<string, string> { { "userName", appUser.UserName } };
var properties = new AuthenticationProperties(propertyDictionary);
var oAuthIdentity = await appUser.GenerateUserIdentityAsync(userManager);
var ticket = new AuthenticationTicket(oAuthIdentity, properties);
// Token is validated.
context.Validated(ticket);
}
}
If the context has a validated ticket, the RefreshTokenProvider is called. In the Create method you can set the expiration time and choose to add the refresh token to the ticket. Do not issue new tokens while the current one isn't expired yet. Otherwise the user may never have to login again!
You can always add the refresh_token if it is somehow persisted. Or you can add a new refresh_token on login only. The user is identified so the 'old' refresh_token doesn't matter anymore since it will expire before the new refresh_token does. If you want to use one active refesh_token only, then you'll have to persist it.
class RefreshTokenProvider : AuthenticationTokenProvider
{
public override void Create(AuthenticationTokenCreateContext context)
{
var form = context.Request.ReadFormAsync().Result;
var grantType = form.GetValues("grant_type");
// do not issue a new refresh_token if a refresh_token was used.
if (grantType[0] != "refresh_token")
{
// 35 days.
int expire = 35 * 24 * 60 * 60;
context.Ticket.Properties.ExpiresUtc = new DateTimeOffset(DateTime.Now.AddSeconds(expire));
// Add the refresh_token to the ticket.
context.SetToken(context.SerializeTicket());
}
base.Create(context);
}
public override void Receive(AuthenticationTokenReceiveContext context)
{
context.DeserializeTicket(context.Token);
base.Receive(context);
}
}
This is just a simple implementation of the refresh_token flow and not complete nor tested. It is just to give you some ideas on implementing the refresh_token flow. As you can see it isn't hard to add claims to the ClaimsIdentity. I didn't add code where persisted claims are maintained. All what matters is that the persisted claims are automatically added!
Please notice that I reset the ClaimsIdentity (new ticket) on refreshing the access_token using the refresh_token. This will create a new ClaimsIdentity with the current state of claims.
I will end with one final remark. I was talking about roles being claims. You may expect that User.IsInRole checks the AspNetUserRoles table. But it doesn't. As roles are claims it checks the claims collection for available roles.
I'm using forms authentication in my MVC application. This is working fine. But not I want to adjust authorization to only allow people in certain roles. The logins correspond to users in active directory and the roles correspond to the groups the users are in.
For authentication, I simply call FormsAuthentication.SetAuthCookie(username, true) after verifying the login.
For authorizing, I first applied the attribute to the controllers I want to secure
[Authorize(Roles = "AllowedUsers")]
public class MyController
...
Next, I'm handling the OnAuthenticate event in global.asax.
protected void FormsAuthentication_OnAuthenticate(Object sender, FormsAuthenticationEventArgs args)
{
if (FormsAuthentication.CookiesSupported)
{
if (Request.Cookies[FormsAuthentication.FormsCookieName] != null)
{
try
{
FormsAuthenticationTicket ticket = FormsAuthentication.Decrypt(
Request.Cookies[FormsAuthentication.FormsCookieName].Value);
// Create WindowsPrincipal from username. This adds active directory
// group memberships as roles to the user.
args.User = new WindowsPrincipal(new WindowsIdentity(ticket.Name));
FormsAuthentication.SetAuthCookie(ticket.Name, true);
}
catch (Exception e)
{
// Decrypt method failed.
}
}
}
else
{
throw new HttpException("Cookieless Forms Authentication is not " + "supported for this application.");
}
}
With this when someone accesses the website they get the login screen. From there they can actually log in. However, somehow it doesn't save the auth cookie and they get a login screen after the next link they click. I tried adding a call to SetAuthCookie() in OnAuthenticate() but they made no difference.
Before I added this event handler to handle authorization, authentication worked fine. So somewhere in the framework User is being set. I'm wondering if this the correct approach and I'm just missing something or if I need a different approach.
What do I need to do to get this to work?
Thanks,
Scott
It seems like my initial approach won't work. I was trying to get ASP.NET to automatically load user roles from their AD account. No comment was given on whether this was possible. However, the research I've done indicates I'll have to write code to load AD group memberships into user roles.
The solution to creating the user principal that ASP.NET MVC uses appears to be to create it in FormsAuthentication_OnAuthenticate() and assign it to Context.User. It appears if I don't set Context.User ASP.NET MVC creates a user principal based off the auth ticket after FormsAuthentication_OnAuthenticate() returns. Additionally, ASP.NET MVC appears to do nothing with Context.User if I set it in FormsAuthentication_OnAuthenticate().
The following is what I ended up doing.
This is the code that handles authentication
public ActionResult LogOn(FormCollection collection, string returnUrl)
{
// Code that authenticates user against active directory
if (authenticated)
{
var authTicket = new FormsAuthenticationTicket(username, true, 20);
string encryptedTicket = FormsAuthentication.Encrypt(authTicket);
var authCookie = new HttpCookie(FormsAuthentication.FormsCookieName, encryptedTicket);
authCookie.Expires = DateTime.Now.AddMinutes(30);
Response.Cookies.Add(authCookie);
if (Url.IsLocalUrl(returnUrl)
&& returnUrl.Length > 1
&& returnUrl.StartsWith("/", StringComparison.OrdinalIgnoreCase)
&& !returnUrl.StartsWith("//", StringComparison.OrdinalIgnoreCase)
&& !returnUrl.StartsWith("/\\", StringComparison.OrdinalIgnoreCase))
{
return Redirect(returnUrl);
}
else
{
return Redirect("~/");
}
}
return View();
}
I initially tried just calling FormsAuthentication.SetAuthCookie(username, true) instead of manually creating, encrypting, and adding it to the Response cookie collections. That worked in the development environment. However, it didn't after I published to the website.
This is the log off code
public ActionResult LogOff()
{
var authCookie = Request.Cookies[FormsAuthentication.FormsCookieName];
if (authCookie != null)
{
authCookie.Expires = DateTime.Today.AddDays(-1);
}
Response.Cookies.Add(authCookie);
FormsAuthentication.SignOut();
return RedirectToAction("Index", "Home");
}
FormsAuthentication.SignOut() doesn't seem to do anything after I switched to manually creating, encrypting, and adding the auth ticket to the response cookie collection in the logon code. So I had to manually expire the cookie.
This is the code I have for FormsAuthentication_OnAuthenticate()
protected void FormsAuthentication_OnAuthenticate(Object sender, FormsAuthenticationEventArgs args)
{
HttpCookie authCookie = Context.Request.Cookies[FormsAuthentication.FormsCookieName];
if (authCookie == null || string.IsNullOrWhiteSpace(authCookie.Value))
return;
FormsAuthenticationTicket authTicket = FormsAuthentication.Decrypt(authCookie.Value);
UserData userData = null;
if (Application["UserData_" + authTicket.Name] == null)
{
userData = new UserData(authTicket.Name);
Application["UserData_" + authTicket.Name] = userData;
}
else
{
userData = (UserData)Application["UserData_" + authTicket.Name];
}
Context.User = new GenericPrincipal(new GenericIdentity(authTicket.Name), userData.Roles);
}
UserData is a class I created to handle caching user roles. This was needed because of the time it takes for active directory to return the group memberships the user belongs to. For completeness, the following is the code I have for UserData.
public class UserData
{
private int _TimeoutInMinutes;
private string[] _Roles = null;
public string UserName { get; private set; }
public DateTime Expires { get; private set; }
public bool Expired { get { return Expires < DateTime.Now; } }
public string[] Roles
{
get
{
if (Expired || _Roles == null)
{
_Roles = GetADContainingGroups(UserName).ToArray();
Expires = DateTime.Now.AddMinutes(_TimeoutInMinutes);
}
return _Roles;
}
}
public UserData(string userName, int timeoutInMinutes = 20)
{
UserName = userName;
_TimeoutInMinutes = timeoutInMinutes;
}
}
Roles can also be stored in a cookie and you have at least two options:
a role provider cookie (another cookie that supports the forms cookie), set with cacheRolesInCookie="true" on a role provider config in web.config. Roles are read the first time authorization module asks for roles and the cookie is issued then
a custom role provider that stores roles in the userdata section of the forms cookie, roles have to be added to the user data section of the forms cookie manually
The Authorization module asks the current principal for user roles, which, if role provider is enabled, either scans the role cookie (the first option) or fires the custom role provider methods.
Yet another, recommended approach is to switch to the Session Authentication Module (SAM) that can replace forms authentication. There are important pros, including the fact that SAM recreates ClaimsPrincipal out of the cookie and roles are just Role claims:
// create cookie
SessionAuthenticationModule sam =
(SessionAuthenticationModule)
this.Context.ApplicationInstance.Modules["SessionAuthenticationModule"];
ClaimsPrincipal principal =
new ClaimsPrincipal( new GenericPrincipal( new GenericIdentity( "username" ), null ) );
// create any userdata you want. by creating custom types of claims you can have
// an arbitrary number of your own types of custom data
principal.Identities[0].Claims.Add( new Claim( ClaimTypes.Role, "role1" ) );
principal.Identities[0].Claims.Add( new Claim( ClaimTypes.Role, "role2" ) );
var token =
sam.CreateSessionSecurityToken(
principal, null, DateTime.Now, DateTime.Now.AddMinutes( 20 ), false );
sam.WriteSessionTokenToCookie( token );
From now on, the identity is stored in a cookie and managed automatically and, yes, the Authorization attribute on your controllers works as expected.
Read more on replacing forms module with SAM on my blog:
http://www.wiktorzychla.com/2012/09/forms-authentication-revisited.html