DHI.Services for Security — Internal Developer Guide¶
This guide explains the Security pieces in DHI.Services used across our products: accounts, authentication (with lockout & throttling), refresh tokens, two-factor (OTP), authorization via user-groups, password history / expiry, and the small mail building blocks used for registration and password reset. It follows the same structure as the Documents guide.
What the Security module does¶
At a glance:
- User accounts (create/update/remove) with PBKDF2 password hashing (legacy SHA-1 supported for back-compat).
- Authentication service that validates credentials, throttles by client IP, and locks accounts after repeated failures.
- Registration & password reset with email templates and activation/reset tokens.
- Refresh tokens (issue, exchange, revoke) for session management.
- Two-factor (OTP) framework with pluggable authenticators and CIDR IP whitelisting.
- Authorization via user-groups (roles are obsolete).
- Password history & expiry (prevent reuse; configurable policy).
- Small helpers: claims utilities, mail sender/templates.
Core layers follow the same Repository ↔ Service pattern as other domains.
Key concepts¶
Accounts & passwords¶
Account(BaseNamedEntity<string>) carries Id, Name, Email, Company, PhoneNumber, flags (Activated,Enabled,AllowMePasswordChange,Locked), lockout fields (NoOfUnsuccessfulLoginAttempts,LastLoginAttemptedDate,LockedDateEnd), plus metadata.- Password storage:
EncryptedPasswordholds either- PBKDF2: 36 bytes =
16-byte salt+20-byte hash(10,000 iterations), or - legacy SHA-1: 20-byte hash (supported only to validate existing repos).
- PBKDF2: 36 bytes =
SetPassword(string)hashes with PBKDF2. Never setEncryptedPassworddirectly.
Authentication flow (validate + protect)¶
AuthenticationServicedelegates password checks to an authentication provider (IAuthenticationProvider, implemented byIAccountRepository).- On failures it uses progressive delays per client IP (1s → 2s → 4s → …) and updates login attempt counters.
- Lockout: after
MaxNumberOfLoginAttemptswithin theResetInterval, account is locked forLockedPeriod. A correct login after lock expiry unlocks.
Tokens¶
- Activation/reset tokens live on the
Account(Token,TokenExpiration) and are managed byRegistrationService. - Refresh tokens (
RefreshToken) are separate entities with their own repository/service. Key points:- Token id is
accountIdoraccountId-clientIp(if a client IP is included). - Each token has a string token, UTC expiration, and IsExpired guard.
- Exchange replaces a valid token with a new one; revoke by id or by account.
- Token id is
Two-factor (OTP)¶
OtpServicehosts one or moreIOtpAuthenticators keyed by name (e.g.,TotpAuthenticator), and implements:ValidateOtp(otp, twoFactorAuthenticators, key)for verification.GetOtpConfiguration(twoFactorAuthenticators, ipWhitelistMatch)to decide whether OTP is required, supports a specialCIDR:rule to whitelist IPs.GenerateOtpAuthenticatorSetupCode(accountName, twoFactorAuthenticators, key)for TOTP provisioning (manual code + QR).
Authorization (user-groups, not roles)¶
- Use groups:
UserGroup+UserGroupServiceprovide membership and queries. ClaimsPrincipalExtensions:GetUserId()→ current subject id.GetPrincipals()→{userId + all ClaimTypes.GroupSid values}(handy for permission checks).
Permissionis a small immutable struct: principals set + operation + Allowed/Denied.
Account.Roles/GetRoles()are obsolete. Use user-groups.
Password history & expiry¶
- Policy (
PasswordExpirationPolicy): defaultsPasswordExpiryDurationInDays = 3,PreviousPasswordsReUseLimit = 3. PasswordHistorystores the encrypted password and its expiry date.PasswordHistoryService:ValidateCurrentPassword(...)also throttles by client IP.AddPasswordHistoryAsync(...)on change; prevents reuse of the last N passwords.- Comparison (
PasswordHistoryComparer) supports both PBKDF2 and legacy SHA-1.
Core types overview¶
Entities¶
Account— user profile, status, token fields, password hash.RefreshToken— token string, accountId, expiration, optional clientIp;IsExpired.UserGroup— named group withHashSet<string> Users.Permission/PermissionType— immutable permission tuple.PasswordHistory— accountId, encrypted password, expiry.
Repositories (JSON implementations provided)¶
IAccountRepository:IAuthenticationProvider,IDiscreteRepository<Account,string>,IUpdatableRepository<…>- JSON impl:
AccountRepository - Extra lookups:
GetByEmail(),GetByToken() - Also exposes lock/unlock/reset helpers used by auth.
- JSON impl:
IRefreshTokenRepository: JSON implRefreshTokenRepositoryGetByAccount(),GetByToken()
IUserGroupRepository: JSON implUserGroupRepositoryIPasswordHistoryRepository: JSON implPasswordHistoryRepositoryGetMostRecentByAccountId,GetRecentByAccountId,GetByAccountId
IMailTemplateRepository: JSON implMailTemplateRepository
Services¶
AccountService— CRUD with guards:- Add/TryAdd/AddOrUpdate require
EncryptedPassword != null, and setActivated = trueon creation. - Cannot remove
adminaccount. UpdateMe(Account)lets a user change their own contact data and, if allowed, password.
- Add/TryAdd/AddOrUpdate require
RegistrationService— Register → email, Activate, ResetPassword → email, UpdatePassword.- Uses
MailTemplateand anIMailSender(e.g.,SmtpMailSender) to send activation/reset emails. - Tokens are GUID “N” strings; expiry defaults to 1 day.
- On password updates it optionally writes password history and enforces reuse policy.
- Uses
AuthenticationService— Validate(accountId, password, clientIp) with throttling and lockout; also exposes provider discovery helpers.RefreshTokenService— Create, Exchange, GetByToken, GetByAccount, RemoveByAccount, ContainsAccount.OtpService— ValidateOtp, GetOtpConfiguration (CIDR/IP whitelist), GenerateOtpAuthenticatorSetupCode.UserGroupService— AddUser / RemoveUser, membership checks, “all groups for user”.MailTemplateService— trivial wrapper around template repo.
“ServiceConnection” types exist for dynamic creation but are marked obsolete; prefer standard DI.
Guarding inputs & obsolete bits¶
- Use
Guard.Against.*(e.g.,NullOrEmpty) at public boundaries. - Obsolete (back-compat only):
Account.HashPassword(SHA-1) andAccount.ValidatePassword— use PBKDF2 viaSetPassword()and provider validation.- Role APIs:
Account.Roles,Account.GetRoles(), andAccountService.GetRoles(). AuthenticationServiceConnection,AccountServiceConnection,RefreshTokenServiceConnection(prefer DI).IAuthentication.IAccessTokenProvider.
Typical tasks (with code)¶
Bootstrap JSON-backed repos/services¶
var appData = Path.Combine(builder.Environment.ContentRootPath, "App_Data");
// Accounts
var accountsRepo = new AccountRepository(Path.Combine(appData, "accounts.json"));
var accounts = new AccountService(accountsRepo);
// Refresh tokens
var rtRepo = new RefreshTokenRepository(Path.Combine(appData, "refreshTokens.json"));
var refreshTokens = new RefreshTokenService(rtRepo);
// User groups
var groupsRepo = new UserGroupRepository(Path.Combine(appData, "groups.json"));
var groups = new UserGroupService(groupsRepo);
// Password history (optional but recommended)
var pwdHistRepo = new PasswordHistoryRepository(Path.Combine(appData, "passwordHistory.json"));
var pwdHistory = new PasswordHistoryService(pwdHistRepo);
// Mail templates + sender
var mailTemplates = new MailTemplateService(new MailTemplateRepository(Path.Combine(appData, "mailTemplates.json")));
var mailSender = new SmtpMailSender("smtp.yourdomain.local"); // or host,port,user,pass
// Registration flows
var registration = new RegistrationService(
accountsRepo, mailSender,
activationEmailTemplate: mailTemplates.Get("activation-template"),
passwordResetEmailTemplate: mailTemplates.Get("passwordreset-template"));
Create an account (hashing via PBKDF2)¶
var acc = new Account("alice", "Alice Jensen")
{
Email = "alice@acme.com",
Company = "ACME",
};
acc.SetPassword("S7rong-P@ss!");
accounts.Add(acc);
Register → activate via email link¶
// registration sends an activation mail using template placeholders: {0}=name, {1}=link
registration.Register(acc, activationUri: "https://example.com/activate");
// later, when the user clicks the link:
var ok = registration.Activate(activationToken);
Login with throttling & lockout¶
var auth = new AuthenticationService(accountsRepo);
var ok = await auth.Validate("alice", "S7rong-P@ss!", clientIp: httpContext.Connection.RemoteIpAddress.ToString());
Password reset (email) → update¶
registration.ResetPassword("alice", "https://example.com/reset", mailBodyName: "default");
// in reset handler:
var success = registration.UpdatePassword(resetToken, "N3wS7rongP@ss!");
Enforce password history / expiry (optional)¶
// Validate at login time against expiry:
var historyOk = await pwdHistory.ValidateCurrentPassword("alice", "N3wS7rongP@ss!", DateTime.UtcNow, clientIp);
// Add a record after password change:
await pwdHistory.AddPasswordHistoryAsync(acc, "N3wS7rongP@ss!", DateTime.UtcNow);
Two-factor (OTP): require, validate, provision¶
// Suppose you have a TOTP authenticator implementing IOtpAuthenticator:
var otp = new OtpService(new Dictionary<string, IOtpAuthenticator> {
{ "Totp", new TotpAuthenticator(/* secrets/config */) }
});
// Account’s 2FA metadata (examples)
var twoFA = new[] {
"CIDR:10.0.0.0/8", // if matches request IP, OTP not required
"Totp:issuer=ACME;digits=6" // otherwise require TOTP via this provider
};
// At login UI step 1:
var cfg = otp.GetOtpConfiguration(twoFA, ipWhitelistMatch: cidrs => CidrHelper.IsClientWhitelisted(cidrs, clientIp));
if (cfg.OtpRequired) {
// Ask user for OTP and show available providers in cfg.OtpAuthenticatorIds
}
// Validate OTP:
var ok2 = otp.ValidateOtp(inputOtp, twoFA, otpAuthenticatorKey: "Totp");
// Provision (for UI that sets up TOTP):
var (manual, qr) = otp.GenerateOtpAuthenticatorSetupCode("alice", twoFA, "Totp");
Groups (authorization)¶
groups.Add(new UserGroup("admins", "Administrators", new HashSet<string>()));
groups.AddUser("admins", "alice");
var isInAny = groups.AnyContainsUser(new[] { "admins", "editors" }, "alice"); // true
Refresh tokens¶
// Issue
var token = refreshTokens.CreateRefreshToken("alice", TimeSpan.FromDays(7), clientIp);
// Exchange (rotate)
var rotated = refreshTokens.ExchangeRefreshToken(token.Token, "alice", TimeSpan.FromDays(7), clientIp);
// Revoke
var removed = refreshTokens.RemoveByAccount("alice");
Policies & configuration¶
- LoginAttemptPolicy (used by
AuthenticationServiceandAccountRepository):MaxNumberOfLoginAttempts— lock when reached withinResetInterval.LockedPeriod— how long the account stays locked.ResetInterval— window for counting attempts; attempts reset when exceeded.
- PasswordExpirationPolicy:
PasswordExpiryDurationInDays(default 3)PreviousPasswordsReUseLimit(default 3)
- OTP:
twoFactorAuthenticatorsis a string list: each item"type:configuration".- Special type
CIDRholds IP/net strings; if client matches, OTP may be skipped. OtpServicethrows clear errors for missing provider or invalid configuration.
Events & interception¶
All services derive from the standard base services and raise Adding/Added, Updating/Updated, Removing/Removed events with CancelEventArgs<T> on the “before” hooks (you can cancel).
FilterService (if you use real-time filters) additionally exposes Adding/Added/Deleting/Deleted transport connection events.
Provider discovery & connections¶
- Discovery helpers list compatible implementations from loaded assemblies:
AuthenticationService.GetAuthenticationProviderTypes(...)AccountService.GetRepositoryTypes(...)UserGroupService.GetRepositoryTypes(...)RefreshTokenService.GetRepositoryTypes(...)PasswordHistoryService.GetRepositoryTypes(...)
- ServiceConnection classes exist (Account/Authentication/RefreshToken/PasswordHistory/UserGroup), but are marked obsolete in favor of ASP.NET DI. Prefer normal DI/constructor injection.
Errors & edge cases¶
AccountService.Add*throws ifEncryptedPasswordis null.- Admin account cannot be removed.
- Back-compat hashing: if an existing
EncryptedPasswordis 20 bytes, it’s treated as legacy SHA-1 and still validated. - Activation / reset fail when token is unknown or expired.
- OTP: if the OTP provider key is unknown or not allowed by the account’s config, you get an explicit exception.
- Refresh token exchange throws when the supplied token/id is invalid or expired.
- UpdateMe respects
AllowMePasswordChange; iffalse, password changes are ignored. - Lock/unlock: unlocking occurs automatically on a valid login after
LockedDateEnd.
Quick reference¶
| Need… | Use… | Notes | |
|---|---|---|---|
| Create account + secure password | Account.SetPassword + AccountService.Add |
PBKDF2, sets Activated=true on creation |
|
| Login with throttling & lockout | AuthenticationService.Validate(id, pwd, clientIp) |
Uses LoginAttemptPolicy; doubles delay on failures |
|
| Register & email activation | RegistrationService.Register → Activate(token) |
Templates via MailTemplate + IMailSender |
|
| Password reset via email | ResetPassword(id | email, resetUri, body)→UpdatePassword(token,pwd) |
Writes password history if configured | |
| Enforce password expiry/reuse | PasswordHistoryService.ValidateCurrentPassword / AddPasswordHistoryAsync |
Policy defaults: 3 days / last 3 | |
| Manage refresh tokens | RefreshTokenService.Create/Exchange/RemoveByAccount |
Scoping by accountId or accountId-clientIp | |
| Two-factor (OTP) | OtpService.GetOtpConfiguration / ValidateOtp / GenerateSetupCode |
Supports CIDR whitelist |
|
| Group-based authorization | UserGroupService.AddUser/RemoveUser/AnyContainsUser |
Use ClaimsPrincipalExtensions to extract principals |
Minimal wiring (Program.cs)¶
// 1) App_Data convenience
AppDomain.CurrentDomain.SetData("DataDirectory",
Path.Combine(builder.Environment.ContentRootPath, "App_Data"));
// 2) Repos & services (see §5.1 for full example)
// 3) ASP.NET DI example
builder.Services.AddSingleton<IAccountRepository>(_ => new AccountRepository("[AppData]accounts.json".Resolve()));
builder.Services.AddSingleton<IRefreshTokenRepository>(_ => new RefreshTokenRepository("[AppData]refreshTokens.json".Resolve()));
builder.Services.AddSingleton<IUserGroupRepository>(_ => new UserGroupRepository("[AppData]groups.json".Resolve()));
builder.Services.AddSingleton<IPasswordHistoryRepository>(_ => new PasswordHistoryRepository("[AppData]passwordHistory.json".Resolve()));
builder.Services.AddSingleton<IMailTemplateRepository>(_ => new MailTemplateRepository("[AppData]mailTemplates.json".Resolve()));
builder.Services.AddSingleton<IMailSender>(_ => new SmtpMailSender("smtp.host.local"));
builder.Services.AddSingleton<AuthenticationService>();
builder.Services.AddSingleton<RefreshTokenService>();
builder.Services.AddSingleton<UserGroupService>();
builder.Services.AddSingleton<PasswordHistoryService>();
builder.Services.AddSingleton<MailTemplateService>();
builder.Services.AddSingleton<RegistrationService>();
Final notes¶
- Keep roles out of new code—user-groups replace them.
- If you migrate old JSON stores, leave legacy hashes in place; the provider will keep validating them, and any new password set will be stored as PBKDF2.
- When you build UI flows, surface clear messages for lockout and token expiry, and always require client IP for auth throttling.