Chapter 12: Security and Authentication
Multi-Mode Authentication for POS and Admin Portals
This chapter provides complete security implementation including dual authentication flows, JWT tokens, role-based access control, and tenant isolation.
12.1 Authentication Architecture Overview
┌───────────────────────────────────────────────────────────────────┐
│ Authentication Flows │
├───────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ POS Client │ │ Admin Portal │ │
│ │ (Touch Screen) │ │ (Web Browser) │ │
│ └────────┬────────┘ └───────────┬─────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ PIN Login │ │ Email/Password Login │ │
│ │ (4-6 digits) │ │ + Optional MFA │ │
│ └────────┬────────┘ └───────────┬─────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ JWT Token Issued │ │
│ │ • Short-lived for POS (8 hours = shift) │ │
│ │ • Longer for Admin (24 hours with refresh) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────┘
12.2 JWT Token Structure
{
"header": {
"alg": "RS256",
"typ": "JWT",
"kid": "key-2025-01"
},
"payload": {
"sub": "emp_john_smith",
"tid": "tenant_nexus",
"lid": "loc_gm",
"rid": "reg_gm_01",
"name": "John Smith",
"email": "john@nexus.com",
"roles": ["staff"],
"permissions": [
"pos.sale.create",
"pos.sale.void",
"pos.discount.apply",
"pos.customer.view",
"pos.customer.create"
],
"auth_method": "pin",
"iat": 1705320000,
"exp": 1705348800,
"iss": "https://auth.pos-platform.com",
"aud": "pos-api"
}
}
Token Claims Explained
| Claim | Description |
|---|---|
sub | Subject (employee/user ID) |
tid | Tenant ID |
lid | Location ID (POS only) |
rid | Register ID (POS only) |
roles | Role names |
permissions | Fine-grained permissions |
auth_method | “pin” or “password” |
12.3 Role-Based Permission Matrix
Role Definitions
// File: src/POS.Domain/Security/Roles.cs
namespace POS.Domain.Security;
public static class Roles
{
public const string Staff = "staff";
public const string Manager = "manager";
public const string Admin = "admin";
public const string Buyer = "buyer";
public const string Owner = "owner";
}
Permission Catalog
// File: src/POS.Domain/Security/Permissions.cs
namespace POS.Domain.Security;
public static class Permissions
{
// POS Operations
public const string PosSaleCreate = "pos.sale.create";
public const string PosSaleVoid = "pos.sale.void";
public const string PosSaleReturn = "pos.sale.return";
public const string PosDiscountApply = "pos.discount.apply";
public const string PosDiscountOverride = "pos.discount.override";
public const string PosPriceOverride = "pos.price.override";
public const string PosDrawerOpen = "pos.drawer.open";
public const string PosDrawerCount = "pos.drawer.count";
public const string PosHoldRecall = "pos.hold.recall";
// Customer Operations
public const string CustomerView = "pos.customer.view";
public const string CustomerCreate = "pos.customer.create";
public const string CustomerUpdate = "pos.customer.update";
public const string CustomerDelete = "pos.customer.delete";
public const string CustomerLoyaltyAdjust = "pos.customer.loyalty.adjust";
// Inventory Operations
public const string InventoryView = "inventory.view";
public const string InventoryAdjust = "inventory.adjust";
public const string InventoryTransfer = "inventory.transfer";
public const string InventoryCount = "inventory.count";
public const string InventoryReceive = "inventory.receive";
// Catalog Operations
public const string CatalogItemView = "catalog.items.read";
public const string CatalogItemCreate = "catalog.items.write";
public const string CatalogItemUpdate = "catalog.items.write";
public const string CatalogItemDelete = "catalog.items.delete";
public const string CatalogItemBulk = "catalog.items.bulk";
// Reports
public const string ReportsView = "reports.view";
public const string ReportsExport = "reports.export";
public const string ReportsSalesDetail = "reports.sales.detail";
public const string ReportsEmployeePerformance = "reports.employee.performance";
// Administration
public const string AdminEmployees = "admin.employees";
public const string AdminLocations = "admin.locations";
public const string AdminSettings = "admin.settings";
public const string AdminIntegrations = "admin.integrations";
public const string AdminBilling = "admin.billing";
public const string AdminAuditLog = "admin.audit";
}
Role-Permission Mapping
| Permission | Staff | Manager | Admin | Buyer | Owner |
|---|---|---|---|---|---|
| pos.sale.create | X | X | X | - | X |
| pos.sale.void | - | X | X | - | X |
| pos.sale.return | - | X | X | - | X |
| pos.discount.apply | - | X | X | - | X |
| pos.discount.override | - | X | X | - | X |
| pos.price.override | - | X | X | - | X |
| pos.drawer.open | X | X | X | - | X |
| pos.drawer.count | X | X | X | - | X |
| pos.customer.view | X | X | X | - | X |
| pos.customer.create | X | X | X | - | X |
| pos.customer.update | - | X | X | - | X |
| pos.customer.delete | - | - | X | - | X |
| inventory.view | X | X | X | X | X |
| inventory.adjust | - | X | X | - | X |
| inventory.transfer | - | X | X | X | X |
| inventory.receive | - | X | X | X | X |
| inventory.count | - | X | X | X | X |
| catalog.items.read | X | X | X | X | X |
| catalog.items.write | - | X | X | X | X |
| catalog.items.delete | - | - | X | - | X |
| reports.view | - | X | X | X | X |
| reports.export | - | X | X | - | X |
| admin.employees | - | - | X | - | X |
| admin.locations | - | - | X | - | X |
| admin.settings | - | - | X | - | X |
| admin.billing | - | - | - | - | X |
12.4 Authentication Controller
// File: src/POS.Api/Controllers/AuthController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace POS.Api.Controllers;
[ApiController]
[Route("api/v1/auth")]
public class AuthController : ControllerBase
{
private readonly IEmployeeService _employeeService;
private readonly IUserService _userService;
private readonly ITenantService _tenantService;
private readonly ITokenService _tokenService;
private readonly IAuditLogger _auditLogger;
private readonly ILogger<AuthController> _logger;
public AuthController(
IEmployeeService employeeService,
IUserService userService,
ITenantService tenantService,
ITokenService tokenService,
IAuditLogger auditLogger,
ILogger<AuthController> logger)
{
_employeeService = employeeService;
_userService = userService;
_tenantService = tenantService;
_tokenService = tokenService;
_auditLogger = auditLogger;
_logger = logger;
}
/// <summary>
/// PIN-based login for POS terminals
/// </summary>
[HttpPost("pin-login")]
[AllowAnonymous]
public async Task<ActionResult<LoginResponse>> PinLogin(
[FromBody] PinLoginRequest request,
CancellationToken ct)
{
// Validate tenant
var tenant = await _tenantService.GetBySubdomainAsync(
request.TenantSubdomain, ct);
if (tenant is null || !tenant.IsActive)
{
_logger.LogWarning(
"PIN login attempt for unknown tenant: {Tenant}",
request.TenantSubdomain);
return Unauthorized(new ProblemDetails
{
Title = "Invalid Credentials",
Detail = "The provided credentials are invalid."
});
}
// Validate location
var location = await _tenantService.GetLocationAsync(
tenant.Id, request.LocationId, ct);
if (location is null || !location.IsActive)
{
return Unauthorized(new ProblemDetails
{
Title = "Invalid Location",
Detail = "The specified location is not available."
});
}
// Validate employee PIN
var employee = await _employeeService.ValidatePinAsync(
tenant.Id, request.Pin, ct);
if (employee is null)
{
await _auditLogger.LogAsync(new AuditEvent
{
TenantId = tenant.Id,
EventType = "AuthFailure",
Details = $"Failed PIN login attempt at {request.LocationId}",
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
}, ct);
return Unauthorized(new ProblemDetails
{
Title = "Invalid Credentials",
Detail = "The provided PIN is incorrect."
});
}
// Check employee has access to this location
if (!employee.LocationIds.Contains(request.LocationId) &&
!employee.Roles.Contains(Roles.Admin))
{
return Unauthorized(new ProblemDetails
{
Title = "Location Access Denied",
Detail = "You do not have access to this location."
});
}
// Generate token (8-hour shift duration)
var token = await _tokenService.GenerateTokenAsync(new TokenRequest
{
Subject = employee.Id,
TenantId = tenant.Id,
LocationId = request.LocationId,
RegisterId = request.RegisterId,
Name = employee.FullName,
Email = employee.Email,
Roles = employee.Roles,
AuthMethod = "pin",
ExpiresIn = TimeSpan.FromHours(8)
});
await _auditLogger.LogAsync(new AuditEvent
{
TenantId = tenant.Id,
EmployeeId = employee.Id,
EventType = "PinLogin",
Details = $"Logged in at {request.LocationId}, register {request.RegisterId}",
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
}, ct);
_logger.LogInformation(
"Employee {EmployeeId} logged in at {LocationId}",
employee.Id, request.LocationId);
return Ok(new LoginResponse
{
Token = token.AccessToken,
ExpiresAt = token.ExpiresAt,
Employee = new EmployeeInfo
{
Id = employee.Id,
Name = employee.FullName,
Roles = employee.Roles,
Permissions = employee.Permissions
}
});
}
/// <summary>
/// Email/password login for Admin portal
/// </summary>
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<LoginResponse>> Login(
[FromBody] LoginRequest request,
CancellationToken ct)
{
// Validate tenant
var tenant = await _tenantService.GetBySubdomainAsync(
request.TenantSubdomain, ct);
if (tenant is null || !tenant.IsActive)
{
return Unauthorized(new ProblemDetails
{
Title = "Invalid Credentials",
Detail = "The provided credentials are invalid."
});
}
// Validate user credentials
var user = await _userService.ValidateCredentialsAsync(
tenant.Id, request.Email, request.Password, ct);
if (user is null)
{
await _auditLogger.LogAsync(new AuditEvent
{
TenantId = tenant.Id,
EventType = "AuthFailure",
Details = $"Failed login attempt for {request.Email}",
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
}, ct);
return Unauthorized(new ProblemDetails
{
Title = "Invalid Credentials",
Detail = "The provided credentials are invalid."
});
}
// Check if MFA is required
if (user.MfaEnabled)
{
if (string.IsNullOrEmpty(request.MfaCode))
{
return Ok(new LoginResponse
{
RequiresMfa = true,
MfaToken = await _tokenService.GenerateMfaTokenAsync(user.Id)
});
}
var mfaValid = await _userService.ValidateMfaCodeAsync(
user.Id, request.MfaCode, ct);
if (!mfaValid)
{
return Unauthorized(new ProblemDetails
{
Title = "Invalid MFA Code",
Detail = "The provided MFA code is incorrect."
});
}
}
// Generate tokens (24-hour access, 7-day refresh)
var token = await _tokenService.GenerateTokenAsync(new TokenRequest
{
Subject = user.Id,
TenantId = tenant.Id,
Name = user.FullName,
Email = user.Email,
Roles = user.Roles,
AuthMethod = "password",
ExpiresIn = TimeSpan.FromHours(24),
IncludeRefreshToken = true
});
await _auditLogger.LogAsync(new AuditEvent
{
TenantId = tenant.Id,
UserId = user.Id,
EventType = "Login",
Details = "Admin portal login",
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
}, ct);
return Ok(new LoginResponse
{
Token = token.AccessToken,
RefreshToken = token.RefreshToken,
ExpiresAt = token.ExpiresAt,
User = new UserInfo
{
Id = user.Id,
Name = user.FullName,
Email = user.Email,
Roles = user.Roles,
Permissions = user.Permissions
}
});
}
/// <summary>
/// Refresh access token using refresh token
/// </summary>
[HttpPost("refresh")]
[AllowAnonymous]
public async Task<ActionResult<LoginResponse>> RefreshToken(
[FromBody] RefreshTokenRequest request,
CancellationToken ct)
{
var result = await _tokenService.RefreshTokenAsync(
request.RefreshToken, ct);
if (!result.IsSuccess)
{
return Unauthorized(new ProblemDetails
{
Title = "Invalid Token",
Detail = "The refresh token is invalid or expired."
});
}
return Ok(new LoginResponse
{
Token = result.Value!.AccessToken,
RefreshToken = result.Value.RefreshToken,
ExpiresAt = result.Value.ExpiresAt
});
}
/// <summary>
/// Logout and invalidate tokens
/// </summary>
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout(CancellationToken ct)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var tenantId = User.FindFirstValue("tid");
await _tokenService.RevokeAllTokensAsync(userId!, ct);
await _auditLogger.LogAsync(new AuditEvent
{
TenantId = tenantId!,
UserId = userId,
EventType = "Logout",
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
}, ct);
return NoContent();
}
/// <summary>
/// Change PIN (for POS employees)
/// </summary>
[HttpPost("change-pin")]
[Authorize]
public async Task<IActionResult> ChangePin(
[FromBody] ChangePinRequest request,
CancellationToken ct)
{
var employeeId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var tenantId = User.FindFirstValue("tid");
var result = await _employeeService.ChangePinAsync(
tenantId!, employeeId!, request.CurrentPin, request.NewPin, ct);
if (!result.IsSuccess)
{
return BadRequest(new ProblemDetails
{
Title = "PIN Change Failed",
Detail = result.Error!.Message
});
}
return NoContent();
}
/// <summary>
/// Validate current session
/// </summary>
[HttpGet("me")]
[Authorize]
public async Task<ActionResult<SessionInfo>> GetCurrentSession(
CancellationToken ct)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var tenantId = User.FindFirstValue("tid");
var authMethod = User.FindFirstValue("auth_method");
if (authMethod == "pin")
{
var employee = await _employeeService.GetByIdAsync(
tenantId!, userId!, ct);
return Ok(new SessionInfo
{
UserId = userId!,
TenantId = tenantId!,
Name = employee!.FullName,
Roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(),
Permissions = User.FindAll("permission").Select(c => c.Value).ToList(),
LocationId = User.FindFirstValue("lid"),
RegisterId = User.FindFirstValue("rid"),
AuthMethod = authMethod
});
}
else
{
var user = await _userService.GetByIdAsync(tenantId!, userId!, ct);
return Ok(new SessionInfo
{
UserId = userId!,
TenantId = tenantId!,
Name = user!.FullName,
Email = user.Email,
Roles = User.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList(),
Permissions = User.FindAll("permission").Select(c => c.Value).ToList(),
AuthMethod = authMethod!
});
}
}
}
12.5 Token Service Implementation
// File: src/POS.Infrastructure/Security/TokenService.cs
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
namespace POS.Infrastructure.Security;
public class TokenService : ITokenService
{
private readonly JwtSettings _jwtSettings;
private readonly IRefreshTokenRepository _refreshTokenRepo;
private readonly IRolePermissionResolver _permissionResolver;
private readonly ILogger<TokenService> _logger;
public TokenService(
IOptions<JwtSettings> jwtSettings,
IRefreshTokenRepository refreshTokenRepo,
IRolePermissionResolver permissionResolver,
ILogger<TokenService> logger)
{
_jwtSettings = jwtSettings.Value;
_refreshTokenRepo = refreshTokenRepo;
_permissionResolver = permissionResolver;
_logger = logger;
}
public async Task<TokenResult> GenerateTokenAsync(TokenRequest request)
{
var permissions = await _permissionResolver.ResolvePermissionsAsync(
request.Roles);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, request.Subject),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new("tid", request.TenantId),
new("name", request.Name),
new("auth_method", request.AuthMethod)
};
if (!string.IsNullOrEmpty(request.Email))
claims.Add(new Claim(JwtRegisteredClaimNames.Email, request.Email));
if (!string.IsNullOrEmpty(request.LocationId))
claims.Add(new Claim("lid", request.LocationId));
if (!string.IsNullOrEmpty(request.RegisterId))
claims.Add(new Claim("rid", request.RegisterId));
foreach (var role in request.Roles)
claims.Add(new Claim(ClaimTypes.Role, role));
foreach (var permission in permissions)
claims.Add(new Claim("permission", permission));
var key = new SymmetricSecurityKey(
Convert.FromBase64String(_jwtSettings.SecretKey));
var credentials = new SigningCredentials(
key, SecurityAlgorithms.HmacSha256);
var expires = DateTime.UtcNow.Add(request.ExpiresIn);
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
expires: expires,
signingCredentials: credentials
);
var accessToken = new JwtSecurityTokenHandler().WriteToken(token);
var result = new TokenResult
{
AccessToken = accessToken,
ExpiresAt = expires
};
if (request.IncludeRefreshToken)
{
var refreshToken = GenerateRefreshToken();
await _refreshTokenRepo.StoreAsync(new RefreshTokenEntity
{
Token = refreshToken,
UserId = request.Subject,
TenantId = request.TenantId,
ExpiresAt = DateTime.UtcNow.AddDays(7),
CreatedAt = DateTime.UtcNow
});
result.RefreshToken = refreshToken;
}
return result;
}
public async Task<Result<TokenResult>> RefreshTokenAsync(
string refreshToken,
CancellationToken ct = default)
{
var stored = await _refreshTokenRepo.GetByTokenAsync(refreshToken, ct);
if (stored is null || stored.IsRevoked || stored.ExpiresAt < DateTime.UtcNow)
{
return Result<TokenResult>.Failure(
DomainError.InvalidToken("Refresh token is invalid or expired"));
}
// Revoke old refresh token
await _refreshTokenRepo.RevokeAsync(refreshToken, ct);
// Generate new tokens
var newToken = await GenerateTokenAsync(new TokenRequest
{
Subject = stored.UserId,
TenantId = stored.TenantId,
Name = stored.UserName,
Email = stored.Email,
Roles = stored.Roles,
AuthMethod = "password",
ExpiresIn = TimeSpan.FromHours(24),
IncludeRefreshToken = true
});
return Result<TokenResult>.Success(newToken);
}
public async Task RevokeAllTokensAsync(string userId, CancellationToken ct = default)
{
await _refreshTokenRepo.RevokeAllForUserAsync(userId, ct);
}
private static string GenerateRefreshToken()
{
var randomBytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomBytes);
return Convert.ToBase64String(randomBytes);
}
}
12.6 Tenant Context Middleware
// File: src/POS.Api/Middleware/TenantContextMiddleware.cs
using System.Security.Claims;
namespace POS.Api.Middleware;
public class TenantContextMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TenantContextMiddleware> _logger;
public TenantContextMiddleware(
RequestDelegate next,
ILogger<TenantContextMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(
HttpContext context,
ITenantContext tenantContext,
ITenantService tenantService)
{
string? tenantId = null;
// 1. Try from JWT claims (authenticated requests)
if (context.User.Identity?.IsAuthenticated == true)
{
tenantId = context.User.FindFirstValue("tid");
}
// 2. Try from subdomain
if (string.IsNullOrEmpty(tenantId))
{
var host = context.Request.Host.Host;
var subdomain = GetSubdomain(host);
if (!string.IsNullOrEmpty(subdomain))
{
var tenant = await tenantService.GetBySubdomainAsync(
subdomain, context.RequestAborted);
tenantId = tenant?.Id;
}
}
// 3. Try from header (API integrations)
if (string.IsNullOrEmpty(tenantId))
{
tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
}
if (!string.IsNullOrEmpty(tenantId))
{
tenantContext.SetTenant(tenantId);
// Add to response headers for debugging
context.Response.Headers["X-Tenant-Id"] = tenantId;
}
else if (!IsPublicEndpoint(context.Request.Path))
{
_logger.LogWarning(
"Unable to resolve tenant for path {Path}",
context.Request.Path);
context.Response.StatusCode = 400;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = "Tenant Required",
Detail = "Unable to determine tenant context.",
Status = 400
});
return;
}
await _next(context);
}
private static string? GetSubdomain(string host)
{
var parts = host.Split('.');
if (parts.Length >= 3 && parts[0] != "www" && parts[0] != "api")
{
return parts[0];
}
return null;
}
private static bool IsPublicEndpoint(PathString path)
{
var publicPaths = new[]
{
"/health",
"/api/v1/auth/login",
"/api/v1/auth/pin-login",
"/swagger"
};
return publicPaths.Any(p =>
path.StartsWithSegments(p, StringComparison.OrdinalIgnoreCase));
}
}
// Tenant Context Interface and Implementation
public interface ITenantContext
{
string? TenantId { get; }
void SetTenant(string tenantId);
}
public class TenantContext : ITenantContext
{
public string? TenantId { get; private set; }
public void SetTenant(string tenantId)
{
TenantId = tenantId;
}
}
12.7 API Key Authentication for Integrations
// File: src/POS.Api/Authentication/ApiKeyAuthenticationHandler.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;
namespace POS.Api.Authentication;
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
private const string ApiKeyHeaderName = "X-API-Key";
private readonly IApiKeyService _apiKeyService;
public ApiKeyAuthenticationHandler(
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IApiKeyService apiKeyService)
: base(options, logger, encoder)
{
_apiKeyService = apiKeyService;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKeyHeaderValues))
{
return AuthenticateResult.NoResult();
}
var providedApiKey = apiKeyHeaderValues.FirstOrDefault();
if (string.IsNullOrEmpty(providedApiKey))
{
return AuthenticateResult.NoResult();
}
var apiKey = await _apiKeyService.ValidateApiKeyAsync(
providedApiKey, Context.RequestAborted);
if (apiKey is null)
{
return AuthenticateResult.Fail("Invalid API key");
}
if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt < DateTime.UtcNow)
{
return AuthenticateResult.Fail("API key has expired");
}
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, apiKey.Id),
new("tid", apiKey.TenantId),
new("api_key_name", apiKey.Name),
new("auth_method", "api_key")
};
foreach (var scope in apiKey.Scopes)
{
claims.Add(new Claim("scope", scope));
}
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
// Update last used
await _apiKeyService.RecordUsageAsync(apiKey.Id, Context.RequestAborted);
return AuthenticateResult.Success(ticket);
}
}
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions { }
12.8 Authorization Policies
// File: src/POS.Api/Extensions/AuthorizationExtensions.cs
using Microsoft.AspNetCore.Authorization;
namespace POS.Api.Extensions;
public static class AuthorizationExtensions
{
public static IServiceCollection AddPosAuthorization(
this IServiceCollection services)
{
services.AddAuthorization(options =>
{
// POS Operations
options.AddPolicy("pos.sale.create",
policy => policy.RequireClaim("permission", Permissions.PosSaleCreate));
options.AddPolicy("pos.sale.void",
policy => policy.RequireClaim("permission", Permissions.PosSaleVoid));
options.AddPolicy("pos.sale.return",
policy => policy.RequireClaim("permission", Permissions.PosSaleReturn));
options.AddPolicy("pos.discount.apply",
policy => policy.RequireClaim("permission", Permissions.PosDiscountApply));
// Inventory Operations
options.AddPolicy("inventory.view",
policy => policy.RequireClaim("permission", Permissions.InventoryView));
options.AddPolicy("inventory.adjust",
policy => policy.RequireClaim("permission", Permissions.InventoryAdjust));
// Catalog Operations
options.AddPolicy("catalog.items.read",
policy => policy.RequireClaim("permission", Permissions.CatalogItemView));
options.AddPolicy("catalog.items.write",
policy => policy.RequireClaim("permission", Permissions.CatalogItemCreate));
options.AddPolicy("catalog.items.delete",
policy => policy.RequireClaim("permission", Permissions.CatalogItemDelete));
options.AddPolicy("catalog.items.bulk",
policy => policy.RequireClaim("permission", Permissions.CatalogItemBulk));
// Reports
options.AddPolicy("reports.view",
policy => policy.RequireClaim("permission", Permissions.ReportsView));
// Admin
options.AddPolicy("admin.settings",
policy => policy.RequireClaim("permission", Permissions.AdminSettings));
// Role-based policies
options.AddPolicy("ManagerOrAbove",
policy => policy.RequireRole(
Roles.Manager, Roles.Admin, Roles.Owner));
options.AddPolicy("AdminOrOwner",
policy => policy.RequireRole(Roles.Admin, Roles.Owner));
// Integration API policy
options.AddPolicy("api.integration",
policy => policy.RequireAssertion(context =>
context.User.HasClaim("auth_method", "api_key") &&
context.User.HasClaim("scope", "integration")));
});
return services;
}
}
12.9 Password Hashing
// File: src/POS.Infrastructure/Security/PasswordHasher.cs
using System.Security.Cryptography;
namespace POS.Infrastructure.Security;
public class PasswordHasher : IPasswordHasher
{
private const int SaltSize = 16;
private const int HashSize = 32;
private const int Iterations = 100000;
public string HashPassword(string password)
{
using var algorithm = new Rfc2898DeriveBytes(
password,
SaltSize,
Iterations,
HashAlgorithmName.SHA256);
var salt = algorithm.Salt;
var hash = algorithm.GetBytes(HashSize);
var hashBytes = new byte[SaltSize + HashSize];
Array.Copy(salt, 0, hashBytes, 0, SaltSize);
Array.Copy(hash, 0, hashBytes, SaltSize, HashSize);
return Convert.ToBase64String(hashBytes);
}
public bool VerifyPassword(string password, string hashedPassword)
{
var hashBytes = Convert.FromBase64String(hashedPassword);
var salt = new byte[SaltSize];
Array.Copy(hashBytes, 0, salt, 0, SaltSize);
using var algorithm = new Rfc2898DeriveBytes(
password,
salt,
Iterations,
HashAlgorithmName.SHA256);
var hash = algorithm.GetBytes(HashSize);
for (var i = 0; i < HashSize; i++)
{
if (hashBytes[SaltSize + i] != hash[i])
return false;
}
return true;
}
}
public class PinHasher : IPinHasher
{
public string HashPin(string pin)
{
using var sha256 = SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(pin);
var hash = sha256.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
public bool VerifyPin(string pin, string hashedPin)
{
var hash = HashPin(pin);
return hash == hashedPin;
}
}
12.10 6-Gate Security Pyramid (CI/CD Pipeline)
The security pipeline enforces six automated gates that every code change must pass before reaching production. These gates are ordered from fastest (static analysis) to slowest (dynamic scanning) and run in the CI/CD pipeline.
┌─────────────┐
│ Gate 6 │ OWASP ZAP
│ Dynamic │ (Runtime scanning)
┌┴─────────────┴┐
│ Gate 5 │ Pact
│ Contract │ (Consumer-driven contracts)
┌┴───────────────┴┐
│ Gate 4 │ ArchUnit (.NET)
│ Architecture │ (Dependency rules)
┌┴─────────────────┴┐
│ Gate 3 │ GitLeaks
│ Secrets │ (Secret detection)
┌┴───────────────────┴┐
│ Gate 2 │ Snyk
│ Dependencies │ (Vulnerability scanning)
┌┴─────────────────────┴┐
│ Gate 1 │ SonarQube
│ Static Analysis │ (Code quality + SAST)
└───────────────────────┘
Gate Configuration
| Gate | Tool | Runs On | Blocks Deploy If | Typical Duration |
|---|---|---|---|---|
| 1. Static Analysis | SonarQube | Every PR | Quality gate fails (bugs, code smells, security hotspots) | 2-5 min |
| 2. Dependency Scan | Snyk | Every PR + daily | High/Critical CVE found in dependencies | 1-2 min |
| 3. Secret Detection | GitLeaks | Every commit | Any secret pattern detected (API keys, passwords, tokens) | < 30 sec |
| 4. Architecture Tests | NetArchTest (.NET ArchUnit) | Every PR | Dependency rule violation (e.g., Domain references Infrastructure) | 1-2 min |
| 5. Contract Tests | Pact | Every PR | Consumer-provider contract broken | 3-5 min |
| 6. Dynamic Scanning | OWASP ZAP | Nightly + pre-release | High/Critical vulnerability found via runtime probing | 15-30 min |
CI/CD Pipeline Integration
# File: .github/workflows/security-gates.yml
name: Security Gates
on:
pull_request:
branches: [main, develop]
jobs:
gate-1-static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@v3
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- name: Quality Gate Check
uses: sonarsource/sonarqube-quality-gate-action@v1
timeout-minutes: 5
gate-2-dependency-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Snyk Security Scan
uses: snyk/actions/dotnet@master
with:
args: --severity-threshold=high
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
gate-3-secret-detection:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: GitLeaks Scan
uses: gitleaks/gitleaks-action@v2
env:
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
gate-4-architecture-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Run Architecture Tests
run: dotnet test tests/POS.ArchitectureTests --filter "Category=ArchitectureRule"
gate-5-contract-tests:
runs-on: ubuntu-latest
needs: [gate-1-static-analysis, gate-2-dependency-scan]
steps:
- uses: actions/checkout@v4
- name: Run Pact Consumer Tests
run: dotnet test tests/POS.ContractTests
- name: Publish Pacts to Broker
run: |
pact-broker publish pacts/ \
--consumer-app-version=${{ github.sha }} \
--broker-base-url=${{ secrets.PACT_BROKER_URL }}
gate-6-dynamic-scan:
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || contains(github.event.pull_request.labels.*.name, 'pre-release')
needs: [gate-4-architecture-tests, gate-5-contract-tests]
steps:
- name: OWASP ZAP Full Scan
uses: zaproxy/action-full-scan@v0.11.0
with:
target: ${{ secrets.STAGING_URL }}
fail_action: true
rules_file_name: zap-rules.tsv
Architecture Test Examples (Gate 4)
// File: tests/POS.ArchitectureTests/LayerDependencyTests.cs
using NetArchTest.Rules;
namespace POS.ArchitectureTests;
[Category("ArchitectureRule")]
public class LayerDependencyTests
{
[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(typeof(POS.Domain.Entities.Order).Assembly)
.ShouldNot()
.HaveDependencyOn("POS.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful,
"Domain layer must not depend on Infrastructure layer");
}
[Fact]
public void Domain_Should_Not_Reference_Application()
{
var result = Types.InAssembly(typeof(POS.Domain.Entities.Order).Assembly)
.ShouldNot()
.HaveDependencyOn("POS.Application")
.GetResult();
Assert.True(result.IsSuccessful,
"Domain layer must not depend on Application layer");
}
[Fact]
public void Application_Should_Not_Reference_Api()
{
var result = Types.InAssembly(typeof(POS.Application.Interfaces.IOrderService).Assembly)
.ShouldNot()
.HaveDependencyOn("POS.Api")
.GetResult();
Assert.True(result.IsSuccessful,
"Application layer must not depend on API layer");
}
[Fact]
public void Controllers_Should_Not_Access_Repositories_Directly()
{
var result = Types.InNamespace("POS.Api.Controllers")
.ShouldNot()
.HaveDependencyOn("POS.Infrastructure.Persistence.Repositories")
.GetResult();
Assert.True(result.IsSuccessful,
"Controllers must use services, not repositories directly");
}
}
Summary
This chapter covered the complete security implementation:
- Dual authentication flows: PIN for POS, Email/Password for Admin
- JWT token structure with tenant, location, and permission claims
- Role-based permission matrix from Staff to Owner
- Complete AuthController with all authentication endpoints
- Tenant context middleware for multi-tenant isolation
- API key authentication for external integrations
- 6-Gate Security Pyramid CI/CD pipeline (SonarQube, Snyk, GitLeaks, ArchUnit, Pact, OWASP ZAP)
Next: Chapter 13: Integration Patterns covers integration patterns for Shopify and payment processing.
Document Information
| Attribute | Value |
|---|---|
| Version | 5.0.0 |
| Created | 2025-12-29 |
| Updated | 2026-02-22 |
| Author | Claude Code |
| Status | Active |
| Part | IV - Backend |
| Chapter | 12 of 32 |
This chapter is part of the POS Blueprint Book. All content is self-contained.