- @{
- if (Model.AllowSelfRegistration)
- {
-
- }
- else
- {
-
-
- Creation of local accounts is currently unavailable.
-
-
-
- }
- }
-
+ @if (Model.AllowSelfRegistration)
+ {
+
+ }
+ 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)
+ {
+
+ }
+ else
+ {
+
- }
+
}
-
-
+ }
+
+
+
+@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
+