security

object

%audit

/phlo/resources/security/audit.phlo

Audit log for model mutations (opt-in via static idColumn/objAudit). Schema: resources/security/audit.sql

Off unless a model sets objAudit, and then every create, change and delete is written: an update as the difference between before and after, a create as the new row, a delete as the row that went. Pass exclude for columns you would rather not keep, a password hash or a token; the log outlives the record, so what goes in is a decision, not a detail. purge() is there because a log nobody prunes eventually costs more than the table it watches.

auditlogcompliancetraceability
static

audit :: log ($model, $action, $before = [], $after = [], $exclude = []):void

line 10
$class = is_object($model) ? get_class($model) : (string)$model
$pk = (is_string($class) && class_exists($class) && property_exists($class, 'idColumn')) ? $class::$idColumn : 'id'
$id = is_object($model) ? ($model->$pk ?? $model->id ?? null) : null
if ($id === null) return
$before = (array)$before
$after = (array)$after
foreach ($exclude AS $col) unset($before[$col], $after[$col])
$changes = $action === 'update' ? static::diff($before, $after) : ($action === 'create' ? $after : $before)
$class::DB()->query(
	'INSERT INTO audit_log (ts, '.$class::DB()->quoteId('user').', model, record_id, action, changes, ip) VALUES (?, ?, ?, ?, ?, ?, ?)',
	time(),
	class_exists('session') && isset(%session->user) ? (int)%session->user : null,
	$class,
	(string)$id,
	$action,
	json_encode($changes, jsonFlat),
	(string)($_SERVER['REMOTE_ADDR'] ?? null),
)
static

audit :: diff ($before, $after):array

line 31
Berechnet de verschillen tussen twee datasets en retourneert een array van gewijzigde eigenschappen met hun oude en nieuwe waarden.
$changed = []
foreach ($after AS $col => $newVal){
	$oldVal = $before[$col] ?? null
	if ($oldVal !== $newVal) $changed[$col] = ['from' => $oldVal, 'to' => $newVal]
}
return $changed
static

audit :: history ($model, $recordId, $limit = 50):array

line 40
Haal de auditgeschiedenis op voor een specifiek model en record-ID uit de audit_log-tabel, gesorteerd op tijdstempel in aflopende volgorde, met een limiet op het aantal resultaten.
$class = is_object($model) ? get_class($model) : (string)$model
return $class::DB()->query(
	'SELECT * FROM audit_log WHERE model=? AND record_id=? ORDER BY ts DESC LIMIT ?',
	$class, (string)$recordId, (int)$limit,
)->fetchAll(\PDO::FETCH_OBJ)
static

audit :: byUser ($model, $userId, $fromTs = 0, $limit = 100):array

line 48
Haal auditlogboekvermeldingen op voor een specifieke gebruiker, gefilterd op een tijdstempel en beperkt tot een bepaald aantal resultaten.
return $model::DB()->query(
	'SELECT * FROM audit_log WHERE '.$model::DB()->quoteId('user').'=? AND ts >= ? ORDER BY ts DESC LIMIT ?',
	(int)$userId, (int)$fromTs, (int)$limit,
)->fetchAll(\PDO::FETCH_OBJ)
static

audit :: purge ($model, $olderThanSeconds = 31536000)

line 55
Verwijdert vermeldingen uit de audit_log-tabel die ouder zijn dan een opgegeven aantal seconden.
$model::DB()->query('DELETE FROM audit_log WHERE ts < ?', time() - $olderThanSeconds)
object

%captcha

/phlo/resources/security/captcha.phlo

Self-contained interactive slider-puzzle captcha (no external service). The server picks a secret gap position and renders the background plus a loose piece with GD; the client drags the piece into place. verify() checks the end position plus human drag behaviour (time, path, variation). Single-use and session-bound; the gap position never leaves the server.

Runs entirely on your own server, so no visitor is handed to a third party and nothing has to be disclosed in a privacy statement. The gap position never leaves the server, and it judges the drag as well as the endpoint, so a script that jumps straight to the answer is refused. verify() does not consume the puzzle: call consume() yourself, and only on success, or a failed attempt costs the visitor their challenge. It needs GD.

captchaspambothuman-verificationsecurity
static

captcha :: W

line 11
Genereert een CAPTCHA-afbeelding met een breedte van 300 pixels.
300
static

captcha :: H

line 12
Deze constante vertegenwoordigt de hoogte van de CAPTCHA-afbeelding in pixels.
180
static

captcha :: P

line 13
captcha::$P is een constante die de waarde van de captcha-parameter bevat die wordt gebruikt voor validatie in Phlo-toepassingen.
56
static

captcha :: tol

line 14
captcha::$tol retourneert het tolerantieniveau voor het CAPTCHA-validatieproces, dat bepaalt hoe soepel het systeem is bij het beoordelen van gebruikersreacties.
8
static

captcha :: ttl

line 15
Stelt de time-to-live (TTL) voor de captcha in seconden in.
600
static

captcha :: issue:array

line 17
Genereert een willekeurige kloof voor een captcha-uitdaging en slaat de kloofwaarden op in de sessie met een vervaltijd.
$gapX = random_int(static::$P + 14, static::$W - static::$P - 6)
$gapY = random_int(12, static::$H - static::$P - 12)
%session->captcha = arr(gap: $gapX, exp: time() + static::$ttl)
return arr(gapX: $gapX, gapY: $gapY)
static

captcha :: images ($gapX, $gapY):array

