security

object

%audit

/phlo/resources/security/audit.phlo
version 1.0
creator q-ai.nl
summary Audit log for model mutations (opt-in via static idColumn/objAudit). Schema: resources/security/audit.sql
package security
frontend false
backend true
tags audit log compliance traceability
static

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

line 9
记录对模型所做的更改到审计日志中,捕获用户、操作类型以及修改前后的更改等详细信息。
$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
计算两组数据之间的差异,返回一个包含更改属性及其旧值和新值的数组。
$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)

line 39
从audit_log表中检索特定模型和记录ID的审计历史,按时间戳降序排序,并限制结果数量。
$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
检索特定用户的审计日志条目,按时间戳过滤,并限制为指定数量的结果。
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
删除审计日志表中超过指定秒数的条目。
$model::DB()->query('DELETE FROM audit_log WHERE ts < ?', time() - $olderThanSeconds)
object

%captcha

/phlo/resources/security/captcha.phlo
version 1.0
creator q-ai.nl
summary 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.
package security
frontend true
backend true
requires @session lang DOM/exists php-ext:gd
tags captcha spam bot human-verification security
advice verify() does not consume the captcha; call consume() only on success.
static

captcha :: W

line 10
生成宽度为300像素的验证码图像。
300
static

captcha :: H

line 11
此常量表示CAPTCHA图像的高度(以像素为单位)。
180
static

captcha :: P

line 12
captcha::$P 是一个常量,保存用于 Phlo 应用程序验证的验证码参数值。
56
static

captcha :: tol

line 13
captcha::$tol 返回 CAPTCHA 验证过程的容忍度,决定系统在评估用户响应时的宽容程度。
8
static

captcha :: ttl

line 14
设置验证码的生存时间(TTL),单位为秒。
600
static

captcha :: issue

line 16
生成一个随机间隙用于验证码挑战,并将间隙值与过期时间存储在会话中。
$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
生成一个验证码图像和一个拼图块,并将其作为base64编码的PNG数据返回。背景图像具有渐变和随机椭圆,而拼图块是背景的裁剪部分。
$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
通过检查会话数据、遥测和各种条件来验证验证码,以确保响应的有效性。
$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

line 76
从会话中移除验证码数据,实际上消耗了它。
unset(%session->captcha)
method

%captcha -> widget

line 80
通过发出挑战并使用指定参数创建图像来生成 CAPTCHA 小部件。
$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
生成一个CAPTCHA视图,包含一个可拖动的滑块供用户完成拼图,从而增强对自动提交的安全性。
<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 101
定义验证码组件的CSS样式,包括布局、颜色和交互元素。
#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
version 1.0
creator q-ai.nl
summary Credentials resolver from env and ini sources
package security
frontend false
backend true
tags credentials env ini secrets configuration
method

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

line 9
初始化creds对象,如果未提供值则解析值,并将每个值分配给相应的属性,根据需要创建static或SensitiveParameterValue的新实例。
$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
此函数解析并合并来自INI文件和环境变量的凭据数据,返回合并后的数据数组。
$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 24
从指定的文件路径加载INI文件中的配置设置,并将其作为关联数组返回。如果文件不存在或无法解析,则返回一个空数组。
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
提取以指定前缀开头的环境变量值,选项上限于主机,并将其作为关联数组返回。
$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

line 49
该函数处理请求中的主机,将其转换为大写,移除非字母数字字符,并修剪任何前导或尾随分隔符。
$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
根据提供的部分将值分配给嵌套数组结构,必要时创建中间数组。
$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
将$add数组的内容合并到$base数组中,如果两个都是数组,则递归地组合值。
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
从对象中检索与指定键关联的值,以安全的方式返回敏感值。
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
此函数处理objData中的每个项目,将SensitiveParameterValue的实例替换为与其长度相对应的星号,同时保持其他值不变。
loop($this->objData, fn($value) => is_a($value, '\SensitiveParameterValue') ? str_repeat('*', strlen($value->getValue())) : $value)
object

%CSRF

/phlo/resources/security/CSRF.phlo
version 1.0
creator q-ai.nl
summary Rotating async CSRF protection for Phlo requests
package security
frontend true
backend true
requires @session token payload
provides app.mod.csrf
tags csrf security async forms
view

%CSRF -> view

line 11
生成一个包含CSRF令牌的meta标签,用于安全的表单提交。
<meta name=csrf content="$this->token">
prop

%CSRF -> token

line 12
如果会话中尚不存在,则生成一个32个字符的CSRF令牌。
%session->csrf ??= token(32)
method

%CSRF -> verify

line 13
通过将其与在HTTP头中接收到的令牌进行比较来验证CSRF令牌。
hash_equals($this->token, (string)($_SERVER['HTTP_X_CSRF_TOKEN'] ?? void))
method

%CSRF -> update

line 14
更新会话中的CSRF令牌并将其分配给csrf属性。
arr(csrf: $this->token = %session->csrf = token(32))
view

script

line 16
在文档的meta标签中设置CSRF令牌值。
app.mod.csrf = value => obj('meta[name="csrf"]').content = value
object

%JWT

/phlo/resources/security/JWT.phlo
version 1.0
creator q-ai.nl
summary Sign and verify compact HS256 JSON Web Tokens (RFC 7519), secure by default
package security
frontend false
backend true
requires php-ext:hash
tags jwt jws hs256 token auth security
method

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

