1688 lines
55 KiB
Markdown
1688 lines
55 KiB
Markdown
# Login API (JWT + ASP.NET Identity) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Implement three auth endpoints (`POST /api/auth/login`, `/api/auth/refresh`, `/api/auth/logout`) using ASP.NET Core Identity for user management and custom JWT + DB-stored refresh tokens for stateless authentication.
|
||
|
||
**Architecture:** ASP.NET Core Identity manages users, roles, and password hashing. A `TokenService` generates HS256 JWTs (15-minute lifetime) and cryptographically-random refresh tokens stored as SHA-256 hashes in a PostgreSQL `RefreshTokens` table. The refresh token travels in an `HttpOnly; Secure; SameSite=Strict` cookie (`rolac_rt`); the access token is returned in the JSON response body. Every refresh rotates the token (old revoked, new issued) to limit replay windows.
|
||
|
||
**Tech Stack:** .NET 8, ASP.NET Core Identity, EF Core 8, Npgsql.EntityFrameworkCore.PostgreSQL, Microsoft.IdentityModel.Tokens (JWT), xUnit, Moq, EF InMemory (tests)
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
**New files:**
|
||
| Path | Responsibility |
|
||
|------|----------------|
|
||
| `API/ROLAC.API/Entities/AppUser.cs` | `IdentityUser` subclass — adds MemberId, IsActive, LastLoginAt, LanguagePreference |
|
||
| `API/ROLAC.API/Entities/AppRole.cs` | `IdentityRole` subclass — adds Description |
|
||
| `API/ROLAC.API/Entities/RefreshToken.cs` | DB record per issued refresh token (hashed, revocable, rotatable) |
|
||
| `API/ROLAC.API/Data/AppDbContext.cs` | `IdentityDbContext<AppUser, AppRole, string>` + `RefreshTokens` DbSet |
|
||
| `API/ROLAC.API/Data/DbSeeder.cs` | Seeds 13 RBAC roles + a dev super_admin user on startup |
|
||
| `API/ROLAC.API/DTOs/Auth/LoginRequest.cs` | `{ Email, Password }` with validation attributes |
|
||
| `API/ROLAC.API/DTOs/Auth/LoginResponse.cs` | `{ AccessToken, ExpiresIn, User }` |
|
||
| `API/ROLAC.API/Services/ITokenService.cs` | Interface: GenerateAccessToken / GenerateRefreshToken / HashToken |
|
||
| `API/ROLAC.API/Services/TokenService.cs` | Implementation |
|
||
| `API/ROLAC.API/Services/IAuthService.cs` | Interface: LoginAsync / RefreshAsync / LogoutAsync |
|
||
| `API/ROLAC.API/Services/AuthService.cs` | Implementation |
|
||
| `API/ROLAC.API/Controllers/AuthController.cs` | 3 endpoints wired to AuthService |
|
||
| `API/ROLAC.API.Tests/ROLAC.API.Tests.csproj` | xUnit + Moq test project |
|
||
| `API/ROLAC.API.Tests/Services/TokenServiceTests.cs` | 7 unit tests for TokenService |
|
||
| `API/ROLAC.API.Tests/Services/AuthServiceTests.cs` | 9 unit tests for AuthService |
|
||
|
||
**Modified files:**
|
||
| Path | What changes |
|
||
|------|--------------|
|
||
| `API/ROLAC.API/ROLAC.API.csproj` | Add 5 NuGet packages |
|
||
| `API/ROLAC.API/Program.cs` | Wire Identity, EF Core, JWT auth, CORS, DI, startup seeding |
|
||
| `API/ROLAC.API/appsettings.json` | Add Jwt section (non-secret values only) |
|
||
| `API/ROLAC.API/appsettings.Development.json` | Add ConnectionStrings + Jwt:SecretKey (gitignored) |
|
||
| `API/ROLAC.API/ROLAC.API.sln` | Add test project reference |
|
||
|
||
---
|
||
|
||
## Task 1: Install NuGet packages
|
||
|
||
**Files:** Modify `API/ROLAC.API/ROLAC.API.csproj`
|
||
|
||
- [ ] **Step 1: Add the 5 required packages**
|
||
|
||
Run from `E:\VSProject\ROLAC`:
|
||
```powershell
|
||
dotnet add API/ROLAC.API/ROLAC.API.csproj package Microsoft.AspNetCore.Identity.EntityFrameworkCore --version 8.0.11
|
||
dotnet add API/ROLAC.API/ROLAC.API.csproj package Microsoft.EntityFrameworkCore.Design --version 8.0.11
|
||
dotnet add API/ROLAC.API/ROLAC.API.csproj package Npgsql.EntityFrameworkCore.PostgreSQL --version 8.0.11
|
||
dotnet add API/ROLAC.API/ROLAC.API.csproj package Microsoft.AspNetCore.Authentication.JwtBearer --version 8.0.11
|
||
dotnet add API/ROLAC.API/ROLAC.API.csproj package Microsoft.IdentityModel.Tokens --version 7.5.2
|
||
```
|
||
|
||
- [ ] **Step 2: Restore and verify**
|
||
|
||
```powershell
|
||
dotnet restore API/ROLAC.API/ROLAC.API.csproj
|
||
dotnet build API/ROLAC.API/ROLAC.API.csproj
|
||
```
|
||
|
||
Expected: `Build succeeded. 0 Error(s)`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```powershell
|
||
git add API/ROLAC.API/ROLAC.API.csproj
|
||
git commit -m "chore: add Identity, EF Core PostgreSQL, JWT Bearer packages"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Create entities
|
||
|
||
**Files:**
|
||
- Create `API/ROLAC.API/Entities/AppUser.cs`
|
||
- Create `API/ROLAC.API/Entities/AppRole.cs`
|
||
- Create `API/ROLAC.API/Entities/RefreshToken.cs`
|
||
|
||
- [ ] **Step 1: Create `Entities/AppUser.cs`**
|
||
|
||
```csharp
|
||
using Microsoft.AspNetCore.Identity;
|
||
|
||
namespace ROLAC.API.Entities;
|
||
|
||
public class AppUser : IdentityUser
|
||
{
|
||
/// <summary>Links this login account to a church member record. Null for admin-only accounts.</summary>
|
||
public int? MemberId { get; set; }
|
||
|
||
/// <summary>UI language preference: 'en' or 'zh-TW'.</summary>
|
||
public string LanguagePreference { get; set; } = "en";
|
||
|
||
/// <summary>False = account suspended (returns 403 even with correct password).</summary>
|
||
public bool IsActive { get; set; } = true;
|
||
|
||
public DateTime? LastLoginAt { get; set; }
|
||
|
||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||
|
||
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `Entities/AppRole.cs`**
|
||
|
||
```csharp
|
||
using Microsoft.AspNetCore.Identity;
|
||
|
||
namespace ROLAC.API.Entities;
|
||
|
||
public class AppRole : IdentityRole
|
||
{
|
||
public string? Description { get; set; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `Entities/RefreshToken.cs`**
|
||
|
||
```csharp
|
||
namespace ROLAC.API.Entities;
|
||
|
||
public class RefreshToken
|
||
{
|
||
public int Id { get; set; }
|
||
|
||
public string UserId { get; set; } = null!;
|
||
public AppUser User { get; set; } = null!;
|
||
|
||
/// <summary>SHA-256 hex of the raw token sent to the client. Never store raw tokens.</summary>
|
||
public string TokenHash { get; set; } = null!;
|
||
|
||
public DateTime ExpiresAt { get; set; }
|
||
public DateTime CreatedAt { get; set; }
|
||
|
||
/// <summary>Set when this token is revoked (logout or rotation).</summary>
|
||
public DateTime? RevokedAt { get; set; }
|
||
|
||
/// <summary>Points to the hash of the token that replaced this one during rotation.</summary>
|
||
public string? ReplacedByHash { get; set; }
|
||
|
||
public string? DeviceInfo { get; set; }
|
||
public string? IpAddress { get; set; }
|
||
|
||
// Computed helpers — NOT mapped to DB columns (ignored in OnModelCreating)
|
||
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
|
||
public bool IsRevoked => RevokedAt.HasValue;
|
||
public bool IsActive => !IsRevoked && !IsExpired;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Build to verify**
|
||
|
||
```powershell
|
||
dotnet build API/ROLAC.API/ROLAC.API.csproj
|
||
```
|
||
|
||
Expected: `Build succeeded. 0 Error(s)`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```powershell
|
||
git add API/ROLAC.API/Entities/
|
||
git commit -m "feat: add AppUser, AppRole, RefreshToken entities"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Create AppDbContext and DbSeeder
|
||
|
||
**Files:**
|
||
- Create `API/ROLAC.API/Data/AppDbContext.cs`
|
||
- Create `API/ROLAC.API/Data/DbSeeder.cs`
|
||
|
||
- [ ] **Step 1: Create `Data/AppDbContext.cs`**
|
||
|
||
```csharp
|
||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using ROLAC.API.Entities;
|
||
|
||
namespace ROLAC.API.Data;
|
||
|
||
public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||
{
|
||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||
|
||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||
|
||
protected override void OnModelCreating(ModelBuilder builder)
|
||
{
|
||
base.OnModelCreating(builder);
|
||
|
||
builder.Entity<RefreshToken>(entity =>
|
||
{
|
||
entity.HasKey(e => e.Id);
|
||
|
||
// Unique index on hash — enables fast lookup and prevents duplicate tokens
|
||
entity.HasIndex(e => e.TokenHash).IsUnique();
|
||
|
||
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
|
||
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
|
||
entity.Property(e => e.DeviceInfo).HasMaxLength(200);
|
||
entity.Property(e => e.IpAddress).HasMaxLength(45);
|
||
entity.Property(e => e.ReplacedByHash).HasMaxLength(64);
|
||
|
||
entity.HasOne(e => e.User)
|
||
.WithMany(u => u.RefreshTokens)
|
||
.HasForeignKey(e => e.UserId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
// Computed properties are not DB columns
|
||
entity.Ignore(e => e.IsExpired);
|
||
entity.Ignore(e => e.IsRevoked);
|
||
entity.Ignore(e => e.IsActive);
|
||
});
|
||
|
||
builder.Entity<AppUser>(entity =>
|
||
{
|
||
entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en");
|
||
});
|
||
|
||
builder.Entity<AppRole>(entity =>
|
||
{
|
||
entity.Property(e => e.Description).HasMaxLength(500);
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `Data/DbSeeder.cs`**
|
||
|
||
```csharp
|
||
using Microsoft.AspNetCore.Identity;
|
||
using ROLAC.API.Entities;
|
||
|
||
namespace ROLAC.API.Data;
|
||
|
||
public static class DbSeeder
|
||
{
|
||
private static readonly (string Name, string Description)[] Roles =
|
||
[
|
||
("super_admin", "System administrator — full access"),
|
||
("pastor", "Pastor — full member and financial overview"),
|
||
("board_member", "Board member — church governance"),
|
||
("coworker_chair", "Coworker chair — coordinates ministry leaders"),
|
||
("ministry_leader", "Ministry leader — scoped to own ministry"),
|
||
("district_leader", "District leader — manages multiple cell groups"),
|
||
("cell_leader", "Cell leader — scoped to own cell group"),
|
||
("coworker", "Coworker — general worker in assigned ministry"),
|
||
("finance", "Finance — manages giving and expense reports"),
|
||
("secretary", "Secretary — manages member data and scheduling"),
|
||
("worship_leader", "Worship leader — manages song library and setlists (Phase deferred)"),
|
||
("member", "Member — views own profile and service roster"),
|
||
("visitor", "Visitor — public pages only"),
|
||
];
|
||
|
||
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
|
||
{
|
||
foreach (var (name, description) in Roles)
|
||
{
|
||
if (!await roleManager.RoleExistsAsync(name))
|
||
{
|
||
await roleManager.CreateAsync(new AppRole
|
||
{
|
||
Name = name,
|
||
Description = description,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Creates a super_admin test account for local development.
|
||
/// DO NOT call this in production — remove or guard with IsDevelopment().
|
||
/// Credentials: admin@rolac.org / Admin1234!
|
||
/// </summary>
|
||
public static async Task SeedAdminUserAsync(UserManager<AppUser> userManager)
|
||
{
|
||
const string adminEmail = "admin@rolac.org";
|
||
const string adminPassword = "Admin1234!";
|
||
|
||
if (await userManager.FindByEmailAsync(adminEmail) is null)
|
||
{
|
||
var admin = new AppUser
|
||
{
|
||
UserName = adminEmail,
|
||
Email = adminEmail,
|
||
EmailConfirmed = true,
|
||
IsActive = true,
|
||
LanguagePreference = "en",
|
||
CreatedAt = DateTime.UtcNow,
|
||
};
|
||
|
||
var result = await userManager.CreateAsync(admin, adminPassword);
|
||
if (result.Succeeded)
|
||
await userManager.AddToRoleAsync(admin, "super_admin");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Build to verify**
|
||
|
||
```powershell
|
||
dotnet build API/ROLAC.API/ROLAC.API.csproj
|
||
```
|
||
|
||
Expected: `Build succeeded. 0 Error(s)`
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```powershell
|
||
git add API/ROLAC.API/Data/
|
||
git commit -m "feat: add AppDbContext (Identity + RefreshTokens) and DbSeeder (13 roles + dev admin)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Create DTOs
|
||
|
||
**Files:**
|
||
- Create `API/ROLAC.API/DTOs/Auth/LoginRequest.cs`
|
||
- Create `API/ROLAC.API/DTOs/Auth/LoginResponse.cs`
|
||
|
||
- [ ] **Step 1: Create `DTOs/Auth/LoginRequest.cs`**
|
||
|
||
```csharp
|
||
using System.ComponentModel.DataAnnotations;
|
||
|
||
namespace ROLAC.API.DTOs.Auth;
|
||
|
||
public class LoginRequest
|
||
{
|
||
[Required]
|
||
[EmailAddress]
|
||
[MaxLength(256)]
|
||
public string Email { get; set; } = null!;
|
||
|
||
[Required]
|
||
[MinLength(8)]
|
||
[MaxLength(128)]
|
||
public string Password { get; set; } = null!;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `DTOs/Auth/LoginResponse.cs`**
|
||
|
||
```csharp
|
||
namespace ROLAC.API.DTOs.Auth;
|
||
|
||
public class LoginResponse
|
||
{
|
||
/// <summary>Short-lived JWT (15 min). Store in memory — never in localStorage.</summary>
|
||
public string AccessToken { get; set; } = null!;
|
||
|
||
/// <summary>Seconds until the access token expires. Always 900 (15 × 60).</summary>
|
||
public int ExpiresIn { get; set; }
|
||
|
||
public UserInfo User { get; set; } = null!;
|
||
}
|
||
|
||
public class UserInfo
|
||
{
|
||
public string Id { get; set; } = null!;
|
||
public string Email { get; set; } = null!;
|
||
public IList<string> Roles { get; set; } = [];
|
||
public string LanguagePreference { get; set; } = "en";
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```powershell
|
||
git add API/ROLAC.API/DTOs/
|
||
git commit -m "feat: add LoginRequest and LoginResponse DTOs"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Create test project + TokenService (TDD)
|
||
|
||
**Files:**
|
||
- Create `API/ROLAC.API.Tests/ROLAC.API.Tests.csproj`
|
||
- Create `API/ROLAC.API/Services/ITokenService.cs`
|
||
- Create `API/ROLAC.API/Services/TokenService.cs`
|
||
- Create `API/ROLAC.API.Tests/Services/TokenServiceTests.cs`
|
||
|
||
- [ ] **Step 1: Create `API/ROLAC.API.Tests/ROLAC.API.Tests.csproj`**
|
||
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
|
||
<PropertyGroup>
|
||
<TargetFramework>net8.0</TargetFramework>
|
||
<Nullable>enable</Nullable>
|
||
<ImplicitUsings>enable</ImplicitUsings>
|
||
<IsPackable>false</IsPackable>
|
||
<IsTestProject>true</IsTestProject>
|
||
</PropertyGroup>
|
||
|
||
<ItemGroup>
|
||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||
<PackageReference Include="xunit" Version="2.9.3" />
|
||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||
<PrivateAssets>all</PrivateAssets>
|
||
</PackageReference>
|
||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||
<PrivateAssets>all</PrivateAssets>
|
||
</PackageReference>
|
||
<PackageReference Include="Moq" Version="4.20.72" />
|
||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||
<PackageReference Include="Microsoft.Extensions.Configuration.Memory" Version="8.0.0" />
|
||
</ItemGroup>
|
||
|
||
<ItemGroup>
|
||
<ProjectReference Include="..\ROLAC.API\ROLAC.API.csproj" />
|
||
</ItemGroup>
|
||
|
||
</Project>
|
||
```
|
||
|
||
- [ ] **Step 2: Add test project to solution**
|
||
|
||
```powershell
|
||
dotnet sln API/ROLAC.API/ROLAC.API.sln add API/ROLAC.API.Tests/ROLAC.API.Tests.csproj
|
||
```
|
||
|
||
Expected: `Project 'API/ROLAC.API.Tests/ROLAC.API.Tests.csproj' added to the solution.`
|
||
|
||
- [ ] **Step 3: Write failing tests for TokenService**
|
||
|
||
Create `API/ROLAC.API.Tests/Services/TokenServiceTests.cs`:
|
||
|
||
```csharp
|
||
using System.IdentityModel.Tokens.Jwt;
|
||
using System.Security.Claims;
|
||
using Microsoft.Extensions.Configuration;
|
||
using ROLAC.API.Entities;
|
||
using ROLAC.API.Services;
|
||
|
||
namespace ROLAC.API.Tests.Services;
|
||
|
||
public class TokenServiceTests
|
||
{
|
||
private readonly TokenService _sut;
|
||
|
||
public TokenServiceTests()
|
||
{
|
||
var config = new ConfigurationBuilder()
|
||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||
{
|
||
{ "Jwt:SecretKey", "test-secret-key-that-is-at-least-32-characters-long!!" },
|
||
{ "Jwt:Issuer", "rolac-api-test" },
|
||
{ "Jwt:Audience", "rolac-client-test" },
|
||
{ "Jwt:AccessTokenExpiryMinutes", "15" },
|
||
{ "Jwt:RefreshTokenExpiryDays", "30" },
|
||
})
|
||
.Build();
|
||
|
||
_sut = new TokenService(config);
|
||
}
|
||
|
||
[Fact]
|
||
public void GenerateAccessToken_ReturnsValidJwt_WithUserIdAndRoles()
|
||
{
|
||
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
|
||
var roles = new List<string> { "member", "finance" };
|
||
|
||
var token = _sut.GenerateAccessToken(user, roles);
|
||
|
||
Assert.NotEmpty(token);
|
||
var handler = new JwtSecurityTokenHandler();
|
||
var jwt = handler.ReadJwtToken(token);
|
||
|
||
Assert.Equal("user-123", jwt.Subject);
|
||
Assert.Equal("test@rolac.org", jwt.Claims.First(c => c.Type == JwtRegisteredClaimNames.Email).Value);
|
||
|
||
var roleClaims = jwt.Claims
|
||
.Where(c => c.Type == ClaimTypes.Role)
|
||
.Select(c => c.Value)
|
||
.ToList();
|
||
Assert.Contains("member", roleClaims);
|
||
Assert.Contains("finance", roleClaims);
|
||
}
|
||
|
||
[Fact]
|
||
public void GenerateAccessToken_ExpiresIn15Minutes()
|
||
{
|
||
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
|
||
|
||
var token = _sut.GenerateAccessToken(user, new List<string>());
|
||
|
||
var handler = new JwtSecurityTokenHandler();
|
||
var jwt = handler.ReadJwtToken(token);
|
||
var expectedExpiry = DateTime.UtcNow.AddMinutes(15);
|
||
|
||
Assert.True((jwt.ValidTo - expectedExpiry).Duration() < TimeSpan.FromSeconds(5));
|
||
}
|
||
|
||
[Fact]
|
||
public void GenerateRefreshToken_Returns64ByteBase64String()
|
||
{
|
||
var token = _sut.GenerateRefreshToken();
|
||
|
||
Assert.NotEmpty(token);
|
||
var bytes = Convert.FromBase64String(token);
|
||
Assert.Equal(64, bytes.Length);
|
||
}
|
||
|
||
[Fact]
|
||
public void GenerateRefreshToken_ProducesUniqueTokensEachCall()
|
||
{
|
||
var token1 = _sut.GenerateRefreshToken();
|
||
var token2 = _sut.GenerateRefreshToken();
|
||
|
||
Assert.NotEqual(token1, token2);
|
||
}
|
||
|
||
[Fact]
|
||
public void HashToken_SameInputProducesSameHash()
|
||
{
|
||
const string raw = "some-raw-token-value";
|
||
|
||
var hash1 = _sut.HashToken(raw);
|
||
var hash2 = _sut.HashToken(raw);
|
||
|
||
Assert.Equal(hash1, hash2);
|
||
}
|
||
|
||
[Fact]
|
||
public void HashToken_DifferentInputsProduceDifferentHashes()
|
||
{
|
||
var hash1 = _sut.HashToken("token-a");
|
||
var hash2 = _sut.HashToken("token-b");
|
||
|
||
Assert.NotEqual(hash1, hash2);
|
||
}
|
||
|
||
[Fact]
|
||
public void HashToken_Returns64CharLowercaseHexString()
|
||
{
|
||
var hash = _sut.HashToken("any-token");
|
||
|
||
Assert.Equal(64, hash.Length);
|
||
Assert.True(hash.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run build — expect failure (TokenService doesn't exist yet)**
|
||
|
||
```powershell
|
||
dotnet build API/ROLAC.API.Tests/ROLAC.API.Tests.csproj
|
||
```
|
||
|
||
Expected: Build failure — `The type or namespace name 'TokenService' could not be found`
|
||
|
||
- [ ] **Step 5: Create `Services/ITokenService.cs`**
|
||
|
||
```csharp
|
||
using ROLAC.API.Entities;
|
||
|
||
namespace ROLAC.API.Services;
|
||
|
||
public interface ITokenService
|
||
{
|
||
/// <summary>Generates a signed HS256 JWT containing userId, email, and roles claims.</summary>
|
||
string GenerateAccessToken(AppUser user, IList<string> roles);
|
||
|
||
/// <summary>Generates a cryptographically-random 64-byte base64 string (the raw token value).</summary>
|
||
string GenerateRefreshToken();
|
||
|
||
/// <summary>Returns the SHA-256 hex hash of the raw token. Always hash before storing to DB.</summary>
|
||
string HashToken(string rawToken);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Create `Services/TokenService.cs`**
|
||
|
||
```csharp
|
||
using System.IdentityModel.Tokens.Jwt;
|
||
using System.Security.Claims;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using Microsoft.IdentityModel.Tokens;
|
||
using ROLAC.API.Entities;
|
||
|
||
namespace ROLAC.API.Services;
|
||
|
||
public class TokenService : ITokenService
|
||
{
|
||
private readonly IConfiguration _config;
|
||
|
||
public TokenService(IConfiguration config)
|
||
{
|
||
_config = config;
|
||
}
|
||
|
||
public string GenerateAccessToken(AppUser user, IList<string> roles)
|
||
{
|
||
var secretKey = _config["Jwt:SecretKey"]!;
|
||
var issuer = _config["Jwt:Issuer"]!;
|
||
var audience = _config["Jwt:Audience"]!;
|
||
var expiryMin = int.Parse(_config["Jwt:AccessTokenExpiryMinutes"]!);
|
||
|
||
var claims = new List<Claim>
|
||
{
|
||
new(JwtRegisteredClaimNames.Sub, user.Id),
|
||
new(JwtRegisteredClaimNames.Email, user.Email!),
|
||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||
};
|
||
|
||
foreach (var role in roles)
|
||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||
|
||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
|
||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||
|
||
var token = new JwtSecurityToken(
|
||
issuer: issuer,
|
||
audience: audience,
|
||
claims: claims,
|
||
expires: DateTime.UtcNow.AddMinutes(expiryMin),
|
||
signingCredentials: creds);
|
||
|
||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||
}
|
||
|
||
public string GenerateRefreshToken()
|
||
{
|
||
var bytes = new byte[64];
|
||
using var rng = RandomNumberGenerator.Create();
|
||
rng.GetBytes(bytes);
|
||
return Convert.ToBase64String(bytes);
|
||
}
|
||
|
||
public string HashToken(string rawToken)
|
||
{
|
||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
|
||
return Convert.ToHexString(bytes).ToLower();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Run TokenService tests — expect all 7 pass**
|
||
|
||
```powershell
|
||
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter "FullyQualifiedName~TokenServiceTests" -v normal
|
||
```
|
||
|
||
Expected:
|
||
```
|
||
Test Run Successful.
|
||
Total tests: 7 Passed: 7
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```powershell
|
||
git add API/ROLAC.API/Services/ITokenService.cs API/ROLAC.API/Services/TokenService.cs `
|
||
API/ROLAC.API.Tests/ API/ROLAC.API/ROLAC.API.sln
|
||
git commit -m "feat: add TokenService with JWT generation and refresh token hashing (TDD, 7 tests)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Create AuthService (TDD)
|
||
|
||
**Files:**
|
||
- Create `API/ROLAC.API.Tests/Services/AuthServiceTests.cs`
|
||
- Create `API/ROLAC.API/Services/IAuthService.cs`
|
||
- Create `API/ROLAC.API/Services/AuthService.cs`
|
||
|
||
- [ ] **Step 1: Write failing tests for AuthService**
|
||
|
||
Create `API/ROLAC.API.Tests/Services/AuthServiceTests.cs`:
|
||
|
||
```csharp
|
||
using Microsoft.AspNetCore.Identity;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Configuration;
|
||
using Moq;
|
||
using ROLAC.API.Data;
|
||
using ROLAC.API.Entities;
|
||
using ROLAC.API.Services;
|
||
|
||
namespace ROLAC.API.Tests.Services;
|
||
|
||
public class AuthServiceTests : IDisposable
|
||
{
|
||
private readonly Mock<UserManager<AppUser>> _userManagerMock;
|
||
private readonly AppDbContext _context;
|
||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||
private readonly IConfiguration _config;
|
||
private readonly AuthService _sut;
|
||
|
||
public AuthServiceTests()
|
||
{
|
||
// UserManager requires IUserStore; all other deps can be null for unit tests
|
||
var store = new Mock<IUserStore<AppUser>>();
|
||
_userManagerMock = new Mock<UserManager<AppUser>>(
|
||
store.Object, null, null, null, null, null, null, null, null);
|
||
|
||
// Fresh in-memory DB per test class instance — prevents cross-test pollution
|
||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||
.Options;
|
||
_context = new AppDbContext(options);
|
||
|
||
_tokenServiceMock = new Mock<ITokenService>();
|
||
|
||
_config = new ConfigurationBuilder()
|
||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||
{
|
||
{ "Jwt:RefreshTokenExpiryDays", "30" },
|
||
})
|
||
.Build();
|
||
|
||
_sut = new AuthService(_userManagerMock.Object, _context, _tokenServiceMock.Object, _config);
|
||
}
|
||
|
||
public void Dispose() => _context.Dispose();
|
||
|
||
// ── LoginAsync ─────────────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public async Task LoginAsync_ValidCredentials_ReturnsTokensAndPersistsRefreshToken()
|
||
{
|
||
var user = new AppUser
|
||
{
|
||
Id = "u1",
|
||
Email = "alice@rolac.org",
|
||
UserName = "alice@rolac.org",
|
||
IsActive = true,
|
||
};
|
||
|
||
_userManagerMock.Setup(m => m.FindByEmailAsync("alice@rolac.org")).ReturnsAsync(user);
|
||
_userManagerMock.Setup(m => m.CheckPasswordAsync(user, "Password1!")).ReturnsAsync(true);
|
||
_userManagerMock.Setup(m => m.IsLockedOutAsync(user)).ReturnsAsync(false);
|
||
_userManagerMock.Setup(m => m.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||
_userManagerMock.Setup(m => m.GetRolesAsync(user)).ReturnsAsync(new List<string> { "member" });
|
||
_userManagerMock.Setup(m => m.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||
|
||
_tokenServiceMock.Setup(t => t.GenerateAccessToken(user, It.IsAny<IList<string>>())).Returns("access.token.value");
|
||
_tokenServiceMock.Setup(t => t.GenerateRefreshToken()).Returns("raw-refresh-token");
|
||
_tokenServiceMock.Setup(t => t.HashToken("raw-refresh-token")).Returns("hashed-token-64chars");
|
||
|
||
var result = await _sut.LoginAsync("alice@rolac.org", "Password1!", "127.0.0.1", "TestAgent/1.0");
|
||
|
||
Assert.True(result.Success);
|
||
Assert.Equal("access.token.value", result.AccessToken);
|
||
Assert.Equal("raw-refresh-token", result.RefreshToken);
|
||
Assert.Null(result.Error);
|
||
|
||
// Refresh token must be persisted to DB as a hash
|
||
var stored = await _context.RefreshTokens.SingleAsync();
|
||
Assert.Equal("hashed-token-64chars", stored.TokenHash);
|
||
Assert.Equal("u1", stored.UserId);
|
||
Assert.Equal("127.0.0.1", stored.IpAddress);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task LoginAsync_UserNotFound_ReturnsFailure()
|
||
{
|
||
_userManagerMock.Setup(m => m.FindByEmailAsync("nobody@rolac.org")).ReturnsAsync((AppUser?)null);
|
||
|
||
var result = await _sut.LoginAsync("nobody@rolac.org", "Password1!", null, null);
|
||
|
||
Assert.False(result.Success);
|
||
Assert.Equal("Invalid credentials", result.Error);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task LoginAsync_InactiveUser_ReturnsFailureWithoutCheckingPassword()
|
||
{
|
||
var user = new AppUser { Id = "u2", Email = "inactive@rolac.org", UserName = "inactive@rolac.org", IsActive = false };
|
||
_userManagerMock.Setup(m => m.FindByEmailAsync("inactive@rolac.org")).ReturnsAsync(user);
|
||
|
||
var result = await _sut.LoginAsync("inactive@rolac.org", "Password1!", null, null);
|
||
|
||
Assert.False(result.Success);
|
||
Assert.Equal("Invalid credentials", result.Error);
|
||
// CheckPasswordAsync must never be called for inactive users
|
||
_userManagerMock.Verify(m => m.CheckPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>()), Times.Never);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task LoginAsync_WrongPassword_IncrementsFailCountAndReturnsFailure()
|
||
{
|
||
var user = new AppUser { Id = "u3", Email = "bob@rolac.org", UserName = "bob@rolac.org", IsActive = true };
|
||
_userManagerMock.Setup(m => m.FindByEmailAsync("bob@rolac.org")).ReturnsAsync(user);
|
||
_userManagerMock.Setup(m => m.CheckPasswordAsync(user, "WrongPass!")).ReturnsAsync(false);
|
||
_userManagerMock.Setup(m => m.AccessFailedAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||
|
||
var result = await _sut.LoginAsync("bob@rolac.org", "WrongPass!", null, null);
|
||
|
||
Assert.False(result.Success);
|
||
Assert.Equal("Invalid credentials", result.Error);
|
||
_userManagerMock.Verify(m => m.AccessFailedAsync(user), Times.Once);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task LoginAsync_LockedOutUser_ReturnsLockoutError()
|
||
{
|
||
var user = new AppUser { Id = "u4", Email = "locked@rolac.org", UserName = "locked@rolac.org", IsActive = true };
|
||
_userManagerMock.Setup(m => m.FindByEmailAsync("locked@rolac.org")).ReturnsAsync(user);
|
||
_userManagerMock.Setup(m => m.CheckPasswordAsync(user, "Password1!")).ReturnsAsync(true);
|
||
_userManagerMock.Setup(m => m.IsLockedOutAsync(user)).ReturnsAsync(true);
|
||
|
||
var result = await _sut.LoginAsync("locked@rolac.org", "Password1!", null, null);
|
||
|
||
Assert.False(result.Success);
|
||
Assert.Equal("Account is locked. Try again later.", result.Error);
|
||
}
|
||
|
||
// ── RefreshAsync ────────────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public async Task RefreshAsync_ValidToken_RotatesTokenAndReturnsNewPair()
|
||
{
|
||
var user = new AppUser { Id = "u5", Email = "carol@rolac.org", UserName = "carol@rolac.org", IsActive = true };
|
||
_context.Users.Add(user);
|
||
|
||
var existing = new RefreshToken
|
||
{
|
||
UserId = "u5",
|
||
User = user,
|
||
TokenHash = "existing-hash",
|
||
ExpiresAt = DateTime.UtcNow.AddDays(25),
|
||
CreatedAt = DateTime.UtcNow.AddDays(-5),
|
||
};
|
||
_context.RefreshTokens.Add(existing);
|
||
await _context.SaveChangesAsync();
|
||
|
||
_tokenServiceMock.Setup(t => t.HashToken("raw-existing")).Returns("existing-hash");
|
||
_tokenServiceMock.Setup(t => t.GenerateRefreshToken()).Returns("new-raw-refresh");
|
||
_tokenServiceMock.Setup(t => t.HashToken("new-raw-refresh")).Returns("new-hash");
|
||
_tokenServiceMock.Setup(t => t.GenerateAccessToken(It.IsAny<AppUser>(), It.IsAny<IList<string>>())).Returns("new.access.token");
|
||
_userManagerMock.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>())).ReturnsAsync(new List<string> { "member" });
|
||
|
||
var result = await _sut.RefreshAsync("raw-existing", "127.0.0.1");
|
||
|
||
Assert.True(result.Success);
|
||
Assert.Equal("new.access.token", result.AccessToken);
|
||
Assert.Equal("new-raw-refresh", result.NewRefreshToken);
|
||
|
||
// Old token revoked
|
||
Assert.NotNull(existing.RevokedAt);
|
||
Assert.Equal("new-hash", existing.ReplacedByHash);
|
||
|
||
// New token persisted
|
||
var newToken = await _context.RefreshTokens.SingleAsync(t => t.TokenHash == "new-hash");
|
||
Assert.Equal("u5", newToken.UserId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task RefreshAsync_RevokedToken_ReturnsFailure()
|
||
{
|
||
var user = new AppUser { Id = "u6", Email = "dave@rolac.org", UserName = "dave@rolac.org" };
|
||
_context.Users.Add(user);
|
||
|
||
var revoked = new RefreshToken
|
||
{
|
||
UserId = "u6",
|
||
User = user,
|
||
TokenHash = "revoked-hash",
|
||
ExpiresAt = DateTime.UtcNow.AddDays(20),
|
||
CreatedAt = DateTime.UtcNow.AddDays(-1),
|
||
RevokedAt = DateTime.UtcNow.AddHours(-1), // already revoked
|
||
};
|
||
_context.RefreshTokens.Add(revoked);
|
||
await _context.SaveChangesAsync();
|
||
|
||
_tokenServiceMock.Setup(t => t.HashToken("raw-revoked")).Returns("revoked-hash");
|
||
|
||
var result = await _sut.RefreshAsync("raw-revoked", null);
|
||
|
||
Assert.False(result.Success);
|
||
Assert.Equal("Invalid or expired refresh token", result.Error);
|
||
}
|
||
|
||
// ── LogoutAsync ─────────────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public async Task LogoutAsync_ActiveToken_RevokesTokenAndReturnsTrue()
|
||
{
|
||
var user = new AppUser { Id = "u7", Email = "eve@rolac.org", UserName = "eve@rolac.org" };
|
||
_context.Users.Add(user);
|
||
|
||
var active = new RefreshToken
|
||
{
|
||
UserId = "u7",
|
||
User = user,
|
||
TokenHash = "active-hash",
|
||
ExpiresAt = DateTime.UtcNow.AddDays(29),
|
||
CreatedAt = DateTime.UtcNow,
|
||
};
|
||
_context.RefreshTokens.Add(active);
|
||
await _context.SaveChangesAsync();
|
||
|
||
_tokenServiceMock.Setup(t => t.HashToken("raw-active")).Returns("active-hash");
|
||
|
||
var result = await _sut.LogoutAsync("raw-active");
|
||
|
||
Assert.True(result);
|
||
Assert.NotNull(active.RevokedAt);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task LogoutAsync_UnknownToken_ReturnsFalse()
|
||
{
|
||
_tokenServiceMock.Setup(t => t.HashToken("unknown-raw")).Returns("unknown-hash");
|
||
|
||
var result = await _sut.LogoutAsync("unknown-raw");
|
||
|
||
Assert.False(result);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run build — expect failure (AuthService doesn't exist yet)**
|
||
|
||
```powershell
|
||
dotnet build API/ROLAC.API.Tests/ROLAC.API.Tests.csproj
|
||
```
|
||
|
||
Expected: Build failure — `The type or namespace name 'AuthService' could not be found`
|
||
|
||
- [ ] **Step 3: Create `Services/IAuthService.cs`**
|
||
|
||
```csharp
|
||
namespace ROLAC.API.Services;
|
||
|
||
public interface IAuthService
|
||
{
|
||
/// <summary>
|
||
/// Validates credentials and issues access + refresh tokens.
|
||
/// Returns (Success=false, Error=message) on any failure — same message for user-not-found
|
||
/// and wrong-password to prevent user enumeration.
|
||
/// </summary>
|
||
Task<(bool Success, string? AccessToken, string? RefreshToken, string? Error)> LoginAsync(
|
||
string email, string password, string? ipAddress, string? deviceInfo);
|
||
|
||
/// <summary>
|
||
/// Validates the raw refresh token, revokes it, and issues a rotated replacement.
|
||
/// </summary>
|
||
Task<(bool Success, string? AccessToken, string? NewRefreshToken, string? Error)> RefreshAsync(
|
||
string rawRefreshToken, string? ipAddress);
|
||
|
||
/// <summary>
|
||
/// Revokes the given refresh token. Returns false if not found or already inactive.
|
||
/// </summary>
|
||
Task<bool> LogoutAsync(string rawRefreshToken);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `Services/AuthService.cs`**
|
||
|
||
```csharp
|
||
using Microsoft.AspNetCore.Identity;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using ROLAC.API.Data;
|
||
using ROLAC.API.Entities;
|
||
|
||
namespace ROLAC.API.Services;
|
||
|
||
public class AuthService : IAuthService
|
||
{
|
||
private readonly UserManager<AppUser> _userManager;
|
||
private readonly AppDbContext _context;
|
||
private readonly ITokenService _tokenService;
|
||
private readonly IConfiguration _config;
|
||
|
||
public AuthService(
|
||
UserManager<AppUser> userManager,
|
||
AppDbContext context,
|
||
ITokenService tokenService,
|
||
IConfiguration config)
|
||
{
|
||
_userManager = userManager;
|
||
_context = context;
|
||
_tokenService = tokenService;
|
||
_config = config;
|
||
}
|
||
|
||
public async Task<(bool Success, string? AccessToken, string? RefreshToken, string? Error)> LoginAsync(
|
||
string email, string password, string? ipAddress, string? deviceInfo)
|
||
{
|
||
var user = await _userManager.FindByEmailAsync(email);
|
||
|
||
// Return the same message for "not found" and "inactive" to prevent user enumeration
|
||
if (user is null || !user.IsActive)
|
||
return (false, null, null, "Invalid credentials");
|
||
|
||
var passwordValid = await _userManager.CheckPasswordAsync(user, password);
|
||
if (!passwordValid)
|
||
{
|
||
await _userManager.AccessFailedAsync(user);
|
||
return (false, null, null, "Invalid credentials");
|
||
}
|
||
|
||
if (await _userManager.IsLockedOutAsync(user))
|
||
return (false, null, null, "Account is locked. Try again later.");
|
||
|
||
await _userManager.ResetAccessFailedCountAsync(user);
|
||
|
||
var roles = await _userManager.GetRolesAsync(user);
|
||
var accessToken = _tokenService.GenerateAccessToken(user, roles);
|
||
var rawToken = _tokenService.GenerateRefreshToken();
|
||
var tokenHash = _tokenService.HashToken(rawToken);
|
||
var expiryDays = int.Parse(_config["Jwt:RefreshTokenExpiryDays"]!);
|
||
|
||
_context.RefreshTokens.Add(new RefreshToken
|
||
{
|
||
UserId = user.Id,
|
||
User = user,
|
||
TokenHash = tokenHash,
|
||
ExpiresAt = DateTime.UtcNow.AddDays(expiryDays),
|
||
CreatedAt = DateTime.UtcNow,
|
||
IpAddress = ipAddress,
|
||
DeviceInfo = deviceInfo,
|
||
});
|
||
|
||
user.LastLoginAt = DateTime.UtcNow;
|
||
await _userManager.UpdateAsync(user);
|
||
await _context.SaveChangesAsync();
|
||
|
||
return (true, accessToken, rawToken, null);
|
||
}
|
||
|
||
public async Task<(bool Success, string? AccessToken, string? NewRefreshToken, string? Error)> RefreshAsync(
|
||
string rawRefreshToken, string? ipAddress)
|
||
{
|
||
var tokenHash = _tokenService.HashToken(rawRefreshToken);
|
||
|
||
var stored = await _context.RefreshTokens
|
||
.Include(rt => rt.User)
|
||
.FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash);
|
||
|
||
if (stored is null || !stored.IsActive)
|
||
return (false, null, null, "Invalid or expired refresh token");
|
||
|
||
// Rotate: revoke old token, issue replacement
|
||
var newRaw = _tokenService.GenerateRefreshToken();
|
||
var newHash = _tokenService.HashToken(newRaw);
|
||
var expiryDays = int.Parse(_config["Jwt:RefreshTokenExpiryDays"]!);
|
||
|
||
stored.RevokedAt = DateTime.UtcNow;
|
||
stored.ReplacedByHash = newHash;
|
||
|
||
var roles = await _userManager.GetRolesAsync(stored.User);
|
||
var accessToken = _tokenService.GenerateAccessToken(stored.User, roles);
|
||
|
||
_context.RefreshTokens.Add(new RefreshToken
|
||
{
|
||
UserId = stored.UserId,
|
||
User = stored.User,
|
||
TokenHash = newHash,
|
||
ExpiresAt = DateTime.UtcNow.AddDays(expiryDays),
|
||
CreatedAt = DateTime.UtcNow,
|
||
IpAddress = ipAddress,
|
||
});
|
||
|
||
await _context.SaveChangesAsync();
|
||
|
||
return (true, accessToken, newRaw, null);
|
||
}
|
||
|
||
public async Task<bool> LogoutAsync(string rawRefreshToken)
|
||
{
|
||
var tokenHash = _tokenService.HashToken(rawRefreshToken);
|
||
|
||
var stored = await _context.RefreshTokens
|
||
.FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash);
|
||
|
||
if (stored is null || !stored.IsActive)
|
||
return false;
|
||
|
||
stored.RevokedAt = DateTime.UtcNow;
|
||
await _context.SaveChangesAsync();
|
||
return true;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run AuthService tests — expect all 9 pass**
|
||
|
||
```powershell
|
||
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter "FullyQualifiedName~AuthServiceTests" -v normal
|
||
```
|
||
|
||
Expected:
|
||
```
|
||
Test Run Successful.
|
||
Total tests: 9 Passed: 9
|
||
```
|
||
|
||
- [ ] **Step 6: Run full suite — expect all 16 pass**
|
||
|
||
```powershell
|
||
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -v normal
|
||
```
|
||
|
||
Expected:
|
||
```
|
||
Test Run Successful.
|
||
Total tests: 16 Passed: 16
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```powershell
|
||
git add API/ROLAC.API/Services/IAuthService.cs API/ROLAC.API/Services/AuthService.cs `
|
||
API/ROLAC.API.Tests/Services/AuthServiceTests.cs
|
||
git commit -m "feat: add AuthService with login/refresh/logout logic (TDD, 16 tests passing)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Create AuthController
|
||
|
||
**Files:** Create `API/ROLAC.API/Controllers/AuthController.cs`
|
||
|
||
- [ ] **Step 1: Create `Controllers/AuthController.cs`**
|
||
|
||
```csharp
|
||
using Microsoft.AspNetCore.Identity;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using ROLAC.API.DTOs.Auth;
|
||
using ROLAC.API.Entities;
|
||
using ROLAC.API.Services;
|
||
|
||
namespace ROLAC.API.Controllers;
|
||
|
||
[ApiController]
|
||
[Route("api/auth")]
|
||
public class AuthController : ControllerBase
|
||
{
|
||
private readonly IAuthService _authService;
|
||
private readonly UserManager<AppUser> _userManager;
|
||
private const string CookieName = "rolac_rt";
|
||
|
||
public AuthController(IAuthService authService, UserManager<AppUser> userManager)
|
||
{
|
||
_authService = authService;
|
||
_userManager = userManager;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Authenticate with email and password.
|
||
/// Returns a 15-minute JWT access token in the response body and a 30-day
|
||
/// refresh token in an HttpOnly cookie named <c>rolac_rt</c>.
|
||
/// </summary>
|
||
[HttpPost("login")]
|
||
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||
{
|
||
if (!ModelState.IsValid)
|
||
return BadRequest(ModelState);
|
||
|
||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||
var deviceInfo = Request.Headers.UserAgent.ToString();
|
||
|
||
var (success, accessToken, refreshToken, error) =
|
||
await _authService.LoginAsync(request.Email, request.Password, ipAddress, deviceInfo);
|
||
|
||
if (!success)
|
||
return Unauthorized(new { message = error });
|
||
|
||
var user = await _userManager.FindByEmailAsync(request.Email);
|
||
var roles = await _userManager.GetRolesAsync(user!);
|
||
|
||
SetRefreshTokenCookie(refreshToken!);
|
||
|
||
return Ok(new LoginResponse
|
||
{
|
||
AccessToken = accessToken!,
|
||
ExpiresIn = 900,
|
||
User = new UserInfo
|
||
{
|
||
Id = user!.Id,
|
||
Email = user.Email!,
|
||
Roles = roles,
|
||
LanguagePreference = user.LanguagePreference,
|
||
},
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// Exchange the refresh token cookie for a new access token.
|
||
/// The old refresh token is revoked and a new one is set in the cookie (rotation).
|
||
/// </summary>
|
||
[HttpPost("refresh")]
|
||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||
public async Task<IActionResult> Refresh()
|
||
{
|
||
var rawToken = Request.Cookies[CookieName];
|
||
if (string.IsNullOrEmpty(rawToken))
|
||
return Unauthorized(new { message = "No refresh token provided" });
|
||
|
||
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||
|
||
var (success, accessToken, newRefreshToken, error) =
|
||
await _authService.RefreshAsync(rawToken, ipAddress);
|
||
|
||
if (!success)
|
||
{
|
||
ClearRefreshTokenCookie();
|
||
return Unauthorized(new { message = error });
|
||
}
|
||
|
||
SetRefreshTokenCookie(newRefreshToken!);
|
||
|
||
return Ok(new { accessToken, expiresIn = 900 });
|
||
}
|
||
|
||
/// <summary>
|
||
/// Revoke the current refresh token and clear the cookie.
|
||
/// Safe to call even when no cookie is present.
|
||
/// </summary>
|
||
[HttpPost("logout")]
|
||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||
public async Task<IActionResult> Logout()
|
||
{
|
||
var rawToken = Request.Cookies[CookieName];
|
||
if (!string.IsNullOrEmpty(rawToken))
|
||
await _authService.LogoutAsync(rawToken);
|
||
|
||
ClearRefreshTokenCookie();
|
||
return NoContent();
|
||
}
|
||
|
||
private void SetRefreshTokenCookie(string rawToken)
|
||
{
|
||
Response.Cookies.Append(CookieName, rawToken, new CookieOptions
|
||
{
|
||
HttpOnly = true,
|
||
Secure = true,
|
||
SameSite = SameSiteMode.Strict,
|
||
Expires = DateTimeOffset.UtcNow.AddDays(30),
|
||
});
|
||
}
|
||
|
||
private void ClearRefreshTokenCookie()
|
||
{
|
||
Response.Cookies.Delete(CookieName, new CookieOptions
|
||
{
|
||
HttpOnly = true,
|
||
Secure = true,
|
||
SameSite = SameSiteMode.Strict,
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build to verify**
|
||
|
||
```powershell
|
||
dotnet build API/ROLAC.API/ROLAC.API.csproj
|
||
```
|
||
|
||
Expected: `Build succeeded. 0 Error(s)`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```powershell
|
||
git add API/ROLAC.API/Controllers/AuthController.cs
|
||
git commit -m "feat: add AuthController with POST /api/auth/login, /refresh, /logout"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Configure appsettings
|
||
|
||
**Files:**
|
||
- Modify `API/ROLAC.API/appsettings.json`
|
||
- Modify `API/ROLAC.API/appsettings.Development.json`
|
||
|
||
- [ ] **Step 1: Replace `appsettings.json` — non-secret values only**
|
||
|
||
```json
|
||
{
|
||
"Logging": {
|
||
"LogLevel": {
|
||
"Default": "Information",
|
||
"Microsoft.AspNetCore": "Warning"
|
||
}
|
||
},
|
||
"AllowedHosts": "*",
|
||
"Jwt": {
|
||
"Issuer": "rolac-api",
|
||
"Audience": "rolac-client",
|
||
"AccessTokenExpiryMinutes": "15",
|
||
"RefreshTokenExpiryDays": "30"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Replace `appsettings.Development.json` — local dev secrets**
|
||
|
||
```json
|
||
{
|
||
"Logging": {
|
||
"LogLevel": {
|
||
"Default": "Information",
|
||
"Microsoft.AspNetCore": "Warning",
|
||
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
||
}
|
||
},
|
||
"ConnectionStrings": {
|
||
"DefaultConnection": "Host=localhost;Port=5432;Database=rolac_dev;Username=postgres;Password=YOUR_LOCAL_POSTGRES_PASSWORD"
|
||
},
|
||
"Jwt": {
|
||
"SecretKey": "dev-only-secret-key-must-be-at-least-32-chars-long-change-in-prod!!"
|
||
}
|
||
}
|
||
```
|
||
|
||
⚠️ **Replace `YOUR_LOCAL_POSTGRES_PASSWORD`** with your actual local PostgreSQL password.
|
||
⚠️ **This file must NOT be committed.** Verify it is gitignored before proceeding.
|
||
|
||
- [ ] **Step 3: Ensure `appsettings.Development.json` is gitignored**
|
||
|
||
```powershell
|
||
Select-String "appsettings.Development" .gitignore
|
||
```
|
||
|
||
If the output is empty, add it:
|
||
```powershell
|
||
Add-Content .gitignore "`nAPI/ROLAC.API/appsettings.Development.json"
|
||
```
|
||
|
||
Then verify:
|
||
```powershell
|
||
git status API/ROLAC.API/appsettings.Development.json
|
||
```
|
||
|
||
Expected: `nothing to commit` (file is ignored)
|
||
|
||
- [ ] **Step 4: Commit only the non-secret file**
|
||
|
||
```powershell
|
||
git add API/ROLAC.API/appsettings.json .gitignore
|
||
git commit -m "config: add JWT non-secret settings; gitignore appsettings.Development.json"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Wire up Program.cs
|
||
|
||
**Files:** Replace `API/ROLAC.API/Program.cs`
|
||
|
||
- [ ] **Step 1: Replace the full content of `Program.cs`**
|
||
|
||
```csharp
|
||
using System.Text;
|
||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||
using Microsoft.AspNetCore.Identity;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.IdentityModel.Tokens;
|
||
using Microsoft.OpenApi.Models;
|
||
using ROLAC.API.Data;
|
||
using ROLAC.API.Entities;
|
||
using ROLAC.API.Services;
|
||
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
|
||
// ── Database ──────────────────────────────────────────────────────────────────
|
||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||
|
||
// ── ASP.NET Core Identity ─────────────────────────────────────────────────────
|
||
builder.Services.AddIdentity<AppUser, AppRole>(options =>
|
||
{
|
||
options.Password.RequireDigit = true;
|
||
options.Password.RequiredLength = 8;
|
||
options.Password.RequireUppercase = true;
|
||
options.Password.RequireLowercase = true;
|
||
options.Password.RequireNonAlphanumeric = false;
|
||
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
|
||
options.Lockout.MaxFailedAccessAttempts = 5;
|
||
options.Lockout.AllowedForNewUsers = true;
|
||
options.User.RequireUniqueEmail = true;
|
||
})
|
||
.AddEntityFrameworkStores<AppDbContext>()
|
||
.AddDefaultTokenProviders();
|
||
|
||
// ── JWT Authentication ────────────────────────────────────────────────────────
|
||
var jwtSection = builder.Configuration.GetSection("Jwt");
|
||
var secretKey = jwtSection["SecretKey"]
|
||
?? throw new InvalidOperationException("Jwt:SecretKey is not configured. Add it to appsettings.Development.json or environment variables.");
|
||
|
||
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 = jwtSection["Issuer"],
|
||
ValidAudience = jwtSection["Audience"],
|
||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)),
|
||
ClockSkew = TimeSpan.Zero, // tokens expire exactly on time — no grace period
|
||
};
|
||
});
|
||
|
||
// ── CORS ──────────────────────────────────────────────────────────────────────
|
||
builder.Services.AddCors(options =>
|
||
{
|
||
options.AddPolicy("AllowAngularApp", policy =>
|
||
{
|
||
policy
|
||
.WithOrigins("http://localhost:4200", "https://app.rolac.org")
|
||
.AllowAnyHeader()
|
||
.AllowAnyMethod()
|
||
.AllowCredentials(); // Required — Angular must send HttpOnly cookie on /refresh
|
||
});
|
||
});
|
||
|
||
// ── Application Services ──────────────────────────────────────────────────────
|
||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||
|
||
// ── Controllers + Swagger ─────────────────────────────────────────────────────
|
||
builder.Services.AddControllers();
|
||
builder.Services.AddEndpointsApiExplorer();
|
||
builder.Services.AddSwaggerGen(c =>
|
||
{
|
||
c.SwaggerDoc("v1", new OpenApiInfo { Title = "ROLAC API", Version = "v1" });
|
||
|
||
// Allow pasting a Bearer token in Swagger UI to test protected endpoints
|
||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||
{
|
||
Description = "Enter: Bearer {your JWT}",
|
||
Name = "Authorization",
|
||
In = ParameterLocation.Header,
|
||
Type = SecuritySchemeType.ApiKey,
|
||
Scheme = "Bearer",
|
||
});
|
||
c.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||
{
|
||
{
|
||
new OpenApiSecurityScheme
|
||
{
|
||
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
|
||
},
|
||
Array.Empty<string>()
|
||
}
|
||
});
|
||
});
|
||
|
||
var app = builder.Build();
|
||
|
||
// ── Startup: migrate DB + seed roles + seed dev admin ────────────────────────
|
||
using (var scope = app.Services.CreateScope())
|
||
{
|
||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<AppRole>>();
|
||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AppUser>>();
|
||
|
||
await db.Database.MigrateAsync(); // applies pending EF migrations
|
||
await DbSeeder.SeedRolesAsync(roleManager); // ensures all 13 RBAC roles exist
|
||
|
||
if (app.Environment.IsDevelopment())
|
||
await DbSeeder.SeedAdminUserAsync(userManager); // admin@rolac.org / Admin1234!
|
||
}
|
||
|
||
// ── HTTP Pipeline ─────────────────────────────────────────────────────────────
|
||
if (app.Environment.IsDevelopment())
|
||
{
|
||
app.UseSwagger();
|
||
app.UseSwaggerUI();
|
||
}
|
||
|
||
app.UseHttpsRedirection();
|
||
app.UseCors("AllowAngularApp");
|
||
app.UseAuthentication();
|
||
app.UseAuthorization();
|
||
app.MapControllers();
|
||
|
||
app.Run();
|
||
```
|
||
|
||
- [ ] **Step 2: Build to verify**
|
||
|
||
```powershell
|
||
dotnet build API/ROLAC.API/ROLAC.API.csproj
|
||
```
|
||
|
||
Expected: `Build succeeded. 0 Error(s)`
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```powershell
|
||
git add API/ROLAC.API/Program.cs
|
||
git commit -m "feat: wire Identity, JWT, CORS, DI, and startup seeding in Program.cs"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Create and apply EF Core migration
|
||
|
||
**Pre-requisite:** PostgreSQL must be running locally with the credentials matching `appsettings.Development.json`.
|
||
|
||
- [ ] **Step 1: Verify dotnet-ef tool is installed**
|
||
|
||
```powershell
|
||
dotnet ef --version
|
||
```
|
||
|
||
If not found:
|
||
```powershell
|
||
dotnet tool install --global dotnet-ef --version 8.0.11
|
||
```
|
||
|
||
- [ ] **Step 2: Create the initial migration**
|
||
|
||
```powershell
|
||
dotnet ef migrations add InitialAuth `
|
||
--project API/ROLAC.API/ROLAC.API.csproj `
|
||
--startup-project API/ROLAC.API/ROLAC.API.csproj `
|
||
--output-dir Data/Migrations
|
||
```
|
||
|
||
Expected: `Done. To undo this action, use 'ef migrations remove'`
|
||
|
||
This creates three files in `API/ROLAC.API/Data/Migrations/`.
|
||
|
||
- [ ] **Step 3: Verify migration content**
|
||
|
||
Open the generated `<timestamp>_InitialAuth.cs`. It should create:
|
||
- `AspNetUsers` — with `MemberId`, `LanguagePreference`, `IsActive`, `LastLoginAt`, `CreatedAt` columns
|
||
- `AspNetRoles` — with `Description` column
|
||
- `AspNetUserRoles`, `AspNetUserClaims`, `AspNetRoleClaims`, `AspNetUserLogins`, `AspNetUserTokens`
|
||
- `RefreshTokens` — with `TokenHash` unique index, `UserId` FK → `AspNetUsers`
|
||
|
||
If any of the above are missing, the entities were not picked up correctly — re-check the namespace and that `AppDbContext` compiles without errors.
|
||
|
||
- [ ] **Step 4: Apply migration to local PostgreSQL**
|
||
|
||
```powershell
|
||
dotnet ef database update `
|
||
--project API/ROLAC.API/ROLAC.API.csproj `
|
||
--startup-project API/ROLAC.API/ROLAC.API.csproj
|
||
```
|
||
|
||
Expected:
|
||
```
|
||
Build succeeded.
|
||
Applying migration '<timestamp>_InitialAuth'.
|
||
Done.
|
||
```
|
||
|
||
- [ ] **Step 5: Commit migration files**
|
||
|
||
```powershell
|
||
git add API/ROLAC.API/Data/Migrations/
|
||
git commit -m "feat: add InitialAuth EF Core migration (Identity tables + RefreshTokens)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Smoke test with Swagger UI
|
||
|
||
- [ ] **Step 1: Start the API**
|
||
|
||
```powershell
|
||
dotnet run --project API/ROLAC.API/ROLAC.API.csproj
|
||
```
|
||
|
||
Expected startup output (port numbers may vary):
|
||
```
|
||
info: Applying migration 'InitialAuth'. ← skipped on subsequent runs
|
||
info: Now listening on: https://localhost:7001
|
||
info: Now listening on: http://localhost:5001
|
||
```
|
||
|
||
- [ ] **Step 2: Open Swagger UI**
|
||
|
||
Navigate to `https://localhost:7001/swagger` in a browser.
|
||
|
||
You should see three endpoints under **Auth**:
|
||
- `POST /api/auth/login`
|
||
- `POST /api/auth/refresh`
|
||
- `POST /api/auth/logout`
|
||
|
||
- [ ] **Step 3: Test successful login**
|
||
|
||
Expand `POST /api/auth/login` → **Try it out** → paste:
|
||
```json
|
||
{
|
||
"email": "admin@rolac.org",
|
||
"password": "Admin1234!"
|
||
}
|
||
```
|
||
|
||
Click **Execute**.
|
||
|
||
Expected: **200 OK** with body:
|
||
```json
|
||
{
|
||
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||
"expiresIn": 900,
|
||
"user": {
|
||
"id": "<guid>",
|
||
"email": "admin@rolac.org",
|
||
"roles": ["super_admin"],
|
||
"languagePreference": "en"
|
||
}
|
||
}
|
||
```
|
||
|
||
The response **Headers** should include `Set-Cookie: rolac_rt=...`
|
||
|
||
- [ ] **Step 4: Test token refresh**
|
||
|
||
Call `POST /api/auth/refresh` (no body needed — the browser sends the cookie).
|
||
|
||
Expected: **200 OK** with a new `accessToken`.
|
||
|
||
- [ ] **Step 5: Test logout**
|
||
|
||
Call `POST /api/auth/logout`.
|
||
|
||
Expected: **204 No Content**. The `rolac_rt` cookie is cleared.
|
||
|
||
- [ ] **Step 6: Test wrong password returns 401**
|
||
|
||
Call `POST /api/auth/login` with:
|
||
```json
|
||
{ "email": "admin@rolac.org", "password": "WrongPassword!" }
|
||
```
|
||
|
||
Expected: **401 Unauthorized**:
|
||
```json
|
||
{ "message": "Invalid credentials" }
|
||
```
|
||
|
||
- [ ] **Step 7: Run full test suite one final time**
|
||
|
||
```powershell
|
||
dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -v normal
|
||
```
|
||
|
||
Expected:
|
||
```
|
||
Test Run Successful.
|
||
Total tests: 16 Passed: 16
|
||
```
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
When complete, the ROLAC API will have:
|
||
|
||
| Endpoint | Method | Auth | Cookie |
|
||
|----------|--------|------|--------|
|
||
| `/api/auth/login` | POST | None | Sets `rolac_rt` |
|
||
| `/api/auth/refresh` | POST | None (reads cookie) | Rotates `rolac_rt` |
|
||
| `/api/auth/logout` | POST | None (reads cookie) | Clears `rolac_rt` |
|
||
|
||
**Security properties:**
|
||
- Refresh tokens never stored in plaintext — SHA-256 hashed in DB
|
||
- Token rotation on every refresh — stolen tokens have a window of at most one use before detection
|
||
- Account lockout after 5 failed attempts (5-minute lockout)
|
||
- `IsActive = false` accounts blocked regardless of correct password
|
||
- CORS configured for Angular dev (`localhost:4200`) and production (`app.rolac.org`)
|
||
- 16 unit tests covering all success and failure paths
|