diff --git a/.vscode/launch.json b/.vscode/launch.json index 1bcbc59b..2f75cee9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -109,6 +109,11 @@ "serverReadyAction": { "action": "openExternally", "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "presentation": { + "hidden": false, + "group": "run", + "order": 1 } }, { diff --git a/src/Yavsc.Server/Helpers/UserHelpers.cs b/src/Yavsc.Server/Helpers/UserHelpers.cs index 20d74897..d5b57a29 100644 --- a/src/Yavsc.Server/Helpers/UserHelpers.cs +++ b/src/Yavsc.Server/Helpers/UserHelpers.cs @@ -16,7 +16,7 @@ namespace Yavsc.Helpers public static string GetUserName(this ClaimsPrincipal user) { - return user.FindFirstValue(ClaimTypes.Name); + return user.FindFirstValue("name"); } public static bool IsSignedIn(this ClaimsPrincipal user) diff --git a/src/Yavsc.Server/Models/ApplicationDbContext.cs b/src/Yavsc.Server/Models/ApplicationDbContext.cs index 0c9efb4d..9e2e4230 100644 --- a/src/Yavsc.Server/Models/ApplicationDbContext.cs +++ b/src/Yavsc.Server/Models/ApplicationDbContext.cs @@ -36,6 +36,7 @@ namespace Yavsc.Models using Microsoft.EntityFrameworkCore.ChangeTracking; using Yavsc.Abstract.Models.Messaging; using Org.BouncyCastle.Asn1.Crmf; + using Microsoft.AspNetCore.Identity; public class ApplicationDbContext : IdentityDbContext { @@ -85,8 +86,7 @@ namespace Yavsc.Models } builder.Entity().Property(a => a.ParentCode).IsRequired(false); - //builder.Entity().HasOne(p => p.Author).WithMany(a => a.Posts); - + builder.Entity>().HasKey(i=> new { i.LoginProvider, i.UserId, i.ProviderKey }); } // this is not a failback procedure. @@ -301,6 +301,6 @@ namespace Yavsc.Models public DbSet Scopes { get; set; } public DbSet blogSpotPublications{ get; set; } - + public DbSet> AspNetUserLogins { get; set; } } } diff --git a/src/Yavsc.Server/Models/IdentityUserLogin.cs b/src/Yavsc.Server/Models/IdentityUserLogin.cs new file mode 100644 index 00000000..49afeeea --- /dev/null +++ b/src/Yavsc.Server/Models/IdentityUserLogin.cs @@ -0,0 +1,33 @@ +namespace Yavsc.Models.Auth +{ + using Microsoft.AspNetCore.Identity; + using System.ComponentModel.DataAnnotations.Schema; + + public class YaIdentityUserLogin + { + + /// + /// Gets or sets the login provider for the login (e.g. facebook, google) + /// + public virtual string LoginProvider { get; set; } = default!; + + /// + /// Gets or sets the unique provider identifier for this login. + /// + public virtual string ProviderKey { get; set; } = default!; + + /// + /// Gets or sets the friendly name used in a UI for this login. + /// + public virtual string? ProviderDisplayName { get; set; } + + /// + /// Gets or sets the primary key of the user associated with this login. + /// + public String UserId { get; set; } = default!; + + [ForeignKey("UserId")] + public virtual ApplicationUser User { get; set; } + + } +} diff --git a/src/Yavsc/Controllers/Accounting/ExternalController.cs b/src/Yavsc/Controllers/Accounting/ExternalController.cs new file mode 100644 index 00000000..1e0dd487 --- /dev/null +++ b/src/Yavsc/Controllers/Accounting/ExternalController.cs @@ -0,0 +1,228 @@ +/* + Copyright (c) 2024 HigginsSoft, Alexander Higgins - https://github.com/alexhiggins732/ + + Copyright (c) 2018, Brock Allen & Dominick Baier. All rights reserved. + + Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + Source code and license this software can be found + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. +*/ + +using System.Security.Claims; +using IdentityModel; +using IdentityServer8; +using IdentityServer8.Events; +using IdentityServer8.Services; +using IdentityServer8.Stores; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Yavsc; +using Yavsc.Extensions; +using Yavsc.Interfaces; +using Yavsc.Models; + +namespace IdentityServerHost.Quickstart.UI; + +[SecurityHeaders] +[AllowAnonymous] +public class ExternalController : Controller +{ + private readonly IIdentityServerInteractionService _interaction; + private readonly IClientStore _clientStore; + private readonly ILogger _logger; + private readonly IEventService _events; + private IExternalIdentityManager _users; + + private readonly SignInManager _signInManager; + private readonly RoleManager _roleManager; + private readonly ApplicationDbContext _dbContext; + + public ExternalController( + IIdentityServerInteractionService interaction, + IClientStore clientStore, + IEventService events, + ILogger logger, + IExternalIdentityManager externalIdentityProviderManager, + SignInManager signInManager, + ApplicationDbContext dbContext, + RoleManager roleManager + ) + { + // if the TestUserStore is not in DI, then we'll just use the global users collection + // this is where you would plug in your own custom identity management library (e.g. ASP.NET Identity) + _users = externalIdentityProviderManager; + _interaction = interaction; + _clientStore = clientStore; + _logger = logger; + _events = events; + _signInManager = signInManager; + _roleManager = roleManager; + _dbContext = dbContext; + } + + /// + /// initiate roundtrip to external authentication provider + /// + [HttpGet] + public IActionResult Challenge(string scheme, string returnUrl) + { + if (string.IsNullOrEmpty(returnUrl)) returnUrl = "~/"; + + // validate returnUrl - either it is a valid OIDC URL or back to a local page + if (Url.IsLocalUrl(returnUrl) == false && _interaction.IsValidReturnUrl(returnUrl) == false) + { + // user might have clicked on a malicious link - should be logged + throw new Exception("invalid return URL"); + } + + // start challenge and roundtrip the return URL and scheme + var props = new AuthenticationProperties + { + RedirectUri = Url.Action(nameof(Callback)), + Items = + { + { "returnUrl", returnUrl }, + { "scheme", scheme }, + } + }; + + return Challenge(props, scheme); + + } + + /// + /// Post processing of external authentication + /// + [HttpGet] + public async Task Callback() + { + // read external identity from the temporary cookie + var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); + if (result?.Succeeded != true) + { + throw new Exception("External authentication error"); + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}"); + _logger.LogDebug("External claims: {@claims}", externalClaims); + } + + // lookup our user and external provider info + var (user, provider, providerUserId, claims) = await FindUserFromExternalProvider(result); + if (user == null) + { + // this might be where you might initiate a custom workflow for user registration + // in this sample we don't show how that would be done, as our sample implementation + // simply auto-provisions new external user + user = AutoProvisionUser(provider, providerUserId, claims); + } + + // this allows us to collect any additional claims or properties + // for the specific protocols used and store them in the local auth cookie. + // this is typically used to store data needed for signout from those protocols. + var additionalLocalClaims = new List(); + var localSignInProps = new AuthenticationProperties(); + ProcessLoginCallback(result, additionalLocalClaims, localSignInProps); + + // issue authentication cookie for user + var isuser = new IdentityServerUser(user.Id) + { + DisplayName = user.UserName, + IdentityProvider = provider, + AdditionalClaims = additionalLocalClaims + }; + await HttpContext.SignInAsync(isuser, localSignInProps); + //await HttpContext.SignInAsync(user, _roleManager, false, _dbContext); + + // delete temporary cookie used during external authentication + await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme); + // retrieve return URL + var returnUrl = result.Properties.Items["returnUrl"] ?? "~/"; + + // check if external login is in the context of an OIDC request + var context = await _interaction.GetAuthorizationContextAsync(returnUrl); + await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.Id, user.UserName, true, context?.Client.ClientId)); + + if (context != null) + { + if (context.IsNativeClient()) + { + // The client is native, so this change in how to + // return the response is for better UX for the end user. + return this.LoadingPage("Redirect", returnUrl); + } + } + + + return Redirect(returnUrl); + } + + private async Task<(ApplicationUser user, + string provider, + string providerUserId, + IEnumerable claims)> + FindUserFromExternalProvider(AuthenticateResult result) + { + var externalUser = result.Principal; + + // try to determine the unique id of the external user (issued by the provider) + // the most common claim type for that are the sub claim and the NameIdentifier + // depending on the external provider, some other claim type might be used + var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ?? + externalUser.FindFirst(ClaimTypes.NameIdentifier) ?? + throw new Exception("Unknown userid"); + + // remove the user id claim so we don't include it as an extra claim if/when we provision the user + var claims = externalUser.Claims.ToList(); + claims.Remove(userIdClaim); + + var provider = result.Properties.Items["scheme"]; + var providerUserId = userIdClaim.Value; + + // find external user + + ApplicationUser? user = await _users.FindByExternaleProviderAsync (provider, providerUserId); + + return (user, provider, providerUserId, claims); + } + +/// +/// Register a new user by external id +/// +/// +/// +/// +/// + private ApplicationUser AutoProvisionUser(string provider, string providerUserId, IEnumerable claims) + { + var user = _users.AutoProvisionUser(provider, providerUserId, claims.ToList()); + return user; + } + + // if the external login is OIDC-based, there are certain things we need to preserve to make logout work + // this will be different for WS-Fed, SAML2p or other protocols + private void ProcessLoginCallback(AuthenticateResult externalResult, List localClaims, AuthenticationProperties localSignInProps) + { + // if the external system sent a session id claim, copy it over + // so we can use it for single sign-out + var sid = externalResult.Principal.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId); + if (sid != null) + { + localClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value)); + } + + // if the external provider issued an id_token, we'll keep it for signout + var idToken = externalResult.Properties.GetTokenValue("id_token"); + if (idToken != null) + { + localSignInProps.StoreTokens(new[] { new AuthenticationToken { Name = "id_token", Value = idToken } }); + } + } +} diff --git a/src/Yavsc/Controllers/Accounting/IExternalIdentityProviderManager.cs b/src/Yavsc/Controllers/Accounting/IExternalIdentityProviderManager.cs new file mode 100644 index 00000000..8b762c5c --- /dev/null +++ b/src/Yavsc/Controllers/Accounting/IExternalIdentityProviderManager.cs @@ -0,0 +1,22 @@ +/* + Copyright (c) 2024 HigginsSoft, Alexander Higgins - https://github.com/alexhiggins732/ + + Copyright (c) 2018, Brock Allen & Dominick Baier. All rights reserved. + + Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + Source code and license this software can be found + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. +*/ + +using System.Security.Claims; +using Yavsc.Models; + +namespace Yavsc.Interfaces; + +public interface IExternalIdentityManager +{ + ApplicationUser AutoProvisionUser(string provider, string providerUserId, List claims); + Task FindByExternaleProviderAsync(string provider, string providerUserId); +} diff --git a/src/Yavsc/Extensions/HostingExtensions.cs b/src/Yavsc/Extensions/HostingExtensions.cs index abac3b0c..7b91dbc3 100644 --- a/src/Yavsc/Extensions/HostingExtensions.cs +++ b/src/Yavsc/Extensions/HostingExtensions.cs @@ -36,6 +36,7 @@ using Microsoft.IdentityModel.Protocols.Configuration; using IdentityModel; using System.Security.Claims; using IdentityServer8.Security; +using Yavsc.Interfaces; namespace Yavsc.Extensions; @@ -112,6 +113,7 @@ public static class HostingExtensions AddYavscPolicies(services); services.AddScoped(); + services.AddTransient(); AddAuthentication(builder); @@ -215,11 +217,12 @@ public static class HostingExtensions // set the redirect URI to https://localhost:5001/signin-google options.ClientId = googleClientId; options.ClientSecret = googleClientSecret; + }); } private static IIdentityServerBuilder AddIdentityServer(WebApplicationBuilder builder) { - builder.Services.AddTransient(); + //builder.Services.AddTransient(); var identityServerBuilder = builder.Services.AddIdentityServer(options => { options.Events.RaiseErrorEvents = true; @@ -234,7 +237,7 @@ public static class HostingExtensions .AddInMemoryIdentityResources(Config.IdentityResources) .AddInMemoryClients(Config.Clients) .AddInMemoryApiScopes(Config.ApiScopes) - .AddProfileService() + // .AddProfileService() .AddAspNetIdentity() ; if (builder.Environment.IsDevelopment()) diff --git a/src/Yavsc/Services/ExternalIdentityManager.cs b/src/Yavsc/Services/ExternalIdentityManager.cs new file mode 100644 index 00000000..df82ae90 --- /dev/null +++ b/src/Yavsc/Services/ExternalIdentityManager.cs @@ -0,0 +1,28 @@ +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using Yavsc.Interfaces; +using Yavsc.Models; + +public class ExternalIdentityManager : IExternalIdentityManager +{ + private ApplicationDbContext _applicationDbContext; + + public ExternalIdentityManager(ApplicationDbContext applicationDbContext) + { + _applicationDbContext = applicationDbContext; + } + public ApplicationUser AutoProvisionUser(string provider, string providerUserId, List claims) + { + throw new NotImplementedException(); + } + + public async Task FindByExternaleProviderAsync(string provider, string providerUserId) + { + var user = await _applicationDbContext.AspNetUserLogins + .FirstOrDefaultAsync( + i => (i.LoginProvider == provider) && (i.ProviderKey == providerUserId) + ); + if (user == null) return null; + return await _applicationDbContext.Users.FirstOrDefaultAsync(u=>u.Id == user.UserId); + } +} diff --git a/src/Yavsc/Views/Shared/_LoginPartial.cshtml b/src/Yavsc/Views/Shared/_LoginPartial.cshtml index 1288c981..b038b3c7 100644 --- a/src/Yavsc/Views/Shared/_LoginPartial.cshtml +++ b/src/Yavsc/Views/Shared/_LoginPartial.cshtml @@ -2,17 +2,10 @@ @inject SignInManager SignInManager @inject UserManager UserManager -@{ - #nullable enable - string? name = null; - if (Context.User!=null) - { - name = Context.User.GetUserName(); - } -} - -@if (name!=null) +@if (Context.User?.Identity?.IsAuthenticated ?? false) { + string userName = User.GetUserName(); + }