line 10
构造一个JWT实例,指定密钥、发行者和宽限时间。它确保密钥至少为32个字节长,如果不满足此条件,则抛出错误。
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
通过使用指定的生存时间(TTL)对提供的声明进行签名来生成 JSON Web Token(JWT)。该令牌包括签发时间(iat)和到期时间(exp)时间戳,并可以选择性地包含发行者(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
通过检查其结构、签名和声明(如过期时间和发行者)来验证 JSON Web Token (JWT)。
$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 37
使用HMAC和SHA-256及秘密密钥为给定主体生成签名。
$this->encode(hash_hmac('sha256', $body, $this->secret, true))
method

%JWT -> encode ($data):string

line 38
使用base64编码将给定数据编码为JSON Web Token (JWT)格式。
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
从 base64 编码的字符串解码 JSON Web Token (JWT),将 URL 安全字符替换为标准 base64 字符。
(string)base64_decode(strtr($data, '-_', '+/'))
object

%OAuth2

/phlo/resources/security/OAuth2.phlo
version 1.0
creator q-ai.nl
summary 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.
package security
frontend false
backend true
requires HTTP
tags oauth oauth2 token authorization refresh authentication
static

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

line 10
通过将查询参数附加到端点生成OAuth2流程的授权URL。
$endpoint.(str_contains($endpoint, '?') ? '&' : '?').http_build_query($params)
static

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

line 12
通过向指定的令牌URL发送请求,使用必要的凭据和参数来检索OAuth2令牌,处理错误并将响应作为关联数组返回。
$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
使用OAuth2协议将授权码兑换为访问令牌。
static::token($tokenUrl, $clientId, $clientSecret, 'authorization_code', ['code' => $code, 'redirect_uri' => $redirectUri] + $extra)
static

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

line 28
此方法使用提供的刷新令牌和附加参数刷新OAuth2访问令牌。
static::token($tokenUrl, $clientId, $clientSecret, 'refresh_token', ['refresh_token' => $refreshToken] + $extra)
object

%rate

/phlo/resources/security/rate.phlo
version 1.0
creator q-ai.nl
summary Rate-limit (fixed window) on the rate_limit table. Schema: resources/security/rate.sql
package security
frontend false
backend true
requires @MySQL
tags rate limit throttle abuse
static

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

line 10
通过在数据库中增加计数并确保在定义的时间窗口内不超过指定限制,检查给定键的速率限制。
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() <= $limit
static

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

line 20
使用 APCu 缓存检查特定键的速率限制是否已超过。
$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)

line 27
检索当前的速率限制状态,包括已使用的请求数量、限制和重置限制的时间。
$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
此方法通过从MySQL数据库中的rate_limit表中删除相应条目来重置速率限制。
%MySQL->query('DELETE FROM rate_limit WHERE rkey=?', $key)
static

rate :: purge ($olderThanSeconds = 604800)

line 35
从rate_limit表中删除window_start早于指定时间(以秒为单位)的条目。
%MySQL->query('DELETE FROM rate_limit WHERE window_start < ?', time() - $olderThanSeconds)
object

%security

/phlo/resources/security/security.phlo
version 1.0
creator q-ai.nl
summary Generic security resource
package security
frontend true
backend true
requires @session token
tags security csp nonce headers
prop

%security -> whitelist

line 10
定义一个安全白名单,仅允许指定的条目。
[]
method

%security -> setNonce

line 12
使用指定长度的生成令牌为应用程序设置一个随机数值。
%app->nonce = token(8)
method

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

line 14
设置 X-Frame-Options 头部以控制页面是否可以在框架中显示,默认模式为 'DENY'。
%res->header('X-Frame-Options', $mode)
method

%security -> frameWhitelist

line 15
生成一个用于安全目的的白名单框架来源字符串,仅允许指定的域在iframe中嵌入内容。
$this->whitelist ? ' '.implode(space, array_map(fn($d) => "https://*.$d", (array)$this->whitelist)) : void
method

%security -> strict

line 17
为HTTP响应设置严格的安全头,包括Cache-Control和Content-Security-Policy,以增强对各种攻击的防护。
$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
为响应设置基本安全头,包括限制资源加载到同一来源的内容安全策略,并指定脚本、样式、图像、字体和连接的允许来源。
$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
为响应设置内容安全策略头,根据请求的异步状态和调试模式定义各种内容类型的允许源。
$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
为API响应设置与安全相关的HTTP头,增强对各种网络漏洞的保护。
%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
为响应设置各种与安全相关的HTTP头,包括Referrer-Policy、X-Content-Type-Options、跨源策略和X-Frame-Options,以增强对跨源攻击的防护。
%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
version 1.0
creator q-ai.nl
summary 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.
package security
frontend false
backend true
requires OAuth2 creds payload HTTP php-ext:openssl
tags oauth oidc social login google microsoft apple authentication
advice 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).
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
检查给定提供者的社交配置是否已设置,以及客户端ID是否不为空。
$c = static::config($provider)
return $c && $c['client_id'] !== void
static

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 $profile
static

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 : null
static

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) === 1
static

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 $keys
static

social :: key ($provider, $kid)

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

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-----'.lf
static

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.$content
static

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
将给定数据编码为base64 URL安全格式,通过将'+'替换为'-',将'/'替换为'_',并修剪任何尾随的等号。
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
从DER编码的签名中提取'r'和's'值,并将其填充到32字节。
$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
该方法在字符串左侧填充空字节,以确保其长度为32个字符,在填充之前去除任何前导空字节。
$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
使用提供的密钥解密给定的 base64 编码的加密字符串,返回原始数据或在解密失败时返回 false。
($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 9
使用秘密密钥加密给定数据并返回 base64 编码的结果。
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
使用定义的字母表生成指定长度的随机令牌,可选地用输入字符串作为种子以增加随机性。
	$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

我们使用必要的cookie来使该网站正常工作。在您的许可下,我们还使用分析工具来改善网站。