line 24
Genereert een CAPTCHA-afbeelding en een puzzelstuk, en retourneert deze als base64-gecodeerde PNG-gegevens. De achtergrondafbeelding heeft een verloop en willekeurige ellipsen, terwijl het stuk een bijgesneden sectie van de achtergrond is.
$w = static::$W
$h = static::$H
$p = static::$P
$bg = imagecreatetruecolor($w, $h)
$r0 = random_int(50, 170)
$g0 = random_int(50, 170)
$b0 = random_int(50, 170)
for ($x = 0; $x < $w; $x++){
	$col = imagecolorallocate($bg, (int)($r0 + $x * 0.5) % 256, (int)($g0 + $x * 0.2 + 30) % 256, (int)($b0 + ($w - $x) * 0.4) % 256)
	imagefilledrectangle($bg, $x, 0, $x, $h, $col)
}
imagealphablending($bg, true)
for ($i = 0; $i < 7; $i++){
	$col = imagecolorallocatealpha($bg, random_int(0, 255), random_int(0, 255), random_int(0, 255), random_int(55, 95))
	imagefilledellipse($bg, random_int(0, $w), random_int(0, $h), random_int(50, 130), random_int(50, 130), $col)
}
$piece = imagecreatetruecolor($p, $p)
imagecopy($piece, $bg, 0, 0, $gapX, $gapY, $p, $p)
imagerectangle($piece, 0, 0, $p - 1, $p - 1, imagecolorallocate($piece, 245, 245, 245))
imagefilledrectangle($bg, $gapX, $gapY, $gapX + $p - 1, $gapY + $p - 1, imagecolorallocatealpha($bg, 0, 0, 0, 95))
imagerectangle($bg, $gapX, $gapY, $gapX + $p - 1, $gapY + $p - 1, imagecolorallocate($bg, 255, 255, 255))
ob_start()
imagepng($bg)
$bgData = base64_encode(ob_get_clean())
ob_start()
imagepng($piece)
$pieceData = base64_encode(ob_get_clean())
return arr(bg: 'data:image/png;base64,'.$bgData, piece: 'data:image/png;base64,'.$pieceData)
static

captcha :: verify ($x, $telemetry):bool

line 59
Judges the drag as much as where it ended.
$c = %session->captcha ?? null
if (!$c || time() > (int)($c['exp'] ?? 0)) return false
if (abs((int)round((float)$x) - (int)($c['gap'] ?? -999)) > static::$tol) return false
$t = json_decode((string)$telemetry, true)
if (!is_array($t)) return false
$d = (int)($t['d'] ?? 0)
$n = (int)($t['n'] ?? 0)
$xs = is_array($t['x'] ?? null) ? array_values($t['x']) : []
if ($d < 200 || $d > 60000) return false
if ($n < 5 || count($xs) < 4) return false
$deltas = []
for ($i = 1; $i < count($xs); $i++) $deltas[] = (float)$xs[$i] - (float)$xs[$i - 1]
if (array_sum(array_map('abs', $deltas)) < 30) return false
$mean = array_sum($deltas) / count($deltas)
$var = 0
foreach ($deltas AS $dd) $var += ($dd - $mean) ** 2
if (sqrt($var / count($deltas)) < 0.5) return false
return true
static

captcha :: consume:void

line 80
Verwijdert de captcha-gegevens uit de sessie, waardoor deze effectief wordt geconsumeerd.
unset(%session->captcha)
method

%captcha -> widget:string

line 84
Genereert een CAPTCHA-widget door een uitdaging uit te geven en een afbeelding te maken met gespecificeerde parameters.
$ch = static::issue()
$img = static::images($ch['gapX'], $ch['gapY'])
return (string)$this->field($img['bg'], $img['piece'], $ch['gapY'], static::$W, static::$H, static::$P)
view

%captcha -> field ($bg, $piece, $gapY, $w, $h, $p)

line 90
Genereert een CAPTCHA-weergave die een slepen-schuifknop bevat waarmee gebruikers een puzzel kunnen voltooien, wat de beveiliging tegen geautomatiseerde inzendingen verbetert.
<div#captcha data-w="$w" data-h="$h" data-p="$p" data-gapy="$gapY">
	<div.stage>
		<img.bg src="{{ $bg }}" alt="">
		<img.piece src="{{ $piece }}" alt="" draggable="false">
	</div>
	<div.track>
		<div.fill></div>
		<button.thumb type="button" tabindex="-1" aria-label="{{ en('Drag the slider until the piece fits') }}">&#8250;&#8250;</button>
	</div>
	<p.hint>{en: Drag the slider until the piece snaps into place}</p>
	<input type=hidden name=captcha_x>
	<input type=hidden name=captcha_t>
</div>
view

style

line 105
Definieert de CSS-stijlen voor de captcha-component, inclusief lay-out, kleuren en interactieve elementen.
#captcha {
	margin: 14px 0 4px
	max-width: 300px
}
#captcha .stage {
	border-radius: 8px
	line-height: 0
	overflow: hidden
	position: relative
	touch-action: none
	user-select: none
	width: 100%
}
#captcha .bg {
	display: block
	height: auto
	pointer-events: none
	width: 100%
}
#captcha .piece {
	box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25), 0 2px 6px rgba(0, 0, 0, 0.4)
	left: 6px
	pointer-events: none
	position: absolute
	top: 0
}
#captcha .track {
	background: #e7e5e4
	border-radius: 8px
	height: 42px
	margin-top: 10px
	position: relative
	touch-action: none
}
#captcha .fill {
	background: #fed7aa
	border-radius: 8px
	height: 100%
	left: 0
	position: absolute
	top: 0
	width: 0
}
#captcha .thumb {
	align-items: center
	appearance: none
	background: #ea580c
	border: 0
	border-radius: 8px
	box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3)
	color: #fff
	cursor: grab
	display: flex
	font-family: inherit
	font-size: 18px
	font-weight: 700
	height: 42px
	justify-content: center
	left: 0
	margin: 0
	padding: 0
	position: absolute
	top: 0
	touch-action: none
	width: 42px
}
#captcha .thumb:active: cursor: grabbing
#captcha .thumb:hover: background: #c2410c
#captcha .hint {
	color: #78716c
	font-size: 12px
	margin: 6px 0 0
}
view

script

