diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..09c15ee --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-format": { + "version": "3.3.111304", + "commands": [ + "dotnet-format" + ] + }, + "dotnet-ef": { + "version": "3.1.3", + "commands": [ + "dotnet-ef" + ] + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4d5aa7b..22b5344 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,39 @@ obj/ /.vs/ /.vscode/ *.user + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] diff --git a/docs/defaults/src/WebApp/appsettings.json b/docs/defaults/src/WebApp/appsettings.json index 362d4b8..3333633 100644 --- a/docs/defaults/src/WebApp/appsettings.json +++ b/docs/defaults/src/WebApp/appsettings.json @@ -6,29 +6,32 @@ } }, "IdentityServer": { - "Clients": [ - { - "ClientId": "codidact_client", - "ClientSecrets": [ - { - "Value": "LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564=" - } - ], - "AllowedGrantTypes": [ - "authorization_code" - ], - "AllowedScopes": [ - "openid", - "profile" - ], - "RedirectUris": [ - "http://localhost:8000/signin-oidc" - ], - "RequireConsent": false - } - ] + "Clients": [{ + "ClientId": "codidact_client", + "ClientSecrets": [{ + "Value": "LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564=" + }], + "AllowedGrantTypes": [ + "authorization_code" + ], + "AllowedScopes": [ + "openid", + "profile" + ], + "RedirectUris": [ + "http://localhost:8000/signin-oidc" + ], + "RequireConsent": false + }] }, "ConnectionStrings": { "Authentication": "Data Source=authentication.db" + }, + "Mail": { + "Host": "smtp.gmail.com", + "Port": 465, + "Sender": "example@gmail.com", + "SenderName": "example", + "EnableSsl": true } } diff --git a/src/Application/Common/Interfaces/IMailService.cs b/src/Application/Common/Interfaces/IMailService.cs new file mode 100644 index 0000000..0559152 --- /dev/null +++ b/src/Application/Common/Interfaces/IMailService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Codidact.Authentication.Domain.Entities; + +namespace Codidact.Authentication.Application.Common.Interfaces +{ + public interface IMailService + { + Task SendResetPassword(ApplicationUser user, string token, string returnUrl); + Task SendVerificationEmail(ApplicationUser user, string token, string returnUrl); + } +} diff --git a/src/Application/Options/MailOptions.cs b/src/Application/Options/MailOptions.cs new file mode 100644 index 0000000..cb28a99 --- /dev/null +++ b/src/Application/Options/MailOptions.cs @@ -0,0 +1,11 @@ +namespace Codidact.Authentication.Application.Options +{ + public class MailOptions + { + public string Host { get; set; } + public int Port { get; set; } + public string SenderName { get; set; } + public string Sender { get; set; } + public bool EnableSsl { get; set; } + } +} diff --git a/src/Domain/Entities/ApplicationUser.cs b/src/Domain/Entities/ApplicationUser.cs index f24b8b0..92a7f8c 100644 --- a/src/Domain/Entities/ApplicationUser.cs +++ b/src/Domain/Entities/ApplicationUser.cs @@ -4,6 +4,6 @@ namespace Codidact.Authentication.Domain.Entities { public class ApplicationUser : IdentityUser { - + public string DisplayName { get; set; } } } diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index ce8467b..bc0a35d 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -9,6 +9,7 @@ using Codidact.Authentication.Application.Common.Interfaces; using Codidact.Authentication.Infrastructure.Services; using Codidact.Authentication.Domain.Entities; +using Codidact.Authentication.Application.Options; namespace Codidact.Authentication.Infrastructure { @@ -45,7 +46,8 @@ public static IServiceCollection AddInfrastructure( options.Password.RequireUppercase = false; }) .AddEntityFrameworkStores() - .AddSignInManager(); + .AddSignInManager() + .AddDefaultTokenProviders(); var identityServerBuilder = services.AddIdentityServer() .AddInMemoryClients(configuration.GetSection("IdentityServer:Clients")) @@ -60,6 +62,18 @@ public static IServiceCollection AddInfrastructure( services.AddAuthentication() .AddIdentityCookies(); + services.AddSingleton(configuration.GetSection("Mail").Get()); + + + if (environment.IsDevelopment()) + { + services.AddScoped(); + } + else + { + services.AddScoped(); + } + return services; } } diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index 24477fd..35edfc7 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -5,13 +5,15 @@ Codidact.Authentication.Infrastructure - - - - - - - + + + + + + + + + diff --git a/src/Infrastructure/Persistance/Migrations/20200403160315_AddDisplayName.Designer.cs b/src/Infrastructure/Persistance/Migrations/20200403160315_AddDisplayName.Designer.cs new file mode 100644 index 0000000..98f4f2c --- /dev/null +++ b/src/Infrastructure/Persistance/Migrations/20200403160315_AddDisplayName.Designer.cs @@ -0,0 +1,275 @@ +// +using System; +using Codidact.Authentication.Infrastructure.Persistance; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace Codidact.Authentication.Infrastructure.Persistance.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20200403160315_AddDisplayName")] + partial class AddDisplayName + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) + .HasAnnotation("ProductVersion", "3.1.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + modelBuilder.Entity("Codidact.Authentication.Domain.Entities.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.Property("NormalizedUserName") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasName("UserNameIndex"); + + b.ToTable("identity_user"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.Property("NormalizedName") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasName("RoleNameIndex"); + + b.ToTable("identity_role"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("identity_role_claim"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("identity_user_claim"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("identity_user_login"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("identity_user_role"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("identity_user_token"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Codidact.Authentication.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Codidact.Authentication.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Codidact.Authentication.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Codidact.Authentication.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Persistance/Migrations/20200403160315_AddDisplayName.cs b/src/Infrastructure/Persistance/Migrations/20200403160315_AddDisplayName.cs new file mode 100644 index 0000000..1b21a73 --- /dev/null +++ b/src/Infrastructure/Persistance/Migrations/20200403160315_AddDisplayName.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Codidact.Authentication.Infrastructure.Persistance.Migrations +{ + public partial class AddDisplayName : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DisplayName", + table: "identity_user", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DisplayName", + table: "identity_user"); + } + } +} diff --git a/src/Infrastructure/Persistance/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Infrastructure/Persistance/Migrations/ApplicationDbContextModelSnapshot.cs index 56a9eed..03aa568 100644 --- a/src/Infrastructure/Persistance/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Infrastructure/Persistance/Migrations/ApplicationDbContextModelSnapshot.cs @@ -33,6 +33,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsConcurrencyToken() .HasColumnType("text"); + b.Property("DisplayName") + .HasColumnType("text"); + b.Property("Email") .HasColumnType("character varying(256)") .HasMaxLength(256); diff --git a/src/Infrastructure/Services/DevelopmentMailService.cs b/src/Infrastructure/Services/DevelopmentMailService.cs new file mode 100644 index 0000000..52e7a6d --- /dev/null +++ b/src/Infrastructure/Services/DevelopmentMailService.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using System; + +using Microsoft.Extensions.Logging; + +using Codidact.Authentication.Application.Common.Interfaces; +using Codidact.Authentication.Domain.Entities; + +namespace Codidact.Authentication.Infrastructure.Services +{ + public class DevelopmentMailService : IMailService + { + private readonly ILogger _logger; + + public DevelopmentMailService(ILogger logger) + { + _logger = logger; + } + + public Task SendResetPassword(ApplicationUser user, string token, string returnUrl) + { + _logger.LogInformation($"Sending password reset email to {user.Email} with the return url {returnUrl}."); + return Task.CompletedTask; + } + public Task SendVerificationEmail(ApplicationUser user, string token, string returnUrl) + { + _logger.LogInformation($"Sending email verfication email to {user.Email} with the return url {returnUrl}."); + return Task.CompletedTask; + } + } +} diff --git a/src/Infrastructure/Services/MailService.cs b/src/Infrastructure/Services/MailService.cs new file mode 100644 index 0000000..91544c3 --- /dev/null +++ b/src/Infrastructure/Services/MailService.cs @@ -0,0 +1,58 @@ +using MailKit.Net.Smtp; +using MimeKit; +using MimeKit.Text; +using System.Threading.Tasks; +using System.Web; + +using Codidact.Authentication.Application.Common.Interfaces; +using Codidact.Authentication.Domain.Entities; +using Codidact.Authentication.Application.Options; + +namespace Codidact.Authentication.Infrastructure.Services +{ + public class MailService : IMailService + { + private MailOptions _emailConfiguration; + private ISecretsService _secretsService; + public MailService(MailOptions emailConfiguration, ISecretsService secretsService) + { + _emailConfiguration = emailConfiguration; + _secretsService = secretsService; + } + private async Task SendEmailAsync(ApplicationUser user, string subject, string textMessage) + { + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(_emailConfiguration.SenderName, _emailConfiguration.Sender)); + message.To.Add(new MailboxAddress(user.DisplayName, user.Email)); + message.Subject = subject; + message.Body = new TextPart(TextFormat.Html) + { + Text = textMessage + }; + + using (var emailClient = new SmtpClient()) + { + await emailClient.ConnectAsync(_emailConfiguration.Host, _emailConfiguration.Port, _emailConfiguration.EnableSsl); + + emailClient.AuthenticationMechanisms.Remove("XOAUTH2"); + + await emailClient.AuthenticateAsync(_emailConfiguration.Sender, await _secretsService.Get("EmailConfiguration:SenderEmailPassword")); + + await emailClient.SendAsync(message); + + await emailClient.DisconnectAsync(true); + } + + } + public Task SendResetPassword(ApplicationUser user, string token, string returnUrl) + { + var email = HttpUtility.UrlEncode(user.Email); + return SendEmailAsync(user, "Reset your Password", $"Click here to reset your password Reset"); + } + public Task SendVerificationEmail(ApplicationUser user, string token, string returnUrl) + { + var email = HttpUtility.UrlEncode(user.Email); + return SendEmailAsync(user, "Verify your Email", $"Click here to verify your email Verify"); + } + } +} diff --git a/src/WebApp/.gitignore b/src/WebApp/.gitignore index a07fde2..b3f1d04 100644 --- a/src/WebApp/.gitignore +++ b/src/WebApp/.gitignore @@ -8,3 +8,5 @@ # can be found in '/docs/defaults'. /appsettings.json /Properties/launchSettings.json + +/smtp.log diff --git a/src/WebApp/Pages/Account/Email-Verification.cshtml b/src/WebApp/Pages/Account/Email-Verification.cshtml new file mode 100644 index 0000000..7512d39 --- /dev/null +++ b/src/WebApp/Pages/Account/Email-Verification.cshtml @@ -0,0 +1,19 @@ +@page +@model EmailVerificationModel + +
+ + + +
+ + @if (Model.Verified == false) { +

Your email could not be verified, please try again.

+ } + else + { +

Your email is verified.

+ } + +
+
diff --git a/src/WebApp/Pages/Account/Email-Verification.cshtml.cs b/src/WebApp/Pages/Account/Email-Verification.cshtml.cs new file mode 100644 index 0000000..509ce51 --- /dev/null +++ b/src/WebApp/Pages/Account/Email-Verification.cshtml.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using System.Web; + +using Codidact.Authentication.Domain.Entities; +using Codidact.Authentication.Application.Common.Interfaces; + + +namespace Codidact.Authentication.WebApp.Pages.Account +{ + [BindProperties] + [ValidateAntiForgeryToken] + public class EmailVerificationModel : PageModel + { + private readonly UserManager _userManager; + private readonly IMailService _emailService; + public EmailVerificationModel( + UserManager userManager, + IMailService emailService + ) + { + _userManager = userManager; + _emailService = emailService; + + } + [Required] + [FromQuery(Name = "returnurl")] + public string ReturnUrl { get; set; } = "/index"; + [FromQuery(Name = "token")] + [Required(ErrorMessage = "Invalid email verification request")] + public string Token { get; set; } + + [Required(ErrorMessage = "Invalid email verification request")] + [FromQuery(Name = "email")] + public string Email { get; set; } + public bool Verified { get; set; } + public async Task OnGet(string token, string returnUrl, string email) + { + if (!ModelState.IsValid) + { + return LocalRedirect("/Index"); + } + Token = token; + ReturnUrl = returnUrl; + Email = email; + return await ConfirmEmailAsync(); + } + public async Task OnPostEmailVerifyAsync() + { + var user = await _userManager.GetUserAsync(User); + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + + await _emailService.SendVerificationEmail(user, token, "/index"); + return Content("Email-Sent"); + } + public async Task ConfirmEmailAsync() + { + if (ModelState.IsValid) + { + var user = await _userManager.FindByEmailAsync(Email); + if (user == null) + { + ModelState.AddModelError("Email", "Email not found"); + } + else + { + if (!user.EmailConfirmed) + { + var result = await _userManager.ConfirmEmailAsync(user, Token); + if (!result.Succeeded) + { + foreach (var error in result.Errors) + { + ModelState.AddModelError(error.Code, error.Description); + } + Verified = result.Succeeded; + } + else + { + Verified = result.Succeeded; + } + } + else + { + return LocalRedirect("/Index"); + } + } + } + return Page(); + } + } +} + diff --git a/src/WebApp/Pages/Account/Forgot-Password.cshtml b/src/WebApp/Pages/Account/Forgot-Password.cshtml new file mode 100644 index 0000000..12e26b3 --- /dev/null +++ b/src/WebApp/Pages/Account/Forgot-Password.cshtml @@ -0,0 +1,31 @@ +@page +@model ForgotPasswordModel + +
+
+

