Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Projects endpoints + JWT auth #23

Merged
merged 2 commits into from
Oct 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/BHC24.Api/BHC24.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
Expand All @@ -25,6 +26,7 @@
<PackageReference Include="Serilog.Sinks.Async" Version="2.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.1.2" />
</ItemGroup>

</Project>
70 changes: 70 additions & 0 deletions src/BHC24.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using BHC24.Api.Persistence.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames;

namespace BHC24.Api.Controllers;

[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
private readonly UserManager<AppUser> _userManager;
private readonly IConfiguration _configuration;

public AuthController(UserManager<AppUser> userManager, IConfiguration configuration)
{
_userManager = userManager;
_configuration = configuration;
}

[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var user = new AppUser { UserName = request.Email, Email = request.Email };
var result = await _userManager.CreateAsync(user, request.Password);
if (result.Succeeded)
{
return Ok();
}
return BadRequest(result.Errors);
}

[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user != null && await _userManager.CheckPasswordAsync(user, request.Password))
{
var token = GenerateJwtToken(user);
return Ok(new { Token = token });
}
return Unauthorized();
}

private string GenerateJwtToken(AppUser user)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.UserName),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())
};

var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

var token = new JwtSecurityToken(
issuer: _configuration["Jwt:Issuer"],
audience: _configuration["Jwt:Audience"],
claims: claims,
expires: DateTime.Now.AddDays(1),
signingCredentials: creds);

return new JwtSecurityTokenHandler().WriteToken(token);
}
}
111 changes: 111 additions & 0 deletions src/BHC24.Api/Controllers/ProjectController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using BHC24.Api.Extensions;
using BHC24.Api.Models;
using BHC24.Api.Models.Projects;
using BHC24.Api.Persistence;
using BHC24.Api.Persistence.Models;
using BHC24.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace BHC24.Api.Controllers;

[ApiController]
[Authorize]
[Route("api/[controller]")]
public class ProjectController : ControllerBase
{
private readonly BhcDbContext _dbContext;
private readonly AuthUserProvider _authUser;

public ProjectController(BhcDbContext dbContext, AuthUserProvider authUser)
{
_dbContext = dbContext;
_authUser = authUser;
}

[HttpGet, AllowAnonymous]
public async Task<IActionResult> GetList([FromQuery] PaginationRequest request)
{
var projects = await _dbContext.Projects
.Select(p => new GetProjectResponse
{
Title = p.Title,
Description = p.Description,
Owner = p.Owner.UserName,
Collaborators = p.Collaborators.Select(c => c.UserName),
})
.PaginateAsync(request);

return Ok(projects);
}

[HttpPost]
public async Task<IActionResult> Add(AddProjectRequest request)
{
var user = await _authUser.GetAsync();

var project = new Project
{
Title = request.Title,
Description = request.Description,
Owner = user
};

_dbContext.Projects.Add(project);
await _dbContext.SaveChangesAsync();

return Ok();
}

[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, AddProjectRequest request)
{
var project = await _dbContext.Projects
.Include(p => p.Owner)
.Include(p => p.Collaborators)
.FirstOrDefaultAsync(p => p.Id == id);

if (project is null)
{
return NotFound();
}

if (project.Owner.Id != (await _authUser.GetAsync()).Id)
{
return Forbid();
}

project.Title = request.Title;
project.Description = request.Description;

await _dbContext.SaveChangesAsync();

return Ok();
}

[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var project = await _dbContext.Projects
.Include(p => p.Owner)
.Include(p => p.Collaborators)
.FirstOrDefaultAsync(p => p.Id == id);

if (project == null)
{
return NotFound();
}

if (project.Owner.Id != (await _authUser.GetAsync()).Id)
{
return Forbid();
}

_dbContext.Projects.Remove(project);
await _dbContext.SaveChangesAsync();

return Ok();
}
}
6 changes: 6 additions & 0 deletions src/BHC24.Api/Extensions/QueryableExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ public static async IAsyncEnumerable<TEntity> ToAsyncEnumerable<TEntity>(this IQ
}
}

public static async Task<PaginationResponse<TEntity>> PaginateAsync<TEntity>(
this IQueryable<TEntity> query, PaginationRequest request, CancellationToken ct = default)
{
return await query.PaginateAsync(request.Page, request.PageSize, ct);
}

