From 62b41f1f2b6c7d4c04001f43cb0d369c3d06ec1c Mon Sep 17 00:00:00 2001 From: Ateeb Date: Thu, 30 Jan 2025 11:59:12 +0100 Subject: [PATCH 1/2] Core --- .../Configuration/ConfigurationSettings.cs | 15 +++ .../Configuration/IConfigurationSettings.cs | 7 ++ .../Controllers/WeatherForecastController.cs | 35 +++++++ exercise.wwwapi/Data/DataContext.cs | 21 +++++ exercise.wwwapi/Data/PersonData.cs | 6 ++ exercise.wwwapi/EndPoints/AuthApi.cs | 93 +++++++++++++++++++ exercise.wwwapi/EndPoints/SecureApi.cs | 28 ++++++ .../Helpers/ClaimsPrincipalHelper.cs | 33 +++++++ exercise.wwwapi/Models/Payload.cs | 12 +++ exercise.wwwapi/Models/User.cs | 17 ++++ exercise.wwwapi/Models/UserRequestDto.cs | 12 +++ exercise.wwwapi/Models/UserResponseDto.cs | 12 +++ exercise.wwwapi/Repository/IRepository.cs | 18 ++++ exercise.wwwapi/Repository/Repository.cs | 64 +++++++++++++ 14 files changed, 373 insertions(+) create mode 100644 exercise.wwwapi/Configuration/ConfigurationSettings.cs create mode 100644 exercise.wwwapi/Configuration/IConfigurationSettings.cs create mode 100644 exercise.wwwapi/Controllers/WeatherForecastController.cs create mode 100644 exercise.wwwapi/Data/DataContext.cs create mode 100644 exercise.wwwapi/Data/PersonData.cs create mode 100644 exercise.wwwapi/EndPoints/AuthApi.cs create mode 100644 exercise.wwwapi/EndPoints/SecureApi.cs create mode 100644 exercise.wwwapi/Helpers/ClaimsPrincipalHelper.cs create mode 100644 exercise.wwwapi/Models/Payload.cs create mode 100644 exercise.wwwapi/Models/User.cs create mode 100644 exercise.wwwapi/Models/UserRequestDto.cs create mode 100644 exercise.wwwapi/Models/UserResponseDto.cs create mode 100644 exercise.wwwapi/Repository/IRepository.cs create mode 100644 exercise.wwwapi/Repository/Repository.cs diff --git a/exercise.wwwapi/Configuration/ConfigurationSettings.cs b/exercise.wwwapi/Configuration/ConfigurationSettings.cs new file mode 100644 index 0000000..be681a0 --- /dev/null +++ b/exercise.wwwapi/Configuration/ConfigurationSettings.cs @@ -0,0 +1,15 @@ +namespace exercise.wwwapi.Configuration +{ + public class ConfigurationSettings : IConfigurationSettings + { + IConfiguration _configuration; + public ConfigurationSettings() + { + _configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + } + public string GetValue(string key) + { + return _configuration.GetValue(key)!; + } + } +} diff --git a/exercise.wwwapi/Configuration/IConfigurationSettings.cs b/exercise.wwwapi/Configuration/IConfigurationSettings.cs new file mode 100644 index 0000000..d06bc43 --- /dev/null +++ b/exercise.wwwapi/Configuration/IConfigurationSettings.cs @@ -0,0 +1,7 @@ +namespace exercise.wwwapi.Configuration +{ + public interface IConfigurationSettings + { + string GetValue(string key); + } +} diff --git a/exercise.wwwapi/Controllers/WeatherForecastController.cs b/exercise.wwwapi/Controllers/WeatherForecastController.cs new file mode 100644 index 0000000..92ee567 --- /dev/null +++ b/exercise.wwwapi/Controllers/WeatherForecastController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace exercise.wwwapi.Controllers; + +[ApiController] +[Authorize] +[Route("[controller]")] +public class WeatherForecastController : ControllerBase +{ + private static readonly string[] Summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + private readonly ILogger _logger; + + public WeatherForecastController(ILogger logger) + { + _logger = logger; + } + + [HttpGet(Name = "GetWeatherForecast")] + public IEnumerable Get() + { + + return Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = Summaries[Random.Shared.Next(Summaries.Length)] + }) + .ToArray(); + } +} diff --git a/exercise.wwwapi/Data/DataContext.cs b/exercise.wwwapi/Data/DataContext.cs new file mode 100644 index 0000000..10ff96d --- /dev/null +++ b/exercise.wwwapi/Data/DataContext.cs @@ -0,0 +1,21 @@ +using exercise.wwwapi.Configuration; +using exercise.wwwapi.Models; +using Microsoft.EntityFrameworkCore; +using System.Numerics; + +namespace exercise.wwwapi.Data +{ + public class DataContext : DbContext + { + public DataContext(DbContextOptions options) : base(options) + { + + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + //optionsBuilder.UseInMemoryDatabase(databaseName: "Database"); + } + + public DbSet Users { get; set; } + } +} diff --git a/exercise.wwwapi/Data/PersonData.cs b/exercise.wwwapi/Data/PersonData.cs new file mode 100644 index 0000000..4d9eced --- /dev/null +++ b/exercise.wwwapi/Data/PersonData.cs @@ -0,0 +1,6 @@ +namespace exercise.wwwapi.Data +{ + public class PersonData + { + } +} diff --git a/exercise.wwwapi/EndPoints/AuthApi.cs b/exercise.wwwapi/EndPoints/AuthApi.cs new file mode 100644 index 0000000..4c9c2a4 --- /dev/null +++ b/exercise.wwwapi/EndPoints/AuthApi.cs @@ -0,0 +1,93 @@ +using exercise.wwwapi.Configuration; +using exercise.wwwapi.Helpers; +using exercise.wwwapi.Models; +using exercise.wwwapi.Repository; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace exercise.wwwapi.EndPoints +{ + public static class AuthApi + { + public static void ConfigureAuthApi(this WebApplication app) + { + app.MapPost("register", Register); + app.MapPost("login", Login); + app.MapGet("users", GetUsers); + + } + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task GetUsers(IRepository service, ClaimsPrincipal user) + { + return TypedResults.Ok(service.GetAll()); + } + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + private static async Task Register(UserRequestDto request, IRepository service) + { + + //user exists + if (service.GetAll().Where(u => u.Username == request.Username).Any()) return Results.Conflict(new Payload() { status = "Username already exists!", data = request }); + + string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); + + var user = new User(); + + user.Username = request.Username; + user.PasswordHash = passwordHash; + user.Email = request.Email; + + service.Insert(user); + service.Save(); + + return Results.Ok(new Payload() { data = "Created Account" }); + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + private static async Task Login(UserRequestDto request, IRepository service, IConfigurationSettings config) + { + //user doesn't exist + if (!service.GetAll().Where(u => u.Username == request.Username).Any()) return Results.BadRequest(new Payload() { status = "User does not exist", data = request }); + + User user = service.GetAll().FirstOrDefault(u => u.Username == request.Username)!; + + + if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) + { + return Results.BadRequest(new Payload() { status = "Wrong Password", data = request }); + } + string token = CreateToken(user, config); + return Results.Ok(new Payload() { data = token }) ; + + } + private static string CreateToken(User user, IConfigurationSettings config) + { + List claims = new List + { + new Claim(ClaimTypes.Sid, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.Email, user.Email), + + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config.GetValue("AppSettings:Token"))); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha512Signature); + var token = new JwtSecurityToken( + claims: claims, + expires: DateTime.Now.AddDays(1), + signingCredentials: credentials + ); + var jwt = new JwtSecurityTokenHandler().WriteToken(token); + return jwt; + } + } +} + diff --git a/exercise.wwwapi/EndPoints/SecureApi.cs b/exercise.wwwapi/EndPoints/SecureApi.cs new file mode 100644 index 0000000..03da8c7 --- /dev/null +++ b/exercise.wwwapi/EndPoints/SecureApi.cs @@ -0,0 +1,28 @@ +using exercise.wwwapi.Helpers; +using exercise.wwwapi.Models; +using exercise.wwwapi.Repository; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Win32; +using System.Security.Claims; + +namespace exercise.wwwapi.EndPoints +{ + public static class SecureApi + { + public static void ConfigureSecureApi(this WebApplication app) + { + app.MapGet("message", GetMessage); + + + } + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task GetMessage(IRepository service, ClaimsPrincipal user, ILogger logger) + { + logger.LogDebug(new string('*', 1000)); + return TypedResults.Ok(new { LoggedIn = true, UserId=user.UserRealId().ToString(), Email = $"{user.Email()}", Message = "Pulled the userid and email out of the claims" }); + } + } +} diff --git a/exercise.wwwapi/Helpers/ClaimsPrincipalHelper.cs b/exercise.wwwapi/Helpers/ClaimsPrincipalHelper.cs new file mode 100644 index 0000000..c34ad1f --- /dev/null +++ b/exercise.wwwapi/Helpers/ClaimsPrincipalHelper.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using System.Runtime.CompilerServices; +using System.Security.Claims; + +namespace exercise.wwwapi.Helpers +{ + public static class ClaimsPrincipalHelper + { + public static int? UserRealId(this ClaimsPrincipal user) + { + Claim? claim = user.FindFirst(ClaimTypes.Sid); + return int.Parse(claim?.Value); + } + public static string UserId(this ClaimsPrincipal user) + { + IEnumerable claims = user.Claims.Where(c => c.Type == ClaimTypes.NameIdentifier); + return claims.Count() >= 2 ? claims.ElementAt(1).Value : null; + + } + + public static string? Email(this ClaimsPrincipal user) + { + Claim? claim = user.FindFirst(ClaimTypes.Email); + return claim?.Value; + } + public static string? Role(this ClaimsPrincipal user) + { + Claim? claim = user.FindFirst(ClaimTypes.Role); + return claim?.Value; + } + + } +} diff --git a/exercise.wwwapi/Models/Payload.cs b/exercise.wwwapi/Models/Payload.cs new file mode 100644 index 0000000..1a09290 --- /dev/null +++ b/exercise.wwwapi/Models/Payload.cs @@ -0,0 +1,12 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations.Schema; + +namespace exercise.wwwapi.Models +{ + [NotMapped] + public class Payload where T : class + { + public string status { get; set; } = "success"; + public T data { get; set; } + } +} diff --git a/exercise.wwwapi/Models/User.cs b/exercise.wwwapi/Models/User.cs new file mode 100644 index 0000000..4feb230 --- /dev/null +++ b/exercise.wwwapi/Models/User.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace exercise.wwwapi.Models +{ + [Table("users")] + public class User + { + [Column("id")] + public int Id { get; set; } + [Column("username")] + public string Username { get; set; } + [Column("passwordhash")] + public string PasswordHash { get; set; } + [Column("email")] + public string Email { get; set; } + } +} diff --git a/exercise.wwwapi/Models/UserRequestDto.cs b/exercise.wwwapi/Models/UserRequestDto.cs new file mode 100644 index 0000000..35cb377 --- /dev/null +++ b/exercise.wwwapi/Models/UserRequestDto.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace exercise.wwwapi.Models +{ + [NotMapped] + public class UserRequestDto + { + public required string Username { get; set; } + public required string Password { get; set; } + public required string Email { get; set; } + } +} diff --git a/exercise.wwwapi/Models/UserResponseDto.cs b/exercise.wwwapi/Models/UserResponseDto.cs new file mode 100644 index 0000000..fe06ba3 --- /dev/null +++ b/exercise.wwwapi/Models/UserResponseDto.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace exercise.wwwapi.Models +{ + [NotMapped] + public class UserResponseDto + { + public string Username { get; set; } + public string PasswordHash { get; set; } + public string Email { get; set; } + } +} diff --git a/exercise.wwwapi/Repository/IRepository.cs b/exercise.wwwapi/Repository/IRepository.cs new file mode 100644 index 0000000..f1ab9b2 --- /dev/null +++ b/exercise.wwwapi/Repository/IRepository.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; + +namespace exercise.wwwapi.Repository +{ + public interface IRepository where T : class + { + IEnumerable GetAll(); + IEnumerable GetAll(params Expression>[] includeExpressions); + T GetById(object id); + void Insert(T obj); + void Update(T obj); + void Delete(object id); + void Save(); + DbSet Table { get; } + + } +} diff --git a/exercise.wwwapi/Repository/Repository.cs b/exercise.wwwapi/Repository/Repository.cs new file mode 100644 index 0000000..cc163d0 --- /dev/null +++ b/exercise.wwwapi/Repository/Repository.cs @@ -0,0 +1,64 @@ +using exercise.wwwapi.Data; +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; + +namespace exercise.wwwapi.Repository +{ + public class Repository : IRepository where T : class + { + + + private DataContext _db; + private DbSet _table = null; + + public Repository(DataContext db) + { + _db = db; + _table = _db.Set(); + } + + public IEnumerable GetAll(params Expression>[] includeExpressions) + { + if (includeExpressions.Any()) + { + var set = includeExpressions + .Aggregate>, IQueryable> + (_table, (current, expression) => current.Include(expression)); + } + return _table.ToList(); + } + + public IEnumerable GetAll() + { + return _table.ToList(); + } + public T GetById(object id) + { + return _table.Find(id); + } + + public void Insert(T obj) + { + _table.Add(obj); + } + public void Update(T obj) + { + _table.Attach(obj); + _db.Entry(obj).State = EntityState.Modified; + } + + public void Delete(object id) + { + T existing = _table.Find(id); + _table.Remove(existing); + } + + + public void Save() + { + _db.SaveChanges(); + } + public DbSet Table { get { return _table; } } + + } +} From ebd0579a505ebe2e21b5993d7f2f634a7cbd7724 Mon Sep 17 00:00:00 2001 From: Ateeb Date: Sun, 2 Feb 2025 21:37:49 +0100 Subject: [PATCH 2/2] core done --- .../Controllers/WeatherForecastController.cs | 35 ----- exercise.wwwapi/DTO/AddBlogDTO.cs | 15 +++ exercise.wwwapi/DTO/BlogDTO.cs | 19 +++ exercise.wwwapi/DTO/UpdateDTO.cs | 11 ++ exercise.wwwapi/DTO/UserDTO.cs | 9 ++ .../{Models => DTO}/UserRequestDto.cs | 2 +- .../{Models => DTO}/UserResponseDto.cs | 2 +- exercise.wwwapi/Data/DataContext.cs | 10 +- exercise.wwwapi/Data/PersonData.cs | 6 - exercise.wwwapi/EndPoints/AuthApi.cs | 1 + exercise.wwwapi/EndPoints/BlogEndpoint.cs | 109 ++++++++++++++++ exercise.wwwapi/EndPoints/SecureApi.cs | 6 +- exercise.wwwapi/Helpers/Mapper.cs | 22 ++++ .../{Models => Helpers}/Payload.cs | 2 +- exercise.wwwapi/Models/Blog.cs | 19 +++ exercise.wwwapi/Models/User.cs | 2 + exercise.wwwapi/Program.cs | 120 ++++++++++++++++++ exercise.wwwapi/Repository/IRepository.cs | 4 + exercise.wwwapi/Repository/Repository.cs | 53 +++++++- exercise.wwwapi/appsettings.example.json | 15 --- exercise.wwwapi/exercise.wwwapi.csproj | 24 +++- 21 files changed, 415 insertions(+), 71 deletions(-) delete mode 100644 exercise.wwwapi/Controllers/WeatherForecastController.cs create mode 100644 exercise.wwwapi/DTO/AddBlogDTO.cs create mode 100644 exercise.wwwapi/DTO/BlogDTO.cs create mode 100644 exercise.wwwapi/DTO/UpdateDTO.cs create mode 100644 exercise.wwwapi/DTO/UserDTO.cs rename exercise.wwwapi/{Models => DTO}/UserRequestDto.cs (89%) rename exercise.wwwapi/{Models => DTO}/UserResponseDto.cs (88%) delete mode 100644 exercise.wwwapi/Data/PersonData.cs create mode 100644 exercise.wwwapi/EndPoints/BlogEndpoint.cs create mode 100644 exercise.wwwapi/Helpers/Mapper.cs rename exercise.wwwapi/{Models => Helpers}/Payload.cs (88%) create mode 100644 exercise.wwwapi/Models/Blog.cs delete mode 100644 exercise.wwwapi/appsettings.example.json diff --git a/exercise.wwwapi/Controllers/WeatherForecastController.cs b/exercise.wwwapi/Controllers/WeatherForecastController.cs deleted file mode 100644 index 92ee567..0000000 --- a/exercise.wwwapi/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace exercise.wwwapi.Controllers; - -[ApiController] -[Authorize] -[Route("[controller]")] -public class WeatherForecastController : ControllerBase -{ - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } -} diff --git a/exercise.wwwapi/DTO/AddBlogDTO.cs b/exercise.wwwapi/DTO/AddBlogDTO.cs new file mode 100644 index 0000000..8cc5385 --- /dev/null +++ b/exercise.wwwapi/DTO/AddBlogDTO.cs @@ -0,0 +1,15 @@ +namespace exercise.wwwapi.DTO +{ + public class AddBlogDTO + { + public string Title { get; set; } + + public string Content { get; set; } + + public string Description { get; set; } + + public string Author { get; set; } + + public int UserId { get; set; } + } +} diff --git a/exercise.wwwapi/DTO/BlogDTO.cs b/exercise.wwwapi/DTO/BlogDTO.cs new file mode 100644 index 0000000..a34f556 --- /dev/null +++ b/exercise.wwwapi/DTO/BlogDTO.cs @@ -0,0 +1,19 @@ +using exercise.wwwapi.Models; + +namespace exercise.wwwapi.DTO +{ + public class BlogDTO + { + public int Id { get; set; } + + public string Title { get; set; } + + public string Content { get; set; } + + public string Description { get; set; } + + public string Author { get; set; } + + public UserDTO User { get; set; } + } +} diff --git a/exercise.wwwapi/DTO/UpdateDTO.cs b/exercise.wwwapi/DTO/UpdateDTO.cs new file mode 100644 index 0000000..d700d57 --- /dev/null +++ b/exercise.wwwapi/DTO/UpdateDTO.cs @@ -0,0 +1,11 @@ +namespace exercise.wwwapi.DTO +{ + public class UpdateDTO + { + public string Title { get; set; } + public string Content { get; set; } + public string Description { get; set; } + public string Author { get; set; } + } + +} diff --git a/exercise.wwwapi/DTO/UserDTO.cs b/exercise.wwwapi/DTO/UserDTO.cs new file mode 100644 index 0000000..a6160a9 --- /dev/null +++ b/exercise.wwwapi/DTO/UserDTO.cs @@ -0,0 +1,9 @@ +namespace exercise.wwwapi.DTO +{ + public class UserDTO + { + public int Id { get; set; } + public string Username { get; set; } + public string Email { get; set; } + } +} diff --git a/exercise.wwwapi/Models/UserRequestDto.cs b/exercise.wwwapi/DTO/UserRequestDto.cs similarity index 89% rename from exercise.wwwapi/Models/UserRequestDto.cs rename to exercise.wwwapi/DTO/UserRequestDto.cs index 35cb377..d4cf1d1 100644 --- a/exercise.wwwapi/Models/UserRequestDto.cs +++ b/exercise.wwwapi/DTO/UserRequestDto.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations.Schema; -namespace exercise.wwwapi.Models +namespace exercise.wwwapi.DTO { [NotMapped] public class UserRequestDto diff --git a/exercise.wwwapi/Models/UserResponseDto.cs b/exercise.wwwapi/DTO/UserResponseDto.cs similarity index 88% rename from exercise.wwwapi/Models/UserResponseDto.cs rename to exercise.wwwapi/DTO/UserResponseDto.cs index fe06ba3..8d39178 100644 --- a/exercise.wwwapi/Models/UserResponseDto.cs +++ b/exercise.wwwapi/DTO/UserResponseDto.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations.Schema; -namespace exercise.wwwapi.Models +namespace exercise.wwwapi.DTO { [NotMapped] public class UserResponseDto diff --git a/exercise.wwwapi/Data/DataContext.cs b/exercise.wwwapi/Data/DataContext.cs index 10ff96d..7636bca 100644 --- a/exercise.wwwapi/Data/DataContext.cs +++ b/exercise.wwwapi/Data/DataContext.cs @@ -7,15 +7,19 @@ namespace exercise.wwwapi.Data { public class DataContext : DbContext { - public DataContext(DbContextOptions options) : base(options) - { + private string connectionString; + public DataContext(DbContextOptions options) : base(options) { + + var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + connectionString = configuration.GetValue("ConnectionStrings:DefaultConnectionString"); } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - //optionsBuilder.UseInMemoryDatabase(databaseName: "Database"); + optionsBuilder.UseNpgsql(connectionString); } public DbSet Users { get; set; } + public DbSet Blogs { get; set; } } } diff --git a/exercise.wwwapi/Data/PersonData.cs b/exercise.wwwapi/Data/PersonData.cs deleted file mode 100644 index 4d9eced..0000000 --- a/exercise.wwwapi/Data/PersonData.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace exercise.wwwapi.Data -{ - public class PersonData - { - } -} diff --git a/exercise.wwwapi/EndPoints/AuthApi.cs b/exercise.wwwapi/EndPoints/AuthApi.cs index 4c9c2a4..6a8b844 100644 --- a/exercise.wwwapi/EndPoints/AuthApi.cs +++ b/exercise.wwwapi/EndPoints/AuthApi.cs @@ -1,4 +1,5 @@ using exercise.wwwapi.Configuration; +using exercise.wwwapi.DTO; using exercise.wwwapi.Helpers; using exercise.wwwapi.Models; using exercise.wwwapi.Repository; diff --git a/exercise.wwwapi/EndPoints/BlogEndpoint.cs b/exercise.wwwapi/EndPoints/BlogEndpoint.cs new file mode 100644 index 0000000..a1149be --- /dev/null +++ b/exercise.wwwapi/EndPoints/BlogEndpoint.cs @@ -0,0 +1,109 @@ +using System.Security.Claims; +using AutoMapper; +using exercise.wwwapi.DTO; +using exercise.wwwapi.Helpers; +using exercise.wwwapi.Models; +using exercise.wwwapi.Repository; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace exercise.wwwapi.EndPoints +{ + public static class BlogEndpoint + { + public static void ConfigureBlogApi(this WebApplication app) + { + app.MapGet("getBlogs", GetBlogs); + app.MapGet("getBlogs/{id}", GetBlog); + app.MapPost("AddBlogs", AddBlog); + app.MapPut("updateBlog/{id}", UpdateBlog); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task GetBlogs(IRepository repository, IMapper mapper) + { + var blogs = await repository.GetWithIncludes(p => p.User); + + if (blogs == null || !blogs.Any()) + { + return TypedResults.NotFound(new Payload { status = "error", data = "No blogs found." }); + } + + var response = mapper.Map>(blogs); + return TypedResults.Ok(new Payload> { data = response }); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task GetBlog(IRepository repository, IMapper mapper, int id) + { + var blog = await repository.GetByIdWithIncludes(id, p => p.User); + if (blog == null) + { + return TypedResults.NotFound(new Payload { status = "error", data = $"Blog with ID {id} not found." }); + } + + var response = mapper.Map(blog); + return TypedResults.Ok(new Payload { data = response }); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public static async Task AddBlog(IRepository repository, IMapper mapper, AddBlogDTO blogDto, ClaimsPrincipal user) + { + int? userId = user.UserRealId(); + if (userId == null) + { + return TypedResults.BadRequest(new Payload { status = "error", data = "User ID could not be determined." }); + } + + Blog create = new Blog() + { + Title = blogDto.Title, + Content = blogDto.Content, + Description = blogDto.Description, + Author = blogDto.Author, + UserId = userId.Value + }; + + repository.Insert(create); + repository.Save(); + + var response = mapper.Map(create); + return TypedResults.Created($"/blogs/{create.Id}", new Payload { data = response }); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task UpdateBlog(IRepository repository, IMapper mapper, int id, UpdateDTO blogDto, ClaimsPrincipal user) + { + var blog = repository.GetById(id); + if (blog == null) + { + return TypedResults.NotFound(new Payload { status = "error", data = "Blog not found." }); + } + if (blog.UserId != user.UserRealId()) + { + return TypedResults.BadRequest(new Payload { status = "error", data = "Unauthorized to update this blog." }); + } + + blog.Title = blogDto.Title; + blog.Content = blogDto.Content; + blog.Description = blogDto.Description; + blog.Author = blogDto.Author; + + repository.Update(blog); + repository.Save(); + + var response = mapper.Map(blog); + return TypedResults.Ok(new Payload { data = response }); + } + } +} diff --git a/exercise.wwwapi/EndPoints/SecureApi.cs b/exercise.wwwapi/EndPoints/SecureApi.cs index 03da8c7..e193fb9 100644 --- a/exercise.wwwapi/EndPoints/SecureApi.cs +++ b/exercise.wwwapi/EndPoints/SecureApi.cs @@ -13,9 +13,11 @@ public static class SecureApi public static void ConfigureSecureApi(this WebApplication app) { app.MapGet("message", GetMessage); - + //app.MapGet("message2", GetMessage2); + } + [Authorize] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] @@ -24,5 +26,7 @@ private static async Task GetMessage(IRepository service, ClaimsP logger.LogDebug(new string('*', 1000)); return TypedResults.Ok(new { LoggedIn = true, UserId=user.UserRealId().ToString(), Email = $"{user.Email()}", Message = "Pulled the userid and email out of the claims" }); } + + } } diff --git a/exercise.wwwapi/Helpers/Mapper.cs b/exercise.wwwapi/Helpers/Mapper.cs new file mode 100644 index 0000000..313c301 --- /dev/null +++ b/exercise.wwwapi/Helpers/Mapper.cs @@ -0,0 +1,22 @@ +using AutoMapper; +using exercise.wwwapi.DTO; +using exercise.wwwapi.Models; + +namespace exercise.wwwapi.Helpers +{ + public class Mapper : Profile + { + public Mapper() + { + CreateMap(); + CreateMap() + .ForMember(dest => dest.User, opt => opt.MapFrom(src => src.User)); + //CreateMap(); + //.ForMember(dest => dest.User, opt => opt.MapFrom(src => src.User)); + CreateMap(); + CreateMap(); + } + + + } +} diff --git a/exercise.wwwapi/Models/Payload.cs b/exercise.wwwapi/Helpers/Payload.cs similarity index 88% rename from exercise.wwwapi/Models/Payload.cs rename to exercise.wwwapi/Helpers/Payload.cs index 1a09290..0567725 100644 --- a/exercise.wwwapi/Models/Payload.cs +++ b/exercise.wwwapi/Helpers/Payload.cs @@ -1,7 +1,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations.Schema; -namespace exercise.wwwapi.Models +namespace exercise.wwwapi.Helpers { [NotMapped] public class Payload where T : class diff --git a/exercise.wwwapi/Models/Blog.cs b/exercise.wwwapi/Models/Blog.cs new file mode 100644 index 0000000..91dce64 --- /dev/null +++ b/exercise.wwwapi/Models/Blog.cs @@ -0,0 +1,19 @@ +namespace exercise.wwwapi.Models +{ + public class Blog + { + public int Id { get; set; } + + public string Title { get; set; } + + public string Content { get; set; } + + public string Description { get; set; } + + public string Author { get; set; } + + public int UserId { get; set; } + + public User User { get; set; } + } +} diff --git a/exercise.wwwapi/Models/User.cs b/exercise.wwwapi/Models/User.cs index 4feb230..65ae168 100644 --- a/exercise.wwwapi/Models/User.cs +++ b/exercise.wwwapi/Models/User.cs @@ -13,5 +13,7 @@ public class User public string PasswordHash { get; set; } [Column("email")] public string Email { get; set; } + + public List Blogs { get; set; } = new List(); } } diff --git a/exercise.wwwapi/Program.cs b/exercise.wwwapi/Program.cs index 47f22ef..2ab19b7 100644 --- a/exercise.wwwapi/Program.cs +++ b/exercise.wwwapi/Program.cs @@ -1,6 +1,104 @@ +using exercise.wwwapi.Configuration; +using exercise.wwwapi.Data; +using exercise.wwwapi.EndPoints; +using exercise.wwwapi.Models; +using exercise.wwwapi.Repository; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using Scalar.AspNetCore; +using System.Diagnostics; +using System.Text; + var builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); +var config = new ConfigurationSettings(); // Add services to the container. +builder.Services.AddScoped(); +builder.Services.AddScoped, Repository>(); +builder.Services.AddScoped, Repository>(); +builder.Services.AddScoped>(); +builder.Services.AddAutoMapper(typeof(Program)); +builder.Services.AddDbContext(options => { + + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnectionString")); + options.LogTo(message => Debug.WriteLine(message)); + +}); +//authentication verifying who they say they are +//authorization verifying what they have access to +builder.Services.AddAuthentication(x => +{ + x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + x.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; + +}).AddJwtBearer(x => +{ + x.TokenValidationParameters = new TokenValidationParameters + { + + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config.GetValue("AppSettings:Token"))), + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + ValidateIssuerSigningKey = false + + }; +}); +builder.Services.AddSwaggerGen(s => +{ + s.SwaggerDoc("v1", new OpenApiInfo + { + Version = "v1", + Title = "C# API Authentication", + Description = "Demo of an API using JWT as an authentication method", + Contact = new OpenApiContact + { + Name = "ateeb", + Email = "ateeb@nigel.nigel", + Url = new Uri("https://www.boolean.co.uk") + }, + License = new OpenApiLicense + { + Name = "Boolean", + Url = new Uri("https://github.com/boolean-uk/csharp-api-auth") + } + + }); + + s.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = "Add an Authorization header with a JWT token using the Bearer scheme see the app.http file for an example.)", + Name = "Authorization", + Type = SecuritySchemeType.ApiKey, + In = ParameterLocation.Header, + Scheme = "Bearer" + }); + + s.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); + +}); +builder.Services.AddAuthorization(); + +builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -12,9 +110,31 @@ { app.UseSwagger(); app.UseSwaggerUI(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/openapi/v1.json", "Demo API"); + }); + app.MapScalarApiReference(); } +app.UseCors(x => x + .AllowAnyMethod() + .AllowAnyHeader() + .SetIsOriginAllowed(origin => true) // allow any origin + .AllowCredentials()); // allow credentials + app.UseHttpsRedirection(); +app.UseAuthentication(); + +app.UseAuthorization(); + +app.MapControllers(); + +app.ConfigureAuthApi(); + +app.ConfigureSecureApi(); + +app.ConfigureBlogApi(); app.Run(); \ No newline at end of file diff --git a/exercise.wwwapi/Repository/IRepository.cs b/exercise.wwwapi/Repository/IRepository.cs index f1ab9b2..11cf419 100644 --- a/exercise.wwwapi/Repository/IRepository.cs +++ b/exercise.wwwapi/Repository/IRepository.cs @@ -7,6 +7,10 @@ public interface IRepository where T : class { IEnumerable GetAll(); IEnumerable GetAll(params Expression>[] includeExpressions); + Task> GetWithIncludes(params Expression>[] includes); + Task> GetWithNestedIncludes(params Func, IQueryable>[] includeActions); + Task GetByIdWithIncludes(int id, params Expression>[] includes); + Task AddAsync(T entity); T GetById(object id); void Insert(T obj); void Update(T obj); diff --git a/exercise.wwwapi/Repository/Repository.cs b/exercise.wwwapi/Repository/Repository.cs index cc163d0..ecfe3e5 100644 --- a/exercise.wwwapi/Repository/Repository.cs +++ b/exercise.wwwapi/Repository/Repository.cs @@ -19,13 +19,47 @@ public Repository(DataContext db) public IEnumerable GetAll(params Expression>[] includeExpressions) { + IQueryable query = _table; if (includeExpressions.Any()) { - var set = includeExpressions - .Aggregate>, IQueryable> - (_table, (current, expression) => current.Include(expression)); + query = includeExpressions.Aggregate(query, (current, expression) => current.Include(expression)); } - return _table.ToList(); + return query.ToList(); + } + + + public async Task> GetWithIncludes(params Expression>[] includes) + { + IQueryable query = _table; + foreach (var include in includes) + { + query = query.Include(include); + } + return await query.ToListAsync(); + } + + + public async Task> GetWithNestedIncludes(params Func, IQueryable>[] includeActions) + { + IQueryable query = _table; + + foreach (var includeAction in includeActions) + { + query = includeAction(query); + } + + return await query.ToListAsync(); + } + public async Task GetByIdWithIncludes(int id, params Expression>[] includes) + { + IQueryable query = _table; + + foreach (var include in includes) + { + query = query.Include(include); + } + + return await query.FirstOrDefaultAsync(entity => EF.Property(entity, "Id") == id); } public IEnumerable GetAll() @@ -50,14 +84,25 @@ public void Update(T obj) public void Delete(object id) { T existing = _table.Find(id); + if (existing == null) return; _table.Remove(existing); } + public void Save() { _db.SaveChanges(); } + public async Task AddAsync(T entity) + { + await _table.AddAsync(entity); + return entity; + } + + + + public DbSet Table { get { return _table; } } } diff --git a/exercise.wwwapi/appsettings.example.json b/exercise.wwwapi/appsettings.example.json deleted file mode 100644 index 7a9d42d..0000000 --- a/exercise.wwwapi/appsettings.example.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "JwtTokenSettings": { - "ValidIssuer": "YourCompanyServer", - "ValidAudience": "YourProductAudience", - "SymmetricSecurityKey": "SOME_RANDOM_SECRET", - "JwtRegisteredClaimNamesSub": "SOME_RANDOM_CODE" - } -} diff --git a/exercise.wwwapi/exercise.wwwapi.csproj b/exercise.wwwapi/exercise.wwwapi.csproj index 56929a8..97ecb81 100644 --- a/exercise.wwwapi/exercise.wwwapi.csproj +++ b/exercise.wwwapi/exercise.wwwapi.csproj @@ -7,9 +7,25 @@ true - - - - + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + +