Merge pull request #9 from Kareadita/feature/user-login

User Login + Member fetching
This commit is contained in:
Joseph Milazzo 2020-12-17 09:23:35 -06:00 committed by GitHub
commit 497496c609
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 719 additions and 21 deletions

5
.gitignore vendored
View File

@ -443,5 +443,6 @@ $RECYCLE.BIN/
# App specific # App specific
appsettings.json appsettings.json
/API/datingapp.db-shm /API/kavita.db
/API/datingapp.db-wal /API/kavita.db-shm
/API/kavita.db-wal

View File

@ -5,6 +5,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.1" NoWarn="NU1605" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.1" NoWarn="NU1605" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.1" NoWarn="NU1605" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.1" NoWarn="NU1605" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.1"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.1">

View File

@ -0,0 +1,94 @@
using System;
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 IUserRepository _userRepository;
private readonly ILogger<AccountController> _logger;
public AccountController(DataContext context, ITokenService tokenService, IUserRepository userRepository, ILogger<AccountController> logger)
{
_context = context;
_tokenService = tokenService;
_userRepository = userRepository;
_logger = logger;
}
[HttpPost("register")]
public async Task<ActionResult<UserDto>> 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,
IsAdmin = registerDto.IsAdmin,
LastActive = DateTime.Now
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
return new UserDto()
{
Username = user.UserName,
Token = _tokenService.CreateToken(user),
IsAdmin = user.IsAdmin
};
}
[HttpPost("login")]
public async Task<ActionResult<UserDto>> Login(LoginDto loginDto)
{
var user = await _userRepository.GetUserByUsernameAsync(loginDto.Username);
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");
}
// Update LastActive on account
user.LastActive = DateTime.Now;
_userRepository.Update(user);
await _userRepository.SaveAllAsync();
return new UserDto()
{
Username = user.UserName,
Token = _tokenService.CreateToken(user),
IsAdmin = user.IsAdmin
};
}
private async Task<bool> UserExists(string username)
{
return await _context.Users.AnyAsync(user => user.UserName == username.ToLower());
}
}
}

View File

@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class BaseApiController : ControllerBase
{
}
}

View File

@ -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<ActionResult<IEnumerable<MemberDto>>> GetUsers()
{
return Ok(await _userRepository.GetMembersAsync());
}
}
}

8
API/DTOs/LoginDto.cs Normal file
View File

@ -0,0 +1,8 @@
namespace API.DTOs
{
public class LoginDto
{
public string Username { get; set; }
public string Password { get; set; }
}
}

16
API/DTOs/MemberDto.cs Normal file
View File

@ -0,0 +1,16 @@
using System;
namespace API.DTOs
{
/// <summary>
/// Represents a member of a Kavita server.
/// </summary>
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; }
}
}

14
API/DTOs/RegisterDto.cs Normal file
View File

@ -0,0 +1,14 @@
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; }
public bool IsAdmin { get; set; }
}
}

9
API/DTOs/UserDto.cs Normal file
View File

@ -0,0 +1,9 @@
namespace API.DTOs
{
public class UserDto
{
public string Username { get; set; }
public string Token { get; set; }
public bool IsAdmin { get; set; }
}
}

15
API/Data/DataContext.cs Normal file
View File

@ -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<AppUser> Users { get; set; }
}
}

View File

@ -0,0 +1,56 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<byte[]>("PasswordHash")
.HasColumnType("BLOB");
b.Property<byte[]>("PasswordSalt")
.HasColumnType("BLOB");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserName = table.Column<string>(type: "TEXT", nullable: true),
PasswordHash = table.Column<byte[]>(type: "BLOB", nullable: true),
PasswordSalt = table.Column<byte[]>(type: "BLOB", nullable: true),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
LastActive = table.Column<DateTime>(type: "TEXT", nullable: false),
IsAdmin = table.Column<bool>(type: "INTEGER", nullable: false),
RowVersion = table.Column<uint>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@ -0,0 +1,54 @@
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<byte[]>("PasswordHash")
.HasColumnType("BLOB");
b.Property<byte[]>("PasswordSalt")
.HasColumnType("BLOB");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -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<bool> SaveAllAsync()
{
return await _context.SaveChangesAsync() > 0;
}
public async Task<IEnumerable<AppUser>> GetUsersAsync()
{
return await _context.Users.ToListAsync();
}
public async Task<AppUser> GetUserByIdAsync(int id)
{
return await _context.Users.FindAsync(id);
}
public async Task<AppUser> GetUserByUsernameAsync(string username)
{
return await _context.Users.SingleOrDefaultAsync(x => x.UserName == username);
}
public async Task<IEnumerable<MemberDto>> GetMembersAsync()
{
return await _context.Users.ProjectTo<MemberDto>(_mapper.ConfigurationProvider).ToListAsync();
}
public async Task<MemberDto> GetMemberAsync(string username)
{
return await _context.Users.Where(x => x.UserName == username)
.ProjectTo<MemberDto>(_mapper.ConfigurationProvider)
.SingleOrDefaultAsync();
}
}
}

