220 lines
8.5 KiB
C#
220 lines
8.5 KiB
C#
using System.Security.Claims;
|
|
using Fido2NetLib;
|
|
using Fido2NetLib.Objects;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Cloud.Models;
|
|
|
|
namespace Cloud.Controllers;
|
|
|
|
[AllowAnonymous]
|
|
[ApiController]
|
|
[Route("api/auth")]
|
|
public class AuthController : ControllerBase
|
|
{
|
|
private const string RegOptionsKey = "fido2.reg.options";
|
|
private const string AssertOptionsKey = "fido2.assert.options";
|
|
|
|
private readonly IFido2 _fido2;
|
|
private readonly AppDbContext _db;
|
|
|
|
public AuthController(IFido2 fido2, AppDbContext db)
|
|
{
|
|
_fido2 = fido2;
|
|
_db = db;
|
|
}
|
|
|
|
// ── Registration ──────────────────────────────────────────────────────────
|
|
|
|
[HttpPost("register/options")]
|
|
public async Task<IActionResult> RegisterOptions()
|
|
{
|
|
// Only allow registration when unenrolled or already authenticated
|
|
var hasCredentials = await _db.PasskeyCredentials.AnyAsync();
|
|
if (hasCredentials && !User.Identity!.IsAuthenticated)
|
|
return Forbid();
|
|
|
|
var user = new Fido2User
|
|
{
|
|
Id = Guid.NewGuid().ToByteArray(),
|
|
Name = "owner",
|
|
DisplayName = "Owner"
|
|
};
|
|
|
|
var existingKeys = await _db.PasskeyCredentials
|
|
.Select(c => new PublicKeyCredentialDescriptor(c.CredentialId))
|
|
.ToListAsync();
|
|
|
|
var options = _fido2.RequestNewCredential(new RequestNewCredentialParams
|
|
{
|
|
User = user,
|
|
ExcludeCredentials = existingKeys,
|
|
AuthenticatorSelection = new AuthenticatorSelection
|
|
{
|
|
ResidentKey = ResidentKeyRequirement.Required,
|
|
UserVerification = UserVerificationRequirement.Required,
|
|
},
|
|
AttestationPreference = AttestationConveyancePreference.None,
|
|
});
|
|
|
|
HttpContext.Session.SetString(RegOptionsKey, options.ToJson());
|
|
return Ok(options);
|
|
}
|
|
|
|
[HttpPost("register/complete")]
|
|
public async Task<IActionResult> RegisterComplete([FromBody] AuthenticatorAttestationRawResponse attestationResponse)
|
|
{
|
|
var json = HttpContext.Session.GetString(RegOptionsKey);
|
|
if (json is null) return BadRequest("Session expired. Please retry enrollment.");
|
|
|
|
var options = CredentialCreateOptions.FromJson(json);
|
|
HttpContext.Session.Remove(RegOptionsKey);
|
|
|
|
var hasCredentials = await _db.PasskeyCredentials.AnyAsync();
|
|
if (hasCredentials && !User.Identity!.IsAuthenticated)
|
|
return Forbid();
|
|
|
|
IsCredentialIdUniqueToUserAsyncDelegate isUnique = async (args, _) =>
|
|
!await _db.PasskeyCredentials.AnyAsync(c => c.CredentialId == args.CredentialId);
|
|
|
|
var result = await _fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
|
|
{
|
|
AttestationResponse = attestationResponse,
|
|
OriginalOptions = options,
|
|
IsCredentialIdUniqueToUserCallback = isUnique,
|
|
});
|
|
|
|
var credential = new PasskeyCredential
|
|
{
|
|
CredentialId = result.Id,
|
|
PublicKey = result.PublicKey,
|
|
SignCount = result.SignCount,
|
|
UserHandle = result.User.Id,
|
|
AaGuid = result.AaGuid.ToString(),
|
|
};
|
|
_db.PasskeyCredentials.Add(credential);
|
|
await _db.SaveChangesAsync();
|
|
|
|
var claims = new[] { new Claim(ClaimTypes.Name, "owner") };
|
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
await HttpContext.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
new ClaimsPrincipal(identity),
|
|
new AuthenticationProperties { IsPersistent = true });
|
|
|
|
return Ok(new { message = "Passkey enrolled and authenticated." });
|
|
}
|
|
|
|
// ── Authentication ────────────────────────────────────────────────────────
|
|
|
|
[HttpPost("login/options")]
|
|
public async Task<IActionResult> LoginOptions()
|
|
{
|
|
var existingKeys = await _db.PasskeyCredentials
|
|
.Select(c => new PublicKeyCredentialDescriptor(c.CredentialId))
|
|
.ToListAsync();
|
|
|
|
var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams
|
|
{
|
|
AllowedCredentials = existingKeys,
|
|
UserVerification = UserVerificationRequirement.Required,
|
|
});
|
|
|
|
HttpContext.Session.SetString(AssertOptionsKey, options.ToJson());
|
|
return Ok(options);
|
|
}
|
|
|
|
[HttpPost("login/complete")]
|
|
public async Task<IActionResult> LoginComplete([FromBody] AuthenticatorAssertionRawResponse assertionResponse)
|
|
{
|
|
var json = HttpContext.Session.GetString(AssertOptionsKey);
|
|
if (json is null) return BadRequest("Session expired. Please retry sign-in.");
|
|
|
|
var options = AssertionOptions.FromJson(json);
|
|
HttpContext.Session.Remove(AssertOptionsKey);
|
|
|
|
// assertionResponse.RawId contains the credential ID as raw bytes
|
|
var credentialId = assertionResponse.RawId;
|
|
var allCredentials = await _db.PasskeyCredentials.ToListAsync();
|
|
var stored = allCredentials.FirstOrDefault(c => c.CredentialId.SequenceEqual(credentialId));
|
|
|
|
if (stored is null) return Unauthorized("Credential not recognized.");
|
|
|
|
IsUserHandleOwnerOfCredentialIdAsync isOwner = (args, _) =>
|
|
Task.FromResult(args.UserHandle.SequenceEqual(stored.UserHandle));
|
|
|
|
var result = await _fido2.MakeAssertionAsync(new MakeAssertionParams
|
|
{
|
|
AssertionResponse = assertionResponse,
|
|
OriginalOptions = options,
|
|
StoredPublicKey = stored.PublicKey,
|
|
StoredSignatureCounter = (uint)stored.SignCount,
|
|
IsUserHandleOwnerOfCredentialIdCallback = isOwner,
|
|
});
|
|
|
|
stored.SignCount = result.SignCount;
|
|
await _db.SaveChangesAsync();
|
|
|
|
var claims = new[] { new Claim(ClaimTypes.Name, "owner") };
|
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
await HttpContext.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
new ClaimsPrincipal(identity),
|
|
new AuthenticationProperties { IsPersistent = true });
|
|
|
|
return Ok(new { message = "Authenticated." });
|
|
}
|
|
|
|
// ── Logout ────────────────────────────────────────────────────────────────
|
|
|
|
[HttpPost("logout")]
|
|
public async Task<IActionResult> Logout()
|
|
{
|
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
return Ok();
|
|
}
|
|
|
|
// ── Status ────────────────────────────────────────────────────────────────
|
|
|
|
[HttpGet("status")]
|
|
public async Task<IActionResult> Status()
|
|
{
|
|
var hasCredentials = await _db.PasskeyCredentials.AnyAsync();
|
|
return Ok(new
|
|
{
|
|
enrolled = hasCredentials,
|
|
authenticated = User.Identity?.IsAuthenticated ?? false,
|
|
});
|
|
}
|
|
|
|
// ── Dev-only test helpers (Development environment only) ──────────────────
|
|
|
|
[HttpPost("dev-login")]
|
|
public async Task<IActionResult> DevLogin([FromServices] IWebHostEnvironment env)
|
|
{
|
|
if (!env.IsDevelopment()) return NotFound();
|
|
|
|
var claims = new[] { new Claim(ClaimTypes.Name, "owner") };
|
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
await HttpContext.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
new ClaimsPrincipal(identity),
|
|
new AuthenticationProperties { IsPersistent = false });
|
|
|
|
return Ok(new { message = "Dev login successful." });
|
|
}
|
|
|
|
[HttpDelete("credentials")]
|
|
public async Task<IActionResult> DeleteAllCredentials([FromServices] IWebHostEnvironment env)
|
|
{
|
|
if (!env.IsDevelopment()) return NotFound();
|
|
|
|
_db.PasskeyCredentials.RemoveRange(_db.PasskeyCredentials);
|
|
await _db.SaveChangesAsync();
|
|
return Ok(new { message = "All credentials deleted." });
|
|
}
|
|
}
|