diff --git a/FoodsharingSiegen.Contracts/Entity/User.cs b/FoodsharingSiegen.Contracts/Entity/User.cs index 2424cca..8ea2ec4 100644 --- a/FoodsharingSiegen.Contracts/Entity/User.cs +++ b/FoodsharingSiegen.Contracts/Entity/User.cs @@ -95,6 +95,16 @@ namespace FoodsharingSiegen.Contracts.Entity set => EncryptedPassword = Cryptor.Encrypt(value); } + /// + /// Gets or sets the reset token for password recovery (ab) + /// + public string? ResetToken { get; set; } + + /// + /// Gets or sets the expiry date for the reset token (ab) + /// + public DateTime? ResetTokenExpiry { get; set; } + /// /// Gets or sets the value of the type (ab) /// diff --git a/FoodsharingSiegen.Server/Auth/AuthService.cs b/FoodsharingSiegen.Server/Auth/AuthService.cs index 72ded49..c85531c 100644 --- a/FoodsharingSiegen.Server/Auth/AuthService.cs +++ b/FoodsharingSiegen.Server/Auth/AuthService.cs @@ -50,6 +50,11 @@ namespace FoodsharingSiegen.Server.Auth /// private User? _user; + /// + /// The mail service + /// + private readonly IMailService _mailService; + #endregion #region Setup/Teardown @@ -60,14 +65,17 @@ namespace FoodsharingSiegen.Server.Auth /// The context /// The local storage service /// The authentication state provider + /// The mail service public AuthService( FsContext context, LocalStorageService localStorageService, - AuthenticationStateProvider authenticationStateProvider) + AuthenticationStateProvider authenticationStateProvider, + IMailService mailService) { Context = context; _localStorageService = localStorageService; _authenticationStateProvider = authenticationStateProvider; + _mailService = mailService; } #endregion @@ -186,5 +194,52 @@ namespace FoodsharingSiegen.Server.Auth } #endregion + + #region Password Recovery + + public async Task InitiatePasswordReset(string email, string baseUri) + { + if (string.IsNullOrWhiteSpace(email)) return; + + var user = await Context.Users!.FirstOrDefaultAsync(x => x.Mail.ToLower() == email.ToLower()); + if (user == null) return; // Do not leak existence + + var resetToken = Guid.NewGuid().ToString("N"); + user.ResetToken = resetToken; + user.ResetTokenExpiry = DateTime.UtcNow.AddMinutes(30); + + await Context.SaveChangesAsync(); + + var resetLink = $"{baseUri.TrimEnd('/')}/reset-password/{resetToken}"; + var mailBody = $"Hallo {user.Name},

Um dein Passwort zurückzusetzen, klicke bitte auf den folgenden Link (dieser ist 30 Minuten gültig):
{resetLink}

