ulinzi

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 7, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

README

ulinzilib-go

A Go port of the .NET 8 Adelphi.SecurityLib — packaged as a reusable Go module for services such as the ShughuliYangu backend. It re-implements the library's behaviour in idiomatic Go; it does not link or ship any .NET assembly.

Entities live in a dedicated domain package (pure User, AppUserMfa, AppUserPasskey, IdpConfiguration, UserLogin, … — no I/O or framework deps); the root ulinzi package holds the application service + the repository ports; postgres is the only adapter. Faithful to the reference, security.users carries no MFA secret/state and no external-IdP subject ids — MFA lives in security.app_user_mfa and external identities in security.user_logins.

Status

Area State
Opaque-session validation (revoke / absolute + idle expiry / inactive user)
Role + team permission resolution with parent-role inheritance
net/http middleware — SessionEnforcement, RequirePermission
Local login / issuance / logout, CreateUser, ChangePassword
Pluggable MFA registry (MfaMethod): TOTP + single-use recovery codes
MFA secret encryption at rest (AES-256-GCM) + TOTP replay guard
Password policy enforcement (+ optional HIBP breach check)
Brute-force lockout (progressive) + login-attempt tracking + admin unlock
Tamper-evident audit log (hash-chained, verifiable)
Security-stamp global logout (password/MFA/role change invalidates sessions)
Per-user session cap (evict oldest)
WebAuthn / passkeys — real FIDO2 registration + assertion (WebAuthnService, security.app_user_passkeys)
External IdP linkagesecurity.user_logins resolve/link/unlink (WithExternalLogins)
IdP configuration — per-provider config with encrypted secrets (IdpConfigService, security.idp_configurations)
goose migrations that own + seed the security schema (embedded)
Multi-provider OIDC token exchange, trusted devices, IP whitelisting 🚧 roadmap (ROADMAP.md)

Install

go get github.com/Cyrus-0101/ulinzilib-go@latest

Apply the security schema

