diff --git a/samples/IdentitySample.Mvc/Controllers/AccountController.cs b/samples/IdentitySample.Mvc/Controllers/AccountController.cs index adb7f812a2..82c85a75a9 100644 --- a/samples/IdentitySample.Mvc/Controllers/AccountController.cs +++ b/samples/IdentitySample.Mvc/Controllers/AccountController.cs @@ -172,6 +172,9 @@ namespace IdentitySample.Controllers var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false); if (result.Succeeded) { + // Update any authentication tokens if login succeeded + await _signInManager.UpdateExternalAuthenticationTokensAsync(info); + _logger.LogInformation(5, "User logged in with {Name} provider.", info.LoginProvider); return RedirectToLocal(returnUrl); } @@ -188,7 +191,7 @@ namespace IdentitySample.Controllers // If the user does not have an account, then ask the user to create an account. ViewData["ReturnUrl"] = returnUrl; ViewData["LoginProvider"] = info.LoginProvider; - var email = info.ExternalPrincipal.FindFirstValue(ClaimTypes.Email); + var email = info.Principal.FindFirstValue(ClaimTypes.Email); return View("ExternalLoginConfirmation", new ExternalLoginConfirmationViewModel { Email = email }); } } @@ -217,6 +220,10 @@ namespace IdentitySample.Controllers { await _signInManager.SignInAsync(user, isPersistent: false); _logger.LogInformation(6, "User created an account using {Name} provider.", info.LoginProvider); + + // Update any authentication tokens as well + await _signInManager.UpdateExternalAuthenticationTokensAsync(info); + return RedirectToLocal(returnUrl); } } diff --git a/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/IdentityDbContext.cs b/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/IdentityDbContext.cs index d6062d24a9..a9eae3ced3 100644 --- a/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/IdentityDbContext.cs +++ b/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/IdentityDbContext.cs @@ -135,6 +135,11 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore /// public DbSet> UserRoles { get; set; } + /// + /// Gets or sets the of User tokens. + /// + public DbSet> UserTokens { get; set; } + /// /// Gets or sets the of roles. /// @@ -207,6 +212,12 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore b.HasKey(l => new { l.LoginProvider, l.ProviderKey }); b.ToTable("AspNetUserLogins"); }); + + builder.Entity>(b => + { + b.HasKey(l => new { l.UserId, l.LoginProvider, l.Name }); + b.ToTable("AspNetUserTokens"); + }); } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/IdentityUserToken.cs b/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/IdentityUserToken.cs new file mode 100644 index 0000000000..3c1eb76bdf --- /dev/null +++ b/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/IdentityUserToken.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore +{ + /// + /// Represents an authentication token for a user. + /// + /// The type of the primary key used for users. + public class IdentityUserToken where TKey : IEquatable + { + /// + /// Gets or sets the primary key of the user that the token belongs to. + /// + public virtual TKey UserId { get; set; } + + /// + /// Gets or sets the LoginProvider this token is from. + /// + public virtual string LoginProvider { get; set; } + + /// + /// Gets or sets the name of the token. + /// + public virtual string Name { get; set; } + + /// + /// Gets or sets the token value. + /// + public virtual string Value { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/UserStore.cs b/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/UserStore.cs index 86041327ad..766ab658de 100644 --- a/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/UserStore.cs +++ b/src/Microsoft.AspNetCore.Identity.EntityFrameworkCore/UserStore.cs @@ -63,7 +63,8 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore IUserLockoutStore, IUserPhoneNumberStore, IQueryableUserStore, - IUserTwoFactorStore + IUserTwoFactorStore, + IUserAuthenticationTokenStore where TUser : IdentityUser where TRole : IdentityRole where TContext : DbContext @@ -523,6 +524,7 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore private DbSet> UserClaims { get { return Context.Set>(); } } private DbSet> UserRoles { get { return Context.Set>(); } } private DbSet> UserLogins { get { return Context.Set>(); } } + private DbSet> UserTokens { get { return Context.Set>(); } } /// /// Get the claims associated with the specified as an asynchronous operation. @@ -653,7 +655,6 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore LoginProvider = login.LoginProvider, ProviderDisplayName = login.ProviderDisplayName }; - // TODO: fixup so we don't have to update both UserLogins.Add(l); return Task.FromResult(false); } @@ -1196,5 +1197,69 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore } return new List(); } + + private Task> FindToken(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + { + var userId = user.Id; + return UserTokens.SingleOrDefaultAsync(l => l.UserId.Equals(userId) && l.LoginProvider == loginProvider && l.Name == name, cancellationToken); + } + + // + public virtual async Task SetTokenAsync(TUser user, string loginProvider, string name, string value, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + var token = await FindToken(user, loginProvider, name, cancellationToken); + if (token == null) + { + UserTokens.Add(new IdentityUserToken + { + UserId = user.Id, + LoginProvider = loginProvider, + Name = name, + Value = value + }); + } + else + { + token.Value = value; + } + } + + public async Task RemoveTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + var userId = user.Id; + var entry = await UserTokens.SingleOrDefaultAsync(l => l.UserId.Equals(userId) && l.LoginProvider == loginProvider && l.Name == name, cancellationToken); + if (entry != null) + { + UserTokens.Remove(entry); + } + } + + public async Task GetTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + var entry = await FindToken(user, loginProvider, name, cancellationToken); + return entry?.Value; + } } } diff --git a/src/Microsoft.AspNetCore.Identity/DataProtectionTokenProvider.cs b/src/Microsoft.AspNetCore.Identity/DataProtectionTokenProvider.cs index e7c7054ade..09f74095a7 100644 --- a/src/Microsoft.AspNetCore.Identity/DataProtectionTokenProvider.cs +++ b/src/Microsoft.AspNetCore.Identity/DataProtectionTokenProvider.cs @@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.Identity /// Provides protection and validation of identity tokens. /// /// The type used to represent a user. - public class DataProtectorTokenProvider : IUserTokenProvider where TUser : class + public class DataProtectorTokenProvider : IUserTwoFactorTokenProvider where TUser : class { /// /// Initializes a new instance of the class. diff --git a/src/Microsoft.AspNetCore.Identity/ExternalLoginInfo.cs b/src/Microsoft.AspNetCore.Identity/ExternalLoginInfo.cs index 10384d006b..682e97f367 100644 --- a/src/Microsoft.AspNetCore.Identity/ExternalLoginInfo.cs +++ b/src/Microsoft.AspNetCore.Identity/ExternalLoginInfo.cs @@ -1,7 +1,9 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Collections.Generic; using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; namespace Microsoft.AspNetCore.Identity { @@ -13,20 +15,22 @@ namespace Microsoft.AspNetCore.Identity /// /// Creates a new instance of /// - /// The to associate with this login. + /// The to associate with this login. /// The provider associated with this login information. /// The unique identifier for this user provided by the login provider. /// The display name for this user provided by the login provider. - public ExternalLoginInfo(ClaimsPrincipal externalPrincipal, string loginProvider, string providerKey, + public ExternalLoginInfo(ClaimsPrincipal principal, string loginProvider, string providerKey, string displayName) : base(loginProvider, providerKey, displayName) { - ExternalPrincipal = externalPrincipal; + Principal = principal; } /// /// Gets or sets the associated with this login. /// /// The associated with this login. - public ClaimsPrincipal ExternalPrincipal { get; set; } + public ClaimsPrincipal Principal { get; set; } + + public IEnumerable AuthenticationTokens { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Identity/IUserAuthenticationTokenStore.cs b/src/Microsoft.AspNetCore.Identity/IUserAuthenticationTokenStore.cs new file mode 100644 index 0000000000..28ddee9dcc --- /dev/null +++ b/src/Microsoft.AspNetCore.Identity/IUserAuthenticationTokenStore.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity +{ + /// + /// Provides an abstraction to store a user's authentication tokens. + /// + /// The type encapsulating a user. + public interface IUserAuthenticationTokenStore : IUserStore where TUser : class + { + /// + /// Sets the token value for a particular user. + /// + /// The user. + /// The authentication provider for the token. + /// The name of the token. + /// The value of the token. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + Task SetTokenAsync(TUser user, string loginProvider, string name, string value, CancellationToken cancellationToken); + + /// + /// Deletes a token for a user. + /// + /// The user. + /// The authentication provider for the token. + /// The name of the token. + /// The used to propagate notifications that the operation should be canceled. + Task RemoveTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken); + + /// + /// Returns the token value. + /// + /// The user. + /// The authentication provider for the token. + /// The name of the token. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + Task GetTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.AspNetCore.Identity/IUserLoginStore.cs b/src/Microsoft.AspNetCore.Identity/IUserLoginStore.cs index ef8d5b4808..69b057be34 100644 --- a/src/Microsoft.AspNetCore.Identity/IUserLoginStore.cs +++ b/src/Microsoft.AspNetCore.Identity/IUserLoginStore.cs @@ -31,10 +31,7 @@ namespace Microsoft.AspNetCore.Identity /// The login provide whose information should be removed. /// The key given by the external login provider for the specified user. /// The used to propagate notifications that the operation should be canceled. - /// - /// The that contains a flag the result of the asynchronous removing operation. The flag will be true if - /// the login information was existed and removed, otherwise false. - /// + /// The that represents the asynchronous operation. Task RemoveLoginAsync(TUser user, string loginProvider, string providerKey, CancellationToken cancellationToken); /// diff --git a/src/Microsoft.AspNetCore.Identity/IUserTokenProvider.cs b/src/Microsoft.AspNetCore.Identity/IUserTwoFactorTokenProvider.cs similarity index 92% rename from src/Microsoft.AspNetCore.Identity/IUserTokenProvider.cs rename to src/Microsoft.AspNetCore.Identity/IUserTwoFactorTokenProvider.cs index 4349034c98..39b7f43da6 100644 --- a/src/Microsoft.AspNetCore.Identity/IUserTokenProvider.cs +++ b/src/Microsoft.AspNetCore.Identity/IUserTwoFactorTokenProvider.cs @@ -6,10 +6,10 @@ using System.Threading.Tasks; namespace Microsoft.AspNetCore.Identity { /// - /// Provides an abstraction for token generators. + /// Provides an abstraction for two factor token generators. /// /// The type encapsulating a user. - public interface IUserTokenProvider where TUser : class + public interface IUserTwoFactorTokenProvider where TUser : class { /// /// Generates a token for the specified and . @@ -27,7 +27,7 @@ namespace Microsoft.AspNetCore.Identity /// and validated it with the same purpose a token with the purpose of TOTP would not pass the heck even if it was /// for the same user. /// - /// Implementations of should validate that purpose is not null or empty to + /// Implementations of should validate that purpose is not null or empty to /// help with token separation. /// Task GenerateAsync(string purpose, UserManager manager, TUser user); diff --git a/src/Microsoft.AspNetCore.Identity/IdentityBuilder.cs b/src/Microsoft.AspNetCore.Identity/IdentityBuilder.cs index 52dc6d0a5c..4896b6009b 100644 --- a/src/Microsoft.AspNetCore.Identity/IdentityBuilder.cs +++ b/src/Microsoft.AspNetCore.Identity/IdentityBuilder.cs @@ -133,11 +133,11 @@ namespace Microsoft.AspNetCore.Identity /// Adds a token provider for the . /// /// The name of the provider to add. - /// The type of the to add. + /// The type of the to add. /// The current instance. public virtual IdentityBuilder AddTokenProvider(string providerName, Type provider) { - if (!typeof(IUserTokenProvider<>).MakeGenericType(UserType).GetTypeInfo().IsAssignableFrom(provider.GetTypeInfo())) + if (!typeof(IUserTwoFactorTokenProvider<>).MakeGenericType(UserType).GetTypeInfo().IsAssignableFrom(provider.GetTypeInfo())) { throw new InvalidOperationException(Resources.FormatInvalidManagerType(provider.Name, "IUserTokenProvider", UserType.Name)); } diff --git a/src/Microsoft.AspNetCore.Identity/SignInManager.cs b/src/Microsoft.AspNetCore.Identity/SignInManager.cs index 39bc20ccc3..fbb7d2819a 100644 --- a/src/Microsoft.AspNetCore.Identity/SignInManager.cs +++ b/src/Microsoft.AspNetCore.Identity/SignInManager.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Authentication; @@ -460,8 +461,43 @@ namespace Microsoft.AspNetCore.Identity { return null; } - // REVIEW: fix this wrap - return new ExternalLoginInfo(auth.Principal, provider, providerKey, new AuthenticationDescription(auth.Description).DisplayName); + return new ExternalLoginInfo(auth.Principal, provider, providerKey, new AuthenticationDescription(auth.Description).DisplayName) + { + AuthenticationTokens = new AuthenticationProperties(auth.Properties).GetTokens() + }; + } + + /// + /// Stores any authentication tokens found in the external authentication cookie into the associated user. + /// + /// The information from the external login provider. + /// The that represents the asynchronous operation, containing the of the operation. + public virtual async Task UpdateExternalAuthenticationTokensAsync(ExternalLoginInfo externalLogin) + { + if (externalLogin == null) + { + throw new ArgumentNullException(nameof(externalLogin)); + } + + if (externalLogin.AuthenticationTokens != null && externalLogin.AuthenticationTokens.Any()) + { + var user = await UserManager.FindByLoginAsync(externalLogin.LoginProvider, externalLogin.ProviderKey); + if (user == null) + { + return IdentityResult.Failed(); + } + + foreach (var token in externalLogin.AuthenticationTokens) + { + var result = await UserManager.SetAuthenticationTokenAsync(user, externalLogin.LoginProvider, token.Name, token.Value); + if (!result.Succeeded) + { + return result; + } + } + } + + return IdentityResult.Success; } /// diff --git a/src/Microsoft.AspNetCore.Identity/TotpSecurityStampBasedTokenProvider.cs b/src/Microsoft.AspNetCore.Identity/TotpSecurityStampBasedTokenProvider.cs index 4e3e2c85bd..82d27668ad 100644 --- a/src/Microsoft.AspNetCore.Identity/TotpSecurityStampBasedTokenProvider.cs +++ b/src/Microsoft.AspNetCore.Identity/TotpSecurityStampBasedTokenProvider.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Identity /// Represents a token provider that generates time based codes using the user's security stamp. /// /// The type encapsulating a user. - public abstract class TotpSecurityStampBasedTokenProvider : IUserTokenProvider + public abstract class TotpSecurityStampBasedTokenProvider : IUserTwoFactorTokenProvider where TUser : class { /// @@ -28,7 +28,7 @@ namespace Microsoft.AspNetCore.Identity /// and validated it with the same purpose a token with the purpose of TOTP would not pass the check even if it was /// for the same user. /// - /// Implementations of should validate that purpose is not null or empty to + /// Implementations of should validate that purpose is not null or empty to /// help with token separation. /// public virtual async Task GenerateAsync(string purpose, UserManager manager, TUser user) diff --git a/src/Microsoft.AspNetCore.Identity/UserManager.cs b/src/Microsoft.AspNetCore.Identity/UserManager.cs index 1a0635177a..59bf64b618 100644 --- a/src/Microsoft.AspNetCore.Identity/UserManager.cs +++ b/src/Microsoft.AspNetCore.Identity/UserManager.cs @@ -26,8 +26,8 @@ namespace Microsoft.AspNetCore.Identity protected const string ResetPasswordTokenPurpose = "ResetPassword"; protected const string ConfirmEmailTokenPurpose = "EmailConfirmation"; - private readonly Dictionary> _tokenProviders = - new Dictionary>(); + private readonly Dictionary> _tokenProviders = + new Dictionary>(); private TimeSpan _defaultLockout = TimeSpan.Zero; private bool _disposed; @@ -87,7 +87,7 @@ namespace Microsoft.AspNetCore.Identity _context = services.GetService()?.HttpContext; foreach (var providerName in Options.Tokens.ProviderMap.Keys) { - var provider = services.GetRequiredService(Options.Tokens.ProviderMap[providerName].ProviderType) as IUserTokenProvider; + var provider = services.GetRequiredService(Options.Tokens.ProviderMap[providerName].ProviderType) as IUserTwoFactorTokenProvider; if (provider != null) { RegisterTokenProvider(providerName, provider); @@ -122,6 +122,21 @@ namespace Microsoft.AspNetCore.Identity internal IdentityOptions Options { get; set; } + /// + /// Gets a flag indicating whether the backing user store supports authentication tokens. + /// + /// + /// true if the backing user store supports authentication tokens, otherwise false. + /// + public virtual bool SupportsUserAuthenticationTokens + { + get + { + ThrowIfDisposed(); + return Store is IUserAuthenticationTokenStore; + } + } + /// /// Gets a flag indicating whether the backing user store supports two factor authentication. /// @@ -1601,7 +1616,7 @@ namespace Microsoft.AspNetCore.Identity /// /// The name of the provider to register. /// The provider to register. - public virtual void RegisterTokenProvider(string providerName, IUserTokenProvider provider) + public virtual void RegisterTokenProvider(string providerName, IUserTwoFactorTokenProvider provider) { ThrowIfDisposed(); if (provider == null) @@ -1953,6 +1968,92 @@ namespace Microsoft.AspNetCore.Identity return store.GetUsersInRoleAsync(NormalizeKey(roleName), CancellationToken); } + /// + /// Returns an authentication token for a user. + /// + /// + /// The authentication scheme for the provider the token is associated with. + /// The name of the token. + /// + public virtual Task GetAuthenticationTokenAsync(TUser user, string loginProvider, string tokenName) + { + ThrowIfDisposed(); + var store = GetTokenStore(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + if (loginProvider == null) + { + throw new ArgumentNullException(nameof(loginProvider)); + } + if (tokenName == null) + { + throw new ArgumentNullException(nameof(tokenName)); + } + + return store.GetTokenAsync(user, loginProvider, tokenName, CancellationToken); + } + + /// + /// Sets an authentication token for a user. + /// + /// + /// The authentication scheme for the provider the token is associated with. + /// The name of the token. + /// The value of the token. + /// + public virtual async Task SetAuthenticationTokenAsync(TUser user, string loginProvider, string tokenName, string tokenValue) + { + ThrowIfDisposed(); + var store = GetTokenStore(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + if (loginProvider == null) + { + throw new ArgumentNullException(nameof(loginProvider)); + } + if (tokenName == null) + { + throw new ArgumentNullException(nameof(tokenName)); + } + + // REVIEW: should updating any tokens affect the security stamp? + await store.SetTokenAsync(user, loginProvider, tokenName, tokenValue, CancellationToken); + return await UpdateUserAsync(user); + } + + /// + /// Remove an authentication token for a user. + /// + /// + /// The authentication scheme for the provider the token is associated with. + /// The name of the token. + /// Whether a token was removed. + public virtual async Task RemoveAuthenticationTokenAsync(TUser user, string loginProvider, string tokenName) + { + ThrowIfDisposed(); + var store = GetTokenStore(); + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + if (loginProvider == null) + { + throw new ArgumentNullException(nameof(loginProvider)); + } + if (tokenName == null) + { + throw new ArgumentNullException(nameof(tokenName)); + } + + await store.RemoveTokenAsync(user, loginProvider, tokenName, CancellationToken); + return await UpdateUserAsync(user); + } + + /// /// Releases the unmanaged resources used by the role manager and optionally releases the managed resources. /// @@ -1966,7 +2067,6 @@ namespace Microsoft.AspNetCore.Identity } } - // IUserFactorStore methods internal IUserTwoFactorStore GetUserTwoFactorStore() { var cast = Store as IUserTwoFactorStore; @@ -1977,7 +2077,6 @@ namespace Microsoft.AspNetCore.Identity return cast; } - // IUserLockoutStore methods internal IUserLockoutStore GetUserLockoutStore() { var cast = Store as IUserLockoutStore; @@ -1988,7 +2087,6 @@ namespace Microsoft.AspNetCore.Identity return cast; } - // IUserEmailStore methods internal IUserEmailStore GetEmailStore(bool throwOnFail = true) { var cast = Store as IUserEmailStore; @@ -1999,7 +2097,6 @@ namespace Microsoft.AspNetCore.Identity return cast; } - // IUserPhoneNumberStore methods internal IUserPhoneNumberStore GetPhoneNumberStore() { var cast = Store as IUserPhoneNumberStore; @@ -2010,7 +2107,6 @@ namespace Microsoft.AspNetCore.Identity return cast; } - // Two factor APIS internal async Task CreateSecurityTokenAsync(TUser user) { return Encoding.Unicode.GetBytes(await GetSecurityStampAsync(user)); @@ -2137,11 +2233,6 @@ namespace Microsoft.AspNetCore.Identity return IdentityResult.Success; } - /// - /// Validate user and update. Called by other UserManager methods - /// - /// - /// private async Task UpdateUserAsync(TUser user) { var result = await ValidateUserInternal(user); @@ -2154,7 +2245,16 @@ namespace Microsoft.AspNetCore.Identity return await Store.UpdateAsync(user, CancellationToken); } - // IUserPasswordStore methods + private IUserAuthenticationTokenStore GetTokenStore() + { + var cast = Store as IUserAuthenticationTokenStore; + if (cast == null) + { + throw new NotSupportedException("Resources.StoreNotIUserAuthenticationTokenStore"); + } + return cast; + } + private IUserPasswordStore GetPasswordStore() { var cast = Store as IUserPasswordStore; diff --git a/test/Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test/SqlStoreTestBase.cs b/test/Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test/SqlStoreTestBase.cs index 071796c6cb..9bdf820e1c 100644 --- a/test/Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test/SqlStoreTestBase.cs +++ b/test/Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test/SqlStoreTestBase.cs @@ -112,6 +112,7 @@ namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test Assert.True(VerifyColumns(db, "AspNetUserRoles", "UserId", "RoleId")); Assert.True(VerifyColumns(db, "AspNetUserClaims", "Id", "UserId", "ClaimType", "ClaimValue")); Assert.True(VerifyColumns(db, "AspNetUserLogins", "UserId", "ProviderKey", "LoginProvider", "ProviderDisplayName")); + Assert.True(VerifyColumns(db, "AspNetUserTokens", "UserId", "LoginProvider", "Name", "Value")); VerifyIndex(db, "AspNetRoles", "RoleNameIndex"); VerifyIndex(db, "AspNetUsers", "UserNameIndex"); diff --git a/test/Microsoft.AspNetCore.Identity.InMemory.Test/ControllerTest.cs b/test/Microsoft.AspNetCore.Identity.InMemory.Test/ControllerTest.cs new file mode 100644 index 0000000000..b09c626e37 --- /dev/null +++ b/test/Microsoft.AspNetCore.Identity.InMemory.Test/ControllerTest.cs @@ -0,0 +1,117 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Diagnostics; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Builder.Internal; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Authentication; +using Microsoft.AspNetCore.Http.Features.Authentication; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity.Test; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.Identity.InMemory.Test +{ + public class ControllerTest + { + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task VerifyAccountControllerSignIn(bool isPersistent) + { + var context = new Mock(); + var auth = new Mock(); + context.Setup(c => c.Authentication).Returns(auth.Object).Verifiable(); + auth.Setup(a => a.SignInAsync(new IdentityCookieOptions().ApplicationCookieAuthenticationScheme, + It.IsAny(), + It.IsAny())).Returns(Task.FromResult(0)).Verifiable(); + // REVIEW: is persistant mocking broken + //It.Is(v => v.IsPersistent == isPersistent))).Returns(Task.FromResult(0)).Verifiable(); + var contextAccessor = new Mock(); + contextAccessor.Setup(a => a.HttpContext).Returns(context.Object); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(contextAccessor.Object); + services.AddIdentity(); + services.AddSingleton, InMemoryStore>(); + services.AddSingleton, InMemoryStore>(); + + var app = new ApplicationBuilder(services.BuildServiceProvider()); + app.UseCookieAuthentication(); + + // Act + var user = new TestUser + { + UserName = "Yolo" + }; + const string password = "Yol0Sw@g!"; + var userManager = app.ApplicationServices.GetRequiredService>(); + var signInManager = app.ApplicationServices.GetRequiredService>(); + + IdentityResultAssert.IsSuccess(await userManager.CreateAsync(user, password)); + + var result = await signInManager.PasswordSignInAsync(user, password, isPersistent, false); + + // Assert + Assert.True(result.Succeeded); + context.VerifyAll(); + auth.VerifyAll(); + contextAccessor.VerifyAll(); + } + + [Fact] + public async Task VerifyAccountControllerExternalLoginWithTokensFlow() + { + // Setup the external cookie like it would look from a real OAuth2 + var externalId = ""; + var authScheme = ""; + var externalIdentity = new ClaimsIdentity(); + externalIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, externalId)); + var externalPrincipal = new ClaimsPrincipal(externalIdentity); + var externalLogin = new ExternalLoginInfo(externalPrincipal, authScheme, externalId, "displayname") + { + AuthenticationTokens = new[] { + new AuthenticationToken { Name = "refresh_token", Value = "refresh" }, + new AuthenticationToken { Name = "access_token", Value = "access" } + } + }; + + var auth = new Mock(); + auth.Setup(a => a.AuthenticateAsync(It.IsAny())).Returns(Task.FromResult(0)); + var context = new Mock(); + context.Setup(c => c.Authentication).Returns(auth.Object).Verifiable(); + var contextAccessor = new Mock(); + contextAccessor.Setup(a => a.HttpContext).Returns(context.Object); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(contextAccessor.Object); + services.AddIdentity(); + services.AddSingleton, InMemoryStore>(); + services.AddSingleton, InMemoryStore>(); + + var app = new ApplicationBuilder(services.BuildServiceProvider()); + app.UseCookieAuthentication(); + + // Act + var user = new TestUser + { + UserName = "Yolo" + }; + var userManager = app.ApplicationServices.GetRequiredService>(); + var signInManager = app.ApplicationServices.GetRequiredService>(); + + IdentityResultAssert.IsSuccess(await userManager.CreateAsync(user)); + IdentityResultAssert.IsSuccess(await userManager.AddLoginAsync(user, new UserLoginInfo(authScheme, externalId, "whatever"))); + IdentityResultAssert.IsSuccess(await signInManager.UpdateExternalAuthenticationTokensAsync(externalLogin)); + Assert.Equal("refresh", await userManager.GetAuthenticationTokenAsync(user, authScheme, "refresh_token")); + Assert.Equal("access", await userManager.GetAuthenticationTokenAsync(user, authScheme, "access_token")); + } + } +} diff --git a/test/Microsoft.AspNetCore.Identity.InMemory.Test/HttpSignInTest.cs b/test/Microsoft.AspNetCore.Identity.InMemory.Test/HttpSignInTest.cs deleted file mode 100644 index 1ca48f21a3..0000000000 --- a/test/Microsoft.AspNetCore.Identity.InMemory.Test/HttpSignInTest.cs +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System.Diagnostics; -using System.Security.Claims; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Builder.Internal; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Authentication; -using Microsoft.AspNetCore.Identity.Test; -using Microsoft.Extensions.DependencyInjection; -using Moq; -using Xunit; - -namespace Microsoft.AspNetCore.Identity.InMemory.Test -{ - public class HttpSignInTest - { - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task VerifyAccountControllerSignIn(bool isPersistent) - { - var context = new Mock(); - var auth = new Mock(); - context.Setup(c => c.Authentication).Returns(auth.Object).Verifiable(); - auth.Setup(a => a.SignInAsync(new IdentityCookieOptions().ApplicationCookieAuthenticationScheme, - It.IsAny(), - It.IsAny())).Returns(Task.FromResult(0)).Verifiable(); - // REVIEW: is persistant mocking broken - //It.Is(v => v.IsPersistent == isPersistent))).Returns(Task.FromResult(0)).Verifiable(); - var contextAccessor = new Mock(); - contextAccessor.Setup(a => a.HttpContext).Returns(context.Object); - var services = new ServiceCollection(); - services.AddLogging(); - services.AddSingleton(contextAccessor.Object); - services.AddIdentity(); - services.AddSingleton, InMemoryStore>(); - services.AddSingleton, InMemoryStore>(); - - var app = new ApplicationBuilder(services.BuildServiceProvider()); - app.UseCookieAuthentication(); - - // Act - var user = new TestUser - { - UserName = "Yolo" - }; - const string password = "Yol0Sw@g!"; - var userManager = app.ApplicationServices.GetRequiredService>(); - var signInManager = app.ApplicationServices.GetRequiredService>(); - - IdentityResultAssert.IsSuccess(await userManager.CreateAsync(user, password)); - - var result = await signInManager.PasswordSignInAsync(user, password, isPersistent, false); - - // Assert - Assert.True(result.Succeeded); - context.VerifyAll(); - auth.VerifyAll(); - contextAccessor.VerifyAll(); - } - } -} diff --git a/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryStore.cs b/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryStore.cs index c555fe9ad2..d9f67f3851 100644 --- a/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryStore.cs +++ b/test/Microsoft.AspNetCore.Identity.InMemory.Test/InMemoryStore.cs @@ -23,7 +23,8 @@ namespace Microsoft.AspNetCore.Identity.InMemory IQueryableUserStore, IUserTwoFactorStore, IQueryableRoleStore, - IRoleClaimStore + IRoleClaimStore, + IUserAuthenticationTokenStore where TRole : TestRole where TUser : TestUser { @@ -499,6 +500,54 @@ namespace Microsoft.AspNetCore.Identity.InMemory return Task.FromResult(0); } + public Task SetTokenAsync(TUser user, string loginProvider, string name, string value, CancellationToken cancellationToken) + { + var tokenEntity = + user.Tokens.SingleOrDefault( + l => + l.TokenName == name && l.LoginProvider == loginProvider && + l.UserId == user.Id); + if (tokenEntity != null) + { + tokenEntity.TokenValue = value; + } + else + { + user.Tokens.Add(new TestUserToken + { + UserId = user.Id, + LoginProvider = loginProvider, + TokenName = name, + TokenValue = value + }); + } + return Task.FromResult(0); + } + + public Task RemoveTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + { + var tokenEntity = + user.Tokens.SingleOrDefault( + l => + l.TokenName == name && l.LoginProvider == loginProvider && + l.UserId == user.Id); + if (tokenEntity != null) + { + user.Tokens.Remove(tokenEntity); + } + return Task.FromResult(0); + } + + public Task GetTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + { + var tokenEntity = + user.Tokens.SingleOrDefault( + l => + l.TokenName == name && l.LoginProvider == loginProvider && + l.UserId == user.Id); + return Task.FromResult(tokenEntity?.TokenValue); + } + public IQueryable Roles { get { return _roles.Values.AsQueryable(); } diff --git a/test/Microsoft.AspNetCore.Identity.Test/UserManagerTest.cs b/test/Microsoft.AspNetCore.Identity.Test/UserManagerTest.cs index b1c42d5808..076ad0853a 100644 --- a/test/Microsoft.AspNetCore.Identity.Test/UserManagerTest.cs +++ b/test/Microsoft.AspNetCore.Identity.Test/UserManagerTest.cs @@ -1133,7 +1133,7 @@ namespace Microsoft.AspNetCore.Identity.Test } } - private class NoOpTokenProvider : IUserTokenProvider + private class NoOpTokenProvider : IUserTwoFactorTokenProvider { public string Name { get; } = "Noop"; diff --git a/test/Shared/TestUser.cs b/test/Shared/TestUser.cs index 2e26888623..744fa3d013 100644 --- a/test/Shared/TestUser.cs +++ b/test/Shared/TestUser.cs @@ -89,17 +89,9 @@ namespace Microsoft.AspNetCore.Identity.Test /// public virtual int AccessFailedCount { get; set; } - /// - /// Navigation property for users in the role - /// public virtual ICollection> Roles { get; private set; } = new List>(); - /// - /// Navigation property for users claims - /// public virtual ICollection> Claims { get; private set; } = new List>(); - /// - /// Navigation property for users logins - /// public virtual ICollection> Logins { get; private set; } = new List>(); + public virtual ICollection> Tokens { get; private set; } = new List>(); } } diff --git a/test/Shared/TestUserToken.cs b/test/Shared/TestUserToken.cs new file mode 100644 index 0000000000..8e9adfa18a --- /dev/null +++ b/test/Shared/TestUserToken.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Identity.Test +{ + public class TestUserToken : TestUserToken { } + + /// + /// Entity type for a user's login (i.e. facebook, google) + /// + /// + public class TestUserToken where TKey : IEquatable + { + /// + /// The login provider for the login (i.e. facebook, google) + /// + public virtual string LoginProvider { get; set; } + + /// + /// Key representing the login for the provider + /// + public virtual string TokenName { get; set; } + + /// + /// Display name for the login + /// + public virtual string TokenValue { get; set; } + + /// + /// User Id for the user who owns this login + /// + public virtual TKey UserId { get; set; } + } +} \ No newline at end of file diff --git a/test/Shared/UserManagerTestBase.cs b/test/Shared/UserManagerTestBase.cs index 11ff6e88df..4ad4585e9e 100644 --- a/test/Shared/UserManagerTestBase.cs +++ b/test/Shared/UserManagerTestBase.cs @@ -815,7 +815,7 @@ namespace Microsoft.AspNetCore.Identity.Test Assert.False(await manager.IsEmailConfirmedAsync(user)); } - private class StaticTokenProvider : IUserTokenProvider + private class StaticTokenProvider : IUserTwoFactorTokenProvider { public async Task GenerateAsync(string purpose, UserManager manager, TUser user) { @@ -1909,6 +1909,30 @@ namespace Microsoft.AspNetCore.Identity.Test Assert.True(!factors.Any()); } + [Fact] + public async Task CanGetSetUpdateAndRemoveUserToken() + { + if (ShouldSkipDbTests()) + { + return; + } + var manager = CreateManager(); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + Assert.Null(await manager.GetAuthenticationTokenAsync(user, "provider", "name")); + IdentityResultAssert.IsSuccess(await manager.SetAuthenticationTokenAsync(user, "provider", "name", "value")); + Assert.Equal("value", await manager.GetAuthenticationTokenAsync(user, "provider", "name")); + + IdentityResultAssert.IsSuccess(await manager.SetAuthenticationTokenAsync(user, "provider", "name", "value2")); + Assert.Equal("value2", await manager.GetAuthenticationTokenAsync(user, "provider", "name")); + + IdentityResultAssert.IsSuccess(await manager.RemoveAuthenticationTokenAsync(user, "whatevs", "name")); + Assert.Equal("value2", await manager.GetAuthenticationTokenAsync(user, "provider", "name")); + + IdentityResultAssert.IsSuccess(await manager.RemoveAuthenticationTokenAsync(user, "provider", "name")); + Assert.Null(await manager.GetAuthenticationTokenAsync(user, "provider", "name")); + } + [Fact] public async Task CanGetValidTwoFactor() {