diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs index b026a14..af77642 100644 --- a/Controllers/AuthController.cs +++ b/Controllers/AuthController.cs @@ -1,18 +1,14 @@ using System.Diagnostics; using Microsoft.AspNetCore.Mvc; +using SecureDesignProject.Extensions; using SecureDesignProject.Models; +using SecureDesignProject.Services; + namespace SecureDesignProject.Controllers; -public class AuthController : Controller +public class AuthController(ILogger logger, AuthService authService) : Controller { - private readonly ILogger _logger; - - public AuthController(ILogger logger) - { - _logger = logger; - } - public IActionResult Login() { return View(); @@ -23,6 +19,40 @@ public IActionResult Register() return View(); } + [HttpPost] + public IActionResult Login([FromForm] LoginDetails loginDetails) + { + var loginResult = authService.AttemptLogin(loginDetails); + + if (!loginResult.success) + { + TempData["errorMsg"] = "Invalid email or password"; + + return View(); + } + + Response.SetSessionKeyCookie(loginResult.sessionKey); + + return RedirectToAction("Index", "Home"); + } + + [HttpPost] + public IActionResult Register([FromForm] RegisterDetails registerDetails) + { + var createAccountResult = authService.AttemptCreateAccount(registerDetails); + + if (!createAccountResult.success) + { + TempData["errorMsg"] = "Could not create account with that email."; + + return View(); + } + + Response.SetSessionKeyCookie(createAccountResult.SessionKey); + + return RedirectToAction("Index", "Home"); + } + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public IActionResult Error() { diff --git a/Extensions/HttpExtensions.cs b/Extensions/HttpExtensions.cs new file mode 100644 index 0000000..20cd470 --- /dev/null +++ b/Extensions/HttpExtensions.cs @@ -0,0 +1,15 @@ +namespace SecureDesignProject.Extensions; + +public static class HttpExtensions +{ + public static void SetSessionKeyCookie(this HttpResponse response, byte[] sessionKey) + { + var options = new CookieOptions + { + Expires = DateTime.Now.AddMinutes(60), + HttpOnly = true + }; + response.Cookies.Append("session_key", Convert.ToHexString(sessionKey), options); + + } +} \ No newline at end of file diff --git a/Extensions/StringExtensions.cs b/Extensions/StringExtensions.cs new file mode 100644 index 0000000..9bba24b --- /dev/null +++ b/Extensions/StringExtensions.cs @@ -0,0 +1,6 @@ +namespace SecureDesignProject.Extensions; + +public static class StringExtensions +{ + public static bool IsNullOrWhiteSpace(this string? value) => string.IsNullOrWhiteSpace(value); +} \ No newline at end of file diff --git a/Middleware/AuthMiddleware.cs b/Middleware/AuthMiddleware.cs new file mode 100644 index 0000000..b8d9ceb --- /dev/null +++ b/Middleware/AuthMiddleware.cs @@ -0,0 +1,45 @@ +using SecureDesignProject.Extensions; +using SecureDesignProject.Services; + +namespace SecureDesignProject.Middleware; + + +public class AuthMiddleware(RequestDelegate next, AuthService authService) +{ + public async Task InvokeAsync(HttpContext context) + { + // skip check and call next middleware if /auth path + if (context.Request.Path.StartsWithSegments("/auth")) + { + await next(context); + + return; + } + + // redirect to login page if invalid token + var sessionKey = Convert.FromHexString(context.Request.Cookies["session_key"] ?? ""); + if (sessionKey.Length == 0) + { + context.Response.Redirect("/auth/login"); + return; + } + + if (!authService.IsValidSession(sessionKey!)) + { + context.Response.Redirect("/auth/login"); + return; + } + + // Call the next delegate/middleware in the pipeline. + await next(context); + } +} + +public static class AuthMiddlewareExtensions +{ + public static IApplicationBuilder UseAuthMiddleware( + this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} \ No newline at end of file diff --git a/Migrations/1_create_db.sql b/Migrations/1_create_db.sql index e49c95e..d66078e 100644 --- a/Migrations/1_create_db.sql +++ b/Migrations/1_create_db.sql @@ -8,7 +8,7 @@ GO CREATE TABLE Accounts( AccountId UNIQUEIDENTIFIER NOT NULL, Email NVARCHAR(MAX) NOT NULL UNIQUE, - HashedPassword VARCHAR(MAX) NOT NULL, + HashedPassword BINARY(256) NOT NULL, PRIMARY KEY (AccountId) ); @@ -81,7 +81,7 @@ CREATE TABLE Sessions( SessionId UNIQUEIDENTIFIER NOT NULL, AccountId UNIQUEIDENTIFIER NOT NULL, - Token VARCHAR(MAX) NOT NULL, + SessionKey BINARY(256) NOT NULL, IsValid BIT NOT NULL, Created DATETIME NOT NULL, diff --git a/Migrations/2_add_seperate_salt_col.sql b/Migrations/2_add_seperate_salt_col.sql new file mode 100644 index 0000000..7f4cb31 --- /dev/null +++ b/Migrations/2_add_seperate_salt_col.sql @@ -0,0 +1,2 @@ +ALTER TABLE Accounts +ADD Salt BINARY(128) NOT NULL DEFAULT 123; \ No newline at end of file diff --git a/Models/Auth.cs b/Models/Auth.cs deleted file mode 100644 index 606aec0..0000000 --- a/Models/Auth.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace SecureDesignProject.Models; - -public record LoginDetails -{ - public string Email { get; init; } - public string Password { get; init; } -}; - -public record Session -{ - public Guid SessionId { get; init; } - public Guid AccountId { get; init; } - - public bool IsValid { get; init; } - public string Token { get; init; } - - public DateTime Created { get; init; } - public DateTime LastSeen { get; init; } - public DateTime Expires { get; init; } -} \ No newline at end of file diff --git a/Models/AuthModels.cs b/Models/AuthModels.cs new file mode 100644 index 0000000..bb19082 --- /dev/null +++ b/Models/AuthModels.cs @@ -0,0 +1,68 @@ +using System.ComponentModel.DataAnnotations; + +namespace SecureDesignProject.Models; + +public record LoginDetails +{ + [Required] + [MaxLength(255)] + public string Email { get; init; } = ""; + + [Required] + [MaxLength(255)] + public string Password { get; init; } = ""; +}; + +public record RegisterDetails +{ + [Required] + [MaxLength(255)] + public string Email { get; init; } = ""; + + [Required] + [MaxLength(255)] + [MinLength(8)] + public string Password { get; init; } = ""; + + [Required] + [MaxLength(255)] + public string FirstName { get; init; } = ""; + + [Required] + [MaxLength(255)] + public string LastName { get; init; } = ""; + + [Required] + public int InviteCode { get; init; } +}; + +public record Account +{ + public Guid AccountId { get; init; } = Guid.NewGuid(); + public string Email { get; init; } + public byte[] HashedPassword { get; init; } + + public byte[] Salt { get; init; } +} + +public record Patient +{ + public Guid PatientId { get; init; } = Guid.NewGuid(); + public Guid AccountId { get; init; } + public string FirstName { get; init; } = ""; + public string LastName { get; init; } = ""; + public string? Address { get; init; } +} + +public record Session +{ + public Guid SessionId { get; init; } + public Guid AccountId { get; init; } + + public bool IsValid { get; init; } + public byte[] SessionKey { get; init; } + + public DateTime Created { get; init; } + public DateTime LastSeen { get; init; } + public DateTime Expires { get; init; } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs index 574ae94..a84bd4d 100644 --- a/Program.cs +++ b/Program.cs @@ -1,7 +1,12 @@ +using SecureDesignProject.Middleware; +using SecureDesignProject.Services; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllersWithViews(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var app = builder.Build(); @@ -18,7 +23,7 @@ app.UseRouting(); -app.UseAuthorization(); +app.UseAuthMiddleware(); app.MapControllerRoute( name: "default", diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json index 220be4b..b67d7d9 100644 --- a/Properties/launchSettings.json +++ b/Properties/launchSettings.json @@ -13,7 +13,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "applicationUrl": "https://localhost:7049;http://localhost:5133", + "applicationUrl": "https://localhost:7049", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/SecureDesignProject.csproj b/SecureDesignProject.csproj index 34547d2..22acc0c 100644 --- a/SecureDesignProject.csproj +++ b/SecureDesignProject.csproj @@ -8,7 +8,10 @@ - + + + + diff --git a/Services/AuthService.cs b/Services/AuthService.cs new file mode 100644 index 0000000..465a04c --- /dev/null +++ b/Services/AuthService.cs @@ -0,0 +1,102 @@ +using System.Security.Cryptography; +using System.Text; +using SecureDesignProject.Models; +using Konscious.Security.Cryptography; + +namespace SecureDesignProject.Services; + +public class AuthService(DatabaseService dbService) +{ + public (bool success, byte[] sessionKey) AttemptLogin(LoginDetails loginDetails) + { + var account = dbService.GetAccountByEmail(loginDetails.Email); + + if (account == null || VerifyHash(loginDetails.Password, account.HashedPassword, account.Salt)) + { + return (false, []); + } + + var newSession = dbService.InsertSession(new Session + { + SessionId = Guid.NewGuid(), + AccountId = account.AccountId, + + SessionKey = RandomNumberGenerator.GetBytes(255), + IsValid = true, + + Created = DateTime.Now, + LastSeen = DateTime.Now, + Expires = DateTime.Now.AddMinutes(10), + }); + + return (true, newSession.SessionKey); + } + + public bool IsValidSession(byte[] key) + { + var session = dbService.GetSessionByKey(key); + + return session != null; + } + + public (bool success, byte[] SessionKey) AttemptCreateAccount(RegisterDetails registerDetails) + { + var existingAccount = dbService.GetAccountByEmail(registerDetails.Email); + + if (existingAccount != null) + { + return (false, []); + } + + var salt = GenerateSalt(); + var account = dbService.InsertAccount(new Account + { + Email = registerDetails.Email, + HashedPassword = HashPassword(registerDetails.Password, salt), + }); + + + + var patient = dbService.InsertPatient(new Patient + { + FirstName = registerDetails.FirstName, + LastName = registerDetails.LastName, + AccountId = account.AccountId, + Address = null + }); + + + //login new user + var (success, sessionKey) = AttemptLogin(new LoginDetails + { + Email = registerDetails.Email, + Password = registerDetails.Password + }); + + return (success, sessionKey); + } + + private static byte[] HashPassword(string password, byte[] salt) + { + var argon2Id = new Argon2id(Encoding.UTF8.GetBytes(password)); + argon2Id.Salt = salt; + argon2Id.Iterations = 4; + argon2Id.MemorySize = 64000; + argon2Id.DegreeOfParallelism = 1; // num of threads + + var hash = argon2Id.GetBytes(64); // 64 byte hash + + return hash; + } + + private static byte[] GenerateSalt() + { + return RandomNumberGenerator.GetBytes(128); + } + + private static bool VerifyHash(string password, byte[] hash, byte[] salt) + { + var newHash = HashPassword(password, salt); + return hash.SequenceEqual(newHash); + } +} \ No newline at end of file diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs new file mode 100644 index 0000000..ca3165d --- /dev/null +++ b/Services/DatabaseService.cs @@ -0,0 +1,59 @@ +using System.Data.SqlClient; +using Dapper; +using SecureDesignProject.Models; + +namespace SecureDesignProject.Services; + +public class DatabaseService +{ + private string ConnectionString = "Server=localhost,1433;Database=HealthServiceDb;User Id=SA;Password=Super-Secret-Password;TrustServerCertificate=true;Encrypt=false;"; + + public Account? GetAccountByEmail(string email) + { + using var db = new SqlConnection(ConnectionString); + + return db.QuerySingleOrDefault("SELECT * FROM Accounts WHERE Email = @email", new { email }); + } + + public Session? GetSessionByKey(byte[] key) + { + using var db = new SqlConnection(ConnectionString); + + return db.QuerySingleOrDefault("SELECT * FROM Sessions WHERE SessionKey = @key AND IsValid=1", new { key }); + } + + public Account InsertAccount(Account account) + { + using var db = new SqlConnection(ConnectionString); + + db.Execute("insert into Accounts(AccountId, Email, HashedPassword) values (@AccountId, @Email, @HashedPassword)", account); + + return account; + } + + public Patient InsertPatient(Patient patient) + { + using var db = new SqlConnection(ConnectionString); + + db.Execute(""" + insert into Patients(PatientId, AccountId, FirstName, LastName, Address) + values (@PatientId, @AccountId, @FirstName, @LastName, @Address) + """, + patient); + + return patient; + } + + public Session InsertSession(Session session) + { + using var db = new SqlConnection(ConnectionString); + + db.Execute(""" + insert into Sessions(SessionId, AccountId, SessionKey, IsValid, Created, LastSeen, Expires) + values (@SessionId, @AccountId, @SessionKey, @IsValid, @Created, @LastSeen, @Expires) + """, + session); + + return session; + } +} \ No newline at end of file diff --git a/Views/Auth/Login.cshtml b/Views/Auth/Login.cshtml new file mode 100644 index 0000000..e7988a8 --- /dev/null +++ b/Views/Auth/Login.cshtml @@ -0,0 +1,56 @@ + +@model SecureDesignProject.Models.LoginDetails + + +

Login

+
+ +@using (Html.BeginForm("Login", "Auth", FormMethod.Post)) +{ +
+ +

Enter email and password:

+ + + @Html.ValidationSummary(true, "", new { @class = "text-danger" }) + + @Html.TextBoxFor(m => m.Email, new + { + id = "email-box", + type="text", + placeholder="Email", + @class = "form-control m-3 w-auto" + }) + @Html.ValidationMessageFor(model => model.Email, default, new + { + @class = "m-3 text-danger" + }) + + @Html.PasswordFor(m => m.Password,new + { + id = "email-box", + type="password", + placeholder="Password", + @class = "form-control m-3 w-auto" + }) + @Html.ValidationMessageFor(model => model.Password, default, new + { + @class = "m-3 text-danger" + }) + +
+ + + +} + + +@if (TempData["errorMsg"] != null) +{ + +} + +
+Don't have an account yet? + + diff --git a/Views/Auth/Register.cshtml b/Views/Auth/Register.cshtml new file mode 100644 index 0000000..b35a623 --- /dev/null +++ b/Views/Auth/Register.cshtml @@ -0,0 +1,92 @@ + +@model SecureDesignProject.Models.RegisterDetails + + +

Register

+
+ +@using (Html.BeginForm("Register", "Auth", FormMethod.Post)) +{ +
+ +

Enter your details:

+ + + @Html.ValidationSummary(true, "", new { @class = "text-danger" }) + + @Html.TextBoxFor(m => m.Email, new + { + id = "email-box", + type="text", + placeholder="Email", + @class = "form-control m-3 w-auto" + }) + @Html.ValidationMessageFor(model => model.Email, default, new + { + @class = "m-3 text-danger" + }) + + @Html.TextBoxFor(m => m.FirstName, new + { + id = "firstname-box", + type="text", + placeholder="First Name", + @class = "form-control m-3 w-auto" + }) + @Html.ValidationMessageFor(model => model.FirstName, default, new + { + @class = "m-3 text-danger" + }) + + @Html.TextBoxFor(m => m.LastName, new + { + id = "lastname-box", + type="text", + placeholder="Last Name", + @class = "form-control m-3 w-auto" + }) + @Html.ValidationMessageFor(model => model.LastName, default, new + { + @class = "m-3 text-danger" + }) + + @Html.PasswordFor(m => m.Password,new + { + id = "email-box", + type="password", + placeholder="Password", + @class = "form-control m-3 w-auto" + }) + @Html.ValidationMessageFor(model => model.Password, default, new + { + @class = "m-3 text-danger" + }) + + @Html.TextBoxFor(m => m.InviteCode, new + { + id = "code-box", + type="number", + placeholder="Code", + @class = "form-control m-3 w-auto" + }) + @Html.ValidationMessageFor(model => model.InviteCode, default, new + { + @class = "m-3 text-danger" + }) + +
+ + + +} + + +@if (TempData["errorMsg"] != null) +{ + +} + +
+Already have an account? + + diff --git a/Views/Dashbord/Home.cshtml b/Views/Dashbord/Home.cshtml deleted file mode 100644 index 2274eea..0000000 --- a/Views/Dashbord/Home.cshtml +++ /dev/null @@ -1,5 +0,0 @@ -@{ - ViewData["Title"] = "Dashboard"; -} - -

@ViewData["Title"]

diff --git a/Views/Dashbord/Patient.cshtml b/Views/Dashbord/Patient.cshtml new file mode 100644 index 0000000..ae1b276 --- /dev/null +++ b/Views/Dashbord/Patient.cshtml @@ -0,0 +1,6 @@ +@{ + ViewData["Title"] = "Dashoard"; +} + +

Patient Dashboard

+ diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml index 87ead48..07da760 100644 --- a/Views/Shared/_Layout.cshtml +++ b/Views/Shared/_Layout.cshtml @@ -12,7 +12,7 @@