line 181
const captcha = {
	scale: 1, startCss: 6, travelMax: 0, maxThumb: 0,
	dragging: false, t0: 0, samples: [],
	cap: null, bg: null, piece: null, thumb: null, track: null, fill: null, xField: null, tField: null, submit: null,
	grab(){
		this.cap = obj('#captcha')
		if (!this.cap) return false
		this.bg = obj('.bg', this.cap)
		this.piece = obj('.piece', this.cap)
		this.thumb = obj('.thumb', this.cap)
		this.track = obj('.track', this.cap)
		this.fill = obj('.fill', this.cap)
		this.xField = obj('[name=captcha_x]', this.cap)
		this.tField = obj('[name=captcha_t]', this.cap)
		this.submit = obj('#registerSubmit')
		return true
	},
	layout(){
		if (!this.grab()) return
		const Wr = this.bg.clientWidth || +this.cap.dataset.w
		this.scale = Wr / +this.cap.dataset.w
		const pieceCss = +this.cap.dataset.p * this.scale
		this.piece.style.width = pieceCss + 'px'
		this.piece.style.height = pieceCss + 'px'
		this.piece.style.top = (+this.cap.dataset.gapy * this.scale) + 'px'
		this.startCss = 6 * this.scale
		this.piece.style.left = this.startCss + 'px'
		this.travelMax = Wr - pieceCss - this.startCss
		this.maxThumb = this.track.clientWidth - this.thumb.clientWidth
		if (this.submit) this.submit.disabled = true
	},
	set(tx){
		tx = Math.max(0, Math.min(this.maxThumb, tx))
		this.thumb.style.left = tx + 'px'
		this.fill.style.width = (tx + this.thumb.clientWidth) + 'px'
		const frac = this.maxThumb > 0 ? tx / this.maxThumb : 0
		const pl = this.startCss + frac * (this.travelMax - this.startCss)
		this.piece.style.left = pl + 'px'
		return pl / this.scale
	},
	down(e){
		if (!this.grab()) return
		this.dragging = true
		this.t0 = performance.now()
		this.samples = []
		e.preventDefault()
	},
	move(e){
		if (!this.dragging) return
		const px = e.touches ? e.touches[0].clientX : e.clientX
		const rect = this.track.getBoundingClientRect()
		const nx = this.set(px - rect.left - this.thumb.clientWidth / 2)
		this.samples.push({x: nx, t: performance.now() - this.t0})
	},
	up(){
		if (!this.dragging) return
		this.dragging = false
		const xs = this.samples.map(s => Math.round(s.x))
		this.xField.value = xs.length ? xs[xs.length - 1] : 0
		const step = Math.max(1, Math.ceil(xs.length / 24))
		this.tField.value = JSON.stringify({d: Math.round(this.samples.length ? this.samples[this.samples.length - 1].t : 0), n: this.samples.length, x: xs.filter((_, i) => i % step === 0)})
		if (this.submit) this.submit.disabled = false
	}
}

onExist('#captcha', () => captcha.layout())
on('load', '#captcha .bg', () => captcha.layout())
on('resize', window, () => captcha.layout())
on('mousedown touchstart', '#captcha .thumb', (el, e) => captcha.down(e))
on('mousemove touchmove', window, (el, e) => last(captcha.move(e), false))
on('mouseup touchend', window, (el, e) => captcha.up())
object

%creds

/phlo/resources/security/creds.phlo

Credentials resolver from env and ini sources

One place for every secret, filled from data/creds.ini and from the environment, where PHLO__Section__key sets a value and PHLO_<HOST>__Section__key overrides it for one host, with <HOST> the request host uppercased and every other character an underscore. Values are wrapped so a var_dump or an error page shows stars instead of the secret. Keep the ini file out of the repository and let the environment win on a server.

credentialsenvinisecretsconfiguration
method

%creds -> __construct (?array $values = null)

line 10
Initialiseert het creds-object, waarbij waarden worden opgelost als deze niet zijn opgegeven, en wijst elke waarde toe aan de bijbehorende eigenschap, waarbij indien nodig een nieuwe instantie van static of SensitiveParameterValue wordt gemaakt.
$values ??= $this->resolve()
foreach ($values AS $key => $value){
	$this->$key = is_array($value) ? new static($value) : new \SensitiveParameterValue((string)$value)
}
method

%creds -> resolve:array

line 17
Deze functie lost en voegt inloggegevens samen vanuit een INI-bestand en omgevingsvariabelen, en retourneert de gecombineerde gegevensarray.
$data = []
$this->merge($data, $this->loadINI(data.'creds.ini'))
$this->merge($data, $this->envValues(false))
$this->merge($data, $this->envValues(true))
return $data
method

%creds -> loadINI (string $file):array

line 25
Laadt configuratie-instellingen uit een INI-bestand dat is opgegeven door het gegeven bestandspad en retourneert deze als een associatieve array. Als het bestand niet bestaat of niet kan worden geparsed, retourneert het een lege array.
if (!is_file($file)) return []
$ini = parse_ini_file($file, true, INI_SCANNER_RAW)
return is_array($ini) ? $ini : []
method

%creds -> envValues (bool $hostScoped = false):array

line 31
Haalt omgevingsvariabele waarden op die beginnen met een opgegeven voorvoegsel, optioneel beperkt tot de host, en retourneert deze als een associatieve array.
$out = []
$prefix = $hostScoped ? ('PHLO_'.$this->hostKey().'__') : 'PHLO__'
$sources = []
is_array($_ENV ?? null) && $sources[] = $_ENV
is_array($_SERVER ?? null) && $sources[] = $_SERVER
is_array($env = getenv()) && $sources[] = $env
foreach ($sources AS $source){
	foreach ($source AS $key => $value){
		$key = (string)$key
		if (!str_starts_with($key, $prefix)) continue
		$path = substr($key, strlen($prefix))
		if (!$path) continue
		$this->envAssign($out, explode('__', $path), (string)$value)
	}
}
return $out
method

%creds -> hostKey:string

line 50
Deze functie verwerkt de host van de aanvraag, zet deze om naar hoofdletters, verwijdert niet-alfanumerieke tekens en trimt eventuele leidende of volgende scheidingstekens.
$host = strtoupper(%req->host)
$host = preg_replace('/[^A-Z0-9]+/', us, $host)
return trim($host, us)
method

%creds -> envAssign (array &$target, array $parts, string $value):void

