From a2d84e182d51902af05f5636ced7bf8ddc284bd3 Mon Sep 17 00:00:00 2001 From: Robert Marshall Date: Fri, 3 Jan 2020 13:32:20 +0000 Subject: [PATCH] Add authentication --- Website.Tests/Models/UserTests.cs | 17 ++++++++ Website/Controllers/AccountController.cs | 54 ++++++++++++++++++++++++ Website/Controllers/BlogController.cs | 6 +++ Website/Data/States/UserState.cs | 10 +++++ Website/Data/UserRepository.cs | 25 +++++++++++ Website/Models/LoginRequest.cs | 7 +++ Website/Models/User.cs | 29 +++++++++++++ Website/Startup.cs | 45 +++++++++++--------- Website/ViewModels/LoginViewModel.cs | 6 +++ Website/Views/Account/Index.cshtml | 8 ++++ Website/Views/Account/Login.cshtml | 20 +++++++++ 11 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 Website.Tests/Models/UserTests.cs create mode 100644 Website/Controllers/AccountController.cs create mode 100644 Website/Data/States/UserState.cs create mode 100644 Website/Data/UserRepository.cs create mode 100644 Website/Models/LoginRequest.cs create mode 100644 Website/Models/User.cs create mode 100644 Website/ViewModels/LoginViewModel.cs create mode 100644 Website/Views/Account/Index.cshtml create mode 100644 Website/Views/Account/Login.cshtml diff --git a/Website.Tests/Models/UserTests.cs b/Website.Tests/Models/UserTests.cs new file mode 100644 index 0000000..6a3a76d --- /dev/null +++ b/Website.Tests/Models/UserTests.cs @@ -0,0 +1,17 @@ +using FluentAssertions; +using Website.Models; +using Xunit; + +namespace Website.Tests.Models { + public class UserTests { + [Fact] + public void ValidatePassword_WithValidSHA256Input_ReturnsTrue() { + var user = new User { + Password= "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + }; + + + user.ValidatePassword("password").Should().BeTrue(); + } + } +} \ No newline at end of file diff --git a/Website/Controllers/AccountController.cs b/Website/Controllers/AccountController.cs new file mode 100644 index 0000000..f26d440 --- /dev/null +++ b/Website/Controllers/AccountController.cs @@ -0,0 +1,54 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Website.Data; +using Website.Models; +using Website.ViewModels; + +namespace Website.Controllers { + public class AccountController:Controller { + private readonly UserRepository _repo; + + public AccountController(UserRepository repo) => _repo = repo; + + public IActionResult Index() => View(); + + [HttpGet] + public IActionResult Login(string returnUrl, bool failedAttempt = false) { + var model = new LoginViewModel { + ReturnUrl = returnUrl, + FailedAttempt = failedAttempt + }; + return View(model); + } + + [HttpPost] + public async Task Login(LoginRequest request) { + try { + var user = await _repo.GetUserByEmail(request.Username); + return user.ValidatePassword(request.Password) + ? await SetIdentityAndRedirect(request.ReturnUrl, user) + : Login(request.ReturnUrl, true); + } + catch (Exception e) { + return Login(request.ReturnUrl, true); + } + } + + private async Task SetIdentityAndRedirect(string returnUrl, User user) { + var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(ClaimTypes.Name, user.Username)); + identity.AddClaim(new Claim(ClaimTypes.Email, user.Username)); + + var principal = new ClaimsPrincipal(identity); + await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); + + return string.IsNullOrEmpty(returnUrl) + ? (IActionResult) RedirectToAction(nameof(Index)) + : Redirect(returnUrl); + } + } +} \ No newline at end of file diff --git a/Website/Controllers/BlogController.cs b/Website/Controllers/BlogController.cs index 8c086e2..8860cf3 100644 --- a/Website/Controllers/BlogController.cs +++ b/Website/Controllers/BlogController.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Website.Data; using Website.Models; @@ -38,6 +39,7 @@ namespace Website.Controllers } } + [Authorize] public async Task Edit(int? id) { if (!id.HasValue) return View(); @@ -56,6 +58,7 @@ namespace Website.Controllers } } + [Authorize] [HttpPost] public async Task Save(BlogPostSubmission submission) { var post = submission.Id.HasValue ? await _repo.GetPostByIdAsync(submission.Id.Value) : new BlogPost(); @@ -68,12 +71,14 @@ namespace Website.Controllers return RedirectToAction(nameof(Edit), new{savedPost.Id}); } + [Authorize] public async Task Manage() { var posts = await _repo.GetAllPostsAsync(); var models = posts.OrderByDescending(post => post.Timestamp).Select(post => new BlogPostViewModel(post)); return View(models); } + [Authorize] public async Task Publish(int id) { var post = await _repo.GetPostByIdAsync(id); post.Publish(); @@ -82,6 +87,7 @@ namespace Website.Controllers return RedirectToAction(nameof(Manage)); } + [Authorize] public async Task Delete(int id) { await _repo.DeletePostAsync(id); diff --git a/Website/Data/States/UserState.cs b/Website/Data/States/UserState.cs new file mode 100644 index 0000000..1b699dd --- /dev/null +++ b/Website/Data/States/UserState.cs @@ -0,0 +1,10 @@ +namespace Website.Data.States { + public class UserState { + public string User_Id { get; set; } + public string User_Email { get; set; } + public string User_Password { get; set; } + public string User_Created { get; set; } + public string User_Deleted { get; set; } + public string Group_Id { get; set; } + } +} \ No newline at end of file diff --git a/Website/Data/UserRepository.cs b/Website/Data/UserRepository.cs new file mode 100644 index 0000000..6e91b56 --- /dev/null +++ b/Website/Data/UserRepository.cs @@ -0,0 +1,25 @@ +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Website.Data.States; +using Website.Models; + +namespace Website.Data { + public class UserRepository { + private readonly IDatabaseProvider _dbProvider; + + public UserRepository(IDatabaseProvider dbProvider) { + _dbProvider = dbProvider; + } + + public async Task GetUserByEmail(string email) { + const string query = "SELECT * FROM users WHERE user_email=@email"; + + using (var connection = _dbProvider.NewConnection()) { + connection.Open(); + var result = await connection.QueryAsync(query, new {email}); + return new User(result.Single()); + } + } + } +} \ No newline at end of file diff --git a/Website/Models/LoginRequest.cs b/Website/Models/LoginRequest.cs new file mode 100644 index 0000000..9e28748 --- /dev/null +++ b/Website/Models/LoginRequest.cs @@ -0,0 +1,7 @@ +namespace Website.Models { + public class LoginRequest { + public string Username { get; set; } + public string Password { get; set; } + public string ReturnUrl { get; set; } + } +} \ No newline at end of file diff --git a/Website/Models/User.cs b/Website/Models/User.cs new file mode 100644 index 0000000..2254d38 --- /dev/null +++ b/Website/Models/User.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; +using System.Text; +using Website.Data.States; + +namespace Website.Models { + public class User { + public User(UserState state) { + Username = state.User_Email; + Password = state.User_Password; + } + + public bool ValidatePassword(string password) { + using (var sha256 = SHA256.Create()) { + var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(password)); + + var builder = new StringBuilder(); + foreach (var b in hash) + builder.Append(b.ToString("x2")); + var hashString = builder.ToString(); + + return hashString == Password; + } + } + + + public string Username { get; set; } + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/Website/Startup.cs b/Website/Startup.cs index e129e69..ce3c750 100644 --- a/Website/Startup.cs +++ b/Website/Startup.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -27,15 +28,18 @@ namespace Website options.MinimumSameSitePolicy = SameSiteMode.None; }); - services.AddSingleton(Configuration); + services.AddSingleton(Configuration); RegisterRepositories(services); + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); + services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2); } private void RegisterRepositories(IServiceCollection services) => services.AddSingleton() - .AddSingleton(); + .AddSingleton() + .AddSingleton(); // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IHostingEnvironment env) @@ -51,24 +55,23 @@ namespace Website app.UseHsts(); } - app.UseStatusCodePagesWithReExecute("/Error/PageNotFound"); - app.UseHttpsRedirection(); - app.UseStaticFiles(); - - app.UseMvc(routes => - { - routes.MapRoute( - name: "blogPages", - template: "blog/{action}/{page:int}", - defaults: new { controller = "Blog", action = "Page", page = 1 }); - routes.MapRoute( - name: "blogView", - template: "blog/view/{url}", - defaults: new { controller = "Blog", action = "Entry"}); - routes.MapRoute( - name: "default", - template: "{controller=Home}/{action=Index}/{id?}"); - }); + app.UseStatusCodePagesWithReExecute("/Error/PageNotFound") + .UseHttpsRedirection() + .UseStaticFiles() + .UseAuthentication() + .UseMvc(routes => { + routes.MapRoute( + name: "blogPages", + template: "blog/{action}/{page:int}", + defaults: new {controller = "Blog", action = "Page", page = 1}); + routes.MapRoute( + name: "blogView", + template: "blog/view/{url}", + defaults: new {controller = "Blog", action = "Entry"}); + routes.MapRoute( + name: "default", + template: "{controller=Home}/{action=Index}/{id?}"); + }); } } } diff --git a/Website/ViewModels/LoginViewModel.cs b/Website/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..4bf6edd --- /dev/null +++ b/Website/ViewModels/LoginViewModel.cs @@ -0,0 +1,6 @@ +namespace Website.ViewModels { + public class LoginViewModel { + public string ReturnUrl { get; set; } + public bool FailedAttempt { get; set; } + } +} \ No newline at end of file diff --git a/Website/Views/Account/Index.cshtml b/Website/Views/Account/Index.cshtml new file mode 100644 index 0000000..6b1f86e --- /dev/null +++ b/Website/Views/Account/Index.cshtml @@ -0,0 +1,8 @@ +@model object + +@{ + ViewBag.Title = "title"; + Layout = "_Layout"; +} + +

title

diff --git a/Website/Views/Account/Login.cshtml b/Website/Views/Account/Login.cshtml new file mode 100644 index 0000000..533be7f --- /dev/null +++ b/Website/Views/Account/Login.cshtml @@ -0,0 +1,20 @@ +@model LoginViewModel + +@{ + ViewBag.Title = "Login"; +} + +@if (!string.IsNullOrEmpty(Model.ReturnUrl)) { +
Please log in to perform that action
+} + +@if (Model.FailedAttempt) { +
Could not log in with those credentials
+} + +
+ + + + +