27
API/Entities/AppUser.cs Normal file
View File

@ -0,0 +1,27 @@
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; }
public bool IsAdmin { get; set; }
[ConcurrencyCheck]
public uint RowVersion { get; set; }
public void OnSavingChanges()
{
RowVersion++;
}
}
}

View File

@ -0,0 +1,19 @@
namespace API.Entities.Interfaces
{
/// <summary>
/// An interface abstracting an entity that has a concurrency token.
/// </summary>
public interface IHasConcurrencyToken
{
/// <summary>
/// Gets the version of this row. Acts as a concurrency token.
/// </summary>
uint RowVersion { get; }
/// <summary>
/// Called when saving changes to this entity.
/// </summary>
void OnSavingChanges();
}
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,27 @@
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;
namespace API.Extensions
{
public static class ApplicationServiceExtensions
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration config)
{
services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly);
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<ITokenService, TokenService>();
services.AddDbContext<DataContext>(options =>
{
options.UseSqlite(config.GetConnectionString("DefaultConnection"));
});
return services;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,14 @@
using API.DTOs;
using API.Entities;
using AutoMapper;
namespace API.Helpers
{
public class AutoMapperProfiles : Profile
{
public AutoMapperProfiles()
{
CreateMap<AppUser, MemberDto>();
}
}
}

View File

@ -0,0 +1,9 @@
using API.Entities;
namespace API.Interfaces
{
public interface ITokenService
{
string CreateToken(AppUser user);
}
}

View File

@ -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<bool> SaveAllAsync();
Task<IEnumerable<AppUser>> GetUsersAsync();
Task<AppUser> GetUserByIdAsync(int id);
Task<AppUser> GetUserByUsernameAsync(string username);
Task<IEnumerable<MemberDto>> GetMembersAsync();
Task<MemberDto> GetMemberAsync(string username);
}
}

View File

@ -0,0 +1,56 @@
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<ExceptionMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> 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)
: 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);
}
}
}
}

View File

@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -11,9 +11,26 @@ namespace API
{ {
public class Program 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<DataContext>();
// Apply all migrations on startup
await context.Database.MigrateAsync();
}
catch (Exception ex)
{
var logger = services.GetRequiredService < ILogger<Program>>();
logger.LogError(ex, "An error occurred during migration");
}
await host.RunAsync();
} }
public static IHostBuilder CreateHostBuilder(string[] args) => public static IHostBuilder CreateHostBuilder(string[] args) =>

View File

@ -20,7 +20,7 @@
"API": { "API": {
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": "true", "dotnetRunMessages": "true",
"launchBrowser": true, "launchBrowser": false,
"launchUrl": "swagger", "launchUrl": "swagger",
"applicationUrl": "https://localhost:5001;http://localhost:5000", "applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": { "environmentVariables": {

View File

@ -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<Claim>
{
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);
}
}
}

View File

@ -1,33 +1,31 @@
using System; using API.Extensions;
using System.Collections.Generic; using API.Middleware;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
namespace API namespace API
{ {
public class Startup public class Startup
{ {
public Startup(IConfiguration configuration) private readonly IConfiguration _config;
{
Configuration = configuration;
}
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. // This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
services.AddApplicationServices(_config);
services.AddControllers(); services.AddControllers();
services.AddCors();
services.AddIdentityServices(_config);
services.AddSwaggerGen(c => services.AddSwaggerGen(c =>
{ {
c.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" }); c.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" });
@ -37,9 +35,11 @@ namespace API
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{ {
app.UseMiddleware<ExceptionMiddleware>();
if (env.IsDevelopment()) if (env.IsDevelopment())
{ {
app.UseDeveloperExceptionPage(); //app.UseDeveloperExceptionPage();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1")); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1"));
} }
@ -47,6 +47,11 @@ namespace API
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseRouting(); app.UseRouting();
// Ordering is important. Cors, authentication, authorization
app.UseCors(policy => policy.AllowAnyHeader().AllowAnyMethod().WithOrigins("https://localhost:4200"));
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();