Migrations are embedded in the optional migrate subpackage (//go:embed), so a go get consumer can run them with no checkout. The core ulinzi / postgres packages import neither embed nor goose — only your migrator entrypoint imports migrate.

import (
    "database/sql"
    _ "github.com/jackc/pgx/v5/stdlib"
    "github.com/Cyrus-0101/ulinzilib-go/migrate"
)

db, _ := sql.Open("pgx", os.Getenv("DATABASE_URL"))
defer db.Close()
if err := migrate.Up(db); err != nil { log.Fatal(err) }

Or use the bundled CLI (embedded migrations by default), or the external goose CLI:

export ULINZI_DATABASE_URL='postgres://user:pass@localhost:5432/shughuliyangu?sslmode=disable'
go run ./cmd/ulinzi-migrate -cmd up          # or: make migrate-up / migrate-status / migrate-down
goose -dir migrate/migrations postgres "$ULINZI_DATABASE_URL" up

Use it

package main

import (
    "context"
    "net/http"

    "github.com/jackc/pgx/v5/pgxpool"

    ulinzi "github.com/Cyrus-0101/ulinzilib-go"
    ulinzipg "github.com/Cyrus-0101/ulinzilib-go/postgres"
)

func main() {
    pool, _ := pgxpool.New(context.Background(), "postgres://.../shughuliyangu")
    store := ulinzipg.New(pool) // implements every ulinzi store interface

    // Build a TOTP secret protector from a 32-byte key (KMS/secret manager in prod).
    protector, _ := ulinzi.NewAESGCMSecretProtector(mustLoadKey())
    totp := ulinzi.NewTOTPService(store, "ShughuliYangu",
        ulinzi.TOTPWithSecretProtector(protector),
        ulinzi.TOTPWithReplayGuard(store))
    recovery := ulinzi.NewRecoveryCodeMethod(store)

    auth := ulinzi.New(store, ulinzi.DefaultConfig(),
        ulinzi.WithAuthStore(store),                 // login / issuance / ChangePassword
        ulinzi.WithMfaMethods(totp, recovery),       // pluggable MFA factors
        ulinzi.WithPasswordPolicy(store),            // enforce password rules
        ulinzi.WithLockout(store),                   // brute-force lockout
        ulinzi.WithAudit(store),                     // tamper-evident audit log
        ulinzi.WithSecurityStamps(store),            // global logout on credential change
    )

    mux := http.NewServeMux()
    mux.HandleFunc("/api/funnels", func(w http.ResponseWriter, r *http.Request) {
        p, ok := ulinzi.PrincipalFromContext(r.Context())
        if !ok || !auth.HasPermission(r.Context(), "funnel.canRead") {
            http.Error(w, "forbidden", http.StatusForbidden)
            return
        }
        _ = p // p.UserID, p.Roles, p.Has(key) — layer tenant scope on top
    })

    // SessionEnforcement validates the opaque cookie and injects the Principal.
    _ = http.ListenAndServe(":8080", auth.SessionEnforcement(mux))
}

See docs/USAGE.md for the full guide (login, MFA enrolment, password change, admin unlock, audit verification, tenancy).

HasPermission(ctx, key) / Principal.Has(key) are the Go analogue of the .NET IAuthorizationService.HasPermissionAsync(userId, key). Layer ShughuliYangu's tenant scoping (allowedSubAccountIds) on top of the permission check.

.NET → Go mapping

.NET Go (ulinzi)
services.AddSecurityLib(...) ulinzi.New(ulinzipg.New(pool), cfg, With...)
SessionEnforcementMiddleware Service.SessionEnforcement(next)
IAuthorizationService.HasPermissionAsync Service.HasPermission / Principal.Has
IMfaService (typed factors) MfaMethod registry (TOTPService, RecoveryCodeMethod, …)
IWebAuthnPasskeyService (Fido2NetLib) WebAuthnService (github.com/go-webauthn/webauthn) + PasskeyStore
IUserRepository.GetByZitadelUserIdAsync (external logins) Service.ResolveExternalLogin + ExternalLoginStore (security.user_logins)
IIdpConfigurationRepository + IIdpSecretProtector IdpConfigService + IdpSecretProtector (AESGCMIdpSecretProtector)
IMfaSecretProtector (DataProtection) MfaSecretProtector (AESGCMSecretProtector)
IMfaCodeUsageRepository (replay) MfaUsageStore.TryClaimMfaCode
IPasswordPolicyService / IHibpService ValidatePassword + PasswordPolicyStore / HibpService
ILockoutPolicyService / ILoginAttemptsService LockoutService + LockoutStore
IAuditService (append log) AuditServicehash-chained, VerifyChain
UpdateSecurityStampAsync + SecurityStampValidator Service.InvalidateUserSessions + per-request stamp check
EF Core migrations embedded goose SQL in migrate + cmd/ulinzi-migrate

Shared vs. own security database

  • Own (default, recommended): the migrate package's goose migrations solely own the security schema alongside your app schema. One clean migration owner.
  • Shared (unified SSO with MailRush): point at the DB the .NET UlinziLib owns and do not run these migrations there (EF Core and goose must not both own one schema). This module then only reads sessions/permissions; the .NET service owns login/MFA/issuance.

Security model

Opaque sessions, bcrypt password hashing, TOTP with a replay ledger, AES-256-GCM encryption of MFA secrets, PBKDF2-salted single-use recovery codes, progressive lockout, uniform login errors + timing equalization, security-stamp global logout, and a hash-chained audit trail. See SECURITY.md for the full model and how to report vulnerabilities.

Testing

go test ./...                    # unit tests (fakes — no database)
go test -race -cover ./...       # race detector + coverage
go test -tags integration ./postgres   # integration: needs a migrated ULINZI_DATABASE_URL

CI (.github/workflows/ci.yml) runs gofmt, vet, build, and go test -race on every push and PR.

Layout

domain/              PURE entities/value objects (User, UserAuth, SessionRecord,
                       SessionPolicy, Principal, AppUserMfa, AppUserPasskey,
                       IdpConfiguration, UserLogin, policies, …) — the DDD domain layer
ulinzi.go            Service, Config, options (WithAuthStore/WithMfaMethods/…/WithExternalLogins)
aliases.go           re-exports of domain types under the ulinzi.* names (API stability)
store.go             Store interface (session + permission reads)
session.go           ValidateSession (opaque-session + security-stamp checks)
account.go           Login / CreateUser / ChangePassword / Logout
authz.go             HasPermission / permission errors
middleware.go        SessionEnforcement + RequirePermission (net/http)
mfa.go               MfaMethod registry: TOTPService + RecoveryCodeMethod + replay guard
secretprotector.go   MfaSecretProtector (AES-256-GCM)
externallogin.go     ExternalLoginStore + Service resolve/link/unlink (security.user_logins)
idpconfig.go         IdpConfigStore + IdpConfigService (partial-update upsert, fail-soft decrypt)
idpsecret.go         IdpSecretProtector (AES-256-GCM, per-purpose AAD)
passkey.go           PasskeyStore port
webauthn.go          WebAuthnService (real FIDO2 ceremonies) + ChallengeStore
password_policy.go   PasswordPolicy + ValidatePassword ; hibp.go — HIBP client
lockout.go           progressive brute-force lockout + admin unlock
audit.go             hash-chained tamper-evident audit log
stamp.go             security-stamp global logout + session cap
stubs.go             roadmap interfaces (OIDC token exchange / trusted-device / IP-whitelist)
postgres/            pgx-backed implementation of every store interface (store/identity/passkey/…)
migrate/             embedded goose SQL (//go:embed) + Up/Down/Status
cmd/ulinzi-migrate   optional migration CLI (embedded by default, -dir to override)

Contributing

See CONTRIBUTING.md — including the workflow for porting a new capability.

Documentation

Overview

Package ulinzi is a Go port of the .NET 8 security library (identity, opaque sessions, MFA, tamper-evident audit, password/lockout policy, RBAC permissions), all isolated in a dedicated PostgreSQL "security" schema.

The importable packages are a pure abstraction of functions and types and embed no SQL: the "security" schema is owned by the optional migrate subpackage (github.com/Cyrus-0101/ulinzilib-go/migrate), applied via cmd/ulinzi-migrate.

Capabilities:

Entities live in the sibling domain package (github.com/Cyrus-0101/ulinzilib-go/domain) and are re-exported here as aliases for API stability. Remaining roadmap surfaces (OIDC token exchange, trusted devices, IP whitelisting) are stable interfaces in stubs.go returning ErrNotImplemented.

Index

Constants

View Source
const (
	ProviderLocal   = domain.ProviderLocal
	ProviderZitadel = domain.ProviderZitadel
	ProviderEntra   = domain.ProviderEntra
	ProviderGoogle  = domain.ProviderGoogle
)

Identity-provider constants, re-exported from the domain layer.

View Source
const (
	AuditUserCreated            = "UserCreated"
	AuditPasswordChanged        = "PasswordChanged"
	AuditPasswordReset          = "PasswordReset"
	AuditPasswordResetRequested = "PasswordResetRequested"
	AuditEmailConfirmed         = "EmailConfirmed"
	AuditEmailChanged           = "EmailChanged"
	AuditLoginSucceeded         = "PasswordSignIn"
	AuditLoginFailed            = "PasswordSignInFailed"
	AuditLogout                 = "Logout"
	AuditMfaEnrolled            = "MfaEnrolled"
	AuditMfaVerified            = "MfaVerified"
	AuditMfaDisabled            = "MfaDisabled"
	AuditAccountLocked          = "AccountLocked"
	AuditSessionsInvalidated    = "SessionsInvalidated"
)

Audit event types. UserCreated..SessionsInvalidated match the .NET string literals; the MFA/logout/lockout events are additions (the .NET reference does not audit those).

View Source
const (
	AuditStatusSuccess = "success"
	AuditStatusFailure = "failure"
)

AuditStatusSuccess / AuditStatusFailure are the two outcome values.

View Source
const Version = "0.3.0"

Version is the current module version.

Variables

View Source
var (
	// ErrInvalidCredentials is returned for any bad email/password/MFA combo
	// (deliberately uniform — never leak which factor failed).
	ErrInvalidCredentials = errors.New("ulinzi: invalid credentials")
	// ErrMFARequired is returned when a user has MFA enabled but Login was
	// called without a TOTP/recovery code. The caller should prompt for it and
	// retry with LoginInput.TOTPCode set.
	ErrMFARequired = errors.New("ulinzi: mfa code required")
	// ErrEmailTaken is returned by CreateUser when the email already exists.
	ErrEmailTaken = errors.New("ulinzi: email already registered")
	// ErrAuthUnavailable is returned by Login/Logout/CreateUser/ChangePassword
	// when the Service was built without the required store (WithAuthStore).
	ErrAuthUnavailable = errors.New("ulinzi: auth store not configured (use New(store, cfg, WithAuthStore(...)))")
)

Login / provisioning errors.

View Source
var (
	// ErrSessionNotFound is returned when no session matches the opaque cookie.
	ErrSessionNotFound = errors.New("ulinzi: session not found")
	// ErrSessionRevoked is returned when the session has been logged out.
	ErrSessionRevoked = errors.New("ulinzi: session revoked")
	// ErrSessionExpired is returned on absolute or idle timeout.
	ErrSessionExpired = errors.New("ulinzi: session expired")
	// ErrUserInactive is returned when the session's user is disabled.
	ErrUserInactive = errors.New("ulinzi: user inactive")
	// ErrSessionStampChanged is returned when the user's security stamp changed
	// after the session was issued (password/MFA/role change, or forced global
	// logout). Callers should treat it like ErrSessionRevoked and re-authenticate.
	ErrSessionStampChanged = errors.New("ulinzi: session invalidated (security stamp changed)")
	// ErrAccountLocked is returned by Login when the account is locked out by the
	// brute-force lockout policy.
	ErrAccountLocked = errors.New("ulinzi: account locked out")
	// ErrLockoutUnavailable is returned by lockout admin operations when the
	// Service was built without WithLockout.
	ErrLockoutUnavailable = errors.New("ulinzi: lockout not configured (use WithLockout)")
	// ErrNoPrincipal is returned when a context carries no validated principal.
	ErrNoPrincipal = errors.New("ulinzi: no principal in context")
	// ErrNotImplemented marks wave-2 surface that is not ported yet.
	ErrNotImplemented = errors.New("ulinzi: not implemented (wave-2)")
)
View Source
var (
	// ErrExternalLoginUnavailable is returned by the external-login operations
	// when the Service was built without WithExternalLogins.
	ErrExternalLoginUnavailable = errors.New("ulinzi: external-login store not configured (use WithExternalLogins)")
	// ErrExternalLoginNotFound is returned by ResolveExternalLogin when no local
	// user is linked to the given (provider, providerKey).
	ErrExternalLoginNotFound = errors.New("ulinzi: external login not found")
)

External-login errors.

View Source
var (
	// ErrWebAuthnChallengeMissing is returned when a Finish* call runs without a
	// matching Begin* (no cached challenge, or it expired). Fail closed.
	ErrWebAuthnChallengeMissing = errors.New("ulinzi: webauthn challenge missing or expired")
	// ErrWebAuthnCredentialExists is returned when registration produces a
	// credential id that is already registered.
	ErrWebAuthnCredentialExists = errors.New("ulinzi: passkey credential already registered")
)

WebAuthn ceremony errors.

View Source
var ErrIdpProviderRequired = errors.New("ulinzi: idp provider is required")

ErrIdpProviderRequired is returned when an IdP config operation is called with a blank provider key.

View Source
var ErrMfaNotEnrolled = errors.New("ulinzi: mfa not enrolled")

ErrMfaNotEnrolled is returned when an MFA operation is attempted for a user who has no enrolment row.

View Source
var ErrPasskeyNotFound = errors.New("ulinzi: passkey not found")

ErrPasskeyNotFound is returned when a passkey lookup finds no matching credential.

View Source
var ErrSecretProtection = errors.New("ulinzi: mfa secret could not be decrypted")

ErrSecretProtection is returned when a stored secret cannot be decrypted and is not recognizable legacy plaintext (fail closed).

Functions

func ContextWithPrincipal

func ContextWithPrincipal(ctx context.Context, p *Principal) context.Context

ContextWithPrincipal returns a copy of ctx carrying the principal.

func ValidatePassword

func ValidatePassword(ctx context.Context, password string, policy PasswordPolicy, hibp HibpService) []string

ValidatePassword returns all policy violations for password; an empty slice means valid. An inactive policy always passes. The breach-database (HIBP) check runs LAST and only when there are no structural violations. Character counting is by rune (Unicode-aware); "special" is any non-letter, non-digit.

Types

type AESGCMIdpSecretProtector added in v0.4.0

type AESGCMIdpSecretProtector struct {
	// contains filtered or unexported fields
}

AESGCMIdpSecretProtector implements IdpSecretProtector with AES-256-GCM. The stored form is "v1:" + base64std(nonce || ciphertext || tag) — identical to AESGCMSecretProtector, but with a per-purpose GCM AAD.

func NewAESGCMIdpSecretProtector added in v0.4.0

func NewAESGCMIdpSecretProtector(key []byte) (*AESGCMIdpSecretProtector, error)

NewAESGCMIdpSecretProtector builds a protector from a 16/24/32-byte key (32 = AES-256, recommended). Source it from a KMS/secret manager, not source code. Prefer a key independent of the MFA protector's key.

func (*AESGCMIdpSecretProtector) ProtectClientSecret added in v0.4.0

func (p *AESGCMIdpSecretProtector) ProtectClientSecret(provider, plaintext string) (string, error)

func (*AESGCMIdpSecretProtector) ProtectServiceUserToken added in v0.4.0

func (p *AESGCMIdpSecretProtector) ProtectServiceUserToken(provider, plaintext string) (string, error)

func (*AESGCMIdpSecretProtector) ProtectZitadelClientID added in v0.4.0

func (p *AESGCMIdpSecretProtector) ProtectZitadelClientID(plaintext string) (string, error)

func (*AESGCMIdpSecretProtector) TryUnprotectClientSecret added in v0.4.0

func (p *AESGCMIdpSecretProtector) TryUnprotectClientSecret(provider, ciphertext string) (string, bool)

func (*AESGCMIdpSecretProtector) TryUnprotectServiceUserToken added in v0.4.0

func (p *AESGCMIdpSecretProtector) TryUnprotectServiceUserToken(provider, ciphertext string) (string, bool)

func (*AESGCMIdpSecretProtector) TryUnprotectZitadelClientID added in v0.4.0

func (p *AESGCMIdpSecretProtector) TryUnprotectZitadelClientID(ciphertext string) (string, bool)

type AESGCMSecretProtector

type AESGCMSecretProtector struct {
	// contains filtered or unexported fields
}

AESGCMSecretProtector implements MfaSecretProtector with AES-256-GCM. The stored form is "v1:" + base64std(nonce || ciphertext || tag).

func NewAESGCMSecretProtector

func NewAESGCMSecretProtector(key []byte) (*AESGCMSecretProtector, error)

NewAESGCMSecretProtector builds a protector from a 16/24/32-byte key (32 = AES-256, recommended). Source the key from a KMS/secret manager, not source.

func (*AESGCMSecretProtector) Protect

func (p *AESGCMSecretProtector) Protect(secret string) (string, error)

func (*AESGCMSecretProtector) Unprotect

func (p *AESGCMSecretProtector) Unprotect(ciphertext string) (string, error)

func (*AESGCMSecretProtector) UnprotectOrLegacy

func (p *AESGCMSecretProtector) UnprotectOrLegacy(value string) (string, bool, error)

type ActiveLockout

type ActiveLockout = domain.ActiveLockout

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type AppUserMfa added in v0.4.0

type AppUserMfa = domain.AppUserMfa

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type AppUserPasskey added in v0.4.0

type AppUserPasskey = domain.AppUserPasskey

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type AuditEvent

type AuditEvent = domain.AuditEvent

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type AuditService

type AuditService struct {
	// contains filtered or unexported fields
}

AuditService writes a tamper-evident, hash-chained security audit log. Each row's hash covers its canonical fields plus the previous row's hash, so any edit or deletion of a historical row breaks the chain and is detectable by VerifyChain. (This is stronger than the .NET reference, which is a plain append log — ulinzilib-go implements the "tamper-evident" property for real.)

func NewAuditService

func NewAuditService(store AuditStore) *AuditService

NewAuditService builds an AuditService over the given store.

func (*AuditService) Record

func (s *AuditService) Record(ctx context.Context, e AuditEvent) error

Record appends one event. EventType is required; Status defaults to "success"; OccurredAt defaults to now. EventType/Status are truncated to their column widths.

func (*AuditService) VerifyChain

func (s *AuditService) VerifyChain(ctx context.Context) (ok bool, brokenSeq int64, err error)

VerifyChain recomputes the whole chain and reports the first tampered row (by seq), if any. ok=true means the chain is intact.

type AuditStore

type AuditStore interface {
	// AppendChained serializes appends (an advisory lock in the implementation),
	// reads the previous row's hash, calls hashFn(prevHash) to compute this row's
	// hash, and inserts the row with prev_hash + hash atomically.
	AppendChained(ctx context.Context, e AuditEvent, hashFn func(prevHash string) string) error
	// VerifyAuditChain walks the log in seq order and recomputes each row's hash
	// with hashFn(prevHash, event); it returns ok=false and the seq of the first
	// row whose stored hash or prev-linkage does not match.
	VerifyAuditChain(ctx context.Context, hashFn func(prevHash string, e AuditEvent) string) (ok bool, brokenSeq int64, err error)
}

AuditStore persists chained audit rows.

type AuthStore

type AuthStore interface {
	GetUserAuthByEmail(ctx context.Context, normalizedEmail string) (*UserAuth, error) // nil, nil if none
	CreateUser(ctx context.Context, id uuid.UUID, email, normalizedEmail, passwordHash, securityStamp string) error
	CreateSession(ctx context.Context, rec *SessionRecord, loginCompletedAt time.Time) error
	RevokeSession(ctx context.Context, sessionID, reason string, at time.Time) error
}

AuthStore is the write-side data dependency for login/session issuance and user provisioning. The PostgreSQL Store in ./postgres satisfies it alongside Store, MfaStore, and the optional PasswordStore / SessionCapStore.

type ChallengeStore added in v0.4.0

type ChallengeStore interface {
	// Save stores data for (userID, purpose) with a TTL, replacing any prior value.
	Save(ctx context.Context, userID uuid.UUID, purpose string, data []byte, ttl time.Duration) error
	// Take atomically retrieves and DELETES the stored data for (userID, purpose).
	// ok=false means nothing was stored (or it expired) — the challenge is single-use.
	Take(ctx context.Context, userID uuid.UUID, purpose string) (data []byte, ok bool, err error)
}

ChallengeStore persists the per-user WebAuthn ceremony challenge (SessionData) between the Begin and Finish steps. A Finish with no stored challenge MUST fail closed. The in-process InMemoryChallengeStore is fine for a single replica; multi-replica deployments should back this with a shared store (e.g. Redis).

type Config

type Config struct {
	// CookieName is the opaque session cookie the SessionEnforcement middleware
	// reads. Defaults to "ulinzi.session".
	CookieName string
	// LastActivityThrottle bounds how often a valid session's last_activity_at
	// is written back, mirroring UlinziLib's ~30s throttle to avoid write storms.
	LastActivityThrottle time.Duration
	// DefaultSessionPolicy is used when no row exists in security.session_policies.
	DefaultSessionPolicy SessionPolicy
	// PasswordHashCost is the bcrypt cost used by CreateUser; 0 uses bcrypt.DefaultCost.
	PasswordHashCost int
}

Config tunes Service behaviour. The zero value is unusable; prefer DefaultConfig and override fields as needed. New also fills sensible defaults.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns production-sensible defaults.

type DefaultHibpService

type DefaultHibpService struct {
	// contains filtered or unexported fields
}

DefaultHibpService queries Have I Been Pwned's k-anonymity range API: only the first 5 hex chars of the password's SHA-1 are ever sent. It fails open.

func NewHibpService

func NewHibpService() *DefaultHibpService

NewHibpService returns a HIBP client with a 5s timeout.

func (*DefaultHibpService) IsCompromised

func (h *DefaultHibpService) IsCompromised(ctx context.Context, password string) (bool, error)

type EnrollResult

type EnrollResult struct {
	Secret          string
	ProvisioningURI string // otpauth:// URL for QR codes
	RecoveryCodes   []string
}

EnrollResult is returned once, at enrolment. Secret and RecoveryCodes are shown to the user now and never recoverable again (only protected/hashed forms persist).

type ExternalLoginStore added in v0.4.0

type ExternalLoginStore interface {
	// GetUserIDByExternalLogin resolves the local user id for an external subject.
	// It returns ok=false (and uuid.Nil) when no link exists.
	GetUserIDByExternalLogin(ctx context.Context, provider IdentityProvider, providerKey string) (userID uuid.UUID, ok bool, err error)
	// LinkExternalLogin creates or updates the link for (LoginProvider, ProviderKey),
	// pointing it at UserID. It is an idempotent upsert on the composite key.
	LinkExternalLogin(ctx context.Context, login UserLogin) error
	// UnlinkExternalLogin removes the (provider, providerKey) link, reporting
	// whether a row was removed.
	UnlinkExternalLogin(ctx context.Context, provider IdentityProvider, providerKey string) (removed bool, err error)
	// ListExternalLogins returns all external links for a user.
	ListExternalLogins(ctx context.Context, userID uuid.UUID) ([]UserLogin, error)
}

ExternalLoginStore persists external identity-provider links (security.user_logins) — the replacement for the removed users.zitadel_user_id / entra_user_id / google_user_id columns. It maps an external provider subject to a local user (one row per provider+key).

type HibpService

type HibpService interface {
	IsCompromised(ctx context.Context, password string) (bool, error)
}

HibpService reports whether a password appears in a public breach corpus. Implementations MUST fail OPEN: any error returns (false, nil) so a breach lookup outage never blocks a password change. It is defense-in-depth only and is consulted by ValidatePassword solely when the policy enables it.

type IPWhitelistService

type IPWhitelistService interface {
	IsAllowed(ctx context.Context, ip string) (bool, error)
}

IPWhitelistService gates access by client IP / CIDR.

type IdentityProvider

type IdentityProvider = domain.IdentityProvider

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type IdpConfigInfo added in v0.4.0

type IdpConfigInfo = domain.IdpConfigInfo

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type IdpConfigService added in v0.4.0

type IdpConfigService struct {
	// contains filtered or unexported fields
}

IdpConfigService reads and writes identity-provider configuration, owning the IdpSecretProtector so callers never handle ciphertext. It mirrors the .NET IdpConfigurationRepository: reads return a ciphertext-free projection (IdpConfigInfo), the write path is a partial-update upsert, and the decrypt helpers fail soft. Provider keys are normalized to lowercase.

func NewIdpConfigService added in v0.4.0

func NewIdpConfigService(store IdpConfigStore, protector IdpSecretProtector) *IdpConfigService

NewIdpConfigService builds the service. A nil protector uses NoopIdpSecretProtector (dev/tests only — secrets are then stored in plaintext).

func (*IdpConfigService) DecryptedClientSecret added in v0.4.0

func (s *IdpConfigService) DecryptedClientSecret(ctx context.Context, provider string) (secret string, ok bool, err error)

DecryptedClientSecret returns the provider's decrypted client secret. ok=false means it is unset or undecryptable (fail soft).

func (*IdpConfigService) DecryptedServiceUserToken added in v0.4.0

func (s *IdpConfigService) DecryptedServiceUserToken(ctx context.Context, provider string) (token string, ok bool, err error)

DecryptedServiceUserToken returns the provider's decrypted service-user token.

func (*IdpConfigService) DecryptedZitadelClientID added in v0.4.0

func (s *IdpConfigService) DecryptedZitadelClientID(ctx context.Context) (clientID string, ok bool, err error)

DecryptedZitadelClientID returns the decrypted Zitadel client id (Zitadel is the only provider whose client_id column is stored encrypted).

func (*IdpConfigService) Get added in v0.4.0

func (s *IdpConfigService) Get(ctx context.Context, provider string) (*IdpConfigInfo, error)

Get returns the ciphertext-free view of a provider's config, or nil if none.

func (*IdpConfigService) List added in v0.4.0

List returns the ciphertext-free views of every configured provider.

func (*IdpConfigService) RawClientID added in v0.4.0

func (s *IdpConfigService) RawClientID(ctx context.Context, provider string) (*string, error)

RawClientID returns the client_id column verbatim (still ciphertext for zitadel), for callers that decrypt on their own schedule. It returns nil when the value is unset or no row exists.

func (*IdpConfigService) Upsert added in v0.4.0

func (s *IdpConfigService) Upsert(ctx context.Context, provider string, in UpsertIdpConfigInput, userID *uuid.UUID) (*IdpConfigInfo, error)

Upsert applies a partial update to a provider's config, encrypting any supplied secrets, and returns the resulting ciphertext-free view.

type IdpConfigStore added in v0.4.0

type IdpConfigStore interface {
	// GetIdpConfigByProvider returns the config for a provider, or nil if none.
	GetIdpConfigByProvider(ctx context.Context, provider string) (*IdpConfiguration, error)
	// ListIdpConfigs returns all configs ordered by provider.
	ListIdpConfigs(ctx context.Context) ([]IdpConfiguration, error)
	// UpsertIdpConfig inserts or replaces the row for c.Provider (a full write of
	// the already-merged, already-encrypted entity).
	UpsertIdpConfig(ctx context.Context, c *IdpConfiguration) error
}

IdpConfigStore persists identity-provider configuration (security.idp_configurations), one row per provider. The store round-trips the full IdpConfiguration (ciphertext columns included); encryption and the partial-update merge are applied above it by IdpConfigService.

type IdpConfiguration added in v0.4.0

type IdpConfiguration = domain.IdpConfiguration

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type IdpSecretProtector added in v0.4.0

type IdpSecretProtector interface {
	ProtectClientSecret(provider, plaintext string) (string, error)
	TryUnprotectClientSecret(provider, ciphertext string) (plaintext string, ok bool)
	ProtectServiceUserToken(provider, plaintext string) (string, error)
	TryUnprotectServiceUserToken(provider, ciphertext string) (plaintext string, ok bool)
	ProtectZitadelClientID(plaintext string) (string, error)
	TryUnprotectZitadelClientID(ciphertext string) (plaintext string, ok bool)
}

IdpSecretProtector encrypts the sensitive columns of an IdpConfiguration (the client secret, the service-user token, and the Zitadel client id) at rest. Each value is bound to a DISTINCT purpose (GCM AAD) so ciphertext cannot be moved between fields or providers. The Try* readers FAIL SOFT: on any decrypt/format error (including a rotated key) they return ("", false) rather than an error, so "is this configured?" callers degrade gracefully — mirroring the .NET IIdpSecretProtector.TryUnprotect* contract. There is deliberately no legacy-plaintext fallback (unlike MFA secrets): IdP secrets fail closed.

type InMemoryChallengeStore added in v0.4.0

type InMemoryChallengeStore struct {
	// contains filtered or unexported fields
}

InMemoryChallengeStore is a process-local ChallengeStore with per-entry TTL. It is safe for a single replica; use a shared backend for multi-replica setups.

func NewInMemoryChallengeStore added in v0.4.0

func NewInMemoryChallengeStore() *InMemoryChallengeStore

NewInMemoryChallengeStore returns an empty in-process challenge store.

func (*InMemoryChallengeStore) Save added in v0.4.0

func (c *InMemoryChallengeStore) Save(_ context.Context, userID uuid.UUID, purpose string, data []byte, ttl time.Duration) error

Save implements ChallengeStore.

func (*InMemoryChallengeStore) Take added in v0.4.0

func (c *InMemoryChallengeStore) Take(_ context.Context, userID uuid.UUID, purpose string) ([]byte, bool, error)

Take implements ChallengeStore: it removes the entry (single-use) and reports ok=false when it is absent or expired.

type LockoutPolicy

type LockoutPolicy = domain.LockoutPolicy

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

func DefaultLockoutPolicy

func DefaultLockoutPolicy() LockoutPolicy

DefaultLockoutPolicy mirrors the .NET defaults: 5 failures / 15m window, progressive x2 capped at 24h, never permanent.

type LockoutService

type LockoutService struct {
	// contains filtered or unexported fields
}

LockoutService applies progressive brute-force lockout keyed by (normalized) email. Call CheckLockout BEFORE verifying the password; call RecordFailure / RecordSuccess AFTER.

func NewLockoutService

func NewLockoutService(store LockoutStore) *LockoutService

NewLockoutService builds a LockoutService over the given store.

func (*LockoutService) CheckLockout

func (s *LockoutService) CheckLockout(ctx context.Context, email string) (locked bool, info *ActiveLockout, err error)

CheckLockout reports whether the email is currently locked out.

func (*LockoutService) RecordFailure

func (s *LockoutService) RecordFailure(ctx context.Context, email string, userID *string, reason, ip, ua string) (bool, error)

RecordFailure appends a failed attempt and, if the threshold is reached within the observation window, opens a (possibly escalated/permanent) lockout. It returns whether the account is now locked.

func (*LockoutService) RecordSuccess

func (s *LockoutService) RecordSuccess(ctx context.Context, email string, userID *string, ip, ua string) error

RecordSuccess appends a successful attempt. It does not close existing lockouts (escalation persists until admin unlock); the sliding window ages out naturally.

func (*LockoutService) Unlock

func (s *LockoutService) Unlock(ctx context.Context, email, reason string, byUserID *string) error

Unlock clears all open lockouts for the email and resets progressive escalation. This is the admin path to release an account without waiting for the timeout (and the only way to release a permanent lockout).

type LockoutStore

type LockoutStore interface {
	GetLockoutPolicy(ctx context.Context) (*LockoutPolicy, error) // nil if none
	RecordLoginAttempt(ctx context.Context, a LoginAttempt) error
	CountRecentFailures(ctx context.Context, email string, since time.Time) (int, error)
	// GetActiveLockout returns the most recent lockout row not yet admin-unlocked
	// (even if expired), or nil.
	GetActiveLockout(ctx context.Context, email string) (*ActiveLockout, error)
	InsertLockout(ctx context.Context, email string, userID *string, lockoutCount, failedCount int, lockedAt time.Time, expiresAt *time.Time) error
	// UnlockAccount closes all open lockouts for the email (admin action). This is
	// the only path that resets progressive escalation.
	UnlockAccount(ctx context.Context, email, reason string, byUserID *string, at time.Time) error
}

LockoutStore is the data dependency for brute-force lockout, keyed by email.

type LoginAttempt

type LoginAttempt = domain.LoginAttempt

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type LoginInput

type LoginInput struct {
	Email     string
	Password  string
	TOTPCode  string // required only when the user has MFA enabled
	IPAddress string
	UserAgent string
}

LoginInput carries credentials plus optional MFA + request metadata.

type LoginResult

type LoginResult struct {
	SessionID string     // opaque token — set this as the session cookie value
	Principal *Principal // the authenticated principal (roles + permissions)
}

LoginResult is the outcome of a successful Login.

type Mfa deprecated

type Mfa = domain.AppUserMfa

Mfa is the former name of AppUserMfa.

Deprecated: use AppUserMfa (the entity is security.app_user_mfa).

type MfaMethod

type MfaMethod interface {
	// Name identifies the method ("totp", "recovery", …).
	Name() string
	// IsEnrolled reports whether the user has this factor set up.
	IsEnrolled(ctx context.Context, userID uuid.UUID) (bool, error)
	// Verify checks a submitted code/assertion, consuming it if single-use.
	Verify(ctx context.Context, userID uuid.UUID, code string) (bool, error)
}

MfaMethod is one pluggable second factor. The Service holds an ordered set of methods (registered with WithMfaMethods); at login it treats a user as MFA-protected if ANY method reports IsEnrolled, and accepts the login if ANY enrolled method verifies the supplied code. New factors (WebAuthn, SMS, …) implement this interface without touching the login flow.

type MfaSecretProtector

type MfaSecretProtector interface {
	// Protect encrypts a Base32 TOTP secret for storage.
	Protect(base32Secret string) (string, error)
	// Unprotect decrypts stored ciphertext back to the Base32 secret. It errors
	// on any value that is not ciphertext produced by Protect.
	Unprotect(ciphertext string) (string, error)
	// UnprotectOrLegacy decrypts ciphertext; on failure it returns the input as
	// legacy plaintext ONLY if it matches the Base32 shape (^[A-Z2-7]+=*$),
	// otherwise it fails closed. wasEncrypted reports whether the value was real
	// ciphertext, so callers can re-encrypt legacy plaintext on next verify.
	// Empty input returns ("", false, nil).
	UnprotectOrLegacy(value string) (secret string, wasEncrypted bool, err error)
}

MfaSecretProtector encrypts TOTP shared secrets (security.app_user_mfa. authenticator_key) at rest. The secret is the entire second factor, so it must never be persisted in plaintext by new code. This mirrors the .NET IMfaSecretProtector (which wraps ASP.NET DataProtection); ulinzilib-go uses AES-256-GCM with a key supplied by the host.

type MfaStore

type MfaStore interface {
	GetMfa(ctx context.Context, userID uuid.UUID) (*AppUserMfa, error) // nil, nil if none
	UpsertMfa(ctx context.Context, m *AppUserMfa) error
}

MfaStore persists MFA enrolment state (security.app_user_mfa). It reads and writes the AppUserMfa aggregate (defined in the domain package).

type MfaUsageStore

type MfaUsageStore interface {
	// TryClaimMfaCode atomically records a one-time use of codeHash for userID
	// with the given TTL. It returns true on first use, false if the code was
	// already claimed within its window (a replay). On any uncertainty it must
	// fail closed (return false).
	TryClaimMfaCode(ctx context.Context, userID uuid.UUID, codeHash string, ttl time.Duration) (bool, error)
}

MfaUsageStore is the optional TOTP replay ledger (security.mfa_code_usages).

type NewUser

type NewUser struct {
	Email    string
	Password string
}

NewUser describes a local user to provision.

type NoopHibpService

type NoopHibpService struct{}

NoopHibpService always reports not-compromised; the default when breach checking is disabled.

func (NoopHibpService) IsCompromised

func (NoopHibpService) IsCompromised(context.Context, string) (bool, error)

type NoopIdpSecretProtector added in v0.4.0

type NoopIdpSecretProtector struct{}

NoopIdpSecretProtector stores secrets verbatim (no encryption). It is the default when no protector is configured and is intended for tests/dev ONLY — it leaves IdP secrets readable in the database. Configure an AESGCMIdpSecretProtector in production.

func (NoopIdpSecretProtector) ProtectClientSecret added in v0.4.0

func (NoopIdpSecretProtector) ProtectClientSecret(_, plaintext string) (string, error)

func (NoopIdpSecretProtector) ProtectServiceUserToken added in v0.4.0

func (NoopIdpSecretProtector) ProtectServiceUserToken(_, plaintext string) (string, error)

func (NoopIdpSecretProtector) ProtectZitadelClientID added in v0.4.0

func (NoopIdpSecretProtector) ProtectZitadelClientID(plaintext string) (string, error)

func (NoopIdpSecretProtector) TryUnprotectClientSecret added in v0.4.0

func (NoopIdpSecretProtector) TryUnprotectClientSecret(_, ciphertext string) (string, bool)

func (NoopIdpSecretProtector) TryUnprotectServiceUserToken added in v0.4.0

func (NoopIdpSecretProtector) TryUnprotectServiceUserToken(_, ciphertext string) (string, bool)

func (NoopIdpSecretProtector) TryUnprotectZitadelClientID added in v0.4.0

func (NoopIdpSecretProtector) TryUnprotectZitadelClientID(ciphertext string) (string, bool)

type NoopSecretProtector

type NoopSecretProtector struct{}

NoopSecretProtector stores secrets verbatim (no encryption). It is the default when no protector is configured and is intended for tests/dev ONLY — it leaves TOTP seeds readable in the database. Configure an AESGCMSecretProtector in prod.

func (NoopSecretProtector) Protect

func (NoopSecretProtector) Protect(secret string) (string, error)

func (NoopSecretProtector) Unprotect

func (NoopSecretProtector) Unprotect(ciphertext string) (string, error)

func (NoopSecretProtector) UnprotectOrLegacy

func (NoopSecretProtector) UnprotectOrLegacy(value string) (string, bool, error)

type OIDCService

type OIDCService interface {
	Exchange(ctx context.Context, provider IdentityProvider, code, redirectURI string) (userID string, err error)
}

OIDCService exchanges an external identity-provider assertion for a local user (multi-provider SSO — Zitadel / Entra / Google). Local password login is implemented today; federated login is on the roadmap.

type Option

type Option func(*Service)

Option configures optional Service capabilities.

func WithAudit

func WithAudit(store AuditStore) Option

WithAudit enables the tamper-evident (hash-chained) audit trail.

func WithAuthStore

func WithAuthStore(a AuthStore) Option

WithAuthStore enables the login/session-issuance and user-provisioning API (Login, Logout, CreateUser, ChangePassword). The PostgreSQL Store satisfies AuthStore.

func WithExternalLogins added in v0.4.0

func WithExternalLogins(store ExternalLoginStore) Option

WithExternalLogins enables external identity-provider linkage: resolving, linking, and unlinking external logins against security.user_logins. The PostgreSQL Store satisfies ExternalLoginStore.

func WithHibp

func WithHibp(h HibpService) Option

WithHibp sets the breached-password checker consulted when the password policy has CheckBreachDatabase enabled.

func WithLockout

func WithLockout(store LockoutStore) Option

WithLockout enables brute-force lockout + login-attempt tracking around Login.

func WithMfaMethods

func WithMfaMethods(methods ...MfaMethod) Option

WithMfaMethods registers pluggable MFA factors (e.g. a TOTPService and a RecoveryCodeMethod). At login a user is treated as MFA-protected if any registered method reports IsEnrolled, and the login succeeds if any enrolled method verifies the submitted code.

func WithPasswordPolicy

func WithPasswordPolicy(store PasswordPolicyStore) Option

WithPasswordPolicy enables password-policy enforcement in CreateUser and ChangePassword, reading the active policy from the store.

func WithSecurityStamps

func WithSecurityStamps(store SecurityStampStore) Option

WithSecurityStamps enables security-stamp rotation, which forces global logout of a user's sessions on password/MFA/role change or explicit invalidation.

type PasskeyStore added in v0.4.0

type PasskeyStore interface {
	// GetPasskeyByCredentialID looks up a passkey by its globally-unique credential
	// id — the sign-in lookup. Returns (nil, nil) if none.
	GetPasskeyByCredentialID(ctx context.Context, credentialID string) (*AppUserPasskey, error)
	// ListPasskeysByUserID returns a user's passkeys, oldest first.
	ListPasskeysByUserID(ctx context.Context, userID uuid.UUID) ([]AppUserPasskey, error)
	// AddPasskey inserts a new passkey. A duplicate credential_id must return an error.
	AddPasskey(ctx context.Context, p *AppUserPasskey) error
	// UpdatePasskeySignCount persists the monotonic signature counter after a
	// successful assertion.
	UpdatePasskeySignCount(ctx context.Context, id uuid.UUID, signCount uint32) error
	// RemovePasskeyByUserAndCredentialID deletes a user's passkey, reporting whether
	// a row was removed.
	RemovePasskeyByUserAndCredentialID(ctx context.Context, userID uuid.UUID, credentialID string) (removed bool, err error)
}

PasskeyStore persists WebAuthn passkey credentials (security.app_user_passkeys). It reads and writes the AppUserPasskey aggregate; a user may own many passkeys. Reads return (nil, nil) / an empty slice when nothing matches.

type PasswordPolicy

type PasswordPolicy = domain.PasswordPolicy

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

func DefaultPasswordPolicy

func DefaultPasswordPolicy() PasswordPolicy

DefaultPasswordPolicy mirrors the .NET application defaults: 12..128 length, all character classes, 4 unique characters, breach check off.

type PasswordPolicyError

type PasswordPolicyError struct{ Violations []string }

PasswordPolicyError aggregates every policy violation (validation does not fail fast, except that the breach check runs only when nothing else failed).

func (*PasswordPolicyError) Error

func (e *PasswordPolicyError) Error() string

type PasswordPolicyStore

type PasswordPolicyStore interface {
	// GetPasswordPolicy returns the active policy, or nil if none is configured
	// (callers fall back to DefaultPasswordPolicy).
	GetPasswordPolicy(ctx context.Context) (*PasswordPolicy, error)
}

PasswordPolicyStore loads the active single-row password policy.

type PasswordStore

type PasswordStore interface {
	GetUserAuthByID(ctx context.Context, id uuid.UUID) (*UserAuth, error)
	UpdatePasswordHash(ctx context.Context, id uuid.UUID, passwordHash string) error
}

PasswordStore is the optional dependency ChangePassword needs (satisfied by the PostgreSQL Store). Without it, ChangePassword returns ErrAuthUnavailable.

type PermissionError

type PermissionError struct{ Key string }

PermissionError indicates the principal lacks a required permission key.

func (*PermissionError) Error

func (e *PermissionError) Error() string

type Principal

type Principal = domain.Principal

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

func PrincipalFromContext

func PrincipalFromContext(ctx context.Context) (*Principal, bool)

PrincipalFromContext extracts the validated principal injected by SessionEnforcement, if any.

type RecoveryCodeMethod

type RecoveryCodeMethod struct {
	// contains filtered or unexported fields
}

RecoveryCodeMethod is the single-use recovery-code factor. It shares the app_user_mfa row minted by TOTPService.EnrollTOTP and implements MfaMethod (Name "recovery"), so recovery codes are a first-class pluggable factor.

func NewRecoveryCodeMethod

func NewRecoveryCodeMethod(store MfaStore) *RecoveryCodeMethod

NewRecoveryCodeMethod builds a recovery-code factor over the MFA store.

func (*RecoveryCodeMethod) IsEnrolled

func (m *RecoveryCodeMethod) IsEnrolled(ctx context.Context, userID uuid.UUID) (bool, error)

IsEnrolled implements MfaMethod: MFA is enabled and unused codes remain.

func (*RecoveryCodeMethod) Name

func (m *RecoveryCodeMethod) Name() string

Name implements MfaMethod.

func (*RecoveryCodeMethod) Verify

func (m *RecoveryCodeMethod) Verify(ctx context.Context, userID uuid.UUID, code string) (bool, error)

Verify implements MfaMethod: consumes a matching recovery code.

type SecurityStampStore

type SecurityStampStore interface {
	// BumpSecurityStamp sets security.users.security_stamp to newStamp.
	BumpSecurityStamp(ctx context.Context, userID uuid.UUID, newStamp string) error
}

SecurityStampStore rotates a user's security stamp. A stamp is snapshotted onto each session at issuance and compared during ValidateSession; rotating it forces every prior session to fail its next validation (global logout).

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is the authentication/authorization facade. Construct it with New, passing a Store (see ./postgres). Enable optional capabilities with the With* options: WithAuthStore (login/issuance), WithMfaMethods (MFA), WithPasswordPolicy, WithLockout, WithAudit, WithSecurityStamps.

func New

func New(store Store, cfg Config, opts ...Option) *Service

New builds a Service, filling any unset Config fields with defaults. Pass the With* options to enable login, MFA, password policy, lockout, and audit.

func (*Service) Audit

func (s *Service) Audit() *AuditService

Audit returns the configured AuditService, or nil if WithAudit was not used.

func (*Service) ChangePassword

func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error

ChangePassword verifies the current password, enforces the password policy on the new one, updates the hash, and rotates the security stamp (global logout of other sessions). Requires WithAuthStore over a store implementing PasswordStore.

func (*Service) CookieName

func (s *Service) CookieName() string

CookieName returns the configured session cookie name.

func (*Service) CreateUser

func (s *Service) CreateUser(ctx context.Context, in NewUser) (uuid.UUID, error)

CreateUser provisions a local user with a bcrypt-hashed password, enforcing the password policy when configured (WithPasswordPolicy).

func (*Service) HasPermission

func (s *Service) HasPermission(ctx context.Context, permissionKey string) bool

HasPermission reports whether the principal in ctx holds the permission key. It returns false when no principal is present. Pair it with SessionEnforcement, which injects the principal.

func (*Service) InvalidateUserSessions

func (s *Service) InvalidateUserSessions(ctx context.Context, userID uuid.UUID) error

InvalidateUserSessions rotates the user's security stamp, forcing global logout of every session issued before this call (each is rejected on its next validation). Requires WithSecurityStamps.

func (*Service) LinkExternalLogin added in v0.4.0

func (s *Service) LinkExternalLogin(ctx context.Context, provider IdentityProvider, providerKey string, displayName *string, userID uuid.UUID) error

LinkExternalLogin links an external provider subject to a local user (idempotent on the composite key). Requires WithExternalLogins.

func (*Service) ListExternalLogins added in v0.4.0

func (s *Service) ListExternalLogins(ctx context.Context, userID uuid.UUID) ([]UserLogin, error)

ListExternalLogins lists a user's external identity links. Requires WithExternalLogins.

func (*Service) Login

func (s *Service) Login(ctx context.Context, in LoginInput) (*LoginResult, error)

Login verifies the account is not locked out, then the password (and MFA, if the user has it enabled), issues an opaque session, and returns the session token + authenticated Principal. It returns ErrAccountLocked, ErrMFARequired, or ErrInvalidCredentials as appropriate. Lockout/audit are applied when the corresponding options are configured.

func (*Service) Logout

func (s *Service) Logout(ctx context.Context, sessionID string) error

Logout revokes a session by its opaque token.

func (*Service) RequireContextPermission

func (s *Service) RequireContextPermission(ctx context.Context, permissionKey string) error

RequireContextPermission returns ErrNoPrincipal if ctx has no principal, or a permission error string if the principal lacks the key. It is a convenience for non-HTTP call sites (jobs, gRPC) that still want the same permission gate.

func (*Service) RequirePermission

func (s *Service) RequirePermission(key string, next http.Handler) http.Handler

RequirePermission wraps a handler, returning 403 unless the request's principal holds the permission key (401 if there is no principal). Use it after SessionEnforcement.

func (*Service) ResolveExternalLogin added in v0.4.0

func (s *Service) ResolveExternalLogin(ctx context.Context, provider IdentityProvider, providerKey string) (*User, error)

ResolveExternalLogin returns the local user linked to an external provider subject. It returns ErrExternalLoginNotFound when the subject is not linked (or the linked user no longer exists) and ErrUserInactive when the linked user is disabled. This is the Go analogue of the .NET IUserRepository.GetByZitadelUserIdAsync, generalized to any provider. Requires WithExternalLogins.

func (*Service) SessionEnforcement

func (s *Service) SessionEnforcement(next http.Handler) http.Handler

SessionEnforcement validates the opaque session cookie on every request and injects the resulting Principal into the request context. On any failure it responds 401. It is the Go analogue of UlinziLib's SessionEnforcementMiddleware and composes as standard net/http middleware (works with chi, stdlib, etc.).

func (*Service) UnlinkExternalLogin added in v0.4.0

func (s *Service) UnlinkExternalLogin(ctx context.Context, provider IdentityProvider, providerKey string) (bool, error)

UnlinkExternalLogin removes an external provider link, reporting whether a row was removed. Requires WithExternalLogins.

func (*Service) UnlockAccount

func (s *Service) UnlockAccount(ctx context.Context, email, reason string, byUserID *string) error

UnlockAccount clears any brute-force lockout on the email and resets the progressive escalation (admin action). It requires WithLockout.

func (*Service) ValidateSession

func (s *Service) ValidateSession(ctx context.Context, sessionID string) (*Principal, error)

ValidateSession resolves an opaque session cookie value into a Principal, mirroring UlinziLib's SessionEnforcementMiddleware:

  1. look up the session row by its opaque id;
  2. reject revoked (logged-out) sessions;
  3. reject on absolute expiry (token_expires_at, or created_at + session timeout);
  4. reject on idle expiry (now - last_activity > idle timeout);
  5. reject if the user is inactive;
  6. resolve roles + permission keys into the Principal;
  7. throttled write-back of last_activity (best effort).

It returns one of ErrSessionNotFound, ErrSessionRevoked, ErrSessionExpired, or ErrUserInactive on failure.

type SessionCapStore

type SessionCapStore interface {
	EnforceSessionCap(ctx context.Context, userID uuid.UUID, max int) error
}

SessionCapStore optionally enforces the per-user session cap (MaxSessions): after a new session is created, sessions beyond the newest max are evicted. AuthStore implementations may also satisfy this; Login uses it opportunistically.

type SessionPolicy

type SessionPolicy = domain.SessionPolicy

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type SessionRecord

type SessionRecord = domain.SessionRecord

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type Store

type Store interface {
	// GetSessionByID looks up a session by its opaque cookie value (session_id).
	GetSessionByID(ctx context.Context, sessionID string) (*SessionRecord, error)
	// TouchSession updates a session's last_activity_at (throttled by the caller).
	TouchSession(ctx context.Context, id uuid.UUID, at time.Time) error
	// GetActiveSessionPolicy returns the most recent session policy, or nil.
	GetActiveSessionPolicy(ctx context.Context) (*SessionPolicy, error)
	// GetUserByID returns the user, or nil if not found.
	GetUserByID(ctx context.Context, id uuid.UUID) (*User, error)
	// GetUserPermissions returns the resolved permission keys for a user
	// (role grants UNION team grants).
	GetUserPermissions(ctx context.Context, userID uuid.UUID) ([]string, error)
	// GetUserRoles returns the user's (non-expired) role names.
	GetUserRoles(ctx context.Context, userID uuid.UUID) ([]string, error)
}

Store is the data dependency for wave-1: opaque-session validation and permission resolution against the "security" schema. The PostgreSQL implementation lives in ./postgres.

Implementations should return ErrSessionNotFound when a session row is absent and (nil, nil) when an optional lookup (policy, user) finds nothing.

type TOTPOption

type TOTPOption func(*TOTPService)

TOTPOption configures optional TOTPService behaviour.

func TOTPWithReplayGuard

func TOTPWithReplayGuard(u MfaUsageStore) TOTPOption

TOTPWithReplayGuard rejects re-use of a valid TOTP code within its window.

func TOTPWithSecretProtector

func TOTPWithSecretProtector(p MfaSecretProtector) TOTPOption

TOTPWithSecretProtector encrypts the TOTP shared secret at rest.

type TOTPService

type TOTPService struct {
	// contains filtered or unexported fields
}

TOTPService is the default authenticator-app factor: TOTP verification plus single-use recovery codes, backed by github.com/pquerna/otp. It implements MfaMethod (Name "totp") and also exposes enrolment/management methods. The TOTP secret is encrypted at rest via an MfaSecretProtector, and successful TOTP verifications are recorded in an optional replay ledger.

func NewTOTPService

func NewTOTPService(store MfaStore, issuer string, opts ...TOTPOption) *TOTPService

NewTOTPService builds a TOTPService. issuer is the label shown in authenticator apps (e.g. "ShughuliYangu"); it defaults to "UlinziLib". Without TOTPWithSecretProtector the secret is stored in plaintext (dev only).

func (*TOTPService) Disable

func (s *TOTPService) Disable(ctx context.Context, userID uuid.UUID) error

Disable turns MFA off and clears the secret + recovery codes.

func (*TOTPService) EnrollTOTP

func (s *TOTPService) EnrollTOTP(ctx context.Context, userID uuid.UUID) (EnrollResult, error)

EnrollTOTP generates a fresh TOTP secret + recovery codes for the user and persists them (disabled until the first successful VerifyTOTP). The returned plaintext secret + codes must be surfaced to the user immediately.

func (*TOTPService) IsEnabled

func (s *TOTPService) IsEnabled(ctx context.Context, userID uuid.UUID) (bool, error)

IsEnabled reports whether the user has completed MFA enrolment.

func (*TOTPService) IsEnrolled

func (s *TOTPService) IsEnrolled(ctx context.Context, userID uuid.UUID) (bool, error)

IsEnrolled implements MfaMethod: the user has a confirmed authenticator.

func (*TOTPService) Name

func (s *TOTPService) Name() string

Name implements MfaMethod.

func (*TOTPService) Verify

func (s *TOTPService) Verify(ctx context.Context, userID uuid.UUID, code string) (bool, error)

Verify implements MfaMethod: validates a TOTP code (with replay guard).

func (*TOTPService) VerifyRecoveryCode

func (s *TOTPService) VerifyRecoveryCode(ctx context.Context, userID uuid.UUID, code string) (bool, error)

VerifyRecoveryCode checks a recovery code and consumes it on success.

func (*TOTPService) VerifyTOTP

func (s *TOTPService) VerifyTOTP(ctx context.Context, userID uuid.UUID, code string) (bool, error)

VerifyTOTP validates a 6-digit code. The first successful verification flips two_factor_enabled to true (completing enrolment). A valid code is claimed in the replay ledger (when configured) so it cannot be reused within its window.

type TrustedDeviceService

type TrustedDeviceService interface {
	Trust(ctx context.Context, userID, deviceToken string) error
	IsTrusted(ctx context.Context, userID, deviceToken string) (bool, error)
}

TrustedDeviceService remembers a device so MFA can be skipped on it later ("remember this device").

type UnimplementedIPWhitelistService

type UnimplementedIPWhitelistService struct{}

UnimplementedIPWhitelistService is a roadmap placeholder.

func (UnimplementedIPWhitelistService) IsAllowed

type UnimplementedOIDCService

type UnimplementedOIDCService struct{}

UnimplementedOIDCService is a roadmap placeholder.

func (UnimplementedOIDCService) Exchange

type UnimplementedTrustedDeviceService

type UnimplementedTrustedDeviceService struct{}

UnimplementedTrustedDeviceService is a roadmap placeholder.

func (UnimplementedTrustedDeviceService) IsTrusted

func (UnimplementedTrustedDeviceService) Trust

type UpsertIdpConfigInput added in v0.4.0

type UpsertIdpConfigInput struct {
	DisplayName      *string
	ClientID         *string // plaintext; encrypted on write for the zitadel provider
	TenantID         *string
	DiscoveryURL     *string
	MetadataJSON     *string
	ClientSecret     *string // plaintext; encrypted on write when non-blank
	ServiceUserToken *string // plaintext; encrypted on write when non-blank
}

UpsertIdpConfigInput carries a partial update to an IdP configuration. A nil pointer leaves the existing value unchanged; a non-nil pointer overwrites. ClientSecret and ServiceUserToken are PLAINTEXT inputs — the service encrypts them on write, and a blank value KEEPS the existing secret (never clears it), mirroring the .NET UpsertAsync semantics.

type User

type User = domain.User

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type UserAuth

type UserAuth = domain.UserAuth

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type UserLogin added in v0.4.0

type UserLogin = domain.UserLogin

The canonical entity and value types live in the domain package (the DDD domain layer). The aliases below re-export them under this package's names so existing callers, the postgres adapter, and the tests keep compiling — the alias and the domain type are the SAME type. domain is the single source of truth; new code may reference either name.

type WebAuthnConfig added in v0.4.0

type WebAuthnConfig struct {
	RPID          string
	RPDisplayName string
	RPOrigins     []string
	// ChallengeTTL bounds how long a Begin* challenge is valid before its Finish*
	// must arrive. Defaults to 5 minutes.
	ChallengeTTL time.Duration
}

WebAuthnConfig is the relying-party configuration for passkeys. RPID is the effective domain (e.g. "example.com"); RPOrigins are the fully-qualified origins permitted to complete ceremonies (e.g. "https://app.example.com"). These should come from the auth-settings source, not be hard-coded.

type WebAuthnService

type WebAuthnService struct {
	// contains filtered or unexported fields
}

WebAuthnService runs the FIDO2/WebAuthn registration and assertion ceremonies over a PasskeyStore and a ChallengeStore, using github.com/go-webauthn. It replaces the former roadmap stub with a real implementation: attestation and assertion are cryptographically verified, credential ids are base64url, the monotonic sign counter is persisted on each assertion, and Finish* fails closed without a matching Begin*.

func NewWebAuthnService added in v0.4.0

func NewWebAuthnService(store PasskeyStore, sessions ChallengeStore, cfg WebAuthnConfig) (*WebAuthnService, error)

NewWebAuthnService builds the service. A nil ChallengeStore uses an in-process InMemoryChallengeStore. It errors if the relying-party config is invalid.

func (*WebAuthnService) BeginLogin

func (s *WebAuthnService) BeginLogin(ctx context.Context, userID uuid.UUID, name, displayName string) ([]byte, error)

BeginLogin starts a passkey assertion ceremony for a known user. It returns the JSON for navigator.credentials.get() and caches the challenge. It returns ErrPasskeyNotFound when the user has no passkeys.

func (*WebAuthnService) BeginRegistration

func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID uuid.UUID, name, displayName string) ([]byte, error)

BeginRegistration starts a passkey enrolment ceremony. It returns the JSON to hand to navigator.credentials.create() and caches the challenge for FinishReg- istration. The user's existing passkeys are excluded so a device cannot enrol twice.

func (*WebAuthnService) FinishLogin

func (s *WebAuthnService) FinishLogin(ctx context.Context, userID uuid.UUID, name, displayName string, response []byte) (*AppUserPasskey, error)

FinishLogin verifies the authenticator's assertion response (the raw JSON body from navigator.credentials.get()), advances the stored sign counter, and returns the matched passkey. The credential must belong to userID.

func (*WebAuthnService) FinishRegistration

func (s *WebAuthnService) FinishRegistration(ctx context.Context, userID uuid.UUID, name, displayName string, response []byte, label string) (*AppUserPasskey, error)

FinishRegistration verifies the authenticator's attestation response (the raw JSON body from navigator.credentials.create()), stores the new passkey, and returns it. label is an optional user-facing name for the credential.

func (*WebAuthnService) ListPasskeys added in v0.4.0

func (s *WebAuthnService) ListPasskeys(ctx context.Context, userID uuid.UUID) ([]AppUserPasskey, error)

ListPasskeys returns a user's registered passkeys, oldest first.

func (*WebAuthnService) RemovePasskey added in v0.4.0

func (s *WebAuthnService) RemovePasskey(ctx context.Context, userID uuid.UUID, credentialID string) (bool, error)

RemovePasskey removes one of a user's passkeys by credential id, reporting whether a row was removed.

Directories

Path Synopsis
Package domain holds ulinzilib-go's pure entity and value types — the DDD domain layer, a faithful port of Adelphi.SecurityLib's Domain/Entities.
Package domain holds ulinzilib-go's pure entity and value types — the DDD domain layer, a faithful port of Adelphi.SecurityLib's Domain/Entities.
Package migrate embeds ulinzilib-go's "security"-schema goose migrations and applies them to a PostgreSQL database.
Package migrate embeds ulinzilib-go's "security"-schema goose migrations and applies them to a PostgreSQL database.
Package postgres provides the PostgreSQL-backed ulinzi.Store implementation (wave-1: opaque-session validation + permission resolution) over the "security" schema, using pgx.
Package postgres provides the PostgreSQL-backed ulinzi.Store implementation (wave-1: opaque-session validation + permission resolution) over the "security" schema, using pgx.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL