From 55c8e8a7bbfe8fb9a1e8888c112261e02b8bbb29 Mon Sep 17 00:00:00 2001 From: MarkusHorstmann Date: Fri, 20 Oct 2023 16:12:28 -0700 Subject: [PATCH 01/13] API Key Authentication (#53) * API Key Authentication * Make AzureAD work without config --- .../Pages/Account/Manage/ManageApiKeys.cshtml | 90 +++++++++ .../Account/Manage/ManageApiKeys.cshtml.cs | 148 ++++++++++++++ .../Pages/Account/Manage/ManageNavPages.cs | 11 + .../Pages/Account/Manage/_ManageNav.cshtml | 8 + .../ApiKeyAuthenticationHandler.cs | 89 +++++++++ .../Authentication/ApiKeyTokenProvider.cs | 189 ++++++++++++++++++ .../BasicAuthenticationHandler.cs | 44 ++-- .../SignedInUserAuthenticationHandler.cs | 82 ++++++++ .../Controllers/AccessController.cs | 9 +- .../Controllers/ApprovalController.cs | 3 +- .../Controllers/ExplorerController.cs | 2 +- .../Controllers/InfoModelController.cs | 6 +- .../Interfaces/IUserService.cs | 1 + UACloudLibraryServer/Startup.cs | 56 +++++- UACloudLibraryServer/UA-CloudLibrary.csproj | 2 +- UACloudLibraryServer/UserService.cs | 47 ++++- 16 files changed, 737 insertions(+), 50 deletions(-) create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml.cs create mode 100644 UACloudLibraryServer/Authentication/ApiKeyAuthenticationHandler.cs create mode 100644 UACloudLibraryServer/Authentication/ApiKeyTokenProvider.cs rename UACloudLibraryServer/{ => Authentication}/BasicAuthenticationHandler.cs (63%) create mode 100644 UACloudLibraryServer/Authentication/SignedInUserAuthenticationHandler.cs 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..4d211596 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml @@ -0,0 +1,90 @@ +@page +@model ManageApiKeysModel +@{ + ViewData["Title"] = "Manage API Keys"; + ViewData["ActivePage"] = ManageNavPages.ManageApiKeys; +} + +

@ViewData["Title"]

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

+ Generated Key +

+ + + + + + + + + + + +
+ + + @Model.GeneratedApiKeyName +
+ + + @Model.GeneratedApiKey +
+
+ Usage: Add an HTTP Header X-Api-key with the API key as the header value. + Note that the key name is encoded into the API Key and 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.ApiKeyNames?.Any() == true) + { +
+

+ Existing API keys +

+ + + @foreach (var apiKey in Model.ApiKeyNames) + { + + + + + } + +
@apiKey +
+
+ + +
+
+
+
+ } +
+ +@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..a23703f4 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml.cs @@ -0,0 +1,148 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +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 ApiKeyNames { 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)}'."); + } + + ApiKeyNames = 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."); + ApiKeyNames = 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..98f7bf24 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml @@ -1,6 +1,10 @@ @inject SignInManager SignInManager @{ var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync().ConfigureAwait(false)).Any(); + bool apiKeyAuth = false; +#if APIKEY_AUTH + apiKeyAuth = true; +#endif } 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..1859c54e --- /dev/null +++ b/UACloudLibraryServer/Authentication/ApiKeyTokenProvider.cs @@ -0,0 +1,189 @@ +/* ======================================================================== + * 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) + { + return await _appDbContext.UserTokens.Where(t => t.UserId == user.Id && t.LoginProvider == ApiKeyTokenProvider.ApiKeyProviderName).Select(t => t.Name).ToListAsync().ConfigureAwait(false); + } + } + + [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/Controllers/AccessController.cs b/UACloudLibraryServer/Controllers/AccessController.cs index 39c96abe..75f2a410 100644 --- a/UACloudLibraryServer/Controllers/AccessController.cs +++ b/UACloudLibraryServer/Controllers/AccessController.cs @@ -38,17 +38,14 @@ 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/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..9528c186 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,7 +91,11 @@ public void ConfigureServices(IServiceCollection services) options.SignIn.RequireConfirmedAccount = !string.IsNullOrEmpty(Configuration["EmailSenderAPIKey"]) ) .AddRoles() - .AddEntityFrameworkStores(); +#if APIKEY_AUTH + .AddTokenProvider(ApiKeyTokenProvider.ApiKeyProviderName) +#endif + .AddEntityFrameworkStores() + ; services.AddScoped(); @@ -109,7 +114,11 @@ public void ConfigureServices(IServiceCollection services) services.AddAuthentication() .AddScheme("BasicAuthentication", null) - ; + .AddScheme("SignedInUserAuthentication", null) +#if APIKEY_AUTH + .AddScheme("ApiKeyAuthentication", null); +#endif + ; if (Configuration["OAuth2ClientId"] != null) { @@ -160,12 +169,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 +237,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 +400,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; + } } } From b15446bc132336b3947580e5ec9a2af3f608ac27 Mon Sep 17 00:00:00 2001 From: Markus Horstmann Date: Fri, 20 Oct 2023 16:31:30 -0700 Subject: [PATCH 02/13] ApiKey: show prefix in UI --- .../Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml | 3 ++- .../Identity/Pages/Account/Manage/ManageApiKeys.cshtml.cs | 2 +- .../Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml | 5 +++-- UACloudLibraryServer/Authentication/ApiKeyTokenProvider.cs | 5 +++-- UACloudLibraryServer/Controllers/AccessController.cs | 1 - 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml index 4d211596..6d8739ba 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml @@ -68,7 +68,8 @@ @foreach (var apiKey in Model.ApiKeyNames) { - @apiKey + @apiKey.KeyName + @apiKey.KeyPrefix...
diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml.cs index a23703f4..c27e0492 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageApiKeys.cshtml.cs @@ -41,7 +41,7 @@ public ManageApiKeysModel( ///
[TempData] public string StatusMessage { get; set; } - public List ApiKeyNames { get; private set; } + public List<(string KeyName, string KeyPrefix)> ApiKeyNames { get; private set; } [TempData] public string GeneratedApiKeyName { get; set; } diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml index 98f7bf24..447dd665 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml @@ -1,9 +1,10 @@ @inject SignInManager SignInManager @{ var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync().ConfigureAwait(false)).Any(); - bool apiKeyAuth = false; #if APIKEY_AUTH - apiKeyAuth = true; + bool apiKeyAuth = true; +#else + bool apiKeyAuth = false; #endif }