feat: add CalDAV app password entity and migration Phase12cCalDav

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 21:57:41 +02:00
1 parent 298d765465
commit 77ab48a954
7 files changed
+2876

No files matched your search

@@ -36,6 +36,8 @@ public static class AuditActions
public const string CalendarDeleted = "calendar.deleted";
public const string CalendarImported = "calendar.imported";
public const string FeedRevoked = "calendar.feed_revoked";
public const string CalDavCredentialCreated = "calendar.caldav_credential_created";
public const string CalDavCredentialRevoked = "calendar.caldav_credential_revoked";
public const string AiProviderCreated = "ai.provider_created";
public const string AiProviderUpdated = "ai.provider_updated";
public const string AiProviderDeleted = "ai.provider_deleted";
@@ -54,6 +56,8 @@ public static class AuditActions
CalendarDeleted => "Kalender gelöscht",
CalendarImported => "Kalender importiert",
FeedRevoked => "Kalender-Abo widerrufen",
CalDavCredentialCreated => "App-Passwort (Kalender-Sync) erstellt",
CalDavCredentialRevoked => "App-Passwort (Kalender-Sync) widerrufen",
AiProviderCreated => "KI-Anbieter angelegt",
AiProviderUpdated => "KI-Anbieter geändert",
AiProviderDeleted => "KI-Anbieter gelöscht",
@@ -0,0 +1,70 @@
using System.Security.Cryptography;
using System.Text;
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Calendar;
/// <summary>
/// App-Passwort eines Benutzers für CalDAV-Clients (iOS, Thunderbird, DAVx5; ADR-0017). Nie das normale Passwort: Das Geheimnis wird
/// einmalig angezeigt, nur als SHA-256 gespeichert und kann jederzeit widerrufen werden. Es gilt ausschließlich für <c>/caldav</c>.
/// </summary>
public sealed class CalDavCredential : AuditableEntity
{
public const int LabelMaxLength = 60;
public const int MaxActivePerUser = 10;
public Guid UserId { get; set; }
/// <summary>Bezeichnung, z. B. „iPhone von Anna“.</summary>
public string Label { get; set; } = string.Empty;
/// <summary>SHA-256 des normalisierten App-Passworts (hex, kleingeschrieben).</summary>
public string SecretHash { get; set; } = string.Empty;
/// <summary>Erste Zeichen zur Wiedererkennung in der Verwaltung.</summary>
public string SecretPrefix { get; set; } = string.Empty;
public DateTimeOffset? LastUsedAt { get; set; }
/// <summary>User-Agent des letzten Zugriffs (gekürzt) – hilft beim Erkennen des Geräts.</summary>
public string? LastUsedClient { get; set; }
public DateTimeOffset? RevokedAt { get; set; }
public Guid? RevokedBy { get; set; }
public bool IsActive => RevokedAt is null;
}
/// <summary>
/// App-Passwörter: 20 Zeichen aus einem 31er-Alphabet (rund 99 Bit Zufall), gruppiert zu <c>xxxx-xxxx-xxxx-xxxx-xxxx</c>.
/// Wegen der hohen Entropie genügt ein schneller Hash (Basic-Auth sendet das Geheimnis bei jeder Anfrage).
/// </summary>
public static class CalDavSecret
{
public const int PrefixLength = 4;
private const string Alphabet = "abcdefghjkmnpqrstuvwxyz23456789"; // ohne l, i, o, 0, 1 (Verwechslungsgefahr)
private const int Length = 20;
public static string Generate()
{
var chars = new char[Length];
for (var i = 0; i < Length; i++)
{
chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
}
return string.Join('-', Enumerable.Range(0, Length / 4).Select(g => new string(chars, g * 4, 4)));
}
/// <summary>Entfernt Trenner/Leerzeichen und vereinheitlicht die Schreibweise (iOS fügt gern Leerzeichen ein).</summary>
public static string Normalize(string? secret) =>
new((secret ?? string.Empty).Where(char.IsAsciiLetterOrDigit).Select(char.ToLowerInvariant).ToArray());
public static bool IsWellFormed(string? secret) => Normalize(secret).Length == Length;
public static string Hash(string secret) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Normalize(secret))));
public static string Prefix(string secret) => Normalize(secret)[..PrefixLength];
}
@@ -60,6 +60,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<IcsFeed> IcsFeeds => Set<IcsFeed>();
public DbSet<CalDavCredential> CalDavCredentials => Set<CalDavCredential>();
public DbSet<ChatChannel> ChatChannels => Set<ChatChannel>();
public DbSet<ChatMember> ChatMembers => Set<ChatMember>();
@@ -49,6 +49,21 @@ internal sealed class CalendarEventConfiguration : IEntityTypeConfiguration<Cale
}
}
internal sealed class CalDavCredentialConfiguration : IEntityTypeConfiguration<CalDavCredential>
{
public void Configure(EntityTypeBuilder<CalDavCredential> builder)
{
builder.ToTable("CalDavCredentials");
builder.Property(c => c.Label).HasMaxLength(CalDavCredential.LabelMaxLength).IsRequired();
builder.Property(c => c.SecretHash).HasMaxLength(64).IsRequired();
builder.Property(c => c.SecretPrefix).HasMaxLength(CalDavSecret.PrefixLength).IsRequired();
builder.Property(c => c.LastUsedClient).HasMaxLength(100);
builder.Ignore(c => c.IsActive);
builder.HasIndex(c => c.SecretHash).IsUnique();
builder.HasIndex(c => c.UserId);
}
}
internal sealed class IcsFeedConfiguration : IEntityTypeConfiguration<IcsFeed>
{
public void Configure(EntityTypeBuilder<IcsFeed> builder)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase12cCalDav : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CalDavCredentials",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Label = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: false),
SecretHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
SecretPrefix = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
LastUsedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LastUsedClient = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
RevokedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RevokedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CalDavCredentials", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_CalDavCredentials_SecretHash",
table: "CalDavCredentials",
column: "SecretHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CalDavCredentials_UserId",
table: "CalDavCredentials",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CalDavCredentials");
}
}
}
@@ -218,6 +218,65 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
b.ToTable("AuditLog", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Calendar.CalDavCredential", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<DateTimeOffset?>("LastUsedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("LastUsedClient")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("RevokedBy")
.HasColumnType("uniqueidentifier");
b.Property<string>("SecretHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SecretPrefix")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("SecretHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("CalDavCredentials", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Calendar.CalendarEvent", b =>
{
b.Property<Guid>("Id")