diff --git a/src/.gitignore b/src/.gitignore index df00081..9d1101c 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,2 +1,5 @@ # dp -appsettings.Development.json \ No newline at end of file +appsettings.Development.json + +# dp: EF Core Migrations +SpanishByExample.Api/Migrations/ diff --git a/src/SpanishByExample.Api/Authentication/AuthDbContext.cs b/src/SpanishByExample.Api/Authentication/AuthDbContext.cs new file mode 100644 index 0000000..70182d6 --- /dev/null +++ b/src/SpanishByExample.Api/Authentication/AuthDbContext.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace SpanishByExample.Api.Authentication; + +/// +/// This context is only for: +/// - Users +/// - Roles +/// - Identity tables +/// +public class AuthDbContext : IdentityDbContext +{ + public AuthDbContext(DbContextOptions options) + : base(options) + { + + } +} diff --git a/src/SpanishByExample.Api/Authentication/Dtos/LoginDto.cs b/src/SpanishByExample.Api/Authentication/Dtos/LoginDto.cs new file mode 100644 index 0000000..d127d8a --- /dev/null +++ b/src/SpanishByExample.Api/Authentication/Dtos/LoginDto.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace SpanishByExample.Api.Authentication.Dtos; + +/// +/// DTO used by login action. +/// +public class LoginDto +{ + [Required] + public string Username { get; set; } = null!; + + [Required] + public string Password { get; set; } = null!; +} \ No newline at end of file diff --git a/src/SpanishByExample.Api/Authentication/Dtos/RegisterDto.cs b/src/SpanishByExample.Api/Authentication/Dtos/RegisterDto.cs new file mode 100644 index 0000000..7d40953 --- /dev/null +++ b/src/SpanishByExample.Api/Authentication/Dtos/RegisterDto.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace SpanishByExample.Api.Authentication.Dtos; + +/// +/// DTO used by register action. +/// +public class RegisterDto +{ + [Required] + public string Username { get; set; } = null!; + + [Required, EmailAddress] + public string Email{ get; set; } = null!; + + [Required] + public string Password { get; set; } = null!; +} \ No newline at end of file diff --git a/src/SpanishByExample.Api/Authentication/JwtOptions.cs b/src/SpanishByExample.Api/Authentication/JwtOptions.cs new file mode 100644 index 0000000..aa59bc0 --- /dev/null +++ b/src/SpanishByExample.Api/Authentication/JwtOptions.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +/// +/// JWT Options from the configuration. +/// +public class JwtOptions +{ + public const string SectionName = "Jwt"; + + [Required] + public string Key { get; set; } = null!; + + [Required] + public string Issuer { get; set; } = null!; + + [Required] + public string Audience { get; set; } = null!; +} \ No newline at end of file diff --git a/src/SpanishByExample.Api/Controllers/AuthController.cs b/src/SpanishByExample.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..c4dd6b5 --- /dev/null +++ b/src/SpanishByExample.Api/Controllers/AuthController.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using SpanishByExample.Api.Authentication.Dtos; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; + +namespace SpanishByExample.Api.Controllers; + +/// +/// Controller for authentication using ASP.NET Core Identity and JWT Bearer Authentication. +/// +[Route("api/[controller]")] +[ApiController] +public class AuthController(IOptions _jwtOptions, UserManager _userManager) : ControllerBase +{ + #region Actions + + /// + /// Registers a new user. + /// + /// User data. + /// Created user incl. ID. + [HttpPost("register")] + public async Task Register(RegisterDto registerDto) + { + var user = new IdentityUser + { + UserName = registerDto.Username, + Email = registerDto.Email, + }; + + var result = await _userManager.CreateAsync(user, registerDto.Password); + + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + return Ok(new { user.Id, user.UserName, user.Email }); + } + + /// + /// Log-in of user. + /// + /// User data. + /// JWT Bearer token. + [HttpPost("login")] + public async Task Login(LoginDto loginDto) + { + var user = await _userManager.FindByNameAsync(loginDto.Username); + + if (user == null || !await _userManager.CheckPasswordAsync(user, loginDto.Password)) + { + return Unauthorized(); + } + + var token = GenerateJwtToken(user); + + return Ok(new { token }); + } + + #endregion + + #region Private Methods + + private string GenerateJwtToken(IdentityUser user) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id) + }; + + var jwtConfig = _jwtOptions.Value; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtConfig.Key)); + + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var token = new JwtSecurityToken( + issuer: jwtConfig.Issuer, + audience: jwtConfig.Audience, + claims: claims, + expires: DateTime.UtcNow.AddHours(2), + signingCredentials: creds); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + #endregion +} diff --git a/src/SpanishByExample.Api/Program.cs b/src/SpanishByExample.Api/Program.cs index 5a2a40f..4be7de3 100644 --- a/src/SpanishByExample.Api/Program.cs +++ b/src/SpanishByExample.Api/Program.cs @@ -1,6 +1,14 @@ -using SpanishByExample.Core.Interfaces; +// TODO add author info + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using SpanishByExample.Api.Authentication; using SpanishByExample.Business; +using SpanishByExample.Core.Interfaces; using SpanishByExample.DataAccess; +using System.Text; var builder = WebApplication.CreateBuilder(args); @@ -13,6 +21,8 @@ // dp: Added for Swagger support. builder.Services.AddSwaggerGen(); +RegisterAuthentication(builder); + RegisterServices(builder); var app = builder.Build(); @@ -29,14 +39,65 @@ app.UseHttpsRedirection(); +// dp: enable JWT Bearer authentication +app.UseAuthentication(); + app.UseAuthorization(); app.MapControllers(); app.Run(); +#region Private Methods + +// TODO ren to RegisterCustomServices static void RegisterServices(WebApplicationBuilder builder) { builder.Services.AddScoped(); builder.Services.AddScoped(); -} \ No newline at end of file +} + +static void RegisterAuthentication(WebApplicationBuilder builder) +{ + // Register Identity + + builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("SpanishByExampleDb"))); + + builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + + // Read JWT Options from config + + builder.Services.AddOptions() + .Bind(builder.Configuration.GetRequiredSection(JwtOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName) + .Get() ?? throw new InvalidOperationException("Jwt configuration missing."); + + + // Register JWT + + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + options.TokenValidationParameters = new() + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtOptions.Issuer, + ValidAudience = jwtOptions.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Key)) + }); +} + +#endregion \ No newline at end of file diff --git a/src/SpanishByExample.Api/SpanishByExample.Api.csproj b/src/SpanishByExample.Api/SpanishByExample.Api.csproj index 26b25b1..4e0cfeb 100644 --- a/src/SpanishByExample.Api/SpanishByExample.Api.csproj +++ b/src/SpanishByExample.Api/SpanishByExample.Api.csproj @@ -7,7 +7,21 @@ + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/SpanishByExample.Api/appsettings.json b/src/SpanishByExample.Api/appsettings.json index 10f68b8..7f1d394 100644 --- a/src/SpanishByExample.Api/appsettings.json +++ b/src/SpanishByExample.Api/appsettings.json @@ -1,9 +1,17 @@ { + "ConnectionStrings": { + "DefaultConnectionString": "" + }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Jwt": { + "Key": "", + "Issuer": "SpanishByExample", + "Audience": "SpanishByExample" + } } diff --git a/tests/SpanishByExample.Tests/SpanishByExample.Tests.csproj b/tests/SpanishByExample.Tests/SpanishByExample.Tests.csproj index 6fa4e2d..fed68a2 100644 --- a/tests/SpanishByExample.Tests/SpanishByExample.Tests.csproj +++ b/tests/SpanishByExample.Tests/SpanishByExample.Tests.csproj @@ -10,7 +10,7 @@ - +