Chapter 17: 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.
17.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) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────┘
17.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": ["cashier", "supervisor"],
"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” |
17.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 Cashier = "cashier";
public const string Supervisor = "supervisor";
public const string Manager = "manager";
public const string Admin = "admin";
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 | Cashier | Supervisor | Manager | Admin | Owner |
|---|---|---|---|---|---|
| pos.sale.create | X | X | X | X | X |
| pos.sale.void | - | X | X | X | X |
| pos.sale.return | - | X | X | X | X |
| pos.discount.apply | - | X | X | X | X |
| pos.discount.override | - | - | X | X | X |
| pos.price.override | - | - | X | X | X |
| pos.drawer.open | X | X | X | X | X |
| pos.drawer.count | - | X | X | X | X |
| pos.customer.view | X | X | X | X | X |
| pos.customer.create | X | X | X | X | X |
| pos.customer.update | - | X | X | X | X |
| pos.customer.delete | - | - | X | X | X |
| inventory.view | X | X | X | X | X |
| inventory.adjust | - | - | X | X | X |
| inventory.transfer | - | - | X | X | X |
| inventory.count | - | X | X | X | X |
| catalog.items.read | X | X | X | X | X |
| catalog.items.write | - | - | X | X | X |
| catalog.items.delete | - | - | - | X | X |
| reports.view | - | X | X | X | X |
| reports.export | - | - | X | X | X |
| admin.employees | - | - | X | X | X |
| admin.locations | - | - | - | X | X |
| admin.settings | - | - | - | X | X |
| admin.billing | - | - | - | - | X |
17.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!
});
}
}
}
17.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);
}
}
17.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;
}
}
17.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 { }
17.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;
}
}
17.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;
}
}
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 Cashier to Owner
- Complete AuthController with all authentication endpoints
- Tenant context middleware for multi-tenant isolation
- API key authentication for external integrations
Next: Chapter 18 covers integration patterns for Shopify and payment processing.