From 3c9895d11cbc6f5e2250d875d7cc030b7551aa99 Mon Sep 17 00:00:00 2001 From: Kristian Sylte Date: Fri, 31 Jan 2025 13:06:43 +0100 Subject: [PATCH 1/2] Non functional code --- .../Configuration/ConfigurationSettings.cs | 15 +++ .../Configuration/IConfigurationSettings.cs | 7 ++ exercise.wwwapi/Data/DataContext.cs | 38 ++++++ exercise.wwwapi/EndPoints/AuthApi.cs | 113 ++++++++++++++++++ exercise.wwwapi/EndPoints/SecureApi.cs | 85 +++++++++++++ .../Helpers/ClaimsPrincipalHelper.cs | 35 ++++++ exercise.wwwapi/Models/BlogPost.cs | 18 +++ exercise.wwwapi/Models/Payload.cs | 13 ++ exercise.wwwapi/Models/User.cs | 22 ++++ exercise.wwwapi/Models/UserFollow.cs | 12 ++ exercise.wwwapi/Models/UserRequestDto.cs | 12 ++ exercise.wwwapi/Models/UserResponseDto.cs | 11 ++ exercise.wwwapi/Program.cs | 109 ++++++++++++++++- exercise.wwwapi/Repository/IRepository.cs | 18 +++ exercise.wwwapi/Repository/Repository.cs | 67 +++++++++++ exercise.wwwapi/app.http | 0 exercise.wwwapi/appsettings.example.json | 15 --- exercise.wwwapi/exercise.wwwapi.csproj | 17 +++ exercise.wwwapi/utils/AutoMapper.cs | 12 ++ 19 files changed, 603 insertions(+), 16 deletions(-) create mode 100644 exercise.wwwapi/Configuration/ConfigurationSettings.cs create mode 100644 exercise.wwwapi/Configuration/IConfigurationSettings.cs create mode 100644 exercise.wwwapi/Data/DataContext.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/BlogPost.cs create mode 100644 exercise.wwwapi/Models/Payload.cs create mode 100644 exercise.wwwapi/Models/User.cs create mode 100644 exercise.wwwapi/Models/UserFollow.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 create mode 100644 exercise.wwwapi/app.http delete mode 100644 exercise.wwwapi/appsettings.example.json create mode 100644 exercise.wwwapi/utils/AutoMapper.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/Data/DataContext.cs b/exercise.wwwapi/Data/DataContext.cs new file mode 100644 index 0000000..97f4f10 --- /dev/null +++ b/exercise.wwwapi/Data/DataContext.cs @@ -0,0 +1,38 @@ +using System.Numerics; +using exercise.wwwapi.Configuration; +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(); + this.Database.SetConnectionString(connectionString); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseLazyLoadingProxies().UseNpgsql(connectionString); + } + + protected override void OnModelCreating(ModelBuilder m) + { + m.Entity().HasKey(c => c.Id); + m.Entity().HasMany(c => c.Posts).WithOne().HasForeignKey(t => t.AuthorId); + + m.Entity().HasKey(c => c.Id); + } + + public DbSet Blogs { get; set; } + public DbSet Users { get; set; } +} diff --git a/exercise.wwwapi/EndPoints/AuthApi.cs b/exercise.wwwapi/EndPoints/AuthApi.cs new file mode 100644 index 0000000..183ad56 --- /dev/null +++ b/exercise.wwwapi/EndPoints/AuthApi.cs @@ -0,0 +1,113 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using exercise.wwwapi.Configuration; +using exercise.wwwapi.Models; +using exercise.wwwapi.Repository; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; + +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).RequireAuthorization(); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task GetUsers(IRepository repo, ClaimsPrincipal user) + { + return TypedResults.Ok(await repo.GetAll()); + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + private static async Task Register(UserRequestDto request, IRepository repo) + { + //user exists + if (await UserExists(repo, request.Username)) + return Results.Conflict( + new Payload() + { + status = "Username already exists!", + data = request, + } + ); + + string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password); + + var user = new User() + { + Username = request.Username, + PasswordHash = passwordHash, + Email = request.Email, + }; + + await repo.Insert(user); + + return Results.Ok(new Payload() { data = "Created Account" }); + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + private static async Task Login( + UserRequestDto request, + IRepository repo, + IConfigurationSettings config + ) + { + User? user = await GetByUsername(repo, request.Username); + if (user == null) + return Results.BadRequest( + new Payload() { status = "User does not exist", data = request } + ); + + 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(5), + signingCredentials: credentials + ); + var jwt = new JwtSecurityTokenHandler().WriteToken(token); + return jwt; + } + + private static async Task UserExists(IRepository repo, string username) + { + return (await repo.GetBy(user => user.Username == username)) != null; + } + + private static async Task GetByUsername(IRepository repo, string username) + { + return await repo.GetBy(user => user.Username == username); + } +} diff --git a/exercise.wwwapi/EndPoints/SecureApi.cs b/exercise.wwwapi/EndPoints/SecureApi.cs new file mode 100644 index 0000000..b8ad768 --- /dev/null +++ b/exercise.wwwapi/EndPoints/SecureApi.cs @@ -0,0 +1,85 @@ +using System.Security.Claims; +using AutoMapper; +using exercise.wwwapi.Helpers; +using exercise.wwwapi.Models; +using exercise.wwwapi.Repository; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace exercise.wwwapi.EndPoints; + +public static class SecureApi +{ + public static void ConfigureSecureApi(this WebApplication app) + { + app.MapPost("/posts", CreatePost); + app.MapPut("/posts", UpdatePost); + app.MapGet("/posts", GetPosts); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task>, UnauthorizedHttpResult>> GetPosts( + IRepository userRepo, + IRepository blogRepo, + ClaimsPrincipal user + ) + { + return TypedResults.Ok(await blogRepo.GetAll()); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task< + Results, UnauthorizedHttpResult, BadRequest> + > CreatePost( + IRepository userRepo, + IRepository blogRepo, + ClaimsPrincipal user, + BlogPost blogpost + ) + { + var id = user.UserRealId(); + if (id == null) + { + return TypedResults.BadRequest("Error with user id"); + } + + var post = BlogPost.Create(id ?? 0, blogpost.Text); + var result = await blogRepo.Insert(blogpost); + if (result == null) + { + return TypedResults.BadRequest("Something went wrong :)"); + } + return TypedResults.Ok(result); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task< + Results, UnauthorizedHttpResult, BadRequest> + > UpdatePost( + IRepository userRepo, + IRepository blogRepo, + IMapper mapper, + ClaimsPrincipal user, + int blogId, + BlogPostPost updated + ) + { + if (blogId != user.UserRealId()) + { + return TypedResults.Unauthorized(); + } + var result = await blogRepo.Update(mapper, blogId, updated); + if (result == null) + { + return TypedResults.BadRequest("Try again with a better payload please"); + } + return TypedResults.Ok(result); + } +} diff --git a/exercise.wwwapi/Helpers/ClaimsPrincipalHelper.cs b/exercise.wwwapi/Helpers/ClaimsPrincipalHelper.cs new file mode 100644 index 0000000..0fd362a --- /dev/null +++ b/exercise.wwwapi/Helpers/ClaimsPrincipalHelper.cs @@ -0,0 +1,35 @@ +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); + if (claim == null) + return null; + 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/BlogPost.cs b/exercise.wwwapi/Models/BlogPost.cs new file mode 100644 index 0000000..5faaa55 --- /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 required string Text { get; set; } + public required int AuthorId { get; set; } + + public static BlogPost Create(int authorId, string text) + { + return new BlogPost { Text = text, AuthorId = authorId }; + } +} + +public class BlogPostPost +{ + public required string Text { get; set; } +} diff --git a/exercise.wwwapi/Models/Payload.cs b/exercise.wwwapi/Models/Payload.cs new file mode 100644 index 0000000..e0ad1fa --- /dev/null +++ b/exercise.wwwapi/Models/Payload.cs @@ -0,0 +1,13 @@ +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 required T data { get; set; } + } +} diff --git a/exercise.wwwapi/Models/User.cs b/exercise.wwwapi/Models/User.cs new file mode 100644 index 0000000..39c1dd9 --- /dev/null +++ b/exercise.wwwapi/Models/User.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace exercise.wwwapi.Models; + +[Table("users")] +public class User +{ + [Column("id")] + public int Id { get; set; } + + [Column("username")] + public required string Username { get; set; } + + [Column("passwordhash")] + public required string PasswordHash { get; set; } + + [Column("email")] + public required string Email { get; set; } + + [NotMapped] + public virtual IEnumerable? Posts { get; set; } +} diff --git a/exercise.wwwapi/Models/UserFollow.cs b/exercise.wwwapi/Models/UserFollow.cs new file mode 100644 index 0000000..962812f --- /dev/null +++ b/exercise.wwwapi/Models/UserFollow.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace exercise.wwwapi.Models; + +public class UserFollow +{ + public int id { get; set; } + public required int FollowerId { get; set; } + public virtual User? Follower { get; set; } + public required int FolloweeId { get; set; } + public virtual User? Followee { 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..a88e245 --- /dev/null +++ b/exercise.wwwapi/Models/UserResponseDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace exercise.wwwapi.Models; + +[NotMapped] +public class UserResponseDto +{ + public required string Username { get; set; } + public required string PasswordHash { get; set; } + public required string Email { get; set; } +} diff --git a/exercise.wwwapi/Program.cs b/exercise.wwwapi/Program.cs index 47f22ef..6e1585d 100644 --- a/exercise.wwwapi/Program.cs +++ b/exercise.wwwapi/Program.cs @@ -1,9 +1,95 @@ +using System.Diagnostics; +using System.Text; +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.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using Scalar.AspNetCore; + 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>(); +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.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(); +builder.Services.AddScoped, Repository>(); var app = builder.Build(); @@ -12,9 +98,30 @@ { 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.Run(); \ No newline at end of file +app.Run(); diff --git a/exercise.wwwapi/Repository/IRepository.cs b/exercise.wwwapi/Repository/IRepository.cs new file mode 100644 index 0000000..f5de614 --- /dev/null +++ b/exercise.wwwapi/Repository/IRepository.cs @@ -0,0 +1,18 @@ +using System.Linq.Expressions; +using AutoMapper; +using Microsoft.EntityFrameworkCore; + +namespace exercise.wwwapi.Repository +{ + public interface IRepository + where T : class + { + Task> GetAll(); + Task GetById(object id); + Task Insert(T obj); + Task Update(IMapper mapper, object id, U source); + Task Delete(object id); + Task GetBy(Expression> pred); + Task Exists(object id); + } +} diff --git a/exercise.wwwapi/Repository/Repository.cs b/exercise.wwwapi/Repository/Repository.cs new file mode 100644 index 0000000..97c1323 --- /dev/null +++ b/exercise.wwwapi/Repository/Repository.cs @@ -0,0 +1,67 @@ +using System.Linq.Expressions; +using AutoMapper; +using exercise.wwwapi.Data; +using Microsoft.EntityFrameworkCore; + +namespace exercise.wwwapi.Repository +{ + public class Repository : IRepository + where T : class + { + private DataContext db; + private DbSet table; + + public Repository(DataContext db) + { + this.db = db; + table = db.Set(); + } + + public async Task> GetAll() + { + return await table.ToListAsync(); + } + + public async Task GetById(object id) + { + return await table.FindAsync(id); + } + + public async Task Insert(T obj) + { + var result = table.Add(obj); + await db.SaveChangesAsync(); + return result.Entity; + } + + public async Task Update(IMapper mapper, object id, U source) + { + var dest = await GetById(id); + if (dest == null) + return null; + mapper.Map(source, dest); + await db.SaveChangesAsync(); + return dest; + } + + public async Task Delete(object id) + { + var entity = await GetById(id); + if (entity == null) + return null; + db.Remove(entity); + await db.SaveChangesAsync(); + return entity; + } + + public async Task Exists(object id) + { + return (await GetById(id)) != null; + } + + public async Task GetBy(Expression> pred) + { + return await table.FirstOrDefaultAsync(pred); + } + } +} diff --git a/exercise.wwwapi/app.http b/exercise.wwwapi/app.http new file mode 100644 index 0000000..e69de29 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..239cd64 100644 --- a/exercise.wwwapi/exercise.wwwapi.csproj +++ b/exercise.wwwapi/exercise.wwwapi.csproj @@ -8,8 +8,25 @@ + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/exercise.wwwapi/utils/AutoMapper.cs b/exercise.wwwapi/utils/AutoMapper.cs new file mode 100644 index 0000000..55fbd04 --- /dev/null +++ b/exercise.wwwapi/utils/AutoMapper.cs @@ -0,0 +1,12 @@ +using AutoMapper; +using exercise.wwwapi.Models; + +namespace exercise.wwwapi.utils; + +public class Mapper : Profile +{ + public Mapper() + { + CreateMap(); + } +} From f41e8948493b4583d5a9b5c6f0e5a783e616fa8c Mon Sep 17 00:00:00 2001 From: Kristian Sylte Date: Mon, 3 Feb 2025 17:30:35 +0100 Subject: [PATCH 2/2] Core + extension 1 --- exercise.wwwapi/Data/DataContext.cs | 10 +++ exercise.wwwapi/EndPoints/AuthApi.cs | 2 +- exercise.wwwapi/EndPoints/SecureApi.cs | 101 ++++++++++++++++++++++++- exercise.wwwapi/Models/User.cs | 6 ++ exercise.wwwapi/Models/UserFollow.cs | 5 ++ exercise.wwwapi/Program.cs | 12 ++- 6 files changed, 131 insertions(+), 5 deletions(-) diff --git a/exercise.wwwapi/Data/DataContext.cs b/exercise.wwwapi/Data/DataContext.cs index 97f4f10..08677ac 100644 --- a/exercise.wwwapi/Data/DataContext.cs +++ b/exercise.wwwapi/Data/DataContext.cs @@ -29,10 +29,20 @@ protected override void OnModelCreating(ModelBuilder m) { m.Entity().HasKey(c => c.Id); m.Entity().HasMany(c => c.Posts).WithOne().HasForeignKey(t => t.AuthorId); + m.Entity() + .HasMany(u => u.Following) + .WithOne(f => f.Follower) + .HasForeignKey(f => f.FollowerId); + + m.Entity() + .HasMany(u => u.FollowedBy) + .WithOne(f => f.Followee) + .HasForeignKey(f => f.FolloweeId); m.Entity().HasKey(c => c.Id); } public DbSet Blogs { get; set; } public DbSet Users { get; set; } + public DbSet UserFollows { get; set; } } diff --git a/exercise.wwwapi/EndPoints/AuthApi.cs b/exercise.wwwapi/EndPoints/AuthApi.cs index 183ad56..a9dd19a 100644 --- a/exercise.wwwapi/EndPoints/AuthApi.cs +++ b/exercise.wwwapi/EndPoints/AuthApi.cs @@ -94,7 +94,7 @@ private static string CreateToken(User user, IConfigurationSettings config) var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha512Signature); var token = new JwtSecurityToken( claims: claims, - expires: DateTime.Now.AddDays(5), + expires: DateTime.Now.AddDays(1), signingCredentials: credentials ); var jwt = new JwtSecurityTokenHandler().WriteToken(token); diff --git a/exercise.wwwapi/EndPoints/SecureApi.cs b/exercise.wwwapi/EndPoints/SecureApi.cs index b8ad768..cef49e8 100644 --- a/exercise.wwwapi/EndPoints/SecureApi.cs +++ b/exercise.wwwapi/EndPoints/SecureApi.cs @@ -16,6 +16,9 @@ public static void ConfigureSecureApi(this WebApplication app) app.MapPost("/posts", CreatePost); app.MapPut("/posts", UpdatePost); app.MapGet("/posts", GetPosts); + app.MapPost("/users/follow/{userId}", FollowUser); + app.MapPost("/users/unfollow/{userId}", UnFollowUser); + app.MapGet("/posts/following", GetFollowingPosts); } [Authorize] @@ -39,7 +42,7 @@ private static async Task< IRepository userRepo, IRepository blogRepo, ClaimsPrincipal user, - BlogPost blogpost + BlogPostPost blogpost ) { var id = user.UserRealId(); @@ -49,7 +52,7 @@ BlogPost blogpost } var post = BlogPost.Create(id ?? 0, blogpost.Text); - var result = await blogRepo.Insert(blogpost); + var result = await blogRepo.Insert(post); if (result == null) { return TypedResults.BadRequest("Something went wrong :)"); @@ -71,7 +74,10 @@ private static async Task< BlogPostPost updated ) { - if (blogId != user.UserRealId()) + var source = await blogRepo.GetById(blogId); + if (source == null) + return TypedResults.BadRequest("No blog with given id"); + if (source.AuthorId != user.UserRealId()) { return TypedResults.Unauthorized(); } @@ -82,4 +88,93 @@ BlogPostPost updated } return TypedResults.Ok(result); } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + private static async Task< + Results, UnauthorizedHttpResult, BadRequest> + > FollowUser( + IRepository followRepo, + IRepository userRepo, + ClaimsPrincipal user, + int followUserId + ) + { + var id = user.UserRealId(); + if (id == null) + { + return TypedResults.BadRequest("You don't exist?"); + } + if (!await userRepo.Exists(followUserId)) + { + return TypedResults.BadRequest("User to follow does not exist"); + } + var followExists = await followRepo.GetBy(f => + f.FolloweeId == followUserId && f.FollowerId == id + ); + if (followExists != null) + { + return TypedResults.BadRequest("Already following"); + } + var follow = new UserFollow + { + FollowerId = user.UserRealId() ?? 0, + FolloweeId = followUserId, + }; + var result = await followRepo.Insert(follow); + return TypedResults.Ok(result); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + private static async Task< + Results, UnauthorizedHttpResult, BadRequest> + > UnFollowUser( + IRepository followRepo, + IRepository userRepo, + ClaimsPrincipal user, + int followUserId + ) + { + var id = user.UserRealId(); + if (id == null) + { + return TypedResults.BadRequest("You don't exist?"); + } + var follow = await followRepo.GetBy(f => + f.FolloweeId == followUserId && f.FollowerId == id + ); + if (follow == null) + { + return TypedResults.BadRequest("You don't follow this user"); + } + var result = await followRepo.Delete(follow.id); + return TypedResults.Ok(result); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + private static async Task< + Results>, UnauthorizedHttpResult, BadRequest> + > GetFollowingPosts( + IRepository followRepo, + IRepository userRepo, + ClaimsPrincipal user + ) + { + var id = user.UserRealId(); + if (id == null) + { + return TypedResults.BadRequest("You don't exist?"); + } + var following = (await followRepo.GetAll()).Where(f => f.FollowerId == id); + var a = following.Select(f => f.Followee).SelectMany(p => p!.Posts!); + return TypedResults.Ok(a); + } } diff --git a/exercise.wwwapi/Models/User.cs b/exercise.wwwapi/Models/User.cs index 39c1dd9..518b510 100644 --- a/exercise.wwwapi/Models/User.cs +++ b/exercise.wwwapi/Models/User.cs @@ -19,4 +19,10 @@ public class User [NotMapped] public virtual IEnumerable? Posts { get; set; } + + [NotMapped] + public virtual IEnumerable? Following { get; set; } + + [NotMapped] + public virtual IEnumerable? FollowedBy { get; set; } } diff --git a/exercise.wwwapi/Models/UserFollow.cs b/exercise.wwwapi/Models/UserFollow.cs index 962812f..2a70f40 100644 --- a/exercise.wwwapi/Models/UserFollow.cs +++ b/exercise.wwwapi/Models/UserFollow.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; namespace exercise.wwwapi.Models; @@ -6,7 +7,11 @@ public class UserFollow { public int id { get; set; } public required int FollowerId { get; set; } + + [JsonIgnore] public virtual User? Follower { get; set; } public required int FolloweeId { get; set; } + + [JsonIgnore] public virtual User? Followee { get; set; } } diff --git a/exercise.wwwapi/Program.cs b/exercise.wwwapi/Program.cs index 6e1585d..364e0db 100644 --- a/exercise.wwwapi/Program.cs +++ b/exercise.wwwapi/Program.cs @@ -19,6 +19,7 @@ // 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 => @@ -48,6 +49,14 @@ ValidateLifetime = false, ValidateIssuerSigningKey = false, }; + x.Events = new JwtBearerEvents() + { + OnAuthenticationFailed = context => + { + var err = context.Exception.ToString(); + return context.Response.WriteAsync(err); + }, + }; }); builder.Services.AddSwaggerGen(s => @@ -90,6 +99,7 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddScoped, Repository>(); +builder.Services.AddCors(); var app = builder.Build(); @@ -118,7 +128,7 @@ app.UseAuthorization(); -app.MapControllers(); +//app.MapControllers(); app.ConfigureAuthApi();