security
object
%audit
/phlo/resources/security/audit.phlo
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 $changedstatic
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
static
captcha :: W
line 10
生成宽度为300像素的验证码图像。
300static
captcha :: H
line 11
此常量表示CAPTCHA图像的高度(以像素为单位)。
180static
captcha :: P
line 12
captcha::$P 是一个常量,保存用于 Phlo 应用程序验证的验证码参数值。
56static
captcha :: tol
line 13
captcha::$tol 返回 CAPTCHA 验证过程的容忍度,决定系统在评估用户响应时的宽容程度。
8static
captcha :: ttl
line 14
设置验证码的生存时间(TTL),单位为秒。
600static
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 truestatic
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') }}">››</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
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 $datamethod
%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 $outmethod
%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
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 = valueobject
%JWT
/phlo/resources/security/JWT.phlo
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 $claimsmethod
%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
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
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() <= $limitstatic
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) <= $limitstatic
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
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)) : voidmethod
%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
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'] !== 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
将给定数据编码为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)) : falsefunction
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