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" ]
+ }
}