public static async Task<PaginationResponse<TEntity>> PaginateAsync<TEntity>(
this IQueryable<TEntity> query, int page, int pageSize, CancellationToken ct = default)
{
Expand Down
7 changes: 7 additions & 0 deletions src/BHC24.Api/Models/LoginRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace BHC24.Api.Controllers;

public class LoginRequest
{
public string Email { get; set; }

Check warning on line 5 in src/BHC24.Api/Models/LoginRequest.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Email' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public string Password { get; set; }

Check warning on line 6 in src/BHC24.Api/Models/LoginRequest.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Password' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
}
11 changes: 11 additions & 0 deletions src/BHC24.Api/Models/Projects/Project.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace BHC24.Api.Models.Projects;

public record GetProjectResponse
{
public string Title { get; init; }

Check warning on line 5 in src/BHC24.Api/Models/Projects/Project.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Title' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public string Description { get; init; }

Check warning on line 6 in src/BHC24.Api/Models/Projects/Project.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Description' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public string Owner { get; init; }

Check warning on line 7 in src/BHC24.Api/Models/Projects/Project.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Owner' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public IEnumerable<string> Collaborators { get; init; }

Check warning on line 8 in src/BHC24.Api/Models/Projects/Project.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Collaborators' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
}

public record AddProjectRequest(string Title, string Description);
7 changes: 7 additions & 0 deletions src/BHC24.Api/Models/RegisterRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace BHC24.Api.Controllers;

public class RegisterRequest
{
public string Email { get; set; }

Check warning on line 5 in src/BHC24.Api/Models/RegisterRequest.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Email' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
public string Password { get; set; }

Check warning on line 6 in src/BHC24.Api/Models/RegisterRequest.cs

View workflow job for this annotation

GitHub Actions / build

Non-nullable property 'Password' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
}
69 changes: 64 additions & 5 deletions src/BHC24.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
using System.Text;
using BHC24.Api.Extensions;
using BHC24.Api.Persistence;
using BHC24.Api.Persistence.Models;
using BHC24.Api.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using Serilog;

var builder = WebApplication.CreateBuilder(args);
Expand All @@ -11,7 +16,35 @@
// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Open Project Platform", Version = "v1" });

c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
In = ParameterLocation.Header,
Description = "Please enter a valid token",
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT"
});

c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
new string[] { }
}
});
});

builder.Services.AddDbContext<BhcDbContext>();

Expand All @@ -35,6 +68,32 @@
.AllowAnyHeader();
}));

builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))

Check warning on line 86 in src/BHC24.Api/Program.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 's' in 'byte[] Encoding.GetBytes(string s)'.
};
});

builder.Services.AddHttpContextAccessor();
builder.Services.AddAuthorization();


builder.Services
.AddScoped<AuthUserProvider>();

builder.Services.AddControllers();

builder.Services.AddAntiforgery();
Expand All @@ -55,12 +114,12 @@
app.Map("/", () => Results.Redirect("/swagger"));

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.UseAntiforgery();
app.UseCors("MyPolicy");
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });

using var scope = app.Services.CreateScope();
var context = scope.ServiceProvider.GetService<BhcDbContext>();
Expand Down
4 changes: 2 additions & 2 deletions src/BHC24.Api/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchBrowser": false,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5091",
"environmentVariables": {
Expand All @@ -22,7 +22,7 @@
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchBrowser": false,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7113;http://localhost:5091",
"environmentVariables": {
Expand Down
34 changes: 34 additions & 0 deletions src/BHC24.Api/Services/AuthUserProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.Security.Claims;
using BHC24.Api.Persistence.Models;
using Microsoft.AspNetCore.Identity;

namespace BHC24.Api.Services;

public class AuthUserProvider
{
private readonly UserManager<AppUser> _userManager;
private readonly IHttpContextAccessor _httpContextAccessor;

public AuthUserProvider(UserManager<AppUser> userManager, IHttpContextAccessor httpContextAccessor)
{
_userManager = userManager;
_httpContextAccessor = httpContextAccessor;
}

public async Task<AppUser> GetAsync()
{
string? userEmail = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userEmail))
{
throw new InvalidOperationException("User is not authenticated");
}

AppUser? user = await _userManager.FindByEmailAsync(userEmail);
if (user == null)
{
throw new InvalidOperationException("User not found");
}

return user;
}
}
Loading
Loading