diff --git a/exercise.wwwapi/Configuration/ConfigurationSettings.cs b/exercise.wwwapi/Configuration/ConfigurationSettings.cs new file mode 100644 index 0000000..301c601 --- /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)!; + } + } +} \ No newline at end of file diff --git a/exercise.wwwapi/Configuration/IConfigurationSettings.cs b/exercise.wwwapi/Configuration/IConfigurationSettings.cs new file mode 100644 index 0000000..ad90ed5 --- /dev/null +++ b/exercise.wwwapi/Configuration/IConfigurationSettings.cs @@ -0,0 +1,7 @@ +namespace exercise.wwwapi.Configuration +{ + public interface IConfigurationSettings + { + string GetValue(string key); + } +} \ No newline at end of file diff --git a/exercise.wwwapi/DTO/BlogPostDTO.cs b/exercise.wwwapi/DTO/BlogPostDTO.cs new file mode 100644 index 0000000..2a82d47 --- /dev/null +++ b/exercise.wwwapi/DTO/BlogPostDTO.cs @@ -0,0 +1,7 @@ +namespace exercise.wwwapi.DTO +{ + public class BlogPostDTO + { + public string Text { get; set; } + } +} diff --git a/exercise.wwwapi/DTO/BlogPostLinkDTO.cs b/exercise.wwwapi/DTO/BlogPostLinkDTO.cs new file mode 100644 index 0000000..964e6c3 --- /dev/null +++ b/exercise.wwwapi/DTO/BlogPostLinkDTO.cs @@ -0,0 +1,9 @@ +namespace exercise.wwwapi.DTO +{ + public class BlogPostLinkDTO + { + public int id { get; set; } + public string text { get; set; } + public int authorId { get; set; } + } +} diff --git a/exercise.wwwapi/DTO/UserRequestDTO.cs b/exercise.wwwapi/DTO/UserRequestDTO.cs new file mode 100644 index 0000000..b8b2754 --- /dev/null +++ b/exercise.wwwapi/DTO/UserRequestDTO.cs @@ -0,0 +1,9 @@ +namespace exercise.wwwapi.DTO +{ + public class UserRequestDTO + { + public required string Name { get; set; } + public required string Email { get; set; } + public required string Password { get; set; } + } +} diff --git a/exercise.wwwapi/Data/DataContext.cs b/exercise.wwwapi/Data/DataContext.cs new file mode 100644 index 0000000..797d33f --- /dev/null +++ b/exercise.wwwapi/Data/DataContext.cs @@ -0,0 +1,41 @@ +using System.Net.Sockets; +using exercise.wwwapi.Models; +using Microsoft.EntityFrameworkCore; + +namespace exercise.wwwapi.Data + +{ + public class DataContext : DbContext + { + private string _connectionString; + public DataContext(DbContextOptions options) : base(options) + { + var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + _connectionString = configuration.GetValue("ConnectionStrings:DefaultConnectionString")!; + this.Database.EnsureCreated(); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(_connectionString); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity().ToTable("users"); + modelBuilder.Entity().ToTable("blog_posts"); + + // Relationships + modelBuilder.Entity() + .HasOne(bp => bp.Author) + .WithMany(u => u.Blogs) + .HasForeignKey(bp => bp.authorId); + } + + public DbSet BlogPosts { get; set; } + public DbSet Users { get; set; } + + } + +} \ No newline at end of file diff --git a/exercise.wwwapi/EndPoints/AuthApi.cs b/exercise.wwwapi/EndPoints/AuthApi.cs new file mode 100644 index 0000000..fa8f682 --- /dev/null +++ b/exercise.wwwapi/EndPoints/AuthApi.cs @@ -0,0 +1,93 @@ +using exercise.wwwapi.Configuration; +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.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.Name == request.Name).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.Name = request.Name; + user.Password = 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.Name == request.Name).Any()) return Results.BadRequest(new Payload() { status = "User does not exist", data = request }); + + User user = service.GetAll().FirstOrDefault(u => u.Name == request.Name)!; + + + if (!BCrypt.Net.BCrypt.Verify(request.Password, user.Password)) + { + 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.Name), + 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; + } + } +} \ No newline at end of file diff --git a/exercise.wwwapi/EndPoints/BlogPostEndpoint.cs b/exercise.wwwapi/EndPoints/BlogPostEndpoint.cs new file mode 100644 index 0000000..434cf50 --- /dev/null +++ b/exercise.wwwapi/EndPoints/BlogPostEndpoint.cs @@ -0,0 +1,119 @@ +using System; +using System.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using AutoMapper; +using exercise.wwwapi.Configuration; +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.IdentityModel.Tokens; +using Microsoft.Win32; + +namespace exercise.wwwapi.EndPoints +{ + public static class BlogPostEndpoint + { + public static void ConfigureBlogPostEndpoint(this WebApplication app) + { + app.MapGet("blogpost", GetAll); + app.MapGet("blogpost/{id}", GetById); + app.MapPost("blogpost", AddBlogPost); + app.MapPut("blogpost/{id}", UpdateBlogPost); + } + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task GetAll(IRepository repository, ClaimsPrincipal user) + { + var userId = user.UserRealId(); + + if (userId != null) + { + var blogposts = repository.GetAll(); + Payload> payload = new Payload>(); + payload.data = new List(); + + foreach (var blogpost in blogposts) + { + payload.data.Add(new BlogPostLinkDTO() + { + id = blogpost.id, + text = blogpost.text, + authorId = blogpost.authorId + }); + } + payload.status = "success"; + return TypedResults.Ok(payload); + } + else + { + return Results.Unauthorized(); + } + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task GetById(IRepository repo, int id) + { + return TypedResults.Ok(repo.GetById(id)); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task AddBlogPost(BlogPostDTO request, IRepository repository, ClaimsPrincipal user) + { + var userId = user.UserRealId(); + if (userId != null) + { + var blogpost = new BlogPost() + { + text = request.Text, + authorId = userId.Value + }; + repository.Insert(blogpost); + repository.Save(); + return Results.Ok(new Payload() { data = "Created BlogPost" }); + } + else + { + return Results.Unauthorized(); + } + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + + private static async Task UpdateBlogPost(BlogPostDTO request, IRepository repository, int id, ClaimsPrincipal user) + { + var userId = user.UserRealId(); + if (userId != null) + { + var blogpost = repository.GetById(id); + if (blogpost == null) + { + return Results.NotFound(); + } + if (blogpost.authorId != userId.Value) + { + return Results.Unauthorized(); + } + blogpost.text = request.Text; + repository.Update(blogpost); + repository.Save(); + return Results.Ok(new Payload() { data = "Updated BlogPost" }); + } + else + { + return Results.Unauthorized(); + } + } + } +} diff --git a/exercise.wwwapi/Helpers/ClaimsPrincipalHelper.cs b/exercise.wwwapi/Helpers/ClaimsPrincipalHelper.cs new file mode 100644 index 0000000..cf25295 --- /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; + } + + } +} \ No newline at end of file diff --git a/exercise.wwwapi/Helpers/Payload.cs b/exercise.wwwapi/Helpers/Payload.cs new file mode 100644 index 0000000..aabf54d --- /dev/null +++ b/exercise.wwwapi/Helpers/Payload.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace exercise.wwwapi.Helpers +{ + [NotMapped] + public class Payload where T : class + { + public string status { get; set; } = "Success"; + public T data { get; set; } + } +} diff --git a/exercise.wwwapi/Models/BlogPost.cs b/exercise.wwwapi/Models/BlogPost.cs new file mode 100644 index 0000000..fa02c65 --- /dev/null +++ b/exercise.wwwapi/Models/BlogPost.cs @@ -0,0 +1,18 @@ +namespace exercise.wwwapi.Models +{ + public class BlogPost + { + public int id { get; set; } + public string text { get; set; } + public int authorId { get; set; } + public User Author { get; set; } + + public void Update(BlogPost post) + { + if (post.text != null) + { + text = post.text; + } + } + } +} diff --git a/exercise.wwwapi/Models/User.cs b/exercise.wwwapi/Models/User.cs new file mode 100644 index 0000000..603ff65 --- /dev/null +++ b/exercise.wwwapi/Models/User.cs @@ -0,0 +1,13 @@ +namespace exercise.wwwapi.Models +{ + public class User + { + public int Id { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string Password { get; set; } + + public ICollection Blogs { get; set; } + + } +} diff --git a/exercise.wwwapi/Program.cs b/exercise.wwwapi/Program.cs index 47f22ef..6868c35 100644 --- a/exercise.wwwapi/Program.cs +++ b/exercise.wwwapi/Program.cs @@ -1,6 +1,103 @@ +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.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 = "Nigel", + Email = "nigel@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 +109,29 @@ { 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.ConfigureBlogPostEndpoint(); app.Run(); \ No newline at end of file diff --git a/exercise.wwwapi/Repository/IRepository.cs b/exercise.wwwapi/Repository/IRepository.cs new file mode 100644 index 0000000..07c9085 --- /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; } + + } +} \ No newline at end of file diff --git a/exercise.wwwapi/Repository/Repository.cs b/exercise.wwwapi/Repository/Repository.cs new file mode 100644 index 0000000..6651831 --- /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; } } + + } +} \ No newline at end of file diff --git a/exercise.wwwapi/exercise.wwwapi.csproj b/exercise.wwwapi/exercise.wwwapi.csproj index 56929a8..6147ded 100644 --- a/exercise.wwwapi/exercise.wwwapi.csproj +++ b/exercise.wwwapi/exercise.wwwapi.csproj @@ -8,7 +8,26 @@ + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + +