diff --git a/API/ROLAC.API/Controllers/AuthController.cs b/API/ROLAC.API/Controllers/AuthController.cs new file mode 100644 index 0000000..e773cfa --- /dev/null +++ b/API/ROLAC.API/Controllers/AuthController.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Auth; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/auth")] +public class AuthController : ControllerBase +{ + private const string CookieName = "rolac_rt"; + private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds + + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + => _authService = authService; + + // ------------------------------------------------------------------------- + // POST /api/auth/login + // ------------------------------------------------------------------------- + + /// Authenticates a user and returns an access token. + [HttpPost("login")] + [AllowAnonymous] + [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Login([FromBody] LoginRequest request) + { + try + { + var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); + var device = Request.Headers.UserAgent.FirstOrDefault(); + var (response, raw) = await _authService.LoginAsync(request, ip, device); + SetRefreshCookie(raw); + return Ok(response); + } + catch (UnauthorizedAccessException ex) + { + return Unauthorized(new { message = ex.Message }); + } + } + + // ------------------------------------------------------------------------- + // POST /api/auth/refresh + // ------------------------------------------------------------------------- + + /// + /// Rotates the refresh token (read from the HttpOnly cookie) and returns a + /// new access token. + /// + [HttpPost("refresh")] + [AllowAnonymous] + [ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Refresh() + { + var raw = Request.Cookies[CookieName]; + if (string.IsNullOrEmpty(raw)) + return Unauthorized(new { message = "Refresh token not found." }); + + try + { + var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); + var (response, newRaw) = await _authService.RefreshAsync(raw, ip); + SetRefreshCookie(newRaw); + return Ok(response); + } + catch (UnauthorizedAccessException ex) + { + ClearRefreshCookie(); + return Unauthorized(new { message = ex.Message }); + } + } + + // ------------------------------------------------------------------------- + // POST /api/auth/logout + // ------------------------------------------------------------------------- + + /// Revokes the current refresh token and clears the cookie. + [HttpPost("logout")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task Logout() + { + var raw = Request.Cookies[CookieName]; + if (!string.IsNullOrEmpty(raw)) + await _authService.LogoutAsync(raw); + + ClearRefreshCookie(); + return NoContent(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private void SetRefreshCookie(string rawToken) + => Response.Cookies.Append(CookieName, rawToken, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + MaxAge = TimeSpan.FromSeconds(CookieMaxAge), + Path = "/api/auth", + }); + + private void ClearRefreshCookie() + => Response.Cookies.Delete(CookieName, new CookieOptions + { + HttpOnly = true, + Secure = true, + SameSite = SameSiteMode.Strict, + Path = "/api/auth", + }); +} diff --git a/API/ROLAC.API/Data/DbSeeder.cs b/API/ROLAC.API/Data/DbSeeder.cs index 458e000..a5aa05d 100644 --- a/API/ROLAC.API/Data/DbSeeder.cs +++ b/API/ROLAC.API/Data/DbSeeder.cs @@ -37,6 +37,22 @@ public static class DbSeeder } } + /// + /// Seeds roles and (in Development) the default admin account. + /// Called once on application startup after migrations have been applied. + /// + public static async Task SeedAsync(IServiceProvider services) + { + var roleManager = services.GetRequiredService>(); + var userManager = services.GetRequiredService>(); + var env = services.GetRequiredService(); + + await SeedRolesAsync(roleManager); + + if (env.IsDevelopment()) + await SeedAdminUserAsync(userManager); + } + /// /// Creates a super_admin test account for local development. /// DO NOT call this in production — remove or guard with IsDevelopment(). diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index df2434c..e24be6b 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -1,23 +1,137 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using ROLAC.API.Data; +using ROLAC.API.Entities; +using ROLAC.API.Services; + var builder = WebApplication.CreateBuilder(args); +var config = builder.Configuration; -// Add services to the container. +// --------------------------------------------------------------------------- +// Database +// --------------------------------------------------------------------------- +builder.Services.AddDbContext(opt => + opt.UseNpgsql(config.GetConnectionString("DefaultConnection"))); +// --------------------------------------------------------------------------- +// Identity +// --------------------------------------------------------------------------- +builder.Services + .AddIdentity(opt => + { + opt.Password.RequiredLength = 8; + opt.Password.RequireDigit = true; + opt.Password.RequireUppercase = true; + opt.Password.RequireLowercase = true; + opt.Password.RequireNonAlphanumeric = true; + opt.User.RequireUniqueEmail = true; + opt.SignIn.RequireConfirmedAccount = false; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + +// --------------------------------------------------------------------------- +// JWT Authentication +// --------------------------------------------------------------------------- +var jwtKey = config["Jwt:SecretKey"] + ?? throw new InvalidOperationException("Jwt:SecretKey is not configured."); + +builder.Services + .AddAuthentication(opt => + { + opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(opt => + { + opt.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = config["Jwt:Issuer"], + ValidAudience = config["Jwt:Audience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + // Roles were written as JWT short name "role"; map to ClaimTypes.Role for [Authorize]. + RoleClaimType = "role", + ClockSkew = TimeSpan.Zero, + }; + }); + +// --------------------------------------------------------------------------- +// CORS +// --------------------------------------------------------------------------- +var allowedOrigins = config.GetSection("Cors:AllowedOrigins").Get() + ?? ["http://localhost:4200"]; + +builder.Services.AddCors(opt => + opt.AddPolicy("Angular", policy => + policy.WithOrigins(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials())); + +// --------------------------------------------------------------------------- +// Application services +// --------------------------------------------------------------------------- +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// --------------------------------------------------------------------------- +// Swagger / MVC +// --------------------------------------------------------------------------- builder.Services.AddControllers(); -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(opt => +{ + opt.SwaggerDoc("v1", new() { Title = "ROLAC API", Version = "v1" }); + // Enable JWT in Swagger UI + opt.AddSecurityDefinition("Bearer", new() + { + Name = "Authorization", + Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = Microsoft.OpenApi.Models.ParameterLocation.Header, + Description = "Enter your JWT access token.", + }); + opt.AddSecurityRequirement(new() + { + { + new() { Reference = new() { Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, Id = "Bearer" } }, + [] + } + }); +}); + +// --------------------------------------------------------------------------- +// Build +// --------------------------------------------------------------------------- var app = builder.Build(); -// Configure the HTTP request pipeline. +// Apply migrations + seed on startup +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + await DbSeeder.SeedAsync(scope.ServiceProvider); +} + if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } +app.UseHttpsRedirection(); +app.UseCors("Angular"); +app.UseAuthentication(); app.UseAuthorization(); - app.MapControllers(); app.Run(); diff --git a/API/ROLAC.API/appsettings.json b/API/ROLAC.API/appsettings.json index 10f68b8..66a1488 100644 --- a/API/ROLAC.API/appsettings.json +++ b/API/ROLAC.API/appsettings.json @@ -5,5 +5,14 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Jwt": { + "Issuer": "rolac-api", + "Audience": "rolac-client", + "AccessTokenExpiryMinutes": "15", + "RefreshTokenExpiryDays": "30" + }, + "Cors": { + "AllowedOrigins": [ "https://localhost:4200" ] + } }