diff --git a/.gitignore b/.gitignore index 4c4191372101d55cc67f3e4bfb44a4fdb4712781..36bc62afd72d6592bab03593c76885a0702b5673 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 0000000000000000000000000000000000000000..5db458a570be5b7c921ecdddadbb17e66a7e7f01 --- /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 1fe0e1355996508aed35daaf0898ed609bef97be..0000000000000000000000000000000000000000 --- 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 258c353c20634fcfd0baeb43fa6b59b3270eeb45..0000000000000000000000000000000000000000 --- 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 f96d3fae41d10134c92fbddcaa624d70658b76a8..0000000000000000000000000000000000000000 --- 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 c51b1357a9a8ab5339ab7df7f8de092110c9bd46..833f523a4205addf8b73e6ffbdecd7cad7441d0a 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 127fa1f97a38fa6fe33fa983bd1d09948500e9a3..c22b1e8c442a76d6fb2c9b880b1f94e94ba889b9 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 0000000000000000000000000000000000000000..db3ea7b26e8b102298ad3ad7751336fbaa9fa60e --- /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 669f778778cf443f568fd70d91682cf4de87110b..961ae0596bea04d3b3c4de3fe42e0a0342084272 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 0000000000000000000000000000000000000000..605d9ff252e60b3dbdd2119b641146677525a3bc --- /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 7b96d641e47306319f3e2507d96169b5b20a9e19..5634559f4ee13289e4d98c30a48fb95d3cbbabbc 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 85c0ac6797a55a3ed56fae3cb30451f46c438f71..cca8b8e0158e1fa3e7e4a11200d268ab2a68e0af 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 8821c9f6561580839b9d559e1d1960b9a5800f81..34f4e5e0759c53f485bb87bd0c1731be921d5e2d 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 ecdd9b6dceb3e9b72965817febd9aa10ffdd8bf5..b8cc84e46a7826a1610c48d2207ee38b35d42633 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 e9bedbf57b8b49cd336a1faa28adaae65d80b752..4f30e068812105547297a93446eefa9e2d5de353 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 9bdd34b08d72f322fcb5bf0bf838881c238455cc..b780468943ee9c96b6b194e3c260a9ee822d3d9e 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 de2a6bdac89f4cb25f5408a32fcc8eaa2ae1e03e..40a3c13839960d05e47af8ab467a1299e053880e 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)