DHI.Services.Security.WebApi — Internal Developer Guide¶
This package exposes HTTP endpoints for accounts, authentication, user-groups, and mail-templates on top of the domain services in DHI.Services. It’s intended for service-to-service and admin tooling consumption and ships with opinionated security behaviors (password policy, lockout, OTP, refresh tokens).
Source sample (ready to run): GitHub – Security Host https://github.com/DHI/DomainServicesSamples/tree/main/Host/Security
High-level architecture¶
- Controllers
AccountsController— CRUD for accounts, self-service “/me”, registration, activation, password reset, password policy & lockout policy surfaces.AuthenticationController— login (JWT access + refresh), token refresh, OTP registration/validation, token validation, credential validation.UserGroupsController— CRUD for user-groups and membership management.MailTemplatesController— CRUD for email templates used in registration & password reset flows.
- DTOs — Stable request/response contracts (no password hashes).
- Services (from core package) —
AccountService,AuthenticationService,UserGroupService,RegistrationService,PasswordHistoryService,RefreshTokenService. - Optional integrations
IPwnedPasswordsClient(HIBP breach checks)PasswordPolicy(custom validator)OtpService+IOtpAuthenticator(TOTP/HOTP or similar)PasswordHistoryService+PasswordExpirationPolicy
- Persistence wrappers (JSON file repositories rooted in App_Data):
Security.WebApi.MailTemplateRepositorySecurity.WebApi.RefreshTokenRepositorySecurity.WebApi.UserGroupRepository
- Utilities
- JWT claim builders (
IClaimsSet), client IP detection (respectsCF-Connecting-IP/X-Forwarded-For), CIDR whitelist for OTP bypass. - System.Text.Json converters (custom serialization).
- JWT claim builders (
API versioning: all controllers use [ApiVersion("1")]. Swagger annotations are present.
Configuration (appsettings)¶
These keys must be present for features you enable:
{
"Tokens": {
"Issuer": "your-issuer",
"Audience": "your-audience",
"PrivateRSAKey": "<path-or-value>", // used to sign JWTs
"PublicRSAKey": "<path-or-value>", // used to validate JWTs
"ExpirationInMinutes": 30,
"RefreshExpirationInDays": 365,
"DisableOtp": false
},
"Registration": {
"SmtpSetCredentials": true,
"SmtpHost": "smtp.example.com",
"SmtpPort": 587,
"SmtpUsername": "smtp-user",
"SmtpPassword": "smtp-pass",
"TokenLifeTime": "1.00:00:00", // TimeSpan (1 day)
"AccountActivationUri": "https://app/activate",
"PasswordResetUri": "https://app/reset"
},
"AppConfiguration": {
"2FAMetadataKey": "2FAMetadata" // user-group metadata key storing OTP config
}
}
Data directory: The JSON repositories look under
AppDomain.CurrentDomain.GetData("DataDirectory"). Ensure this is set at startup to a folder that contains*.jsonfiles for mail templates, refresh tokens, and user groups.
Dependency injection (what to register)¶
At minimum:
IAccountRepositoryIUserGroupRepository(or the WebApi JSON repo wrapper)IRefreshTokenRepository(or the WebApi JSON repo wrapper)IAuthenticationProvider(backed by your account repo)IMailTemplateRepository(if you use registration/password reset emails)
Optional:
IPwnedPasswordsClientPasswordPolicy(custom implementation withValidateAsync)IPasswordHistoryRepository+PasswordExpirationPolicyOtpServicewith one or moreIOtpAuthenticatorIClaimsSet(defaults toClaimsSetusing user-group membership)
Authorization:
- Define an “AdministratorsOnly” policy that checks membership in your Admin group (we store group ids as claims with type
ClaimTypes.GroupSid). Most admin endpoints require this policy.
Security behaviors (built-in)¶
- Password hashing: PBKDF2 with random 16-byte salt (10,000 iterations). Legacy SHA-1 is only accepted for existing hashes (not generated).
- Breach check (optional): If
IPwnedPasswordsClientis registered, incoming passwords are rejected if found in HIBP. - Password policy (optional): If a
PasswordPolicyis registered, all set/reset operations must passValidateAsync. - Password history (optional): If wired, the service enforces expiry (
PasswordExpirationPolicy.PasswordExpiryDurationInDays) and reuse (PreviousPasswordsReUseLimit). - Lockout:
LoginAttemptPolicydrives incremental lock and reset windows (see API to read the policy). - OTP (optional): Enforced if enabled and the user’s groups carry 2FA metadata; can be bypassed for whitelisted CIDR blocks.
- Progressive delay: Authentication attempts introduce exponential backoff per client IP on failures.
- JWT tokens: RSA-signed access tokens (
RS256) + long-lived refresh tokens (random 32-byte, base64).
Claims & groups¶
We produce claims as follows:
sub(JWT) — account idname— account nameemail— optionalcompany— optional- metadata — each
account.Metadatakey/value as a claim (key lower-cased; valueToString()ed) - roles — legacy roles (if present) as
ClaimTypes.Role - groups — every user-group id as
ClaimTypes.GroupSid
Your resource authorization can target:
- Role claims (legacy)
- Group claims (
ClaimTypes.GroupSid) - Custom metadata claims
Two-factor (OTP) model¶
- User-group metadata under key
AppConfiguration:2FAMetadataKeysupplies 2FA config as astring[]. - Special value
"CIDR:<block>"entries define IP whitelist rules; if the client IP is within any listed CIDR, OTP is not required. - If client is not whitelisted and there are no valid OTP authenticators for the user, access is forbidden; otherwise, client must supply OTP.
Endpoints¶
Below are the main routes with the essentials: path, auth, what it does, and the key request/response shapes. All endpoints return standard HTTP status codes and JSON bodies.
1) Authentication¶
POST api/tokens — Create access/refresh token¶
- Auth: Anonymous (validates credentials)
- Body:
ValidationDTO { id, password, otp?, otpAuthenticator? } - Flow:
- Validate credentials (+ lockout & progressive delay)
- Check account flags (
Enabled,Locked) - Enforce password expiry/reuse if
PasswordHistoryServiceis active - Enforce OTP, possibly return
OtpConfigurationhandshake if OTP is required but not provided - Issue
accessToken(JWT) +refreshToken
- 200 OK:
{
"accessToken": { "token": "<jwt>", "expiration": "2025-01-01T00:00:00Z" },
"tokenType": "bearer",
"refreshToken": { "token": "<opaque>", "expiration": "2026-01-01T00:00:00Z" }
}
{ "otpRequired": true, "otpAuthenticatorIds": ["YourOtpAuthenticator"] }
"Account validation failed." | "Account is disabled." | "Account is locked." | "Illegal one-time password."
POST api/tokens/refresh — Exchange refresh token¶
- Auth: Anonymous (uses the token)
- Body: JSON string:
"refresh-token" - Returns: New access token + new refresh token pair
- Errors: 400 if account disabled, 404/400 if token invalid/expired
POST api/tokens/otp/registration — Get OTP setup codes¶
- Auth: Anonymous (validates credentials first)
- Body:
OtpRegistrationDTO { id, password, otpAuthenticator } - Returns:
{ manualEntryCode, qrCode }for selected authenticator - Errors: 400 if OTP disabled/unavailable or validation fails
POST api/accounts/validation — Validate credentials¶
- Auth: Requires caller token? (Controller doesn’t
[Authorize]; used by internal flows) - Body:
ValidationDTO - Returns:
200 OKwith Account (on success) or400 BadRequestwith reason (including"Account password is expired.")
POST api/tokens/validation — Validate access token¶
- Auth: Anonymous
- Body: JSON string:
"<access-token>" - Returns:
200 OK "Token is valid"or400 BadRequest
Warning
GET api/tokens/PasswordHistory/Get/{id}/{password} exists for troubleshooting. It’s AllowAnonymous and exposes credentials in the URL; do not enable in production.
2) Accounts¶
Note
All admin endpoints require the AdministratorsOnly policy.
PUT api/accounts/activation?token=...¶
- Completes registration with a one-time activation token
- 200 / 400
POST api/accounts¶
- Auth: AdministratorsOnly
- Body:
AccountDTO(requirespassword) - Validations (optional):
IPwnedPasswordsClient,PasswordPolicy - Side-effects: Creates account (activated), adds to groups, writes password history (if enabled)
- 201 Created with
AccountDTO
PUT api/accounts¶
- Auth: AdministratorsOnly
- Body:
AccountUpdateDTO(password optional) - Group updates: If including
"Administrators"inuserGroups, only an existing admin may grant that; otherwise throws. - 200 OK with updated
AccountDTO
DELETE api/accounts/{id}¶
- Auth: AdministratorsOnly
- 204 NoContent
GET api/accounts/{id}¶
- Auth: AdministratorsOnly
- 200 with
AccountDTO(includes user-groups)
GET api/accounts¶
- Auth: AdministratorsOnly
- 200 with
AccountDTO[]
GET api/accounts/count¶
- Auth: AdministratorsOnly
- 200 with integer
POST api/accounts/registration¶
- Auth: Anonymous
- Body:
RegistrationDTO - Flow: breach/policy checks → create inactive account → email activation link
- 202 Accepted
PUT api/accounts/me¶
- Auth: Any authenticated user
- Body:
MeDTO - Only self-service updates for email, phone, company, and password (if
AllowMePasswordChange == true). - 200 returns the submitted
MeDTO
GET api/accounts/me¶
- Auth: Any authenticated user
- 200 with
AccountDTOfor the caller
GET api/accounts/passwordpolicy¶
- 200 returns the registered
PasswordPolicy(or throws if none)
POST api/accounts/passwordreset?mailBody=default¶
- Auth: Anonymous
- Body: JSON string:
"email-or-accountId" - Sends reset email with tokenized link
- 202 or 404
PUT api/accounts/password?token=...¶
- Auth: Anonymous (token-gated)
- Body: JSON string:
"newPassword" - Policy/breach checks applied, password history recorded
- 200 or 400
GET api/accounts/loginattemptpolicy¶
- 200 returns
LoginAttemptPolicy(MaxAttempts, ResetInterval, LockedPeriod)
3) User groups¶
All endpoints require AdministratorsOnly.
- GET
api/usergroups/{id}→UserGroup - GET
api/usergroups→UserGroup[] - GET
api/usergroups/count→ integer - GET
api/usergroups/ids?userId=...→ string[] (all ids or the ones the user belongs to) - POST
api/usergroupswithUserGroupDTO→201 Created - POST
api/usergroups/user/{userId}bodystring[] groupIds→ add user to groups - PUT
api/usergroupswithUserGroupDTO→200with stored group - DELETE
api/usergroups/{id}→204 - DELETE
api/usergroups/user/{userId}?groupId=...→ remove from a group or all
2FA metadata location: group Metadata under key AppConfiguration:2FAMetadataKey (default 2FAMetadata) should store an array of strings, e.g.:
{
"2FAMetadata": [
"GoogleOtp:issuer=MyApp",
"CIDR:203.0.113.0/24 &Comment: HQ",
"CIDR:2001:db8::/32"
]
}
4) Mail templates¶
All endpoints require AdministratorsOnly.
- GET
api/mailtemplates/{id}→MailTemplate - GET
api/mailtemplates→MailTemplate[] - GET
api/mailtemplates/count→ integer - GET
api/mailtemplates/ids→ string[] - POST
api/mailtemplateswithMailTemplateDTO→201 - PUT
api/mailtemplateswithMailTemplateDTO→200 - DELETE
api/mailtemplates/{id}→204
Mail templates are used by registration and password reset emails. Bodies in the template let you serve alternate body variants by key (e.g., ?mailBody=passwordless).
Request/response examples¶
Create token¶
POST /api/tokens
Content-Type: application/json
{
"id": "jdoe",
"password": "S3cure!passw0rd",
"otp": "123456",
"otpAuthenticator": "GoogleOtpAuthenticator"
}
200 OK
{
"accessToken": { "token": "eyJhbGciOiJSUzI1NiIs...", "expiration": "2025-01-01T00:00:00Z" },
"tokenType": "bearer",
"refreshToken": { "token": "8dH4...==", "expiration": "2026-01-01T00:00:00Z" }
}
Register account¶
POST /api/accounts/registration
Content-Type: application/json
{
"id": "jdoe",
"name": "Jane Doe",
"email": "jane@example.com",
"company": "Contoso",
"password": "S3cure!passw0rd"
}
202 Accepted (activation email sent).
Wiring notes & extension points¶
-
JWT signing keys
Tokens:PrivateRSAKey/PublicRSAKeyare resolved viaRSA.BuildSigningKey("<value>".Resolve()). You can store the keys as file paths or direct PEM content behind theResolve()mechanism used in your environment. -
Pwned passwords If you don’t register
IPwnedPasswordsClient, breach checks are skipped. -
Password policy If you don’t register
PasswordPolicy, policy validation is skipped. TheGET /api/accounts/passwordpolicyendpoint throws. -
Password history Entirely optional. If enabled, the system:
- Rejects expired passwords at login.
- Adds history after registration, admin updates with a new password, self-updates, and after a password reset.
- Prevents reuse based on
PreviousPasswordsReUseLimit.
-
OTP If you don’t register
OtpService(or setTokens:DisableOtp=true), OTP behavior is disabled and the/otp/*endpoint will return a 400. -
AdministratorsOnly policy You must configure this policy yourself. Typical approach: require a
ClaimTypes.GroupSidequal to"Administrators"(or your chosen group id). -
Serialization
SerializerOptionsDefault.Optionsregisters custom converters:ByteArrayConverter(for byte[] round-tripping in JSON with$type/$value)MailTemplateConverter,UserGroupConverter- Dictionary/object type resolvers from
DHI.Services.Converters
-
Client IP
AuthenticationControllerdetermines the real client IP usingCF-Connecting-IP, falls back toX-Forwarded-For, then to the socket address. Ensure your reverse proxy forwards one of these headers.
Operational safeguards¶
- Do not expose
GET /api/tokens/PasswordHistory/Get/{id}/{password}in production; it’s for diagnostics and logs credentials in the URL path. - Admin elevation guard:
AccountsController.UpdateUserGroupsrefuses to grant “Administrators” unless the caller is already an admin. - Account disable/lock: Token creation and token refresh reject disabled accounts; logins honor lockout state.
- Legacy roles:
Account.RolesandGetRoles()are marked obsolete. Prefer user-groups and permission claims (but roles still flow intoClaimTypes.Rolefor backward compatibility).
Minimal startup checklist¶
-
Set data root
AppDomain.CurrentDomain.SetData("DataDirectory", Path.Combine(env.ContentRootPath, "App_Data")); -
Register repos/services
services.AddSingleton<IAccountRepository, MyAccountRepository>(); services.AddSingleton<IUserGroupRepository>(sp => new Security.WebApi.UserGroupRepository("usergroups.json", SerializerOptionsDefault.Options)); services.AddSingleton<IRefreshTokenRepository>(sp => new Security.WebApi.RefreshTokenRepository("refreshtokens.json", SerializerOptionsDefault.Options)); services.AddSingleton<IMailTemplateRepository>(sp => new Security.WebApi.MailTemplateRepository("mailtemplates.json", SerializerOptionsDefault.Options)); services.AddSingleton<IAuthenticationProvider>(sp => sp.GetRequiredService<IAccountRepository>()); services.AddSingleton<LoginAttemptPolicy>(); // Optional: // services.AddSingleton<PasswordPolicy, MyPasswordPolicy>(); // services.AddSingleton<IPwnedPasswordsClient, PwnedPasswordsClient>(); // services.AddSingleton<IPasswordHistoryRepository, JsonPasswordHistoryRepository>(); // services.AddSingleton<PasswordExpirationPolicy>(); // services.AddSingleton(new OtpService(new Dictionary<string, IOtpAuthenticator>{{"GoogleOtpAuthenticator", new GoogleOtpAuthenticator(...)}})); -
Authorization policy
services.AddAuthorization(opts => { opts.AddPolicy("AdministratorsOnly", p => p.RequireClaim(ClaimTypes.GroupSid, "Administrators")); }); -
Swagger & API versioning as usual.
Glossary (objects/DTOs at a glance)¶
AccountDTO— admin create/read:id, name, password, email, company, phoneNumber, activated, enabled, allowMePasswordChange, userGroups, metadata, lockout fieldsAccountUpdateDTO— admin update: same as above butpasswordoptionalMeDTO— self update:id, name, email, company, phoneNumber, password?RegistrationDTO— self registration:id, name, email, company?, passwordValidationDTO— login:id, password, otp?, otpAuthenticator?TokenDTO/TokensDTO— JWT tokensUserGroupDTO—id, name, users?, metadata?LoginAttemptPolicy—MaxNumberOfLoginAttempts,ResetInterval,LockedPeriod