Viele Grüße
Dein Foodsharing Team"; + + await _mailService.SendEmailAsync(user.Mail, "Passwort zurücksetzen", mailBody); + } + + public async Task ResetPassword(string token, string newPassword) + { + if (string.IsNullOrWhiteSpace(token)) return new OperationResult(new Exception("Ungültiges Token.")); + if (string.IsNullOrWhiteSpace(newPassword)) return new OperationResult(new Exception("Passwort darf nicht leer sein.")); + + var user = await Context.Users!.FirstOrDefaultAsync(x => x.ResetToken == token && x.ResetTokenExpiry > DateTime.UtcNow); + if (user == null) return new OperationResult(new Exception("Token ist ungültig oder abgelaufen.")); + + user.Password = newPassword; + user.ResetToken = null; + user.ResetTokenExpiry = null; + + await Context.SaveChangesAsync(); + + return new OperationResult(); + } + + public async Task IsResetTokenValid(string token) + { + if (string.IsNullOrWhiteSpace(token)) return false; + var user = await Context.Users!.FirstOrDefaultAsync(x => x.ResetToken == token && x.ResetTokenExpiry > DateTime.UtcNow); + return user != null; + } + + #endregion } } \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Migrations/20260418111848_PasswordRecovery.Designer.cs b/FoodsharingSiegen.Server/Migrations/20260418111848_PasswordRecovery.Designer.cs new file mode 100644 index 0000000..1f9dd1f --- /dev/null +++ b/FoodsharingSiegen.Server/Migrations/20260418111848_PasswordRecovery.Designer.cs @@ -0,0 +1,223 @@ +// +using System; +using FoodsharingSiegen.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FoodsharingSiegen.Server.Migrations +{ + [DbContext(typeof(FsContext))] + [Migration("20260418111848_PasswordRecovery")] + partial class PasswordRecovery + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.3"); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Audit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Data1") + .HasColumnType("TEXT"); + + b.Property("Data2") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserID") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserID"); + + b.ToTable("Audits"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Interaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alert") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Feedback") + .HasColumnType("INTEGER"); + + b.Property("FeedbackInfo") + .HasColumnType("TEXT"); + + b.Property("Info1") + .HasColumnType("TEXT"); + + b.Property("Info2") + .HasColumnType("TEXT"); + + b.Property("NotNeeded") + .HasColumnType("INTEGER"); + + b.Property("ProspectID") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserID") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProspectID"); + + b.HasIndex("UserID"); + + b.ToTable("Interactions"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Prospect", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FsId") + .HasColumnType("INTEGER"); + + b.Property("Memo") + .HasColumnType("TEXT"); + + b.Property("Modified") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RecordState") + .HasColumnType("INTEGER"); + + b.Property("Warning") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Prospects"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ForceLogout") + .HasColumnType("INTEGER"); + + b.Property("Groups") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Mail") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Memo") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Network") + .HasColumnType("INTEGER"); + + b.Property("ResetToken") + .HasColumnType("TEXT"); + + b.Property("ResetTokenExpiry") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("Verified") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Audit", b => + { + b.HasOne("FoodsharingSiegen.Contracts.Entity.User", "User") + .WithMany() + .HasForeignKey("UserID"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Interaction", b => + { + b.HasOne("FoodsharingSiegen.Contracts.Entity.Prospect", "Prospect") + .WithMany("Interactions") + .HasForeignKey("ProspectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FoodsharingSiegen.Contracts.Entity.User", "User") + .WithMany("Interactions") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Prospect"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Prospect", b => + { + b.Navigation("Interactions"); + }); + + modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.User", b => + { + b.Navigation("Interactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FoodsharingSiegen.Server/Migrations/20260418111848_PasswordRecovery.cs b/FoodsharingSiegen.Server/Migrations/20260418111848_PasswordRecovery.cs new file mode 100644 index 0000000..9907259 --- /dev/null +++ b/FoodsharingSiegen.Server/Migrations/20260418111848_PasswordRecovery.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FoodsharingSiegen.Server.Migrations +{ + /// + public partial class PasswordRecovery : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ResetToken", + table: "Users", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ResetTokenExpiry", + table: "Users", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ResetToken", + table: "Users"); + + migrationBuilder.DropColumn( + name: "ResetTokenExpiry", + table: "Users"); + } + } +} diff --git a/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs b/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs index ce71748..05af1a6 100644 --- a/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs +++ b/FoodsharingSiegen.Server/Migrations/FsContextModelSnapshot.cs @@ -160,6 +160,12 @@ namespace FoodsharingSiegen.Server.Migrations b.Property("Network") .HasColumnType("INTEGER"); + b.Property("ResetToken") + .HasColumnType("TEXT"); + + b.Property("ResetTokenExpiry") + .HasColumnType("TEXT"); + b.Property("Type") .HasColumnType("INTEGER"); diff --git a/FoodsharingSiegen.Server/Pages/ForgotPassword.razor b/FoodsharingSiegen.Server/Pages/ForgotPassword.razor new file mode 100644 index 0000000..73df78a --- /dev/null +++ b/FoodsharingSiegen.Server/Pages/ForgotPassword.razor @@ -0,0 +1,54 @@ +@page "/forgot-password" +@using FoodsharingSiegen.Shared.Helper +@layout LoginLayout + +@inherits FoodsharingSiegen.Server.BaseClasses.FsBase + +@AppSettings.Terms.Title - Passwort vergessen + +
+
+ +
+
+ +

Einarbeitungen @AppSettings.Terms.Title

+

Passwort zurücksetzen

+
+ + @if (IsSubmitted) + { +
+ Wenn ein Benutzerkonto mit dieser E-Mail-Adresse existiert, wurde eine E-Mail mit weiteren Anweisungen versendet. +
+ + } + else + { + + + E-Mail Adresse + + + + + + + + } +
+
+
\ No newline at end of file diff --git a/FoodsharingSiegen.Server/Pages/ForgotPassword.razor.cs b/FoodsharingSiegen.Server/Pages/ForgotPassword.razor.cs new file mode 100644 index 0000000..ed92fff --- /dev/null +++ b/FoodsharingSiegen.Server/Pages/ForgotPassword.razor.cs @@ -0,0 +1,41 @@ +using Blazorise; +using FoodsharingSiegen.Server.BaseClasses; +using FoodsharingSiegen.Server.Auth; +using FoodsharingSiegen.Shared.Helper; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using System.Threading.Tasks; + +namespace FoodsharingSiegen.Server.Pages +{ + public partial class ForgotPassword : FsBase + { + public string MailAddress { get; set; } = string.Empty; + public ValidationStatus IsValidMail { get; set; } = ValidationStatus.None; + + public bool IsSubmitted { get; set; } + public bool IsLoading { get; set; } + + public async Task SubmitRequest() + { + if (IsValidMail != ValidationStatus.Success) return; + + IsLoading = true; + await InvokeAsync(StateHasChanged); + + await AuthService.InitiatePasswordReset(MailAddress, NavigationManager.BaseUri); + + IsSubmitted = true; + IsLoading = false; + await InvokeAsync(StateHasChanged); + } + + public async Task TextEdit_KeyUp(KeyboardEventArgs e) + { + if (e.Key == "Enter" && IsValidMail == ValidationStatus.Success) + { + await SubmitRequest(); + } + } + } +} \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Pages/Login.razor b/FoodsharingSiegen.Server/Pages/Login.razor index a4dc468..0ad92fd 100644 --- a/FoodsharingSiegen.Server/Pages/Login.razor +++ b/FoodsharingSiegen.Server/Pages/Login.razor @@ -40,6 +40,9 @@ + \ No newline at end of file diff --git a/FoodsharingSiegen.Server/Pages/ResetPassword.razor b/FoodsharingSiegen.Server/Pages/ResetPassword.razor new file mode 100644 index 0000000..af21130 --- /dev/null +++ b/FoodsharingSiegen.Server/Pages/ResetPassword.razor @@ -0,0 +1,82 @@ +@page "/reset-password/{Token}" +@using FoodsharingSiegen.Shared.Helper +@layout LoginLayout + +@inherits FoodsharingSiegen.Server.BaseClasses.FsBase + +@AppSettings.Terms.Title - Neues Passwort setzen + +
+
+ +
+
+ +

