diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Controllers/AccountController.cs | 116 | ||||
| -rw-r--r-- | src/Controllers/AppControllerBase.cs | 22 | ||||
| -rw-r--r-- | src/Data/Database/AppDbContext.cs | 25 | ||||
| -rw-r--r-- | src/Data/Database/Base.cs | 10 | ||||
| -rw-r--r-- | src/Data/Database/User.cs | 20 | ||||
| -rw-r--r-- | src/Data/Dtos/LoginRequestDto.cs | 9 | ||||
| -rw-r--r-- | src/Data/General/AppPath.cs | 27 | ||||
| -rw-r--r-- | src/Data/General/LoggedInUser.cs | 10 | ||||
| -rw-r--r-- | src/Data/Result/ErrorResult.cs | 13 | ||||
| -rw-r--r-- | src/Data/Static/AppJsonSettings.cs | 17 | ||||
| -rw-r--r-- | src/Data/Static/AppPaths.cs | 16 | ||||
| -rw-r--r-- | src/IOL.WebApi.Template.csproj | 18 | ||||
| -rw-r--r-- | src/Program.cs | 38 | ||||
| -rw-r--r-- | src/Properties/launchSettings.json | 31 | ||||
| -rw-r--r-- | src/Startup.cs | 74 |
15 files changed, 446 insertions, 0 deletions
diff --git a/src/Controllers/AccountController.cs b/src/Controllers/AccountController.cs new file mode 100644 index 0000000..7f94711 --- /dev/null +++ b/src/Controllers/AccountController.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using IOL.WebApi.Template.Data.Database; +using IOL.WebApi.Template.Data.Dtos; +using IOL.WebApi.Template.Data.Result; +using IOL.Helpers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace IOL.WebApi.Template.Controllers +{ + public class AccountController : AppControllerBase + { + private readonly AppDbContext _context; + private readonly IAuthenticationService _authentication; + + public AccountController( + AppDbContext context, + IAuthenticationService authentication + ) { + _context = context; + _authentication = authentication; + } + + [AllowAnonymous] + [HttpPost("login")] + public ActionResult Login(LoginRequestDto payload) { + if (!ModelState.IsValid) + return BadRequest(ModelState); + var user = _context.Users.SingleOrDefault(u => u.Username == payload.Username); + if (user == default || !user.VerifyPassword(payload.Password)) + return BadRequest(new ErrorResult("Invalid username or password")); + + var claims = new List<Claim> { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.Username), + }; + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + var authenticationProperties = new AuthenticationProperties { + AllowRefresh = true, + IssuedUtc = DateTimeOffset.UtcNow, + }; + + if (payload.Persist) { + authenticationProperties.ExpiresUtc = DateTimeOffset.UtcNow.AddMonths(6); + authenticationProperties.IsPersistent = true; + } + + HttpContext.SignInAsync(principal, authenticationProperties); + return Ok(); + } + + [HttpGet("logout")] + [AllowAnonymous] + public ActionResult Logout() { + HttpContext.SignOutAsync(); + return Ok(); + } + + [AllowAnonymous] + [HttpGet("create-initial")] + public ActionResult CreateInitialUser(string username, string password) { + if (_context.Users.Any()) { + return NotFound(); + } + + var user = new User(username); + user.HashAndSetPassword(password); + _context.Users.Add(user); + _context.SaveChanges(); + return Ok(); + } + + [AllowAnonymous] + [HttpGet("me")] + public async Task<ActionResult> GetLoggedInUser() { + var authres = + await _authentication.AuthenticateAsync(HttpContext, CookieAuthenticationDefaults.AuthenticationScheme); + if (authres.Succeeded) + return Ok(LoggedInUser); + + await HttpContext.SignOutAsync(); + return StatusCode(403); + } + + [HttpPost("update-password")] + public ActionResult UpdatePassword([FromBody] string newPassword) { + if (newPassword.IsNullOrWhiteSpace()) { + return BadRequest(new ErrorResult("Invalid request", + "The new password field is required")); + } + + if (newPassword.Length < 6) { + return BadRequest(new ErrorResult("Invalid request", + "The new password must contain atleast 6 characters")); + } + + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + HttpContext.SignOutAsync(); + return StatusCode(403); + } + + user.HashAndSetPassword(newPassword); + _context.SaveChanges(); + return Ok(); + } + } +} diff --git a/src/Controllers/AppControllerBase.cs b/src/Controllers/AppControllerBase.cs new file mode 100644 index 0000000..36b52d7 --- /dev/null +++ b/src/Controllers/AppControllerBase.cs @@ -0,0 +1,22 @@ +using System.Linq; +using System.Security.Claims; +using IOL.WebApi.Template.Data.General; +using IOL.Helpers; +using Microsoft.AspNetCore.Mvc; + +namespace IOL.WebApi.Template.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class AppControllerBase : ControllerBase + { + public string CurrentHost => Request.GetRequestHost(); + + public AppControllerBase() { } + + public LoggedInUser LoggedInUser => new() { + Username = User.Identity?.Name, + Id = User.Claims.SingleOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value.ToGuid() ?? default + }; + } +} diff --git a/src/Data/Database/AppDbContext.cs b/src/Data/Database/AppDbContext.cs new file mode 100644 index 0000000..9bfabc4 --- /dev/null +++ b/src/Data/Database/AppDbContext.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; + +namespace IOL.WebApi.Template.Data.Database +{ + public class AppDbContext : DbContext + { + public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } + public DbSet<User> Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { +#if false + var seedUser = new User("admin@example.org"); + seedUser.Id = Guid.NewGuid(); + seedUser.HashAndSetPassword("asdf1234"); + modelBuilder.Entity<User>().HasData(seedUser); +#endif + + modelBuilder.Entity<User>(e => { + e.ToTable("users"); + }); + + base.OnModelCreating(modelBuilder); + } + } +} diff --git a/src/Data/Database/Base.cs b/src/Data/Database/Base.cs new file mode 100644 index 0000000..4b1ea55 --- /dev/null +++ b/src/Data/Database/Base.cs @@ -0,0 +1,10 @@ +using System; + +namespace IOL.WebApi.Template.Data.Database +{ + public class Base + { + public Guid Id { get; set; } + public DateTime Created { get; set; } + } +} diff --git a/src/Data/Database/User.cs b/src/Data/Database/User.cs new file mode 100644 index 0000000..31513a3 --- /dev/null +++ b/src/Data/Database/User.cs @@ -0,0 +1,20 @@ +using IOL.Helpers; + +namespace IOL.WebApi.Template.Data.Database +{ + public class User : Base + { + public User(string username) => Username = username; + + public string Username { get; set; } + public string Password { get; set; } + + public void HashAndSetPassword(string password) { + Password = PasswordHelper.HashPassword(password); + } + + public bool VerifyPassword(string password) { + return PasswordHelper.Verify(password, Password); + } + } +} diff --git a/src/Data/Dtos/LoginRequestDto.cs b/src/Data/Dtos/LoginRequestDto.cs new file mode 100644 index 0000000..bed49c4 --- /dev/null +++ b/src/Data/Dtos/LoginRequestDto.cs @@ -0,0 +1,9 @@ +namespace IOL.WebApi.Template.Data.Dtos +{ + public class LoginRequestDto + { + public string Username { get; set; } + public string Password { get; set; } + public bool Persist { get; set; } + } +} diff --git a/src/Data/General/AppPath.cs b/src/Data/General/AppPath.cs new file mode 100644 index 0000000..241cf65 --- /dev/null +++ b/src/Data/General/AppPath.cs @@ -0,0 +1,27 @@ +using System.IO; +using IOL.Helpers; + +namespace IOL.WebApi.Template.Data.General +{ + public sealed record AppPath + { + public string HostPath { get; init; } + public string WebPath { get; init; } + + public string GetHostPathForFilename(string filename, string fallback = "") { + if (filename.IsNullOrWhiteSpace()) { + return fallback; + } + + return Path.Combine(HostPath, filename); + } + + public string GetWebPathForFilename(string filename, string fallback = "") { + if (filename.IsNullOrWhiteSpace()) { + return fallback; + } + + return Path.Combine(WebPath, filename); + } + } +} diff --git a/src/Data/General/LoggedInUser.cs b/src/Data/General/LoggedInUser.cs new file mode 100644 index 0000000..d278d3f --- /dev/null +++ b/src/Data/General/LoggedInUser.cs @@ -0,0 +1,10 @@ +using System; + +namespace IOL.WebApi.Template.Data.General +{ + public class LoggedInUser + { + public Guid Id { get; set; } + public string Username { get; set; } + } +} diff --git a/src/Data/Result/ErrorResult.cs b/src/Data/Result/ErrorResult.cs new file mode 100644 index 0000000..3e585b3 --- /dev/null +++ b/src/Data/Result/ErrorResult.cs @@ -0,0 +1,13 @@ +namespace IOL.WebApi.Template.Data.Result +{ + public class ErrorResult + { + public ErrorResult(string title = default, string text = default) { + Title = title; + Text = text; + } + + public string Title { get; set; } + public string Text { get; set; } + } +} diff --git a/src/Data/Static/AppJsonSettings.cs b/src/Data/Static/AppJsonSettings.cs new file mode 100644 index 0000000..8aad1ba --- /dev/null +++ b/src/Data/Static/AppJsonSettings.cs @@ -0,0 +1,17 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace IOL.WebApi.Template.Data.Static +{ + public static class AppJsonSettings + { + public static Action<JsonOptions> Value { get; } = options => { + options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve; + options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString; + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }; + } +} diff --git a/src/Data/Static/AppPaths.cs b/src/Data/Static/AppPaths.cs new file mode 100644 index 0000000..64a249a --- /dev/null +++ b/src/Data/Static/AppPaths.cs @@ -0,0 +1,16 @@ +using System.IO; +using IOL.WebApi.Template.Data.General; + +namespace IOL.WebApi.Template.Data.Static +{ + public static class AppPaths + { + public static AppPath AppData => new() { + HostPath = Path.Combine(Directory.GetCurrentDirectory(), "AppData") + }; + + public static AppPath DataProtectionKeys => new() { + HostPath = Path.Combine(Directory.GetCurrentDirectory(), "AppData", "data-protection-keys") + }; + } +} diff --git a/src/IOL.WebApi.Template.csproj b/src/IOL.WebApi.Template.csproj new file mode 100644 index 0000000..5fc7513 --- /dev/null +++ b/src/IOL.WebApi.Template.csproj @@ -0,0 +1,18 @@ +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net5.0</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="EFCore.NamingConventions" Version="5.0.2" /> + <PackageReference Include="IOL.Helpers" Version="1.0.4" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.5"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="5.0.5.1" /> + <PackageReference Include="Serilog.AspNetCore" Version="4.1.0" /> + </ItemGroup> + +</Project> diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..113b013 --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using Serilog; + +namespace IOL.WebApi.Template +{ + public class Program + { + public static int Main(string[] args) { + Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + + try { + Log.Information("Starting web host"); + CreateHostBuilder(args).Build().Run(); + return 0; + } catch (Exception ex) { + Log.Fatal(ex, "Host terminated unexpectedly"); + return 1; + } finally { + Log.CloseAndFlush(); + } + } + + private static IHostBuilder CreateHostBuilder(string[] args) { + return Host.CreateDefaultBuilder(args) + .UseSerilog() + .ConfigureWebHostDefaults(webBuilder => { + webBuilder.UseKestrel(o => + o.AddServerHeader = false); + webBuilder.UseStartup<Startup>(); + }); + } + } +} diff --git a/src/Properties/launchSettings.json b/src/Properties/launchSettings.json new file mode 100644 index 0000000..bb0b5fa --- /dev/null +++ b/src/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:4608", + "sslPort": 44370 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IOL.WebApi.Template": { + "commandName": "Project", + "dotnetRunMessages": "true", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Startup.cs b/src/Startup.cs new file mode 100644 index 0000000..a3a7009 --- /dev/null +++ b/src/Startup.cs @@ -0,0 +1,74 @@ +using System; +using System.IO; +using IOL.WebApi.Template.Data.Database; +using IOL.WebApi.Template.Data.Static; +using IOL.Helpers; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace IOL.WebApi.Template +{ + public class Startup + { + public Startup(IConfiguration configuration, IWebHostEnvironment webHostEnvironment) { + Configuration = configuration; + WebHostEnvironment = webHostEnvironment; + } + + private IWebHostEnvironment WebHostEnvironment { get; } + private IConfiguration Configuration { get; } + + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) { + services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(AppPaths.DataProtectionKeys.HostPath)); + + services.Configure(AppJsonSettings.Value); + + services.AddDbContext<AppDbContext>(options => { + options.UseNpgsql("Server={DB_HOST};Port={DB_PORT};Database={DB_NAME};User Id={DB_USER};Password={DB_PASSWORD}".UnicornFormatWithEnvironment(Configuration), + builder => { + builder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); + builder.EnableRetryOnFailure(5, TimeSpan.FromSeconds(10), default); + }) + .UseSnakeCaseNamingConvention(); + if (WebHostEnvironment.IsDevelopment()) + options.EnableSensitiveDataLogging(); + }); + + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => { + options.Cookie.Name = ""; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + }); + + services.AddControllers() + .AddJsonOptions(AppJsonSettings.Value); + } + + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app) { + if (WebHostEnvironment.IsDevelopment()) { + app.UseDeveloperExceptionPage(); + } + + app.UseStaticFiles(); + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseStatusCodePages(); + app.UseEndpoints(endpoints => { + endpoints.MapControllers(); + }); + } + } +} |