line 56
Wijst een waarde toe aan een geneste arraystructuur op basis van de opgegeven delen, waarbij indien nodig tussenliggende arrays worden aangemaakt.
$parts = array_values(array_filter(loop($parts, fn($part) => trim($part)), 'strlen'))
if (!$parts) return
$node = &$target
$last = count($parts) - 1
foreach ($parts AS $i => $part){
	if ($i === $last){
		$node[$part] = $value
		return
	}
	if (!isset($node[$part]) || !is_array($node[$part])) $node[$part] = []
	$node = &$node[$part]
}
method

%creds -> merge (array &$base, array $add):void

line 71
Voegt de inhoud van de $add-array samen met de $base-array, waarbij waarden recursief worden gecombineerd als beide arrays zijn.
foreach ($add AS $key => $value){
	if (isset($base[$key]) && is_array($base[$key]) && is_array($value)){
		$this->merge($base[$key], $value)
		continue
	}
	$base[$key] = $value
}
method

%creds -> objGet ($key)

line 81
Haal de waarde op die aan de opgegeven sleutel is gekoppeld aan het object, en retourneer gevoelige waarden op een veilige manier.
if ($key === 'toArray') return loop($this->objData, fn($value) => is_a($value, 'SensitiveParameterValue') ? $value->getValue() : $value)
if (isset($this->objData[$key]) && is_a($this->objData[$key], '\SensitiveParameterValue')) return $this->objData[$key]->getValue()
method

%creds -> objInfo:array

line 86
Deze functie verwerkt elk item in objData, vervangt instanties van SensitiveParameterValue door sterren die overeenkomen met hun lengte, terwijl andere waarden ongewijzigd blijven.
loop($this->objData, fn($value) => is_a($value, '\SensitiveParameterValue') ? str_repeat('*', strlen($value->getValue())) : $value)
object

%CSRF

/phlo/resources/security/CSRF.phlo

Rotating async CSRF protection for Phlo requests

Put %CSRF in your head view and it writes the meta tag the frontend reads from. The rest you wire yourself: verify() checks the X-CSRF-Token header against the session, and update() answers with a fresh token as a command the page applies to its meta tag. A route that does both makes a stolen token worth one request at most, which is the whole point; a route that only verifies keeps one token for the life of the session. It protects a session, so it says nothing about an API authenticated with a bearer token.

csrfsecurityasyncforms
view

%CSRF -> view

line 12
Genereert een meta-tag met de CSRF-token voor veilige formulierindieningen.
<meta name=csrf content="$this->token">
prop

%CSRF -> token:string

line 13
Genereert een CSRF-token van 32 tekens als er nog geen in de sessie bestaat.
%session->csrf ??= token(32)
method

%CSRF -> verify:bool

line 14
Verifieert het CSRF-token door het te vergelijken met het token dat in de HTTP-header is ontvangen.
hash_equals($this->token, (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? void))
method

%CSRF -> update:array

line 15
Werk de CSRF-token in de sessie bij en wijs deze toe aan de csrf-eigenschap.
arr(csrf: $this->token = %session->csrf = token(32))
view

script

line 17
Stelt de CSRF-tokenwaarde in de meta-tag van het document in.
app.mod.csrf = value => obj('meta[name="csrf"]').content = value
object

%JWT

/phlo/resources/security/JWT.phlo

Sign and verify compact HS256 JSON Web Tokens (RFC 7519), secure by default

HS256 only, and a token that claims another algorithm is refused rather than tried, which is the classic way these are broken. The secret must be at least 32 bytes, an expiry is always written, and verify() throws with a 401 instead of returning false, so a route can simply call it. Name an issuer and it is checked as well. A signed token is readable by anyone holding it, so keep secrets out of the claims.

jwtjwshs256tokenauthsecurity
method

%JWT -> __construct (public string $secret, public string $issuer = void, public int $leeway = 30)

line 11
Maakt een JWT-instantie met een opgegeven geheim, uitgever en spelingstijd. Het zorgt ervoor dat het geheim minimaal 32 bytes lang is en geeft een foutmelding als aan deze voorwaarde niet wordt voldaan.
strlen($this->secret) >= 32 || error('JWT secret must be at least 32 bytes', 500)
method

%JWT -> sign (array $claims, int $ttl = 3600):string

line 13
Genereert een JSON Web Token (JWT) door de opgegeven claims te ondertekenen met een gespecificeerde tijdsduur (TTL). De token bevat tijdstempels voor uitgifte (iat) en vervaldatum (exp), en kan optioneel een uitgever (iss) bevatten.
$now = time()
$claims['iat'] = $now
$claims['exp'] = $now + $ttl
if ($this->issuer !== void) $claims['iss'] = $this->issuer
$body = $this->encode(['alg' => 'HS256', 'typ' => 'JWT']).dot.$this->encode($claims)
return $body.dot.$this->sig($body)
method

%JWT -> verify (string $token):array

line 22
Verifieert een JSON Web Token (JWT) door de structuur, handtekening en claims zoals vervaldatum en uitgever te controleren.
$token = preg_replace('/^Bearer\s+/i', void, trim($token))
$parts = explode(dot, $token)
count($parts) === 3 || error('JWT malformed', 401)
[$h, $p, $s] = $parts
$header = (array)json_decode((string)$this->decode($h), true)
($header['alg'] ?? void) === 'HS256' || error('JWT algorithm not allowed', 401)
hash_equals($this->sig($h.dot.$p), $s) || error('JWT signature invalid', 401)
$claims = (array)json_decode((string)$this->decode($p), true)
$now = time()
isset($claims['nbf']) && $now + $this->leeway < $claims['nbf'] && error('JWT not yet valid', 401)
isset($claims['exp']) && $now - $this->leeway >= $claims['exp'] && error('JWT expired', 401)
$this->issuer === void || ($claims['iss'] ?? void) === $this->issuer || error('JWT issuer mismatch', 401)
return $claims
method

%JWT -> sig (string $body):string

line 38
Genereert een handtekening voor de gegeven body met HMAC met SHA-256 en een geheime sleutel.
$this->encode(hash_hmac('sha256', $body, $this->secret, true))
method

%JWT -> encode ($data):string