Einarbeitungen @AppSettings.Terms.Title

+

Neues Passwort festlegen

+
+ + @if (IsInitializing) + { +
+ +

Token wird überprüft...

+
+ } + else if (!IsTokenValid) + { +
+ + Der Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Bitte fordere einen neuen an. +
+ + } + else if (IsSuccess) + { +
+ Passwort erfolgreich aktualisiert. Du kannst dich jetzt anmelden. +
+ + } + else + { + + + Neues Passwort + + + + + + + Passwort bestätigen + + + + + @if (!string.IsNullOrEmpty(ErrorMessage)) + { +
+ @ErrorMessage +
+ } + + + } +
+
+
\ No newline at end of file diff --git a/FoodsharingSiegen.Server/Pages/ResetPassword.razor.cs b/FoodsharingSiegen.Server/Pages/ResetPassword.razor.cs new file mode 100644 index 0000000..9549a74 --- /dev/null +++ b/FoodsharingSiegen.Server/Pages/ResetPassword.razor.cs @@ -0,0 +1,77 @@ +using Blazorise; +using FoodsharingSiegen.Server.BaseClasses; +using FoodsharingSiegen.Server.Auth; +using FoodsharingSiegen.Shared.Helper; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using System.Threading.Tasks; + +namespace FoodsharingSiegen.Server.Pages +{ + public partial class ResetPassword : FsBase + { + [Parameter] + public string Token { get; set; } = string.Empty; + + public string NewPassword { get; set; } = string.Empty; + public string ConfirmPassword { get; set; } = string.Empty; + + public ValidationStatus IsValidPassword { get; set; } = ValidationStatus.None; + public ValidationStatus IsValidPasswordConfirmation { get; set; } = ValidationStatus.None; + + public string ErrorMessage { get; set; } = string.Empty; + public bool IsSuccess { get; set; } + public bool IsLoading { get; set; } + public bool IsInitializing { get; set; } = true; + public bool IsTokenValid { get; set; } + + protected override async Task OnInitializedAsync() + { + IsTokenValid = await AuthService.IsResetTokenValid(Token); + IsInitializing = false; + } + + public void ValidatePasswordConfirmation(ValidatorEventArgs args) + { + var confirmPassword = System.Convert.ToString(args.Value); + if (string.IsNullOrWhiteSpace(confirmPassword)) + { + args.Status = ValidationStatus.None; + return; + } + + args.Status = confirmPassword == NewPassword ? ValidationStatus.Success : ValidationStatus.Error; + } + + public async Task SubmitReset() + { + if (IsValidPassword != ValidationStatus.Success || IsValidPasswordConfirmation != ValidationStatus.Success) return; + + IsLoading = true; + ErrorMessage = string.Empty; + await InvokeAsync(StateHasChanged); + + var result = await AuthService.ResetPassword(Token, NewPassword); + + if (result.Success) + { + IsSuccess = true; + } + else + { + ErrorMessage = result.Exception?.Message ?? "Ein Fehler ist aufgetreten."; + } + + IsLoading = false; + await InvokeAsync(StateHasChanged); + } + + public async Task TextEdit_KeyUp(KeyboardEventArgs e) + { + if (e.Key == "Enter" && IsValidPassword == ValidationStatus.Success && IsValidPasswordConfirmation == ValidationStatus.Success) + { + await SubmitReset(); + } + } + } +} \ No newline at end of file diff --git a/FoodsharingSiegen.Shared/Helper/ValidationHelper.cs b/FoodsharingSiegen.Shared/Helper/ValidationHelper.cs index 2e3b989..c09ffb4 100644 --- a/FoodsharingSiegen.Shared/Helper/ValidationHelper.cs +++ b/FoodsharingSiegen.Shared/Helper/ValidationHelper.cs @@ -46,8 +46,8 @@ namespace FoodsharingSiegen.Shared.Helper return; } - var isValid = password.Length > 3; - + var isValid = password.Length >= 8 && password.Any(char.IsDigit); + args.Status = isValid ? ValidationStatus.Success : ValidationStatus.Error; }