feat: add CalDAV app password entity and migration Phase12cCalDav
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
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)
|
||||
|
||||
Generated
+2670
File diff suppressed because it is too large.
Load diff
+56
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user