security
object
%audit
/phlo/resources/security/audit.phlo
static
audit :: log ($model, $action, $before = [], $after = [], $exclude = [])
line 9
Logs changes made to a model in the audit log, capturing details such as the user, action type, and changes before and after the modification.
$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, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
(string)($_SERVER['REMOTE_ADDR'] ?? null),
)static
audit :: diff ($before, $after)
line 30
Calculates the differences between two sets of data, returning an array of changed properties along with their old and new values.
$changed = []
foreach ($after AS $col => $newVal){
$oldVal = $before[$col] ?? null
if ($oldVal !== $newVal) $changed[$col] = ['from' => $oldVal, 'to' => $newVal]
}
return $changedstatic
audit :: history ($model, $recordId, $limit = 50)
line 39
Retrieves the audit history for a specific model and record ID from the audit_log table, ordered by timestamp in descending order, with a limit on the number of results.
$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)
line 47
Retrieves audit log entries for a specific user, filtered by a timestamp and limited to a specified number of results.
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 54
Deletes entries from the audit_log table that are older than a specified number of seconds.
$model::DB()->query('DELETE FROM audit_log WHERE ts < ?', time() - $olderThanSeconds)object
%captcha
/phlo/resources/security/captcha.phlo
static
captcha :: W
line 10
Generates a CAPTCHA image with a specified width of 300 pixels.
300static
captcha :: H
line 11
This constant represents the height of the CAPTCHA image in pixels.
180static
captcha :: P
line 12
captcha::$P is a constant that holds the value of the captcha parameter used for validation in Phlo applications.
56static
captcha :: tol
line 13
captcha::$tol returns the tolerance level for the CAPTCHA validation process, which determines how lenient the system is when assessing user responses.
8static
captcha :: ttl
line 14
Sets the time-to-live (TTL) for the captcha in seconds.
600static
captcha :: issue
line 16
Generates a random gap for a captcha challenge and stores the gap values in the session with an expiration time.
$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)
line 23
Generates a CAPTCHA image and a puzzle piece, returning them as base64-encoded PNG data. The background image features a gradient and random ellipses, while the piece is a cropped section of the background.
$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)
line 55
Verifies a captcha by checking session data, telemetry, and various conditions to ensure the validity of the response.
$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 truestatic
captcha :: consume
line 76
Removes the captcha data from the session, effectively consuming it.
unset(%session->captcha)method
%captcha -> widget
line 80
Generates a CAPTCHA widget by issuing a challenge and creating an image with specified 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 86
Generates a CAPTCHA view that includes a draggable slider for users to complete a puzzle, enhancing security against automated submissions.
<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') }}">››</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 101
Defines the CSS styles for the captcha component, including layout, colors, and interactive elements.
#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 177
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
method
%creds -> __construct (?array $values = null)
line 9
Initializes the creds object, resolving values if not provided, and assigns each value to the corresponding property, creating a new instance of static or SensitiveParameterValue as needed.
$values ??= $this->resolve()
foreach ($values AS $key => $value){
$this->$key = is_array($value) ? new static($value) : new \SensitiveParameterValue((string)$value)
}method
%creds -> resolve
line 16
This function resolves and merges credential data from an INI file and environment variables, returning the combined data array.
$data = []
$this->merge($data, $this->loadINI(data.'creds.ini'))
$this->merge($data, $this->envValues(false))
$this->merge($data, $this->envValues(true))
return $datamethod
%creds -> loadINI (string $file):array
line 24
Loads configuration settings from an INI file specified by the given file path and returns them as an associative array. If the file does not exist or cannot be parsed, it returns an empty 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 30
Extracts environment variable values that start with a specified prefix, optionally scoped to the host, and returns them as an associative 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 $outmethod
%creds -> hostKey
line 49
This function processes the host from the request, converting it to uppercase, removing non-alphanumeric characters, and trimming any leading or trailing separators.
$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 55
Assigns a value to a nested array structure based on the provided parts, creating intermediate arrays as necessary.
$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 70
Merges the contents of the $add array into the $base array, recursively combining values if both are arrays.
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 80
Retrieves the value associated with the specified key from the object, returning sensitive values in a secure manner.
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
line 85
This function processes each item in objData, replacing instances of SensitiveParameterValue with asterisks corresponding to their length, while leaving other values unchanged.
loop($this->objData, fn($value) => is_a($value, '\SensitiveParameterValue') ? str_repeat('*', strlen($value->getValue())) : $value)object
%CSRF
/phlo/resources/security/CSRF.phlo
view
%CSRF -> view
line 11
Generates a meta tag containing the CSRF token for secure form submissions.
<meta name=csrf content="$this->token">prop
%CSRF -> token
line 12
Generates a CSRF token of 32 characters if one does not already exist in the session.
%session->csrf ??= token(32)method
%CSRF -> verify
line 13
Verifies the CSRF token by comparing it with the token received in the HTTP header.
hash_equals($this->token, (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? void))method
%CSRF -> update
line 14
Updates the CSRF token in the session and assigns it to the csrf property.
arr(csrf: $this->token = %session->csrf = token(32))view
script
line 16
Sets the CSRF token value in the meta tag of the document.
app.mod.csrf = value => obj('meta[name="csrf"]').content = valueobject
%JWT
/phlo/resources/security/JWT.phlo
method
%JWT -> __construct (public string $secret, public string $issuer = void, public int $leeway = 30)
line 10
Constructs a JWT instance with a specified secret, issuer, and leeway time. It ensures that the secret is at least 32 bytes long, throwing an error if this condition is not met.
strlen($this->secret) >= 32 || error('JWT secret must be at least 32 bytes', 500)method
%JWT -> sign (array $claims, int $ttl = 3600):string
line 12
Generates a JSON Web Token (JWT) by signing the provided claims with a specified time-to-live (TTL). The token includes issued at (iat) and expiration (exp) timestamps, and can optionally include an issuer (iss).
$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 21
Verifies a JSON Web Token (JWT) by checking its structure, signature, and claims such as expiration and issuer.
$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 $claimsmethod
%JWT -> sig (string $body):string
line 37
Generates a signature for the given body using HMAC with SHA-256 and a secret key.
$this->encode(hash_hmac('sha256', $body, $this->secret, true))method
%JWT -> encode ($data):string
line 38
Encodes the given data into a JSON Web Token (JWT) format using base64 encoding.
rtrim(strtr(base64_encode(is_string($data) ? $data : (string)json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)), '+/', '-_'), eq)method
%JWT -> decode (string $data):string
line 39
Decodes a JSON Web Token (JWT) from a base64-encoded string, replacing URL-safe characters with standard base64 characters.
(string)base64_decode(strtr($data, '-_', '+/'))object
%OAuth2
/phlo/resources/security/OAuth2.phlo
static
OAuth2 :: authorizeUrl ($endpoint, array $params)
line 10
Generates the authorization URL for the OAuth2 flow by appending query parameters to the endpoint.
$endpoint.(str_contains($endpoint, '?') ? '&' : '?').http_build_query($params)static
OAuth2 :: token ($tokenUrl, $clientId, $clientSecret, $grantType, array $extra = [])
line 12
Retrieves an OAuth2 token by sending a request to the specified token URL with the necessary credentials and parameters, handling errors and returning the response as an associative array.
$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 = [])
line 26
Exchanges an authorization code for an access token using the OAuth2 protocol.
static::token($tokenUrl, $clientId, $clientSecret, 'authorization_code', ['code' => $code, 'redirect_uri' => $redirectUri] + $extra)static
OAuth2 :: refresh ($tokenUrl, $clientId, $clientSecret, $refreshToken, array $extra = [])
line 28
This method refreshes an OAuth2 access token using a provided refresh token and additional parameters.
static::token($tokenUrl, $clientId, $clientSecret, 'refresh_token', ['refresh_token' => $refreshToken] + $extra)object
%rate
/phlo/resources/security/rate.phlo
static
rate :: check ($key, $limit, $windowSeconds, $storage = 'db')
line 10
Checks the rate limit for a given key by incrementing the count in a database and ensuring it does not exceed a specified limit within a defined time window.
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 so the check needs no separate
// read-then-write (which let parallel requests 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() <= $limitstatic
rate :: checkApcu ($key, $limit, $windowSeconds)
line 20
Checks if the rate limit for a specific key has been exceeded using APCu caching.
$window = (int)floor(time() / $windowSeconds) * $windowSeconds
$apcuKey = 'phlo.rate.'.$key.':'.$window
apcu_add($apcuKey, 0, $windowSeconds)
return apcu_inc($apcuKey, 1) <= $limitstatic
rate :: status ($key, $limit, $windowSeconds)
line 27
Retrieves the current rate limit status, including the number of requests used, the limit, and the time until the limit resets.
$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 34
This method resets the rate limit by deleting the corresponding entry from the rate_limit table in the MySQL database.
%MySQL->query('DELETE FROM rate_limit WHERE rkey=?', $key)static
rate :: purge ($olderThanSeconds = 604800)
line 35
Deletes entries from the rate_limit table where the window_start is older than the specified time in seconds.
%MySQL->query('DELETE FROM rate_limit WHERE window_start < ?', time() - $olderThanSeconds)object
%security
/phlo/resources/security/security.phlo
prop
%security -> whitelist
line 10
Defines a whitelist for security purposes, allowing only specified entries.
[]method
%security -> setNonce
line 12
Sets a nonce value for the application using a generated token of specified length.
%app->nonce = token(8)method
%security -> frameProtect ($mode = 'DENY')
line 14
Sets the X-Frame-Options header to control whether the page can be displayed in a frame, with the default mode being 'DENY'.
%res->header('X-Frame-Options', $mode)method
%security -> frameWhitelist
line 15
Generates a string of whitelisted frame origins for security purposes, allowing only specified domains to embed the content in an iframe.
$this->whitelist ? ' '.implode(space, array_map(fn($d) => "https://*.$d", (array)$this->whitelist)) : voidmethod
%security -> strict
line 17
Sets strict security headers for HTTP responses, including Cache-Control and Content-Security-Policy, to enhance security against various attacks.
$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:; font-src 'self'; connect-src 'self'; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")method
%security -> basic
line 26
Sets basic security headers for the response, including a Content-Security-Policy that restricts resource loading to the same origin and specifies allowed sources for scripts, styles, images, fonts, and connections.
$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:; font-src 'self'; connect-src 'self'; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")method
%security -> marketing
line 31
Sets the Content Security Policy headers for the response, defining allowed sources for various content types based on the request's async status and debug mode.
$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:; font-src 'self'; connect-src 'self'; form-action 'self'; object-src 'none'; frame-src 'self'$this->frameWhitelist; frame-ancestors 'none'; base-uri 'self'")method
%security -> api
line 36
Sets security-related HTTP headers for the API response, enhancing protection against various web vulnerabilities.
%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
line 43
Sets various security-related HTTP headers for the response, including Referrer-Policy, X-Content-Type-Options, Cross-Origin policies, and X-Frame-Options, to enhance security against cross-origin attacks.
%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
static
social :: providers
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)
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)
line 50
Checks if the social configuration for a given provider is set and if the client ID is not void.
$c = static::config($provider)
return $c && $c['client_id'] !== voidstatic
social :: authUrl ($provider, $state, $nonce = void)
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)
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 $profilestatic
social :: decodeIdToken ($jwt)
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 : nullstatic
social :: algs
line 93
arr(RS256: OPENSSL_ALGO_SHA256)static
social :: verifySignature ($provider, $jwt)
line 95
$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) === 1static
social :: jwks ($provider)
line 108
$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 $keysstatic
social :: key ($provider, $kid)
line 122
foreach (static::jwks($provider) AS $jwk){
if ((string)($jwk['kid'] ?? void) === $kid) return static::jwkToPem((array)$jwk)
}
return voidstatic
social :: jwkToPem (array $jwk)
line 129
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-----'.lfstatic
social :: der ($tag, $content)
line 141
$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.$contentstatic
social :: derInt ($bytes)
line 148
$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)
line 155
$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)
line 162
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)
line 170
$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)
line 181
$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)
line 199
Encodes the given data into a base64 URL-safe format by replacing '+' with '-', '/' with '_', and trimming any trailing equal signs.
rtrim(strtr(base64_encode((string)$data), '+/', '-_'), eq)static
social :: appleSecret
line 201
$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)
line 219
Extracts the 'r' and 's' values from a DER-encoded signature and pads them to 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)
line 227
This method pads a string on the left with null bytes to ensure it is 32 characters long, trimming any leading null bytes before padding.
$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 11
Decrypts the given base64-encoded encrypted string using the provided key, returning the original data or false if decryption fails.
($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)) : falsefunction
encrypt($data, $key):string
/phlo/resources/security/encryption.phlo line 9
Encrypts the given data using a secret key and returns the base64-encoded result.
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)
/phlo/resources/security/token.phlo line 9
Generates a random token of specified length using a defined alphabet, optionally seeded with an input string for added randomness.
$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