diff --git a/exercise.wwwapi/Configuration/ConfigurationSettings.cs b/exercise.wwwapi/Configuration/ConfigurationSettings.cs new file mode 100644 index 0000000..014e4ba --- /dev/null +++ b/exercise.wwwapi/Configuration/ConfigurationSettings.cs @@ -0,0 +1,22 @@ + + +using Microsoft.Extensions.Configuration; + +namespace exercise.wwwapi.Configuration +{ + public class ConfigurationSettings : IConfigurationSettings + { + private readonly 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..687e06b --- /dev/null +++ b/exercise.wwwapi/Configuration/IConfigurationSettings.cs @@ -0,0 +1,9 @@ +namespace exercise.wwwapi.Configuration +{ + public interface IConfigurationSettings + { + string GetValue(string key); + + + } +} 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/UserDTO.cs b/exercise.wwwapi/DTO/UserDTO.cs new file mode 100644 index 0000000..9d41803 --- /dev/null +++ b/exercise.wwwapi/DTO/UserDTO.cs @@ -0,0 +1,11 @@ +namespace exercise.wwwapi.DTO +{ + public class UserDTO + { + public int Id { get; set; } + public string Username { get; set; } + public string PasswordHash { get; set; } + public string Email { get; set; } + + } +} diff --git a/exercise.wwwapi/DTO/UserRequestDTO.cs b/exercise.wwwapi/DTO/UserRequestDTO.cs new file mode 100644 index 0000000..d7542f4 --- /dev/null +++ b/exercise.wwwapi/DTO/UserRequestDTO.cs @@ -0,0 +1,10 @@ +namespace exercise.wwwapi.DTO +{ + 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/DTO/UserResponseDTO.cs b/exercise.wwwapi/DTO/UserResponseDTO.cs new file mode 100644 index 0000000..1fdade0 --- /dev/null +++ b/exercise.wwwapi/DTO/UserResponseDTO.cs @@ -0,0 +1,9 @@ +namespace exercise.wwwapi.DTO +{ + public class UserResponseDTO + { + public string Username { get; set; } + public string PasswordHash { get; set; } + public string Email { get; set; } + } +} diff --git a/exercise.wwwapi/Data/DataContext.cs b/exercise.wwwapi/Data/DataContext.cs new file mode 100644 index 0000000..4b9d388 --- /dev/null +++ b/exercise.wwwapi/Data/DataContext.cs @@ -0,0 +1,34 @@ +using exercise.wwwapi.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using System.Diagnostics; + + + +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); + optionsBuilder.LogTo(message => Debug.WriteLine(message)); + } + + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + + } + 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..d09bde7 --- /dev/null +++ b/exercise.wwwapi/Endpoints/AuthApi.cs @@ -0,0 +1,78 @@ +using exercise.wwwapi.Configuration; +using exercise.wwwapi.DTO; +using exercise.wwwapi.Helper; +using exercise.wwwapi.Helpers; +using exercise.wwwapi.Models; +using exercise.wwwapi.Repository; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using System.Linq; + + +namespace exercise.wwwapi.Endpoints +{ + public static class AuthApi + { + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public static async Task Register(UserRequestDTO request, IRepository repository) + { + var users= await repository.GetAll(); + if (users.Any(u => u.Username == 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 + }; + + repository.Insert(user); + + return Results.Ok(new Payload { data = "Created Account" }); + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public static async Task Login(UserRequestDTO request, IRepository repository, IConfigurationSettings config) + { + var users = await repository.GetAll(); + User? user = users.FirstOrDefault(u => u.Username == 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(1), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + } +} + diff --git a/exercise.wwwapi/Endpoints/SecureApi.cs b/exercise.wwwapi/Endpoints/SecureApi.cs new file mode 100644 index 0000000..0abdcbb --- /dev/null +++ b/exercise.wwwapi/Endpoints/SecureApi.cs @@ -0,0 +1,106 @@ +using exercise.wwwapi.DTO; +using exercise.wwwapi.Helper; +using exercise.wwwapi.Helpers; +using exercise.wwwapi.Models; +using exercise.wwwapi.Repository; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; + +namespace exercise.wwwapi.Endpoints +{ + public static class SecureApi + { + public static void ConfigureUserEndpoint(this WebApplication app) + { + var userGroup = app.MapGroup("users"); + userGroup.MapGet("/", GetUsers); + + var postGroup = app.MapGroup("posts"); + postGroup.MapGet("/", GetPosts); + postGroup.MapPost("/", CreatePost); + postGroup.MapPut("/{id}", UpdatePost); + + app.MapPost("/register", AuthApi.Register); + app.MapPost("/login", AuthApi.Login); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task GetUsers(IRepository repository, ClaimsPrincipal user) + { + var users = await repository.GetAll(); + return TypedResults.Ok(users); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task GetPosts(IRepository repository, ClaimsPrincipal user) + { + var posts = await repository.GetAll(); + return TypedResults.Ok(posts); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + private static async Task CreatePost(IRepository postRepo, IRepository userRepo, BlogPostDTO postDTO, ClaimsPrincipal user) + { + int? userId = user.UserRealId(); + + var users = await userRepo.GetAll(); + User? author = users.FirstOrDefault(u => u.Id == userId); + + if (author == null) + return Results.BadRequest(new Payload { status = "User not found" }); + + BlogPost post = new BlogPost + { + Text = postDTO.Text, + AuthorId = userId.Value, + Author = author + }; + + postRepo.Insert(post); + + return TypedResults.Ok(new Payload { data = "Post Created" }); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task UpdatePost( + IRepository postRepo, + IRepository userRepo, + BlogPostDTO postDTO, + ClaimsPrincipal user, + int id) + { + int? userId = user.UserRealId(); + if (userId == null) + return Results.Unauthorized(); + + // Hent posten fra databasen (bruk AsNoTracking for å unngå problemer med ChangeTracker) + BlogPost? existingPost = (await postRepo.GetAll()).FirstOrDefault(p => p.Id == id); + + if (existingPost == null) + return Results.NotFound(new Payload { status = "Post not found" }); + + if (existingPost.AuthorId != userId) + return Results.Forbid(); // HTTP 403 hvis brukeren ikke er eieren av innlegget + + existingPost.Text = postDTO.Text; + + // Bruk den oppdaterte Update-metoden som er async + await postRepo.Update(existingPost); + + return TypedResults.Ok(new Payload { data = "Post Updated Successfully" }); + } + } + + } + diff --git a/exercise.wwwapi/Helper/ClaimsPrincipalHelper.cs b/exercise.wwwapi/Helper/ClaimsPrincipalHelper.cs new file mode 100644 index 0000000..17caa44 --- /dev/null +++ b/exercise.wwwapi/Helper/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); + 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/Helper/Payload.cs b/exercise.wwwapi/Helper/Payload.cs new file mode 100644 index 0000000..2560bc2 --- /dev/null +++ b/exercise.wwwapi/Helper/Payload.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace exercise.wwwapi.Helper +{ + [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..431bdb8 --- /dev/null +++ b/exercise.wwwapi/Models/BlogPost.cs @@ -0,0 +1,20 @@ +namespace exercise.wwwapi.Models +{ + using System.ComponentModel.DataAnnotations; + using System.ComponentModel.DataAnnotations.Schema; + + public class BlogPost + { + + public int Id { get; set; } + + + public string Text { get; set; } + + + public int AuthorId { get; set; } + + public User Author { get; set; } + } +} + diff --git a/exercise.wwwapi/Models/User.cs b/exercise.wwwapi/Models/User.cs new file mode 100644 index 0000000..b9f949a --- /dev/null +++ b/exercise.wwwapi/Models/User.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace exercise.wwwapi.Models +{ + [Table("users")] + public class User + { + [Column("id")] + public int Id { get; set; } // Endret fra int til Guid + + [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/Program.cs b/exercise.wwwapi/Program.cs index 47f22ef..23c15b6 100644 --- a/exercise.wwwapi/Program.cs +++ b/exercise.wwwapi/Program.cs @@ -1,20 +1,120 @@ + +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.Hosting; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Diagnostics; +using System.Text; + var builder = WebApplication.CreateBuilder(args); -// Add services to the container. -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -var app = builder.Build(); -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) +builder.Services.Configure(builder.Configuration); + + +builder.Services.AddDbContext(options => { + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnectionString")); + options.LogTo(message => Debug.WriteLine(message)); +}); + +builder.Services.AddScoped(); +builder.Services.AddScoped, Repository>(); +builder.Services.AddScoped, Repository>(); + +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(builder.Configuration["AppSettings:Token"] ?? "DefaultSecretKey")), + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + ValidateIssuerSigningKey = true + }; +}); + +builder.Services.AddCors(); + +builder.Services.AddSwaggerGen(s => { - app.UseSwagger(); - app.UseSwaggerUI(); -} + 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(); + +var app = builder.Build(); + + +app.UseSwagger(); +app.UseSwaggerUI(); + +app.UseCors(x => x + .AllowAnyMethod() + .AllowAnyHeader() + .SetIsOriginAllowed(origin => true) + .AllowCredentials()); app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); +app.ConfigureUserEndpoint(); -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..ba6b8bd --- /dev/null +++ b/exercise.wwwapi/Repository/IRepository.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; + +namespace exercise.wwwapi.Repository +{ + public interface IRepository where T : class + { + Task> GetAll(); + Task GetById(object id); + Task Insert(T obj); + Task Update(T obj); + + Task Delete(object id); + DbSet Table { get; } + + } +} + + diff --git a/exercise.wwwapi/Repository/Repository.cs b/exercise.wwwapi/Repository/Repository.cs new file mode 100644 index 0000000..38a2264 --- /dev/null +++ b/exercise.wwwapi/Repository/Repository.cs @@ -0,0 +1,59 @@ +using exercise.wwwapi.Data; +using exercise.wwwapi.Models; +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 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 = await _table.AddAsync(obj); + await _db.SaveChangesAsync(); + return result.Entity; + } + public async Task Update(T obj) + { + _db.Set().Update(obj); + await _db.SaveChangesAsync(); + } + + + + + public async Task Delete(object id) + { + T existing = await _table.FindAsync(id); + _table.Remove(existing); + await _db.SaveChangesAsync(); + return existing; + } + + + 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..67eea83 100644 --- a/exercise.wwwapi/exercise.wwwapi.csproj +++ b/exercise.wwwapi/exercise.wwwapi.csproj @@ -8,8 +8,20 @@ + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + +