From 2b521924d0f37d3ec4af89ea27a1faaa34b12c01 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sat, 12 Dec 2020 20:14:56 -0600 Subject: [PATCH 1/5] Setup basic code for login. --- API/Controllers/AccountController.cs | 82 +++++++++++++++++++ API/Controllers/BaseApiController.cs | 13 +++ API/DTOs/LoginDto.cs | 8 ++ API/DTOs/RegisterDto.cs | 13 +++ API/DTOs/UserDto.cs | 8 ++ API/Data/DataContext.cs | 15 ++++ API/Entities/AppUser.cs | 28 +++++++ .../Interfaces/IHasConcurrencyToken.cs | 19 +++++ API/Errors/ApiException.cs | 16 ++++ .../ApplicationServiceExtensions.cs | 23 ++++++ API/Extensions/IdentityServiceExtensions.cs | 28 +++++++ API/Interfaces/ITokenService.cs | 9 ++ API/Middleware/ExceptionMiddleware.cs | 55 +++++++++++++ API/Services/TokenService.cs | 46 +++++++++++ API/Startup.cs | 24 ++++-- 15 files changed, 381 insertions(+), 6 deletions(-) create mode 100644 API/Controllers/AccountController.cs create mode 100644 API/Controllers/BaseApiController.cs create mode 100644 API/DTOs/LoginDto.cs create mode 100644 API/DTOs/RegisterDto.cs create mode 100644 API/DTOs/UserDto.cs create mode 100644 API/Data/DataContext.cs create mode 100644 API/Entities/AppUser.cs create mode 100644 API/Entities/Interfaces/IHasConcurrencyToken.cs create mode 100644 API/Errors/ApiException.cs create mode 100644 API/Extensions/ApplicationServiceExtensions.cs create mode 100644 API/Extensions/IdentityServiceExtensions.cs create mode 100644 API/Interfaces/ITokenService.cs create mode 100644 API/Middleware/ExceptionMiddleware.cs create mode 100644 API/Services/TokenService.cs diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs new file mode 100644 index 000000000..19903e2f0 --- /dev/null +++ b/API/Controllers/AccountController.cs @@ -0,0 +1,82 @@ +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.Entities; +using API.Interfaces; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Controllers +{ + public class AccountController : BaseApiController + { + private readonly DataContext _context; + private readonly ITokenService _tokenService; + private readonly ILogger _logger; + + public AccountController(DataContext context, ITokenService tokenService, ILogger logger) + { + _context = context; + _tokenService = tokenService; + _logger = logger; + } + + [HttpPost("register")] + public async Task> Register(RegisterDto registerDto) + { + _logger.LogInformation("Username: " + registerDto.Password); + if (await UserExists(registerDto.Username)) + { + return BadRequest("Username is taken."); + } + + using var hmac = new HMACSHA512(); + var user = new AppUser + { + UserName = registerDto.Username.ToLower(), + PasswordHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(registerDto.Password)), + PasswordSalt = hmac.Key + }; + + _context.Users.Add(user); + await _context.SaveChangesAsync(); + + return new UserDto() + { + Username = user.UserName, + Token = _tokenService.CreateToken(user) + }; + } + + [HttpPost("login")] + public async Task> Login(LoginDto loginDto) + { + var user = await _context.Users.SingleOrDefaultAsync(x => x.UserName == loginDto.Username.ToLower()); + + if (user == null) return Unauthorized("Invalid username"); + + using var hmac = new HMACSHA512(user.PasswordSalt); + + var computedHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(loginDto.Password)); + + for (int i = 0; i < computedHash.Length; i++) + { + if (computedHash[i] != user.PasswordHash[i]) return Unauthorized("Invalid password"); + } + + return new UserDto() + { + Username = user.UserName, + Token = _tokenService.CreateToken(user) + }; + } + + private async Task UserExists(string username) + { + return await _context.Users.AnyAsync(user => user.UserName == username.ToLower()); + } + } +} \ No newline at end of file diff --git a/API/Controllers/BaseApiController.cs b/API/Controllers/BaseApiController.cs new file mode 100644 index 000000000..b08265db8 --- /dev/null +++ b/API/Controllers/BaseApiController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class BaseApiController : ControllerBase + { + public BaseApiController() + { + } + } +} \ No newline at end of file diff --git a/API/DTOs/LoginDto.cs b/API/DTOs/LoginDto.cs new file mode 100644 index 000000000..9983415e0 --- /dev/null +++ b/API/DTOs/LoginDto.cs @@ -0,0 +1,8 @@ +namespace API.DTOs +{ + public class LoginDto + { + public string Username { get; set; } + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs new file mode 100644 index 000000000..9110f298d --- /dev/null +++ b/API/DTOs/RegisterDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs +{ + public class RegisterDto + { + [Required] + public string Username { get; set; } + [Required] + [StringLength(8, MinimumLength = 4)] + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs new file mode 100644 index 000000000..15de1b8d9 --- /dev/null +++ b/API/DTOs/UserDto.cs @@ -0,0 +1,8 @@ +namespace API.DTOs +{ + public class UserDto + { + public string Username { get; set; } + public string Token { get; set; } + } +} \ No newline at end of file diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs new file mode 100644 index 000000000..bca4e24be --- /dev/null +++ b/API/Data/DataContext.cs @@ -0,0 +1,15 @@ +using API.Entities; +using Microsoft.EntityFrameworkCore; + +namespace API.Data +{ + public class DataContext : DbContext + { + public DataContext(DbContextOptions options) : base(options) + { + + } + + public DbSet Users { get; set; } + } +} \ No newline at end of file diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs new file mode 100644 index 000000000..3478b74f0 --- /dev/null +++ b/API/Entities/AppUser.cs @@ -0,0 +1,28 @@ +using System; +using System.ComponentModel.DataAnnotations; +using API.Entities.Interfaces; + + +namespace API.Entities +{ + public class AppUser : IHasConcurrencyToken + { + public int Id { get; set; } + public string UserName { get; set; } + public byte[] PasswordHash { get; set; } + public byte[] PasswordSalt { get; set; } + public DateTime Created { get; set; } = DateTime.Now; + public DateTime LastActive { get; set; } + + [ConcurrencyCheck] + public uint RowVersion { get; set; } + + public void OnSavingChanges() + { + RowVersion++; + } + + + + } +} \ No newline at end of file diff --git a/API/Entities/Interfaces/IHasConcurrencyToken.cs b/API/Entities/Interfaces/IHasConcurrencyToken.cs new file mode 100644 index 000000000..9372f1eb7 --- /dev/null +++ b/API/Entities/Interfaces/IHasConcurrencyToken.cs @@ -0,0 +1,19 @@ +namespace API.Entities.Interfaces +{ + /// + /// An interface abstracting an entity that has a concurrency token. + /// + public interface IHasConcurrencyToken + { + /// + /// Gets the version of this row. Acts as a concurrency token. + /// + uint RowVersion { get; } + + /// + /// Called when saving changes to this entity. + /// + void OnSavingChanges(); + + } +} \ No newline at end of file diff --git a/API/Errors/ApiException.cs b/API/Errors/ApiException.cs new file mode 100644 index 000000000..3f026bb64 --- /dev/null +++ b/API/Errors/ApiException.cs @@ -0,0 +1,16 @@ +namespace API.Errors +{ + public class ApiException + { + public int Status { get; set; } + public string Message { get; set; } + public string Details { get; set; } + + public ApiException(int status, string message = null, string details = null) + { + Status = status; + Message = message; + Details = details; + } + } +} \ No newline at end of file diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs new file mode 100644 index 000000000..778eb8c6e --- /dev/null +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -0,0 +1,23 @@ +using API.Data; +using API.Interfaces; +using API.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace API.Extensions +{ + public static class ApplicationServiceExtensions + { + public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration config) + { + services.AddScoped(); + services.AddDbContext(options => + { + options.UseSqlite(config.GetConnectionString("DefaultConnection")); + }); + + return services; + } + } +} \ No newline at end of file diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs new file mode 100644 index 000000000..386efc0f2 --- /dev/null +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -0,0 +1,28 @@ +using System.Collections; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace API.Extensions +{ + public static class IdentityServiceExtensions + { + public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config) + { + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters() + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])), + ValidateIssuer = false, + ValidateAudience = false + }; + }); + return services; + } + } +} \ No newline at end of file diff --git a/API/Interfaces/ITokenService.cs b/API/Interfaces/ITokenService.cs new file mode 100644 index 000000000..e721d9ade --- /dev/null +++ b/API/Interfaces/ITokenService.cs @@ -0,0 +1,9 @@ +using API.Entities; + +namespace API.Interfaces +{ + public interface ITokenService + { + string CreateToken(AppUser user); + } +} \ No newline at end of file diff --git a/API/Middleware/ExceptionMiddleware.cs b/API/Middleware/ExceptionMiddleware.cs new file mode 100644 index 000000000..a946baff9 --- /dev/null +++ b/API/Middleware/ExceptionMiddleware.cs @@ -0,0 +1,55 @@ +using System; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; +using API.Errors; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace API.Middleware +{ + public class ExceptionMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IHostEnvironment _env; + + public ExceptionMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment env) + { + _next = next; + _logger = logger; + _env = env; + } + + public async Task InvokeAsync(HttpContext context) + { + _logger.LogError("The middleware called"); + try + { + await _next(context); // downstream middlewares or http call + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int) HttpStatusCode.InternalServerError; + + var response = _env.IsDevelopment() + ? new ApiException(context.Response.StatusCode, ex.Message, ex.StackTrace?.ToString()) + : new ApiException(context.Response.StatusCode, "Internal Server Error"); + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = + JsonNamingPolicy.CamelCase + }; + + var json = JsonSerializer.Serialize(response, options); + + await context.Response.WriteAsync(json); + + } + } + } +} \ No newline at end of file diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs new file mode 100644 index 000000000..195af02d2 --- /dev/null +++ b/API/Services/TokenService.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using API.Entities; +using API.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; +using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; + + +namespace API.Services +{ + public class TokenService : ITokenService + { + private readonly SymmetricSecurityKey _key; + + public TokenService(IConfiguration config) + { + _key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])); + } + + public string CreateToken(AppUser user) + { + var claims = new List + { + new Claim(JwtRegisteredClaimNames.NameId, user.UserName) + }; + + var creds = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature); + + var tokenDescriptor = new SecurityTokenDescriptor() + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.Now.AddDays(7), + SigningCredentials = creds + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.CreateToken(tokenDescriptor); + + return tokenHandler.WriteToken(token); + } + } +} \ No newline at end of file diff --git a/API/Startup.cs b/API/Startup.cs index 622d31cb2..f05530565 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Extensions; +using API.Middleware; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpsPolicy; @@ -16,18 +18,21 @@ namespace API { public class Startup { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } + private readonly IConfiguration _config; - public IConfiguration Configuration { get; } + public Startup(IConfiguration config) + { + _config = config; + } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { + services.AddApplicationServices(_config); services.AddControllers(); + services.AddCors(); + services.AddIdentityServices(_config); services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" }); @@ -37,9 +42,11 @@ namespace API // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { + app.UseMiddleware(); + if (env.IsDevelopment()) { - app.UseDeveloperExceptionPage(); + //app.UseDeveloperExceptionPage(); app.UseSwagger(); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1")); } @@ -47,6 +54,11 @@ namespace API app.UseHttpsRedirection(); app.UseRouting(); + + // Ordering is important. Cors, authentication, authorization + app.UseCors(policy => policy.AllowAnyHeader().AllowAnyMethod().WithOrigins("https://localhost:4200")); + + app.UseAuthentication(); app.UseAuthorization(); From 5da41ea6f30fee02b6ff5c33d9aeceef6dee5fe7 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 13 Dec 2020 16:07:25 -0600 Subject: [PATCH 2/5] Added User with ability to login and register. By default, user is not an admin. DTO expects an integer and will convert to Boolean. --- .gitignore | 5 +- API/Controllers/AccountController.cs | 6 +- API/Converters/JsonBoolNumberConverter.cs | 30 ++++++++++ API/Converters/JsonBoolStringConverter.cs | 26 +++++++++ API/DTOs/RegisterDto.cs | 4 ++ API/DTOs/UserDto.cs | 1 + .../20201213205325_AddUser.Designer.cs | 56 +++++++++++++++++++ API/Data/Migrations/20201213205325_AddUser.cs | 36 ++++++++++++ .../Migrations/DataContextModelSnapshot.cs | 54 ++++++++++++++++++ API/Entities/AppUser.cs | 5 +- API/Program.cs | 24 +++++++- API/Properties/launchSettings.json | 2 +- 12 files changed, 239 insertions(+), 10 deletions(-) create mode 100644 API/Converters/JsonBoolNumberConverter.cs create mode 100644 API/Converters/JsonBoolStringConverter.cs create mode 100644 API/Data/Migrations/20201213205325_AddUser.Designer.cs create mode 100644 API/Data/Migrations/20201213205325_AddUser.cs create mode 100644 API/Data/Migrations/DataContextModelSnapshot.cs diff --git a/.gitignore b/.gitignore index 0290c0055..75589ba35 100644 --- a/.gitignore +++ b/.gitignore @@ -443,5 +443,6 @@ $RECYCLE.BIN/ # App specific appsettings.json -/API/datingapp.db-shm -/API/datingapp.db-wal \ No newline at end of file +/API/kavita.db +/API/kavita.db-shm +/API/kavita.db-wal \ No newline at end of file diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 19903e2f0..d483617a7 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -38,7 +38,8 @@ namespace API.Controllers { UserName = registerDto.Username.ToLower(), PasswordHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(registerDto.Password)), - PasswordSalt = hmac.Key + PasswordSalt = hmac.Key, + IsAdmin = registerDto.IsAdmin }; _context.Users.Add(user); @@ -47,7 +48,8 @@ namespace API.Controllers return new UserDto() { Username = user.UserName, - Token = _tokenService.CreateToken(user) + Token = _tokenService.CreateToken(user), + IsAdmin = user.IsAdmin }; } diff --git a/API/Converters/JsonBoolNumberConverter.cs b/API/Converters/JsonBoolNumberConverter.cs new file mode 100644 index 000000000..c370fb9fd --- /dev/null +++ b/API/Converters/JsonBoolNumberConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace API.Converters +{ + /// + /// Converts a number to a boolean. + /// This is needed for HDHomerun. + /// + public class JsonBoolNumberConverter : JsonConverter + { + /// + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + { + return Convert.ToBoolean(reader.GetInt32()); + } + + return reader.GetBoolean(); + } + + /// + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } + } +} \ No newline at end of file diff --git a/API/Converters/JsonBoolStringConverter.cs b/API/Converters/JsonBoolStringConverter.cs new file mode 100644 index 000000000..d17a6537a --- /dev/null +++ b/API/Converters/JsonBoolStringConverter.cs @@ -0,0 +1,26 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace API.Converters +{ + public class JsonBoolStringConverter : JsonConverter + { + /// + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return reader.GetString().ToLower() == "true"; + } + + return reader.GetBoolean(); + } + + /// + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } + } +} \ No newline at end of file diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index 9110f298d..34e1a7a60 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -1,4 +1,6 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using API.Converters; namespace API.DTOs { @@ -9,5 +11,7 @@ namespace API.DTOs [Required] [StringLength(8, MinimumLength = 4)] public string Password { get; set; } + [JsonConverter(typeof(JsonBoolNumberConverter))] + public bool IsAdmin { get; set; } } } \ No newline at end of file diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 15de1b8d9..c8b97abb6 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -4,5 +4,6 @@ { public string Username { get; set; } public string Token { get; set; } + public bool IsAdmin { get; set; } } } \ No newline at end of file diff --git a/API/Data/Migrations/20201213205325_AddUser.Designer.cs b/API/Data/Migrations/20201213205325_AddUser.Designer.cs new file mode 100644 index 000000000..565d03517 --- /dev/null +++ b/API/Data/Migrations/20201213205325_AddUser.Designer.cs @@ -0,0 +1,56 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20201213205325_AddUser")] + partial class AddUser + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.1"); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .HasColumnType("BLOB"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20201213205325_AddUser.cs b/API/Data/Migrations/20201213205325_AddUser.cs new file mode 100644 index 000000000..4429111b1 --- /dev/null +++ b/API/Data/Migrations/20201213205325_AddUser.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class AddUser : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserName = table.Column(type: "TEXT", nullable: true), + PasswordHash = table.Column(type: "BLOB", nullable: true), + PasswordSalt = table.Column(type: "BLOB", nullable: true), + Created = table.Column(type: "TEXT", nullable: false), + LastActive = table.Column(type: "TEXT", nullable: false), + IsAdmin = table.Column(type: "INTEGER", nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs new file mode 100644 index 000000000..9ed038826 --- /dev/null +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -0,0 +1,54 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + partial class DataContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.1"); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .HasColumnType("BLOB"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 3478b74f0..a15e894c4 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -13,6 +13,7 @@ namespace API.Entities public byte[] PasswordSalt { get; set; } public DateTime Created { get; set; } = DateTime.Now; public DateTime LastActive { get; set; } + public bool IsAdmin { get; set; } [ConcurrencyCheck] public uint RowVersion { get; set; } @@ -21,8 +22,6 @@ namespace API.Entities { RowVersion++; } - - - + } } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 0f618b9ff..6901678f3 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -2,8 +2,11 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data; using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -11,9 +14,26 @@ namespace API { public class Program { - public static void Main(string[] args) + public static async Task Main(string[] args) { - CreateHostBuilder(args).Build().Run(); + var host = CreateHostBuilder(args).Build(); + + using var scope = host.Services.CreateScope(); + var services = scope.ServiceProvider; + + try + { + var context = services.GetRequiredService(); + // Apply all migrations on startup + await context.Database.MigrateAsync(); + } + catch (Exception ex) + { + var logger = services.GetRequiredService < ILogger>(); + logger.LogError(ex, "An error occurred during migration"); + } + + await host.RunAsync(); } public static IHostBuilder CreateHostBuilder(string[] args) => diff --git a/API/Properties/launchSettings.json b/API/Properties/launchSettings.json index 85ac7db6f..92f840e5c 100644 --- a/API/Properties/launchSettings.json +++ b/API/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" From a920be092d43bcbcea4f05504c54d0c757bb63f2 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 13 Dec 2020 16:26:31 -0600 Subject: [PATCH 3/5] Cleaned up some warnings in the codebase. --- API/Controllers/BaseApiController.cs | 3 --- API/Middleware/ExceptionMiddleware.cs | 2 +- API/Program.cs | 3 --- API/Startup.cs | 7 ------- 4 files changed, 1 insertion(+), 14 deletions(-) diff --git a/API/Controllers/BaseApiController.cs b/API/Controllers/BaseApiController.cs index b08265db8..bb3886ab8 100644 --- a/API/Controllers/BaseApiController.cs +++ b/API/Controllers/BaseApiController.cs @@ -6,8 +6,5 @@ namespace API.Controllers [Route("api/[controller]")] public class BaseApiController : ControllerBase { - public BaseApiController() - { - } } } \ No newline at end of file diff --git a/API/Middleware/ExceptionMiddleware.cs b/API/Middleware/ExceptionMiddleware.cs index a946baff9..5219bf138 100644 --- a/API/Middleware/ExceptionMiddleware.cs +++ b/API/Middleware/ExceptionMiddleware.cs @@ -36,7 +36,7 @@ namespace API.Middleware context.Response.StatusCode = (int) HttpStatusCode.InternalServerError; var response = _env.IsDevelopment() - ? new ApiException(context.Response.StatusCode, ex.Message, ex.StackTrace?.ToString()) + ? new ApiException(context.Response.StatusCode, ex.Message, ex.StackTrace) : new ApiException(context.Response.StatusCode, "Internal Server Error"); var options = new JsonSerializerOptions diff --git a/API/Program.cs b/API/Program.cs index 6901678f3..f3a23a6c1 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,11 +1,8 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using API.Data; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/API/Startup.cs b/API/Startup.cs index f05530565..b691a71a4 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -1,17 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using API.Extensions; using API.Middleware; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.HttpsPolicy; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; using Microsoft.OpenApi.Models; namespace API From 13ed32394957c9fb2fe527c5481f4d94da924855 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Mon, 14 Dec 2020 14:33:09 -0600 Subject: [PATCH 4/5] Added new API for getting Member (aka Users but for use in FE). User is just used for login/registering. --- API/API.csproj | 1 + API/Controllers/AccountController.cs | 18 ++++-- API/Controllers/UsersController.cs | 28 +++++++++ API/Converters/JsonBoolNumberConverter.cs | 30 --------- API/Converters/JsonBoolStringConverter.cs | 26 -------- API/DTOs/MemberDto.cs | 16 +++++ API/DTOs/RegisterDto.cs | 3 - API/Data/UserRepository.cs | 61 +++++++++++++++++++ .../ApplicationServiceExtensions.cs | 4 ++ API/Extensions/ClaimsPrincipalExtensions.cs | 12 ++++ API/Helpers/AutoMapperProfiles.cs | 14 +++++ API/Interfaces/IUserRepository.cs | 19 ++++++ API/Middleware/ExceptionMiddleware.cs | 1 + API/Properties/launchSettings.json | 4 +- 14 files changed, 172 insertions(+), 65 deletions(-) create mode 100644 API/Controllers/UsersController.cs delete mode 100644 API/Converters/JsonBoolNumberConverter.cs delete mode 100644 API/Converters/JsonBoolStringConverter.cs create mode 100644 API/DTOs/MemberDto.cs create mode 100644 API/Data/UserRepository.cs create mode 100644 API/Extensions/ClaimsPrincipalExtensions.cs create mode 100644 API/Helpers/AutoMapperProfiles.cs create mode 100644 API/Interfaces/IUserRepository.cs diff --git a/API/API.csproj b/API/API.csproj index fb104f05e..784cff6b4 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -5,6 +5,7 @@ + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index d483617a7..91273a368 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -1,4 +1,5 @@ -using System.Security.Cryptography; +using System; +using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using API.Data; @@ -15,12 +16,14 @@ namespace API.Controllers { private readonly DataContext _context; private readonly ITokenService _tokenService; + private readonly IUserRepository _userRepository; private readonly ILogger _logger; - public AccountController(DataContext context, ITokenService tokenService, ILogger logger) + public AccountController(DataContext context, ITokenService tokenService, IUserRepository userRepository, ILogger logger) { _context = context; _tokenService = tokenService; + _userRepository = userRepository; _logger = logger; } @@ -39,7 +42,8 @@ namespace API.Controllers UserName = registerDto.Username.ToLower(), PasswordHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(registerDto.Password)), PasswordSalt = hmac.Key, - IsAdmin = registerDto.IsAdmin + IsAdmin = registerDto.IsAdmin, + LastActive = DateTime.Now }; _context.Users.Add(user); @@ -68,11 +72,17 @@ namespace API.Controllers { if (computedHash[i] != user.PasswordHash[i]) return Unauthorized("Invalid password"); } + + // Update LastActive on account + user.LastActive = DateTime.Now; + _userRepository.Update(user); + await _userRepository.SaveAllAsync(); return new UserDto() { Username = user.UserName, - Token = _tokenService.CreateToken(user) + Token = _tokenService.CreateToken(user), + IsAdmin = user.IsAdmin }; } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs new file mode 100644 index 000000000..dd27d3f15 --- /dev/null +++ b/API/Controllers/UsersController.cs @@ -0,0 +1,28 @@ +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers +{ + public class UsersController : BaseApiController + { + private readonly DataContext _context; + private readonly IUserRepository _userRepository; + + public UsersController(DataContext context, IUserRepository userRepository) + { + _context = context; + _userRepository = userRepository; + } + + [HttpGet] + public async Task>> GetUsers() + { + return Ok(await _userRepository.GetMembersAsync()); + } + } +} \ No newline at end of file diff --git a/API/Converters/JsonBoolNumberConverter.cs b/API/Converters/JsonBoolNumberConverter.cs deleted file mode 100644 index c370fb9fd..000000000 --- a/API/Converters/JsonBoolNumberConverter.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace API.Converters -{ - /// - /// Converts a number to a boolean. - /// This is needed for HDHomerun. - /// - public class JsonBoolNumberConverter : JsonConverter - { - /// - public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.Number) - { - return Convert.ToBoolean(reader.GetInt32()); - } - - return reader.GetBoolean(); - } - - /// - public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) - { - writer.WriteBooleanValue(value); - } - } -} \ No newline at end of file diff --git a/API/Converters/JsonBoolStringConverter.cs b/API/Converters/JsonBoolStringConverter.cs deleted file mode 100644 index d17a6537a..000000000 --- a/API/Converters/JsonBoolStringConverter.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace API.Converters -{ - public class JsonBoolStringConverter : JsonConverter - { - /// - public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType == JsonTokenType.String) - { - return reader.GetString().ToLower() == "true"; - } - - return reader.GetBoolean(); - } - - /// - public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) - { - writer.WriteBooleanValue(value); - } - } -} \ No newline at end of file diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs new file mode 100644 index 000000000..a1f8b377b --- /dev/null +++ b/API/DTOs/MemberDto.cs @@ -0,0 +1,16 @@ +using System; + +namespace API.DTOs +{ + /// + /// Represents a member of a Kavita server. + /// + public class MemberDto + { + public int Id { get; set; } + public string Username { get; set; } + public DateTime Created { get; set; } + public DateTime LastActive { get; set; } + public bool IsAdmin { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index 34e1a7a60..61fd26f96 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -1,6 +1,4 @@ using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; -using API.Converters; namespace API.DTOs { @@ -11,7 +9,6 @@ namespace API.DTOs [Required] [StringLength(8, MinimumLength = 4)] public string Password { get; set; } - [JsonConverter(typeof(JsonBoolNumberConverter))] public bool IsAdmin { get; set; } } } \ No newline at end of file diff --git a/API/Data/UserRepository.cs b/API/Data/UserRepository.cs new file mode 100644 index 000000000..2308525a4 --- /dev/null +++ b/API/Data/UserRepository.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; +using API.Interfaces; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data +{ + public class UserRepository : IUserRepository + { + private readonly DataContext _context; + private readonly IMapper _mapper; + + public UserRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Update(AppUser user) + { + _context.Entry(user).State = EntityState.Modified; + } + + public async Task SaveAllAsync() + { + return await _context.SaveChangesAsync() > 0; + } + + public async Task> GetUsersAsync() + { + return await _context.Users.ToListAsync(); + } + + public async Task GetUserByIdAsync(int id) + { + return await _context.Users.FindAsync(id); + } + + public async Task GetUserByUsernameAsync(string username) + { + return await _context.Users.SingleOrDefaultAsync(x => x.UserName == username); + } + + public async Task> GetMembersAsync() + { + return await _context.Users.ProjectTo(_mapper.ConfigurationProvider).ToListAsync(); + } + + public async Task GetMemberAsync(string username) + { + return await _context.Users.Where(x => x.UserName == username) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } + } +} \ No newline at end of file diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 778eb8c6e..07b096c95 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -1,6 +1,8 @@ using API.Data; +using API.Helpers; using API.Interfaces; using API.Services; +using AutoMapper; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -11,6 +13,8 @@ namespace API.Extensions { public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration config) { + services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly); + services.AddScoped(); services.AddScoped(); services.AddDbContext(options => { diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs new file mode 100644 index 000000000..3dacfc854 --- /dev/null +++ b/API/Extensions/ClaimsPrincipalExtensions.cs @@ -0,0 +1,12 @@ +using System.Security.Claims; + +namespace API.Extensions +{ + public static class ClaimsPrincipalExtensions + { + public static string GetUsername(this ClaimsPrincipal user) + { + return user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + } + } +} \ No newline at end of file diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs new file mode 100644 index 000000000..49da92f21 --- /dev/null +++ b/API/Helpers/AutoMapperProfiles.cs @@ -0,0 +1,14 @@ +using API.DTOs; +using API.Entities; +using AutoMapper; + +namespace API.Helpers +{ + public class AutoMapperProfiles : Profile + { + public AutoMapperProfiles() + { + CreateMap(); + } + } +} \ No newline at end of file diff --git a/API/Interfaces/IUserRepository.cs b/API/Interfaces/IUserRepository.cs new file mode 100644 index 000000000..69b872821 --- /dev/null +++ b/API/Interfaces/IUserRepository.cs @@ -0,0 +1,19 @@ +using System.Collections; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; + +namespace API.Interfaces +{ + public interface IUserRepository + { + void Update(AppUser user); + Task SaveAllAsync(); + Task> GetUsersAsync(); + Task GetUserByIdAsync(int id); + Task GetUserByUsernameAsync(string username); + Task> GetMembersAsync(); + Task GetMemberAsync(string username); + } +} \ No newline at end of file diff --git a/API/Middleware/ExceptionMiddleware.cs b/API/Middleware/ExceptionMiddleware.cs index 5219bf138..413fbdb16 100644 --- a/API/Middleware/ExceptionMiddleware.cs +++ b/API/Middleware/ExceptionMiddleware.cs @@ -14,6 +14,7 @@ namespace API.Middleware private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly IHostEnvironment _env; + public ExceptionMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment env) { diff --git a/API/Properties/launchSettings.json b/API/Properties/launchSettings.json index 92f840e5c..677d81685 100644 --- a/API/Properties/launchSettings.json +++ b/API/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, + "launchBrowser": true, "launchUrl": "swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "API": { "commandName": "Project", "dotnetRunMessages": "true", - "launchBrowser": true, + "launchBrowser": false, "launchUrl": "swagger", "applicationUrl": "https://localhost:5001;http://localhost:5000", "environmentVariables": { From 67b97b3be2e360c7b8d60360bfc4ec17c38ccbd9 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Tue, 15 Dec 2020 09:46:45 -0600 Subject: [PATCH 5/5] Small cleanup on Account service. --- API/Controllers/AccountController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 91273a368..a621c1086 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -60,7 +60,7 @@ namespace API.Controllers [HttpPost("login")] public async Task> Login(LoginDto loginDto) { - var user = await _context.Users.SingleOrDefaultAsync(x => x.UserName == loginDto.Username.ToLower()); + var user = await _userRepository.GetUserByUsernameAsync(loginDto.Username); if (user == null) return Unauthorized("Invalid username");