diff --git a/src/VirtoCommerce.Platform.Core/Extensions/StringExtensions.cs b/src/VirtoCommerce.Platform.Core/Extensions/StringExtensions.cs index d22fc11b143..90cd2942f55 100644 --- a/src/VirtoCommerce.Platform.Core/Extensions/StringExtensions.cs +++ b/src/VirtoCommerce.Platform.Core/Extensions/StringExtensions.cs @@ -325,11 +325,7 @@ public static string FirstCharToUpper(this string input) public static bool IsValidEmail(this string input) { - if (input == null) - { - throw new ArgumentNullException(nameof(input)); - } - return _emailRegex.IsMatch(input); + return input != null && _emailRegex.IsMatch(input); } public static string ToSnakeCase(this string name) diff --git a/src/VirtoCommerce.Platform.Core/PlatformOptions.cs b/src/VirtoCommerce.Platform.Core/PlatformOptions.cs index 3c70d8f7ba0..e55c60e1a4d 100644 --- a/src/VirtoCommerce.Platform.Core/PlatformOptions.cs +++ b/src/VirtoCommerce.Platform.Core/PlatformOptions.cs @@ -77,5 +77,7 @@ public class PlatformOptions /// Include null values when serializing Rest API objects. /// public bool IncludeOutputNullValues { get; set; } = true; + + public string ApplicationCookieName { get; set; } = ".VirtoCommerce.Identity.Application"; } } diff --git a/src/VirtoCommerce.Platform.Web/ActionConstraints/FormValueRequiredAttribute.cs b/src/VirtoCommerce.Platform.Web/ActionConstraints/FormValueRequiredAttribute.cs new file mode 100644 index 00000000000..b82bc559a30 --- /dev/null +++ b/src/VirtoCommerce.Platform.Web/ActionConstraints/FormValueRequiredAttribute.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Routing; + +namespace VirtoCommerce.Platform.Web.ActionConstraints; + +public sealed class FormValueRequiredAttribute : ActionMethodSelectorAttribute +{ + private readonly string _name; + private readonly string[] _forbiddenMethods = ["GET", "HEAD", "DELETE", "TRACE"]; + + public FormValueRequiredAttribute(string name) + { + _name = name; + } + + public override bool IsValidForRequest(RouteContext routeContext, ActionDescriptor action) + { + var request = routeContext.HttpContext.Request; + + if (_forbiddenMethods.Contains(request.Method, StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + if (string.IsNullOrEmpty(request.ContentType)) + { + return false; + } + + if (!request.ContentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return !string.IsNullOrEmpty(request.Form[_name]); + } +} diff --git a/src/VirtoCommerce.Platform.Web/Controllers/Api/AuthorizationController.cs b/src/VirtoCommerce.Platform.Web/Controllers/Api/AuthorizationController.cs index 6c1240c669d..ec93e488943 100644 --- a/src/VirtoCommerce.Platform.Web/Controllers/Api/AuthorizationController.cs +++ b/src/VirtoCommerce.Platform.Web/Controllers/Api/AuthorizationController.cs @@ -23,7 +23,10 @@ using VirtoCommerce.Platform.Security.Authorization; using VirtoCommerce.Platform.Security.Extensions; using VirtoCommerce.Platform.Security.OpenIddict; +using VirtoCommerce.Platform.Web.ActionConstraints; using VirtoCommerce.Platform.Web.Extensions; +using VirtoCommerce.Platform.Web.Model; +using VirtoCommerce.Platform.Web.Security; using static OpenIddict.Abstractions.OpenIddictConstants; namespace VirtoCommerce.Platform.Web.Controllers.Api @@ -41,6 +44,8 @@ public class AuthorizationController : Controller private readonly OpenIddictTokenManager _tokenManager; private readonly IAuthorizationService _authorizationService; private readonly IExternalSignInService _externalSignInService; + private readonly IOpenIddictAuthorizationManager _authorizationManager; + private readonly IOpenIddictScopeManager _scopeManager; public AuthorizationController( OpenIddictApplicationManager applicationManager, @@ -52,7 +57,9 @@ public AuthorizationController( IEnumerable claimProviders, OpenIddictTokenManager tokenManager, IAuthorizationService authorizationService, - IExternalSignInService externalSignInService) + IExternalSignInService externalSignInService, + IOpenIddictAuthorizationManager authorizationManager, + IOpenIddictScopeManager scopeManager) { _applicationManager = applicationManager; _identityOptions = identityOptions.Value; @@ -65,6 +72,8 @@ public AuthorizationController( _tokenManager = tokenManager; _authorizationService = authorizationService; _externalSignInService = externalSignInService; + _authorizationManager = authorizationManager; + _scopeManager = scopeManager; } [HttpPost("~/revoke/token")] @@ -174,8 +183,7 @@ public async Task Exchange() // Retrieve the user profile corresponding to the authorization code/refresh token. // Note: if you want to automatically invalidate the authorization code/refresh token - // when the user password/roles change, use the following line instead: - // var user = _signInManager.ValidateSecurityStampAsync(info.Principal); + // when the user password/roles change, use _signInManager.ValidateSecurityStampAsync(info.Principal) instead. var user = await _userManager.GetUserAsync(info.Principal); if (user == null) { @@ -330,11 +338,373 @@ public async Task Exchange() return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme); } + if (openIdConnectRequest.IsAuthorizationCodeGrantType()) + { + // Retrieve the claims principal stored in the refresh token. + var info = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + // Retrieve the user profile corresponding to the refresh token. + // Note: if you want to automatically invalidate the refresh token + // when the user password/roles change, use _signInManager.ValidateSecurityStampAsync(info.Principal) instead. + var user = await _userManager.GetUserAsync(info.Principal); + if (user == null) + { + var properties = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The refresh token is no longer valid.", + }); + + return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + // Ensure the user is still allowed to sign in. + if (!await _signInManager.CanSignInAsync(user)) + { + var properties = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is no longer allowed to sign in.", + }); + + return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + // Create a new ClaimsPrincipal containing the claims that + // will be used to create an id_token, a token or a code. + var principal = await _signInManager.CreateUserPrincipalAsync(user); + + foreach (var claim in principal.Claims) + { + claim.SetDestinations(GetDestinations(claim, principal)); + } + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + return BadRequest(SecurityErrorDescriber.UnsupportedGrantType()); } #endregion + // GET: /api/userinfo + [HttpGet("~/connect/userinfo"), HttpPost("~/connect/userinfo"), Produces("application/json")] + [Authorize(AuthenticationSchemes = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)] + public async Task Userinfo() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return Challenge( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidToken, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The specified access token is bound to an account that no longer exists.", + })); + } + + var claims = new Dictionary(StringComparer.Ordinal) + { + // Note: the "sub" claim is a mandatory claim and must be included in the JSON response. + [Claims.Subject] = await _userManager.GetUserIdAsync(user), + [Claims.Username] = user.UserName, + [Claims.Name] = user.UserName + }; + + if (User.HasScope(Scopes.Email)) + { + claims[Claims.Email] = await _userManager.GetEmailAsync(user); + claims[Claims.EmailVerified] = await _userManager.IsEmailConfirmedAsync(user); + } + + if (User.HasScope(Scopes.Phone)) + { + claims[Claims.PhoneNumber] = await _userManager.GetPhoneNumberAsync(user); + claims[Claims.PhoneNumberVerified] = await _userManager.IsPhoneNumberConfirmedAsync(user); + } + + if (User.HasScope(Scopes.Roles)) + { + claims[Claims.Role] = await _userManager.GetRolesAsync(user); + } + + // Note: the complete list of standard claims supported by the OpenID Connect specification + // can be found here: http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + + return Ok(claims); + } + + [HttpGet("~/connect/authorize")] + [HttpPost("~/connect/authorize")] + [IgnoreAntiforgeryToken] + public async Task Authorize() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + + // If prompt=login was specified by the client application, + + // Retrieve the user principal stored in the authentication cookie. + // If a max_age parameter was provided, ensure that the cookie is not too old. + // If the user principal can't be extracted or the cookie is too old, redirect the user to the login page. + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + if (!result.Succeeded || (request.MaxAge != null && result.Properties?.IssuedUtc != null && + DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value))) + { + // If the client application requested promptless authentication, + // return an error indicating that the user is not logged in. + if (request.HasPrompt(Prompts.None)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in.", + })); + } + + return Challenge( + authenticationSchemes: IdentityConstants.ApplicationScheme, + properties: new AuthenticationProperties + { + RedirectUri = Request.PathBase + Request.Path + QueryString.Create( + Request.HasFormContentType ? Request.Form.ToList() : Request.Query.ToList()) + }); + } + + + // Retrieve the profile of the logged-in user. + var user = await _userManager.GetUserAsync(result.Principal) ?? + throw new InvalidOperationException("The user details cannot be retrieved."); + + // Retrieve the application details from the database. + var application = await _applicationManager.FindByClientIdAsync(request.ClientId) ?? + throw new InvalidOperationException("Details concerning the calling client application cannot be found."); + + // Retrieve the permanent authorizations associated with the user and the calling client application. + var authorizations = await _authorizationManager.FindAsync( + subject: await _userManager.GetUserIdAsync(user), + client: await _applicationManager.GetIdAsync(application), + status: Statuses.Valid, + type: AuthorizationTypes.Permanent, + scopes: request.GetScopes()).ToListAsync(); + + switch (await _applicationManager.GetConsentTypeAsync(application)) + { + // If the consent is external (e.g. when authorizations are granted by a sysadmin), + // immediately return an error if no authorization can be found in the database. + case ConsentTypes.External when authorizations.Count == 0: + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application.", + })); + + // If the consent is implicit or if an authorization was found, + // return an authorization response without displaying the consent form. + case ConsentTypes.Implicit: + case ConsentTypes.External when authorizations.Count != 0: + case ConsentTypes.Explicit when authorizations.Count != 0 && !request.HasPrompt(Prompts.Consent): + var principal = await _signInManager.CreateUserPrincipalAsync(user); + + // Note: in this sample, the granted scopes match the requested scope, + // but you may want to allow the user to uncheck specific scopes. + // For that, simply restrict the list of scopes before calling SetScopes. + principal.SetScopes(request.GetScopes()); + principal.SetResources(await _scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); + + // Automatically create a permanent authorization to avoid requiring explicit consent + // for future authorization or token requests containing the same scopes. + var authorization = authorizations.LastOrDefault() ?? + await _authorizationManager.CreateAsync( + principal: principal, + subject: await _userManager.GetUserIdAsync(user), + client: await _applicationManager.GetIdAsync(application), + type: AuthorizationTypes.Permanent, + scopes: principal.GetScopes()); + + principal.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization)); + + foreach (var claim in principal.Claims) + { + claim.SetDestinations(GetDestinations(claim, principal)); + } + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + // At this point, no authorization was found in the database and an error must be returned + // if the client application specified prompt=none in the authorization request. + case ConsentTypes.Explicit when request.HasPrompt(Prompts.None): + case ConsentTypes.Systematic when request.HasPrompt(Prompts.None): + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Interactive user consent is required.", + })); + + // In every other case, render the consent form. + default: + HttpContext.OverrideScpFormActionUri(request.RedirectUri); + + return View(new AuthorizeViewModel + { + ApplicationName = await _applicationManager.GetDisplayNameAsync(application), + Scope = request.Scope + }); + } + } + + [HttpPost("~/connect/authorize")] + [Authorize, FormValueRequired("submit.Deny")] + // Notify OpenIddict that the authorization grant has been denied by the resource owner + // to redirect the user agent to the client application using the appropriate response_mode. + public IActionResult Deny() + { + return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + [HttpPost("~/connect/authorize"),] + [Authorize, FormValueRequired("submit.Accept")] + public async Task Accept() + { + var request = HttpContext.GetOpenIddictServerRequest() ?? + throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + + // Retrieve the profile of the logged-in user. + var user = await _userManager.GetUserAsync(User) ?? + throw new InvalidOperationException("The user details cannot be retrieved."); + + // Retrieve the application details from the database. + var application = await _applicationManager.FindByClientIdAsync(request.ClientId) ?? + throw new InvalidOperationException("Details concerning the calling client application cannot be found."); + + // Retrieve the permanent authorizations associated with the user and the calling client application. + var authorizations = await _authorizationManager.FindAsync( + subject: await _userManager.GetUserIdAsync(user), + client: await _applicationManager.GetIdAsync(application), + status: Statuses.Valid, + type: AuthorizationTypes.Permanent, + scopes: request.GetScopes()).ToListAsync(); + + // Note: the same check is already made in the other action but is repeated + // here to ensure a malicious user can't abuse this POST-only endpoint and + // force it to return a valid response without the external authorization. + if (authorizations.Count == 0 && await _applicationManager.HasConsentTypeAsync(application, ConsentTypes.External)) + { + return Forbid( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The logged in user is not allowed to access this client application.", + })); + } + + var principal = await _signInManager.CreateUserPrincipalAsync(user); + + // Note: in this sample, the granted scopes match the requested scope, + // but you may want to allow the user to uncheck specific scopes. + // For that, simply restrict the list of scopes before calling SetScopes. + principal.SetScopes(request.GetScopes()); + principal.SetResources(await _scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync()); + + // Automatically create a permanent authorization to avoid requiring explicit consent + // for future authorization or token requests containing the same scopes. + var authorization = authorizations.LastOrDefault() ?? + await _authorizationManager.CreateAsync( + principal: principal, + subject: await _userManager.GetUserIdAsync(user), + client: await _applicationManager.GetIdAsync(application), + type: AuthorizationTypes.Permanent, + scopes: principal.GetScopes()); + + principal.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization)); + + foreach (var claim in principal.Claims) + { + claim.SetDestinations(GetDestinations(claim, principal)); + } + + // Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens. + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + [HttpGet("~/connect/logout")] + public async Task Logout() + { + // Ask ASP.NET Core Identity to delete the local and external cookies created + // when the user agent is redirected from the external identity provider + // after a successful authentication flow (e.g. Google or Facebook). + await _signInManager.SignOutAsync(); + + // Returning a SignOutResult will ask OpenIddict to redirect the user agent + // to the post_logout_redirect_uri specified by the client application or to + // the RedirectUri specified in the authentication properties if none was set. + return SignOut( + authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme, + properties: new AuthenticationProperties + { + RedirectUri = "/" + }); + } + + private static IEnumerable GetDestinations(Claim claim, ClaimsPrincipal principal) + { + // Note: by default, claims are NOT automatically included in the access and identity tokens. + // To allow OpenIddict to serialize them, you must attach them a destination, that specifies + // whether they should be included in access tokens, in identity tokens or in both. + + switch (claim.Type) + { + case Claims.Name: + yield return Destinations.AccessToken; + + if (principal.HasScope(Scopes.Profile)) + { + yield return Destinations.IdentityToken; + } + + yield break; + + case Claims.Email: + yield return Destinations.AccessToken; + + if (principal.HasScope(Scopes.Email)) + { + yield return Destinations.IdentityToken; + } + + yield break; + + case Claims.Role: + yield return Destinations.AccessToken; + + if (principal.HasScope(Scopes.Roles)) + { + yield return Destinations.IdentityToken; + } + + yield break; + + // Never include the security stamp in the access and identity tokens, as it's a secret value. + case "AspNet.Identity.SecurityStamp": + yield break; + + default: + yield return Destinations.AccessToken; + yield break; + } + } + private AuthenticationTicket CreateTicket(OpenIddictEntityFrameworkCoreApplication application) { // Create a new ClaimsIdentity containing the claims that diff --git a/src/VirtoCommerce.Platform.Web/Controllers/Api/OAuthAppsController.cs b/src/VirtoCommerce.Platform.Web/Controllers/Api/OAuthAppsController.cs index 390cecdf2f5..67ab4601825 100644 --- a/src/VirtoCommerce.Platform.Web/Controllers/Api/OAuthAppsController.cs +++ b/src/VirtoCommerce.Platform.Web/Controllers/Api/OAuthAppsController.cs @@ -22,9 +22,13 @@ public class OAuthAppsController : Controller private readonly ISet _defaultPermissions = new HashSet { OpenIddictConstants.Permissions.Endpoints.Authorization, + OpenIddictConstants.Permissions.Endpoints.Logout, OpenIddictConstants.Permissions.Endpoints.Token, OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, - OpenIddictConstants.Permissions.GrantTypes.ClientCredentials + OpenIddictConstants.Permissions.GrantTypes.ClientCredentials, + OpenIddictConstants.Permissions.ResponseTypes.Code, + OpenIddictConstants.Permissions.Scopes.Email, + OpenIddictConstants.Permissions.Scopes.Profile, }; public OAuthAppsController(OpenIddictApplicationManager manager) diff --git a/src/VirtoCommerce.Platform.Web/Model/AuthorizeViewModel.cs b/src/VirtoCommerce.Platform.Web/Model/AuthorizeViewModel.cs new file mode 100644 index 00000000000..1b5f706c0fc --- /dev/null +++ b/src/VirtoCommerce.Platform.Web/Model/AuthorizeViewModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace VirtoCommerce.Platform.Web.Model; + +public class AuthorizeViewModel +{ + [Display(Name = "Application")] + public string ApplicationName { get; set; } + + [Display(Name = "Scope")] + public string Scope { get; set; } +} diff --git a/src/VirtoCommerce.Platform.Web/PushNotifications/PushNotificationUserIdProvider.cs b/src/VirtoCommerce.Platform.Web/PushNotifications/PushNotificationUserIdProvider.cs index a953bfaa9ca..b318ff1bfa4 100644 --- a/src/VirtoCommerce.Platform.Web/PushNotifications/PushNotificationUserIdProvider.cs +++ b/src/VirtoCommerce.Platform.Web/PushNotifications/PushNotificationUserIdProvider.cs @@ -9,6 +9,6 @@ public class PushNotificationUserIdProvider : IUserIdProvider public virtual string GetUserId(HubConnectionContext connection) { // Return user name for compatibility with PushNotification.Creator - return connection.User.FindFirstValue(OpenIddictConstants.Claims.Subject); + return connection.User.FindFirstValue(OpenIddictConstants.Claims.Name); } } diff --git a/src/VirtoCommerce.Platform.Web/Security/Authorization/PermissionAuthorizationPolicyProvider.cs b/src/VirtoCommerce.Platform.Web/Security/Authorization/PermissionAuthorizationPolicyProvider.cs index 76d544a591c..b19d6861ea1 100644 --- a/src/VirtoCommerce.Platform.Web/Security/Authorization/PermissionAuthorizationPolicyProvider.cs +++ b/src/VirtoCommerce.Platform.Web/Security/Authorization/PermissionAuthorizationPolicyProvider.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; +using OpenIddict.Validation.AspNetCore; using VirtoCommerce.Platform.Core.Caching; using VirtoCommerce.Platform.Core.Security; using VirtoCommerce.Platform.Security.Authorization; @@ -49,7 +50,7 @@ private Dictionary GetDynamicAuthorizationPoliciesF { resultLookup[permission.Name] = new AuthorizationPolicyBuilder().AddRequirements(new PermissionAuthorizationRequirement(permission.Name)) //Use the three schemas (JwtBearer, ApiKey and Basic) authentication for permission authorization policies. - .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, ApiKeyAuthenticationOptions.DefaultScheme, BasicAuthenticationOptions.DefaultScheme) + .AddAuthenticationSchemes(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, ApiKeyAuthenticationOptions.DefaultScheme, BasicAuthenticationOptions.DefaultScheme) .Build(); } return resultLookup; diff --git a/src/VirtoCommerce.Platform.Web/Security/ExternalSignInService.cs b/src/VirtoCommerce.Platform.Web/Security/ExternalSignInService.cs index c26ee3d4ca1..45b3c98ba82 100644 --- a/src/VirtoCommerce.Platform.Web/Security/ExternalSignInService.cs +++ b/src/VirtoCommerce.Platform.Web/Security/ExternalSignInService.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; using VirtoCommerce.Platform.Core; using VirtoCommerce.Platform.Core.Common; using VirtoCommerce.Platform.Core.Events; @@ -215,7 +216,7 @@ private bool TryGetUserInfo(ExternalLoginInfo externalLoginInfo, out string user if (providerConfig?.Provider is not null) { userName = providerConfig.Provider.GetUserName(externalLoginInfo); - userEmail = externalLoginInfo.Principal.FindFirstValue(ClaimTypes.Email) ?? + userEmail = externalLoginInfo.Principal.FindFirstValue(OpenIddictConstants.Claims.Email) ?? (userName.IsValidEmail() ? userName : null); } diff --git a/src/VirtoCommerce.Platform.Web/Security/ServiceCollectionExtensions.cs b/src/VirtoCommerce.Platform.Web/Security/ServiceCollectionExtensions.cs index 1e037a30af0..8aa82cffe14 100644 --- a/src/VirtoCommerce.Platform.Web/Security/ServiceCollectionExtensions.cs +++ b/src/VirtoCommerce.Platform.Web/Security/ServiceCollectionExtensions.cs @@ -1,10 +1,13 @@ using System; +using System.Collections.Concurrent; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using NetEscapades.AspNetCore.SecurityHeaders.Headers.ContentSecurityPolicy; using VirtoCommerce.Platform.Core.Common; using VirtoCommerce.Platform.Core.Security; using VirtoCommerce.Platform.Core.Security.Search; @@ -19,6 +22,9 @@ namespace VirtoCommerce.Platform.Web.Security { public static class ServiceCollectionExtensions { + private const string _scpFormActionUriKey = "Content-Security-Policy-Form-Action-Uri"; + private static readonly ConcurrentDictionary _policyCache = new(); + public static IServiceCollection AddSecurityServices(this IServiceCollection services, Action setupAction = null) { services.AddTransient(); @@ -120,44 +126,79 @@ public static void AddForwardedHeaders(this IServiceCollection services) } } - public static void UseCustomSecurityHeaders(this IApplicationBuilder app) + public static void AddCustomSecurityHeaders(this IServiceCollection services) { - var policies = new HeaderPolicyCollection().AddDefaultSecurityHeaders(); - var options = app.ApplicationServices.GetService>().Value; + services.AddSecurityHeaderPolicies() + .SetPolicySelector(context => + { + var options = context.HttpContext.RequestServices.GetService>().Value; + var formActionUri = context.HttpContext.GetFormActionUri() ?? string.Empty; + + if (_policyCache.TryGetValue(formActionUri, out var policies)) + { + return policies; + } + + policies = new HeaderPolicyCollection().AddDefaultSecurityHeaders(); + + if (options.FrameOptions.EqualsIgnoreCase("SameOrigin")) + { + policies.AddFrameOptionsSameOrigin(); + } + else if (options.FrameOptions.EqualsIgnoreCase("Deny")) + { + policies.AddFrameOptionsDeny(); + } + else if (!string.IsNullOrEmpty(options.FrameOptions)) + { + policies.AddFrameOptionsSameOrigin(options.FrameOptions); + } + + policies.AddContentSecurityPolicy(builder => + { + builder.AddObjectSrc().None(); + builder.AddFormAction().Self().Uri(formActionUri); + + if (options.FrameAncestors.EqualsIgnoreCase("None")) + { + builder.AddFrameAncestors().None(); + } + else if (options.FrameAncestors.EqualsIgnoreCase("Self")) + { + builder.AddFrameAncestors().Self(); + } + else if (!string.IsNullOrEmpty(options.FrameAncestors)) + { + builder.AddFrameAncestors().From(options.FrameAncestors); + } + }); + + _policyCache.AddOrUpdate(formActionUri, policies, (_, _) => policies); + + return policies; + }); + } - if (options.FrameOptions.EqualsIgnoreCase("SameOrigin")) - { - policies.AddFrameOptionsSameOrigin(); - } - else if (options.FrameOptions.EqualsIgnoreCase("Deny")) - { - policies.AddFrameOptionsDeny(); - } - else if (!string.IsNullOrEmpty(options.FrameOptions)) - { - policies.AddFrameOptionsSameOrigin(options.FrameOptions); - } + public static void OverrideScpFormActionUri(this HttpContext httpContext, string uri) + { + httpContext.Items[_scpFormActionUriKey] = uri; + } - policies.AddContentSecurityPolicy(builder => - { - builder.AddObjectSrc().None(); - builder.AddFormAction().Self(); + public static string GetFormActionUri(this HttpContext httpContext) + { + return httpContext.Items.TryGetValue(_scpFormActionUriKey, out var value) + ? value as string + : null; + } - if (options.FrameAncestors.EqualsIgnoreCase("None")) - { - builder.AddFrameAncestors().None(); - } - else if (options.FrameAncestors.EqualsIgnoreCase("Self")) - { - builder.AddFrameAncestors().Self(); - } - else if (!string.IsNullOrEmpty(options.FrameAncestors)) - { - builder.AddFrameAncestors().From(options.FrameAncestors); - } - }); + public static T Uri(this T builder, string uri) where T : CspDirectiveBuilder + { + if (!string.IsNullOrWhiteSpace(uri)) + { + builder.Sources.Add(uri); + } - app.UseSecurityHeaders(policies); + return builder; } } } diff --git a/src/VirtoCommerce.Platform.Web/Startup.cs b/src/VirtoCommerce.Platform.Web/Startup.cs index 82ab2566dc7..ed08542b105 100644 --- a/src/VirtoCommerce.Platform.Web/Startup.cs +++ b/src/VirtoCommerce.Platform.Web/Startup.cs @@ -3,14 +3,11 @@ using System.IdentityModel.Tokens.Jwt; using System.IO; using System.Linq; -using System.Net; using System.Reflection; using System.Runtime.InteropServices; using System.Security.Cryptography.X509Certificates; using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -34,6 +31,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using OpenIddict.Abstractions; +using OpenIddict.Validation.AspNetCore; using VirtoCommerce.Platform.Core; using VirtoCommerce.Platform.Core.Common; using VirtoCommerce.Platform.Core.DynamicProperties; @@ -77,7 +75,6 @@ using VirtoCommerce.Platform.Web.Security.Authorization; using VirtoCommerce.Platform.Web.Swagger; using JsonSerializer = Newtonsoft.Json.JsonSerializer; -using MsTokens = Microsoft.IdentityModel.Tokens; namespace VirtoCommerce.Platform.Web @@ -108,6 +105,7 @@ public void ConfigureServices(IServiceCollection services) // Optional Modules Dependecy Resolving services.Add(ServiceDescriptor.Singleton(typeof(IOptionalDependency<>), typeof(OptionalDependencyManager<>))); + services.AddCustomSecurityHeaders(); services.AddForwardedHeaders(); services.AddSingleton(); @@ -250,7 +248,7 @@ public void ConfigureServices(IServiceCollection services) options.MinimumSameSitePolicy = SameSiteMode.None; }); - var authBuilder = services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + var authBuilder = services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme) //Add the second ApiKey auth schema to handle api_key in query string .AddScheme(ApiKeyAuthenticationOptions.DefaultScheme, options => { }) //Add the third BasicAuth auth schema @@ -271,9 +269,10 @@ public void ConfigureServices(IServiceCollection services) // which saves you from doing the mapping in your authorization controller. services.Configure(options => { - options.ClaimsIdentity.UserNameClaimType = OpenIddictConstants.Claims.Subject; - options.ClaimsIdentity.UserIdClaimType = OpenIddictConstants.Claims.Name; + options.ClaimsIdentity.UserNameClaimType = OpenIddictConstants.Claims.Name; + options.ClaimsIdentity.UserIdClaimType = OpenIddictConstants.Claims.Subject; options.ClaimsIdentity.RoleClaimType = OpenIddictConstants.Claims.Role; + options.ClaimsIdentity.EmailClaimType = OpenIddictConstants.Claims.Email; }); services.ConfigureOptions(); @@ -310,31 +309,31 @@ public void ConfigureServices(IServiceCollection services) // register it as a singleton to use in external login providers services.AddSingleton(defaultTokenHandler); - authBuilder.AddJwtBearer(options => - { - options.Authority = Configuration["Auth:Authority"]; - options.Audience = Configuration["Auth:Audience"]; - - if (WebHostEnvironment.IsDevelopment()) - { - options.RequireHttpsMetadata = false; - options.IncludeErrorDetails = true; - } - - MsTokens.X509SecurityKey publicKey = null; - - var publicCert = ServerCertificate.X509Certificate; - publicKey = new MsTokens.X509SecurityKey(publicCert); - options.MapInboundClaims = false; - options.TokenValidationParameters = new MsTokens.TokenValidationParameters - { - NameClaimType = OpenIddictConstants.Claims.Subject, - RoleClaimType = OpenIddictConstants.Claims.Role, - ValidateIssuer = !string.IsNullOrEmpty(options.Authority), - ValidateIssuerSigningKey = true, - IssuerSigningKey = publicKey - }; - }); + //authBuilder.AddJwtBearer(options => + //{ + // options.Authority = Configuration["Auth:Authority"]; + // options.Audience = Configuration["Auth:Audience"]; + + // if (WebHostEnvironment.IsDevelopment()) + // { + // options.RequireHttpsMetadata = false; + // options.IncludeErrorDetails = true; + // } + + // MsTokens.X509SecurityKey publicKey = null; + + // var publicCert = ServerCertificate.X509Certificate; + // publicKey = new MsTokens.X509SecurityKey(publicCert); + // options.MapInboundClaims = false; + // options.TokenValidationParameters = new MsTokens.TokenValidationParameters + // { + // NameClaimType = OpenIddictConstants.Claims.Subject, + // RoleClaimType = OpenIddictConstants.Claims.Role, + // ValidateIssuer = !string.IsNullOrEmpty(options.Authority), + // ValidateIssuerSigningKey = true, + // IssuerSigningKey = publicKey + // }; + //}); services.AddOptions().Bind(Configuration.GetSection("Authorization")).ValidateDataAnnotations(); var authorizationOptions = Configuration.GetSection("Authorization").Get(); @@ -352,19 +351,26 @@ public void ConfigureServices(IServiceCollection services) // Register the ASP.NET Core MVC binder used by OpenIddict. // Note: if you don't call this method, you won't be able to // bind OpenIdConnectRequest or OpenIdConnectResponse parameters. - var builder = options.UseAspNetCore(). - EnableTokenEndpointPassthrough(). - EnableAuthorizationEndpointPassthrough(); + var builder = options.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableLogoutEndpointPassthrough() + .EnableTokenEndpointPassthrough() + .EnableUserinfoEndpointPassthrough() + .EnableStatusCodePagesIntegration(); // Enable the authorization, logout, token and userinfo endpoints. - options.SetTokenEndpointUris("connect/token"); - options.SetUserinfoEndpointUris("api/security/userinfo"); + options.SetTokenEndpointUris("/connect/token") + .SetUserinfoEndpointUris("/connect/userinfo") + .SetAuthorizationEndpointUris("/connect/authorize") + .SetLogoutEndpointUris("/connect/logout"); // Note: the Mvc.Client sample only uses the code flow and the password flow, but you // can enable the other flows if you need to support implicit or client credentials. - options.AllowPasswordFlow() + options + .AllowPasswordFlow() .AllowRefreshTokenFlow() .AllowClientCredentialsFlow() + .AllowAuthorizationCodeFlow() .AllowCustomFlow(PlatformConstants.Security.GrantTypes.Impersonate) .AllowCustomFlow(PlatformConstants.Security.GrantTypes.ExternalSignIn); @@ -412,8 +418,16 @@ public void ConfigureServices(IServiceCollection services) { privateKey = new X509Certificate2(ServerCertificate.PrivateKeyCertBytes, ServerCertificate.PrivateKeyCertPassword, X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.EphemeralKeySet); } + options.AddSigningCertificate(privateKey); options.AddEncryptionCertificate(privateKey); + }) + .AddValidation(options => + { + // Import the configuration from the local OpenIddict server instance. + options.UseLocalServer(); + // Register the ASP.NET Core host. + options.UseAspNetCore(); }); services.Configure(Configuration.GetSection("IdentityOptions")); @@ -427,25 +441,29 @@ public void ConfigureServices(IServiceCollection services) //always return 401 instead of 302 for unauthorized requests services.ConfigureApplicationCookie(options => { - options.Cookie.Name = ".VirtoCommerce.Identity.Application"; - - options.Events.OnRedirectToLogin = context => - { - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - return Task.CompletedTask; - }; - options.Events.OnRedirectToAccessDenied = context => - { - context.Response.StatusCode = (int)HttpStatusCode.Forbidden; - return Task.CompletedTask; - }; + options.Cookie.Name = platformOptions.ApplicationCookieName; + options.LoginPath = "/"; + //TODO: Temporary comment return status codes instead of redirection. It is required for + //normal authorization code flow. We should implement login form as server side view. + //This logic is used to handle 401 errors when token is expired to force redirect to angular login form + //in case we have server side login form, this logic is no longer needed and can be removed. + //options.Events.OnRedirectToLogin = context => + //{ + // context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + // return Task.CompletedTask; + //}; + //options.Events.OnRedirectToAccessDenied = context => + //{ + // context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + // return Task.CompletedTask; + //}; }); services.AddAuthorization(options => { //We need this policy because it is a single way to implicitly use the three schemas (JwtBearer, ApiKey and Basic) authentication for resource based authorization. var multipleSchemaAuthPolicy = new AuthorizationPolicyBuilder() - .AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, ApiKeyAuthenticationOptions.DefaultScheme, BasicAuthenticationOptions.DefaultScheme) + .AddAuthenticationSchemes(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme, ApiKeyAuthenticationOptions.DefaultScheme, BasicAuthenticationOptions.DefaultScheme) .RequireAuthenticatedUser() // Customer user can get token, but can't use any API where auth is needed .RequireAssertion(context => @@ -560,7 +578,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILogger< app.UseHsts(); } - app.UseCustomSecurityHeaders(); + app.UseSecurityHeaders(); //Return all errors as Json response app.UseMiddleware(); diff --git a/src/VirtoCommerce.Platform.Web/Views/Shared/Authorize.cshtml b/src/VirtoCommerce.Platform.Web/Views/Shared/Authorize.cshtml new file mode 100644 index 00000000000..a983002555a --- /dev/null +++ b/src/VirtoCommerce.Platform.Web/Views/Shared/Authorize.cshtml @@ -0,0 +1,22 @@ +@using Microsoft.Extensions.Primitives +@model VirtoCommerce.Platform.Web.Model.AuthorizeViewModel +@{ + Layout = null; +} +
+

