diff --git a/.gitignore b/.gitignore index ba7a514..7485910 100644 --- a/.gitignore +++ b/.gitignore @@ -234,6 +234,7 @@ PublishScripts/ # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets +nuget.config # Microsoft Azure Build Output csx/ diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs new file mode 100644 index 0000000..b79c7c6 --- /dev/null +++ b/Controllers/AuthController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace CampusWorkshops.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + // Usuários de exemplo (NÃO use plaintext em produção) + private static readonly Dictionary Users = new() + { + ["admin@campus"] = ("admin123", new[] { "Admin", "Instructor" }), + ["inst@campus"] = ("inst123", new[] { "Instructor" }), + ["aluno@campus"] = ("aluno123", Array.Empty()) + }; + + private readonly IConfiguration _cfg; + public AuthController(IConfiguration cfg) => _cfg = cfg; + + public record LoginRequest(string Username, string Password); + public record TokenResponse(string AccessToken, DateTime ExpiresAt); + + [HttpPost("login")] + [ProducesResponseType(typeof(TokenResponse), 200)] + [ProducesResponseType(401)] + public IActionResult Login([FromBody] LoginRequest body) + { + if (!Users.TryGetValue(body.Username, out var u) || u.Password != body.Password) + return Unauthorized(); + + var jwt = _cfg.GetSection("Jwt"); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["Key"]!)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, body.Username), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + claims.AddRange(u.Roles.Select(r => new Claim(ClaimTypes.Role, r))); + + var expires = DateTime.UtcNow.AddHours(2); + var token = new JwtSecurityToken( + issuer: jwt["Issuer"], + audience: jwt["Audience"], + claims: claims, + expires: expires, + signingCredentials: creds + ); + var encoded = new JwtSecurityTokenHandler().WriteToken(token); + return Ok(new TokenResponse(encoded, expires)); + } +} diff --git a/Controllers/WorkshopsController.cs b/Controllers/WorkshopsController.cs index 8b37c04..43f625c 100644 --- a/Controllers/WorkshopsController.cs +++ b/Controllers/WorkshopsController.cs @@ -2,6 +2,7 @@ using CampusWorkshops.Api.Dtos; using CampusWorkshops.Api.Models; using CampusWorkshops.Api.Repositories; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace CampusWorkshops.Api.Controllers; @@ -16,10 +17,11 @@ public class WorkshopsController : ControllerBase /// Lista workshops com filtros opcionais. [HttpGet] + [Authorize] public async Task GetAll([FromQuery] DateTimeOffset? from, [FromQuery] DateTimeOffset? to, [FromQuery] string? q, CancellationToken ct) { var workshops = await _repo.GetAllAsync(from, to, q, ct); - + var response = workshops.Select(w => new WorkshopResponse( w.Id, w.Title, @@ -36,10 +38,11 @@ public class WorkshopsController : ControllerBase /// Obtém um workshop por Id. [HttpGet("{id:guid}")] + [Authorize] public async Task GetById(Guid id, CancellationToken ct) { var workshop = await _repo.GetByIdAsync(id, ct); - + if (workshop == null) { return NotFound(); @@ -61,6 +64,7 @@ public class WorkshopsController : ControllerBase /// Cria um novo workshop. [HttpPost] + [Authorize(Policy = "CanWriteWorkshops")] public async Task Create([FromBody] CreateWorkshopRequest body, CancellationToken ct) { if (!ModelState.IsValid) @@ -119,6 +123,8 @@ public class WorkshopsController : ControllerBase /// Atualiza parcialmente um workshop existente. [HttpPatch("{id:guid}")] + [Authorize(Policy = "CanWriteWorkshops")] + public async Task Patch(Guid id, [FromBody] PatchWorkshopRequest body, CancellationToken ct) { if (!ModelState.IsValid) @@ -135,10 +141,10 @@ public class WorkshopsController : ControllerBase // Aplicar apenas os campos fornecidos if (body.Title != null) existingWorkshop.Title = body.Title; - + if (body.Description != null) existingWorkshop.Description = body.Description; - + if (body.StartAt.HasValue) { if (body.StartAt.Value < DateTimeOffset.UtcNow) @@ -147,13 +153,13 @@ public class WorkshopsController : ControllerBase } existingWorkshop.StartAt = body.StartAt.Value; } - + if (body.EndAt.HasValue) existingWorkshop.EndAt = body.EndAt.Value; - + if (body.Location != null) existingWorkshop.Location = body.Location; - + if (body.Capacity.HasValue) { if (body.Capacity.Value < 1) @@ -162,7 +168,7 @@ public class WorkshopsController : ControllerBase } existingWorkshop.Capacity = body.Capacity.Value; } - + if (body.IsOnline.HasValue) existingWorkshop.IsOnline = body.IsOnline.Value; @@ -199,6 +205,8 @@ public class WorkshopsController : ControllerBase /// Remove um workshop. [HttpDelete("{id:guid}")] + [Authorize(Policy = "CanDeleteWorkshops")] + public async Task Delete(Guid id, CancellationToken ct) { var workshop = await _repo.GetByIdAsync(id, ct); diff --git a/Program.cs b/Program.cs index 0e0069a..67ff0c1 100644 --- a/Program.cs +++ b/Program.cs @@ -4,9 +4,42 @@ using Microsoft.AspNetCore.Mvc; using CampusWorkshops.Api.Repositories; using Microsoft.EntityFrameworkCore; using CampusWorkshops.Api.Infrastructure.Data; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); +// JWT +var jwt = builder.Configuration.GetSection("Jwt"); +var keyBytes = Encoding.UTF8.GetBytes(jwt["Key"]!); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new() + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwt["Issuer"], + ValidAudience = jwt["Audience"], + IssuerSigningKey = new SymmetricSecurityKey(keyBytes), + ClockSkew = TimeSpan.FromMinutes(1) // previsível para testes + }; + }); + +builder.Services.AddAuthorization(); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("CanWriteWorkshops", p => p.RequireRole("Instructor","Admin")); + options.AddPolicy("CanDeleteWorkshops", p => p.RequireRole("Admin")); + options.AddPolicy("CanViewAnalytics", p => p.RequireRole("Admin")); +}); + // Add services builder.Services.AddDbContext(options => options.UseSqlite(builder.Configuration.GetConnectionString("WorkshopsDb"))); @@ -22,6 +55,37 @@ builder.Services.AddSwaggerGen(o => Version = "v1", Description = "API para gestão de workshops do campus (MVP in-memory)." }); + var bearerScheme = new OpenApiSecurityScheme + { + Name = "Authorization", + Description = "Cole apenas o JWT (sem 'Bearer ').", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, // <- IMPORTANTE + Scheme = "bearer", // <- minúsculo + BearerFormat = "JWT", + Reference = new OpenApiReference // <- garante que o requirement aponte para esta definição + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }; + + o.AddSecurityDefinition("Bearer", bearerScheme); + + o.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); }); // DI @@ -51,6 +115,9 @@ app.UseExceptionHandler(errApp => app.UseHttpsRedirection(); +app.UseAuthentication(); // <-- antes +app.UseAuthorization(); // <-- depois + app.UseSwagger(); app.UseSwaggerUI(c => { diff --git a/Workshop10-API.csproj b/Workshop10-API.csproj index fc6fcda..725b6a8 100644 --- a/Workshop10-API.csproj +++ b/Workshop10-API.csproj @@ -8,6 +8,7 @@ + diff --git a/appsettings.json b/appsettings.json index 4d56694..fb28713 100644 --- a/appsettings.json +++ b/appsettings.json @@ -5,5 +5,10 @@ "Microsoft.AspNetCore": "Warning" } }, + "Jwt": { + "Issuer": "CampusWorkshops", + "Audience": "CampusWorkshops.Api", + "Key": "some-key" + }, "AllowedHosts": "*" }