diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml new file mode 100644 index 00000000..135e25d7 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml @@ -0,0 +1,119 @@ +@page +@model ManageApiKeysModel +@{ + ViewData["Title"] = "Manage API Keys"; + ViewData["ActivePage"] = ManageNavPages.ManageApiKeys; +} + +

@ViewData["Title"]

+ + + +
+ @if (!string.IsNullOrEmpty(Model.GeneratedApiKey)) + { +
+

+ Generated Key +

+ + + + + + + + + + + +
+
Name:
+
+
@Model.GeneratedApiKeyName
+
+
Key:
+
+
+ @Model.GeneratedApiKey + + + + + + + + + +
+
+
+ Usage: Add an HTTP Header X-Api-key with the API key as the header value. + Note that the key name does not need to be provided separately for authentication. +
+
+ } +
+

+ Generate a new API key +

+
+ API keys are intended for non-interactive services that need to call the REST and GraphQL APIs. They have all the permissions of the user who created them, including any Administrator role membership. +
+
+
+
+ + + +
+ +
+
+ @if (Model.ApiKeysAndNames?.Any() == true) + { +
+

+ Existing API keys +

+ + + @foreach (var apiKeyAndName in Model.ApiKeysAndNames) + { + + + + + + } + +
@apiKeyAndName.KeyName@apiKeyAndName.KeyPrefix... +
+
+ + +
+
+
+
+ } +
+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml.cs new file mode 100644 index 00000000..43f69407 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Opc.Ua.Cloud.Library.Authentication; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account.Manage +{ + public class ManageApiKeysModel : PageModel + { + private readonly UserManager _userManager; + private readonly ApiKeyTokenProvider _apiKeyTokenProvider; + + public ManageApiKeysModel( + UserManager userManager, + ApiKeyTokenProvider apiKeyTokenProvider) + { + _userManager = userManager; + _apiKeyTokenProvider = apiKeyTokenProvider; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [TempData] + public string StatusMessage { get; set; } + public List<(string KeyName, string KeyPrefix)> ApiKeysAndNames { get; private set; } + + [TempData] + public string GeneratedApiKeyName { get; set; } + [TempData] + public string GeneratedApiKey { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [DataType(DataType.Text)] + [Display(Name = "API Key Name")] + public string NewApiKeyName { get; set; } + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + ApiKeysAndNames = await _apiKeyTokenProvider.GetUserApiKeysAsync(user).ConfigureAwait(false); + return Page(); + } + + public async Task OnPostDeleteApiKeyAsync(string apiKeyToDelete) + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var removeResult = await _userManager.RemoveAuthenticationTokenAsync(user, ApiKeyTokenProvider.ApiKeyProviderName, apiKeyToDelete).ConfigureAwait(false); + if (!removeResult.Succeeded) + { + foreach (var error in removeResult.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return Page(); + } + StatusMessage = $"Deleted API key {apiKeyToDelete}."; + return RedirectToPage(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + try + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var newApiKeyName = Input.NewApiKeyName; + + try + { + var newApiKey = await ApiKeyTokenProvider.GenerateAndSetAuthenticationTokenAsync(_userManager, user, newApiKeyName).ConfigureAwait(false); + if (string.IsNullOrEmpty(newApiKey)) + { + ModelState.AddModelError(string.Empty, "A key with this name already exists."); + ApiKeysAndNames = await _apiKeyTokenProvider.GetUserApiKeysAsync(user).ConfigureAwait(false); + return Page(); + } + GeneratedApiKeyName = newApiKeyName; + GeneratedApiKey = newApiKey; + StatusMessage = $"Be sure to save the API key before you leave this page. You will not be able to retrieve it later."; + } + catch (ApiKeyGenerationException ex) + { + foreach (var error in ex.Errors) + { + ModelState.AddModelError(string.Empty, error); + } + return Page(); + } + + } + catch + { + ModelState.AddModelError(string.Empty, "Error generating key."); + } + + return RedirectToPage(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs index b536b1f3..9d07fd1d 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs @@ -31,6 +31,11 @@ public static class ManageNavPages /// public static string ChangePassword => "ChangePassword"; + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string ManageApiKeys => "ManageApiKeys"; /// /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used /// directly from your code. This API may change or be removed in future releases. @@ -79,6 +84,12 @@ public static class ManageNavPages /// public static string ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword); + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string ManageApiKeysNavClass(ViewContext viewContext) => PageNavClass(viewContext, ManageApiKeys); + /// /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used /// directly from your code. This API may change or be removed in future releases. diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml index 98681181..447dd665 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml @@ -1,6 +1,11 @@ @inject SignInManager SignInManager @{ var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync().ConfigureAwait(false)).Any(); +#if APIKEY_AUTH + bool apiKeyAuth = true; +#else + bool apiKeyAuth = false; +#endif } diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml index 28805178..9e625663 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml @@ -1,85 +1,96 @@ -@page +@page @model RegisterModel @{ ViewData["Title"] = "Register"; + var _reCaptchaUrl = $"{@Model.CaptchaSettings.ClientApiUrl}{@Model.CaptchaSettings.SiteKey}"; }

@ViewData["Title"]

-
- @{ - if (Model.AllowSelfRegistration) - { -
-

-

-

Create a new account.

-
-
-
- - - -
-
- - - -
-
- - - -
- -
-
- } - else - { -
-
-

Creation of local accounts is currently unavailable.

-
-
-
- } - } -
+ @if (Model.AllowSelfRegistration) + { +
+
+

Create a new account.

+
+
+ +
+ + + +
+
+ + + +
+
+ + + +
+ + +
+
+ } + else + { +
-

Use another service to register.

-
- @{ - if ((Model.ExternalLogins?.Count ?? 0) == 0) - { -
-

+

Create a new account.

+
+ Creation of local accounts is currently unavailable. +
+
+ } +
+
+

Use another service to register.

+
+ @{ + if ((Model.ExternalLogins?.Count ?? 0) == 0) + { +
+

There are no external authentication services configured. See this article about setting up this ASP.NET application to support logging in via external services . +

+
+ } + else + { +
+
+

+ @foreach (var provider in Model.ExternalLogins!) + { + + }

- } - else - { - -
-

- @foreach (var provider in Model.ExternalLogins!) - { - - } -

-
-
- } + } -
-
+ } +
+
+ +@section Scripts { + - @section Scripts { - + @if (Model.CaptchaSettings.Enabled) { + + } +} \ No newline at end of file diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml.cs index a107d59f..637c6d79 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -10,7 +10,6 @@ using System.Text.Encodings.Web; using System.Threading; using System.Threading.Tasks; -using Castle.Core.Configuration; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; @@ -30,7 +29,11 @@ public class RegisterModel : PageModel private readonly IUserEmailStore _emailStore; private readonly ILogger _logger; private readonly IEmailSender _emailSender; - public bool AllowSelfRegistration { get; set; } + private readonly IConfiguration _configuration; + private readonly Interfaces.ICaptchaValidation _captchaValidation; + private readonly CaptchaSettings _captchaSettings; + + public bool AllowSelfRegistration { get; set; } = true; public RegisterModel( UserManager userManager, @@ -38,7 +41,8 @@ public RegisterModel( SignInManager signInManager, ILogger logger, IEmailSender emailSender, - Microsoft.Extensions.Configuration.IConfiguration configuration) + IConfiguration configuration, + Interfaces.ICaptchaValidation captchaValidation) { _userManager = userManager; _userStore = userStore; @@ -46,7 +50,13 @@ public RegisterModel( _signInManager = signInManager; _logger = logger; _emailSender = emailSender; - AllowSelfRegistration = configuration.GetValue(nameof(AllowSelfRegistration)) == true; + _configuration = configuration; + _captchaValidation = captchaValidation; + + _captchaSettings = new CaptchaSettings(); + configuration.GetSection("CaptchaSettings").Bind(_captchaSettings); + + AllowSelfRegistration = configuration.GetValue(nameof(AllowSelfRegistration)) != false; } /// @@ -68,6 +78,17 @@ public RegisterModel( /// public IList ExternalLogins { get; set; } + /// + /// Populate values for cshtml to use + /// + public CaptchaSettings CaptchaSettings { get { return _captchaSettings; } } + + /// + /// Populate a token returned from client side call to Google Captcha + /// + [BindProperty] + public string CaptchaResponseToken { get; set; } + /// /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used /// directly from your code. This API may change or be removed in future releases. @@ -118,6 +139,11 @@ public async Task OnPostAsync(string returnUrl = null) } returnUrl ??= Url.Content("~/"); ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync().ConfigureAwait(false)).ToList(); + + //Captcha validate + var captchaResult = await _captchaValidation.ValidateCaptcha(CaptchaResponseToken); + if (!string.IsNullOrEmpty(captchaResult)) ModelState.AddModelError("CaptchaResponseToken", captchaResult); + if (ModelState.IsValid) { var user = CreateUser(); diff --git a/UACloudLibraryServer/Authentication/ApiKeyAuthenticationHandler.cs b/UACloudLibraryServer/Authentication/ApiKeyAuthenticationHandler.cs new file mode 100644 index 00000000..3a58a5df --- /dev/null +++ b/UACloudLibraryServer/Authentication/ApiKeyAuthenticationHandler.cs @@ -0,0 +1,89 @@ +/* ======================================================================== + * Copyright (c) 2005-2021 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +namespace Opc.Ua.Cloud.Library.Authentication +{ + using System; + using System.Linq; + using System.Security.Claims; + using System.Text.Encodings.Web; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authentication; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + using Opc.Ua.Cloud.Library.Interfaces; + + public class ApiKeyAuthenticationHandler : AuthenticationHandler + { + private readonly IUserService _userService; + + public ApiKeyAuthenticationHandler( + IUserService userService, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) + : base(options, logger, encoder, clock) + { + _userService = userService; + } + + protected override async Task HandleAuthenticateAsync() + { + try + { + var apiKeyHeader = Request.Headers["x-api-key"]; + if (!apiKeyHeader.Any()) + { + return AuthenticateResult.NoResult(); + } + + if (apiKeyHeader.Count != 1) + { + return AuthenticateResult.Fail("Invalid x-api-key header"); + } + var claims = await _userService.ValidateApiKeyAsync(apiKeyHeader[0]).ConfigureAwait(false); + if (claims?.Any() != true) + { + return AuthenticateResult.Fail("Invalid credentials"); + } + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return AuthenticateResult.Success(ticket); + } + catch (Exception ex) + { + return AuthenticateResult.Fail($"Error during Authentication: {ex.Message}"); + } + } + } +} diff --git a/UACloudLibraryServer/Authentication/ApiKeyTokenProvider.cs b/UACloudLibraryServer/Authentication/ApiKeyTokenProvider.cs new file mode 100644 index 00000000..fcffc14c --- /dev/null +++ b/UACloudLibraryServer/Authentication/ApiKeyTokenProvider.cs @@ -0,0 +1,190 @@ +/* ======================================================================== + * Copyright (c) 2005-2021 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Serialization; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Opc.Ua.Cloud.Library.Authentication +{ + public class ApiKeyTokenProvider : IUserTwoFactorTokenProvider + { + public ApiKeyTokenProvider(AppDbContext appDbContext, ILogger logger) + { + _appDbContext = appDbContext; + _logger = logger; + } + public const string ApiKeyProviderName = nameof(ApiKeyTokenProvider); + + private readonly AppDbContext _appDbContext; + private readonly ILogger _logger; + + public Task CanGenerateTwoFactorTokenAsync(UserManager manager, IdentityUser user) + { + return Task.FromResult(true); + } + + public Task GenerateAsync(string purpose, UserManager manager, IdentityUser user) + { + if (!string.IsNullOrEmpty(purpose)) + { + var secretBytes = RandomNumberGenerator.GetBytes(32); + + // Make it Base64URL + var apiKey = Convert.ToBase64String(secretBytes).Replace("+", "-", StringComparison.Ordinal).Replace("/", "_", StringComparison.Ordinal); + return Task.FromResult(apiKey); + } + throw new ArgumentException($"Unknown purpose {purpose}."); + } + + public async Task ValidateAsync(string purpose, string token, UserManager manager, IdentityUser user) + { + if (!string.IsNullOrEmpty(purpose)) + { + var authTokenHash = await manager.GetAuthenticationTokenAsync(user, ApiKeyProviderName, purpose).ConfigureAwait(false); + if (authTokenHash == null || authTokenHash.Length < 4) + { + return false; + } + var result = manager.PasswordHasher.VerifyHashedPassword(user, authTokenHash.Substring(4), token); + if (result == PasswordVerificationResult.Success || result == PasswordVerificationResult.SuccessRehashNeeded) + { + return true; + } + } + return false; + } + + static Dictionary _apiKeyToUserMap = new(); + + public async Task<(string UserId, string ApiKeyName)> FindUserForApiKey(string apiKey, UserManager manager) + { + if (apiKey.Length < 4) + { + throw new ArgumentException($"Invalid API key format"); + } + // Don't keep the full API key in memory + var partialApiKey = apiKey.Substring(0, apiKey.Length - 16); + if (_apiKeyToUserMap.TryGetValue(partialApiKey, out var cachedUserAndKeyName) && cachedUserAndKeyName.UserId != "collision") + { + return cachedUserAndKeyName; + } + var prefix = apiKey.Substring(0, 4); + var candidateTokens = await _appDbContext.UserTokens.Where(t => t.LoginProvider == ApiKeyTokenProvider.ApiKeyProviderName && t.Value.StartsWith(prefix)).ToListAsync().ConfigureAwait(false); + + foreach (var candidateToken in candidateTokens) + { + var user = await manager.FindByIdAsync(candidateToken.UserId).ConfigureAwait(false); + var result = manager.PasswordHasher.VerifyHashedPassword(user, candidateToken.Value.Substring(4), apiKey); + if (result == PasswordVerificationResult.Success || result == PasswordVerificationResult.SuccessRehashNeeded) + { + var newUserAndKeyName = (user.Id, candidateToken.Name); + + if (cachedUserAndKeyName.UserId != "collision" && !_apiKeyToUserMap.TryAdd(partialApiKey, newUserAndKeyName)) + { + // Key prefix collision: stop using the cache for this key + _logger.LogWarning("APIKey cache collision detected: disabled caching for the colliding keys."); + _apiKeyToUserMap[partialApiKey] = ("collision", null); + } + return newUserAndKeyName; + } + } + throw new ArgumentException($"Key not found"); + + } + + /// + /// + /// + /// UserManager to use to generate and set the key. + /// User for who to generate the key for. + /// Name under which the key will be stored. + /// The generated api key,or null if a key with the name already exists. + /// + public static async Task GenerateAndSetAuthenticationTokenAsync(UserManager userManager, IdentityUser user, string newApiKeyName) + { + var existingToken = await userManager.GetAuthenticationTokenAsync(user, ApiKeyProviderName, newApiKeyName).ConfigureAwait(false); + if (!string.IsNullOrEmpty(existingToken)) + { + return null; + } + + var newApiKey = await userManager.GenerateUserTokenAsync(user, ApiKeyProviderName, newApiKeyName).ConfigureAwait(false); + // Store the first 4 bytes of the unhashed key for more efficient key lookup + var newApiKeyHash = $"{newApiKey.Substring(0, 4)}{userManager.PasswordHasher.HashPassword(user, newApiKey)}"; + + var setTokenResult = await userManager.SetAuthenticationTokenAsync(user, ApiKeyProviderName, newApiKeyName, newApiKeyHash).ConfigureAwait(false); + if (!setTokenResult.Succeeded) + { + throw new ApiKeyGenerationException("Error saving key.", setTokenResult.Errors.Select(e => e.Description).ToList()); + } + return newApiKey; + } + + public async Task> GetUserApiKeysAsync(IdentityUser user) + { + var tokens = await _appDbContext.UserTokens.Where(t => t.UserId == user.Id && t.LoginProvider == ApiKeyTokenProvider.ApiKeyProviderName).ToListAsync().ConfigureAwait(false); + return tokens.Select(t => (t.Name, t.Value.Substring(0, 4))).ToList(); + } + } + + [Serializable] + internal class ApiKeyGenerationException : Exception + { + public IEnumerable Errors { get; private set; } + + public ApiKeyGenerationException() + { + } + + public ApiKeyGenerationException(string message, IEnumerable enumerable) : base(message) + { + Errors = enumerable; + } + + public ApiKeyGenerationException(string message) : base(message) + { + } + + public ApiKeyGenerationException(string message, Exception innerException) : base(message, innerException) + { + } + + protected ApiKeyGenerationException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + } + +} diff --git a/UACloudLibraryServer/BasicAuthenticationHandler.cs b/UACloudLibraryServer/Authentication/BasicAuthenticationHandler.cs similarity index 63% rename from UACloudLibraryServer/BasicAuthenticationHandler.cs rename to UACloudLibraryServer/Authentication/BasicAuthenticationHandler.cs index d25d087e..2de176c2 100644 --- a/UACloudLibraryServer/BasicAuthenticationHandler.cs +++ b/UACloudLibraryServer/Authentication/BasicAuthenticationHandler.cs @@ -27,7 +27,7 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ -namespace Opc.Ua.Cloud.Library +namespace Opc.Ua.Cloud.Library.Authentication { using System; using System.Collections.Generic; @@ -47,11 +47,9 @@ namespace Opc.Ua.Cloud.Library public class BasicAuthenticationHandler : AuthenticationHandler { private readonly IUserService _userService; - private readonly SignInManager _signInManager; public BasicAuthenticationHandler( IUserService userService, - SignInManager signInManager, IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, @@ -59,56 +57,38 @@ public BasicAuthenticationHandler( : base(options, logger, encoder, clock) { _userService = userService; - _signInManager = signInManager; } protected override async Task HandleAuthenticateAsync() { - string username = null; - IEnumerable claims = null; try { - if (StringValues.IsNullOrEmpty(Request.Headers["Authorization"])) + if (!Request.Headers.TryGetValue("Authorization", out var authHeaderStringValue)) { - - if (_signInManager.IsSignedIn(Request.HttpContext.User)) - { - // Allow a previously authenticated, signed in user (for example via ASP.Net cookies from the graphiql browser) - ClaimsPrincipal principal2 = new ClaimsPrincipal(Request.HttpContext.User.Identity); - AuthenticationTicket ticket2 = new AuthenticationTicket(principal2, Scheme.Name); - - return AuthenticateResult.Success(ticket2); - } - - throw new ArgumentException("Authentication header missing in request!"); + return AuthenticateResult.NoResult(); } - AuthenticationHeaderValue authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]); + var authHeader = AuthenticationHeaderValue.Parse(authHeaderStringValue); string[] credentials = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader.Parameter)).Split(':'); - username = credentials.FirstOrDefault(); + var username = credentials.FirstOrDefault(); string password = credentials.LastOrDefault(); - claims = await _userService.ValidateCredentialsAsync(username, password).ConfigureAwait(false); + var claims = await _userService.ValidateCredentialsAsync(username, password).ConfigureAwait(false); if (claims?.Any() != true) { throw new ArgumentException("Invalid credentials"); } + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return AuthenticateResult.Success(ticket); } catch (Exception ex) { - return AuthenticateResult.Fail($"Authentication failed: {ex.Message}"); + return AuthenticateResult.Fail($"Error during Authentication: {ex.Message}"); } - if (claims == null) - { - throw new ArgumentException("Invalid credentials"); - } - - ClaimsIdentity identity = new ClaimsIdentity(claims, Scheme.Name); - ClaimsPrincipal principal = new ClaimsPrincipal(identity); - AuthenticationTicket ticket = new AuthenticationTicket(principal, Scheme.Name); - - return AuthenticateResult.Success(ticket); } } } diff --git a/UACloudLibraryServer/Authentication/SignedInUserAuthenticationHandler.cs b/UACloudLibraryServer/Authentication/SignedInUserAuthenticationHandler.cs new file mode 100644 index 00000000..c36a2d18 --- /dev/null +++ b/UACloudLibraryServer/Authentication/SignedInUserAuthenticationHandler.cs @@ -0,0 +1,82 @@ +/* ======================================================================== + * Copyright (c) 2005-2021 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +namespace Opc.Ua.Cloud.Library.Authentication +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net.Http.Headers; + using System.Security.Claims; + using System.Text; + using System.Text.Encodings.Web; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authentication; + using Microsoft.AspNetCore.Identity; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + using Microsoft.Extensions.Primitives; + using Opc.Ua.Cloud.Library.Interfaces; + + public class SignedInUserAuthenticationHandler : AuthenticationHandler + { + private readonly SignInManager _signInManager; + + public SignedInUserAuthenticationHandler( + SignInManager signInManager, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) + : base(options, logger, encoder, clock) + { + _signInManager = signInManager; + } + + protected override Task HandleAuthenticateAsync() + { + try + { + if (!_signInManager.IsSignedIn(Request.HttpContext.User)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + // Allow a previously authenticated, signed in user (for example via ASP.Net cookies from the graphiql browser) + var principal2 = new ClaimsPrincipal(Request.HttpContext.User.Identity); + var ticket2 = new AuthenticationTicket(principal2, Scheme.Name); + + return Task.FromResult(AuthenticateResult.Success(ticket2)); + } + catch (Exception ex) + { + return Task.FromResult(AuthenticateResult.Fail($"Error during Authentication: {ex.Message}")); + } + } + } +} diff --git a/UACloudLibraryServer/CaptchaValidation.cs b/UACloudLibraryServer/CaptchaValidation.cs new file mode 100644 index 00000000..1bb0634a --- /dev/null +++ b/UACloudLibraryServer/CaptchaValidation.cs @@ -0,0 +1,199 @@ +/* ======================================================================== + * Copyright (c) 2005-2021 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +namespace Opc.Ua.Cloud.Library +{ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Threading.Tasks; + using Microsoft.Extensions.Configuration; + using Microsoft.Extensions.Logging; + using Newtonsoft.Json; + using Opc.Ua.Cloud.Library.Interfaces; + + + public class CaptchaSettings + { + public string SiteVerifyUrl { get; set; } + public string ClientApiUrl { get; set; } + public string SecretKey { get; set; } + public string SiteKey { get; set; } + public float BotThreshold { get; set; } + public bool Enabled { get; set; } = false; + } + + /// + /// Structure matches up with Google's response JSON + /// + public class ReCaptchaResponse + { + public bool success { get; set; } + public double score { get; set; } + public string action { get; set; } + public DateTime challenge_ts { get; set; } + public string hostname { get; set; } + [JsonProperty("error-codes")] + public List error_codes { get; set; } + } + + public class CaptchaValidation : Interfaces.ICaptchaValidation + { + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly CaptchaSettings _captchaSettings; + + public CaptchaValidation( + ILogger logger, + IConfiguration configuration, + IHttpClientFactory httpClientFactory) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + + _captchaSettings = new CaptchaSettings(); + configuration.GetSection("CaptchaSettings").Bind(_captchaSettings); + } + + public async Task ValidateCaptcha(string responseToken) + { + if (!_captchaSettings.Enabled) return null; + + bool configError = false; + //check for valid values + if (_captchaSettings == null) _logger.LogCritical($"ValidateCaptcha|Captcha settings are missing or invalid"); + if (string.IsNullOrEmpty(_captchaSettings.SiteVerifyUrl)) + { + configError = true; + _logger.LogCritical($"ValidateCaptcha|Captcha:BaseAddress is missing or invalid"); + } + if (string.IsNullOrEmpty(_captchaSettings.SecretKey)) + { + configError = true; + _logger.LogCritical($"ValidateCaptcha|Captcha:Secret Key is missing or invalid"); + } + if (string.IsNullOrEmpty(_captchaSettings.SiteKey)) + { + configError = true; + _logger.LogCritical($"ValidateCaptcha|Captcha:Site Key is missing or invalid"); + } + + //non-user caused issue... + if (configError) + { + return "The automated Captcha system is not configured. Please contact the system administrator."; + } + + //var responseToken = Request.Form["reCaptchaResponseToken"]; + if (string.IsNullOrEmpty(responseToken)) + { + _logger.LogCritical($"ValidateCaptcha|Captcha:responseToken is missing or invalid"); + return "The Captcha client response was incorrect or not supplied. Please contact the system administrator."; + } + + //make the API call + HttpClient client = _httpClientFactory.CreateClient(); + try + { + client.BaseAddress = new Uri(_captchaSettings.SiteVerifyUrl); + + //prepare the request + using (var requestMessage = new HttpRequestMessage(HttpMethod.Post, "")) + { + //add the body + var parameters = new Dictionary{ + {"secret", _captchaSettings.SecretKey}, + {"response", responseToken} + //{"remoteip", "ip" } <= this is optional + }; + + requestMessage.Content = new FormUrlEncodedContent(parameters); + + //call the api + HttpResponseMessage response = await client.SendAsync(requestMessage); + + //basic error with call + if (!response.IsSuccessStatusCode) + { + var msg = $"{(int)response.StatusCode}-{response.ReasonPhrase}"; + _logger.LogCritical($"ValidateCaptcha|Error occurred in the API call: {msg}"); + return "An error occurred validating the Captcha response. Please contact your system administrator."; + } + + //check the reCaptcha response + var data = response.Content.ReadAsStringAsync().Result; //Make sure to add a reference to System.Net.Http.Formatting.dll + var recaptchaResponse = Newtonsoft.Json.JsonConvert.DeserializeObject(data); + if (recaptchaResponse == null) + { + _logger.LogCritical($"ValidateCaptcha|Expected Google reCaptcha response was null"); + return "An error occurred retrieving the Captcha response. Please contact your system administrator."; + } + + if (!recaptchaResponse.success) + { + var errors = string.Join(",", recaptchaResponse.error_codes); + _logger.LogCritical($"ValidateCaptcha| Google reCaptcha returned error(s): {errors}"); + return "Error(s) occurred validating the Captcha response. Please contact your system administrator."; + } + + // check reCaptcha response action + //if (recaptchaResponse.action.ToUpper() != expected_action.ToUpper()) + //{ + // //Logging.Log(new Logging.LogItem { Msg = $"Google RecCaptcha action doesn't match:\nExpected action: {expected_action} Given action: {recaptcha_response.action}" }, DefaultLogValues); + // return (recaptchaResponse, false); + //} + + // anything less than 0.5 is a bot + if (recaptchaResponse.score < _captchaSettings.BotThreshold) + { + _logger.LogCritical($"ValidateCaptcha|Bot score: {recaptchaResponse.score} < Threshold: {_captchaSettings.BotThreshold}"); + return "You are not a human. If you believe this is not correct, please contact your system administrator."; + } + else + { + _logger.LogInformation($"ValidateCaptcha|Goggle Bot score: {recaptchaResponse.score} (0 bad, {_captchaSettings.BotThreshold} threshold, 1 good)"); + } + //if we get here, all good. + return null; + } + } + catch (Exception ex) + { + var msg = $"ValidateCaptcha|Unexpected error occurred in the API call: {ex.Message}"; + _logger.LogCritical(ex, msg); + return "An unexpected error occurred validating the Captcha response. Please contact your system administrator."; + } + finally + { + // Dispose once all HttpClient calls are complete. This is not necessary if the containing object will be disposed of; for example in this case the HttpClient instance will be disposed automatically when the application terminates so the following call is superfluous. + client.Dispose(); + } + } + } +} diff --git a/UACloudLibraryServer/Controllers/AccessController.cs b/UACloudLibraryServer/Controllers/AccessController.cs index 39c96abe..e680b46c 100644 --- a/UACloudLibraryServer/Controllers/AccessController.cs +++ b/UACloudLibraryServer/Controllers/AccessController.cs @@ -38,17 +38,13 @@ namespace Opc.Ua.Cloud.Library.Controllers using Microsoft.Extensions.Logging; using Swashbuckle.AspNetCore.Annotations; - [Authorize(AuthenticationSchemes = "BasicAuthentication")] + [Authorize(AuthenticationSchemes = UserService.APIAuthorizationSchemes)] [ApiController] public class AccessController : ControllerBase { - private readonly IDatabase _database; - private readonly ILogger _logger; - public AccessController(IDatabase database, ILoggerFactory logger) + public AccessController() { - _database = database; - _logger = logger.CreateLogger("ApprovalController"); } [HttpPut] diff --git a/UACloudLibraryServer/Controllers/ApprovalController.cs b/UACloudLibraryServer/Controllers/ApprovalController.cs index 46ec770c..50dfea5b 100644 --- a/UACloudLibraryServer/Controllers/ApprovalController.cs +++ b/UACloudLibraryServer/Controllers/ApprovalController.cs @@ -33,12 +33,11 @@ namespace Opc.Ua.Cloud.Library.Controllers using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; - using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Swashbuckle.AspNetCore.Annotations; - [Authorize(AuthenticationSchemes = "BasicAuthentication")] + [Authorize(AuthenticationSchemes = UserService.APIAuthorizationSchemes)] [ApiController] public class ApprovalController : ControllerBase { diff --git a/UACloudLibraryServer/Controllers/ExplorerController.cs b/UACloudLibraryServer/Controllers/ExplorerController.cs index 573e6d1b..a7094b57 100644 --- a/UACloudLibraryServer/Controllers/ExplorerController.cs +++ b/UACloudLibraryServer/Controllers/ExplorerController.cs @@ -32,7 +32,7 @@ namespace Opc.Ua.Cloud.Library.Controllers { - [Authorize(AuthenticationSchemes = "BasicAuthentication")] + [Authorize(AuthenticationSchemes = "SignedInUserAuthentication")] public class ExplorerController : Controller { // GET diff --git a/UACloudLibraryServer/Controllers/InfoModelController.cs b/UACloudLibraryServer/Controllers/InfoModelController.cs index 6dd71e3b..47292bfb 100644 --- a/UACloudLibraryServer/Controllers/InfoModelController.cs +++ b/UACloudLibraryServer/Controllers/InfoModelController.cs @@ -48,22 +48,20 @@ namespace Opc.Ua.Cloud.Library.Controllers using Opc.Ua.Export; using Swashbuckle.AspNetCore.Annotations; - [Authorize(AuthenticationSchemes = "BasicAuthentication")] + [Authorize(AuthenticationSchemes = UserService.APIAuthorizationSchemes)] [ApiController] public class InfoModelController : ControllerBase { private readonly IFileStorage _storage; private readonly IDatabase _database; private readonly ILogger _logger; - private readonly NodeSetModelIndexer _indexer; private readonly NodeSetModelIndexerFactory _nodeSetIndexerFactory; - public InfoModelController(IFileStorage storage, IDatabase database, ILoggerFactory logger, NodeSetModelIndexer indexer, NodeSetModelIndexerFactory nodeSetIndexerFactory) + public InfoModelController(IFileStorage storage, IDatabase database, ILoggerFactory logger, NodeSetModelIndexerFactory nodeSetIndexerFactory) { _storage = storage; _database = database; _logger = logger.CreateLogger("InfoModelController"); - _indexer = indexer; _nodeSetIndexerFactory = nodeSetIndexerFactory; } diff --git a/UACloudLibraryServer/Interfaces/ICaptchaValidation.cs b/UACloudLibraryServer/Interfaces/ICaptchaValidation.cs new file mode 100644 index 00000000..04bc9268 --- /dev/null +++ b/UACloudLibraryServer/Interfaces/ICaptchaValidation.cs @@ -0,0 +1,12 @@ +namespace Opc.Ua.Cloud.Library.Interfaces +{ + using System.Collections.Generic; + using System.Security.Claims; + using System.Threading.Tasks; + + public interface ICaptchaValidation + { + Task ValidateCaptcha(string responseToken); + } +} + diff --git a/UACloudLibraryServer/Interfaces/IUserService.cs b/UACloudLibraryServer/Interfaces/IUserService.cs index 4f17390a..a220723c 100644 --- a/UACloudLibraryServer/Interfaces/IUserService.cs +++ b/UACloudLibraryServer/Interfaces/IUserService.cs @@ -36,6 +36,7 @@ namespace Opc.Ua.Cloud.Library.Interfaces public interface IUserService { Task> ValidateCredentialsAsync(string username, string password); + Task> ValidateApiKeyAsync(string apiKey); } } diff --git a/UACloudLibraryServer/Startup.cs b/UACloudLibraryServer/Startup.cs index 93e59406..0f85ac1c 100644 --- a/UACloudLibraryServer/Startup.cs +++ b/UACloudLibraryServer/Startup.cs @@ -62,6 +62,7 @@ namespace Opc.Ua.Cloud.Library using Microsoft.OpenApi.Models; using Opc.Ua.Cloud.Library.Interfaces; using Microsoft.AspNetCore.Authorization; + using Opc.Ua.Cloud.Library.Authentication; public class Startup { @@ -90,12 +91,18 @@ public void ConfigureServices(IServiceCollection services) options.SignIn.RequireConfirmedAccount = !string.IsNullOrEmpty(Configuration["EmailSenderAPIKey"]) ) .AddRoles() - .AddEntityFrameworkStores(); +#if APIKEY_AUTH + .AddTokenProvider(ApiKeyTokenProvider.ApiKeyProviderName) +#endif + .AddEntityFrameworkStores() + ; services.AddScoped(); services.AddTransient(); + services.AddScoped(); + if (!string.IsNullOrEmpty(Configuration["UseSendGridEmailSender"])) { services.AddTransient(); @@ -109,7 +116,16 @@ public void ConfigureServices(IServiceCollection services) services.AddAuthentication() .AddScheme("BasicAuthentication", null) - ; + .AddScheme("SignedInUserAuthentication", null) +#if APIKEY_AUTH + .AddScheme("ApiKeyAuthentication", null); +#endif + ; + + //for captcha validation call + //add httpclient service for dependency injection + //https://docs.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-6.0 + services.AddHttpClient(); if (Configuration["OAuth2ClientId"] != null) { @@ -160,12 +176,29 @@ public void ConfigureServices(IServiceCollection services) #if AZURE_AD if (Configuration.GetSection("AzureAd")?["ClientId"] != null) { + // Web UI access services.AddAuthentication() .AddMicrosoftIdentityWebApp(Configuration, configSectionName: "AzureAd", openIdConnectScheme: "AzureAd", displayName: Configuration["AADDisplayName"] ?? "Microsoft Account") ; + // Allow access to API via Bearer tokens (for service identities etc.) + services.AddAuthentication() + .AddMicrosoftIdentityWebApi( + Configuration, + configSectionName: "AzureAd", + jwtBearerScheme: "Bearer", + subscribeToJwtBearerMiddlewareDiagnosticsEvents: true + ) + ; + } + else + { + // Need to register a Bearer scheme or the authorization attributes cause errors + services.AddAuthentication() + .AddScheme("Bearer", null); + } #if DEBUG IdentityModelEventSource.ShowPII = true; @@ -211,6 +244,30 @@ public void ConfigureServices(IServiceCollection services) } }); +#if APIKEY_AUTH + options.AddSecurityDefinition("ApiKeyAuth", new OpenApiSecurityScheme { + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header, + Name = "X-API-Key", + //Scheme = "basic" + }); + + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "ApiKeyAuth" + } + }, + Array.Empty() + } + }); +#endif + options.CustomSchemaIds(type => type.ToString()); options.EnableAnnotations(); @@ -350,7 +407,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, AppDbCon endpoints.MapRazorPages(); endpoints.MapBlazorHub(); endpoints.MapGraphQL() - .RequireAuthorization(new AuthorizeAttribute() { AuthenticationSchemes = "BasicAuthentication" }) + .RequireAuthorization(new AuthorizeAttribute() { AuthenticationSchemes = UserService.APIAuthorizationSchemes }) .WithOptions(new GraphQLServerOptions { EnableGetRequests = true, Tool = { Enable = false }, diff --git a/UACloudLibraryServer/UA-CloudLibrary.csproj b/UACloudLibraryServer/UA-CloudLibrary.csproj index 38f19db2..7862ce1e 100644 --- a/UACloudLibraryServer/UA-CloudLibrary.csproj +++ b/UACloudLibraryServer/UA-CloudLibrary.csproj @@ -7,7 +7,7 @@ Linux ./.. ..\docker-compose.dcproj - $(DEFINECONSTANTS);NOLEGACY;AZURE_AD + $(DEFINECONSTANTS);NOLEGACY;AZURE_AD;APIKEY_AUTH diff --git a/UACloudLibraryServer/UserService.cs b/UACloudLibraryServer/UserService.cs index ccf8efc7..798f3dca 100644 --- a/UACloudLibraryServer/UserService.cs +++ b/UACloudLibraryServer/UserService.cs @@ -31,11 +31,13 @@ namespace Opc.Ua.Cloud.Library { using System; using System.Collections.Generic; + using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; + using Opc.Ua.Cloud.Library.Authentication; using Opc.Ua.Cloud.Library.Interfaces; public class UserService : IUserService @@ -43,12 +45,28 @@ public class UserService : IUserService private readonly UserManager _userManager; private readonly ILogger _logger; private readonly IConfiguration _config; + private readonly ApiKeyTokenProvider _apiKeyTokenProvider; - public UserService(UserManager userManager, ILoggerFactory logger, IConfiguration config) + // This string is used in controller attributes: keep it in a central place +#if APIKEY_AUTH +#if AZURE_AD + // APIKEY and AZURE_AD + public const string APIAuthorizationSchemes = "BasicAuthentication,SignedInUserAuthentication,ApiKeyAuthentication,Bearer"; +#else + // APIKEY_AUTH + public const string APIAuthorizationSchemes = "BasicAuthentication,SignedInUserAuthentication,ApiKeyAuthentication"; +#endif +#else + // Basic only + public const string APIAuthorizationSchemes = "BasicAuthentication,SignedInUserAuthentication"; +#endif + + public UserService(UserManager userManager, ILoggerFactory logger, IConfiguration config, ApiKeyTokenProvider apiKeyTokenProvider) { _userManager = userManager; _logger = logger.CreateLogger("UserService"); _config = config; + _apiKeyTokenProvider = apiKeyTokenProvider; } public async Task> ValidateCredentialsAsync(string username, string password) @@ -109,5 +127,32 @@ public async Task> ValidateCredentialsAsync(string username, return claims; } } + + public async Task> ValidateApiKeyAsync(string apiKey) + { + var parsedApiKey = await _apiKeyTokenProvider.FindUserForApiKey(apiKey, _userManager).ConfigureAwait(false); + + var user = await _userManager.FindByIdAsync(parsedApiKey.UserId).ConfigureAwait(false); + if (user == null) + { + return null; + } + if (!await _userManager.VerifyUserTokenAsync(user, ApiKeyTokenProvider.ApiKeyProviderName, parsedApiKey.ApiKeyName, apiKey).ConfigureAwait(false)) + { + return null; + } + List claims = new(); + var roles = await _userManager.GetRolesAsync(user).ConfigureAwait(false); + foreach (var role in roles) + { + claims.Add(new Claim(ClaimTypes.Role, role)); + } + claims.AddRange(await this._userManager.GetClaimsAsync(user).ConfigureAwait(false)); + if (!claims.Any(c => c.Type == ClaimTypes.Name)) + { + claims.Add(new Claim(ClaimTypes.Name, user.UserName)); + } + return claims; + } } } diff --git a/UACloudLibraryServer/appsettings.json b/UACloudLibraryServer/appsettings.json index 6e2b6577..cb73d3cc 100644 --- a/UACloudLibraryServer/appsettings.json +++ b/UACloudLibraryServer/appsettings.json @@ -2,6 +2,15 @@ "CloudLibrary": { "ApprovalRequired": true }, + "AllowSelfRegistration": true, + "CaptchaSettings": { + "SiteVerifyUrl": "https://www.google.com/recaptcha/api/siteverify", + "ClientApiUrl": "https://www.google.com/recaptcha/api.js?render=", + "SecretKey": "", + "SiteKey": "", + "BotThreshold": 0.5, + "Enabled": true + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/UACloudLibraryServer/wwwroot/lib/clipboard-copy-element/LICENSE b/UACloudLibraryServer/wwwroot/lib/clipboard-copy-element/LICENSE new file mode 100644 index 00000000..6dce93c4 --- /dev/null +++ b/UACloudLibraryServer/wwwroot/lib/clipboard-copy-element/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2018 GitHub, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/UACloudLibraryServer/wwwroot/lib/clipboard-copy-element/README.md b/UACloudLibraryServer/wwwroot/lib/clipboard-copy-element/README.md new file mode 100644 index 00000000..1259d79b --- /dev/null +++ b/UACloudLibraryServer/wwwroot/lib/clipboard-copy-element/README.md @@ -0,0 +1,98 @@ +# <clipboard-copy> element + +Copy element text content or input values to the clipboard. + +## Installation + +``` +$ npm install --save @github/clipboard-copy-element +``` + +## Usage + +### Script + +Import as ES modules: + +```js +import '@github/clipboard-copy-element' +``` + +With a script tag: + +```html +