Authorization

+ +

Do you want to grant @Model.ApplicationName access to your data? (scopes requested: @Model.Scope)

+ +
+ @* Flow the request parameters so they can be received by the Accept/Reject actions: *@ + @foreach (var parameter in Context.Request.HasFormContentType ? + (IEnumerable>) Context.Request.Form : Context.Request.Query) + { + + } + + + + +
diff --git a/src/VirtoCommerce.Platform.Web/VirtoCommerce.Platform.Web.csproj b/src/VirtoCommerce.Platform.Web/VirtoCommerce.Platform.Web.csproj index b3b5633c321..fb3c5c8a20f 100644 --- a/src/VirtoCommerce.Platform.Web/VirtoCommerce.Platform.Web.csproj +++ b/src/VirtoCommerce.Platform.Web/VirtoCommerce.Platform.Web.csproj @@ -32,7 +32,7 @@ - + diff --git a/src/VirtoCommerce.Platform.Web/wwwroot/js/app/app.js b/src/VirtoCommerce.Platform.Web/wwwroot/js/app/app.js index d13f6e6cfe7..ddaf31b09c6 100644 --- a/src/VirtoCommerce.Platform.Web/wwwroot/js/app/app.js +++ b/src/VirtoCommerce.Platform.Web/wwwroot/js/app/app.js @@ -15,9 +15,9 @@ angular.lowercase = function (text) { angular.module('platformWebApp', AppDependencies).controller('platformWebApp.appCtrl', ['$rootScope', '$scope', 'platformWebApp.mainMenuService', 'platformWebApp.i18n', 'platformWebApp.modules', 'platformWebApp.moduleHelper', '$state', 'platformWebApp.bladeNavigationService', 'platformWebApp.userProfile', - 'platformWebApp.settings', 'platformWebApp.common', 'THEME_SETTINGS', 'platformWebApp.webApps', + 'platformWebApp.settings', 'platformWebApp.common', 'THEME_SETTINGS', 'platformWebApp.webApps', 'platformWebApp.urlHelper', function ($rootScope, $scope, mainMenuService, - i18n, modules, moduleHelper, $state, bladeNavigationService, userProfile, settings, common, THEME_SETTINGS, webApps) { + i18n, modules, moduleHelper, $state, bladeNavigationService, userProfile, settings, common, THEME_SETTINGS, webApps, urlHelper) { $scope.closeError = function () { $scope.platformError = undefined; @@ -61,7 +61,13 @@ angular.module('platformWebApp', AppDependencies).controller('platformWebApp.app var moduleErrors = "

" + x.id + " " + x.version + "
" + x.validationErrors.join("
"); $scope.platformError.detail += moduleErrors; }); - $state.go('workspace.modularity'); + var returnUrl = urlHelper.getSafeReturnUrl(); + if (returnUrl) { + window.location.href = returnUrl; + } + else { + $state.go('workspace.modularity'); + } } }); @@ -335,8 +341,13 @@ angular.module('platformWebApp', AppDependencies).controller('platformWebApp.app // Comment the following line while debugging or execute this in browser console: angular.reloadWithDebugInfo(); $compileProvider.debugInfoEnabled(false); }]) - .run(['$location', '$rootScope', '$state', '$stateParams', 'platformWebApp.authService', 'platformWebApp.mainMenuService', 'platformWebApp.pushNotificationService', 'platformWebApp.dialogService', '$window', '$animate', '$templateCache', 'gridsterConfig', 'taOptions', '$timeout', '$templateRequest', '$compile', 'platformWebApp.toolbarService', 'platformWebApp.loginOfBehalfUrlResolver', - function ($location, $rootScope, $state, $stateParams, authService, mainMenuService, pushNotificationService, dialogService, $window, $animate, $templateCache, gridsterConfig, taOptions, $timeout, $templateRequest, $compile, toolbarService, loginOfBehalfUrlResolver) { + .run(['$location', '$rootScope', '$state', '$stateParams', 'platformWebApp.authService', 'platformWebApp.mainMenuService', + 'platformWebApp.pushNotificationService', 'platformWebApp.dialogService', '$window', '$animate', '$templateCache', + 'gridsterConfig', 'taOptions', '$timeout', '$templateRequest', '$compile', 'platformWebApp.toolbarService', + 'platformWebApp.loginOfBehalfUrlResolver', 'platformWebApp.urlHelper', + function ($location, $rootScope, $state, $stateParams, authService, mainMenuService, pushNotificationService, + dialogService, $window, $animate, $templateCache, gridsterConfig, taOptions, $timeout, $templateRequest, + $compile, toolbarService, loginOfBehalfUrlResolver, urlHelper) { //Disable animation $animate.enabled(false); @@ -440,7 +451,13 @@ angular.module('platformWebApp', AppDependencies).controller('platformWebApp.app } else if (!authContext.isAdministrator && !authContext.permissions?.length) { $state.go('contact-admin'); } else if (!currentState.name || currentState.name === 'loginDialog') { - $state.go('workspace'); + var returnUrl = urlHelper.getSafeReturnUrl(); + if (returnUrl) { + window.location.href = returnUrl; + } + else { + $state.go('workspace'); + } } }, 500); }); diff --git a/src/VirtoCommerce.Platform.Web/wwwroot/js/common/urlHelper.js b/src/VirtoCommerce.Platform.Web/wwwroot/js/common/urlHelper.js new file mode 100644 index 00000000000..fd8a0fd5a2d --- /dev/null +++ b/src/VirtoCommerce.Platform.Web/wwwroot/js/common/urlHelper.js @@ -0,0 +1,30 @@ +angular.module('platformWebApp').factory('platformWebApp.urlHelper', [function () { + function getSafeReturnUrl() { + const returnUrl = getQueryValue('ReturnUrl'); + if (returnUrl && isLocalUrl(returnUrl)) { + return returnUrl; + } + + return undefined; + } + + function getQueryValue(name) { + const query = new URLSearchParams(window.location.search); + return query.get(name); + } + + function isLocalUrl(value) { + try { + const url = new URL(value, window.location.origin); + return url.origin === window.location.origin; + } catch (e) { + return false; + } + } + + return { + getSafeReturnUrl, + getQueryValue, + isLocalUrl + }; +}]);