using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; using System.Threading.Tasks; using AutoMapper; using Fido2NetLib; using Fido2NetLib.Objects; using gehGassi.Common.Data; using gehGassi.Core.Interfaces; using gehGassi.Domain.Users; using gehGassi.Web.Helper; using gehGassi.Web.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; using JsonSerializer = System.Text.Json.JsonSerializer; namespace gehGassi.Web.Controllers { /// /// Controller für die Verwaltung von Fido-Credentials usw. /// [Authorize] public class FidoController : BaseController { private readonly SignInManager _signInManager; private readonly IUserService _userService; private readonly Fido2 _fidoLib; private readonly IFidoService _fidoService; private readonly ILogger _logger; private readonly IStringLocalizer _localizer; private readonly IMapper _mapper; private readonly UserManager _userManager; private readonly IOptions _fidoOptions; private readonly IDistributedCache _memoryCache; private readonly IOptions _sessionSettings; /// /// Erstellt eine Instanz /// /// Instanz eins IFidoService /// Instanz eines ILogger /// Instanz eines IStringLocalizer /// Instanz eines IMapper /// Instanz eines UserManager /// Instanz eines Fido2Options /// Instanz eines IDistributedCache /// Instanz eines SignInManager /// Instanz eines IUserService /// Instanz eines IOptions SessionSettings public FidoController(IFidoService fidoService, ILogger logger, IStringLocalizer localizer, IMapper mapper, UserManager userManager, IOptions fidoOptions, IDistributedCache memoryCache, SignInManager signInManager, IUserService userService, IOptions sessionSettings) { _fidoService = fidoService; _logger = logger; _localizer = localizer; _mapper = mapper; _userManager = userManager; _fidoOptions = fidoOptions; _memoryCache = memoryCache; _signInManager = signInManager; _userService = userService; _sessionSettings = sessionSettings; _fidoLib = new Fido2(new Fido2Configuration() { ServerDomain = _fidoOptions.Value.ServerDomain, ServerName = _fidoOptions.Value.ServerName, Origin = _fidoOptions.Value.Origin, TimestampDriftTolerance = _fidoOptions.Value.TimestampDriftTolerance }); } #region ByUser /// /// Gibt einen View für die Fido-Credentials-Verwaltung zurück /// /// View public IActionResult Index() { ViewBag.PageInfo = _localizer["Fido_PageInfo"]; return View(); } /// /// Gibt eine Liste von Entitäten basierend auf Abfragekriterien zurück /// /// Abfragekriterien /// Liste von gefundenen Entitäten [HttpPost] [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public IActionResult GetCredentials([FromBody] DataManager dm) { if (dm != null) { var propList = new List(); dm.SetComplexProperties(propList); } var resultList = _fidoService.Filter(dm?.SearchValue ?? "", User.Identity.Name); //Sortierung resultList = dm.ApplySorting(resultList); //Filter resultList = dm.ApplyFiltering(resultList, out var countFiltered); //Paging resultList = dm.ApplyPaging(resultList); var resultListVm = resultList.ToList().Select(item => _mapper.Map(item)).ToList(); //FilterPreview? if (!dm.RequiresCounts) return Json(resultListVm); return Json(new { result = resultListVm, count = countFiltered }); } /// /// Anlegen eines Credentials für Plattform /// /// PartialView [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public IActionResult CreatePlatform() { var model = new CreateFidoCredentialVm() { Username = User.Identity.Name, AttType = "none", AuthType = "platform", UserVerification = "required", RequireResidentKey = false, Name = string.Empty, CredentialType = FidoCredentialTypeVm.Platform }; return PartialView("_CreatePlatform", model); } /// /// Anlegen eines Credentials für CrossPlattform /// /// PartialView [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public IActionResult CreateCrossPlatform() { var model = new CreateFidoCredentialVm() { Username = User.Identity.Name, AttType = "none", AuthType = "cross-platform", UserVerification = "required", RequireResidentKey = false, Name = string.Empty, CredentialType = FidoCredentialTypeVm.Crossplatform }; return PartialView("_CreateCrossPlatform", model); } /// /// Bearbeiten eines Credentials /// /// Id des Credentials /// PartialView [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public async Task Edit(long id) { var item = await _fidoService.GetAsync(id); if (item != null) { var model = _mapper.Map(item); return PartialView("_Edit", model); } return PartialView("_Error"); } /// /// Bearbeiten eines Credentials /// /// Model /// Json [ValidateAntiForgeryToken] [HttpPost] public async Task Edit(EditFidoCredentialVm model) { var result = new ResponseVm { Success = false }; if (ModelState.IsValid) { var item = await _fidoService.GetAsync(model.Id); if (item != null) { _mapper.Map(model, item); await _fidoService.CommitAsync(User.Identity.Name); result.Data = item.ToCamelCaseJson(); result.Success = true; result.Html = string.Empty; return Json(new { result.Success, result.Html, result.Data }); } } result.Html = await PartialView("_Edit", model).ToStringAsync(ControllerContext); return Json(result); } /// /// Löschen eines Credentials /// /// Id der Entitäten /// Json [HttpPost] public async Task Delete(long id) { var result = new ResponseVm { Success = false }; var item = await _fidoService.GetAsync(id); if (item != null) { _fidoService.Remove(item); await _fidoService.CommitAsync(User.Identity.Name); result.Data = item.ToCamelCaseJson(); result.Success = true; } return Json(result); } /// /// Gibt zurück ob ein Benutzer über Fido2-Credentials verfügt /// /// Benutzername /// JSON [AllowAnonymous] [HttpPost] public async Task HasFidoCredentials(string userName) { var hasCredentials = await _fidoService.HasFidoCredentialsAsync(userName); return Json(hasCredentials); } /// /// Hilfsmethode die einen angemeldeten Benutzer (Fido) zur richtigen Startseite weiterleitet /// /// public async Task RedirectFido() { if (User.Identity.IsAuthenticated) { var user = await _userService.GetByUsernameAsync(User.Identity.Name); CultureInfo.CurrentCulture = new CultureInfo(user.PreferredLanguage); var roles = await _userManager.GetRolesAsync(user); if (roles.Contains("Administrator")) return RedirectToAction("Index", "Home", new { culture = CultureInfo.CurrentCulture.TwoLetterISOLanguageName }); else if (roles.Contains("Tenant")) return RedirectToAction("IndexTenant", "Home", new { culture = CultureInfo.CurrentCulture.TwoLetterISOLanguageName }); else if (roles.Contains("Customer")) return RedirectToAction("IndexCustomer", "Home", new { culture = CultureInfo.CurrentCulture.TwoLetterISOLanguageName }); else if (roles.Contains("Instructor")) return RedirectToAction("IndexInstructor", "Home", new { culture = CultureInfo.CurrentCulture.TwoLetterISOLanguageName }); else if (roles.Contains("Student")) return RedirectToAction("IndexStudent", "Home", new { culture = CultureInfo.CurrentCulture.TwoLetterISOLanguageName }); else return RedirectToAction("Status401", "Home", new { culture = CultureInfo.CurrentCulture.TwoLetterISOLanguageName }); } return RedirectToAction("Login", "Account", new { culture = CultureInfo.CurrentCulture.TwoLetterISOLanguageName }); } #endregion #region FIDO2 Specific Register /// /// FIDO2 Options erstellen /// /// /// JSON [HttpPost] [ValidateAntiForgeryToken] public async Task MakeCredentialOptions(CreateFidoCredentialVm model) { try { var user = new Fido2User { DisplayName = model.Username, Name = model.Username, Id = Encoding.UTF8.GetBytes(model.Username) // byte representation of userID is required }; // 2. Get user existing keys by username var items = await _fidoService.GetCredentialsAsync(model.Username); var existingKeys = new List(); foreach (var publicKeyCredentialDescriptor in items) { existingKeys.Add(publicKeyCredentialDescriptor.Descriptor); } // 3. Create options var authenticatorSelection = new AuthenticatorSelection { RequireResidentKey = model.RequireResidentKey, UserVerification = model.UserVerification.ToEnum() }; if (!string.IsNullOrEmpty(model.AuthType)) authenticatorSelection.AuthenticatorAttachment = model.AuthType.ToEnum(); var exts = new AuthenticationExtensionsClientInputs() { Extensions = true, UserVerificationIndex = true, Location = true, UserVerificationMethod = true, BiometricAuthenticatorPerformanceBounds = new AuthenticatorBiometricPerfBounds { FAR = float.MaxValue, FRR = float.MaxValue } }; var options = _fidoLib.RequestNewCredential(user, existingKeys, authenticatorSelection, model.AttType.ToEnum(), exts); // 4. Temporarily store options, session/in-memory cache/redis/db await _memoryCache.SetStringAsync($"{model.Username}.fido2.attestationOptions", options.ToJson(), new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }); await _memoryCache.SetStringAsync($"{model.Username}.fido2.model", JsonSerializer.Serialize(model), new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }); //HttpContext.Session.SetString("fido2.attestationOptions", options.ToJson()); // 5. return options to client var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, DateFormatHandling = DateFormatHandling.IsoDateFormat }; return Json(options, settings); } catch (Exception e) { return Json(new CredentialCreateOptions { Status = "error", ErrorMessage = FormatException(e) }); } } /// /// FIDO2 Credentials erstellen und Speichern /// /// /// JSON [HttpPost] [ValidateAntiForgeryToken] public async Task MakeCredential([FromBody] AuthenticatorAttestationRawResponse attestationResponse) { try { // 1. get the options we sent the client var jsonOptions = await _memoryCache.GetStringAsync($"{User.Identity.Name}.fido2.attestationOptions"); var options = CredentialCreateOptions.FromJson(jsonOptions); var json = await _memoryCache.GetStringAsync($"{User.Identity.Name}.fido2.model"); var model = JsonSerializer.Deserialize(json); // 2. Create callback so that lib can verify credential id is unique to this user async Task Callback(IsCredentialIdUniqueToUserParams args) { var users = await _fidoService.GetUsersByCredentialIdAsync(args.CredentialId); if (users.Count > 0) return false; return true; } // 2. Verify and make the credentials var success = await _fidoLib.MakeNewCredentialAsync(attestationResponse, options, (IsCredentialIdUniqueToUserAsyncDelegate)Callback); // 3. Store the credentials in db var credential = _fidoService.Create(); credential.UserId = options.User.Id; credential.Name = model.Name; credential.CredentialType = (FidoCredentialType)model.CredentialType; credential.Username = options.User.Name; credential.Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId); credential.PublicKey = success.Result.PublicKey; credential.UserHandle = success.Result.User.Id; credential.SignatureCounter = success.Result.Counter; credential.CredType = success.Result.CredType; credential.RegDate = DateTimeOffset.UtcNow; credential.AaGuid = success.Result.Aaguid; _fidoService.Add(credential); await _fidoService.CommitAsync(User.Identity.Name); return Json(success); } catch (Exception e) { return Json(new Fido2.CredentialMakeResult { Status = "error", ErrorMessage = FormatException(e) }); } } /// /// Hilfs-Funktion zum formatieren einer Fehlermeldung /// /// Fehler /// Formatierte Fehlermeldung private string FormatException(Exception e) { return $"{e.Message}{(e.InnerException != null ? " (" + e.InnerException.Message + ")" : "")}"; } #endregion #region FIDO2 Specific Login /// /// Fido-Options für Login erstellen /// /// /// /// JSON [AllowAnonymous] [HttpPost] [ValidateAntiForgeryToken] public async Task AssertionOptionsPost([FromForm] string username, [FromForm] string userVerification) { try { var existingCredentials = new List(); if (!string.IsNullOrEmpty(username)) { var identityUser = await _userManager.FindByNameAsync(username); if(identityUser == null) return Json(new AssertionOptions { Status = "error", ErrorMessage = "unf" }); var user = new Fido2User { DisplayName = identityUser.UserName, Name = identityUser.UserName, Id = Encoding.UTF8.GetBytes(identityUser.UserName) // byte representation of userID is required }; if (user == null) throw new ArgumentException("Username was not registered"); // 2. Get registered credentials from database var items = await _fidoService.GetCredentialsAsync(identityUser.UserName); existingCredentials = items.Select(c => c.Descriptor).ToList(); } var exts = new AuthenticationExtensionsClientInputs() { SimpleTransactionAuthorization = "FIDO", GenericTransactionAuthorization = new TxAuthGenericArg { ContentType = "text/plain", Content = new byte[] { 0x46, 0x49, 0x44, 0x4F } }, UserVerificationIndex = true, Location = true, UserVerificationMethod = true }; // 3. Create options var uv = string.IsNullOrEmpty(userVerification) ? UserVerificationRequirement.Discouraged : userVerification.ToEnum(); var options = _fidoLib.GetAssertionOptions( existingCredentials, uv, exts ); // 4. Temporarily store options, session/in-memory cache/redis/db await _memoryCache.SetStringAsync($"{username}.fido2.assertionOptions", options.ToJson(), new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }); // 5. Return options to client var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, DateFormatHandling = DateFormatHandling.IsoDateFormat }; return Json(options, settings); } catch (Exception e) { return Json(new AssertionOptions { Status = "error", ErrorMessage = FormatException(e) }); } } /// /// Fido Login prüfen /// /// /// /// JSON [AllowAnonymous] [HttpPost] [ValidateAntiForgeryToken] public async Task MakeAssertion([FromBody] AuthenticatorAssertionRawResponse clientResponse, [FromQuery] string username) { try { // 1. Get the assertion options we sent the client var jsonOptions = await _memoryCache.GetStringAsync($"{username}.fido2.assertionOptions"); var options = AssertionOptions.FromJson(jsonOptions); // 2. Get registered credential from database var creds = await _fidoService.GetByIdAsync(clientResponse.Id); if (creds == null) { throw new Exception("Unknown credentials"); } // 3. Get credential counter from database var storedCounter = creds.SignatureCounter; // 4. Create callback to check if userhandle owns the credentialId async Task Callback(IsUserHandleOwnerOfCredentialIdParams args) { var storedCreds = await _fidoService.GetCredentialsByUserHandleAsync(args.UserHandle); return storedCreds.Exists(c => c.Descriptor.Id.SequenceEqual(args.CredentialId)); } // 5. Make the assertion var res = await _fidoLib.MakeAssertionAsync(clientResponse, options, creds.PublicKey, storedCounter, Callback); // 6. Store the updated counter await _fidoService.UpdateCounterAsync(res.CredentialId, res.Counter); var user = await _userService.GetByUsernameAsync(creds.Username); if (user == null) { throw new InvalidOperationException($"Unable to load user."); } if (await _userManager.IsLockedOutAsync(user)) { throw new InvalidOperationException($"Locked out."); } user.LastLoginDate = DateTimeOffset.UtcNow; await _userService.CommitAsync(user.UserName); await _signInManager.SignInAsync(user, isPersistent: _sessionSettings.Value.PersistentCookie); CultureInfo.CurrentCulture = new CultureInfo(user.PreferredLanguage); // 7. return OK to client return Json(res); } catch (Exception e) { return Json(new AssertionVerificationResult { Status = "error", ErrorMessage = FormatException(e) }); } } #endregion } }