line 39
rtrim(strtr(base64_encode(is_string($data) ? $data : (string)json_encode($data, jsonFlat)), '+/', '-_'), eq)
method

%JWT -> decode (string $data):string

line 40
Decodeert een JSON Web Token (JWT) van een base64-gecodeerde string, waarbij URL-veilige tekens worden vervangen door standaard base64-tekens.
(string)base64_decode(strtr($data, '-_', '+/'))
object

%OAuth2

/phlo/resources/security/OAuth2.phlo

Stateless OAuth2 client: build the authorize URL and exchange/refresh tokens. Token storage and config are the caller's responsibility. The protocol primitive under TokenStore and OAuthConnector.

The bare protocol: build an authorize URL, trade a code for tokens, refresh. It keeps nothing and knows nothing about your app, which is what makes it usable for any provider. For a connector you almost never need it directly, since TokenStore and OAuthConnector do the keeping for you; reach for it when you run the login yourself.

oauthoauth2tokenauthorizationrefreshauthentication
static

OAuth2 :: authorizeUrl ($endpoint, array $params):string

line 11
Genereert de autorisatie-URL voor de OAuth2-stroom door queryparameters aan de eindpunt toe te voegen.
$endpoint.(str_contains($endpoint, '?') ? '&' : '?').http_build_query($params)
static

OAuth2 :: token ($tokenUrl, $clientId, $clientSecret, $grantType, array $extra = []):array

