Skip to content

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.MailTemplateRepository
    • Security.WebApi.RefreshTokenRepository
    • Security.WebApi.UserGroupRepository
  • Utilities
    • JWT claim builders (IClaimsSet), client IP detection (respects CF-Connecting-IP / X-Forwarded-For), CIDR whitelist for OTP bypass.
    • System.Text.Json converters (custom serialization).

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 *.json files for mail templates, refresh tokens, and user groups.


Dependency injection (what to register)

At minimum:

  • IAccountRepository
  • IUserGroupRepository (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:

  • IPwnedPasswordsClient
  • PasswordPolicy (custom implementation with ValidateAsync)
  • IPasswordHistoryRepository + PasswordExpirationPolicy
  • OtpService with one or more IOtpAuthenticator
  • IClaimsSet (defaults to ClaimsSet using 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 IPwnedPasswordsClient is registered, incoming passwords are rejected if found in HIBP.
  • Password policy (optional): If a PasswordPolicy is registered, all set/reset operations must pass ValidateAsync.
  • Password history (optional): If wired, the service enforces expiry (PasswordExpirationPolicy.PasswordExpiryDurationInDays) and reuse (PreviousPasswordsReUseLimit).
  • Lockout: LoginAttemptPolicy drives 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 id
  • name — account name
  • email — optional
  • company — optional
  • metadata — each account.Metadata key/value as a claim (key lower-cased; value ToString()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:2FAMetadataKey supplies 2FA config as a string[].
  • 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:
    1. Validate credentials (+ lockout & progressive delay)
    2. Check account flags (Enabled, Locked)
    3. Enforce password expiry/reuse if PasswordHistoryService is active
    4. Enforce OTP, possibly return OtpConfiguration handshake if OTP is required but not provided
    5. 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" }
}
* 200 OK (OTP challenge):

{ "otpRequired": true, "otpAuthenticatorIds": ["YourOtpAuthenticator"] }
* 400 BadRequest: "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 OK with Account (on success) or 400 BadRequest with 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" or 400 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 (requires password)
  • 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" in userGroups, 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 AccountDTO for 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/usergroupsUserGroup[]
  • GET api/usergroups/count → integer
  • GET api/usergroups/ids?userId=... → string[] (all ids or the ones the user belongs to)
  • POST api/usergroups with UserGroupDTO201 Created
  • POST api/usergroups/user/{userId} body string[] groupIds → add user to groups
  • PUT api/usergroups with UserGroupDTO200 with 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/mailtemplatesMailTemplate[]
  • GET api/mailtemplates/count → integer
  • GET api/mailtemplates/ids → string[]
  • POST api/mailtemplates with MailTemplateDTO201
  • PUT api/mailtemplates with MailTemplateDTO200
  • 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 / PublicRSAKey are resolved via RSA.BuildSigningKey("<value>".Resolve()). You can store the keys as file paths or direct PEM content behind the Resolve() 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. The GET /api/accounts/passwordpolicy endpoint 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 set Tokens: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.GroupSid equal to "Administrators" (or your chosen group id).

  • Serialization SerializerOptionsDefault.Options registers custom converters:

    • ByteArrayConverter (for byte[] round-tripping in JSON with $type/$value)
    • MailTemplateConverter, UserGroupConverter
    • Dictionary/object type resolvers from DHI.Services.Converters
  • Client IP AuthenticationController determines the real client IP using CF-Connecting-IP, falls back to X-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.UpdateUserGroups refuses 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.Roles and GetRoles() are marked obsolete. Prefer user-groups and permission claims (but roles still flow into ClaimTypes.Role for backward compatibility).

Minimal startup checklist

  1. Set data root

    AppDomain.CurrentDomain.SetData("DataDirectory", Path.Combine(env.ContentRootPath, "App_Data"));
    
  2. 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(...)}}));
    
  3. Authorization policy

    services.AddAuthorization(opts =>
    {
        opts.AddPolicy("AdministratorsOnly", p => p.RequireClaim(ClaimTypes.GroupSid, "Administrators"));
    });
    
  4. 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 fields
  • AccountUpdateDTO — admin update: same as above but password optional
  • MeDTO — self update: id, name, email, company, phoneNumber, password?
  • RegistrationDTO — self registration: id, name, email, company?, password
  • ValidationDTO — login: id, password, otp?, otpAuthenticator?
  • TokenDTO/TokensDTO — JWT tokens
  • UserGroupDTOid, name, users?, metadata?
  • LoginAttemptPolicyMaxNumberOfLoginAttempts, ResetInterval, LockedPeriod