From 52425c25b32c9a8b2dd8acebfd0e5c651bc24af7 Mon Sep 17 00:00:00 2001 From: Mouaz Abdelsamad <ma03081@surrey.ac.uk> Date: Sun, 3 Mar 2024 13:32:52 +0000 Subject: [PATCH] JWT Auth --- .gitignore | 1 + Controllers/UserController.cs | 129 ++++++++++++++++++ Controllers/UsersController.cs | 74 ---------- .../20240228234031_InitialCreate.Designer.cs | 52 ------- Migrations/20240228234031_InitialCreate.cs | 45 ------ .../ApplicationDbContextModelSnapshot.cs | 22 +++ Models/ApplicationDbContext .cs | 9 +- Models/AuthTokenPair.cs | 14 ++ Models/LoginModel.cs | 4 +- Models/RefreshToken.cs | 19 +++ Models/RegisterModel.cs | 6 +- Models/User.cs | 31 ++--- Program.cs | 27 ++-- Services/AuthService.cs | 67 +++++++-- Services/IAuthService.cs | 6 +- Services/IUserService.cs | 13 +- Services/UserService.cs | 35 +++-- 17 files changed, 303 insertions(+), 251 deletions(-) create mode 100644 Controllers/UserController.cs delete mode 100644 Controllers/UsersController.cs delete mode 100644 Migrations/20240228234031_InitialCreate.Designer.cs delete mode 100644 Migrations/20240228234031_InitialCreate.cs create mode 100644 Models/AuthTokenPair.cs create mode 100644 Models/RefreshToken.cs diff --git a/.gitignore b/.gitignore index 4c41913..36bc62a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin/ .vs/ obj/ +Migrations/ diff --git a/Controllers/UserController.cs b/Controllers/UserController.cs new file mode 100644 index 0000000..5db458a --- /dev/null +++ b/Controllers/UserController.cs @@ -0,0 +1,129 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using UserMicroservice.Models; +using UserMicroservice.Services; + +namespace UserMicroservice.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class UserController : ControllerBase + { + private readonly IUserService _userService; + private readonly IAuthService _authService; + + public UserController(IUserService userService, IAuthService authService) + { + _userService = userService; + _authService = authService; + } + + #region Auth Endpoints + + // POST: api/Users/register + [HttpPost("register")] + public IActionResult Register([FromBody] RegisterModel model) + { + User user = _userService.CreateUser(model.Email, model.Username, model.Password); + if(user == null) + return BadRequest(); + + return authenticateUser(user); + } + + // POST: api/Users/login + [HttpPost("login")] + public IActionResult Login([FromBody] LoginModel model) + { + User? user = _userService.GetUser(model.Username, model.Password); + if(user == null) + return Unauthorized(); + + return authenticateUser(user); + } + + private IActionResult authenticateUser(User user) + { + AuthTokenPair authToken = _authService.AuthenticateUser(user); + if (authToken == null) + return BadRequest(); + + // Set the access token as an HttpOnly cookie + Response.Cookies.Append("AccessToken", authToken.AccessToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddMinutes(30) + }); + + // Set the refresh token as an HttpOnly cookie + Response.Cookies.Append("RefreshToken", authToken.RefreshToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Expires = DateTimeOffset.UtcNow.AddDays(2) + }); + + return Ok(); + } + + // POST: api/Users/logout + [Authorize] + [HttpPost("logout")] + public IActionResult Logout() + { + string? refreshToken = Request.Cookies["RefreshToken"]; + if(string.IsNullOrEmpty(refreshToken)) + return BadRequest(); + + _authService.RevokeRefreshToken(refreshToken); + + // Clear the access token cookie and set it to expire immediately + Response.Cookies.Append("AccessToken", string.Empty, new CookieOptions + { + HttpOnly = true, + Secure = true, + Expires = DateTimeOffset.UtcNow.AddSeconds(-1) + }); + + // Clear the refresh token cookie and set it to expire immediately + Response.Cookies.Append("RefreshToken", string.Empty, new CookieOptions + { + HttpOnly = true, + Secure = true, + Expires = DateTimeOffset.UtcNow.AddSeconds(-1) + }); + + return Ok(); + } + + #endregion + + // GET: api/Users + [Authorize] + [HttpGet()] + public IActionResult GetUsers() + { + List<User> users = _userService.GetUsers(); + if(users == null) + return BadRequest(); + + return Ok(users); + } + + // GET: api/Users/{id} + [Authorize] + [HttpGet("{id}")] + public IActionResult GetUser(int id) + { + User? user = _userService.GetUser(id); + if(user == null) + return NotFound($"User with {id} doesnt exist"); + + return Ok(user); + } + + } +} diff --git a/Controllers/UsersController.cs b/Controllers/UsersController.cs deleted file mode 100644 index 1fe0e13..0000000 --- a/Controllers/UsersController.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using UserMicroservice.Models; -using UserMicroservice.Services; - -namespace UserMicroservice.Controllers -{ - [ApiController] - [Route("api/[controller]")] - public class UsersController : ControllerBase - { - // Dependency injection of a user service - private readonly IUserService _userService; - - public UsersController(IUserService userService) - { - _userService = userService; - } - - // POST: api/Users/register - [HttpPost("register")] - public IActionResult Register([FromBody] RegisterModel model) - { - string token = _userService.RegisterUser(model.Email, model.Password); - if(token == null) - return BadRequest(); - - return Ok(token); - //return Created("api/users/{id}", new { /* user data */ }); - } - - // POST: api/Users/login - [HttpPost("login")] - public IActionResult Login([FromBody] LoginModel model) - { - string? token = _userService.Login(model.Email, model.Password); - if (string.IsNullOrEmpty(token)) - return Unauthorized(); - - return Ok(token); - } - - // GET: api/Users - [Authorize] - [HttpGet()] - public IActionResult GetUsers() - { - List<User> users = new List<User>() - { - new User("User1", "Password1"), - new User("User2", "Password2"), - new User("User3", "Password3") - }; - - return Ok(users); - } - - // GET: api/Users/{id} - [Authorize] - [HttpGet("{id}")] - public IActionResult GetUser(int id) - { - return Ok(new User( /* user data */ )); - } - - // PUT: api/Users/{id} - [Authorize] - [HttpPut("{id}")] - public IActionResult UpdateUser(int id, [FromBody] User user) - { - return Ok(user); - } - } -} diff --git a/Migrations/20240228234031_InitialCreate.Designer.cs b/Migrations/20240228234031_InitialCreate.Designer.cs deleted file mode 100644 index 258c353..0000000 --- a/Migrations/20240228234031_InitialCreate.Designer.cs +++ /dev/null @@ -1,52 +0,0 @@ -// <auto-generated /> -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using UserMicroservice.Models; - -#nullable disable - -namespace UserMicroservice.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20240228234031_InitialCreate")] - partial class InitialCreate - { - /// <inheritdoc /> - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.2") - .HasAnnotation("Relational:MaxIdentifierLength", 64); - - modelBuilder.Entity("UserMicroservice.Models.User", b => - { - b.Property<int>("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - b.Property<string>("Email") - .IsRequired() - .HasColumnType("longtext"); - - b.Property<string>("PasswordHash") - .IsRequired() - .HasColumnType("longtext"); - - b.Property<int>("Type") - .HasColumnType("int"); - - b.Property<string>("Username") - .IsRequired() - .HasColumnType("longtext"); - - b.HasKey("Id"); - - b.ToTable("Users"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Migrations/20240228234031_InitialCreate.cs b/Migrations/20240228234031_InitialCreate.cs deleted file mode 100644 index f96d3fa..0000000 --- a/Migrations/20240228234031_InitialCreate.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace UserMicroservice.Migrations -{ - /// <inheritdoc /> - public partial class InitialCreate : Migration - { - /// <inheritdoc /> - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterDatabase() - .Annotation("MySql:CharSet", "utf8mb4"); - - migrationBuilder.CreateTable( - name: "Users", - columns: table => new - { - Id = table.Column<int>(type: "int", nullable: false) - .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), - Username = table.Column<string>(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - Email = table.Column<string>(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - PasswordHash = table.Column<string>(type: "longtext", nullable: false) - .Annotation("MySql:CharSet", "utf8mb4"), - Type = table.Column<int>(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - }) - .Annotation("MySql:CharSet", "utf8mb4"); - } - - /// <inheritdoc /> - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Users"); - } - } -} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index c51b135..833f523 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1,4 +1,5 @@ // <auto-generated /> +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -18,6 +19,27 @@ namespace UserMicroservice.Migrations .HasAnnotation("ProductVersion", "8.0.2") .HasAnnotation("Relational:MaxIdentifierLength", 64); + modelBuilder.Entity("UserMicroservice.Models.RefreshToken", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property<DateTime>("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property<string>("Token") + .IsRequired() + .HasColumnType("longtext"); + + b.Property<int>("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("RefreshTokens"); + }); + modelBuilder.Entity("UserMicroservice.Models.User", b => { b.Property<int>("Id") diff --git a/Models/ApplicationDbContext .cs b/Models/ApplicationDbContext .cs index 127fa1f..c22b1e8 100644 --- a/Models/ApplicationDbContext .cs +++ b/Models/ApplicationDbContext .cs @@ -13,12 +13,11 @@ namespace UserMicroservice.Models } public DbSet<User> Users { get; set; } - // Add other DbSet properties for other models - - //use the commands to update the db - //dotnet ef migrations add InitialCreate - //dotnet ef database update + public DbSet<RefreshToken> RefreshTokens { get; set; } + // use the commands to update the db + // dotnet ef migrations add AddNewModelTable + // dotnet ef database update protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/Models/AuthTokenPair.cs b/Models/AuthTokenPair.cs new file mode 100644 index 0000000..db3ea7b --- /dev/null +++ b/Models/AuthTokenPair.cs @@ -0,0 +1,14 @@ +namespace UserMicroservice.Models +{ + public class AuthTokenPair + { + public string AccessToken { get; } + public string RefreshToken { get; } + + public AuthTokenPair(string accessToken, string refreshToken) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + } + } +} diff --git a/Models/LoginModel.cs b/Models/LoginModel.cs index 669f778..961ae05 100644 --- a/Models/LoginModel.cs +++ b/Models/LoginModel.cs @@ -2,7 +2,7 @@ { public class LoginModel { - public string Email { get; set; } - public string Password { get; set; } + public required string Username { get; set; } + public required string Password { get; set; } } } diff --git a/Models/RefreshToken.cs b/Models/RefreshToken.cs new file mode 100644 index 0000000..605d9ff --- /dev/null +++ b/Models/RefreshToken.cs @@ -0,0 +1,19 @@ +namespace UserMicroservice.Models +{ + public class RefreshToken + { + public int Id { get; internal set; } + public int UserId { get; internal set; } + public string Token { get; internal set; } + public DateTime ExpirationDate { get; internal set; } + + public RefreshToken() { } + + public RefreshToken(string token, DateTime expirationDate, int userId) + { + Token = token; + ExpirationDate = expirationDate; + UserId = userId; + } + } +} diff --git a/Models/RegisterModel.cs b/Models/RegisterModel.cs index 7b96d64..5634559 100644 --- a/Models/RegisterModel.cs +++ b/Models/RegisterModel.cs @@ -2,8 +2,8 @@ { public class RegisterModel { - public string Username { get; set; } - public string Email { get; set; } - public string Password { get; set; } + public required string Username { get; set; } + public required string Email { get; set; } + public required string Password { get; set; } } } diff --git a/Models/User.cs b/Models/User.cs index 85c0ac6..cca8b8e 100644 --- a/Models/User.cs +++ b/Models/User.cs @@ -2,31 +2,26 @@ { public class User { - // TODO: - // remove setters from properties - // add constructor - // make class internal - // make required properites required - // properties can probably also be internal instead of public + // Parameterless constructor for EF Core + public User() { } - public User(String userName, string passwordHash) + public User(string userName, string email, string passwordHash) { - this.Username = userName; - this.PasswordHash = passwordHash; + Username = userName; + Email = email; + PasswordHash = passwordHash; } - public User() { } // remove this later - - public int Id { get; set; } - public string Username { get; set; } - public string Email { get; set; } = "random@gmail.com"; - public string PasswordHash { get; set; } - public UserType Type { get; set; } = UserType.BUYER; // set to default for now + public int Id { get; internal set; } + public string Username { get; internal set; } + public string Email { get; internal set; } + public string PasswordHash { get; internal set; } + public UserType Type { get; internal set; } = UserType.CUSTOMER; } public enum UserType { - BUYER = 0, - SELLER = 1 + CUSTOMER = 0, + AIRLINE = 1 } } diff --git a/Program.cs b/Program.cs index 8821c9f..34f4e5e 100644 --- a/Program.cs +++ b/Program.cs @@ -5,26 +5,17 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.Text; - var builder = WebApplication.CreateBuilder(args); // Add services to the container. - builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // Configure your DbContext and MySQL connection here -// This is also dependancy injection BTW... builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseMySql(builder.Configuration.GetConnectionString("DefaultConnection"), new MariaDbServerVersion(new Version(10, 4, 20)))); -// new MySqlServerVersion(new Version(8, 0, 21)))); - -// Configuration for the connection string -builder.Configuration.SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .AddEnvironmentVariables(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => @@ -40,7 +31,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }; }); -// Add dependecy injections +// Add dependency injections builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IUserService, UserService>(); @@ -54,7 +45,21 @@ if (app.Environment.IsDevelopment()) } app.UseHttpsRedirection(); + +// Middleware to check for the access token in cookies +app.Use(async (context, next) => +{ + var accessToken = context.Request.Cookies["AccessToken"]; + if (!string.IsNullOrEmpty(accessToken)) + { + context.Request.Headers.Append("Authorization", "Bearer " + accessToken); + } + + await next(); +}); + +app.UseAuthentication(); app.UseAuthorization(); -app.UseAuthentication(); + app.MapControllers(); app.Run(); diff --git a/Services/AuthService.cs b/Services/AuthService.cs index ecdd9b6..b8cc84e 100644 --- a/Services/AuthService.cs +++ b/Services/AuthService.cs @@ -3,19 +3,32 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using UserMicroservice.Models; +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; namespace UserMicroservice.Services { public class AuthService : IAuthService { private readonly IConfiguration _configuration; + private readonly ApplicationDbContext _context; - public AuthService(IConfiguration configuration) + public AuthService(IConfiguration configuration, ApplicationDbContext applicationDbContext) { _configuration = configuration; + _context = applicationDbContext; } - public string GenerateToken(User user) + public AuthTokenPair AuthenticateUser(User user) + { + string accessToken = GenerateAccessToken(user); + string refreshToken = GenerateRefreshToken(user.Id).Token; + + return new AuthTokenPair(accessToken, refreshToken); + } + + private string GenerateAccessToken(User user) { string? configuredKey = _configuration["Jwt:Key"]; string? configuredIssuer = _configuration["Jwt:Issuer"]; @@ -23,10 +36,10 @@ namespace UserMicroservice.Services throwIfNull(configuredKey, configuredIssuer, configuredAudience); - SymmetricSecurityKey key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuredKey)); - SigningCredentials creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuredKey)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - List<Claim> claims = new List<Claim> + var claims = new List<Claim> { new Claim(JwtRegisteredClaimNames.Sub, user.Username), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), @@ -37,22 +50,50 @@ namespace UserMicroservice.Services issuer: configuredIssuer, audience: configuredAudience, claims: claims, - expires: DateTime.Now.AddMinutes(30), + expires: DateTime.UtcNow.AddMinutes(30), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } - private void throwIfNull(string? key, string? issuer, string? audience) + private RefreshToken GenerateRefreshToken(int userId) { - if(string.IsNullOrWhiteSpace(key)) - throw new ArgumentNullException(nameof(key)); + RefreshToken refreshToken = new(Guid.NewGuid().ToString(), DateTime.UtcNow.AddDays(2), userId); + _context.RefreshTokens.Add(refreshToken); + _context.SaveChanges(); - if(string.IsNullOrWhiteSpace(issuer)) - throw new ArgumentNullException(nameof(issuer)); + return refreshToken; + } + + public bool ValidateRefreshToken(string token) + { - if(string.IsNullOrEmpty(audience)) - throw new ArgumentNullException(nameof(audience)); + RefreshToken refreshToken = _context.RefreshTokens.Single(t => t.Token == token); + if (refreshToken != null) { } + { + if (DateTime.UtcNow <= refreshToken.ExpirationDate) + { + return true; + } + + // delete expired token + RevokeRefreshToken(token); + } + return false; + } + + public void RevokeRefreshToken(string token) + { + _context.RefreshTokens.Where(t => t.Token == token).ExecuteDelete(); + _context.SaveChanges(); + } + + private void throwIfNull(string? key, string? issuer, string? audience) + { + if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(issuer) || string.IsNullOrEmpty(audience)) + { + throw new ArgumentNullException("JWT configuration is missing."); + } } } } diff --git a/Services/IAuthService.cs b/Services/IAuthService.cs index e9bedbf..4f30e06 100644 --- a/Services/IAuthService.cs +++ b/Services/IAuthService.cs @@ -4,6 +4,10 @@ namespace UserMicroservice.Services { public interface IAuthService { - string GenerateToken(User user); + AuthTokenPair AuthenticateUser(User user); + + void RevokeRefreshToken(string token); + + bool ValidateRefreshToken(string token); } } diff --git a/Services/IUserService.cs b/Services/IUserService.cs index 9bdd34b..b780468 100644 --- a/Services/IUserService.cs +++ b/Services/IUserService.cs @@ -5,17 +5,12 @@ namespace UserMicroservice.Services // CRUD Based Service public interface IUserService { - User GetUser(string username); + User? GetUser(string username); + User? GetUser(int userId); + User? GetUser(string username, string password); List<User> GetUsers(); - string RegisterUser(string username, string password); + User CreateUser(string email, string userName, string password); User UpdateUser(User updatedUser); bool DeleteUser(string username); - string? Login(string username, string password); } - - // interfaces are great for writing unit tests - // mocking certain parts of the code and asserting the outcome - // also so each class that inherits this have its own implementation - // while have the same parameters and return types - // ~ Russell Horwood } diff --git a/Services/UserService.cs b/Services/UserService.cs index de2a6bd..40a3c13 100644 --- a/Services/UserService.cs +++ b/Services/UserService.cs @@ -5,19 +5,16 @@ namespace UserMicroservice.Services { public class UserService : IUserService { - // look at entity framwork documetation for queries to the db using the DbContext private readonly ApplicationDbContext _context; - private readonly IAuthService _authService; - public UserService(ApplicationDbContext context, IAuthService authService) + public UserService(ApplicationDbContext context) { _context = context; - _authService = authService; } public bool DeleteUser(string username) { - User user = _context.Users.Single(user => user.Username == username); + User? user = _context.Users.SingleOrDefault(user => user.Username == username); if(user == null) return false; @@ -33,30 +30,32 @@ namespace UserMicroservice.Services return users; } - public User GetUser(string username) + public User? GetUser(string username) { - User user = _context.Users.Single(user => user.Username == username); + User? user = _context.Users.SingleOrDefault(user => user.Username == username); return user; } - public string? Login(string username, string password) + public User? GetUser(int userId) { - User user = _context.Users - .Single(user => user.Username == username && user.PasswordHash == password); - - if (user == null) - return null; + User? user = _context.Users.SingleOrDefault(user=> user.Id == userId); + return user; + } - return _authService.GenerateToken(user); + public User? GetUser(string username, string password) + { + User? user = _context.Users + .SingleOrDefault(user => user.Username == username && user.PasswordHash == password); + return user; } - public string RegisterUser(string username, string password) + public User CreateUser(string email, string userName, string password) { - User user = new User { Username = username, PasswordHash = password }; // use the contructor later + User user = new User(userName, email, password); _context.Users.Add(user); _context.SaveChanges(); - - return _authService.GenerateToken(user); + + return user; } public User UpdateUser(User updatedUser) -- GitLab