line 13
Haal een OAuth2-token op door een verzoek te sturen naar de opgegeven token-URL met de benodigde inloggegevens en parameters, waarbij fouten worden afgehandeld en de reactie als een associatieve array wordt geretourneerd.
$body = ['grant_type' => $grantType, 'client_id' => (string)$clientId, 'client_secret' => (string)$clientSecret]
foreach ($extra AS $key => $value){
	if ($value !== null && $value !== void) $body[$key] = $value
}
try {
	$res = HTTP($tokenUrl, ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'], POST: http_build_query($body))
}
catch (\Throwable $e){
	return ['error' => $e->getMessage()]
}
return json_decode((string)$res, true) ?: ['error' => 'Invalid token response']
static

OAuth2 :: exchangeCode ($tokenUrl, $clientId, $clientSecret, $code, $redirectUri = null, array $extra = []):array

line 27
Ruilt een autorisatiecode in voor een toegangstoken met behulp van het OAuth2-protocol.
static::token($tokenUrl, $clientId, $clientSecret, 'authorization_code', ['code' => $code, 'redirect_uri' => $redirectUri] + $extra)
static

OAuth2 :: refresh ($tokenUrl, $clientId, $clientSecret, $refreshToken, array $extra = []):array

line 29
Deze methode ververst een OAuth2-toegangstoken met behulp van een opgegeven verversingstoken en aanvullende parameters.
static::token($tokenUrl, $clientId, $clientSecret, 'refresh_token', ['refresh_token' => $refreshToken] + $extra)
object

%rate

/phlo/resources/security/rate.phlo

Rate-limit (fixed window) on the rate_limit table. Schema: resources/security/rate.sql

A fixed window, counted in one atomic statement, so two requests arriving together cannot both slip past the limit. Storage db survives a restart and is shared across machines; apcu is faster but lives in one server's shared memory, so it is gone after a restart and says nothing about a second machine. Because the window is fixed rather than sliding, a caller can spend a full limit at the end of one window and again at the start of the next.

ratelimitthrottleabuse
static

rate :: check ($key, $limit, $windowSeconds, $storage = 'db'):bool

line 11
if ($storage === 'apcu') return static::checkApcu($key, $limit, $windowSeconds)
$now = time()
// One atomic upsert: increment within the window or reset to a new one, and carry the
// resulting per-request count out via LAST_INSERT_ID. A separate read-then-write would
// let two requests arriving together both pass the limit.
%MySQL->query('INSERT INTO rate_limit (rkey, count, window_start) VALUES (?, LAST_INSERT_ID(1), ?) ON DUPLICATE KEY UPDATE count = LAST_INSERT_ID(IF(? - window_start < ?, count + 1, 1)), window_start = IF(? - window_start < ?, window_start, ?)', $key, $now, $now, $windowSeconds, $now, $windowSeconds, $now)
return (int)%MySQL->query('SELECT LAST_INSERT_ID()')->fetchColumn() <= $limit
static

rate :: checkApcu ($key, $limit, $windowSeconds):bool

line 21
Controleert of de snelheidslimiet voor een specifieke sleutel is overschreden met behulp van APCu-caching.
$window = (int)floor(time() / $windowSeconds) * $windowSeconds
$apcuKey = 'phlo.rate.'.$key.':'.$window
apcu_add($apcuKey, 0, $windowSeconds)
return apcu_inc($apcuKey, 1) <= $limit
static

rate :: status ($key, $limit, $windowSeconds):obj

line 28
Haal de huidige status van de rate limit op, inclusief het aantal gebruikte verzoeken, de limiet en de tijd tot de limiet opnieuw instelt.
$now = time()
$row = %MySQL->query('SELECT count, window_start FROM rate_limit WHERE rkey=?', $key)->fetchObject('obj') ?: null
if (!$row || ($now - (int)$row->window_start) >= $windowSeconds) return obj(used: 0, limit: $limit, resetIn: 0)
return obj(used: (int)$row->count, limit: $limit, resetIn: max(0, ((int)$row->window_start + $windowSeconds) - $now))
static

rate :: reset ($key)

line 35
Deze methode reset de rate limit door de bijbehorende invoer uit de rate_limit-tabel in de MySQL-database te verwijderen.
%MySQL->query('DELETE FROM rate_limit WHERE rkey=?', $key)
static

rate :: purge ($olderThanSeconds = 604800)

line 36
Verwijdert vermeldingen uit de rate_limit-tabel waar window_start ouder is dan de opgegeven tijd in seconden.
%MySQL->query('DELETE FROM rate_limit WHERE window_start < ?', time() - $olderThanSeconds)
object

%security

/phlo/resources/security/security.phlo

Generic security resource

Pick a profile per app rather than writing headers yourself: strict allows only nonced scripts and styles, basic allows your own files, marketing also allows images from anywhere, and api shuts everything off and marks the response as an API. Async responses get no policy, because the page they land in already has one. Add a CDN or a media host to sources and a domain that may frame you to whitelist; those are the two escape hatches, and everything else stays closed. Under debug, basic and marketing let inline scripts through so the debug console runs, so a production site with debug on is running a weaker policy than it thinks.

securitycspnonceheaders
prop

%security -> whitelist:array

line 11
Definieert een whitelist voor beveiligingsdoeleinden, waardoor alleen gespecificeerde vermeldingen zijn toegestaan.
[]
prop

%security -> sources:array

line 12
[]
method

%security -> setNonce:string

line 14
Stelt een nonce-waarde in voor de applicatie met behulp van een gegenereerde token van de opgegeven lengte.
%app->nonce = token(8)
method

%security -> frameProtect ($mode = 'DENY')

line 16
Stelt de X-Frame-Options-header in om te bepalen of de pagina in een frame kan worden weergegeven, met de standaardmodus 'DENY'.
%res->header('X-Frame-Options', $mode)
method

%security -> frameWhitelist:string

line 17
Genereert een string van goedgekeurde frame-oorsprongen voor beveiligingsdoeleinden, waardoor alleen opgegeven domeinen de inhoud in een iframe kunnen insluiten.
$this->whitelist ? ' '.implode(space, array_map(fn($d) => "https://*.$d", (array)$this->whitelist)) : void
method

%security -> sourceList:string

line 19
Extra origins the app loads content from (images, media, fetch), e.g. a CDN; full origins, applied verbatim.
$this->sources ? ' '.implode(space, (array)$this->sources) : void
method

%security -> strict:void

line 21
$this->base
if (%req->async) return
%res->header('Cache-Control', 'no-store')
$nonce = $this->setNonce
%res->header('Content-Security-Policy', "default-src 'self'; script-src 'nonce-$nonce'; worker-src 'self'; style-src 'self' 'nonce-$nonce'; img-src 'self' data:$this->sourceList; media-src 'self' blob:$this->sourceList; font-src 'self'; connect-src 'self'$this->sourceList; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")
method

%security -> basic:void

line 30
Under debug, basic/marketing relax script-src to 'unsafe-inline' so the inline debug console runs.
$this->base
if (%req->async) return
%res->header('Content-Security-Policy', "default-src 'self'; script-src 'self'".(debug ? " 'unsafe-inline'" : void)."; style-src 'self' 'unsafe-inline'; img-src 'self' data:$this->sourceList; media-src 'self' blob:$this->sourceList; font-src 'self'; connect-src 'self'$this->sourceList; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")
method

%security -> marketing:void

line 35
$this->base
if (%req->async) return
%res->header('Content-Security-Policy', "default-src 'self'; script-src 'self'".(debug ? " 'unsafe-inline'" : void)."; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob:$this->sourceList; font-src 'self'; connect-src 'self'$this->sourceList; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")
method

%security -> api:void

line 40
Stelt beveiligingsgerelateerde HTTP-headers in voor de API-respons, waardoor de bescherming tegen verschillende webkwetsbaarheden wordt verbeterd.
%res->api = true
%res->header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")
%res->header('X-Content-Type-Options', 'nosniff')
%res->header('Referrer-Policy', 'no-referrer')
method

%security -> base:void

line 47
Stelt verschillende beveiligingsgerelateerde HTTP-headers in voor de respons, waaronder Referrer-Policy, X-Content-Type-Options, Cross-Origin-beleid en X-Frame-Options, om de beveiliging tegen cross-origin aanvallen te verbeteren.
%res->header('Referrer-Policy', 'strict-origin-when-cross-origin')
%res->header('X-Content-Type-Options', 'nosniff')
%req->async || %res->header('Cross-Origin-Opener-Policy', 'same-origin')
%req->async || %res->header('Cross-Origin-Resource-Policy', 'same-origin')
%req->async || %res->header('Access-Control-Allow-Origin', %req->base)
%req->async || %res->header('X-Frame-Options', 'DENY')
object

%social

/phlo/resources/security/social.phlo

Reusable social login (OIDC) on top of OAuth2: build the authorize URL and turn a callback code into a verified profile. Google, Microsoft and Apple. No user, session or route handling - that is the caller's responsibility.

The id_token is verified against the provider's JWKS (RS256 only, key looked up by kid) before any claim is read, on top of issuer, audience (must equal client_id), expiry and, when supplied, nonce. `verified` reports what the provider actually proved: Microsoft omits email_verified, so it counts only when the optional xms_edov claim states the tenant owns the address. Treat an unverified email as a claim, never as an identity: match users on provider + sub. Apple's client_secret is an ES256 JWT signed with the .p8 key (team_id/key_id/client_id + key from creds).

oauthoidcsociallogingooglemicrosoftappleauthentication
static

social :: providers:array

line 11
arr(
	google: arr(
		label: 'Google',
		authorize: 'https://accounts.google.com/o/oauth2/v2/auth',
		token: 'https://oauth2.googleapis.com/token',
		jwks: 'https://www.googleapis.com/oauth2/v3/certs',
		scope: 'openid email profile',
		issuer: 'https://accounts.google.com',
	),
	microsoft: arr(
		label: 'Microsoft',
		authorize: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
		token: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
		jwks: 'https://login.microsoftonline.com/common/discovery/v2.0/keys',
		scope: 'openid email profile',
		issuer: 'https://login.microsoftonline.com/',
	),
	apple: arr(
		label: 'Apple',
		authorize: 'https://appleid.apple.com/auth/authorize',
		token: 'https://appleid.apple.com/auth/token',
		jwks: 'https://appleid.apple.com/auth/keys',
		scope: 'name email',
		issuer: 'https://appleid.apple.com',
	),
)
static

social :: config ($provider):?array

line 38
$p = static::providers()[$provider] ?? null
if (!$p) return null
$section = %creds->{$provider} ?? null
$creds = is_object($section) ? (array)$section->toArray : []
return $p + arr(
	client_id: (string)($creds['client_id'] ?? void),
	client_secret: (string)($creds['client_secret'] ?? void),
	redirect_uri: (string)($creds['redirect_uri'] ?? ('https://'.%req->host.'/auth/'.$provider.'/callback')),
)
static

social :: configured ($provider):bool

line 50
Controleert of de sociale configuratie voor een gegeven provider is ingesteld en of de client-ID niet leeg is.
$c = static::config($provider)
return $c && $c['client_id'] !== void
static

social :: authUrl ($provider, $state, $nonce = void):string

line 55
$c = static::config($provider)
$params = arr(
	client_id: $c['client_id'],
	redirect_uri: $c['redirect_uri'],
	response_type: 'code',
	scope: $c['scope'],
	state: $state,
)
if ($nonce !== void) $params['nonce'] = $nonce
if ($provider === 'apple') $params['response_mode'] = 'form_post'
return OAuth2::authorizeUrl($c['authorize'], $params)
static

social :: profile ($provider, $code, $nonce = void):?array

line 69
$cfg = static::config($provider)
if (!$cfg || $cfg['client_id'] === void) return null
$secret = $provider === 'apple' ? static::appleSecret() : $cfg['client_secret']
$token = OAuth2::exchangeCode($cfg['token'], $cfg['client_id'], $secret, $code, $cfg['redirect_uri'])
$jwt = (string)($token['id_token'] ?? void)
if (!static::verifySignature($provider, $jwt)) return null
$claims = static::decodeIdToken($jwt)
if (!$claims || !static::verifyClaims($provider, $cfg, $claims, $nonce)) return null
$profile = static::normalize($provider, $claims)
if ($provider === 'apple' && !$profile['name']){
	$u = json_decode((string)(%payload->user ?? void), true)
	if (is_array($u) && !empty($u['name'])) $profile['name'] = trim(((string)($u['name']['firstName'] ?? void)).space.((string)($u['name']['lastName'] ?? void)))
}
return $profile
static

social :: decodeIdToken ($jwt):?array

line 86
$parts = explode(dot, (string)$jwt)
if (count($parts) < 2) return null
$claims = json_decode(static::b64urlDecode($parts[1]), true)
return is_array($claims) ? $claims : null
static

social :: algs:array

line 96
RS256 only, and deliberately so.
arr(RS256: OPENSSL_ALGO_SHA256)
static

social :: verifySignature ($provider, $jwt):bool

line 98
$parts = explode(dot, (string)$jwt)
if (count($parts) !== 3) return false
$header = json_decode(static::b64urlDecode($parts[0]), true)
if (!is_array($header)) return false
$alg = static::algs()[(string)($header['alg'] ?? void)] ?? null
$kid = (string)($header['kid'] ?? void)
if (!$alg || !$kid) return false
$pem = static::key($provider, $kid)
if (!$pem) return false
return openssl_verify($parts[0].dot.$parts[1], static::b64urlDecode($parts[2]), $pem, $alg) === 1
static

social :: jwks ($provider):array

line 111
$cached = %req->socialJwks ?? []
if (isset($cached[$provider])) return $cached[$provider]
$url = (string)(static::providers()[$provider]['jwks'] ?? void)
$keys = []
if ($url){
	$data = json_decode((string)HTTP($url, ['Accept: application/json']), true)
	if (is_array($data) && is_array($data['keys'] ?? null)) $keys = $data['keys']
}
$cached[$provider] = $keys
%req->socialJwks = $cached
return $keys
static

social :: key ($provider, $kid):string

line 125
foreach (static::jwks($provider) AS $jwk){
	if ((string)($jwk['kid'] ?? void) === $kid) return static::jwkToPem((array)$jwk)
}
return void
static

social :: jwkToPem (array $jwk):string

line 132
if ((string)($jwk['kty'] ?? void) !== 'RSA') return void
$n = static::b64urlDecode((string)($jwk['n'] ?? void))
$e = static::b64urlDecode((string)($jwk['e'] ?? void))
if (!$n || !$e) return void
$key = static::der(0x30, static::derInt($n).static::derInt($e))
$bits = static::der(0x03, "\x00".$key)
$algo = static::der(0x30, "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00")
$spki = static::der(0x30, $algo.$bits)
return '-----BEGIN PUBLIC KEY-----'.lf.chunk_split(base64_encode($spki), 64, lf).'-----END PUBLIC KEY-----'.lf
static

social :: der ($tag, $content):string

line 144
$len = strlen($content)
if ($len < 0x80) return chr($tag).chr($len).$content
$bytes = ltrim(pack('N', $len), "\x00")
return chr($tag).chr(0x80 | strlen($bytes)).$bytes.$content
static

social :: derInt ($bytes):string

line 151
$bytes = ltrim((string)$bytes, "\x00")
if ($bytes === void) $bytes = "\x00"
if (ord($bytes[0]) & 0x80) $bytes = "\x00".$bytes
return static::der(0x02, $bytes)
static

social :: b64urlDecode ($data):string

line 158
$data = strtr((string)$data, '-_', '+/')
$pad = strlen($data) % 4
if ($pad) $data .= str_repeat(eq, 4 - $pad)
return (string)base64_decode($data)
static

social :: verifyClaims ($provider, array $cfg, array $claims, $nonce = void):bool

line 165
if ((int)($claims['exp'] ?? 0) <= time()) return false
$aud = $claims['aud'] ?? void
if (!in_array((string)($cfg['client_id'] ?? void), array_map('strval', is_array($aud) ? $aud : [$aud]), true)) return false
if ($nonce !== void && !hash_equals((string)$nonce, (string)($claims['nonce'] ?? void))) return false
return static::verifyIssuer($provider, $claims)
static

social :: verifyIssuer ($provider, array $claims):bool

line 173
$iss = (string)($claims['iss'] ?? void)
if ($provider === 'microsoft'){
	$tid = (string)($claims['tid'] ?? void)
	return $tid !== void && $iss === 'https://login.microsoftonline.com/'.$tid.'/v2.0'
}
$expected = (string)(static::providers()[$provider]['issuer'] ?? void)
if ($expected === void) return false
return $iss === $expected || ($provider === 'google' && $iss === 'accounts.google.com')
static

social :: normalize ($provider, array $claims):array

line 184
$ev = $claims['email_verified'] ?? null
$verified = $ev === true || $ev === 'true'
if ($provider === 'microsoft'){
	$edov = $claims['xms_edov'] ?? null
	$verified = $edov === true || $edov === 'true' || $edov === 1 || $edov === '1'
}
$name = (string)($claims['name'] ?? void)
if (!$name) $name = trim(((string)($claims['given_name'] ?? void)).space.((string)($claims['family_name'] ?? void)))
return arr(
	provider: $provider,
	uid: (string)($claims['sub'] ?? void),
	email: strtolower(trim((string)($claims['email'] ?? void))),
	verified: $verified,
	name: trim($name),
)
static

social :: b64url ($data):string

line 202
Codeert de gegeven data in een base64 URL-veilige indeling door '+' te vervangen door '-', '/' door '_' en eventuele trailing gelijktekens te verwijderen.
rtrim(strtr(base64_encode((string)$data), '+/', '-_'), eq)
static

social :: appleSecret

line 204
$section = %creds->apple ?? null
$creds = is_object($section) ? (array)$section->toArray : []
$teamId = (string)($creds['team_id'] ?? void)
$keyId = (string)($creds['key_id'] ?? void)
$clientId = (string)($creds['client_id'] ?? void)
$keyData = (string)($creds['private_key'] ?? void)
if (!$keyData && !empty($creds['key_file']) && is_file((string)$creds['key_file'])) $keyData = (string)file_get_contents((string)$creds['key_file'])
if (!$teamId || !$keyId || !$clientId || !$keyData) return void
$key = openssl_pkey_get_private($keyData)
if (!$key) return void
$now = time()
$input = static::b64url(json_encode(arr(alg: 'ES256', kid: $keyId))).dot.static::b64url(json_encode(arr(iss: $teamId, iat: $now, exp: $now + 15552000, aud: 'https://appleid.apple.com', sub: $clientId)))
$der = void
if (!openssl_sign($input, $der, $key, OPENSSL_ALGO_SHA256)) return void
return $input.dot.static::b64url(static::derToJose($der))
static

social :: derToJose ($der):string

line 222
Extraheert de 'r' en 's' waarden uit een DER-gecodeerde handtekening en vult deze aan tot 32 bytes.
$rlen = ord($der[3])
$r = substr((string)$der, 4, $rlen)
$slen = ord($der[4 + $rlen + 1])
$s = substr((string)$der, 4 + $rlen + 2, $slen)
return static::pad32($r).static::pad32($s)
static

social :: pad32 ($x):string

line 230
Deze methode voegt null-bytes aan de linkerkant van een string toe om ervoor te zorgen dat deze 32 tekens lang is, waarbij eventuele leidende null-bytes voor het opvullen worden verwijderd.
$x = ltrim((string)$x, "\x00")
return str_pad($x, 32, "\x00", STR_PAD_LEFT)

Functions

function

decrypt($encrypted, $key):string|false

/phlo/resources/security/encryption.phlo line 12

Encrypt and decrypt secretbox payloads using a key

Authenticated encryption through libsodium: a fresh nonce per call travels with the value, so encrypting the same text twice gives different output and a changed ciphertext refuses to decrypt rather than returning rubbish. decrypt() gives false on a failure, so test with === false and not on falsiness. The key is hashed to the right length, which means any string works, but a short one is still a short secret.

encryptdecryptencryptionsodiumsecretboxcrypto
Decrypt de gegeven base64-gecodeerde versleutelde string met de opgegeven sleutel en retourneer de oorspronkelijke gegevens of false als de decryptie mislukt.
($d = base64_decode($encrypted, true)) !== false && strlen($d) >= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ? sodium_crypto_secretbox_open(substr($d, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), substr($d, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), hash('sha256', $key, true)) : false
function

encrypt($data, $key):string

/phlo/resources/security/encryption.phlo line 10

Encrypt and decrypt secretbox payloads using a key

Authenticated encryption through libsodium: a fresh nonce per call travels with the value, so encrypting the same text twice gives different output and a changed ciphertext refuses to decrypt rather than returning rubbish. decrypt() gives false on a failure, so test with === false and not on falsiness. The key is hashed to the right length, which means any string works, but a short one is still a short secret.

encryptdecryptencryptionsodiumsecretboxcrypto
Versleutelt de gegeven data met een geheime sleutel en retourneert het base64-gecodeerde resultaat.
base64_encode(($nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES)).sodium_crypto_secretbox($data, $nonce, hash('sha256', $key, true)))
function

