540 lines
23 KiB
C#

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
{
/// <summary>
/// Controller für die Verwaltung von Fido-Credentials usw.
/// </summary>
[Authorize]
public class FidoController : BaseController
{
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IUserService _userService;
private readonly Fido2 _fidoLib;
private readonly IFidoService _fidoService;
private readonly ILogger<FidoController> _logger;
private readonly IStringLocalizer<FidoController> _localizer;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IOptions<Fido2Options> _fidoOptions;
private readonly IDistributedCache _memoryCache;
private readonly IOptions<SessionSettings> _sessionSettings;
/// <summary>
/// Erstellt eine Instanz
/// </summary>
/// <param name="fidoService">Instanz eins IFidoService</param>
/// <param name="logger">Instanz eines ILogger</param>
/// <param name="localizer">Instanz eines IStringLocalizer</param>
/// <param name="mapper">Instanz eines IMapper</param>
/// <param name="userManager">Instanz eines UserManager</param>
/// <param name="fidoOptions">Instanz eines Fido2Options</param>
/// <param name="memoryCache">Instanz eines IDistributedCache</param>
/// <param name="signInManager">Instanz eines SignInManager</param>
/// <param name="userService">Instanz eines IUserService</param>
/// <param name="sessionSettings">Instanz eines IOptions SessionSettings</param>
public FidoController(IFidoService fidoService, ILogger<FidoController> logger, IStringLocalizer<FidoController> localizer, IMapper mapper, UserManager<ApplicationUser> userManager,
IOptions<Fido2Options> fidoOptions, IDistributedCache memoryCache, SignInManager<ApplicationUser> signInManager, IUserService userService,
IOptions<SessionSettings> 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
/// <summary>
/// Gibt einen View für die Fido-Credentials-Verwaltung zurück
/// </summary>
/// <returns>View</returns>
public IActionResult Index()
{
ViewBag.PageInfo = _localizer["Fido_PageInfo"];
return View();
}
/// <summary>
/// Gibt eine Liste von Entitäten basierend auf Abfragekriterien zurück
/// </summary>
/// <param name="dm">Abfragekriterien</param>
/// <returns>Liste von gefundenen Entitäten</returns>
[HttpPost]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult GetCredentials([FromBody] DataManager dm)
{
if (dm != null)
{
var propList = new List<ComplexProperty>();
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<FidoStoredCredentialListVm>(item)).ToList();
//FilterPreview?
if (!dm.RequiresCounts)
return Json(resultListVm);
return Json(new { result = resultListVm, count = countFiltered });
}
/// <summary>
/// Anlegen eines Credentials für Plattform
/// </summary>
/// <returns>PartialView</returns>
[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);
}
/// <summary>
/// Anlegen eines Credentials für CrossPlattform
/// </summary>
/// <returns>PartialView</returns>
[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);
}
/// <summary>
/// Bearbeiten eines Credentials
/// </summary>
/// <param name="id">Id des Credentials</param>
/// <returns>PartialView</returns>
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public async Task<IActionResult> Edit(long id)
{
var item = await _fidoService.GetAsync(id);
if (item != null)
{
var model = _mapper.Map<EditFidoCredentialVm>(item);
return PartialView("_Edit", model);
}
return PartialView("_Error");
}
/// <summary>
/// Bearbeiten eines Credentials
/// </summary>
/// <param name="model">Model</param>
/// <returns>Json</returns>
[ValidateAntiForgeryToken]
[HttpPost]
public async Task<IActionResult> 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);
}
/// <summary>
/// Löschen eines Credentials
/// </summary>
/// <param name="id">Id der Entitäten</param>
/// <returns>Json</returns>
[HttpPost]
public async Task<IActionResult> 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);
}
/// <summary>
/// Gibt zurück ob ein Benutzer über Fido2-Credentials verfügt
/// </summary>
/// <param name="userName">Benutzername</param>
/// <returns>JSON</returns>
[AllowAnonymous]
[HttpPost]
public async Task<IActionResult> HasFidoCredentials(string userName)
{
var hasCredentials = await _fidoService.HasFidoCredentialsAsync(userName);
return Json(hasCredentials);
}
/// <summary>
/// Hilfsmethode die einen angemeldeten Benutzer (Fido) zur richtigen Startseite weiterleitet
/// </summary>
/// <returns></returns>
public async Task<IActionResult> 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
/// <summary>
/// FIDO2 Options erstellen
/// </summary>
/// <param name="model"></param>
/// <returns>JSON</returns>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<JsonResult> 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<PublicKeyCredentialDescriptor>();
foreach (var publicKeyCredentialDescriptor in items)
{
existingKeys.Add(publicKeyCredentialDescriptor.Descriptor);
}
// 3. Create options
var authenticatorSelection = new AuthenticatorSelection
{
RequireResidentKey = model.RequireResidentKey,
UserVerification = model.UserVerification.ToEnum<UserVerificationRequirement>()
};
if (!string.IsNullOrEmpty(model.AuthType))
authenticatorSelection.AuthenticatorAttachment = model.AuthType.ToEnum<AuthenticatorAttachment>();
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<AttestationConveyancePreference>(), 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) });
}
}
/// <summary>
/// FIDO2 Credentials erstellen und Speichern
/// </summary>
/// <param name="attestationResponse"></param>
/// <returns>JSON</returns>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<JsonResult> 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<CreateFidoCredentialVm>(json);
// 2. Create callback so that lib can verify credential id is unique to this user
async Task<bool> 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) });
}
}
/// <summary>
/// Hilfs-Funktion zum formatieren einer Fehlermeldung
/// </summary>
/// <param name="e">Fehler</param>
/// <returns>Formatierte Fehlermeldung</returns>
private string FormatException(Exception e)
{
return $"{e.Message}{(e.InnerException != null ? " (" + e.InnerException.Message + ")" : "")}";
}
#endregion
#region FIDO2 Specific Login
/// <summary>
/// Fido-Options für Login erstellen
/// </summary>
/// <param name="username"></param>
/// <param name="userVerification"></param>
/// <returns>JSON</returns>
[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> AssertionOptionsPost([FromForm] string username, [FromForm] string userVerification)
{
try
{
var existingCredentials = new List<PublicKeyCredentialDescriptor>();
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<UserVerificationRequirement>();
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) });
}
}
/// <summary>
/// Fido Login prüfen
/// </summary>
/// <param name="clientResponse"></param>
/// <param name="username"></param>
/// <returns>JSON</returns>
[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<JsonResult> 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<bool> 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
}
}