Forgot Password

+

Sometimes we forget our password but thats okay, we can help you out. Don't have an account? Sign up instead.

+
+ +
+ @if (Model.Sent == false) + { +
+
+ + + +
+
+ + } + else + { +
+

Email has been sent to the provided email.

+
+ } +
+ diff --git a/src/WebApp/Pages/Account/Forgot-Password.cshtml.cs b/src/WebApp/Pages/Account/Forgot-Password.cshtml.cs new file mode 100644 index 0000000..8de00c4 --- /dev/null +++ b/src/WebApp/Pages/Account/Forgot-Password.cshtml.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Codidact.Authentication.Domain.Entities; +using Codidact.Authentication.Application.Common.Interfaces; + +namespace Codidact.Authentication.WebApp.Pages.Account +{ + [BindProperties] + public class ForgotPasswordModel : PageModel + { + private readonly UserManager _userManager; + private readonly IMailService _emailService; + public ForgotPasswordModel( + UserManager userManager, + IMailService emailService) + { + _userManager = userManager; + _emailService = emailService; + } + [Required] + public string ReturnUrl { get; set; } = "/index"; + public void OnGet([FromQuery] string returnUrl) + { + if (returnUrl != null) + { + ReturnUrl = returnUrl; + } + } + + + [Required(ErrorMessage = "E-Mail Address is required")] + [DataType(DataType.EmailAddress)] + public string Email { get; set; } + + public bool Sent { get; set; } + + + public async Task OnPostAsync() + { + if (ModelState.IsValid) + { + var user = await _userManager.FindByEmailAsync(Email); + if (user != null) + { + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + await _emailService.SendResetPassword(user, token, ReturnUrl); + Sent = true; + } + else + { + ModelState.AddModelError("Email", "No user found with this email"); + } + } + + return Page(); + } + } +} diff --git a/src/WebApp/Pages/Account/Login.cshtml b/src/WebApp/Pages/Account/Login.cshtml index 268a05b..a3f7ba8 100644 --- a/src/WebApp/Pages/Account/Login.cshtml +++ b/src/WebApp/Pages/Account/Login.cshtml @@ -5,6 +5,7 @@