token(int $length = 8, ?string $input = null):string

/phlo/resources/security/token.phlo line 15

Generate deterministic or random lowercase token

Lowercase letters only, so a token survives being read aloud, typed by hand or used in a URL without escaping. Pass an input and the token is derived from it, so the same input always gives the same token: exactly what you want for a file or a record, and exactly what you do not want for a secret. Leave the input out for anything that must be unguessable.

tokenrandomdeterministicsecurity
Bytes from the last incomplete block of 256 are thrown away rather than wrapped around.
	$length || error('Token must have a minimum length above 0', 500)
	$alphabet = 'abcdefghijklmnopqrstuvwxyz'
	$alphabetLength = strlen($alphabet)
	$limit = intdiv(256, $alphabetLength) * $alphabetLength
	$token = void
	$buffer = void
	$state = is_null($input) ? null : hash('sha256', (string)$input, true)
	while (strlen($token) < $length){
		if ($buffer === void){
			if (is_null($state)) $buffer = random_bytes(32)
			else {
				$state = hash('sha256', $state, true)
				$buffer = $state
			}
		}
		$byte = ord($buffer[0])
		$buffer = substr($buffer, 1)
		if ($byte >= $limit) continue
		$token .= $alphabet[$byte % $alphabetLength]
	}
	return $token

Laatst bijgewerkt op 07-10-2026

We gebruiken essentiële cookies om deze site te laten werken. Met uw toestemming gebruiken we ook analytics om de site te verbeteren.