Files
ChronoCCG/Chrono/Cloud/Controllers/AuthController.cs
T
2026-07-05 17:14:19 -04:00

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." });
}
}