Sign in

Welcome to Codidact! You can login to your account here. Don't have an account? Sign up instead.

+

Forgot your password? Click here to recover it.

diff --git a/src/WebApp/Pages/Account/Register.cshtml.cs b/src/WebApp/Pages/Account/Register.cshtml.cs index 50f1f11..a16b389 100644 --- a/src/WebApp/Pages/Account/Register.cshtml.cs +++ b/src/WebApp/Pages/Account/Register.cshtml.cs @@ -1,21 +1,33 @@ using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; -using Codidact.Authentication.Domain.Entities; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using IdentityServer4.Events; +using IdentityServer4.Services; + +using Codidact.Authentication.Domain.Entities; +using Codidact.Authentication.Application.Common.Interfaces; + namespace Codidact.Authentication.WebApp.Pages.Account { [BindProperties] public class RegisterModel : PageModel { private readonly UserManager _userManager; - - public RegisterModel( - UserManager userManager) + private readonly IMailService _emailService; + private readonly SignInManager _signInManager; + private readonly IEventService _events; + public RegisterModel(SignInManager signInManager, + UserManager userManager, + IMailService emailService, + IEventService events) { _userManager = userManager; + _emailService = emailService; + _signInManager = signInManager; + _events = events; } [Required(ErrorMessage = "E-Mail Address is required")] @@ -37,6 +49,7 @@ public RegisterModel( [Required] public string ReturnUrl { get; set; } = "/index"; + public bool VerificationSent { get; set; } public void OnGet([FromQuery] string returnUrl) { if (returnUrl != null) @@ -53,13 +66,21 @@ public async Task OnPostAsync() } if (ModelState.IsValid) { - var result = await _userManager.CreateAsync(new ApplicationUser + var user = new ApplicationUser { Email = Email, - UserName = DisplayName, - }, Password); + UserName = Email, + DisplayName = DisplayName, + }; + var result = await _userManager.CreateAsync(user, Password); + if (result.Succeeded) { + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + await _signInManager.PasswordSignInAsync(Email, Password, false, false); + await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id.ToString(), user.UserName)); + await _emailService.SendVerificationEmail(user, token, ReturnUrl); + VerificationSent = true; return LocalRedirect(ReturnUrl); } else diff --git a/src/WebApp/Pages/Account/Reset-Password.cshtml b/src/WebApp/Pages/Account/Reset-Password.cshtml new file mode 100644 index 0000000..8af7198 --- /dev/null +++ b/src/WebApp/Pages/Account/Reset-Password.cshtml @@ -0,0 +1,34 @@ +@page +@model ResetPasswordModel + +
+
+

Reset your password

+
+ + + + + + +
+
+
+
+ + +

Choose a strong one. At least 8 characters are recommended. Don't choose common words or names.

+
+ +
+ + +

We want to make sure, that you don't accidentally misspell your password.

+
+
+ +
+
+ diff --git a/src/WebApp/Pages/Account/Reset-Password.cshtml.cs b/src/WebApp/Pages/Account/Reset-Password.cshtml.cs new file mode 100644 index 0000000..6e3e012 --- /dev/null +++ b/src/WebApp/Pages/Account/Reset-Password.cshtml.cs @@ -0,0 +1,79 @@ +using System.Web; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Codidact.Authentication.Domain.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Codidact.Authentication.WebApp.Pages.Account +{ + + [BindProperties] + public class ResetPasswordModel : PageModel + { + private readonly UserManager _userManager; + + public ResetPasswordModel( + UserManager userManager + ) + { + _userManager = userManager; + } + + [Required(ErrorMessage = "Password is required")] + [DataType(DataType.Password)] + public string Password { get; set; } + [Required(ErrorMessage = "Password Confirmaton is required")] + [DataType(DataType.Password)] + public string ConfirmPassword { get; set; } + [Required] + public string ReturnUrl { get; set; } = "/index"; + [Required(ErrorMessage = "Invalid request, No token provided")] + public string Token { get; set; } + [Required(ErrorMessage = "Invalid request, No email provided")] + public string Email { get; set; } + public IActionResult OnGet([FromQuery] string token, [FromQuery] string returnUrl, [FromQuery] string email) + { + if (!ModelState.IsValid) + { + return RedirectToPage("Forgot-Password"); + } + Token = token; + ReturnUrl = returnUrl; + Email = email; + return Page(); + } + public async Task OnPostAsync() + { + if (!Password.Equals(ConfirmPassword, System.StringComparison.InvariantCulture)) + { + ModelState.AddModelError("ConfirmPassword", "Password and Password Confirmation must match"); + } + if (ModelState.IsValid) + { + var user = await _userManager.FindByEmailAsync(Email); + if (user == null) + { + ModelState.AddModelError("Email", "Email not found"); + } + else + { + var result = await _userManager.ResetPasswordAsync(user, Token, Password); + if (!result.Succeeded) + { + foreach (var error in result.Errors) + { + ModelState.AddModelError(error.Code, error.Description); + } + } + else + { + return RedirectToPage("Login"); + } + } + } + return Page(); + } + } +} diff --git a/src/WebApp/Pages/Index.cshtml b/src/WebApp/Pages/Index.cshtml index 0ee0b3a..fb1b939 100644 --- a/src/WebApp/Pages/Index.cshtml +++ b/src/WebApp/Pages/Index.cshtml @@ -1,10 +1,20 @@ @page +@model IndexModel +@Html.AntiForgeryToken()

This is The Index! I am Cephalon Sark, your unbiased host!

@if (User?.Identity.IsAuthenticated ?? false) {

You are logged in as @User.Identity.Name, would you like to logout?

+ + @if (Model.EmailVerified == false) + { +

Verification email sent! If you haven't got it, click the button below

+ + } else { +

Email is verified

+ } } else { diff --git a/src/WebApp/Pages/Index.cshtml.cs b/src/WebApp/Pages/Index.cshtml.cs new file mode 100644 index 0000000..3eda039 --- /dev/null +++ b/src/WebApp/Pages/Index.cshtml.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +using Codidact.Authentication.Domain.Entities; +using Codidact.Authentication.Application.Common.Interfaces; + +namespace Codidact.Authentication.WebApp.Pages +{ + [BindProperties] + public class IndexModel : PageModel + { + private readonly UserManager _userManager; + public IndexModel( + UserManager userManager, + IMailService emailService + ) + { + _userManager = userManager; + } + public bool EmailVerified { get; set; } = false; + public async void OnGet() + { + if (User?.Identity.IsAuthenticated ?? true) + { + var user = await _userManager.GetUserAsync(User); + EmailVerified = user.EmailConfirmed; + } + } + } +} diff --git a/src/WebApp/Program.cs b/src/WebApp/Program.cs index c30114b..9319a15 100644 --- a/src/WebApp/Program.cs +++ b/src/WebApp/Program.cs @@ -14,12 +14,12 @@ public static void Main(string[] args) public static IWebHostBuilder CreateWebHostBuilder(string[] args) { return WebHost.CreateDefaultBuilder(args) - .ConfigureLogging(builder => - { - builder.ClearProviders(); - builder.AddConsole(); - }) - .UseStartup(); + .ConfigureLogging(builder => + { + builder.ClearProviders(); + builder.AddConsole(); + }) + .UseStartup(); } } } diff --git a/src/WebApp/Startup.cs b/src/WebApp/Startup.cs index 33850d7..97abcd3 100644 --- a/src/WebApp/Startup.cs +++ b/src/WebApp/Startup.cs @@ -11,6 +11,7 @@ using Codidact.Authentication.Application; using Codidact.Authentication.Infrastructure; using Codidact.Authentication.Infrastructure.Persistance; +using Codidact.Authentication.Application.Options; namespace Codidact.Authentication.WebApp { @@ -40,6 +41,8 @@ public void ConfigureServices(IServiceCollection services) options.LowercaseUrls = true; }); + services.Configure(_configuration.GetSection("Mail")); + services.AddRazorPages() .AddRazorRuntimeCompilation(); diff --git a/src/WebApp/wwwroot/js/.eslintrc b/src/WebApp/wwwroot/js/.eslintrc new file mode 100644 index 0000000..5c00a9d --- /dev/null +++ b/src/WebApp/wwwroot/js/.eslintrc @@ -0,0 +1,23 @@ +{ + "extends": ["eslint:recommended", "plugin:prettier/recommended"], + "plugins": ["prettier"], + "rules": { + "prettier/prettier": "error", + "max-len": [ + "error", + { + "code": 120, + "ignoreComments": true + } + ], + "no-undef": ["off"] + }, + "parserOptions": { + "ecmaVersion": 10, + "sourceType": "module" + }, + "env": { + "browser": true, + "node": true + } +} diff --git a/src/WebApp/wwwroot/js/.prettierrc b/src/WebApp/wwwroot/js/.prettierrc new file mode 100644 index 0000000..55ea2b2 --- /dev/null +++ b/src/WebApp/wwwroot/js/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "useTabs": false +} diff --git a/src/WebApp/wwwroot/js/site.js b/src/WebApp/wwwroot/js/site.js index 7913021..9bebc90 100644 --- a/src/WebApp/wwwroot/js/site.js +++ b/src/WebApp/wwwroot/js/site.js @@ -1,14 +1,19 @@ -/* +const $ = (selector) => document.querySelector(selector); +/* * Header slides ---------------- * This code powers the "header slides", which are at the core of mobile nav -*/ + */ -const headerSlideTriggers = document.querySelectorAll("[data-trigger-header-slide]"); +const headerSlideTriggers = document.querySelectorAll( + "[data-trigger-header-slide]" +); for (let i = 0; i < headerSlideTriggers.length; i++) { headerSlideTriggers[i].addEventListener("click", function (e) { - const headerSlide = document.querySelector(this.getAttribute("data-trigger-header-slide")); + const headerSlide = document.querySelector( + this.getAttribute("data-trigger-header-slide") + ); headerSlide.classList.toggle("is-active"); this.classList.toggle("is-active"); @@ -16,10 +21,42 @@ for (let i = 0; i < headerSlideTriggers.length; i++) { // Position header slide appropriately relative to // trigger. const rect = this.getBoundingClientRect(); - hs.style.top = (rect.top + rect.height) + "px"; - hs.style.right = (document.body.clientWidth - rect.right) + "px"; + headerSlide.style.top = rect.top + rect.height + "px"; + headerSlide.style.right = document.body.clientWidth - rect.right + "px"; // Prevent navigation e.preventDefault(); }); } +/* + * Email verification + --------------------- + * This code sends a POST request that send an email to the user + * for verifying their email + */ +$(".js-email-verify").addEventListener("click", () => { + $(".js-email-verify").setAttribute("disabled", "true"); + // temproray, will remove when co-design implements it + $(".js-email-verify").style.opacity = 0.7; + fetch("/account/email-verification?handler=emailVerify", { + method: "POST", + headers: new Headers({ + RequestVerificationToken: $( + 'input[name="__RequestVerificationToken"]' + ).value, + }), + }) + .then((obj) => obj.text()) + .then((response) => { + $(".js-email-verify").removeAttribute("disabled"); + $(".js-email-verify").style.opacity = 1; + if (response === "Email-Sent") { + // TODO: Add a loading bar here. + $(".js-email-verification-status").innerText = + "Sent! Check your inbox"; + } else { + $(".js-email-verification-status").innerText = + "There has been a problem, please try again"; + } + }); +});