DHI.Services.PostgreSQL for Security — Internal Developer Guide¶
This provider stores Security data in PostgreSQL using thin repositories. It auto-creates tables on first use and gives you direct CRUD over Accounts, User Groups, Refresh Tokens, and Password History.
Foundational helpers (base repo, JSON converters, connection-string “Table=” override, etc.) are in PostgreSQL Providers.
What’s in this package¶
AccountRepository— Accounts table (credentials, profile, lockout/enablement, metadata).UserGroupRepository— User groups + membership list (text array).RefreshTokenRepository— Long-lived refresh tokens per account.PasswordHistoryRepository— Rotating/expired passwords per account.
Each repository:
- Ensures its table exists (and indexes) at startup.
- Uses Npgsql and simple SQL (no ORM).
- Serializes
Metadatadictionaries as JSON strings (VARCHAR).
Table schemas¶
The provider will create these tables automatically if missing and add some columns at runtime (see “Automatic provisioning & seeding”).
1) public.accounts¶
CREATE TABLE IF NOT EXISTS public.accounts (
id varchar(255) NOT NULL,
name varchar(255) NOT NULL,
encryptedpassword bytea NOT NULL,
activated boolean NOT NULL,
token varchar(255),
tokenexpiration timestamp without time zone,
company varchar(255),
email varchar(255),
phonenumber varchar(255),
allowmepasswordchange boolean NOT NULL,
enabled boolean NOT NULL,
noofunsuccessfulloginattempts int NOT NULL,
lastloginattempteddate timestamp with time zone,
locked boolean NOT NULL,
lockeddateend timestamp with time zone,
roles varchar(255),
metadata varchar(2048),
CONSTRAINT accounts_pk PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS accounts_index ON public.accounts USING btree (name DESC);
Notes
encryptedpasswordstores either:- legacy SHA-1 hash bytes (20 bytes), or
- PBKDF2 (Rfc2898): 16-byte salt + 20-byte hash (see “Password hashing”).
metadatais serialized JSON (string, up to 2048 chars) — not JSONB.- Case-insensitive lookup on
id/emailis done withLOWER(...).
2) public.usergroups¶
CREATE TABLE IF NOT EXISTS public.usergroups (
id varchar(255) NOT NULL,
name varchar(255) NOT NULL,
users text[], -- membership, holds account IDs
metadata varchar(2048),
CONSTRAINT usergroups_pk PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS usergroups_index ON public.usergroups USING btree (id DESC);
3) public.refreshtokens¶
CREATE TABLE IF NOT EXISTS public.refreshtokens (
id varchar(255) NOT NULL,
token varchar(255) NOT NULL,
accountid varchar(255) NOT NULL,
expiration timestamp without time zone NOT NULL,
clientip varchar(255),
CONSTRAINT refreshtokens_pk PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS refreshtokens_index ON public.refreshtokens USING btree (expiration DESC);
4) public.passwordhistory¶
CREATE TABLE IF NOT EXISTS public.passwordhistory (
id varchar(255) NOT NULL,
accountid varchar(255) NOT NULL,
encryptedpassword bytea NOT NULL,
passwordexpirydate timestamp without time zone NOT NULL,
CONSTRAINT passwordhistory_pk PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS passwordhistory_index ON public.passwordhistory USING btree (accountid);
Automatic provisioning & seeding¶
- On first run:
- Each repository ensures its table exists (
AssureTableExists→CreateDataModel).
- Each repository ensures its table exists (
AccountRepository:- If table empty, seeds a demo admin user:
id = "demo_admin_to_be_deleted",name = "Demo Admin - TO BE DELETED",password = "webapi",activated = true.
- Calls
ExtendDataModel()to add missing modern columns:enabled boolean DEFAULT true NOT NULLnoofunsuccessfulloginattempts int DEFAULT 0 NOT NULLlastloginattempteddate timestamptz DEFAULT now() NOT NULLlocked boolean DEFAULT false NOT NULLlockeddateend timestamptz DEFAULT NULL
- If table empty, seeds a demo admin user:
UserGroupRepository:- If empty, seeds:
Administrators(members:demo_admin_to_be_deleted)Editors(members:demo_admin_to_be_deleted)
- If empty, seeds:
Action required: In any non-test environment, delete or replace the seed user and membership immediately.
Password hashing & validation¶
AccountRepository.ValidatePassword(accountId, password):
- If
EncryptedPassword.Length == 20→ legacy SHA-1 (kept only for backward compatibility). - Else → PBKDF2 (Rfc2898):
- Salt: first 16 bytes of
EncryptedPassword - Iterations: 10000
- Hash: next 20 bytes
- Compares derived 20-byte hash to stored hash.
- Salt: first 16 bytes of
New passwords should always be stored as PBKDF2 (salt+hash in bytea).
Repository APIs (what you call)¶
AccountRepository (PostgreSQL)¶
- CRUD & queries
Get(id)/GetAll()/Contains(id)/Add(account)/Update(account)/Remove(id)GetByEmail(email)— case-insensitiveGetByToken(token)— exact matchCount()
- Auth / validation
ValidatePassword(accountId, password)— validates against storedencryptedpassword
- Lockout helpers (write & save the account)
LockAccount(account, lockPeriod)— setsLocked=true,LockedDateEnd=now+period, updatesLastLoginAttemptedDateResetAccount(account, resetValue)— setsNoOfUnsuccessfulLoginAttempts=resetValue, updatesLastLoginAttemptedDateUnlockAccount(account)— clearsLocked,LockedDateEnd, resets attempts, updatesLastLoginAttemptedDate
Behavioral details
- Most lookups use case-insensitive
LOWER(id)/LOWER(email). Metadatais round-tripped viaSystem.Text.Jsonwith DS converters.GetAll()yields rows ordered byid.
UserGroupRepository¶
Add,Update,RemoveGet(id),GetAll(),GetIds(),Contains(id),Count()usersmaps totext[](account IDs). We write the whole array for updates.
RefreshTokenRepository¶
Add,Update,RemoveGetByToken(token)→Maybe<RefreshToken>GetByAccount(accountId)→ enumerable- Index on
expirationfor cleanup/lookup.
PasswordHistoryRepository¶
Add,Update,RemoveGetByAccountId(accountId)(all, DESC byPasswordExpiryDate)GetMostRecentByAccountId(accountId)(LIMIT 1)GetRecentByAccountId(accountId, limit)
Usage snippets¶
Create repositories¶
var cs = "Host=...;Port=5432;Database=security;Username=...;Password=...;SSL Mode=Require;";
// Accounts
var accounts = new DHI.Services.Provider.PostgreSQL.AccountRepository(cs, logger);
// Groups
var groups = new DHI.Services.Provider.PostgreSQL.UserGroupRepository(cs, logger);
// Refresh tokens
var refreshTokens = new DHI.Services.Provider.PostgreSQL.RefreshTokenRepository(cs, logger);
// Password history
var pwdHistory = new DHI.Services.Provider.PostgreSQL.PasswordHistoryRepository(cs, logger);
Add an account¶
var acc = new Account("jdoe", "John Doe")
{
Email = "jdoe@example.com",
Company = "ACME",
AllowMePasswordChange = true,
Activated = true,
Enabled = true
};
acc.SetPassword("S3cure!passw0rd"); // sets EncryptedPassword
accounts.Add(acc);
Validate a password¶
var ok = await accounts.ValidatePassword("jdoe", "S3cure!passw0rd");
Lock/unlock¶
var maybe = accounts.Get("jdoe");
if (maybe.HasValue)
{
var a = maybe.Value;
accounts.LockAccount(a, TimeSpan.FromMinutes(15));
// ...
accounts.UnlockAccount(a);
}
Add a group & members¶
var ug = new UserGroup("Operators", "Ops team", new HashSet<string>{ "jdoe", "alice" });
groups.Add(ug);
Refresh tokens¶
var rt = refreshTokens.GetByToken(refreshTokenString);
if (rt.HasValue)
{
// exchange/update your token then persist
var entity = rt.Value;
entity.Expiration = DateTime.UtcNow.AddDays(90);
refreshTokens.Update(entity);
}
Operational guidance¶
- Security
- Use TLS to the DB; store the connection string in a secret store.
- Grant least privilege to the application role (CRUD on these tables only).
- Capacity
metadatacapped at 2048 chars. Keep custom claims/config small.
- Time zones
lastloginattempteddateuses timestamptz; other timestamps are without TZ. Store UTC and convert in app/UI.
- Case-insensitivity
- Server enforces
LOWER(id)/LOWER(email)on lookups; treat IDs/emails case-insensitive in your code too.
- Server enforces
- Indexes
- Accounts:
name(desc) - UserGroups:
id(desc) - RefreshTokens:
expiration(desc) - PasswordHistory:
accountid
- Accounts:
Tips¶
- Seed data: remove
demo_admin_to_be_deletedand strip it fromAdministratorsafter first start. - Metadata type: stored as VARCHAR JSON. If you need querying by metadata keys, consider a migration to JSONB; otherwise keep metadata small and client-interpreted.
- Legacy password hashes: accounts created long ago may still use 20-byte SHA-1. They’ll validate, but when users change password they should be upgraded to PBKDF2.
- Extending schema:
ExtendDataModel()adds lockout columns if missing. Keep it enabled until all environments are migrated. - Performance:
GetByEmail/GetByTokenscan the table; add optional indexes if these lookups are frequent in your workload.
Where the base behaviors live¶
All cross-cutting PostgreSQL provider concerns (base repository, JSON converter setup, connection string “Table=” override, etc.) are documented in PostgreSQL Providers.