ulinzi

package module
v0.3.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: 23 Imported by: 0

README

ulinzilib-go

A Go port of .NET 8 security library — packaged as a reusable Go module for services such as the security library — 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.

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)
goose migrations that own + seed the security schema (embedded)
WebAuthn / passkeys, multi-provider OIDC, 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 + IWebAuthnPasskeyService (typed factors) MfaMethod registry (TOTPService, RecoveryCodeMethod, …)
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

ulinzi.go            Service, Config, options (WithAuthStore/WithMfaMethods/WithPasswordPolicy/…)
domain.go            Principal, SessionRecord, User, SessionPolicy
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)
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 (WebAuthn/OIDC/trusted-device/IP-whitelist)
postgres/            pgx-backed implementation of every store interface
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:

Roadmap surfaces (WebAuthn, OIDC, trusted devices, IP whitelisting) are stable interfaces in stubs.go returning ErrNotImplemented.

Index

Constants

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 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 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 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 struct {
	LockoutCount int
	ExpiresAt    *time.Time // nil => permanent
}

ActiveLockout is the most recent open (not admin-unlocked) lockout for an email.

func (*ActiveLockout) IsPermanent

func (a *ActiveLockout) IsPermanent() bool

IsPermanent reports whether the lockout never expires on its own.

type AuditEvent

type AuditEvent struct {
	EventType  string    // required
	UserID     string    // subject/actor (GUID string)
	SessionID  string    // opaque session id, if applicable
	IPAddress  string    // source IP, if known
	UserAgent  string    // client UA, if known
	Details    string    // free-text detail (e.g. failure sub-reason)
	Status     string    // "success" (default) or "failure"
	OccurredAt time.Time // zero => now (UTC)
}

AuditEvent is a single security event to append to the log.

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 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 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 string

IdentityProvider enumerates the authentication providers UlinziLib supports.

const (
	ProviderLocal   IdentityProvider = "Local"
	ProviderZitadel IdentityProvider = "Zitadel"
	ProviderEntra   IdentityProvider = "Entra"
	ProviderGoogle  IdentityProvider = "Google"
)

type LockoutPolicy

type LockoutPolicy struct {
	IsActive              bool
	MaxFailedAttempts     int           // failures within the window that trigger a lockout
	LockoutDuration       time.Duration // base lockout duration
	ObservationWindow     time.Duration // sliding window for counting failures
	ProgressiveEnabled    bool          // exponential escalation on repeat lockouts
	ProgressiveMultiplier float64       // escalation base (e.g. 2.0)
	MaxLockoutDuration    time.Duration // cap on escalated duration
	PermanentThreshold    *int          // lockout_count >= this => permanent; nil => never permanent
}

LockoutPolicy mirrors the enforced subset of security.lockout_policies.

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 struct {
	Email            string
	UserID           *string
	Success          bool
	FailureReason    string
	IPAddress        string
	UserAgent        string
	AttemptType      string // default "password"
	IdentityProvider string // default "local"
}

LoginAttempt is one row appended to security.login_attempts.

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

type Mfa struct {
	ExternalUserID     uuid.UUID
	TwoFactorEnabled   bool
	AuthenticatorKey   string   // protected (encrypted) TOTP secret; see MfaSecretProtector
	RecoveryCodeHashes []string // salted PBKDF2 hashes of the still-unused recovery codes
	UpdatedAt          time.Time
}

Mfa mirrors 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) (*Mfa, error) // nil, nil if none
	UpsertMfa(ctx context.Context, m *Mfa) error
}

MfaStore persists MFA enrolment state (security.app_user_mfa).

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 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 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 PasswordPolicy

type PasswordPolicy struct {
	IsActive            bool
	MinLength           int
	MaxLength           int
	RequireUppercase    bool
	RequireLowercase    bool
	RequireDigit        bool
	RequireSpecial      bool
	MinUniqueChars      int
	CheckBreachDatabase bool
}

PasswordPolicy mirrors the enforced subset of security.password_policies. The stored-but-unenforced fields in the .NET reference (history, min/max age, allow-username) are intentionally omitted — like the .NET library, they are configuration the validator does not act on.

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 struct {
	UserID    uuid.UUID
	SessionID string
	Email     string
	Roles     []string
	// contains filtered or unexported fields
}

Principal is the validated identity attached to a request context after the SessionEnforcement middleware accepts the opaque session cookie.

func PrincipalFromContext

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

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

func (*Principal) Has

func (p *Principal) Has(permissionKey string) bool

Has reports whether the principal holds the given permission key (e.g. "users.canManage"). This is the Go analogue of the .NET IAuthorizationService.HasPermissionAsync(userId, key).

func (*Principal) Permissions

func (p *Principal) Permissions() []string

Permissions returns the resolved permission keys (unordered copy).

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) 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) 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) 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 struct {
	SessionTimeout time.Duration
	IdleTimeout    time.Duration
	MaxSessions    int
}

SessionPolicy mirrors security.session_policies (durations, not raw minutes).

type SessionRecord

type SessionRecord struct {
	ID             uuid.UUID
	UserID         uuid.UUID
	SessionID      string
	CreatedAt      time.Time
	LastActivityAt time.Time
	TokenExpiresAt *time.Time
	LoggedOutAt    *time.Time
	IPAddress      string // set at issuance; not used by validation
	UserAgent      string // set at issuance; not used by validation
	// SecurityStamp is the user's security stamp captured at issuance. Compared
	// against the user's current stamp during validation to force global logout.
	SecurityStamp string
}

SessionRecord mirrors the validation-relevant columns of security.http_session_records. The opaque session cookie value is SessionID.

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 UnimplementedWebAuthnService

type UnimplementedWebAuthnService struct{}

UnimplementedWebAuthnService is a roadmap placeholder.

func (UnimplementedWebAuthnService) BeginLogin

func (UnimplementedWebAuthnService) BeginRegistration

func (UnimplementedWebAuthnService) BeginRegistration(context.Context, string) ([]byte, error)

func (UnimplementedWebAuthnService) FinishLogin

func (UnimplementedWebAuthnService) FinishRegistration

func (UnimplementedWebAuthnService) FinishRegistration(context.Context, string, []byte) error

type User

type User struct {
	ID       uuid.UUID
	Email    string
	UserName string
	IsActive bool
	// SecurityStamp is the user's current security stamp (security.users.security_stamp).
	// A change invalidates every session issued before the change.
	SecurityStamp string
}

User is the subset of security.users needed to build a Principal.

type UserAuth

type UserAuth struct {
	ID            uuid.UUID
	Email         string
	PasswordHash  string
	IsActive      bool
	SecurityStamp string // snapshotted onto the session for global-logout checks
}

UserAuth is the credential view of a user used during login.

type WebAuthnService

type WebAuthnService interface {
	BeginRegistration(ctx context.Context, userID string) (options []byte, err error)
	FinishRegistration(ctx context.Context, userID string, attestation []byte) error
	BeginLogin(ctx context.Context, userID string) (options []byte, err error)
	FinishLogin(ctx context.Context, userID string, assertion []byte) error
}

WebAuthnService manages passkey (FIDO2) registration and assertion.

Directories

Path Synopsis
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