mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-28 13:05:42 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd7aead73 | ||
|
|
bd8c784118 | ||
|
|
9cc8ac2908 | ||
|
|
002400315d | ||
|
|
8934a84d18 | ||
|
|
5ce88b5795 | ||
|
|
b2b21b1a85 | ||
|
|
4b1b9362f3 | ||
|
|
fb32d8d345 | ||
|
|
af5d0e3d29 | ||
|
|
4884ed0cbf | ||
|
|
8aaf68ffb5 | ||
|
|
503e64fe85 | ||
|
|
ba034568e0 | ||
|
|
b7d24881b1 | ||
|
|
1c7c328a2a | ||
|
|
7457fd59aa | ||
|
|
60177720ed | ||
|
|
0445f85d7d | ||
|
|
714ca5a3d8 | ||
|
|
7d27460fee | ||
|
|
82b1cf7198 |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.24
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.24
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
|
||||
@@ -1,12 +1,48 @@
|
||||
FROM alpine:3.21
|
||||
# Alpine >= 3.22 only ships Dovecot 2.4, Dovecot 2.3 is built from the Alpine 3.21 packaging
|
||||
FROM alpine:3.24 AS builder
|
||||
|
||||
# Last commit of the aports 3.21-stable branch
|
||||
ARG APORTS_COMMIT=55126a11a4b9dcd14de3068e3348e9951d144ca6
|
||||
|
||||
RUN apk add --no-cache alpine-sdk git \
|
||||
&& git init -q /aports \
|
||||
&& cd /aports \
|
||||
&& git remote add origin https://github.com/alpinelinux/aports.git \
|
||||
&& git sparse-checkout set main/dovecot community/dovecot-fts-flatcurve \
|
||||
&& git fetch -q --depth 1 --filter=blob:none origin ${APORTS_COMMIT} \
|
||||
&& git checkout -q FETCH_HEAD \
|
||||
&& abuild-keygen -a -n \
|
||||
&& cp /root/.abuild/*.rsa.pub /etc/apk/keys/ \
|
||||
&& apk update \
|
||||
&& cd /aports/main/dovecot \
|
||||
# newer gettext does not overwrite the shipped config.rpath without -f
|
||||
&& sed -i 's/autoreconf -vi/autoreconf -fvi/' APKBUILD \
|
||||
&& abuild -F -r \
|
||||
# flatcurve must be built against the Dovecot 2.3 headers
|
||||
&& apk add --no-cache /root/packages/main/*/dovecot-2*.apk /root/packages/main/*/dovecot-dev-*.apk \
|
||||
&& cd /aports/community/dovecot-fts-flatcurve \
|
||||
&& abuild -F -r \
|
||||
&& mkdir /packages \
|
||||
&& cp /root/.abuild/*.rsa.pub /packages/ \
|
||||
&& for pkg in dovecot dovecot-dev dovecot-lmtpd dovecot-lua dovecot-ldap dovecot-mysql dovecot-sql \
|
||||
dovecot-submissiond dovecot-pigeonhole-plugin dovecot-pop3d dovecot-fts-flatcurve; do \
|
||||
cp /root/packages/*/*/${pkg}-[0-9]*.apk /packages/; \
|
||||
done
|
||||
|
||||
|
||||
FROM alpine:3.24
|
||||
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||
ARG GOSU_VERSION=1.19
|
||||
ARG GOSU_VERSION=1.20
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
# MariaDB Connector/C >= 3.4 refuses DB servers without TLS, this covers Perl DBI, client.cnf covers Dovecot
|
||||
ENV MARIADB_TLS_DISABLE_PEER_VERIFICATION=1
|
||||
|
||||
COPY --from=builder /packages /tmp/packages
|
||||
|
||||
# Add groups and users before installing Dovecot to not break compatibility
|
||||
RUN addgroup -g 5000 vmail \
|
||||
@@ -18,6 +54,7 @@ RUN addgroup -g 5000 vmail \
|
||||
&& adduser -D -u 5000 -G vmail -h /var/vmail vmail \
|
||||
&& adduser -D -G dovecot -u 401 -h /dev/null -s /sbin/nologin dovecot \
|
||||
&& adduser -D -G dovenull -u 402 -h /dev/null -s /sbin/nologin dovenull \
|
||||
&& cp /tmp/packages/*.rsa.pub /etc/apk/keys/ \
|
||||
&& apk add --no-cache --update \
|
||||
bash \
|
||||
bind-tools \
|
||||
@@ -98,22 +135,15 @@ RUN addgroup -g 5000 vmail \
|
||||
supervisor \
|
||||
tzdata \
|
||||
wget \
|
||||
dovecot \
|
||||
dovecot-dev \
|
||||
dovecot-lmtpd \
|
||||
dovecot-lua \
|
||||
dovecot-ldap \
|
||||
dovecot-mysql \
|
||||
dovecot-sql \
|
||||
dovecot-submissiond \
|
||||
dovecot-pigeonhole-plugin \
|
||||
dovecot-pop3d \
|
||||
dovecot-fts-flatcurve \
|
||||
/tmp/packages/*.apk \
|
||||
&& rm -rf /tmp/packages \
|
||||
&& arch=$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) \
|
||||
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$arch" \
|
||||
&& chmod +x /usr/local/bin/gosu \
|
||||
&& gosu nobody true
|
||||
|
||||
# Dovecot's MySQL driver reads [client] from /etc/my.cnf, Connector/C ignores the !includedir there
|
||||
COPY client.cnf /etc/my.cnf
|
||||
COPY trim_logs.sh /usr/local/bin/trim_logs.sh
|
||||
COPY clean_q_aged.sh /usr/local/bin/clean_q_aged.sh
|
||||
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[client]
|
||||
ssl = false
|
||||
ssl-verify-server-cert = false
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.24
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ if [ $nftables_found -gt $iptables_found ]; then
|
||||
fi
|
||||
|
||||
if [ $nftables_found -eq 0 ] && [ $nftables_found -eq $iptables_found ]; then
|
||||
nftables_lines=$(nft list ruleset | wc -l)
|
||||
iptables_lines=$(iptables-save | wc -l)
|
||||
nftables_lines=$(nft list ruleset 2>/dev/null | wc -l)
|
||||
iptables_lines=$(iptables-save 2>/dev/null | wc -l)
|
||||
if [ $nftables_lines -gt $iptables_lines ]; then
|
||||
backend=nftables
|
||||
else
|
||||
|
||||
@@ -1,453 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
DEBUG = False
|
||||
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import atexit
|
||||
import signal
|
||||
import ipaddress
|
||||
from collections import Counter
|
||||
from random import randint
|
||||
from threading import Thread
|
||||
from threading import Lock
|
||||
import redis
|
||||
import json
|
||||
import dns.resolver
|
||||
import dns.exception
|
||||
import uuid
|
||||
from modules.Logger import Logger
|
||||
from modules.IPTables import IPTables
|
||||
from modules.NFTables import NFTables
|
||||
from modules.Netfilter import Netfilter
|
||||
|
||||
def logdebug(msg):
|
||||
if DEBUG:
|
||||
logger.logInfo("DEBUG: %s" % msg)
|
||||
|
||||
# Globals
|
||||
WHITELIST = []
|
||||
BLACKLIST = []
|
||||
bans = {}
|
||||
quit_now = False
|
||||
exit_code = 0
|
||||
lock = Lock()
|
||||
chain_name = "MAILCOW"
|
||||
r = None
|
||||
pubsub = None
|
||||
clear_before_quit = False
|
||||
|
||||
def refreshF2boptions():
|
||||
global f2boptions
|
||||
global quit_now
|
||||
global exit_code
|
||||
f2boptions = {}
|
||||
|
||||
if not r.get('F2B_OPTIONS'):
|
||||
f2boptions['ban_time'] = r.get('F2B_BAN_TIME')
|
||||
f2boptions['max_ban_time'] = r.get('F2B_MAX_BAN_TIME')
|
||||
f2boptions['ban_time_increment'] = r.get('F2B_BAN_TIME_INCREMENT')
|
||||
f2boptions['max_attempts'] = r.get('F2B_MAX_ATTEMPTS')
|
||||
f2boptions['retry_window'] = r.get('F2B_RETRY_WINDOW')
|
||||
f2boptions['netban_ipv4'] = r.get('F2B_NETBAN_IPV4')
|
||||
f2boptions['netban_ipv6'] = r.get('F2B_NETBAN_IPV6')
|
||||
def connect_redis(logger):
|
||||
if os.getenv('REDIS_SLAVEOF_IP', ''):
|
||||
host, port = os.getenv('REDIS_SLAVEOF_IP'), os.getenv('REDIS_SLAVEOF_PORT', '')
|
||||
else:
|
||||
host, port = os.getenv('IPV4_NETWORK', '172.22.1') + '.249', 6379
|
||||
while True:
|
||||
try:
|
||||
f2boptions = json.loads(r.get('F2B_OPTIONS'))
|
||||
except ValueError as e:
|
||||
logger.logCrit(
|
||||
'Error loading F2B options: F2B_OPTIONS is not json. Exception: %s' % e)
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
verifyF2boptions(f2boptions)
|
||||
r.set('F2B_OPTIONS', json.dumps(f2boptions, ensure_ascii=False))
|
||||
|
||||
def verifyF2boptions(f2boptions):
|
||||
verifyF2boption(f2boptions, 'ban_time', 1800)
|
||||
verifyF2boption(f2boptions, 'max_ban_time', 10000)
|
||||
verifyF2boption(f2boptions, 'ban_time_increment', True)
|
||||
verifyF2boption(f2boptions, 'max_attempts', 10)
|
||||
verifyF2boption(f2boptions, 'retry_window', 600)
|
||||
verifyF2boption(f2boptions, 'netban_ipv4', 32)
|
||||
verifyF2boption(f2boptions, 'netban_ipv6', 128)
|
||||
verifyF2boption(f2boptions, 'banlist_id', str(uuid.uuid4()))
|
||||
verifyF2boption(f2boptions, 'manage_external', 0)
|
||||
|
||||
def verifyF2boption(f2boptions, f2boption, f2bdefault):
|
||||
f2boptions[f2boption] = f2boptions[f2boption] if f2boption in f2boptions and f2boptions[f2boption] is not None else f2bdefault
|
||||
|
||||
def refreshF2bregex():
|
||||
global f2bregex
|
||||
global quit_now
|
||||
global exit_code
|
||||
if not r.get('F2B_REGEX'):
|
||||
f2bregex = {}
|
||||
f2bregex[1] = r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)'
|
||||
f2bregex[2] = r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)'
|
||||
f2bregex[3] = r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+'
|
||||
f2bregex[4] = r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+'
|
||||
f2bregex[5] = r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+'
|
||||
f2bregex[6] = r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)'
|
||||
f2bregex[7] = r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)'
|
||||
f2bregex[8] = r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked'
|
||||
f2bregex[9] = r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+'
|
||||
r.set('F2B_REGEX', json.dumps(f2bregex, ensure_ascii=False))
|
||||
else:
|
||||
try:
|
||||
f2bregex = {}
|
||||
f2bregex = json.loads(r.get('F2B_REGEX'))
|
||||
except ValueError:
|
||||
logger.logCrit('Error loading F2B options: F2B_REGEX is not json')
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
def get_ip(address):
|
||||
ip = ipaddress.ip_address(address)
|
||||
if type(ip) is ipaddress.IPv6Address and ip.ipv4_mapped:
|
||||
ip = ip.ipv4_mapped
|
||||
if ip.is_private or ip.is_loopback:
|
||||
return False
|
||||
|
||||
return ip
|
||||
|
||||
def ban(address):
|
||||
global f2boptions
|
||||
global lock
|
||||
logdebug("ban() called with address=%s" % address)
|
||||
refreshF2boptions()
|
||||
MAX_ATTEMPTS = int(f2boptions['max_attempts'])
|
||||
RETRY_WINDOW = int(f2boptions['retry_window'])
|
||||
NETBAN_IPV4 = '/' + str(f2boptions['netban_ipv4'])
|
||||
NETBAN_IPV6 = '/' + str(f2boptions['netban_ipv6'])
|
||||
|
||||
ip = get_ip(address)
|
||||
if not ip:
|
||||
logdebug("No valid IP -- skipping ban()")
|
||||
return
|
||||
address = str(ip)
|
||||
self_network = ipaddress.ip_network(address)
|
||||
|
||||
with lock:
|
||||
temp_whitelist = set(WHITELIST)
|
||||
logdebug("Checking if %s overlaps with any WHITELIST entries" % self_network)
|
||||
if temp_whitelist:
|
||||
for wl_key in temp_whitelist:
|
||||
wl_net = ipaddress.ip_network(wl_key, False)
|
||||
logdebug("Checking overlap between %s and %s" % (self_network, wl_net))
|
||||
if wl_net.overlaps(self_network):
|
||||
logger.logInfo(
|
||||
'Address %s is allowlisted by rule %s' % (self_network, wl_net))
|
||||
return
|
||||
|
||||
net = ipaddress.ip_network(
|
||||
(address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
|
||||
net = str(net)
|
||||
logdebug("Ban net: %s" % net)
|
||||
|
||||
if not net in bans:
|
||||
bans[net] = {'attempts': 0, 'last_attempt': 0, 'ban_counter': 0}
|
||||
logdebug("Initing new ban counter for %s" % net)
|
||||
|
||||
current_attempt = time.time()
|
||||
logdebug("Current attempt ts=%s, previous: %s, retry_window: %s" %
|
||||
(current_attempt, bans[net]['last_attempt'], RETRY_WINDOW))
|
||||
if current_attempt - bans[net]['last_attempt'] > RETRY_WINDOW:
|
||||
bans[net]['attempts'] = 0
|
||||
logdebug("Ban counter for %s reset as window expired" % net)
|
||||
|
||||
bans[net]['attempts'] += 1
|
||||
bans[net]['last_attempt'] = current_attempt
|
||||
logdebug("%s attempts now %d" % (net, bans[net]['attempts']))
|
||||
|
||||
if bans[net]['attempts'] >= MAX_ATTEMPTS:
|
||||
cur_time = int(round(time.time()))
|
||||
NET_BAN_TIME = calcNetBanTime(bans[net]['ban_counter'])
|
||||
logger.logCrit('Banning %s for %d minutes' % (net, NET_BAN_TIME / 60 ))
|
||||
if type(ip) is ipaddress.IPv4Address and int(f2boptions['manage_external']) != 1:
|
||||
with lock:
|
||||
logdebug("Calling tables.banIPv4(%s)" % net)
|
||||
tables.banIPv4(net)
|
||||
elif int(f2boptions['manage_external']) != 1:
|
||||
with lock:
|
||||
logdebug("Calling tables.banIPv6(%s)" % net)
|
||||
tables.banIPv6(net)
|
||||
|
||||
logdebug("Updating F2B_ACTIVE_BANS[%s]=%d" %
|
||||
(net, cur_time + NET_BAN_TIME))
|
||||
r.hset('F2B_ACTIVE_BANS', '%s' % net, cur_time + NET_BAN_TIME)
|
||||
else:
|
||||
logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (
|
||||
MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
|
||||
|
||||
def unban(net):
|
||||
global lock
|
||||
logdebug("Calling unban() with net=%s" % net)
|
||||
if not net in bans:
|
||||
logger.logInfo(
|
||||
'%s is not banned, skipping unban and deleting from queue (if any)' % net)
|
||||
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
|
||||
return
|
||||
logger.logInfo('Unbanning %s' % net)
|
||||
if type(ipaddress.ip_network(net)) is ipaddress.IPv4Network:
|
||||
with lock:
|
||||
logdebug("Calling tables.unbanIPv4(%s)" % net)
|
||||
tables.unbanIPv4(net)
|
||||
else:
|
||||
with lock:
|
||||
logdebug("Calling tables.unbanIPv6(%s)" % net)
|
||||
tables.unbanIPv6(net)
|
||||
r.hdel('F2B_ACTIVE_BANS', '%s' % net)
|
||||
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
|
||||
if net in bans:
|
||||
logdebug("Unban for %s, setting attempts=0, ban_counter+=1" % net)
|
||||
bans[net]['attempts'] = 0
|
||||
bans[net]['ban_counter'] += 1
|
||||
|
||||
def permBan(net, unban=False):
|
||||
global f2boptions
|
||||
global lock
|
||||
|
||||
is_unbanned = False
|
||||
is_banned = False
|
||||
if type(ipaddress.ip_network(net, strict=False)) is ipaddress.IPv4Network:
|
||||
with lock:
|
||||
if unban:
|
||||
is_unbanned = tables.unbanIPv4(net)
|
||||
elif int(f2boptions['manage_external']) != 1:
|
||||
is_banned = tables.banIPv4(net)
|
||||
else:
|
||||
with lock:
|
||||
if unban:
|
||||
is_unbanned = tables.unbanIPv6(net)
|
||||
elif int(f2boptions['manage_external']) != 1:
|
||||
is_banned = tables.banIPv6(net)
|
||||
|
||||
|
||||
if is_unbanned:
|
||||
r.hdel('F2B_PERM_BANS', '%s' % net)
|
||||
logger.logCrit('Removed host/network %s from denylist' % net)
|
||||
elif is_banned:
|
||||
r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
|
||||
logger.logCrit('Added host/network %s to denylist' % net)
|
||||
|
||||
def clear():
|
||||
global lock
|
||||
logger.logInfo('Clearing all bans')
|
||||
for net in bans.copy():
|
||||
logdebug("Unbanning net: %s" % net)
|
||||
unban(net)
|
||||
with lock:
|
||||
logdebug("Clearing IPv4/IPv6 table")
|
||||
tables.clearIPv4Table()
|
||||
tables.clearIPv6Table()
|
||||
try:
|
||||
if r is not None:
|
||||
r.delete('F2B_ACTIVE_BANS')
|
||||
r.delete('F2B_PERM_BANS')
|
||||
# log lines are not always valid UTF-8
|
||||
r = redis.StrictRedis(host=host, port=port, db=0, password=os.environ['REDISPASS'], decode_responses=True,
|
||||
encoding_errors='replace', socket_connect_timeout=5, socket_timeout=30)
|
||||
r.ping()
|
||||
return r
|
||||
except Exception as ex:
|
||||
logger.logWarn('Error clearing redis keys F2B_ACTIVE_BANS and F2B_PERM_BANS: %s' % ex)
|
||||
|
||||
def watch():
|
||||
global pubsub
|
||||
global quit_now
|
||||
global exit_code
|
||||
|
||||
logger.logInfo('Watching Redis channel F2B_CHANNEL')
|
||||
pubsub.subscribe('F2B_CHANNEL')
|
||||
|
||||
while not quit_now:
|
||||
try:
|
||||
for item in pubsub.listen():
|
||||
refreshF2bregex()
|
||||
for rule_id, rule_regex in f2bregex.items():
|
||||
if item['data'] and item['type'] == 'message':
|
||||
try:
|
||||
result = re.search(rule_regex, item['data'])
|
||||
except re.error:
|
||||
result = False
|
||||
if result:
|
||||
addr = result.group(1)
|
||||
ip = ipaddress.ip_address(addr)
|
||||
if ip.is_private or ip.is_loopback:
|
||||
continue
|
||||
logger.logWarn('%s matched rule id %s (%s)' % (addr, rule_id, item['data']))
|
||||
ban(addr)
|
||||
except Exception as ex:
|
||||
logger.logWarn('Error reading log line from pubsub: %s' % ex)
|
||||
pubsub = None
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
def snat4(snat_target):
|
||||
global lock
|
||||
global quit_now
|
||||
|
||||
while not quit_now:
|
||||
time.sleep(10)
|
||||
with lock:
|
||||
tables.snat4(snat_target, os.getenv('IPV4_NETWORK', '172.22.1') + '.0/24')
|
||||
|
||||
def snat6(snat_target):
|
||||
global lock
|
||||
global quit_now
|
||||
|
||||
while not quit_now:
|
||||
time.sleep(10)
|
||||
with lock:
|
||||
tables.snat6(snat_target, os.getenv('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64'))
|
||||
|
||||
def autopurge():
|
||||
global f2boptions
|
||||
logdebug("autopurge thread started")
|
||||
while not quit_now:
|
||||
logdebug("autopurge tick")
|
||||
time.sleep(10)
|
||||
refreshF2boptions()
|
||||
MAX_ATTEMPTS = int(f2boptions['max_attempts'])
|
||||
QUEUE_UNBAN = r.hgetall('F2B_QUEUE_UNBAN')
|
||||
logdebug("QUEUE_UNBAN: %s" % QUEUE_UNBAN)
|
||||
if QUEUE_UNBAN:
|
||||
for net in QUEUE_UNBAN:
|
||||
logdebug("Autopurge: unbanning queued net: %s" % net)
|
||||
unban(str(net))
|
||||
# Only check expiry for actively banned IPs:
|
||||
active_bans = r.hgetall('F2B_ACTIVE_BANS')
|
||||
now = time.time()
|
||||
for net_str, expire_str in active_bans.items():
|
||||
logdebug("Checking ban expiry for (actively banned): %s" % net_str)
|
||||
# Defensive: always process if timer missing or expired
|
||||
try:
|
||||
expire = float(expire_str)
|
||||
except Exception:
|
||||
logdebug("Invalid expire time for %s; unbanning" % net_str)
|
||||
unban(net_str)
|
||||
continue
|
||||
time_left = expire - now
|
||||
logdebug("Time left for %s: %.1f seconds" % (net_str, time_left))
|
||||
if time_left <= 0:
|
||||
logdebug("Ban expired for %s" % net_str)
|
||||
unban(net_str)
|
||||
|
||||
def mailcowChainOrder():
|
||||
global lock
|
||||
global quit_now
|
||||
global exit_code
|
||||
while not quit_now:
|
||||
time.sleep(10)
|
||||
with lock:
|
||||
quit_now, exit_code = tables.checkIPv4ChainOrder()
|
||||
if quit_now: return
|
||||
quit_now, exit_code = tables.checkIPv6ChainOrder()
|
||||
|
||||
def calcNetBanTime(ban_counter):
|
||||
global f2boptions
|
||||
|
||||
BAN_TIME = int(f2boptions['ban_time'])
|
||||
MAX_BAN_TIME = int(f2boptions['max_ban_time'])
|
||||
BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment'])
|
||||
NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** ban_counter
|
||||
NET_BAN_TIME = max([BAN_TIME, min([NET_BAN_TIME, MAX_BAN_TIME])])
|
||||
return NET_BAN_TIME
|
||||
|
||||
def isIpNetwork(address):
|
||||
try:
|
||||
ipaddress.ip_network(address, False)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def genNetworkList(list):
|
||||
resolver = dns.resolver.Resolver()
|
||||
hostnames = []
|
||||
networks = []
|
||||
for key in list:
|
||||
if isIpNetwork(key):
|
||||
networks.append(key)
|
||||
else:
|
||||
hostnames.append(key)
|
||||
for hostname in hostnames:
|
||||
hostname_ips = []
|
||||
for rdtype in ['A', 'AAAA']:
|
||||
try:
|
||||
answer = resolver.resolve(qname=hostname, rdtype=rdtype, lifetime=3)
|
||||
except dns.exception.Timeout:
|
||||
logger.logInfo('Hostname %s timedout on resolve' % hostname)
|
||||
break
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
continue
|
||||
except dns.exception.DNSException as dnsexception:
|
||||
logger.logInfo('%s' % dnsexception)
|
||||
continue
|
||||
for rdata in answer:
|
||||
hostname_ips.append(rdata.to_text())
|
||||
networks.extend(hostname_ips)
|
||||
return set(networks)
|
||||
|
||||
def whitelistUpdate():
|
||||
global lock
|
||||
global quit_now
|
||||
global WHITELIST
|
||||
while not quit_now:
|
||||
start_time = time.time()
|
||||
list = r.hgetall('F2B_WHITELIST')
|
||||
new_whitelist = []
|
||||
if list:
|
||||
new_whitelist = genNetworkList(list)
|
||||
with lock:
|
||||
if Counter(new_whitelist) != Counter(WHITELIST):
|
||||
WHITELIST = new_whitelist
|
||||
logger.logInfo('Allowlist was changed, it has %s entries' % len(WHITELIST))
|
||||
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
|
||||
|
||||
def blacklistUpdate():
|
||||
global quit_now
|
||||
global BLACKLIST
|
||||
while not quit_now:
|
||||
start_time = time.time()
|
||||
list = r.hgetall('F2B_BLACKLIST')
|
||||
new_blacklist = []
|
||||
if list:
|
||||
new_blacklist = genNetworkList(list)
|
||||
if Counter(new_blacklist) != Counter(BLACKLIST):
|
||||
addban = set(new_blacklist).difference(BLACKLIST)
|
||||
delban = set(BLACKLIST).difference(new_blacklist)
|
||||
BLACKLIST = new_blacklist
|
||||
logger.logInfo('Denylist was changed, it has %s entries' % len(BLACKLIST))
|
||||
if addban:
|
||||
for net in addban:
|
||||
permBan(net=net)
|
||||
if delban:
|
||||
for net in delban:
|
||||
permBan(net=net, unban=True)
|
||||
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
|
||||
|
||||
def sigterm_quit(signum, frame):
|
||||
global clear_before_quit
|
||||
logdebug("SIGTERM received, setting clear_before_quit to True and exiting")
|
||||
clear_before_quit = True
|
||||
sys.exit(exit_code)
|
||||
|
||||
def before_quit():
|
||||
logdebug("before_quit called, clear_before_quit=%s" % clear_before_quit)
|
||||
if clear_before_quit:
|
||||
clear()
|
||||
if pubsub is not None:
|
||||
pubsub.unsubscribe()
|
||||
logger.logWarn('Redis connection failed: %s - trying again in 3 seconds' % ex)
|
||||
time.sleep(3)
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger = Logger()
|
||||
logdebug("Sys.argv: %s" % sys.argv)
|
||||
atexit.register(before_quit)
|
||||
signal.signal(signal.SIGTERM, sigterm_quit)
|
||||
|
||||
backend = sys.argv[1]
|
||||
logdebug("Backend: %s" % backend)
|
||||
if backend == "nftables":
|
||||
from modules.NFTables import NFTables
|
||||
logger.logInfo('Using NFTables backend')
|
||||
tables = NFTables(chain_name, logger)
|
||||
else:
|
||||
from modules.IPTables import IPTables
|
||||
logger.logInfo('Using IPTables backend')
|
||||
logger.logWarn(
|
||||
"DEPRECATION: iptables-legacy is deprecated and will be removed in future releases. "
|
||||
@@ -456,93 +44,14 @@ if __name__ == '__main__':
|
||||
time.sleep(5)
|
||||
tables = IPTables(chain_name, logger)
|
||||
|
||||
clear()
|
||||
logger.logInfo("Initializing mailcow netfilter chain")
|
||||
tables.initChainIPv4()
|
||||
tables.initChainIPv6()
|
||||
|
||||
if os.getenv("DISABLE_NETFILTER_ISOLATION_RULE", "").lower() in ("y", "yes"):
|
||||
logger.logInfo(f"Skipping {chain_name} isolation")
|
||||
else:
|
||||
logger.logInfo(f"Setting {chain_name} isolation")
|
||||
tables.create_mailcow_isolation_rule("br-mailcow", [3306, 6379, 8983, 12345], os.getenv("MAILCOW_REPLICA_IP"))
|
||||
|
||||
# connect to redis
|
||||
while True:
|
||||
try:
|
||||
redis_slaveof_ip = os.getenv('REDIS_SLAVEOF_IP', '')
|
||||
redis_slaveof_port = os.getenv('REDIS_SLAVEOF_PORT', '')
|
||||
logdebug(
|
||||
"Connecting redis (SLAVEOF_IP:%s, PORT:%s)" % (redis_slaveof_ip, redis_slaveof_port))
|
||||
if "".__eq__(redis_slaveof_ip):
|
||||
r = redis.StrictRedis(
|
||||
host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0, password=os.environ['REDISPASS'])
|
||||
else:
|
||||
r = redis.StrictRedis(
|
||||
host=redis_slaveof_ip, decode_responses=True, port=redis_slaveof_port, db=0, password=os.environ['REDISPASS'])
|
||||
r.ping()
|
||||
pubsub = r.pubsub()
|
||||
except Exception as ex:
|
||||
logdebug(
|
||||
'Redis connection failed: %s - trying again in 3 seconds' % (ex))
|
||||
time.sleep(3)
|
||||
else:
|
||||
break
|
||||
r = connect_redis(logger)
|
||||
logger.set_redis(r)
|
||||
logdebug("Redis connection established, setting up F2B keys")
|
||||
|
||||
if r.exists('F2B_LOG'):
|
||||
logdebug("Renaming F2B_LOG to NETFILTER_LOG")
|
||||
r.rename('F2B_LOG', 'NETFILTER_LOG')
|
||||
r.delete('F2B_ACTIVE_BANS')
|
||||
r.delete('F2B_PERM_BANS')
|
||||
netfilter = Netfilter(r, tables, logger, os.environ)
|
||||
signal.signal(signal.SIGTERM, lambda signum, frame: netfilter.stop.set())
|
||||
signal.signal(signal.SIGINT, lambda signum, frame: netfilter.stop.set())
|
||||
|
||||
refreshF2boptions()
|
||||
|
||||
watch_thread = Thread(target=watch)
|
||||
watch_thread.daemon = True
|
||||
watch_thread.start()
|
||||
|
||||
if os.getenv('SNAT_TO_SOURCE') and os.getenv('SNAT_TO_SOURCE') != 'n':
|
||||
try:
|
||||
snat_ip = os.getenv('SNAT_TO_SOURCE')
|
||||
snat_ipo = ipaddress.ip_address(snat_ip)
|
||||
if type(snat_ipo) is ipaddress.IPv4Address:
|
||||
snat4_thread = Thread(target=snat4, args=(snat_ip,))
|
||||
snat4_thread.daemon = True
|
||||
snat4_thread.start()
|
||||
except ValueError:
|
||||
print(os.getenv('SNAT_TO_SOURCE') + ' is not a valid IPv4 address')
|
||||
|
||||
if os.getenv('SNAT6_TO_SOURCE') and os.getenv('SNAT6_TO_SOURCE') != 'n':
|
||||
try:
|
||||
snat_ip = os.getenv('SNAT6_TO_SOURCE')
|
||||
snat_ipo = ipaddress.ip_address(snat_ip)
|
||||
if type(snat_ipo) is ipaddress.IPv6Address:
|
||||
snat6_thread = Thread(target=snat6,args=(snat_ip,))
|
||||
snat6_thread.daemon = True
|
||||
snat6_thread.start()
|
||||
except ValueError:
|
||||
print(os.getenv('SNAT6_TO_SOURCE') + ' is not a valid IPv6 address')
|
||||
|
||||
autopurge_thread = Thread(target=autopurge)
|
||||
autopurge_thread.daemon = True
|
||||
autopurge_thread.start()
|
||||
|
||||
mailcowchainwatch_thread = Thread(target=mailcowChainOrder)
|
||||
mailcowchainwatch_thread.daemon = True
|
||||
mailcowchainwatch_thread.start()
|
||||
|
||||
blacklistupdate_thread = Thread(target=blacklistUpdate)
|
||||
blacklistupdate_thread.daemon = True
|
||||
blacklistupdate_thread.start()
|
||||
|
||||
whitelistupdate_thread = Thread(target=whitelistUpdate)
|
||||
whitelistupdate_thread.daemon = True
|
||||
whitelistupdate_thread.start()
|
||||
|
||||
while not quit_now:
|
||||
time.sleep(0.5)
|
||||
|
||||
logdebug("Exiting with code %s" % exit_code)
|
||||
sys.exit(exit_code)
|
||||
netfilter.setup()
|
||||
netfilter.run()
|
||||
netfilter.shutdown()
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,236 @@
|
||||
import ipaddress
|
||||
import time
|
||||
|
||||
|
||||
class BanManager:
|
||||
"""Failed attempts and automatic bans (Redis F2B_ACTIVE_BANS: network => expiry), the denylist (F2B_PERM_BANS)
|
||||
and the allowlist. Every firewall change runs under lock, which also guards the other firewall work of netfilter
|
||||
(chains, SNAT), because the backends must not run in parallel."""
|
||||
|
||||
# Seconds failed logins of an address are not counted after the auth backend was unavailable for it
|
||||
EXCUSE_TIME = 60
|
||||
|
||||
def __init__(self, r, tables, options, logger, lock, clock=time.time):
|
||||
self.r = r
|
||||
self.tables = tables
|
||||
self.options = options
|
||||
self.logger = logger
|
||||
self.lock = lock
|
||||
self.clock = clock
|
||||
self.counters = {}
|
||||
self.banned = set()
|
||||
self.denied = {}
|
||||
self.allowlist = []
|
||||
self.excused = {}
|
||||
|
||||
def excuse(self, ip):
|
||||
"""The auth backend could not check a login of ip: its failed logins are not counted for EXCUSE_TIME"""
|
||||
with self.lock:
|
||||
now = self.clock()
|
||||
if self.excused.get(ip, 0) <= now:
|
||||
self.logger.logWarn('Auth backend unavailable for %s, its failed logins of the next %d seconds are not counted' % (
|
||||
ip, self.EXCUSE_TIME))
|
||||
self.excused[ip] = now + self.EXCUSE_TIME
|
||||
|
||||
def attempt(self, ip):
|
||||
"""Counts a failed attempt of ip and bans its network after max_attempts within retry_window"""
|
||||
options = self.options.values
|
||||
prefix = options['netban_ipv4'] if ip.version == 4 else options['netban_ipv6']
|
||||
net = str(ipaddress.ip_network('%s/%d' % (ip, prefix), strict=False))
|
||||
with self.lock:
|
||||
now = self.clock()
|
||||
allowed = self.allowed_by(ip)
|
||||
if allowed is not None:
|
||||
self.logger.logInfo('Address %s is allowlisted by rule %s' % (ip, allowed))
|
||||
return
|
||||
if self.excused.get(ip, 0) > now:
|
||||
self.logger.logInfo('Not counting the failed login of %s, the auth backend was unavailable' % ip)
|
||||
return
|
||||
if net in self.banned:
|
||||
# log lines of connections from before the ban
|
||||
return
|
||||
counter = self.counter(net)
|
||||
if now - counter['last_attempt'] > options['retry_window']:
|
||||
counter['attempts'] = 0
|
||||
counter['attempts'] += 1
|
||||
counter['last_attempt'] = now
|
||||
if counter['attempts'] < options['max_attempts']:
|
||||
self.logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (
|
||||
options['max_attempts'] - counter['attempts'], options['retry_window'], net))
|
||||
return
|
||||
|
||||
ban_time = self.options.ban_time(counter['ban_counter'])
|
||||
self.logger.logCrit('Banning %s for %d minutes' % (net, ban_time / 60))
|
||||
# Redis first: a firewall rule without expiry would never be lifted
|
||||
self.r.hset('F2B_ACTIVE_BANS', net, int(round(now + ban_time)))
|
||||
self.banned.add(net)
|
||||
if options['manage_external'] != 1:
|
||||
self.apply([net])
|
||||
|
||||
def unban(self, net):
|
||||
"""Lifts the automatic ban of a network; the rule stays while the denylist bans the same network"""
|
||||
with self.lock:
|
||||
normalized = self.normalize(net)
|
||||
if normalized is None:
|
||||
self.logger.logWarn('Removing invalid network %s from the active bans' % net)
|
||||
else:
|
||||
self.logger.logInfo('Unbanning %s' % net)
|
||||
if normalized not in self.denied:
|
||||
self.remove([normalized])
|
||||
self.r.hdel('F2B_ACTIVE_BANS', net)
|
||||
self.r.hdel('F2B_QUEUE_UNBAN', net)
|
||||
if net in self.banned:
|
||||
self.banned.discard(net)
|
||||
counter = self.counter(net)
|
||||
counter['attempts'] = 0
|
||||
counter['ban_counter'] += 1
|
||||
|
||||
def unban_queued(self, entry):
|
||||
"""Unban request of the UI: a banned network, or a network that contains banned ones (allowlisting)"""
|
||||
network = self.network(entry)
|
||||
if network is None:
|
||||
self.logger.logWarn('Removing invalid network %s from the unban queue' % entry)
|
||||
self.r.hdel('F2B_QUEUE_UNBAN', entry)
|
||||
return
|
||||
with self.lock:
|
||||
banned = set(self.r.hkeys('F2B_ACTIVE_BANS')) | self.banned
|
||||
targets = [net for net in banned if self.overlaps(net, network)]
|
||||
for net in targets:
|
||||
self.unban(net)
|
||||
if not targets and str(network) not in self.denied:
|
||||
# a rule without known ban, e.g. left by an older netfilter
|
||||
if self.remove([str(network)]):
|
||||
self.logger.logInfo('Unbanning %s' % network)
|
||||
else:
|
||||
self.logger.logInfo('%s is not banned, removing it from the unban queue' % network)
|
||||
self.r.hdel('F2B_QUEUE_UNBAN', entry)
|
||||
|
||||
def purge(self):
|
||||
"""Processes the unban queue of the UI and lifts expired bans"""
|
||||
for entry in self.r.hkeys('F2B_QUEUE_UNBAN'):
|
||||
self.unban_queued(entry)
|
||||
now = self.clock()
|
||||
for net, expires in self.r.hgetall('F2B_ACTIVE_BANS').items():
|
||||
try:
|
||||
expired = self.network(net) is None or float(expires) <= now
|
||||
except (TypeError, ValueError):
|
||||
expired = True
|
||||
if expired:
|
||||
self.unban(net)
|
||||
with self.lock:
|
||||
# bans whose Redis entry is gone, e.g. deleted by hand
|
||||
for net in self.banned - set(self.r.hkeys('F2B_ACTIVE_BANS')):
|
||||
self.unban(net)
|
||||
self.forget_idle(now)
|
||||
|
||||
def restore(self):
|
||||
"""Applies the unexpired automatic bans of Redis again after a start of netfilter and drops the expired ones"""
|
||||
now = self.clock()
|
||||
restored = []
|
||||
with self.lock:
|
||||
for net, expires in self.r.hgetall('F2B_ACTIVE_BANS').items():
|
||||
try:
|
||||
valid = self.network(net) is not None and float(expires) > now
|
||||
except (TypeError, ValueError):
|
||||
valid = False
|
||||
if not valid:
|
||||
self.r.hdel('F2B_ACTIVE_BANS', net)
|
||||
continue
|
||||
self.banned.add(net)
|
||||
self.counter(net)
|
||||
restored.append(net)
|
||||
if restored and self.options.values['manage_external'] != 1:
|
||||
self.apply(restored)
|
||||
if restored:
|
||||
self.logger.logInfo('Restored %d active bans' % len(restored))
|
||||
|
||||
def reapply(self):
|
||||
"""Applies all bans again, e.g. after the MAILCOW chain had to be recreated"""
|
||||
with self.lock:
|
||||
if self.options.values['manage_external'] != 1:
|
||||
self.apply(list(self.banned) + list(self.denied))
|
||||
|
||||
def set_denylist(self, entries):
|
||||
"""Bans the networks of the denylist permanently and lifts the bans of removed entries"""
|
||||
networks = {}
|
||||
for entry in entries:
|
||||
net = self.normalize(entry)
|
||||
if net is None:
|
||||
self.logger.logWarn('Skipping invalid denylist entry %s' % entry)
|
||||
continue
|
||||
networks[net] = entry
|
||||
with self.lock:
|
||||
if networks.keys() == self.denied.keys():
|
||||
return
|
||||
self.logger.logInfo('Denylist was changed, it has %d entries' % len(networks))
|
||||
added = [net for net in networks if net not in self.denied]
|
||||
removed = [net for net in self.denied if net not in networks]
|
||||
if added and self.options.values['manage_external'] != 1:
|
||||
self.apply(added)
|
||||
for net in added:
|
||||
self.r.hset('F2B_PERM_BANS', networks[net], int(round(self.clock())))
|
||||
self.logger.logCrit('Added host/network %s to denylist' % networks[net])
|
||||
# an automatic ban of the same network keeps its rule
|
||||
auto_banned = {self.normalize(net) for net in self.banned}
|
||||
self.remove([net for net in removed if net not in auto_banned])
|
||||
for net in removed:
|
||||
self.r.hdel('F2B_PERM_BANS', self.denied[net])
|
||||
self.logger.logCrit('Removed host/network %s from denylist' % self.denied[net])
|
||||
self.denied = networks
|
||||
|
||||
def set_allowlist(self, entries):
|
||||
networks = []
|
||||
for entry in entries:
|
||||
network = self.network(entry)
|
||||
if network is None:
|
||||
self.logger.logWarn('Skipping invalid allowlist entry %s' % entry)
|
||||
continue
|
||||
networks.append(network)
|
||||
with self.lock:
|
||||
if set(networks) != set(self.allowlist):
|
||||
self.allowlist = networks
|
||||
self.logger.logInfo('Allowlist was changed, it has %d entries' % len(networks))
|
||||
|
||||
def allowed_by(self, ip):
|
||||
address = ipaddress.ip_network(ip)
|
||||
for network in self.allowlist:
|
||||
if network.version == address.version and network.overlaps(address):
|
||||
return network
|
||||
return None
|
||||
|
||||
def counter(self, net):
|
||||
return self.counters.setdefault(net, {'attempts': 0, 'last_attempt': 0.0, 'ban_counter': 0})
|
||||
|
||||
def forget_idle(self, now):
|
||||
"""Drops counters of networks that were never banned and had no attempt within the retry window"""
|
||||
window = self.options.values['retry_window']
|
||||
with self.lock:
|
||||
for net in [net for net, counter in self.counters.items()
|
||||
if net not in self.banned and counter['ban_counter'] == 0 and now - counter['last_attempt'] > window]:
|
||||
del self.counters[net]
|
||||
for ip in [ip for ip, until in self.excused.items() if until <= now]:
|
||||
del self.excused[ip]
|
||||
|
||||
def apply(self, nets):
|
||||
for net in self.tables.ban(nets):
|
||||
self.logger.logWarn('Could not add the firewall rule for %s' % net)
|
||||
|
||||
def remove(self, nets):
|
||||
return self.tables.unban(nets) if nets else []
|
||||
|
||||
@staticmethod
|
||||
def network(entry):
|
||||
try:
|
||||
return ipaddress.ip_network(entry, strict=False)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def normalize(cls, entry):
|
||||
network = cls.network(entry)
|
||||
return str(network) if network is not None else None
|
||||
|
||||
@classmethod
|
||||
def overlaps(cls, net, network):
|
||||
other = cls.network(net)
|
||||
return other is not None and other.version == network.version and other.overlaps(network)
|
||||
@@ -1,6 +1,7 @@
|
||||
import iptc
|
||||
import time
|
||||
import os
|
||||
import ipaddress
|
||||
import contextlib
|
||||
|
||||
class IPTables:
|
||||
def __init__(self, chain_name, logger):
|
||||
@@ -8,62 +9,10 @@ class IPTables:
|
||||
self.logger = logger
|
||||
|
||||
def initChainIPv4(self):
|
||||
if not iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name) in iptc.Table(iptc.Table.FILTER).chains:
|
||||
iptc.Table(iptc.Table.FILTER).create_chain(self.chain_name)
|
||||
for c in ['FORWARD', 'INPUT']:
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), c)
|
||||
rule = iptc.Rule()
|
||||
rule.src = '0.0.0.0/0'
|
||||
rule.dst = '0.0.0.0/0'
|
||||
target = iptc.Target(rule, self.chain_name)
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
chain.insert_rule(rule)
|
||||
self.init_chain(iptc.Table(iptc.Table.FILTER))
|
||||
|
||||
def initChainIPv6(self):
|
||||
if not iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name) in iptc.Table6(iptc.Table6.FILTER).chains:
|
||||
iptc.Table6(iptc.Table6.FILTER).create_chain(self.chain_name)
|
||||
for c in ['FORWARD', 'INPUT']:
|
||||
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), c)
|
||||
rule = iptc.Rule6()
|
||||
rule.src = '::/0'
|
||||
rule.dst = '::/0'
|
||||
target = iptc.Target(rule, self.chain_name)
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
chain.insert_rule(rule)
|
||||
|
||||
def checkIPv4ChainOrder(self):
|
||||
filter_table = iptc.Table(iptc.Table.FILTER)
|
||||
filter_table.refresh()
|
||||
return self.checkChainOrder(filter_table)
|
||||
|
||||
def checkIPv6ChainOrder(self):
|
||||
filter_table = iptc.Table6(iptc.Table6.FILTER)
|
||||
filter_table.refresh()
|
||||
return self.checkChainOrder(filter_table)
|
||||
|
||||
def checkChainOrder(self, filter_table):
|
||||
err = False
|
||||
exit_code = None
|
||||
|
||||
forward_chain = iptc.Chain(filter_table, 'FORWARD')
|
||||
input_chain = iptc.Chain(filter_table, 'INPUT')
|
||||
for chain in [forward_chain, input_chain]:
|
||||
target_found = False
|
||||
for position, item in enumerate(chain.rules):
|
||||
if item.target.name == self.chain_name:
|
||||
target_found = True
|
||||
if position > 2:
|
||||
self.logger.logCrit('Error in %s chain: %s target not found, restarting container' % (chain.name, self.chain_name))
|
||||
err = True
|
||||
exit_code = 2
|
||||
if not target_found:
|
||||
self.logger.logCrit('Error in %s chain: %s target not found, restarting container' % (chain.name, self.chain_name))
|
||||
err = True
|
||||
exit_code = 2
|
||||
|
||||
return err, exit_code
|
||||
self.init_chain(iptc.Table6(iptc.Table6.FILTER))
|
||||
|
||||
def clearIPv4Table(self):
|
||||
self.clearTable(iptc.Table(iptc.Table.FILTER))
|
||||
@@ -71,68 +20,133 @@ class IPTables:
|
||||
def clearIPv6Table(self):
|
||||
self.clearTable(iptc.Table6(iptc.Table6.FILTER))
|
||||
|
||||
def clearTable(self, filter_table):
|
||||
filter_table.autocommit = False
|
||||
forward_chain = iptc.Chain(filter_table, "FORWARD")
|
||||
input_chain = iptc.Chain(filter_table, "INPUT")
|
||||
mailcow_chain = iptc.Chain(filter_table, self.chain_name)
|
||||
if mailcow_chain in filter_table.chains:
|
||||
for rule in mailcow_chain.rules:
|
||||
mailcow_chain.delete_rule(rule)
|
||||
for rule in forward_chain.rules:
|
||||
if rule.target.name == self.chain_name:
|
||||
forward_chain.delete_rule(rule)
|
||||
for rule in input_chain.rules:
|
||||
if rule.target.name == self.chain_name:
|
||||
input_chain.delete_rule(rule)
|
||||
filter_table.delete_chain(self.chain_name)
|
||||
filter_table.commit()
|
||||
@contextlib.contextmanager
|
||||
def transaction(self, table):
|
||||
# One commit for all changes, autocommit rewrites the whole table for every change
|
||||
table.autocommit = False
|
||||
table.refresh()
|
||||
try:
|
||||
yield
|
||||
table.commit()
|
||||
finally:
|
||||
table.refresh()
|
||||
table.autocommit = True
|
||||
|
||||
def new_rule(self, table):
|
||||
return iptc.Rule6() if isinstance(table, iptc.Table6) else iptc.Rule()
|
||||
|
||||
def family(self, table):
|
||||
return 'IPv6' if isinstance(table, iptc.Table6) else 'IPv4'
|
||||
|
||||
def init_chain(self, filter_table):
|
||||
# Empty MAILCOW chain with one jump at the top of the input and forward chains
|
||||
filter_table.refresh()
|
||||
filter_table.autocommit = True
|
||||
if filter_table.is_chain(self.chain_name):
|
||||
filter_table.flush_entries(self.chain_name)
|
||||
else:
|
||||
filter_table.create_chain(self.chain_name)
|
||||
self.ensure_jumps(filter_table)
|
||||
|
||||
def banIPv4(self, source):
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name)
|
||||
rule = iptc.Rule()
|
||||
rule.src = source
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule in chain.rules:
|
||||
return False
|
||||
chain.insert_rule(rule)
|
||||
return True
|
||||
def ensure_chains(self):
|
||||
# Recreates missing MAILCOW chains and moves the jumps back to the top, True if a chain was recreated
|
||||
recreated = False
|
||||
for filter_table in [iptc.Table(iptc.Table.FILTER), iptc.Table6(iptc.Table6.FILTER)]:
|
||||
filter_table.refresh()
|
||||
if not filter_table.is_chain(self.chain_name):
|
||||
filter_table.create_chain(self.chain_name)
|
||||
self.logger.logWarn(f'MAILCOW {self.family(filter_table)} chain was missing and has been recreated')
|
||||
recreated = True
|
||||
self.ensure_jumps(filter_table)
|
||||
return recreated
|
||||
|
||||
def banIPv6(self, source):
|
||||
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name)
|
||||
rule = iptc.Rule6()
|
||||
rule.src = source
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule in chain.rules:
|
||||
return False
|
||||
chain.insert_rule(rule)
|
||||
return True
|
||||
def ensure_jumps(self, filter_table):
|
||||
# Exactly one jump to MAILCOW as first rule: docker inserts its jump (DOCKER-USER) at the top
|
||||
for name in ['INPUT', 'FORWARD']:
|
||||
chain = iptc.Chain(filter_table, name)
|
||||
positions = [position for position, rule in enumerate(chain.rules) if rule.target.name == self.chain_name]
|
||||
if positions == [0]: continue
|
||||
|
||||
def unbanIPv4(self, source):
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name)
|
||||
rule = iptc.Rule()
|
||||
rule.src = source
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
return False
|
||||
chain.delete_rule(rule)
|
||||
return True
|
||||
with self.transaction(filter_table):
|
||||
for rule in [rule for rule in chain.rules if rule.target.name == self.chain_name]:
|
||||
chain.delete_rule(rule)
|
||||
rule = self.new_rule(filter_table)
|
||||
rule.target = iptc.Target(rule, self.chain_name)
|
||||
chain.insert_rule(rule)
|
||||
if positions:
|
||||
self.logger.logInfo(f'Moved MAILCOW jump to the top of the {self.family(filter_table)} {name} chain, it was at position {positions[0] + 1}')
|
||||
else:
|
||||
self.logger.logInfo(f'Added MAILCOW jump to the {self.family(filter_table)} {name} chain')
|
||||
|
||||
def unbanIPv6(self, source):
|
||||
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name)
|
||||
rule = iptc.Rule6()
|
||||
rule.src = source
|
||||
target = iptc.Target(rule, "REJECT")
|
||||
rule.target = target
|
||||
if rule not in chain.rules:
|
||||
return False
|
||||
chain.delete_rule(rule)
|
||||
return True
|
||||
def clearTable(self, filter_table):
|
||||
try:
|
||||
with self.transaction(filter_table):
|
||||
if not filter_table.is_chain(self.chain_name):
|
||||
return
|
||||
for chain in filter_table.chains:
|
||||
for rule in chain.rules:
|
||||
if rule.target.name == self.chain_name:
|
||||
chain.delete_rule(rule)
|
||||
filter_table.flush_entries(self.chain_name)
|
||||
filter_table.delete_chain(self.chain_name)
|
||||
self.logger.logInfo(f'Clear completed: {self.family(filter_table)}')
|
||||
except Exception as ex:
|
||||
self.logger.logWarn(f'Error clearing the {self.family(filter_table)} MAILCOW chain: {ex}')
|
||||
|
||||
def ban_rule_network(self, rule):
|
||||
# Source network of a ban rule, None for every other rule
|
||||
if rule.target.name not in ('REJECT', 'DROP') or rule.matches or rule.in_interface or rule.out_interface:
|
||||
return None
|
||||
try:
|
||||
return ipaddress.ip_network(rule.src, strict=False)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def split_tables(self, sources):
|
||||
networks = {4: [], 6: []}
|
||||
for source in sources:
|
||||
network = ipaddress.ip_network(source, strict=False)
|
||||
networks[network.version].append(network)
|
||||
tables = [(iptc.Table(iptc.Table.FILTER), networks[4]), (iptc.Table6(iptc.Table6.FILTER), networks[6])]
|
||||
return [(table, table_networks) for table, table_networks in tables if table_networks]
|
||||
|
||||
def ban(self, sources):
|
||||
# One REJECT rule per network, existing rules are kept. Returns the networks that could not be banned
|
||||
failed = []
|
||||
for filter_table, networks in self.split_tables(sources):
|
||||
try:
|
||||
with self.transaction(filter_table):
|
||||
chain = iptc.Chain(filter_table, self.chain_name)
|
||||
existing = {self.ban_rule_network(rule) for rule in chain.rules}
|
||||
for network in dict.fromkeys(networks):
|
||||
if network in existing: continue
|
||||
rule = self.new_rule(filter_table)
|
||||
rule.src = str(network)
|
||||
rule.target = iptc.Target(rule, "REJECT")
|
||||
chain.insert_rule(rule)
|
||||
except Exception as ex:
|
||||
self.logger.logWarn(f'iptables: {ex}')
|
||||
failed += networks
|
||||
return [str(network) for network in failed]
|
||||
|
||||
def unban(self, sources):
|
||||
# Removes every rule of the networks, also duplicates of older versions. Returns the unbanned networks
|
||||
removed = []
|
||||
for filter_table, networks in self.split_tables(sources):
|
||||
targets = set(networks)
|
||||
try:
|
||||
with self.transaction(filter_table):
|
||||
chain = iptc.Chain(filter_table, self.chain_name)
|
||||
found = set()
|
||||
for rule in chain.rules:
|
||||
network = self.ban_rule_network(rule)
|
||||
if network in targets:
|
||||
chain.delete_rule(rule)
|
||||
found.add(network)
|
||||
except Exception as ex:
|
||||
self.logger.logWarn(f'iptables: {ex}')
|
||||
continue
|
||||
removed += found
|
||||
return [str(network) for network in removed]
|
||||
|
||||
def snat4(self, snat_target, source):
|
||||
try:
|
||||
@@ -215,7 +229,9 @@ class IPTables:
|
||||
|
||||
def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""):
|
||||
try:
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name)
|
||||
table = iptc.Table(iptc.Table.FILTER)
|
||||
table.refresh()
|
||||
chain = iptc.Chain(table, self.chain_name)
|
||||
|
||||
# insert mailcow isolation rule
|
||||
rule = iptc.Rule()
|
||||
@@ -231,7 +247,7 @@ class IPTables:
|
||||
chain.insert_rule(rule, position=0)
|
||||
|
||||
# insert mailcow isolation exception rule
|
||||
if _allow != "":
|
||||
if _allow:
|
||||
rule = iptc.Rule()
|
||||
rule.src = _allow
|
||||
rule.in_interface = f'!{_interface}'
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import nftables
|
||||
import ipaddress
|
||||
import os
|
||||
|
||||
class NFTables:
|
||||
ISOLATION_COMMENTS = ('mailcow isolation', 'mailcow isolation allow')
|
||||
|
||||
def __init__(self, chain_name, logger):
|
||||
self.chain_name = chain_name
|
||||
self.logger = logger
|
||||
@@ -12,41 +13,15 @@ class NFTables:
|
||||
self.nft.set_handle_output(True)
|
||||
self.nft_chain_names = {'ip': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} },
|
||||
'ip6': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} } }
|
||||
self.mailcow_chains = set()
|
||||
|
||||
self.search_current_chains()
|
||||
|
||||
def initChainIPv4(self):
|
||||
self.insert_mailcow_chains("ip")
|
||||
self.init_chain("ip")
|
||||
|
||||
def initChainIPv6(self):
|
||||
self.insert_mailcow_chains("ip6")
|
||||
|
||||
def checkIPv4ChainOrder(self):
|
||||
return self.checkChainOrder("ip")
|
||||
|
||||
def checkIPv6ChainOrder(self):
|
||||
return self.checkChainOrder("ip6")
|
||||
|
||||
def checkChainOrder(self, filter_table):
|
||||
err = False
|
||||
exit_code = None
|
||||
|
||||
for chain in ['input', 'forward']:
|
||||
chain_position = self.check_mailcow_chains(filter_table, chain)
|
||||
if chain_position is None: continue
|
||||
|
||||
if chain_position is False:
|
||||
self.logger.logCrit(f'MAILCOW target not found in {filter_table} {chain} table, restarting container to fix it...')
|
||||
err = True
|
||||
exit_code = 2
|
||||
|
||||
if chain_position > 0:
|
||||
chain_position += 1
|
||||
self.logger.logCrit(f'MAILCOW target is in position {chain_position} in the {filter_table} {chain} table, restarting container to fix it...')
|
||||
err = True
|
||||
exit_code = 2
|
||||
|
||||
return err, exit_code
|
||||
self.init_chain("ip6")
|
||||
|
||||
def clearIPv4Table(self):
|
||||
self.clearTable("ip")
|
||||
@@ -54,84 +29,19 @@ class NFTables:
|
||||
def clearIPv6Table(self):
|
||||
self.clearTable("ip6")
|
||||
|
||||
def clearTable(self, _family):
|
||||
is_empty_dict = True
|
||||
json_command = self.get_base_dict()
|
||||
chain_handle = self.get_chain_handle(_family, "filter", self.chain_name)
|
||||
# if no handle, the chain doesn't exists
|
||||
if chain_handle is not None:
|
||||
is_empty_dict = False
|
||||
# flush chain
|
||||
mailcow_chain = {'family': _family, 'table': 'filter', 'name': self.chain_name}
|
||||
flush_chain = {'flush': {'chain': mailcow_chain}}
|
||||
json_command["nftables"].append(flush_chain)
|
||||
|
||||
# remove rule in forward chain
|
||||
# remove rule in input chain
|
||||
chains_family = [self.nft_chain_names[_family]['filter']['input'],
|
||||
self.nft_chain_names[_family]['filter']['forward'] ]
|
||||
|
||||
for chain_base in chains_family:
|
||||
if not chain_base: continue
|
||||
|
||||
rules_handle = self.get_rules_handle(_family, "filter", chain_base)
|
||||
if rules_handle is not None:
|
||||
for r_handle in rules_handle:
|
||||
is_empty_dict = False
|
||||
mailcow_rule = {'family':_family,
|
||||
'table': 'filter',
|
||||
'chain': chain_base,
|
||||
'handle': r_handle }
|
||||
delete_rules = {'delete': {'rule': mailcow_rule} }
|
||||
json_command["nftables"].append(delete_rules)
|
||||
|
||||
# remove chain
|
||||
# after delete all rules referencing this chain
|
||||
if chain_handle is not None:
|
||||
mc_chain_handle = {'family':_family,
|
||||
'table': 'filter',
|
||||
'name': self.chain_name,
|
||||
'handle': chain_handle }
|
||||
delete_chain = {'delete': {'chain': mc_chain_handle} }
|
||||
json_command["nftables"].append(delete_chain)
|
||||
|
||||
if is_empty_dict == False:
|
||||
if self.nft_exec_dict(json_command):
|
||||
self.logger.logInfo(f"Clear completed: {_family}")
|
||||
|
||||
def banIPv4(self, source):
|
||||
ban_dict = self.get_ban_ip_dict(source, "ip")
|
||||
return self.nft_exec_dict(ban_dict)
|
||||
|
||||
def banIPv6(self, source):
|
||||
ban_dict = self.get_ban_ip_dict(source, "ip6")
|
||||
return self.nft_exec_dict(ban_dict)
|
||||
|
||||
def unbanIPv4(self, source):
|
||||
unban_dict = self.get_unban_ip_dict(source, "ip")
|
||||
if not unban_dict:
|
||||
return False
|
||||
return self.nft_exec_dict(unban_dict)
|
||||
|
||||
def unbanIPv6(self, source):
|
||||
unban_dict = self.get_unban_ip_dict(source, "ip6")
|
||||
if not unban_dict:
|
||||
return False
|
||||
return self.nft_exec_dict(unban_dict)
|
||||
|
||||
def snat4(self, snat_target, source):
|
||||
self.snat_rule("ip", snat_target, source)
|
||||
|
||||
def snat6(self, snat_target, source):
|
||||
self.snat_rule("ip6", snat_target, source)
|
||||
|
||||
|
||||
def nft_exec_dict(self, query: dict):
|
||||
def nft_exec_dict(self, query: dict, quiet: bool = False):
|
||||
if not query: return False
|
||||
|
||||
rc, output, error = self.nft.json_cmd(query)
|
||||
if rc != 0:
|
||||
#self.logger.logCrit(f"Nftables Error: {error}")
|
||||
if not quiet:
|
||||
self.logger.logWarn(f"nftables: {str(error).strip() or f'error {rc}'}")
|
||||
return False
|
||||
|
||||
# Prevent returning False or empty string on commands that do not produce output
|
||||
@@ -144,49 +54,61 @@ class NFTables:
|
||||
return {'nftables': [{ 'metainfo': { 'json_schema_version': 1} } ] }
|
||||
|
||||
def search_current_chains(self):
|
||||
nft_chain_names = {'ip': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} },
|
||||
'ip6': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} } }
|
||||
nft_chain_priority = {'ip': {'filter': {'input': None, 'forward': None}, 'nat': {'postrouting': None} },
|
||||
'ip6': {'filter': {'input': None, 'forward': None}, 'nat': {'postrouting': None} } }
|
||||
'ip6': {'filter': {'input': None, 'forward': None}, 'nat': {'postrouting': None} } }
|
||||
mailcow_chains = set()
|
||||
|
||||
# Command: 'nft list chains'
|
||||
_list = {'list' : {'chains': 'null'} }
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if kernel_ruleset:
|
||||
for _object in kernel_ruleset['nftables']:
|
||||
chain = _object.get("chain")
|
||||
if not chain: continue
|
||||
if not kernel_ruleset:
|
||||
return
|
||||
|
||||
_family = chain['family']
|
||||
_table = chain['table']
|
||||
_hook = chain.get("hook")
|
||||
_priority = chain.get("prio")
|
||||
_name = chain['name']
|
||||
|
||||
if _family not in self.nft_chain_names: continue
|
||||
if _table not in self.nft_chain_names[_family]: continue
|
||||
if _hook not in self.nft_chain_names[_family][_table]: continue
|
||||
if _priority is None: continue
|
||||
|
||||
_saved_priority = nft_chain_priority[_family][_table][_hook]
|
||||
if _saved_priority is None or _priority < _saved_priority:
|
||||
# at this point, we know the chain has:
|
||||
# hook and priority set
|
||||
# and it has the lowest priority
|
||||
nft_chain_priority[_family][_table][_hook] = _priority
|
||||
self.nft_chain_names[_family][_table][_hook] = _name
|
||||
|
||||
def search_for_chain(self, kernel_ruleset: dict, chain_name: str):
|
||||
found = False
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
for _object in kernel_ruleset['nftables']:
|
||||
chain = _object.get("chain")
|
||||
if not chain:
|
||||
continue
|
||||
ch_name = chain.get("name")
|
||||
if ch_name == chain_name:
|
||||
found = True
|
||||
break
|
||||
return found
|
||||
if not chain: continue
|
||||
|
||||
_family = chain['family']
|
||||
_table = chain['table']
|
||||
_hook = chain.get("hook")
|
||||
_priority = chain.get("prio")
|
||||
_name = chain['name']
|
||||
|
||||
if _family not in nft_chain_names: continue
|
||||
if _table not in nft_chain_names[_family]: continue
|
||||
if _table == 'filter' and _name == self.chain_name:
|
||||
mailcow_chains.add(_family)
|
||||
if _hook not in nft_chain_names[_family][_table]: continue
|
||||
if _priority is None: continue
|
||||
|
||||
_saved_priority = nft_chain_priority[_family][_table][_hook]
|
||||
if _saved_priority is None or _priority < _saved_priority:
|
||||
# at this point, we know the chain has:
|
||||
# hook and priority set
|
||||
# and it has the lowest priority
|
||||
nft_chain_priority[_family][_table][_hook] = _priority
|
||||
nft_chain_names[_family][_table][_hook] = _name
|
||||
|
||||
self.nft_chain_names = nft_chain_names
|
||||
self.mailcow_chains = mailcow_chains
|
||||
|
||||
def has_filter_chains(self, _family: str):
|
||||
# no base chains: the filter table does not exist, e.g. ip6 if docker has ipv6 disabled
|
||||
return any(self.nft_chain_names[_family]['filter'].values())
|
||||
|
||||
def list_rules(self, _family: str, _table: str, _chain: str):
|
||||
# Command: 'nft list chain {family} {table} {chain}', None if the chain does not exist
|
||||
_list = {'list': {'chain': {'family': _family, 'table': _table, 'name': _chain}}}
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command, quiet=True)
|
||||
if not kernel_ruleset or not isinstance(kernel_ruleset, dict):
|
||||
return None
|
||||
return [_object["rule"] for _object in kernel_ruleset["nftables"] if _object.get("rule")]
|
||||
|
||||
def get_chain_dict(self, _family: str, _name: str):
|
||||
# nft (add | create) chain [<family>] <table> <name>
|
||||
@@ -196,66 +118,101 @@ class NFTables:
|
||||
final_chain["nftables"].append(_add)
|
||||
return final_chain
|
||||
|
||||
def get_mailcow_jump_rule_dict(self, _family: str, _chain: str):
|
||||
_jump_rule = self.get_base_dict()
|
||||
_expr_opt=[]
|
||||
_expr_counter = {'family': _family, 'table': 'filter', 'packets': 0, 'bytes': 0}
|
||||
_counter_dict = {'counter': _expr_counter}
|
||||
_expr_opt.append(_counter_dict)
|
||||
|
||||
_jump_opts = {'jump': {'target': self.chain_name} }
|
||||
|
||||
_expr_opt.append(_jump_opts)
|
||||
|
||||
def get_mailcow_jump_rule(self, _family: str, _chain: str):
|
||||
_expr_opt = [{'counter': {'family': _family, 'table': 'filter', 'packets': 0, 'bytes': 0}},
|
||||
{'jump': {'target': self.chain_name}}]
|
||||
_rule_params = {'family': _family,
|
||||
'table': 'filter',
|
||||
'chain': _chain,
|
||||
'expr': _expr_opt,
|
||||
'comment': "mailcow" }
|
||||
return {'insert': {'rule': _rule_params} }
|
||||
|
||||
_add_rule = {'insert': {'rule': _rule_params} }
|
||||
def is_mailcow_jump(self, rule: dict):
|
||||
# by target, jumps restored by iptables-restore carry no nft comment
|
||||
return any(isinstance(expr, dict) and isinstance(expr.get('jump'), dict) and expr['jump'].get('target') == self.chain_name
|
||||
for expr in rule.get('expr', []))
|
||||
|
||||
_jump_rule["nftables"].append(_add_rule)
|
||||
def init_chain(self, _family: str):
|
||||
# Empty MAILCOW chain with one jump at the top of the input and forward chains
|
||||
if not self.has_filter_chains(_family): return
|
||||
|
||||
return _jump_rule
|
||||
if self.list_rules(_family, 'filter', self.chain_name) is None:
|
||||
if self.nft_exec_dict(self.get_chain_dict(_family, self.chain_name)):
|
||||
self.logger.logInfo(f"MAILCOW {_family} chain created successfully.")
|
||||
else:
|
||||
flush_chain = {'flush': {'chain': {'family': _family, 'table': 'filter', 'name': self.chain_name}}}
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(flush_chain)
|
||||
self.nft_exec_dict(command)
|
||||
|
||||
def insert_mailcow_chains(self, _family: str):
|
||||
nft_input_chain = self.nft_chain_names[_family]['filter']['input']
|
||||
nft_forward_chain = self.nft_chain_names[_family]['filter']['forward']
|
||||
# Command: 'nft list table <family> filter'
|
||||
_table_opts = {'family': _family, 'name': 'filter'}
|
||||
_list = {'list': {'table': _table_opts} }
|
||||
self.ensure_jumps(_family)
|
||||
|
||||
def ensure_chains(self):
|
||||
# Recreates missing MAILCOW chains and moves the jumps back to the top, True if a chain was recreated
|
||||
self.search_current_chains()
|
||||
recreated = False
|
||||
for _family in ['ip', 'ip6']:
|
||||
if not self.has_filter_chains(_family): continue
|
||||
|
||||
if _family not in self.mailcow_chains:
|
||||
if self.nft_exec_dict(self.get_chain_dict(_family, self.chain_name)):
|
||||
self.logger.logWarn(f"MAILCOW {_family} chain was missing and has been recreated")
|
||||
recreated = True
|
||||
self.ensure_jumps(_family)
|
||||
return recreated
|
||||
|
||||
def ensure_jumps(self, _family: str):
|
||||
# Exactly one jump to MAILCOW as first rule: docker inserts its jumps (DOCKER-USER, DOCKER-FORWARD) at the top
|
||||
for hook in ['input', 'forward']:
|
||||
base_chain = self.nft_chain_names[_family]['filter'][hook]
|
||||
if not base_chain: continue
|
||||
|
||||
rules = self.list_rules(_family, 'filter', base_chain)
|
||||
if rules is None: continue
|
||||
|
||||
positions = [position for position, rule in enumerate(rules) if self.is_mailcow_jump(rule)]
|
||||
if positions == [0]: continue
|
||||
|
||||
# delete and insert in one transaction, the chain is never without jump
|
||||
command = self.get_base_dict()
|
||||
for position in positions:
|
||||
_rule_opts = {'family': _family, 'table': 'filter', 'chain': base_chain, 'handle': rules[position]['handle']}
|
||||
command['nftables'].append({'delete': {'rule': _rule_opts}})
|
||||
command['nftables'].append(self.get_mailcow_jump_rule(_family, base_chain))
|
||||
if self.nft_exec_dict(command):
|
||||
if positions:
|
||||
self.logger.logInfo(f"Moved MAILCOW jump to the top of the {_family} {base_chain} chain, it was at position {positions[0] + 1}")
|
||||
else:
|
||||
self.logger.logInfo(f"Added MAILCOW jump to the {_family} {base_chain} chain")
|
||||
|
||||
def clearTable(self, _family: str):
|
||||
# Jumps first, the chain can only be deleted without references. Separate steps, so one failing step
|
||||
# does not roll back the others
|
||||
_list = {'list': {'table': {'family': _family, 'name': 'filter'}}}
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if kernel_ruleset:
|
||||
# chain
|
||||
if not self.search_for_chain(kernel_ruleset, self.chain_name):
|
||||
cadena = self.get_chain_dict(_family, self.chain_name)
|
||||
if self.nft_exec_dict(cadena):
|
||||
self.logger.logInfo(f"MAILCOW {_family} chain created successfully.")
|
||||
kernel_ruleset = self.nft_exec_dict(command, quiet=True)
|
||||
if not kernel_ruleset or not isinstance(kernel_ruleset, dict):
|
||||
return
|
||||
|
||||
input_jump_found, forward_jump_found = False, False
|
||||
delete_jumps = self.get_base_dict()
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
rule = _object.get("rule")
|
||||
if rule and self.is_mailcow_jump(rule):
|
||||
_rule_opts = {'family': _family, 'table': 'filter', 'chain': rule['chain'], 'handle': rule['handle']}
|
||||
delete_jumps['nftables'].append({'delete': {'rule': _rule_opts}})
|
||||
if len(delete_jumps['nftables']) > 1:
|
||||
self.nft_exec_dict(delete_jumps)
|
||||
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("rule"):
|
||||
continue
|
||||
|
||||
rule = _object["rule"]
|
||||
if nft_input_chain and rule["chain"] == nft_input_chain:
|
||||
if rule.get("comment") and rule["comment"] == "mailcow":
|
||||
input_jump_found = True
|
||||
if nft_forward_chain and rule["chain"] == nft_forward_chain:
|
||||
if rule.get("comment") and rule["comment"] == "mailcow":
|
||||
forward_jump_found = True
|
||||
|
||||
if not input_jump_found:
|
||||
command = self.get_mailcow_jump_rule_dict(_family, nft_input_chain)
|
||||
self.nft_exec_dict(command)
|
||||
|
||||
if not forward_jump_found:
|
||||
command = self.get_mailcow_jump_rule_dict(_family, nft_forward_chain)
|
||||
self.nft_exec_dict(command)
|
||||
if self.list_rules(_family, 'filter', self.chain_name) is None:
|
||||
return
|
||||
mailcow_chain = {'family': _family, 'table': 'filter', 'name': self.chain_name}
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append({'flush': {'chain': mailcow_chain}})
|
||||
command['nftables'].append({'delete': {'chain': mailcow_chain}})
|
||||
if self.nft_exec_dict(command):
|
||||
self.logger.logInfo(f"Clear completed: {_family}")
|
||||
|
||||
def delete_nat_rule(self, _family:str, _chain: str, _handle:str):
|
||||
delete_command = self.get_base_dict()
|
||||
@@ -285,23 +242,14 @@ class NFTables:
|
||||
# no postrouting chain, may occur if docker has ipv6 disabled.
|
||||
if not chain_name: return
|
||||
|
||||
# Command: nft list chain <family> nat <chain_name>
|
||||
_chain_opts = {'family': _family, 'table': 'nat', 'name': chain_name}
|
||||
_list = {'list':{'chain': _chain_opts} }
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if not kernel_ruleset:
|
||||
rules = self.list_rules(_family, 'nat', chain_name)
|
||||
if rules is None:
|
||||
return
|
||||
|
||||
rule_position = 0
|
||||
rule_handle = None
|
||||
rule_found = False
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("rule"):
|
||||
continue
|
||||
|
||||
rule = _object["rule"]
|
||||
for rule in rules:
|
||||
if not rule.get("comment") or not rule["comment"] == "mailcow":
|
||||
rule_position +=1
|
||||
continue
|
||||
@@ -314,23 +262,28 @@ class NFTables:
|
||||
target_net = ipaddress.ip_network(snat_target, strict=False)
|
||||
|
||||
if rule_found:
|
||||
saddr_ip = rule["expr"][0]["match"]["right"]["prefix"]["addr"]
|
||||
saddr_len = int(rule["expr"][0]["match"]["right"]["prefix"]["len"])
|
||||
try:
|
||||
saddr_ip = rule["expr"][0]["match"]["right"]["prefix"]["addr"]
|
||||
saddr_len = int(rule["expr"][0]["match"]["right"]["prefix"]["len"])
|
||||
|
||||
daddr_ip = rule["expr"][1]["match"]["right"]["prefix"]["addr"]
|
||||
daddr_len = int(rule["expr"][1]["match"]["right"]["prefix"]["len"])
|
||||
daddr_ip = rule["expr"][1]["match"]["right"]["prefix"]["addr"]
|
||||
daddr_len = int(rule["expr"][1]["match"]["right"]["prefix"]["len"])
|
||||
|
||||
target_ip = rule["expr"][3]["snat"]["addr"]
|
||||
target_ip = rule["expr"][3]["snat"]["addr"]
|
||||
|
||||
saddr_net = ipaddress.ip_network(saddr_ip + '/' + str(saddr_len), strict=False)
|
||||
daddr_net = ipaddress.ip_network(daddr_ip + '/' + str(daddr_len), strict=False)
|
||||
current_target_net = ipaddress.ip_network(target_ip, strict=False)
|
||||
saddr_net = ipaddress.ip_network(saddr_ip + '/' + str(saddr_len), strict=False)
|
||||
daddr_net = ipaddress.ip_network(daddr_ip + '/' + str(daddr_len), strict=False)
|
||||
current_target_net = ipaddress.ip_network(target_ip, strict=False)
|
||||
|
||||
match = all((
|
||||
dest_net == saddr_net,
|
||||
dest_net == daddr_net,
|
||||
target_net == current_target_net
|
||||
))
|
||||
match = all((
|
||||
dest_net == saddr_net,
|
||||
dest_net == daddr_net,
|
||||
target_net == current_target_net
|
||||
))
|
||||
except (KeyError, IndexError, TypeError, ValueError):
|
||||
# rule of another format, replace it
|
||||
saddr_net = dest_net
|
||||
match = False
|
||||
try:
|
||||
if rule_position == 0:
|
||||
if not match:
|
||||
@@ -377,49 +330,13 @@ class NFTables:
|
||||
except:
|
||||
self.logger.logCrit(f"Error running SNAT on {_family}, retrying...")
|
||||
|
||||
def get_chain_handle(self, _family: str, _table: str, chain_name: str):
|
||||
chain_handle = None
|
||||
# Command: 'nft list chains {family}'
|
||||
_list = {'list': {'chains': {'family': _family} } }
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if kernel_ruleset:
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("chain"):
|
||||
continue
|
||||
chain = _object["chain"]
|
||||
if chain["family"] == _family and chain["table"] == _table and chain["name"] == chain_name:
|
||||
chain_handle = chain["handle"]
|
||||
break
|
||||
return chain_handle
|
||||
|
||||
def get_rules_handle(self, _family: str, _table: str, chain_name: str, _comment_filter = "mailcow"):
|
||||
rule_handle = []
|
||||
# Command: 'nft list chain {family} {table} {chain_name}'
|
||||
_chain_opts = {'family': _family, 'table': _table, 'name': chain_name}
|
||||
_list = {'list': {'chain': _chain_opts} }
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if kernel_ruleset:
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("rule"):
|
||||
continue
|
||||
|
||||
rule = _object["rule"]
|
||||
if rule["family"] == _family and rule["table"] == _table and rule["chain"] == chain_name:
|
||||
if rule.get("comment") and rule["comment"] == _comment_filter:
|
||||
rule_handle.append(rule["handle"])
|
||||
return rule_handle
|
||||
|
||||
def get_ban_ip_dict(self, ipaddr: str, _family: str):
|
||||
json_command = self.get_base_dict()
|
||||
rules = self.list_rules(_family, _table, chain_name) or []
|
||||
return [rule["handle"] for rule in rules if rule.get("comment") == _comment_filter]
|
||||
|
||||
def get_ban_rule(self, network, _family: str):
|
||||
expr_opt = []
|
||||
ipaddr_net = ipaddress.ip_network(ipaddr, strict=False)
|
||||
right_dict = {'prefix': {'addr': str(ipaddr_net.network_address), 'len': int(ipaddr_net.prefixlen) } }
|
||||
right_dict = {'prefix': {'addr': str(network.network_address), 'len': int(network.prefixlen) } }
|
||||
|
||||
left_dict = {'payload': {'protocol': _family, 'field': 'saddr'} }
|
||||
match_dict = {'op': '==', 'left': left_dict, 'right': right_dict }
|
||||
@@ -432,228 +349,115 @@ class NFTables:
|
||||
|
||||
rule_dict = {'family': _family, 'table': "filter", 'chain': self.chain_name, 'expr': expr_opt}
|
||||
|
||||
base_dict = {'insert': {'rule': rule_dict} }
|
||||
json_command["nftables"].append(base_dict)
|
||||
return {'insert': {'rule': rule_dict} }
|
||||
|
||||
return json_command
|
||||
def ban_rule_network(self, rule: dict, _family: str):
|
||||
# Source network of a ban rule (saddr match and drop), None for every other rule
|
||||
if rule.get("comment") in self.ISOLATION_COMMENTS:
|
||||
return None
|
||||
exprs = [expr for expr in rule.get("expr", []) if isinstance(expr, dict)]
|
||||
if not any('drop' in expr or 'reject' in expr for expr in exprs):
|
||||
return None
|
||||
for expr in exprs:
|
||||
match = expr.get("match")
|
||||
if not isinstance(match, dict) or match.get("op") != "==":
|
||||
continue
|
||||
left = match.get("left")
|
||||
payload = left.get("payload") if isinstance(left, dict) else None
|
||||
if not isinstance(payload, dict) or payload.get("protocol") != _family or payload.get("field") != "saddr":
|
||||
continue
|
||||
right = match.get("right")
|
||||
try:
|
||||
if isinstance(right, dict) and isinstance(right.get("prefix"), dict):
|
||||
return ipaddress.ip_network(f'{right["prefix"]["addr"]}/{right["prefix"]["len"]}', strict=False)
|
||||
if isinstance(right, str):
|
||||
return ipaddress.ip_network(right, strict=False)
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_unban_ip_dict(self, ipaddr:str, _family: str):
|
||||
json_command = self.get_base_dict()
|
||||
# Command: 'nft list chain {s_family} filter MAILCOW'
|
||||
_chain_opts = {'family': _family, 'table': 'filter', 'name': self.chain_name}
|
||||
_list = {'list': {'chain': _chain_opts} }
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
rule_handle = None
|
||||
if kernel_ruleset:
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("rule"):
|
||||
continue
|
||||
def split_families(self, sources):
|
||||
families = {}
|
||||
for source in sources:
|
||||
network = ipaddress.ip_network(source, strict=False)
|
||||
families.setdefault('ip' if network.version == 4 else 'ip6', []).append(network)
|
||||
return families
|
||||
|
||||
rule = _object["rule"]["expr"][0]["match"]
|
||||
if not "payload" in rule["left"]:
|
||||
continue
|
||||
left_opt = rule["left"]["payload"]
|
||||
if not left_opt["protocol"] == _family:
|
||||
continue
|
||||
if not left_opt["field"] =="saddr":
|
||||
continue
|
||||
def ban(self, sources):
|
||||
# One drop rule per network, existing rules are kept. Returns the networks that could not be banned
|
||||
failed = []
|
||||
for _family, networks in self.split_families(sources).items():
|
||||
rules = self.list_rules(_family, 'filter', self.chain_name)
|
||||
if rules is None:
|
||||
failed += networks
|
||||
continue
|
||||
existing = {self.ban_rule_network(rule, _family) for rule in rules}
|
||||
missing = [network for network in dict.fromkeys(networks) if network not in existing]
|
||||
if not missing: continue
|
||||
|
||||
# ip currently banned
|
||||
rule_right = rule["right"]
|
||||
if isinstance(rule_right, dict):
|
||||
current_rule_ip = rule_right["prefix"]["addr"] + '/' + str(rule_right["prefix"]["len"])
|
||||
else:
|
||||
current_rule_ip = rule_right
|
||||
current_rule_net = ipaddress.ip_network(current_rule_ip)
|
||||
command = self.get_base_dict()
|
||||
for network in missing:
|
||||
command['nftables'].append(self.get_ban_rule(network, _family))
|
||||
if not self.nft_exec_dict(command):
|
||||
failed += missing
|
||||
return [str(network) for network in failed]
|
||||
|
||||
# ip to ban
|
||||
candidate_net = ipaddress.ip_network(ipaddr, strict=False)
|
||||
def unban(self, sources):
|
||||
# Removes every rule of the networks, also duplicates of older versions. Returns the unbanned networks
|
||||
removed = []
|
||||
for _family, networks in self.split_families(sources).items():
|
||||
rules = self.list_rules(_family, 'filter', self.chain_name)
|
||||
if not rules: continue
|
||||
|
||||
if current_rule_net == candidate_net:
|
||||
rule_handle = _object["rule"]["handle"]
|
||||
break
|
||||
|
||||
if rule_handle is not None:
|
||||
mailcow_rule = {'family': _family, 'table': 'filter', 'chain': self.chain_name, 'handle': rule_handle}
|
||||
delete_rule = {'delete': {'rule': mailcow_rule} }
|
||||
json_command["nftables"].append(delete_rule)
|
||||
else:
|
||||
return False
|
||||
|
||||
return json_command
|
||||
|
||||
def check_mailcow_chains(self, family: str, chain: str):
|
||||
position = 0
|
||||
rule_found = False
|
||||
chain_name = self.nft_chain_names[family]['filter'][chain]
|
||||
|
||||
if not chain_name: return None
|
||||
|
||||
_chain_opts = {'family': family, 'table': 'filter', 'name': chain_name}
|
||||
_list = {'list': {'chain': _chain_opts}}
|
||||
command = self.get_base_dict()
|
||||
command['nftables'].append(_list)
|
||||
kernel_ruleset = self.nft_exec_dict(command)
|
||||
if kernel_ruleset:
|
||||
for _object in kernel_ruleset["nftables"]:
|
||||
if not _object.get("rule"):
|
||||
continue
|
||||
rule = _object["rule"]
|
||||
if rule.get("comment") and rule["comment"] == "mailcow":
|
||||
rule_found = True
|
||||
break
|
||||
|
||||
position+=1
|
||||
|
||||
return position if rule_found else False
|
||||
targets = set(networks)
|
||||
command = self.get_base_dict()
|
||||
found = set()
|
||||
for rule in rules:
|
||||
network = self.ban_rule_network(rule, _family)
|
||||
if network in targets:
|
||||
_rule_opts = {'family': _family, 'table': 'filter', 'chain': self.chain_name, 'handle': rule['handle']}
|
||||
command['nftables'].append({'delete': {'rule': _rule_opts}})
|
||||
found.add(network)
|
||||
if found and self.nft_exec_dict(command):
|
||||
removed += found
|
||||
return [str(network) for network in removed]
|
||||
|
||||
def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""):
|
||||
family = "ip"
|
||||
table = "filter"
|
||||
comment_filter_drop = "mailcow isolation"
|
||||
comment_filter_allow = "mailcow isolation allow"
|
||||
json_command = self.get_base_dict()
|
||||
# One rule per port: iptables-nft cannot show an anonymous set, a host that saves and restores the ruleset
|
||||
# with iptables-save (e.g. netfilter-persistent) would get a drop for all ports
|
||||
for comment in self.ISOLATION_COMMENTS:
|
||||
for handle in self.get_rules_handle("ip", "filter", self.chain_name, comment):
|
||||
self.delete_filter_rule("ip", self.chain_name, handle)
|
||||
|
||||
# Delete old mailcow isolation rules
|
||||
handles = self.get_rules_handle(family, table, self.chain_name, comment_filter_drop)
|
||||
for handle in handles:
|
||||
self.delete_filter_rule(family, self.chain_name, handle)
|
||||
handles = self.get_rules_handle(family, table, self.chain_name, comment_filter_allow)
|
||||
for handle in handles:
|
||||
self.delete_filter_rule(family, self.chain_name, handle)
|
||||
|
||||
# insert mailcow isolation rule
|
||||
_match_dict_drop = [
|
||||
{
|
||||
"match": {
|
||||
"op": "!=",
|
||||
"left": {
|
||||
"meta": {
|
||||
"key": "iifname"
|
||||
}
|
||||
},
|
||||
"right": _interface
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"op": "==",
|
||||
"left": {
|
||||
"meta": {
|
||||
"key": "oifname"
|
||||
}
|
||||
},
|
||||
"right": _interface
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"op": "==",
|
||||
"left": {
|
||||
"payload": {
|
||||
"protocol": "tcp",
|
||||
"field": "dport"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"set": _dports
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"counter": {
|
||||
"packets": 0,
|
||||
"bytes": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"drop": None
|
||||
}
|
||||
]
|
||||
rule_drop = { "insert": { "rule": {
|
||||
"family": family,
|
||||
"table": table,
|
||||
"chain": self.chain_name,
|
||||
"comment": comment_filter_drop,
|
||||
"expr": _match_dict_drop
|
||||
}}}
|
||||
json_command["nftables"].append(rule_drop)
|
||||
|
||||
# insert mailcow isolation allow rule
|
||||
if _allow != "":
|
||||
_match_dict_allow = [
|
||||
{
|
||||
"match": {
|
||||
"op": "==",
|
||||
"left": {
|
||||
"payload": {
|
||||
"protocol": "ip",
|
||||
"field": "saddr"
|
||||
}
|
||||
},
|
||||
"right": _allow
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"op": "!=",
|
||||
"left": {
|
||||
"meta": {
|
||||
"key": "iifname"
|
||||
}
|
||||
},
|
||||
"right": _interface
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"op": "==",
|
||||
"left": {
|
||||
"meta": {
|
||||
"key": "oifname"
|
||||
}
|
||||
},
|
||||
"right": _interface
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"op": "==",
|
||||
"left": {
|
||||
"payload": {
|
||||
"protocol": "tcp",
|
||||
"field": "dport"
|
||||
}
|
||||
},
|
||||
"right": {
|
||||
"set": _dports
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"counter": {
|
||||
"packets": 0,
|
||||
"bytes": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"accept": None
|
||||
}
|
||||
]
|
||||
rule_allow = { "insert": { "rule": {
|
||||
"family": family,
|
||||
"table": table,
|
||||
"chain": self.chain_name,
|
||||
"comment": comment_filter_allow,
|
||||
"expr": _match_dict_allow
|
||||
}}}
|
||||
json_command["nftables"].append(rule_allow)
|
||||
|
||||
success = self.nft_exec_dict(json_command)
|
||||
if success == False:
|
||||
if not self.nft_exec_dict(self.get_isolation_dict(_interface, _dports, self.ISOLATION_COMMENTS[0], {"drop": None})):
|
||||
self.logger.logCrit(f"Error adding {self.chain_name} isolation")
|
||||
return False
|
||||
|
||||
# separate, an invalid address must not prevent the drop rules. Inserted last, so they are above them
|
||||
if _allow and not self.nft_exec_dict(self.get_isolation_dict(_interface, _dports, self.ISOLATION_COMMENTS[1], {"accept": None}, _allow)):
|
||||
self.logger.logCrit(f"Error adding {self.chain_name} isolation exception for {_allow}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_isolation_dict(self, _interface:str, _dports:list, _comment:str, _verdict:dict, _saddr:str = ""):
|
||||
json_command = self.get_base_dict()
|
||||
for port in _dports:
|
||||
expr = []
|
||||
if _saddr:
|
||||
expr.append({"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": _saddr}})
|
||||
expr += [
|
||||
{"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": _interface}},
|
||||
{"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": _interface}},
|
||||
{"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": int(port)}},
|
||||
{"counter": {"packets": 0, "bytes": 0}},
|
||||
_verdict
|
||||
]
|
||||
json_command["nftables"].append({"insert": {"rule": {
|
||||
"family": "ip",
|
||||
"table": "filter",
|
||||
"chain": self.chain_name,
|
||||
"comment": _comment,
|
||||
"expr": expr
|
||||
}}})
|
||||
return json_command
|
||||
@@ -0,0 +1,200 @@
|
||||
import ipaddress
|
||||
import threading
|
||||
import dns.exception
|
||||
import dns.resolver
|
||||
from modules.BanManager import BanManager
|
||||
from modules.Options import Options
|
||||
from modules.Regexes import Regexes
|
||||
|
||||
|
||||
class Netfilter:
|
||||
"""Runs the workers of netfilter. Every worker is a loop that logs errors and goes on, so neither a broken log
|
||||
line nor a Redis restart can stop the expiry of bans."""
|
||||
|
||||
ISOLATION_INTERFACE = 'br-mailcow'
|
||||
ISOLATION_PORTS = [3306, 6379, 8983, 12345]
|
||||
|
||||
def __init__(self, r, tables, logger, env):
|
||||
self.r = r
|
||||
self.tables = tables
|
||||
self.logger = logger
|
||||
self.env = env
|
||||
# reentrant, bans.reapply() runs within the chain check
|
||||
self.lock = threading.RLock()
|
||||
self.stop = threading.Event()
|
||||
self.options = Options(logger)
|
||||
self.regexes = Regexes(logger)
|
||||
self.bans = BanManager(r, tables, self.options, logger, self.lock)
|
||||
self.snat4_target = self.snat_target('SNAT_TO_SOURCE', 4)
|
||||
self.snat6_target = self.snat_target('SNAT6_TO_SOURCE', 6)
|
||||
self.resolved = {}
|
||||
self.workers = {
|
||||
'watch': (self.watch, 3),
|
||||
'purge': (self.purge, 10),
|
||||
'chains': (self.check_chains, 10),
|
||||
'lists': (self.update_lists, 60),
|
||||
}
|
||||
if self.snat4_target or self.snat6_target:
|
||||
self.workers['snat'] = (self.snat, 10)
|
||||
self.threads = {}
|
||||
|
||||
def setup(self):
|
||||
if self.r.exists('F2B_LOG'):
|
||||
self.r.rename('F2B_LOG', 'NETFILTER_LOG')
|
||||
# filled again by the lists worker
|
||||
self.r.delete('F2B_PERM_BANS')
|
||||
self.options.refresh(self.r)
|
||||
self.regexes.refresh(self.r)
|
||||
with self.lock:
|
||||
self.tables.clearIPv4Table()
|
||||
self.tables.clearIPv6Table()
|
||||
self.logger.logInfo('Initializing mailcow netfilter chain')
|
||||
self.tables.initChainIPv4()
|
||||
self.tables.initChainIPv6()
|
||||
self.isolate()
|
||||
self.bans.restore()
|
||||
|
||||
def run(self):
|
||||
for name in self.workers:
|
||||
self.start(name)
|
||||
while not self.stop.wait(5):
|
||||
for name, thread in list(self.threads.items()):
|
||||
if not thread.is_alive():
|
||||
self.logger.logCrit('Worker %s stopped unexpectedly, restarting it' % name)
|
||||
self.start(name)
|
||||
|
||||
def shutdown(self):
|
||||
"""Removes the firewall rules, the active bans stay in Redis and are restored on the next start"""
|
||||
self.stop.set()
|
||||
for thread in self.threads.values():
|
||||
thread.join(timeout=5)
|
||||
with self.lock:
|
||||
self.logger.logInfo('Clearing all bans')
|
||||
self.tables.clearIPv4Table()
|
||||
self.tables.clearIPv6Table()
|
||||
try:
|
||||
self.r.delete('F2B_PERM_BANS')
|
||||
except Exception as ex:
|
||||
self.logger.logWarn('Error clearing redis key F2B_PERM_BANS: %s' % ex)
|
||||
|
||||
def start(self, name):
|
||||
task, interval = self.workers[name]
|
||||
thread = threading.Thread(target=self.loop, args=(name, task, interval), name=name, daemon=True)
|
||||
self.threads[name] = thread
|
||||
thread.start()
|
||||
|
||||
def loop(self, name, task, interval):
|
||||
while not self.stop.is_set():
|
||||
try:
|
||||
task()
|
||||
except Exception as ex:
|
||||
self.logger.logWarn('Error in worker %s: %s' % (name, ex))
|
||||
self.stop.wait(interval)
|
||||
|
||||
def watch(self):
|
||||
pubsub = self.r.pubsub(ignore_subscribe_messages=True)
|
||||
try:
|
||||
pubsub.subscribe('F2B_CHANNEL')
|
||||
self.logger.logInfo('Watching Redis channel F2B_CHANNEL')
|
||||
while not self.stop.is_set():
|
||||
message = pubsub.get_message(timeout=1.0)
|
||||
if message and message['type'] == 'message' and message['data']:
|
||||
self.handle_line(message['data'])
|
||||
finally:
|
||||
try:
|
||||
pubsub.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def handle_line(self, line):
|
||||
# a broken line must not end the watcher
|
||||
try:
|
||||
excused = Regexes.temporary_failure(line)
|
||||
if excused is not None:
|
||||
ip = Regexes.parse_ip(excused)
|
||||
if self.external(ip):
|
||||
self.bans.excuse(ip)
|
||||
return
|
||||
result = self.regexes.match(line)
|
||||
if result is None:
|
||||
return
|
||||
rule_id, address = result
|
||||
ip = Regexes.parse_ip(address)
|
||||
if ip is None:
|
||||
self.logger.logWarn('Rule id %s matched an invalid address %s (%s)' % (rule_id, address, line))
|
||||
return
|
||||
if not self.external(ip):
|
||||
return
|
||||
self.logger.logWarn('%s matched rule id %s (%s)' % (ip, rule_id, line))
|
||||
self.bans.attempt(ip)
|
||||
except Exception as ex:
|
||||
self.logger.logWarn('Error processing log line %r: %s' % (line, ex))
|
||||
|
||||
@staticmethod
|
||||
def external(ip):
|
||||
# private and loopback addresses are never banned
|
||||
return ip is not None and not (ip.is_private or ip.is_loopback)
|
||||
|
||||
def purge(self):
|
||||
self.options.refresh(self.r)
|
||||
self.regexes.refresh(self.r)
|
||||
self.bans.purge()
|
||||
|
||||
def check_chains(self):
|
||||
with self.lock:
|
||||
if self.tables.ensure_chains():
|
||||
self.isolate()
|
||||
self.bans.reapply()
|
||||
|
||||
def snat(self):
|
||||
with self.lock:
|
||||
if self.snat4_target:
|
||||
self.tables.snat4(self.snat4_target, self.env.get('IPV4_NETWORK', '172.22.1') + '.0/24')
|
||||
if self.snat6_target:
|
||||
self.tables.snat6(self.snat6_target, self.env.get('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64'))
|
||||
|
||||
def update_lists(self):
|
||||
self.bans.set_allowlist(self.resolve(self.r.hkeys('F2B_WHITELIST')))
|
||||
self.bans.set_denylist(self.resolve(self.r.hkeys('F2B_BLACKLIST')))
|
||||
|
||||
def isolate(self):
|
||||
if self.env.get('DISABLE_NETFILTER_ISOLATION_RULE', '').lower() in ('y', 'yes'):
|
||||
self.logger.logInfo('Skipping MAILCOW isolation')
|
||||
return
|
||||
self.logger.logInfo('Setting MAILCOW isolation')
|
||||
self.tables.create_mailcow_isolation_rule(self.ISOLATION_INTERFACE, self.ISOLATION_PORTS, self.env.get('MAILCOW_REPLICA_IP', ''))
|
||||
|
||||
def resolve(self, entries):
|
||||
"""Networks of the entries; hostnames are resolved, on DNS errors to their last known addresses"""
|
||||
resolver = dns.resolver.Resolver()
|
||||
networks = set()
|
||||
for entry in entries:
|
||||
if BanManager.network(entry) is not None:
|
||||
networks.add(entry)
|
||||
continue
|
||||
addresses = set()
|
||||
for rdtype in ['A', 'AAAA']:
|
||||
try:
|
||||
answer = resolver.resolve(qname=entry, rdtype=rdtype, lifetime=3)
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
continue
|
||||
except dns.exception.DNSException as ex:
|
||||
self.logger.logInfo('Could not resolve %s: %s' % (entry, ex))
|
||||
addresses = self.resolved.get(entry, set())
|
||||
break
|
||||
addresses.update(rdata.to_text() for rdata in answer)
|
||||
self.resolved[entry] = addresses
|
||||
networks |= addresses
|
||||
return networks
|
||||
|
||||
def snat_target(self, name, version):
|
||||
value = self.env.get(name, '')
|
||||
if not value or value == 'n':
|
||||
return None
|
||||
try:
|
||||
if ipaddress.ip_address(value).version == version:
|
||||
return value
|
||||
except ValueError:
|
||||
pass
|
||||
self.logger.logWarn('%s is not a valid IPv%d address' % (value, version))
|
||||
return None
|
||||
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
|
||||
class Options:
|
||||
"""Fail2ban options of the mailcow UI (Redis F2B_OPTIONS). The values are replaced as a whole and never changed
|
||||
in place, so a thread that took them (options.values) keeps a consistent set."""
|
||||
|
||||
DEFAULTS = {
|
||||
'ban_time': 1800,
|
||||
'max_ban_time': 10000,
|
||||
'ban_time_increment': True,
|
||||
'max_attempts': 10,
|
||||
'retry_window': 600,
|
||||
'netban_ipv4': 32,
|
||||
'netban_ipv6': 128,
|
||||
'manage_external': 0,
|
||||
}
|
||||
# Single keys of old installations, read while F2B_OPTIONS does not exist
|
||||
LEGACY_KEYS = {
|
||||
'ban_time': 'F2B_BAN_TIME',
|
||||
'max_ban_time': 'F2B_MAX_BAN_TIME',
|
||||
'ban_time_increment': 'F2B_BAN_TIME_INCREMENT',
|
||||
'max_attempts': 'F2B_MAX_ATTEMPTS',
|
||||
'retry_window': 'F2B_RETRY_WINDOW',
|
||||
'netban_ipv4': 'F2B_NETBAN_IPV4',
|
||||
'netban_ipv6': 'F2B_NETBAN_IPV6',
|
||||
}
|
||||
|
||||
def __init__(self, logger):
|
||||
self.logger = logger
|
||||
self.values = self.normalize({})
|
||||
|
||||
def refresh(self, r):
|
||||
"""Reads the options of the UI; options it lacks are added to Redis once, existing ones are never overwritten"""
|
||||
raw = r.get('F2B_OPTIONS')
|
||||
if raw:
|
||||
try:
|
||||
stored = json.loads(raw)
|
||||
except ValueError as ex:
|
||||
self.logger.logWarn('F2B_OPTIONS is no valid JSON, keeping the current options: %s' % ex)
|
||||
return
|
||||
if not isinstance(stored, dict):
|
||||
self.logger.logWarn('F2B_OPTIONS is no JSON object, keeping the current options')
|
||||
return
|
||||
else:
|
||||
stored = {key: r.get(name) for key, name in self.LEGACY_KEYS.items()}
|
||||
stored = {key: value for key, value in stored.items() if value is not None}
|
||||
|
||||
values = self.normalize(stored)
|
||||
self.values = values
|
||||
# First start or old installation: store the complete set, else only what the UI has not saved yet
|
||||
missing = {key: value for key, value in values.items() if not raw or stored.get(key) is None}
|
||||
if missing:
|
||||
self.add_missing(r, missing)
|
||||
|
||||
def ban_time(self, ban_counter):
|
||||
"""Seconds of the next ban of a network that was banned ban_counter times before"""
|
||||
values = self.values
|
||||
ban_time = values['ban_time']
|
||||
if values['ban_time_increment']:
|
||||
# the exponent is capped, max_ban_time limits the result anyway
|
||||
ban_time = ban_time * 2 ** min(ban_counter, 32)
|
||||
return max(values['ban_time'], min(ban_time, values['max_ban_time']))
|
||||
|
||||
@classmethod
|
||||
def normalize(cls, stored):
|
||||
values = dict(stored)
|
||||
for key, default in cls.DEFAULTS.items():
|
||||
if isinstance(default, bool):
|
||||
values[key] = cls.to_bool(stored.get(key), default)
|
||||
else:
|
||||
values[key] = cls.to_int(stored.get(key), default)
|
||||
values['ban_time'] = max(1, values['ban_time'])
|
||||
values['max_ban_time'] = max(1, values['max_ban_time'])
|
||||
values['max_attempts'] = max(1, values['max_attempts'])
|
||||
values['retry_window'] = max(1, values['retry_window'])
|
||||
# A short prefix would ban whole regions or everything (/0)
|
||||
values['netban_ipv4'] = min(32, max(8, values['netban_ipv4']))
|
||||
values['netban_ipv6'] = min(128, max(8, values['netban_ipv6']))
|
||||
values['manage_external'] = 1 if values['manage_external'] == 1 else 0
|
||||
if not values.get('banlist_id'):
|
||||
values['banlist_id'] = str(uuid.uuid4())
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def to_int(value, default):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def to_bool(value, default):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
return default
|
||||
|
||||
def add_missing(self, r, missing):
|
||||
# WATCH keeps a save of the UI between reading and writing
|
||||
def fill(pipe):
|
||||
raw = pipe.get('F2B_OPTIONS')
|
||||
current = json.loads(raw) if raw else {}
|
||||
if not isinstance(current, dict):
|
||||
return
|
||||
added = {key: value for key, value in missing.items() if current.get(key) is None}
|
||||
if not added:
|
||||
return
|
||||
current.update(added)
|
||||
pipe.multi()
|
||||
pipe.set('F2B_OPTIONS', json.dumps(current, ensure_ascii=False))
|
||||
|
||||
try:
|
||||
r.transaction(fill, 'F2B_OPTIONS')
|
||||
except Exception as ex:
|
||||
self.logger.logWarn('Could not add default options to F2B_OPTIONS: %s' % ex)
|
||||
@@ -0,0 +1,88 @@
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
class Regexes:
|
||||
"""Rules for log lines (Redis F2B_REGEX): rule id => regex whose first group captures the client address"""
|
||||
|
||||
DEFAULTS = {
|
||||
1: r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)',
|
||||
2: r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)',
|
||||
3: r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+',
|
||||
4: r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+',
|
||||
5: r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
|
||||
6: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)',
|
||||
7: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)',
|
||||
8: r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
|
||||
9: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
|
||||
}
|
||||
# Dovecot could not ask the auth backend (identity provider down, mailcowauth.php unreachable). Postfix logs
|
||||
# such a temporary failure like a wrong password, so failed logins of the address are not counted for a while.
|
||||
TEMPORARY_FAILURE = re.compile(r'lua\([^,]+,([0-9a-f\.:]+)(?:,<[^>]+>)?\): passdb-lua: Upstream (?:unavailable|unreachable)')
|
||||
|
||||
def __init__(self, logger):
|
||||
self.logger = logger
|
||||
self.rules = []
|
||||
self.source = None
|
||||
|
||||
def refresh(self, r):
|
||||
"""Compiles the rules of the UI again when they changed; invalid rules are skipped"""
|
||||
raw = r.get('F2B_REGEX')
|
||||
if not raw:
|
||||
raw = json.dumps(self.DEFAULTS, ensure_ascii=False)
|
||||
r.set('F2B_REGEX', raw, nx=True)
|
||||
if raw == self.source:
|
||||
return
|
||||
try:
|
||||
stored = json.loads(raw)
|
||||
except ValueError as ex:
|
||||
self.logger.logWarn('F2B_REGEX is no valid JSON, keeping the current rules: %s' % ex)
|
||||
return
|
||||
if not isinstance(stored, dict):
|
||||
self.logger.logWarn('F2B_REGEX is no JSON object, keeping the current rules')
|
||||
return
|
||||
|
||||
rules = []
|
||||
for rule_id, regex in sorted(stored.items(), key=lambda item: self.sort_key(item[0])):
|
||||
try:
|
||||
rules.append((str(rule_id), re.compile(regex)))
|
||||
except (re.error, TypeError) as ex:
|
||||
self.logger.logWarn('Skipping regex of rule id %s: %s' % (rule_id, ex))
|
||||
self.rules = rules
|
||||
self.source = raw
|
||||
|
||||
def match(self, line):
|
||||
"""(rule id, captured address) of the first rule that matches the line, None if no rule matches"""
|
||||
for rule_id, regex in self.rules:
|
||||
result = regex.search(line)
|
||||
if result and result.lastindex and result.group(1):
|
||||
return rule_id, result.group(1)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def temporary_failure(cls, line):
|
||||
"""Address of a login that failed because the auth backend was unavailable, None for other lines"""
|
||||
result = cls.TEMPORARY_FAILURE.search(line)
|
||||
return result.group(1) if result else None
|
||||
|
||||
@staticmethod
|
||||
def parse_ip(text):
|
||||
"""IP address of a captured address, IPv4-mapped IPv6 as IPv4; a trailing colon (e.g. "1.2.3.4:") is
|
||||
tolerated. None if the text is no address."""
|
||||
for candidate in (text, text.rstrip(':')):
|
||||
try:
|
||||
ip = ipaddress.ip_address(candidate)
|
||||
except ValueError:
|
||||
continue
|
||||
if ip.version == 6 and ip.ipv4_mapped:
|
||||
return ip.ipv4_mapped
|
||||
return ip
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def sort_key(rule_id):
|
||||
try:
|
||||
return (0, int(rule_id))
|
||||
except (TypeError, ValueError):
|
||||
return (1, str(rule_id))
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.21
|
||||
FROM alpine:3.24
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ logger.debug('olefy listen address string: {} (type {})'.format(olefy_listen_add
|
||||
if not olefy_listen_addr_string:
|
||||
olefy_listen_addr = ""
|
||||
else:
|
||||
addr_re = re.compile('[\[" \]]')
|
||||
addr_re = re.compile(r'[\[" \]]')
|
||||
olefy_listen_addr = addr_re.sub('', olefy_listen_addr_string.replace("'", "")).split(',')
|
||||
|
||||
# log runtime variables
|
||||
@@ -206,8 +206,9 @@ class AIO(asyncio.Protocol):
|
||||
self.transport.close()
|
||||
|
||||
|
||||
# start the listeners
|
||||
loop = asyncio.get_event_loop()
|
||||
# start the listeners, Python >= 3.14 no longer creates a loop in get_event_loop()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
# each client connection will create a new protocol instance
|
||||
coro = loop.create_server(AIO, olefy_listen_addr, olefy_listen_port)
|
||||
server = loop.run_until_complete(coro)
|
||||
|
||||
@@ -17,7 +17,7 @@ ARG SOPE_VERSION=SOPE-5.12.12
|
||||
# Security patches to apply (space-separated commit hashes)
|
||||
ARG SOGO_SECURITY_PATCHES=""
|
||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||
ARG GOSU_VERSION=1.19
|
||||
ARG GOSU_VERSION=1.20
|
||||
ENV LC_ALL=C
|
||||
|
||||
# Install dependencies, build SOPE and SOGo, then clean up (all in one layer to minimize image size)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.24
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
# install unbound from alpine:edge to get security patches
|
||||
# minimum version is pinned
|
||||
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main "unbound>=1.26.1-r0"
|
||||
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main "unbound>=1.26.2-r0"
|
||||
|
||||
# install other packages from regular alpine stable repo
|
||||
RUN apk add --update --no-cache \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.24
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
|
||||
@@ -101,7 +101,16 @@ if ($result === false){
|
||||
// Init Identity Provider
|
||||
$iam_provider = identity_provider('init');
|
||||
$iam_settings = identity_provider('get');
|
||||
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']));
|
||||
try {
|
||||
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']));
|
||||
} catch (\Mailcow\Auth\IdpUnavailable $e) {
|
||||
// temporary failure for Dovecot, no failed login
|
||||
error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip'] . ": " . $e->getMessage());
|
||||
http_response_code(503); // Service Unavailable
|
||||
echo json_encode($return);
|
||||
session_destroy();
|
||||
exit;
|
||||
}
|
||||
if ($result) {
|
||||
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
|
||||
set_sasl_log($post['username'], $post['real_rip'], $post['service']);
|
||||
|
||||
@@ -41,6 +41,12 @@ function auth_password_verify(request, password)
|
||||
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "Upstream unreachable"
|
||||
end
|
||||
|
||||
-- 5xx: the backend or the identity provider cannot answer, a temporary failure and no failed login
|
||||
if c >= 500 then
|
||||
dovecot.i_info("HTTP request to auth backend failed with " .. tostring(c) .. " for user " .. request.user)
|
||||
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "Upstream unavailable"
|
||||
end
|
||||
|
||||
if c ~= 200 and c ~= 401 then
|
||||
dovecot.i_info("HTTP request failed with " .. tostring(c) .. " for user " .. request.user)
|
||||
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error"
|
||||
|
||||
@@ -67,9 +67,11 @@ passdb {
|
||||
}
|
||||
# check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail
|
||||
# a return of the following passdb is mandatory
|
||||
# cache_key with password: the lua default (%u) would let a temporary failure fall back to the negative entry
|
||||
# of a passdb lookup (master logins) and log "unknown user", which netfilter counts as failed login
|
||||
passdb {
|
||||
driver = lua
|
||||
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes
|
||||
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
|
||||
}
|
||||
# Set doveadm_password=your-secret-password in data/conf/dovecot/extra.conf (create if missing)
|
||||
service doveadm {
|
||||
|
||||
@@ -36,7 +36,18 @@ function check_login($user, $pass, $extra = null) {
|
||||
|
||||
// Try validate user
|
||||
if (!isset($role) || $role == "user") {
|
||||
$result = user_login($user, $pass, $extra);
|
||||
try {
|
||||
$result = user_login($user, $pass, $extra);
|
||||
} catch (\Mailcow\Auth\IdpUnavailable $e) {
|
||||
// no failed login, netfilter must not count it
|
||||
error_log("mailcow UI: Identity provider unavailable for " . $user . " by " . $_SERVER['REMOTE_ADDR'] . ": " . $e->getMessage());
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $user, '*', $e->getMessage()),
|
||||
'msg' => 'iam_unavailable'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if ($result !== false) {
|
||||
$real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
|
||||
set_sasl_log($user, $real_rip, $extra['service']);
|
||||
@@ -516,11 +527,14 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
|
||||
return false;
|
||||
}
|
||||
if (!$iam_provider) {
|
||||
return false;
|
||||
throw new \Mailcow\Auth\IdpUnavailable('Keycloak is not configured completely');
|
||||
}
|
||||
|
||||
// get access_token for service account of mailcow client
|
||||
$admin_token = identity_provider("get-keycloak-admin-token");
|
||||
if (!$admin_token) {
|
||||
throw new \Mailcow\Auth\IdpUnavailable('Could not get an admin token from Keycloak');
|
||||
}
|
||||
|
||||
// get the mailcow_password attribute from keycloak user
|
||||
$url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users";
|
||||
@@ -541,13 +555,9 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
|
||||
$users = json_decode(curl_exec($curl), true);
|
||||
$code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
|
||||
curl_close($curl);
|
||||
// the password is checked below, a failed request is never a wrong password
|
||||
if ($code != 200) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $user, '*', 'Identity Provider returned HTTP ' . $code),
|
||||
'msg' => 'generic_server_error'
|
||||
);
|
||||
return false;
|
||||
throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code);
|
||||
}
|
||||
// exact=true can still return several users with that email
|
||||
if (!is_array($users) || count($users) != 1) {
|
||||
@@ -687,7 +697,8 @@ function ldap_mbox_login($user, $pass, $extra = null){
|
||||
return false;
|
||||
}
|
||||
if (!$iam_provider) {
|
||||
return false;
|
||||
// identity_provider('init') could not connect
|
||||
throw new \Mailcow\Auth\IdpUnavailable('No connection to the LDAP server');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -700,33 +711,17 @@ function ldap_mbox_login($user, $pass, $extra = null){
|
||||
\Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? '')))));
|
||||
|
||||
$user_res = $ldap_query->firstOrFail();
|
||||
} catch (Exception $e) {
|
||||
// clear $_SESSION['return'] to not leak data
|
||||
$_SESSION['return'] = array();
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $user, '*', $e->getMessage()),
|
||||
'msg' => 'generic_server_error'
|
||||
);
|
||||
} catch (\LdapRecord\Query\ObjectNotFoundException $e) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (!$iam_provider->auth()->attempt($user_res['dn'], $pass)) {
|
||||
return false;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// clear $_SESSION['return'] to not leak data
|
||||
$_SESSION['return'] = array();
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $user, '*', $e->getMessage()),
|
||||
'msg' => 'generic_server_error'
|
||||
);
|
||||
throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
$idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp;
|
||||
if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// get mapped template
|
||||
$idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp;
|
||||
$mapped_template = $idp_login->mappedTemplate($user_res[$iam_settings['attribute_field']] ?? array());
|
||||
$external_id = \Mailcow\Identity\Directory\LdapDirectory::entryId($user_res, $iam_settings['id_attribute'] ?? '');
|
||||
|
||||
|
||||
@@ -60,13 +60,14 @@ function fail2ban($_action, $_data = null, $_extra = null) {
|
||||
$queue_unban = $redis->hGetAll('F2B_QUEUE_UNBAN');
|
||||
if (is_array($active_bans)) {
|
||||
foreach ($active_bans as $network => $banned_until) {
|
||||
$queued_for_unban = (isset($queue_unban[$network]) && $queue_unban[$network] == 1) ? 1 : 0;
|
||||
$difference = $banned_until - time();
|
||||
// Expired bans are lifted by netfilter within seconds
|
||||
$difference = max(0, intval($banned_until) - time());
|
||||
$queued_for_unban = ($difference == 0 || (isset($queue_unban[$network]) && $queue_unban[$network] == 1)) ? 1 : 0;
|
||||
$f2b_options['active_bans'][] = array(
|
||||
'queued_for_unban' => $queued_for_unban,
|
||||
'network' => $network,
|
||||
'ip' => strtok($network,'/'),
|
||||
'banned_until' => sprintf('%02dh %02dm %02ds', ($difference/3600), ($difference/60%60), $difference%60)
|
||||
'banned_until' => sprintf('%02dh %02dm %02ds', intdiv($difference, 3600), intdiv($difference, 60) % 60, $difference % 60)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -108,16 +109,15 @@ function fail2ban($_action, $_data = null, $_extra = null) {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Rules will also be recreated on log events, but rules may seem empty for a second in the UI
|
||||
docker('post', 'netfilter-mailcow', 'restart');
|
||||
// netfilter recreates the default rules within 10 seconds
|
||||
$fail_count = 0;
|
||||
$regex_result = json_decode($redis->Get('F2B_REGEX'), true);
|
||||
while (empty($regex_result) && $fail_count < 10) {
|
||||
while (empty($regex_result) && $fail_count < 15) {
|
||||
$regex_result = json_decode($redis->Get('F2B_REGEX'), true);
|
||||
$fail_count++;
|
||||
sleep(1);
|
||||
}
|
||||
if ($fail_count >= 10) {
|
||||
if (empty($regex_result)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
@@ -141,7 +141,7 @@ function fail2ban($_action, $_data = null, $_extra = null) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
'msg' => array('object_modified', htmlspecialchars($network))
|
||||
'msg' => 'f2b_modified'
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -172,7 +172,7 @@ function fail2ban($_action, $_data = null, $_extra = null) {
|
||||
if (valid_network($network)) {
|
||||
try {
|
||||
$redis->hSet('F2B_WHITELIST', $network, 1);
|
||||
$redis->hDel('F2B_BLACKLIST', $network, 1);
|
||||
$redis->hDel('F2B_BLACKLIST', $network);
|
||||
$redis->hSet('F2B_QUEUE_UNBAN', $network, 1);
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
@@ -199,14 +199,13 @@ function fail2ban($_action, $_data = null, $_extra = null) {
|
||||
if (valid_network($network) && !in_array($network, array(
|
||||
'0.0.0.0',
|
||||
'0.0.0.0/0',
|
||||
getenv('IPV4_NETWORK') . '0/24',
|
||||
getenv('IPV4_NETWORK') . '0',
|
||||
getenv('IPV4_NETWORK') . '.0/24',
|
||||
getenv('IPV4_NETWORK') . '.0',
|
||||
getenv('IPV6_NETWORK')
|
||||
))) {
|
||||
try {
|
||||
$redis->hSet('F2B_BLACKLIST', $network, 1);
|
||||
$redis->hDel('F2B_WHITELIST', $network, 1);
|
||||
//$response = docker('post', 'netfilter-mailcow', 'restart');
|
||||
$redis->hDel('F2B_WHITELIST', $network);
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
@@ -261,62 +260,60 @@ function fail2ban($_action, $_data = null, $_extra = null) {
|
||||
$f2b_options['ban_time'] = ($ban_time < 60) ? 60 : $ban_time;
|
||||
$f2b_options['ban_time_increment'] = ($ban_time_increment == 1) ? true : false;
|
||||
$f2b_options['max_ban_time'] = ($max_ban_time < 60) ? 60 : $max_ban_time;
|
||||
$f2b_options['netban_ipv4'] = ($netban_ipv4 < 8) ? 8 : $netban_ipv4;
|
||||
$f2b_options['netban_ipv6'] = ($netban_ipv6 < 8) ? 8 : $netban_ipv6;
|
||||
$f2b_options['netban_ipv4'] = ($netban_ipv4 > 32) ? 32 : $netban_ipv4;
|
||||
$f2b_options['netban_ipv6'] = ($netban_ipv6 > 128) ? 128 : $netban_ipv6;
|
||||
$f2b_options['netban_ipv4'] = min(32, max(8, $netban_ipv4));
|
||||
$f2b_options['netban_ipv6'] = min(128, max(8, $netban_ipv6));
|
||||
$f2b_options['max_attempts'] = ($max_attempts < 1) ? 1 : $max_attempts;
|
||||
$f2b_options['retry_window'] = ($retry_window < 1) ? 1 : $retry_window;
|
||||
$f2b_options['banlist_id'] = $is_now['banlist_id'];
|
||||
$f2b_options['banlist_id'] = (!empty($is_now['banlist_id'])) ? $is_now['banlist_id'] : uuid4();
|
||||
$f2b_options['manage_external'] = ($manage_external > 0) ? 1 : 0;
|
||||
$wl_items = array();
|
||||
if (!empty($wl)) {
|
||||
foreach (array_filter(array_map('trim', preg_split("/( |,|;|\n)/", $wl))) as $wl_item) {
|
||||
if (valid_network($wl_item) || valid_hostname($wl_item)) {
|
||||
$wl_items[$wl_item] = 1;
|
||||
}
|
||||
else {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
'msg' => array('network_host_invalid', $wl_item)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
$bl_items = array();
|
||||
if (!empty($bl)) {
|
||||
foreach (array_filter(array_map('trim', preg_split("/( |,|;|\n)/", $bl))) as $bl_item) {
|
||||
if (valid_network($bl_item) && !in_array($bl_item, array(
|
||||
'0.0.0.0',
|
||||
'0.0.0.0/0',
|
||||
getenv('IPV4_NETWORK') . '.0/24',
|
||||
getenv('IPV4_NETWORK') . '.0',
|
||||
getenv('IPV6_NETWORK')
|
||||
))) {
|
||||
$bl_items[$bl_item] = 1;
|
||||
}
|
||||
else {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
'msg' => array('network_host_invalid', $bl_item)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
// One transaction, netfilter must never see empty lists in between
|
||||
$redis->multi();
|
||||
$redis->Set('F2B_OPTIONS', json_encode($f2b_options));
|
||||
$redis->Del('F2B_WHITELIST');
|
||||
$redis->Del('F2B_BLACKLIST');
|
||||
if(!empty($wl)) {
|
||||
$wl_array = array_map('trim', preg_split( "/( |,|;|\n)/", $wl));
|
||||
$wl_array = array_filter($wl_array);
|
||||
if (is_array($wl_array)) {
|
||||
foreach ($wl_array as $wl_item) {
|
||||
if (valid_network($wl_item) || valid_hostname($wl_item)) {
|
||||
$redis->hSet('F2B_WHITELIST', $wl_item, 1);
|
||||
}
|
||||
else {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
'msg' => array('network_host_invalid', $wl_item)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
$redis->Del('F2B_WHITELIST', 'F2B_BLACKLIST');
|
||||
if (!empty($wl_items)) {
|
||||
$redis->hMSet('F2B_WHITELIST', $wl_items);
|
||||
}
|
||||
if(!empty($bl)) {
|
||||
$bl_array = array_map('trim', preg_split( "/( |,|;|\n)/", $bl));
|
||||
$bl_array = array_filter($bl_array);
|
||||
if (is_array($bl_array)) {
|
||||
foreach ($bl_array as $bl_item) {
|
||||
if (valid_network($bl_item) && !in_array($bl_item, array(
|
||||
'0.0.0.0',
|
||||
'0.0.0.0/0',
|
||||
getenv('IPV4_NETWORK') . '0/24',
|
||||
getenv('IPV4_NETWORK') . '0',
|
||||
getenv('IPV6_NETWORK')
|
||||
))) {
|
||||
$redis->hSet('F2B_BLACKLIST', $bl_item, 1);
|
||||
}
|
||||
else {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
'msg' => array('network_host_invalid', $bl_item)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($bl_items)) {
|
||||
$redis->hMSet('F2B_BLACKLIST', $bl_items);
|
||||
}
|
||||
$redis->exec();
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
@@ -348,7 +345,9 @@ function fail2ban($_action, $_data = null, $_extra = null) {
|
||||
if (is_array($_extra)) {
|
||||
$_extra = $_extra[0];
|
||||
}
|
||||
if ($_extra != $f2b_options['banlist_id']){
|
||||
// Timing safe, a missing or empty id never matches
|
||||
$banlist_id = (string)($f2b_options['banlist_id'] ?? '');
|
||||
if ($banlist_id === '' || !is_string($_extra) || !hash_equals($banlist_id, $_extra)) {
|
||||
http_response_code(404);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1292,8 +1292,13 @@ function set_tfa($_data) {
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) {
|
||||
if ($row['authsource'] == 'ldap'){
|
||||
if (!ldap_mbox_login($username, $_data["confirm_password"], $iam_settings)) $access_denied = true;
|
||||
else $access_denied = false;
|
||||
try {
|
||||
if (!ldap_mbox_login($username, $_data["confirm_password"], $iam_settings)) $access_denied = true;
|
||||
else $access_denied = false;
|
||||
} catch (\Mailcow\Auth\IdpUnavailable $e) {
|
||||
$_SESSION['return'][] = array('type' => 'danger', 'log' => array(__FUNCTION__, $_data_log, $e->getMessage()), 'msg' => 'iam_unavailable');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (!verify_hash($row['password'], $_data["confirm_password"])) $access_denied = true;
|
||||
else $access_denied = false;
|
||||
|
||||
@@ -1604,7 +1604,9 @@ function init_db_schema()
|
||||
"redirect_uri" => "VARCHAR(2000)",
|
||||
"expires" => "TIMESTAMP NOT NULL",
|
||||
"scope" => "VARCHAR(4000)",
|
||||
"id_token" => "VARCHAR(1000)"
|
||||
"id_token" => "VARCHAR(1000)",
|
||||
"code_challenge" => "VARCHAR(1000)",
|
||||
"code_challenge_method" => "VARCHAR(20)"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"mustangostang/spyc": "^0.6.3",
|
||||
"directorytree/ldaprecord": "^3.3",
|
||||
"twig/twig": "^3.0",
|
||||
"stevenmaguire/oauth2-keycloak": "^4.0",
|
||||
"stevenmaguire/oauth2-keycloak": "^6.1",
|
||||
"league/oauth2-client": "^2.7",
|
||||
"bacon/bacon-qr-code": "^2.0"
|
||||
},
|
||||
|
||||
Generated
+825
-472
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Auth;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* The identity provider could not answer a login (unreachable, timeout, server error). No failed login:
|
||||
* callers report a temporary failure and must not count it as an attempt for netfilter.
|
||||
*/
|
||||
class IdpUnavailable extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -4,6 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Auth\Service;
|
||||
|
||||
use LdapRecord\Auth\BindException;
|
||||
use LdapRecord\Connection;
|
||||
use LdapRecord\LdapRecordException;
|
||||
use Mailcow\Auth\IdpUnavailable;
|
||||
use Mailcow\Identity\Model\Settings;
|
||||
use Mailcow\Identity\Service\LinkService;
|
||||
use Mailcow\MailboxGroup\Service\TemplateService;
|
||||
@@ -19,6 +23,37 @@ class IdpLoginService
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds as the user to check the password, then again as the configured user. False for wrong credentials,
|
||||
* IdpUnavailable when the server cannot answer (client side codes are negative: server down, timeout, ...).
|
||||
*/
|
||||
public function verifyLdapPassword(Connection $ldap, string $dn, string $password): bool
|
||||
{
|
||||
// an empty password would be an unauthenticated bind, which succeeds
|
||||
if ($dn === '' || $password === '') {
|
||||
return false;
|
||||
}
|
||||
$guard = $ldap->auth();
|
||||
try {
|
||||
$guard->bind($dn, $password);
|
||||
$bound = true;
|
||||
} catch (BindException $e) {
|
||||
$code = $e->getDetailedError()?->getErrorCode();
|
||||
if ($code !== null && ($code < 0 || in_array($code, [51, 52], true))) {
|
||||
throw new IdpUnavailable('LDAP bind failed: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
$bound = false;
|
||||
} catch (LdapRecordException $e) {
|
||||
throw new IdpUnavailable('LDAP bind failed: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
try {
|
||||
$guard->bindAsConfiguredUser();
|
||||
} catch (LdapRecordException $e) {
|
||||
// the login needs no further LDAP access
|
||||
}
|
||||
return $bound;
|
||||
}
|
||||
|
||||
/** Template of the first attribute mapping matching one of the IdP attribute values, null if none */
|
||||
public function mappedTemplate(mixed $values): ?string
|
||||
{
|
||||
|
||||
Vendored
+1
-4
@@ -14,10 +14,7 @@ if (PHP_VERSION_ID < 50600) {
|
||||
echo $err;
|
||||
}
|
||||
}
|
||||
trigger_error(
|
||||
$err,
|
||||
E_USER_ERROR
|
||||
);
|
||||
throw new RuntimeException($err);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/composer/autoload_real.php';
|
||||
|
||||
+34
-12
@@ -12,6 +12,7 @@
|
||||
|
||||
namespace Composer;
|
||||
|
||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
||||
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
@@ -23,18 +24,17 @@ if (PHP_VERSION_ID < 80000) {
|
||||
{
|
||||
private $handle;
|
||||
private $position;
|
||||
private $realpath;
|
||||
|
||||
public function stream_open($path, $mode, $options, &$opened_path)
|
||||
{
|
||||
// get rid of composer-bin-proxy:// prefix for __FILE__ & __DIR__ resolution
|
||||
$opened_path = substr($path, 21);
|
||||
$opened_path = realpath($opened_path) ?: $opened_path;
|
||||
$this->handle = fopen($opened_path, $mode);
|
||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
||||
$opened_path = substr($path, 17);
|
||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
||||
$opened_path = $this->realpath;
|
||||
$this->handle = fopen($this->realpath, $mode);
|
||||
$this->position = 0;
|
||||
|
||||
// remove all traces of this stream wrapper once it has been used
|
||||
stream_wrapper_unregister('composer-bin-proxy');
|
||||
|
||||
return (bool) $this->handle;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,16 @@ if (PHP_VERSION_ID < 80000) {
|
||||
return $operation ? flock($this->handle, $operation) : true;
|
||||
}
|
||||
|
||||
public function stream_seek($offset, $whence)
|
||||
{
|
||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
||||
$this->position = ftell($this->handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function stream_tell()
|
||||
{
|
||||
return $this->position;
|
||||
@@ -78,20 +88,32 @@ if (PHP_VERSION_ID < 80000) {
|
||||
|
||||
public function stream_stat()
|
||||
{
|
||||
return fstat($this->handle);
|
||||
return array();
|
||||
}
|
||||
|
||||
public function stream_set_option($option, $arg1, $arg2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function url_stat($path, $flags)
|
||||
{
|
||||
$path = substr($path, 17);
|
||||
if (file_exists($path)) {
|
||||
return stat($path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (function_exists('stream_wrapper_register') && stream_wrapper_register('composer-bin-proxy', 'Composer\BinProxyWrapper')) {
|
||||
include("composer-bin-proxy://" . __DIR__ . '/..'.'/matthiasmullie/minify/bin/minifycss');
|
||||
exit(0);
|
||||
if (
|
||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
||||
) {
|
||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/matthiasmullie/minify/bin/minifycss');
|
||||
}
|
||||
}
|
||||
|
||||
include __DIR__ . '/..'.'/matthiasmullie/minify/bin/minifycss';
|
||||
return include __DIR__ . '/..'.'/matthiasmullie/minify/bin/minifycss';
|
||||
Vendored
+34
-12
@@ -12,6 +12,7 @@
|
||||
|
||||
namespace Composer;
|
||||
|
||||
$GLOBALS['_composer_bin_dir'] = __DIR__;
|
||||
$GLOBALS['_composer_autoload_path'] = __DIR__ . '/..'.'/autoload.php';
|
||||
|
||||
if (PHP_VERSION_ID < 80000) {
|
||||
@@ -23,18 +24,17 @@ if (PHP_VERSION_ID < 80000) {
|
||||
{
|
||||
private $handle;
|
||||
private $position;
|
||||
private $realpath;
|
||||
|
||||
public function stream_open($path, $mode, $options, &$opened_path)
|
||||
{
|
||||
// get rid of composer-bin-proxy:// prefix for __FILE__ & __DIR__ resolution
|
||||
$opened_path = substr($path, 21);
|
||||
$opened_path = realpath($opened_path) ?: $opened_path;
|
||||
$this->handle = fopen($opened_path, $mode);
|
||||
// get rid of phpvfscomposer:// prefix for __FILE__ & __DIR__ resolution
|
||||
$opened_path = substr($path, 17);
|
||||
$this->realpath = realpath($opened_path) ?: $opened_path;
|
||||
$opened_path = $this->realpath;
|
||||
$this->handle = fopen($this->realpath, $mode);
|
||||
$this->position = 0;
|
||||
|
||||
// remove all traces of this stream wrapper once it has been used
|
||||
stream_wrapper_unregister('composer-bin-proxy');
|
||||
|
||||
return (bool) $this->handle;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,16 @@ if (PHP_VERSION_ID < 80000) {
|
||||
return $operation ? flock($this->handle, $operation) : true;
|
||||
}
|
||||
|
||||
public function stream_seek($offset, $whence)
|
||||
{
|
||||
if (0 === fseek($this->handle, $offset, $whence)) {
|
||||
$this->position = ftell($this->handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function stream_tell()
|
||||
{
|
||||
return $this->position;
|
||||
@@ -78,20 +88,32 @@ if (PHP_VERSION_ID < 80000) {
|
||||
|
||||
public function stream_stat()
|
||||
{
|
||||
return fstat($this->handle);
|
||||
return array();
|
||||
}
|
||||
|
||||
public function stream_set_option($option, $arg1, $arg2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function url_stat($path, $flags)
|
||||
{
|
||||
$path = substr($path, 17);
|
||||
if (file_exists($path)) {
|
||||
return stat($path);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (function_exists('stream_wrapper_register') && stream_wrapper_register('composer-bin-proxy', 'Composer\BinProxyWrapper')) {
|
||||
include("composer-bin-proxy://" . __DIR__ . '/..'.'/matthiasmullie/minify/bin/minifyjs');
|
||||
exit(0);
|
||||
if (
|
||||
(function_exists('stream_get_wrappers') && in_array('phpvfscomposer', stream_get_wrappers(), true))
|
||||
|| (function_exists('stream_wrapper_register') && stream_wrapper_register('phpvfscomposer', 'Composer\BinProxyWrapper'))
|
||||
) {
|
||||
return include("phpvfscomposer://" . __DIR__ . '/..'.'/matthiasmullie/minify/bin/minifyjs');
|
||||
}
|
||||
}
|
||||
|
||||
include __DIR__ . '/..'.'/matthiasmullie/minify/bin/minifyjs';
|
||||
return include __DIR__ . '/..'.'/matthiasmullie/minify/bin/minifyjs';
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
name: Test Suite
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
mongodb:
|
||||
image: mongo
|
||||
ports:
|
||||
- 27017:27017
|
||||
myriadb:
|
||||
image: mariadb
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- 3808:3808
|
||||
- 3306:3306
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_DB: oauth2_server_php
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: --health-cmd="pg_isready -h localhost" --health-interval=10s --health-timeout=5s --health-retries=5
|
||||
strategy:
|
||||
matrix:
|
||||
php: [ 7.2, 7.3, 7.4, "8.0", 8.1, 8.2 ]
|
||||
name: "PHP ${{ matrix.php }} Unit Test"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: codecov/codecov-action@v1
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: mongodb, mbstring, intl, redis, pdo_mysql
|
||||
- name: Install composer dependencies
|
||||
uses: nick-invision/retry@v1
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: composer install
|
||||
- name: Run PHPUnit
|
||||
run: vendor/bin/phpunit -v
|
||||
phpstan:
|
||||
name: "PHPStan"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
- name: Install composer dependencies
|
||||
uses: nick-invision/retry@v1
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: composer install
|
||||
- name: Run PHPStan
|
||||
run: |
|
||||
composer require phpstan/phpstan
|
||||
vendor/bin/phpstan analyse --level=0 src/
|
||||
@@ -1,8 +1,6 @@
|
||||
oauth2-server-php
|
||||
=================
|
||||
|
||||
[](https://travis-ci.org/bshaffer/oauth2-server-php)
|
||||
|
||||
[](https://packagist.org/packages/bshaffer/oauth2-server-php)
|
||||
[](https://packagist.org/packages/bshaffer/oauth2-server-php)
|
||||
|
||||
View the [complete documentation](https://bshaffer.github.io/oauth2-server-php-docs/)
|
||||
@@ -16,21 +16,21 @@
|
||||
"psr-0": { "OAuth2": "src/" }
|
||||
},
|
||||
"require":{
|
||||
"php":">=5.3.9"
|
||||
"php":">=7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.0",
|
||||
"aws/aws-sdk-php": "~2.8",
|
||||
"firebase/php-jwt": "~2.2",
|
||||
"predis/predis": "dev-master",
|
||||
"phpunit/phpunit": "^7.5||^8.0",
|
||||
"aws/aws-sdk-php": "^2.8",
|
||||
"firebase/php-jwt": "^6.4",
|
||||
"predis/predis": "^1.1",
|
||||
"thobbs/phpcassa": "dev-master",
|
||||
"mongodb/mongodb": "^1.1"
|
||||
"yoast/phpunit-polyfills": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"predis/predis": "Required to use Redis storage",
|
||||
"thobbs/phpcassa": "Required to use Cassandra storage",
|
||||
"aws/aws-sdk-php": "~2.8 is required to use DynamoDB storage",
|
||||
"firebase/php-jwt": "~2.2 is required to use JWT features",
|
||||
"firebase/php-jwt": "~v6.4 is required to use JWT features",
|
||||
"mongodb/mongodb": "^1.1 is required to use MongoDB storage"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -39,7 +39,7 @@ class Autoloader
|
||||
* Handles autoloading of classes.
|
||||
*
|
||||
* @param string $class - A class name.
|
||||
* @return boolean - Returns true if the class has been loaded
|
||||
* @return void
|
||||
*/
|
||||
public function autoload($class)
|
||||
{
|
||||
|
||||
Vendored
+1
-1
@@ -112,7 +112,7 @@ class HttpBasic implements ClientAssertionTypeInterface
|
||||
*
|
||||
* @ingroup oauth2_section_2
|
||||
*/
|
||||
public function getClientCredentials(RequestInterface $request, ResponseInterface $response = null)
|
||||
public function getClientCredentials(RequestInterface $request, ?ResponseInterface $response = null)
|
||||
{
|
||||
if (!is_null($request->headers('PHP_AUTH_USER')) && !is_null($request->headers('PHP_AUTH_PW'))) {
|
||||
return array('client_id' => $request->headers('PHP_AUTH_USER'), 'client_secret' => $request->headers('PHP_AUTH_PW'));
|
||||
|
||||
Vendored
+2
-1
@@ -78,7 +78,7 @@ class AuthorizeController implements AuthorizeControllerInterface
|
||||
* );
|
||||
* @endcode
|
||||
*/
|
||||
public function __construct(ClientInterface $clientStorage, array $responseTypes = array(), array $config = array(), ScopeInterface $scopeUtil = null)
|
||||
public function __construct(ClientInterface $clientStorage, array $responseTypes = array(), array $config = array(), ?ScopeInterface $scopeUtil = null)
|
||||
{
|
||||
$this->clientStorage = $clientStorage;
|
||||
$this->responseTypes = $responseTypes;
|
||||
@@ -87,6 +87,7 @@ class AuthorizeController implements AuthorizeControllerInterface
|
||||
'enforce_state' => true,
|
||||
'require_exact_redirect_uri' => true,
|
||||
'redirect_status_code' => 302,
|
||||
'enforce_pkce' => false,
|
||||
), $config);
|
||||
|
||||
if (is_null($scopeUtil)) {
|
||||
|
||||
Vendored
+1
-1
@@ -47,7 +47,7 @@ class ResourceController implements ResourceControllerInterface
|
||||
* @param array $config
|
||||
* @param ScopeInterface $scopeUtil
|
||||
*/
|
||||
public function __construct(TokenTypeInterface $tokenType, AccessTokenInterface $tokenStorage, $config = array(), ScopeInterface $scopeUtil = null)
|
||||
public function __construct(TokenTypeInterface $tokenType, AccessTokenInterface $tokenStorage, $config = array(), ?ScopeInterface $scopeUtil = null)
|
||||
{
|
||||
$this->tokenType = $tokenType;
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ use OAuth2\ResponseInterface;
|
||||
* @code
|
||||
* if (!$resourceController->verifyResourceRequest(OAuth2\Request::createFromGlobals(), $response = new OAuth2\Response())) {
|
||||
* $response->send(); // authorization failed
|
||||
* exit();
|
||||
* die();
|
||||
* }
|
||||
* return json_encode($resource); // valid token! Send the stuff!
|
||||
* @endcode
|
||||
|
||||
Vendored
+1
-1
@@ -54,7 +54,7 @@ class TokenController implements TokenControllerInterface
|
||||
* @param ScopeInterface $scopeUtil
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function __construct(AccessTokenInterface $accessToken, ClientInterface $clientStorage, array $grantTypes = array(), ClientAssertionTypeInterface $clientAssertionType = null, ScopeInterface $scopeUtil = null)
|
||||
public function __construct(AccessTokenInterface $accessToken, ClientInterface $clientStorage, array $grantTypes = array(), ?ClientAssertionTypeInterface $clientAssertionType = null, ?ScopeInterface $scopeUtil = null)
|
||||
{
|
||||
if (is_null($clientAssertionType)) {
|
||||
foreach ($grantTypes as $grantType) {
|
||||
|
||||
+20
-7
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace OAuth2\Encryption;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
|
||||
/**
|
||||
* Bridge file to use the firebase/php-jwt package for JWT encoding and decoding.
|
||||
* @author Francis Chuang <francis.chuang@gmail.com>
|
||||
@@ -10,26 +13,36 @@ class FirebaseJwt implements EncryptionInterface
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
if (!class_exists('\JWT')) {
|
||||
if (!class_exists(JWT::class)) {
|
||||
throw new \ErrorException('firebase/php-jwt must be installed to use this feature. You can do this by running "composer require firebase/php-jwt"');
|
||||
}
|
||||
}
|
||||
|
||||
public function encode($payload, $key, $alg = 'HS256', $keyId = null)
|
||||
{
|
||||
return \JWT::encode($payload, $key, $alg, $keyId);
|
||||
return JWT::encode($payload, $key, $alg, $keyId);
|
||||
}
|
||||
|
||||
public function decode($jwt, $key = null, $allowedAlgorithms = null)
|
||||
{
|
||||
try {
|
||||
|
||||
//Maintain BC: Do not verify if no algorithms are passed in.
|
||||
if (!$allowedAlgorithms) {
|
||||
$key = null;
|
||||
$tks = \explode('.', $jwt);
|
||||
if (\count($tks) === 3) {
|
||||
[$headb64] = $tks;
|
||||
$headerRaw = JWT::urlsafeB64Decode($headb64);
|
||||
if (($header = JWT::jsonDecode($headerRaw))) {
|
||||
$key = new Key($key, $header->alg);
|
||||
}
|
||||
}
|
||||
} elseif(is_array($allowedAlgorithms)) {
|
||||
$key = new Key($key, $allowedAlgorithms[0]);
|
||||
} else {
|
||||
$key = new Key($key, $allowedAlgorithms);
|
||||
}
|
||||
|
||||
return (array)\JWT::decode($jwt, $key, $allowedAlgorithms);
|
||||
return (array) JWT::decode($jwt, $key);
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
@@ -37,11 +50,11 @@ class FirebaseJwt implements EncryptionInterface
|
||||
|
||||
public function urlSafeB64Encode($data)
|
||||
{
|
||||
return \JWT::urlsafeB64Encode($data);
|
||||
return JWT::urlsafeB64Encode($data);
|
||||
}
|
||||
|
||||
public function urlSafeB64Decode($b64)
|
||||
{
|
||||
return \JWT::urlsafeB64Decode($b64);
|
||||
return JWT::urlsafeB64Decode($b64);
|
||||
}
|
||||
}
|
||||
Vendored
+35
@@ -84,6 +84,41 @@ class AuthorizationCode implements GrantTypeInterface
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($authCode['code_challenge']) && $authCode['code_challenge']) {
|
||||
if (!($code_verifier = $request->request('code_verifier'))) {
|
||||
$response->setError(400, 'code_verifier_missing', "The PKCE code verifier parameter is required.");
|
||||
|
||||
return false;
|
||||
}
|
||||
// Validate code_verifier according to RFC-7636
|
||||
// @see: https://tools.ietf.org/html/rfc7636#section-4.1
|
||||
if (preg_match('/^[A-Za-z0-9-._~]{43,128}$/', $code_verifier) !== 1) {
|
||||
$response->setError(400, 'code_verifier_invalid', "The PKCE code verifier parameter is invalid.");
|
||||
|
||||
return false;
|
||||
}
|
||||
$code_verifier = $request->request('code_verifier');
|
||||
switch ($authCode['code_challenge_method']) {
|
||||
case 'S256':
|
||||
$code_verifier_hashed = strtr(rtrim(base64_encode(hash('sha256', $code_verifier, true)), '='), '+/', '-_');
|
||||
break;
|
||||
|
||||
case 'plain':
|
||||
$code_verifier_hashed = $code_verifier;
|
||||
break;
|
||||
|
||||
default:
|
||||
$response->setError(400, 'code_challenge_method_invalid', "Unknown PKCE code challenge method.");
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
if ($code_verifier_hashed !== $authCode['code_challenge']) {
|
||||
$response->setError(400, 'code_verifier_mismatch', "The PKCE code verifier parameter does not match the code challenge.");
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($authCode['code'])) {
|
||||
$authCode['code'] = $code; // used to expire the code after the access token is granted
|
||||
}
|
||||
|
||||
+5
-5
@@ -37,7 +37,7 @@ class JwtBearer implements GrantTypeInterface, ClientAssertionTypeInterface
|
||||
* @param EncryptionInterface|JWT $jwtUtil - OPTONAL The class used to decode, encode and verify JWTs.
|
||||
* @param array $config
|
||||
*/
|
||||
public function __construct(JwtBearerInterface $storage, $audience, EncryptionInterface $jwtUtil = null, array $config = array())
|
||||
public function __construct(JwtBearerInterface $storage, $audience, ?EncryptionInterface $jwtUtil = null, array $config = array())
|
||||
{
|
||||
$this->storage = $storage;
|
||||
$this->audience = $audience;
|
||||
@@ -46,13 +46,13 @@ class JwtBearer implements GrantTypeInterface, ClientAssertionTypeInterface
|
||||
$jwtUtil = new Jwt();
|
||||
}
|
||||
|
||||
$this->config = array_merge(array(
|
||||
$config = array_merge(array(
|
||||
'allowed_algorithms' => array('RS256', 'RS384', 'RS512')
|
||||
), $config);
|
||||
|
||||
$this->jwtUtil = $jwtUtil;
|
||||
|
||||
$this->allowedAlgorithms = $this->config['allowed_algorithms'];
|
||||
$this->allowedAlgorithms = $config['allowed_algorithms'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +127,7 @@ class JwtBearer implements GrantTypeInterface, ClientAssertionTypeInterface
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (ctype_digit($jwt['exp'])) {
|
||||
if (ctype_digit((string)$jwt['exp'])) {
|
||||
if ($jwt['exp'] <= time()) {
|
||||
$response->setError(400, 'invalid_grant', "JWT has expired");
|
||||
|
||||
@@ -141,7 +141,7 @@ class JwtBearer implements GrantTypeInterface, ClientAssertionTypeInterface
|
||||
|
||||
// Check the not before time
|
||||
if ($notBefore = $jwt['nbf']) {
|
||||
if (ctype_digit($notBefore)) {
|
||||
if (ctype_digit((string)$notBefore)) {
|
||||
if ($notBefore > time()) {
|
||||
$response->setError(400, 'invalid_grant', "JWT cannot be used before the Not Before (nbf) time");
|
||||
|
||||
|
||||
+40
@@ -16,6 +16,16 @@ class AuthorizeController extends BaseAuthorizeController implements AuthorizeCo
|
||||
*/
|
||||
private $nonce;
|
||||
|
||||
/**
|
||||
* @var mixed
|
||||
*/
|
||||
protected $code_challenge;
|
||||
|
||||
/**
|
||||
* @var mixed
|
||||
*/
|
||||
protected $code_challenge_method;
|
||||
|
||||
/**
|
||||
* Set not authorized response
|
||||
*
|
||||
@@ -65,6 +75,10 @@ class AuthorizeController extends BaseAuthorizeController implements AuthorizeCo
|
||||
// add the nonce to return with the redirect URI
|
||||
$params['nonce'] = $this->nonce;
|
||||
|
||||
// Add PKCE code challenge.
|
||||
$params['code_challenge'] = $this->code_challenge;
|
||||
$params['code_challenge_method'] = $this->code_challenge_method;
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
@@ -90,6 +104,32 @@ class AuthorizeController extends BaseAuthorizeController implements AuthorizeCo
|
||||
|
||||
$this->nonce = $nonce;
|
||||
|
||||
$code_challenge = $request->query('code_challenge');
|
||||
$code_challenge_method = $request->query('code_challenge_method');
|
||||
|
||||
if ($this->config['enforce_pkce']) {
|
||||
if (!$code_challenge) {
|
||||
$response->setError(400, 'missing_code_challenge', 'This application requires you provide a PKCE code challenge');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preg_match('/^[A-Za-z0-9-._~]{43,128}$/', $code_challenge) !== 1) {
|
||||
$response->setError(400, 'invalid_code_challenge', 'The PKCE code challenge supplied is invalid');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!in_array($code_challenge_method, array('plain', 'S256'), true)) {
|
||||
$response->setError(400, 'missing_code_challenge_method', 'This application requires you specify a PKCE code challenge method');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$this->code_challenge = $code_challenge;
|
||||
$this->code_challenge_method = $code_challenge_method;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ class UserInfoController extends ResourceController implements UserInfoControlle
|
||||
* @param array $config
|
||||
* @param ScopeInterface $scopeUtil
|
||||
*/
|
||||
public function __construct(TokenTypeInterface $tokenType, AccessTokenInterface $tokenStorage, UserClaimsInterface $userClaimsStorage, $config = array(), ScopeInterface $scopeUtil = null)
|
||||
public function __construct(TokenTypeInterface $tokenType, AccessTokenInterface $tokenStorage, UserClaimsInterface $userClaimsStorage, $config = array(), ?ScopeInterface $scopeUtil = null)
|
||||
{
|
||||
parent::__construct($tokenType, $tokenStorage, $config, $scopeUtil);
|
||||
|
||||
|
||||
+4
-4
@@ -31,9 +31,9 @@ class AuthorizationCode extends BaseAuthorizationCode implements AuthorizationCo
|
||||
// build the URL to redirect to
|
||||
$result = array('query' => array());
|
||||
|
||||
$params += array('scope' => null, 'state' => null, 'id_token' => null);
|
||||
$params += array('scope' => null, 'state' => null, 'id_token' => null, 'code_challenge' => null, 'code_challenge_method' => null);
|
||||
|
||||
$result['query']['code'] = $this->createAuthorizationCode($params['client_id'], $user_id, $params['redirect_uri'], $params['scope'], $params['id_token']);
|
||||
$result['query']['code'] = $this->createAuthorizationCode($params['client_id'], $user_id, $params['redirect_uri'], $params['scope'], $params['id_token'], $params['code_challenge'], $params['code_challenge_method']);
|
||||
|
||||
if (isset($params['state'])) {
|
||||
$result['query']['state'] = $params['state'];
|
||||
@@ -56,10 +56,10 @@ class AuthorizationCode extends BaseAuthorizationCode implements AuthorizationCo
|
||||
* @see http://tools.ietf.org/html/rfc6749#section-4
|
||||
* @ingroup oauth2_section_4
|
||||
*/
|
||||
public function createAuthorizationCode($client_id, $user_id, $redirect_uri, $scope = null, $id_token = null)
|
||||
public function createAuthorizationCode($client_id, $user_id, $redirect_uri, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null)
|
||||
{
|
||||
$code = $this->generateAuthorizationCode();
|
||||
$this->storage->setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, time() + $this->config['auth_code_lifetime'], $scope, $id_token);
|
||||
$this->storage->setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, time() + $this->config['auth_code_lifetime'], $scope, $id_token, $code_challenge, $code_challenge_method);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -38,7 +38,7 @@ class IdToken implements IdTokenInterface
|
||||
* @param EncryptionInterface $encryptionUtil
|
||||
* @throws LogicException
|
||||
*/
|
||||
public function __construct(UserClaimsInterface $userClaimsStorage, PublicKeyInterface $publicKeyStorage, array $config = array(), EncryptionInterface $encryptionUtil = null)
|
||||
public function __construct(UserClaimsInterface $userClaimsStorage, PublicKeyInterface $publicKeyStorage, array $config = array(), ?EncryptionInterface $encryptionUtil = null)
|
||||
{
|
||||
$this->userClaimsStorage = $userClaimsStorage;
|
||||
$this->publicKeyStorage = $publicKeyStorage;
|
||||
|
||||
+1
-1
@@ -33,5 +33,5 @@ interface AuthorizationCodeInterface extends BaseAuthorizationCodeInterface
|
||||
*
|
||||
* @ingroup oauth2_section_4
|
||||
*/
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null);
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class Request implements RequestInterface
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function __construct(array $query = array(), array $request = array(), array $attributes = array(), array $cookies = array(), array $files = array(), array $server = array(), $content = null, array $headers = null)
|
||||
public function __construct(array $query = array(), array $request = array(), array $attributes = array(), array $cookies = array(), array $files = array(), array $server = array(), $content = null, ?array $headers = null)
|
||||
{
|
||||
$this->initialize($query, $request, $attributes, $cookies, $files, $server, $content, $headers);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ class Request implements RequestInterface
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function initialize(array $query = array(), array $request = array(), array $attributes = array(), array $cookies = array(), array $files = array(), array $server = array(), $content = null, array $headers = null)
|
||||
public function initialize(array $query = array(), array $request = array(), array $attributes = array(), array $cookies = array(), array $files = array(), array $server = array(), $content = null, ?array $headers = null)
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->query = $query;
|
||||
|
||||
@@ -140,11 +140,10 @@ class Response implements ResponseInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $statusCode
|
||||
* @param string $text
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function setStatusCode($statusCode, $text = null)
|
||||
public function setStatusCode(int $statusCode, $text = null)
|
||||
{
|
||||
$this->statusCode = (int) $statusCode;
|
||||
if ($this->isInvalid()) {
|
||||
|
||||
+1
-4
@@ -20,10 +20,7 @@ interface ResponseInterface
|
||||
*/
|
||||
public function addHttpHeaders(array $httpHeaders);
|
||||
|
||||
/**
|
||||
* @param int $statusCode
|
||||
*/
|
||||
public function setStatusCode($statusCode);
|
||||
public function setStatusCode(int $statusCode);
|
||||
|
||||
/**
|
||||
* @param int $statusCode
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ class AccessToken implements AccessTokenInterface
|
||||
* );
|
||||
* @endcode
|
||||
*/
|
||||
public function __construct(AccessTokenStorageInterface $tokenStorage, RefreshTokenInterface $refreshStorage = null, array $config = array())
|
||||
public function __construct(AccessTokenStorageInterface $tokenStorage, ?RefreshTokenInterface $refreshStorage = null, array $config = array())
|
||||
{
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
$this->refreshStorage = $refreshStorage;
|
||||
|
||||
Vendored
+4
-4
@@ -26,9 +26,9 @@ class AuthorizationCode implements AuthorizationCodeInterface
|
||||
// build the URL to redirect to
|
||||
$result = array('query' => array());
|
||||
|
||||
$params += array('scope' => null, 'state' => null);
|
||||
$params += array('scope' => null, 'state' => null, 'code_challenge' => null, 'code_challenge_method' => null);
|
||||
|
||||
$result['query']['code'] = $this->createAuthorizationCode($params['client_id'], $user_id, $params['redirect_uri'], $params['scope']);
|
||||
$result['query']['code'] = $this->createAuthorizationCode($params['client_id'], $user_id, $params['redirect_uri'], $params['scope'], $params['code_challenge'], $params['code_challenge_method']);
|
||||
|
||||
if (isset($params['state'])) {
|
||||
$result['query']['state'] = $params['state'];
|
||||
@@ -53,10 +53,10 @@ class AuthorizationCode implements AuthorizationCodeInterface
|
||||
* @see http://tools.ietf.org/html/rfc6749#section-4
|
||||
* @ingroup oauth2_section_4
|
||||
*/
|
||||
public function createAuthorizationCode($client_id, $user_id, $redirect_uri, $scope = null)
|
||||
public function createAuthorizationCode($client_id, $user_id, $redirect_uri, $scope = null, $code_challenge = null, $code_challenge_method = null)
|
||||
{
|
||||
$code = $this->generateAuthorizationCode();
|
||||
$this->storage->setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, time() + $this->config['auth_code_lifetime'], $scope);
|
||||
$this->storage->setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, time() + $this->config['auth_code_lifetime'], $scope, null, $code_challenge, $code_challenge_method);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -26,7 +26,7 @@ class JwtAccessToken extends AccessToken
|
||||
* or just the token ID is stored
|
||||
* @param EncryptionInterface $encryptionUtil -
|
||||
*/
|
||||
public function __construct(PublicKeyInterface $publicKeyStorage = null, AccessTokenStorageInterface $tokenStorage = null, RefreshTokenInterface $refreshStorage = null, array $config = array(), EncryptionInterface $encryptionUtil = null)
|
||||
public function __construct(?PublicKeyInterface $publicKeyStorage = null, ?AccessTokenStorageInterface $tokenStorage = null, ?RefreshTokenInterface $refreshStorage = null, array $config = array(), ?EncryptionInterface $encryptionUtil = null)
|
||||
{
|
||||
$this->publicKeyStorage = $publicKeyStorage;
|
||||
$config = array_merge(array(
|
||||
|
||||
@@ -150,7 +150,7 @@ class Server implements ResourceControllerInterface,
|
||||
*
|
||||
* @ingroup oauth2_section_7
|
||||
*/
|
||||
public function __construct($storage = array(), array $config = array(), array $grantTypes = array(), array $responseTypes = array(), TokenTypeInterface $tokenType = null, ScopeInterface $scopeUtil = null, ClientAssertionTypeInterface $clientAssertionType = null)
|
||||
public function __construct($storage = array(), array $config = array(), array $grantTypes = array(), array $responseTypes = array(), ?TokenTypeInterface $tokenType = null, ?ScopeInterface $scopeUtil = null, ?ClientAssertionTypeInterface $clientAssertionType = null)
|
||||
{
|
||||
$storage = is_array($storage) ? $storage : array($storage);
|
||||
$this->storages = array();
|
||||
@@ -172,6 +172,7 @@ class Server implements ResourceControllerInterface,
|
||||
'enforce_state' => true,
|
||||
'require_exact_redirect_uri' => true,
|
||||
'allow_implicit' => false,
|
||||
'enforce_pkce' => false,
|
||||
'allow_credentials_in_request_body' => true,
|
||||
'allow_public_clients' => true,
|
||||
'always_issue_new_refresh_token' => false,
|
||||
@@ -288,7 +289,7 @@ class Server implements ResourceControllerInterface,
|
||||
*
|
||||
* @see http://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
||||
*/
|
||||
public function handleUserInfoRequest(RequestInterface $request, ResponseInterface $response = null)
|
||||
public function handleUserInfoRequest(RequestInterface $request, ?ResponseInterface $response = null)
|
||||
{
|
||||
$this->response = is_null($response) ? new Response() : $response;
|
||||
$this->getUserInfoController()->handleUserInfoRequest($request, $this->response);
|
||||
@@ -314,7 +315,7 @@ class Server implements ResourceControllerInterface,
|
||||
*
|
||||
* @ingroup oauth2_section_4
|
||||
*/
|
||||
public function handleTokenRequest(RequestInterface $request, ResponseInterface $response = null)
|
||||
public function handleTokenRequest(RequestInterface $request, ?ResponseInterface $response = null)
|
||||
{
|
||||
$this->response = is_null($response) ? new Response() : $response;
|
||||
$this->getTokenController()->handleTokenRequest($request, $this->response);
|
||||
@@ -327,7 +328,7 @@ class Server implements ResourceControllerInterface,
|
||||
* @param ResponseInterface $response - Response object
|
||||
* @return mixed
|
||||
*/
|
||||
public function grantAccessToken(RequestInterface $request, ResponseInterface $response = null)
|
||||
public function grantAccessToken(RequestInterface $request, ?ResponseInterface $response = null)
|
||||
{
|
||||
$this->response = is_null($response) ? new Response() : $response;
|
||||
$value = $this->getTokenController()->grantAccessToken($request, $this->response);
|
||||
@@ -345,7 +346,7 @@ class Server implements ResourceControllerInterface,
|
||||
* @param ResponseInterface $response
|
||||
* @return Response|ResponseInterface
|
||||
*/
|
||||
public function handleRevokeRequest(RequestInterface $request, ResponseInterface $response = null)
|
||||
public function handleRevokeRequest(RequestInterface $request, ?ResponseInterface $response = null)
|
||||
{
|
||||
$this->response = is_null($response) ? new Response() : $response;
|
||||
$this->getTokenController()->handleRevokeRequest($request, $this->response);
|
||||
@@ -407,7 +408,7 @@ class Server implements ResourceControllerInterface,
|
||||
*
|
||||
* @ingroup oauth2_section_3
|
||||
*/
|
||||
public function validateAuthorizeRequest(RequestInterface $request, ResponseInterface $response = null)
|
||||
public function validateAuthorizeRequest(RequestInterface $request, ?ResponseInterface $response = null)
|
||||
{
|
||||
$this->response = is_null($response) ? new Response() : $response;
|
||||
$value = $this->getAuthorizeController()->validateAuthorizeRequest($request, $this->response);
|
||||
@@ -421,7 +422,7 @@ class Server implements ResourceControllerInterface,
|
||||
* @param string $scope - Scope
|
||||
* @return mixed
|
||||
*/
|
||||
public function verifyResourceRequest(RequestInterface $request, ResponseInterface $response = null, $scope = null)
|
||||
public function verifyResourceRequest(RequestInterface $request, ?ResponseInterface $response = null, $scope = null)
|
||||
{
|
||||
$this->response = is_null($response) ? new Response() : $response;
|
||||
$value = $this->getResourceController()->verifyResourceRequest($request, $this->response, $scope);
|
||||
@@ -434,7 +435,7 @@ class Server implements ResourceControllerInterface,
|
||||
* @param ResponseInterface $response - Response object
|
||||
* @return mixed
|
||||
*/
|
||||
public function getAccessTokenData(RequestInterface $request, ResponseInterface $response = null)
|
||||
public function getAccessTokenData(RequestInterface $request, ?ResponseInterface $response = null)
|
||||
{
|
||||
$this->response = is_null($response) ? new Response() : $response;
|
||||
$value = $this->getResourceController()->getAccessTokenData($request, $this->response);
|
||||
@@ -577,7 +578,7 @@ class Server implements ResourceControllerInterface,
|
||||
}
|
||||
}
|
||||
|
||||
$config = array_intersect_key($this->config, array_flip(explode(' ', 'allow_implicit enforce_state require_exact_redirect_uri')));
|
||||
$config = array_intersect_key($this->config, array_flip(explode(' ', 'allow_implicit enforce_state require_exact_redirect_uri enforce_pkce')));
|
||||
|
||||
if ($this->config['use_openid_connect']) {
|
||||
return new OpenIDAuthorizeController($this->storages['client'], $this->responseTypes, $config, $this->getScopeUtil());
|
||||
|
||||
+2
-2
@@ -191,11 +191,11 @@ class Cassandra implements AuthorizationCodeInterface,
|
||||
* @param string $id_token
|
||||
* @return bool
|
||||
*/
|
||||
public function setAuthorizationCode($authorization_code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null)
|
||||
public function setAuthorizationCode($authorization_code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null)
|
||||
{
|
||||
return $this->setValue(
|
||||
$this->config['code_key'] . $authorization_code,
|
||||
compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token'),
|
||||
compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token', 'code_challenge', 'code_challenge_method'),
|
||||
$expires
|
||||
);
|
||||
}
|
||||
|
||||
+12
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace OAuth2\Storage;
|
||||
|
||||
use Couchbase;
|
||||
use OAuth2\OpenID\Storage\AuthorizationCodeInterface as OpenIDAuthorizationCodeInterface;
|
||||
|
||||
/**
|
||||
@@ -27,14 +28,18 @@ class CouchbaseDB implements AuthorizationCodeInterface,
|
||||
|
||||
public function __construct($connection, $config = array())
|
||||
{
|
||||
if ($connection instanceof \Couchbase) {
|
||||
if (!class_exists(Couchbase::class)) {
|
||||
throw new \RuntimeException('Missing Couchbase');
|
||||
}
|
||||
|
||||
if ($connection instanceof Couchbase) {
|
||||
$this->db = $connection;
|
||||
} else {
|
||||
if (!is_array($connection) || !is_array($connection['servers'])) {
|
||||
throw new \InvalidArgumentException('First argument to OAuth2\Storage\CouchbaseDB must be an instance of Couchbase or a configuration array containing a server array');
|
||||
}
|
||||
|
||||
$this->db = new \Couchbase($connection['servers'], (!isset($connection['username'])) ? '' : $connection['username'], (!isset($connection['password'])) ? '' : $connection['password'], $connection['bucket'], false);
|
||||
$this->db = new Couchbase($connection['servers'], (!isset($connection['username'])) ? '' : $connection['username'], (!isset($connection['password'])) ? '' : $connection['password'], $connection['bucket'], false);
|
||||
}
|
||||
|
||||
$this->config = array_merge(array(
|
||||
@@ -173,7 +178,7 @@ class CouchbaseDB implements AuthorizationCodeInterface,
|
||||
return is_null($code) ? false : $code;
|
||||
}
|
||||
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null)
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null)
|
||||
{
|
||||
// if it exists, update it.
|
||||
if ($this->getAuthorizationCode($code)) {
|
||||
@@ -185,6 +190,8 @@ class CouchbaseDB implements AuthorizationCodeInterface,
|
||||
'expires' => $expires,
|
||||
'scope' => $scope,
|
||||
'id_token' => $id_token,
|
||||
'code_challenge' => $code_challenge,
|
||||
'code_challenge_method' => $code_challenge_method,
|
||||
));
|
||||
} else {
|
||||
$this->setObjectByType('code_table',$code,array(
|
||||
@@ -195,6 +202,8 @@ class CouchbaseDB implements AuthorizationCodeInterface,
|
||||
'expires' => $expires,
|
||||
'scope' => $scope,
|
||||
'id_token' => $id_token,
|
||||
'code_challenge' => $code_challenge,
|
||||
'code_challenge_method' => $code_challenge_method,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -213,12 +213,12 @@ class DynamoDB implements
|
||||
|
||||
}
|
||||
|
||||
public function setAuthorizationCode($authorization_code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null)
|
||||
public function setAuthorizationCode($authorization_code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null)
|
||||
{
|
||||
// convert expires to datestring
|
||||
$expires = date('Y-m-d H:i:s', $expires);
|
||||
|
||||
$clientData = compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'id_token', 'scope');
|
||||
$clientData = compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token', 'code_challenge', 'code_challenge_method');
|
||||
$clientData = array_filter($clientData, 'self::isNotEmpty');
|
||||
|
||||
$result = $this->client->putItem(array(
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ class JwtAccessToken implements JwtAccessTokenInterface
|
||||
* is not necessary when using this grant type.
|
||||
* @param OAuth2\Encryption\EncryptionInterface $encryptionUtil OPTIONAL class to use for "encode" and "decode" functions.
|
||||
*/
|
||||
public function __construct(PublicKeyInterface $publicKeyStorage, AccessTokenInterface $tokenStorage = null, EncryptionInterface $encryptionUtil = null)
|
||||
public function __construct(PublicKeyInterface $publicKeyStorage, ?AccessTokenInterface $tokenStorage = null, ?EncryptionInterface $encryptionUtil = null)
|
||||
{
|
||||
$this->publicKeyStorage = $publicKeyStorage;
|
||||
$this->tokenStorage = $tokenStorage;
|
||||
|
||||
+2
-2
@@ -74,9 +74,9 @@ class Memory implements AuthorizationCodeInterface,
|
||||
), $this->authorizationCodes[$code]);
|
||||
}
|
||||
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null)
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null)
|
||||
{
|
||||
$this->authorizationCodes[$code] = compact('code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token');
|
||||
$this->authorizationCodes[$code] = compact('code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token', 'code_challenge', 'code_challenge_method');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
+5
-1
@@ -179,7 +179,7 @@ class Mongo implements AuthorizationCodeInterface,
|
||||
return is_null($code) ? false : $code;
|
||||
}
|
||||
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null)
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null)
|
||||
{
|
||||
// if it exists, update it.
|
||||
if ($this->getAuthorizationCode($code)) {
|
||||
@@ -192,6 +192,8 @@ class Mongo implements AuthorizationCodeInterface,
|
||||
'expires' => $expires,
|
||||
'scope' => $scope,
|
||||
'id_token' => $id_token,
|
||||
'code_challenge' => $code_challenge,
|
||||
'code_challenge_method' => $code_challenge_method,
|
||||
))
|
||||
);
|
||||
} else {
|
||||
@@ -203,6 +205,8 @@ class Mongo implements AuthorizationCodeInterface,
|
||||
'expires' => $expires,
|
||||
'scope' => $scope,
|
||||
'id_token' => $id_token,
|
||||
'code_challenge' => $code_challenge,
|
||||
'code_challenge_method' => $code_challenge_method,
|
||||
);
|
||||
$this->collection('code_table')->insert($token);
|
||||
}
|
||||
|
||||
+8
-1
@@ -32,6 +32,9 @@ class MongoDB implements AuthorizationCodeInterface,
|
||||
|
||||
public function __construct($connection, $config = array())
|
||||
{
|
||||
if (!class_exists(Database::class) || !class_exists(Client::class)) {
|
||||
throw new \LogicException('Missing MongoDB php extension. Please install mongodb.so');
|
||||
}
|
||||
if ($connection instanceof Database) {
|
||||
$this->db = $connection;
|
||||
} else {
|
||||
@@ -167,7 +170,7 @@ class MongoDB implements AuthorizationCodeInterface,
|
||||
return is_null($code) ? false : $code;
|
||||
}
|
||||
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null)
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null)
|
||||
{
|
||||
// if it exists, update it.
|
||||
if ($this->getAuthorizationCode($code)) {
|
||||
@@ -180,6 +183,8 @@ class MongoDB implements AuthorizationCodeInterface,
|
||||
'expires' => $expires,
|
||||
'scope' => $scope,
|
||||
'id_token' => $id_token,
|
||||
'code_challenge' => $code_challenge,
|
||||
'code_challenge_method' => $code_challenge_method,
|
||||
))
|
||||
);
|
||||
return $result->getMatchedCount() > 0;
|
||||
@@ -192,6 +197,8 @@ class MongoDB implements AuthorizationCodeInterface,
|
||||
'expires' => $expires,
|
||||
'scope' => $scope,
|
||||
'id_token' => $id_token,
|
||||
'code_challenge' => $code_challenge,
|
||||
'code_challenge_method' => $code_challenge_method,
|
||||
);
|
||||
$result = $this->collection('code_table')->insertOne($token);
|
||||
return $result->getInsertedCount() > 0;
|
||||
|
||||
+10
-8
@@ -247,7 +247,7 @@ class Pdo implements
|
||||
* @param string $id_token
|
||||
* @return bool|mixed
|
||||
*/
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null)
|
||||
public function setAuthorizationCode($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null)
|
||||
{
|
||||
if (func_num_args() > 6) {
|
||||
// we are calling with an id token
|
||||
@@ -259,12 +259,12 @@ class Pdo implements
|
||||
|
||||
// if it exists, update it.
|
||||
if ($this->getAuthorizationCode($code)) {
|
||||
$stmt = $this->db->prepare($sql = sprintf('UPDATE %s SET client_id=:client_id, user_id=:user_id, redirect_uri=:redirect_uri, expires=:expires, scope=:scope where authorization_code=:code', $this->config['code_table']));
|
||||
$stmt = $this->db->prepare($sql = sprintf('UPDATE %s SET client_id=:client_id, user_id=:user_id, redirect_uri=:redirect_uri, expires=:expires, scope=:scope, code_challenge=:code_challenge, code_challenge_method=:code_challenge_method where authorization_code=:code', $this->config['code_table']));
|
||||
} else {
|
||||
$stmt = $this->db->prepare(sprintf('INSERT INTO %s (authorization_code, client_id, user_id, redirect_uri, expires, scope) VALUES (:code, :client_id, :user_id, :redirect_uri, :expires, :scope)', $this->config['code_table']));
|
||||
$stmt = $this->db->prepare(sprintf('INSERT INTO %s (authorization_code, client_id, user_id, redirect_uri, expires, scope, code_challenge, code_challenge_method) VALUES (:code, :client_id, :user_id, :redirect_uri, :expires, :scope, :code_challenge, :code_challenge_method)', $this->config['code_table']));
|
||||
}
|
||||
|
||||
return $stmt->execute(compact('code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope'));
|
||||
return $stmt->execute(compact('code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'code_challenge', 'code_challenge_method'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,19 +277,19 @@ class Pdo implements
|
||||
* @param string $id_token
|
||||
* @return bool
|
||||
*/
|
||||
private function setAuthorizationCodeWithIdToken($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null)
|
||||
private function setAuthorizationCodeWithIdToken($code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null)
|
||||
{
|
||||
// convert expires to datestring
|
||||
$expires = date('Y-m-d H:i:s', $expires);
|
||||
|
||||
// if it exists, update it.
|
||||
if ($this->getAuthorizationCode($code)) {
|
||||
$stmt = $this->db->prepare($sql = sprintf('UPDATE %s SET client_id=:client_id, user_id=:user_id, redirect_uri=:redirect_uri, expires=:expires, scope=:scope, id_token =:id_token where authorization_code=:code', $this->config['code_table']));
|
||||
$stmt = $this->db->prepare($sql = sprintf('UPDATE %s SET client_id=:client_id, user_id=:user_id, redirect_uri=:redirect_uri, expires=:expires, scope=:scope, id_token =:id_token, code_challenge=:code_challenge, code_challenge_method=:code_challenge_method where authorization_code=:code', $this->config['code_table']));
|
||||
} else {
|
||||
$stmt = $this->db->prepare(sprintf('INSERT INTO %s (authorization_code, client_id, user_id, redirect_uri, expires, scope, id_token) VALUES (:code, :client_id, :user_id, :redirect_uri, :expires, :scope, :id_token)', $this->config['code_table']));
|
||||
$stmt = $this->db->prepare(sprintf('INSERT INTO %s (authorization_code, client_id, user_id, redirect_uri, expires, scope, id_token, code_challenge, code_challenge_method) VALUES (:code, :client_id, :user_id, :redirect_uri, :expires, :scope, :id_token, :code_challenge, :code_challenge_method)', $this->config['code_table']));
|
||||
}
|
||||
|
||||
return $stmt->execute(compact('code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token'));
|
||||
return $stmt->execute(compact('code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token', 'code_challenge', 'code_challenge_method'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -676,6 +676,8 @@ class Pdo implements
|
||||
expires TIMESTAMP NOT NULL,
|
||||
scope VARCHAR(4000),
|
||||
id_token VARCHAR(1000),
|
||||
code_challenge VARCHAR(1000),
|
||||
code_challenge_method VARCHAR(20),
|
||||
PRIMARY KEY (authorization_code)
|
||||
);
|
||||
|
||||
|
||||
+2
-2
@@ -95,11 +95,11 @@ class Redis implements AuthorizationCodeInterface,
|
||||
return $this->getValue($this->config['code_key'] . $code);
|
||||
}
|
||||
|
||||
public function setAuthorizationCode($authorization_code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null)
|
||||
public function setAuthorizationCode($authorization_code, $client_id, $user_id, $redirect_uri, $expires, $scope = null, $id_token = null, $code_challenge = null, $code_challenge_method = null)
|
||||
{
|
||||
return $this->setValue(
|
||||
$this->config['code_key'] . $authorization_code,
|
||||
compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token'),
|
||||
compact('authorization_code', 'client_id', 'user_id', 'redirect_uri', 'expires', 'scope', 'id_token', 'code_challenge', 'code_challenge_method'),
|
||||
$expires
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ class Bearer implements TokenTypeInterface
|
||||
}
|
||||
|
||||
$contentType = $request->server('CONTENT_TYPE');
|
||||
if (false !== $pos = strpos($contentType, ';')) {
|
||||
if (false !== $pos = strpos((string) $contentType, ';')) {
|
||||
$contentType = substr($contentType, 0, $pos);
|
||||
}
|
||||
|
||||
|
||||
+8
-8
@@ -133,7 +133,7 @@ class AuthorizeControllerTest extends TestCase
|
||||
$server->handleAuthorizeRequest($request, $response = new Response(), true);
|
||||
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$this->assertNotContains('error', $response->getHttpHeader('Location'));
|
||||
$this->assertStringNotContainsString('error', $response->getHttpHeader('Location'));
|
||||
}
|
||||
|
||||
public function testEnforceScope()
|
||||
@@ -161,7 +161,7 @@ class AuthorizeControllerTest extends TestCase
|
||||
$server->handleAuthorizeRequest($request, $response = new Response(), true);
|
||||
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$this->assertNotContains('error', $response->getHttpHeader('Location'));
|
||||
$this->assertStringNotContainsString('error', $response->getHttpHeader('Location'));
|
||||
}
|
||||
|
||||
public function testInvalidRedirectUri()
|
||||
@@ -227,7 +227,7 @@ class AuthorizeControllerTest extends TestCase
|
||||
$server->handleAuthorizeRequest($request, $response = new Response(), true);
|
||||
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$this->assertContains('code', $response->getHttpHeader('Location'));
|
||||
$this->assertStringContainsString('code', $response->getHttpHeader('Location'));
|
||||
}
|
||||
|
||||
public function testRedirectUriWithDifferentQueryAndExactMatchRequired()
|
||||
@@ -263,7 +263,7 @@ class AuthorizeControllerTest extends TestCase
|
||||
$server->handleAuthorizeRequest($request, $response = new Response(), true);
|
||||
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$this->assertContains('code', $response->getHttpHeader('Location'));
|
||||
$this->assertStringContainsString('code', $response->getHttpHeader('Location'));
|
||||
}
|
||||
|
||||
public function testMultipleRedirectUris()
|
||||
@@ -278,14 +278,14 @@ class AuthorizeControllerTest extends TestCase
|
||||
|
||||
$server->handleAuthorizeRequest($request, $response = new Response(), true);
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$this->assertContains('code', $response->getHttpHeader('Location'));
|
||||
$this->assertStringContainsString('code', $response->getHttpHeader('Location'));
|
||||
|
||||
// call again with different (but still valid) redirect URI
|
||||
$request->query['redirect_uri'] = 'http://morehazards.com';
|
||||
|
||||
$server->handleAuthorizeRequest($request, $response = new Response(), true);
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$this->assertContains('code', $response->getHttpHeader('Location'));
|
||||
$this->assertStringContainsString('code', $response->getHttpHeader('Location'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,7 +303,7 @@ class AuthorizeControllerTest extends TestCase
|
||||
|
||||
$server->handleAuthorizeRequest($request, $response = new Response(), true);
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$this->assertContains('state', $response->getHttpHeader('Location'));
|
||||
$this->assertStringContainsString('state', $response->getHttpHeader('Location'));
|
||||
$this->assertStringStartsWith('http://brentertainment.com?code=', $response->getHttpHeader('Location'));
|
||||
|
||||
$parts = parse_url($response->getHttpHeader('Location'));
|
||||
@@ -421,7 +421,7 @@ class AuthorizeControllerTest extends TestCase
|
||||
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$location = $response->getHttpHeader('Location');
|
||||
$this->assertNotContains('error', $location);
|
||||
$this->assertStringNotContainsString('error', $location);
|
||||
|
||||
$parts = parse_url($location);
|
||||
$this->assertFalse(isset($parts['fake']));
|
||||
|
||||
Vendored
+1
-1
@@ -9,7 +9,7 @@ class FirebaseJwtTest extends TestCase
|
||||
{
|
||||
private $privateKey;
|
||||
|
||||
public function setUp()
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->privateKey = <<<EOD
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ class JwtTest extends TestCase
|
||||
{
|
||||
private $privateKey;
|
||||
|
||||
public function setUp()
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->privateKey = <<<EOD
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ class JwtBearerTest extends TestCase
|
||||
{
|
||||
private $privateKey;
|
||||
|
||||
public function setUp()
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->privateKey = <<<EOD
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
|
||||
+3
-3
@@ -29,7 +29,7 @@ class CodeIdTokenTest extends TestCase
|
||||
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$location = $response->getHttpHeader('Location');
|
||||
$this->assertNotContains('error', $location);
|
||||
$this->assertStringNotContainsString('error', $location);
|
||||
|
||||
$parts = parse_url($location);
|
||||
$this->assertArrayHasKey('query', $parts);
|
||||
@@ -87,7 +87,7 @@ class CodeIdTokenTest extends TestCase
|
||||
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$location = $response->getHttpHeader('Location');
|
||||
$this->assertNotContains('error', $location);
|
||||
$this->assertStringNotContainsString('error', $location);
|
||||
|
||||
$parts = parse_url($location);
|
||||
$this->assertArrayHasKey('query', $parts);
|
||||
@@ -133,7 +133,7 @@ class CodeIdTokenTest extends TestCase
|
||||
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$location = $response->getHttpHeader('Location');
|
||||
$this->assertNotContains('error', $location);
|
||||
$this->assertStringNotContainsString('error', $location);
|
||||
|
||||
$parts = parse_url($location);
|
||||
$this->assertArrayHasKey('query', $parts);
|
||||
|
||||
Vendored
+2
-2
@@ -52,7 +52,7 @@ class IdTokenTest extends TestCase
|
||||
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$location = $response->getHttpHeader('Location');
|
||||
$this->assertNotContains('error', $location);
|
||||
$this->assertStringNotContainsString('error', $location);
|
||||
|
||||
$parts = parse_url($location);
|
||||
$this->assertArrayHasKey('fragment', $parts);
|
||||
@@ -110,7 +110,7 @@ class IdTokenTest extends TestCase
|
||||
{
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$location = $response->getHttpHeader('Location');
|
||||
$this->assertNotContains('error', $location);
|
||||
$this->assertStringNotContainsString('error', $location);
|
||||
|
||||
$parts = parse_url($location);
|
||||
$this->assertArrayHasKey('fragment', $parts);
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ class IdTokenTokenTest extends TestCase
|
||||
|
||||
$this->assertEquals($response->getStatusCode(), 302);
|
||||
$location = $response->getHttpHeader('Location');
|
||||
$this->assertNotContains('error', $location);
|
||||
$this->assertStringNotContainsString('error', $location);
|
||||
|
||||
$parts = parse_url($location);
|
||||
$this->assertArrayHasKey('fragment', $parts);
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ class ResponseTest extends TestCase
|
||||
));
|
||||
|
||||
$string = $response->getResponseBody('xml');
|
||||
$this->assertContains('<response><foo>bar</foo><halland>oates</halland></response>', $string);
|
||||
$this->assertStringContainsString('<response><foo>bar</foo><halland>oates</halland></response>', $string);
|
||||
}
|
||||
|
||||
public function testSetRedirect()
|
||||
|
||||
Vendored
+1
-1
@@ -162,7 +162,7 @@ class JwtAccessTokenTest extends TestCase
|
||||
$memoryStorage = Bootstrap::getInstance()->getMemoryStorage();
|
||||
|
||||
$storage = array(
|
||||
'access_token' => new JwtAccessTokenStorage($memoryStorage),
|
||||
'access_token' => new JwtAccessTokenStorage($memoryStorage, $memoryStorage),
|
||||
'client' => $memoryStorage,
|
||||
'client_credentials' => $memoryStorage,
|
||||
);
|
||||
|
||||
+99
-135
@@ -6,27 +6,26 @@ use OAuth2\Request\TestRequest;
|
||||
use OAuth2\ResponseType\AuthorizationCode;
|
||||
use OAuth2\Storage\Bootstrap;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Yoast\PHPUnitPolyfills\Polyfills\ExpectPHPException;
|
||||
|
||||
class ServerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @expectedException LogicException OAuth2\Storage\ClientInterface
|
||||
**/
|
||||
use ExpectPHPException;
|
||||
|
||||
public function testGetAuthorizeControllerWithNoClientStorageThrowsException()
|
||||
{
|
||||
$this->expectExceptionMessage('OAuth2\Storage\ClientInterface');
|
||||
// must set Client Storage
|
||||
$server = new Server();
|
||||
$server->getAuthorizeController();
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException OAuth2\Storage\AccessTokenInterface
|
||||
**/
|
||||
public function testGetAuthorizeControllerWithNoAccessTokenStorageThrowsException()
|
||||
{
|
||||
$this->expectExceptionMessage('OAuth2\Storage\AccessTokenInterface');
|
||||
// must set AccessToken or AuthorizationCode
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->getAuthorizeController();
|
||||
}
|
||||
|
||||
@@ -34,8 +33,8 @@ class ServerTest extends TestCase
|
||||
{
|
||||
// must set AccessToken or AuthorizationCode
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->addResponseType($this->getMock('OAuth2\ResponseType\AccessTokenInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->addResponseType($this->createMock('OAuth2\ResponseType\AccessTokenInterface'));
|
||||
|
||||
$this->assertNotNull($server->getAuthorizeController());
|
||||
}
|
||||
@@ -44,21 +43,19 @@ class ServerTest extends TestCase
|
||||
{
|
||||
// must set AccessToken or AuthorizationCode
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->addResponseType($this->getMock('OAuth2\ResponseType\AuthorizationCodeInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->addResponseType($this->createMock('OAuth2\ResponseType\AuthorizationCodeInterface'));
|
||||
|
||||
$this->assertNotNull($server->getAuthorizeController());
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException allow_implicit
|
||||
**/
|
||||
public function testGetAuthorizeControllerWithClientStorageAndAccessTokenStorageThrowsException()
|
||||
{
|
||||
// must set AuthorizationCode or AccessToken / implicit
|
||||
$this->expectExceptionMessage('allow_implicit');
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\AccessTokenInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\AccessTokenInterface'));
|
||||
|
||||
$this->assertNotNull($server->getAuthorizeController());
|
||||
}
|
||||
@@ -67,8 +64,8 @@ class ServerTest extends TestCase
|
||||
{
|
||||
// must set AuthorizationCode or AccessToken / implicit
|
||||
$server = new Server(array(), array('allow_implicit' => true));
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\AccessTokenInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\AccessTokenInterface'));
|
||||
|
||||
$this->assertNotNull($server->getAuthorizeController());
|
||||
}
|
||||
@@ -77,63 +74,55 @@ class ServerTest extends TestCase
|
||||
{
|
||||
// must set AccessToken or AuthorizationCode
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\AuthorizationCodeInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\ClientInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\AuthorizationCodeInterface'));
|
||||
|
||||
$this->assertNotNull($server->getAuthorizeController());
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException grant_types
|
||||
**/
|
||||
public function testGetTokenControllerWithGrantTypeStorageThrowsException()
|
||||
{
|
||||
$this->expectExceptionMessage('grant_types');
|
||||
$server = new Server();
|
||||
$server->getTokenController();
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException OAuth2\Storage\ClientCredentialsInterface
|
||||
**/
|
||||
public function testGetTokenControllerWithNoClientCredentialsStorageThrowsException()
|
||||
{
|
||||
$this->expectExceptionMessage('OAuth2\Storage\ClientCredentialsInterface');
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\UserCredentialsInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\UserCredentialsInterface'));
|
||||
$server->getTokenController();
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException OAuth2\Storage\AccessTokenInterface
|
||||
**/
|
||||
public function testGetTokenControllerWithNoAccessTokenStorageThrowsException()
|
||||
{
|
||||
$this->expectExceptionMessage('OAuth2\Storage\AccessTokenInterface');
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\ClientCredentialsInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\ClientCredentialsInterface'));
|
||||
$server->getTokenController();
|
||||
}
|
||||
|
||||
public function testGetTokenControllerWithAccessTokenAndClientCredentialsStorage()
|
||||
{
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\AccessTokenInterface'));
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\ClientCredentialsInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\AccessTokenInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\ClientCredentialsInterface'));
|
||||
$server->getTokenController();
|
||||
}
|
||||
|
||||
public function testGetTokenControllerAccessTokenStorageAndClientCredentialsStorageAndGrantTypes()
|
||||
{
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\AccessTokenInterface'));
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\ClientCredentialsInterface'));
|
||||
$server->addGrantType($this->getMockBuilder('OAuth2\GrantType\AuthorizationCode')->disableOriginalConstructor()->getMock());
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\AccessTokenInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\ClientCredentialsInterface'));
|
||||
$server->addGrantType($this->createMock('OAuth2\GrantType\AuthorizationCode'));
|
||||
$server->getTokenController();
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException OAuth2\Storage\AccessTokenInterface
|
||||
**/
|
||||
public function testGetResourceControllerWithNoAccessTokenStorageThrowsException()
|
||||
{
|
||||
$this->expectExceptionMessage('OAuth2\Storage\AccessTokenInterface');
|
||||
$server = new Server();
|
||||
$server->getResourceController();
|
||||
}
|
||||
@@ -141,41 +130,35 @@ class ServerTest extends TestCase
|
||||
public function testGetResourceControllerWithAccessTokenStorage()
|
||||
{
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\AccessTokenInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\AccessTokenInterface'));
|
||||
$server->getResourceController();
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException InvalidArgumentException OAuth2\Storage\AccessTokenInterface
|
||||
**/
|
||||
public function testAddingStorageWithInvalidClass()
|
||||
{
|
||||
$this->expectExceptionMessage('OAuth2\Storage\AccessTokenInterface');
|
||||
$server = new Server();
|
||||
$server->addStorage(new \StdClass());
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException InvalidArgumentException access_token
|
||||
**/
|
||||
public function testAddingStorageWithInvalidKey()
|
||||
{
|
||||
$this->expectExceptionMessage('access_token');
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\AccessTokenInterface'), 'nonexistant_storage');
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\AccessTokenInterface'), 'nonexistant_storage');
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException InvalidArgumentException OAuth2\Storage\AuthorizationCodeInterface
|
||||
**/
|
||||
public function testAddingStorageWithInvalidKeyStorageCombination()
|
||||
{
|
||||
$this->expectExceptionMessage('OAuth2\Storage\AuthorizationCodeInterface');
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\AccessTokenInterface'), 'authorization_code');
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\AccessTokenInterface'), 'authorization_code');
|
||||
}
|
||||
|
||||
public function testAddingStorageWithValidKeyOnlySetsThatKey()
|
||||
{
|
||||
$server = new Server();
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\Memory'), 'access_token');
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\Memory'), 'access_token');
|
||||
|
||||
$reflection = new \ReflectionClass($server);
|
||||
$prop = $reflection->getProperty('storages');
|
||||
@@ -191,7 +174,7 @@ class ServerTest extends TestCase
|
||||
public function testAddingClientStorageSetsClientCredentialsStorageByDefault()
|
||||
{
|
||||
$server = new Server();
|
||||
$memory = $this->getMock('OAuth2\Storage\Memory');
|
||||
$memory = $this->createMock('OAuth2\Storage\Memory');
|
||||
$server->addStorage($memory, 'client');
|
||||
|
||||
$client_credentials = $server->getStorage('client_credentials');
|
||||
@@ -202,7 +185,7 @@ class ServerTest extends TestCase
|
||||
|
||||
public function testAddStorageWithNullValue()
|
||||
{
|
||||
$memory = $this->getMock('OAuth2\Storage\Memory');
|
||||
$memory = $this->createMock('OAuth2\Storage\Memory');
|
||||
$server = new Server($memory);
|
||||
$server->addStorage(null, 'refresh_token');
|
||||
|
||||
@@ -218,7 +201,7 @@ class ServerTest extends TestCase
|
||||
|
||||
public function testNewServerWithNullStorageValue()
|
||||
{
|
||||
$memory = $this->getMock('OAuth2\Storage\Memory');
|
||||
$memory = $this->createMock('OAuth2\Storage\Memory');
|
||||
$server = new Server(array(
|
||||
'client_credentials' => $memory,
|
||||
'refresh_token' => null,
|
||||
@@ -237,7 +220,7 @@ class ServerTest extends TestCase
|
||||
public function testAddingClientCredentialsStorageSetsClientStorageByDefault()
|
||||
{
|
||||
$server = new Server();
|
||||
$memory = $this->getMock('OAuth2\Storage\Memory');
|
||||
$memory = $this->createMock('OAuth2\Storage\Memory');
|
||||
$server->addStorage($memory, 'client_credentials');
|
||||
|
||||
$client = $server->getStorage('client');
|
||||
@@ -249,10 +232,9 @@ class ServerTest extends TestCase
|
||||
public function testSettingClientStorageByDefaultDoesNotOverrideSetStorage()
|
||||
{
|
||||
$server = new Server();
|
||||
$pdo = $this->getMockBuilder('OAuth2\Storage\Pdo')
|
||||
->disableOriginalConstructor()->getMock();
|
||||
$pdo = $this->createMock('OAuth2\Storage\Pdo');
|
||||
|
||||
$memory = $this->getMock('OAuth2\Storage\Memory');
|
||||
$memory = $this->createMock('OAuth2\Storage\Memory');
|
||||
|
||||
$server->addStorage($pdo, 'client');
|
||||
$server->addStorage($memory, 'client_credentials');
|
||||
@@ -266,7 +248,7 @@ class ServerTest extends TestCase
|
||||
|
||||
public function testAddingResponseType()
|
||||
{
|
||||
$storage = $this->getMock('OAuth2\Storage\Memory');
|
||||
$storage = $this->createMock('OAuth2\Storage\Memory');
|
||||
$storage
|
||||
->expects($this->any())
|
||||
->method('getClientDetails')
|
||||
@@ -323,7 +305,7 @@ class ServerTest extends TestCase
|
||||
'code' => 'testcode',
|
||||
));
|
||||
// verify the mock clientAssertionType was called as expected
|
||||
$clientAssertionType = $this->getMock('OAuth2\ClientAssertionType\ClientAssertionTypeInterface', array('validateRequest', 'getClientId'));
|
||||
$clientAssertionType = $this->createMock('OAuth2\ClientAssertionType\ClientAssertionTypeInterface');
|
||||
$clientAssertionType
|
||||
->expects($this->once())
|
||||
->method('validateRequest')
|
||||
@@ -420,98 +402,84 @@ class ServerTest extends TestCase
|
||||
$this->assertFalse($used_token, 'the refresh token used is no longer valid');
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException InvalidArgumentException OAuth2\ResponseType\AuthorizationCodeInterface
|
||||
**/
|
||||
public function testAddingUnknownResponseTypeThrowsException()
|
||||
{
|
||||
$this->expectExceptionMessage('OAuth2\ResponseType\AuthorizationCodeInterface');
|
||||
$server = new Server();
|
||||
$server->addResponseType($this->getMock('OAuth2\ResponseType\ResponseTypeInterface'));
|
||||
$server->addResponseType($this->createMock('OAuth2\ResponseType\ResponseTypeInterface'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException OAuth2\Storage\PublicKeyInterface
|
||||
**/
|
||||
public function testUsingJwtAccessTokensWithoutPublicKeyStorageThrowsException()
|
||||
{
|
||||
$this->expectExceptionMessage('OAuth2\Storage\PublicKeyInterface');
|
||||
$server = new Server(array(), array('use_jwt_access_tokens' => true));
|
||||
$server->addGrantType($this->getMock('OAuth2\GrantType\GrantTypeInterface'));
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\ClientCredentialsInterface'));
|
||||
$server->addStorage($this->getMock('OAuth2\Storage\ClientCredentialsInterface'));
|
||||
$server->addGrantType($this->createMock('OAuth2\GrantType\GrantTypeInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\ClientCredentialsInterface'));
|
||||
$server->addStorage($this->createMock('OAuth2\Storage\ClientCredentialsInterface'));
|
||||
|
||||
$server->getTokenController();
|
||||
}
|
||||
|
||||
public function testUsingJustJwtAccessTokenStorageWithResourceControllerIsOkay()
|
||||
{
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$server = new Server(array($pubkey), array('use_jwt_access_tokens' => true));
|
||||
|
||||
$this->assertNotNull($server->getResourceController());
|
||||
$this->assertInstanceOf('OAuth2\Storage\PublicKeyInterface', $server->getStorage('public_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException OAuth2\Storage\ClientInterface
|
||||
**/
|
||||
public function testUsingJustJwtAccessTokenStorageWithAuthorizeControllerThrowsException()
|
||||
{
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$this->expectExceptionMessage('OAuth2\Storage\ClientInterface');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$server = new Server(array($pubkey), array('use_jwt_access_tokens' => true));
|
||||
$this->assertNotNull($server->getAuthorizeController());
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException grant_types
|
||||
**/
|
||||
public function testUsingJustJwtAccessTokenStorageWithTokenControllerThrowsException()
|
||||
{
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$this->expectExceptionMessage('grant_types');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$server = new Server(array($pubkey), array('use_jwt_access_tokens' => true));
|
||||
$server->getTokenController();
|
||||
}
|
||||
|
||||
public function testUsingJwtAccessTokenAndClientStorageWithAuthorizeControllerIsOkay()
|
||||
{
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$client = $this->getMock('OAuth2\Storage\ClientInterface');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$client = $this->createMock('OAuth2\Storage\ClientInterface');
|
||||
$server = new Server(array($pubkey, $client), array('use_jwt_access_tokens' => true, 'allow_implicit' => true));
|
||||
$this->assertNotNull($server->getAuthorizeController());
|
||||
|
||||
$this->assertInstanceOf('OAuth2\ResponseType\JwtAccessToken', $server->getResponseType('token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException UserClaims
|
||||
**/
|
||||
public function testUsingOpenIDConnectWithoutUserClaimsThrowsException()
|
||||
{
|
||||
$client = $this->getMock('OAuth2\Storage\ClientInterface');
|
||||
$this->expectExceptionMessage('UserClaims');
|
||||
$client = $this->createMock('OAuth2\Storage\ClientInterface');
|
||||
$server = new Server($client, array('use_openid_connect' => true));
|
||||
|
||||
$server->getAuthorizeController();
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException PublicKeyInterface
|
||||
**/
|
||||
public function testUsingOpenIDConnectWithoutPublicKeyThrowsException()
|
||||
{
|
||||
$client = $this->getMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->getMock('OAuth2\OPenID\Storage\UserClaimsInterface');
|
||||
$this->expectExceptionMessage('PublicKeyInterface');
|
||||
$client = $this->createMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->createMock('OAuth2\OPenID\Storage\UserClaimsInterface');
|
||||
$server = new Server(array($client, $userclaims), array('use_openid_connect' => true));
|
||||
|
||||
$server->getAuthorizeController();
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException issuer
|
||||
**/
|
||||
public function testUsingOpenIDConnectWithoutIssuerThrowsException()
|
||||
{
|
||||
$client = $this->getMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$this->expectExceptionMessage('issuer');
|
||||
$client = $this->createMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->createMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$server = new Server(array($client, $userclaims, $pubkey), array('use_openid_connect' => true));
|
||||
|
||||
$server->getAuthorizeController();
|
||||
@@ -519,9 +487,9 @@ class ServerTest extends TestCase
|
||||
|
||||
public function testUsingOpenIDConnectWithIssuerPublicKeyAndUserClaimsIsOkay()
|
||||
{
|
||||
$client = $this->getMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$client = $this->createMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->createMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$server = new Server(array($client, $userclaims, $pubkey), array(
|
||||
'use_openid_connect' => true,
|
||||
'issuer' => 'someguy',
|
||||
@@ -533,14 +501,12 @@ class ServerTest extends TestCase
|
||||
$this->assertNull($server->getResponseType('id_token token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException OAuth2\ResponseType\AccessTokenInterface
|
||||
**/
|
||||
public function testUsingOpenIDConnectWithAllowImplicitWithoutTokenStorageThrowsException()
|
||||
{
|
||||
$client = $this->getMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$this->expectErrorMessage('OAuth2\ResponseType\AccessTokenInterface');
|
||||
$client = $this->createMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->createMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$server = new Server(array($client, $userclaims, $pubkey), array(
|
||||
'use_openid_connect' => true,
|
||||
'issuer' => 'someguy',
|
||||
@@ -552,9 +518,9 @@ class ServerTest extends TestCase
|
||||
|
||||
public function testUsingOpenIDConnectWithAllowImplicitAndUseJwtAccessTokensIsOkay()
|
||||
{
|
||||
$client = $this->getMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$client = $this->createMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->createMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$server = new Server(array($client, $userclaims, $pubkey), array(
|
||||
'use_openid_connect' => true,
|
||||
'issuer' => 'someguy',
|
||||
@@ -570,10 +536,10 @@ class ServerTest extends TestCase
|
||||
|
||||
public function testUsingOpenIDConnectWithAllowImplicitAndAccessTokenStorageIsOkay()
|
||||
{
|
||||
$client = $this->getMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$token = $this->getMock('OAuth2\Storage\AccessTokenInterface');
|
||||
$client = $this->createMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->createMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$token = $this->createMock('OAuth2\Storage\AccessTokenInterface');
|
||||
$server = new Server(array($client, $userclaims, $pubkey, $token), array(
|
||||
'use_openid_connect' => true,
|
||||
'issuer' => 'someguy',
|
||||
@@ -588,17 +554,17 @@ class ServerTest extends TestCase
|
||||
|
||||
public function testUsingOpenIDConnectWithAllowImplicitAndAccessTokenResponseTypeIsOkay()
|
||||
{
|
||||
$client = $this->getMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
// $token = $this->getMock('OAuth2\Storage\AccessTokenInterface');
|
||||
$client = $this->createMock('OAuth2\Storage\ClientInterface');
|
||||
$userclaims = $this->createMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
// $token = $this->createMock('OAuth2\Storage\AccessTokenInterface');
|
||||
$server = new Server(array($client, $userclaims, $pubkey), array(
|
||||
'use_openid_connect' => true,
|
||||
'issuer' => 'someguy',
|
||||
'allow_implicit' => true,
|
||||
));
|
||||
|
||||
$token = $this->getMock('OAuth2\ResponseType\AccessTokenInterface');
|
||||
$token = $this->createMock('OAuth2\ResponseType\AccessTokenInterface');
|
||||
$server->addResponseType($token, 'token');
|
||||
|
||||
$server->getAuthorizeController();
|
||||
@@ -607,17 +573,15 @@ class ServerTest extends TestCase
|
||||
$this->assertInstanceOf('OAuth2\OpenID\ResponseType\IdTokenTokenInterface', $server->getResponseType('id_token token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException LogicException OAuth2\OpenID\Storage\AuthorizationCodeInterface
|
||||
**/
|
||||
public function testUsingOpenIDConnectWithAuthorizationCodeStorageThrowsException()
|
||||
{
|
||||
$client = $this->getMock('OAuth2\Storage\ClientCredentialsInterface');
|
||||
$userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$token = $this->getMock('OAuth2\Storage\AccessTokenInterface');
|
||||
$authcode = $this->getMock('OAuth2\Storage\AuthorizationCodeInterface');
|
||||
$client = $this->createMock('OAuth2\Storage\ClientCredentialsInterface');
|
||||
$userclaims = $this->createMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$token = $this->createMock('OAuth2\Storage\AccessTokenInterface');
|
||||
$authcode = $this->createMock('OAuth2\Storage\AuthorizationCodeInterface');
|
||||
|
||||
$this->expectErrorMessage('OAuth2\OpenID\Storage\AuthorizationCodeInterface');
|
||||
$server = new Server(array($client, $userclaims, $pubkey, $token, $authcode), array(
|
||||
'use_openid_connect' => true,
|
||||
'issuer' => 'someguy'
|
||||
@@ -630,11 +594,11 @@ class ServerTest extends TestCase
|
||||
|
||||
public function testUsingOpenIDConnectWithOpenIDAuthorizationCodeStorageCreatesOpenIDAuthorizationCodeGrantType()
|
||||
{
|
||||
$client = $this->getMock('OAuth2\Storage\ClientCredentialsInterface');
|
||||
$userclaims = $this->getMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->getMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$token = $this->getMock('OAuth2\Storage\AccessTokenInterface');
|
||||
$authcode = $this->getMock('OAuth2\OpenID\Storage\AuthorizationCodeInterface');
|
||||
$client = $this->createMock('OAuth2\Storage\ClientCredentialsInterface');
|
||||
$userclaims = $this->createMock('OAuth2\OpenID\Storage\UserClaimsInterface');
|
||||
$pubkey = $this->createMock('OAuth2\Storage\PublicKeyInterface');
|
||||
$token = $this->createMock('OAuth2\Storage\AccessTokenInterface');
|
||||
$authcode = $this->createMock('OAuth2\OpenID\Storage\AuthorizationCodeInterface');
|
||||
|
||||
$server = new Server(array($client, $userclaims, $pubkey, $token, $authcode), array(
|
||||
'use_openid_connect' => true,
|
||||
@@ -648,7 +612,7 @@ class ServerTest extends TestCase
|
||||
|
||||
public function testMultipleValuedResponseTypeOrderDoesntMatter()
|
||||
{
|
||||
$responseType = $this->getMock('OAuth2\OpenID\ResponseType\IdTokenTokenInterface');
|
||||
$responseType = $this->createMock('OAuth2\OpenID\ResponseType\IdTokenTokenInterface');
|
||||
$server = new Server(array(), array(), array(), array(
|
||||
'token id_token' => $responseType,
|
||||
));
|
||||
@@ -659,7 +623,7 @@ class ServerTest extends TestCase
|
||||
public function testAddGrantTypeWithoutKey()
|
||||
{
|
||||
$server = new Server();
|
||||
$server->addGrantType(new \OAuth2\GrantType\AuthorizationCode($this->getMock('OAuth2\Storage\AuthorizationCodeInterface')));
|
||||
$server->addGrantType(new \OAuth2\GrantType\AuthorizationCode($this->createMock('OAuth2\Storage\AuthorizationCodeInterface')));
|
||||
|
||||
$grantTypes = $server->getGrantTypes();
|
||||
$this->assertEquals('authorization_code', key($grantTypes));
|
||||
@@ -668,7 +632,7 @@ class ServerTest extends TestCase
|
||||
public function testAddGrantTypeWithKey()
|
||||
{
|
||||
$server = new Server();
|
||||
$server->addGrantType(new \OAuth2\GrantType\AuthorizationCode($this->getMock('OAuth2\Storage\AuthorizationCodeInterface')), 'ac');
|
||||
$server->addGrantType(new \OAuth2\GrantType\AuthorizationCode($this->createMock('OAuth2\Storage\AuthorizationCodeInterface')), 'ac');
|
||||
|
||||
$grantTypes = $server->getGrantTypes();
|
||||
$this->assertEquals('ac', key($grantTypes));
|
||||
@@ -677,7 +641,7 @@ class ServerTest extends TestCase
|
||||
public function testAddGrantTypeWithKeyNotString()
|
||||
{
|
||||
$server = new Server();
|
||||
$server->addGrantType(new \OAuth2\GrantType\AuthorizationCode($this->getMock('OAuth2\Storage\AuthorizationCodeInterface')), 42);
|
||||
$server->addGrantType(new \OAuth2\GrantType\AuthorizationCode($this->createMock('OAuth2\Storage\AuthorizationCodeInterface')), 42);
|
||||
|
||||
$grantTypes = $server->getGrantTypes();
|
||||
$this->assertEquals('authorization_code', key($grantTypes));
|
||||
|
||||
+5
-3
@@ -2,8 +2,12 @@
|
||||
|
||||
namespace OAuth2\Storage;
|
||||
|
||||
use Yoast\PHPUnitPolyfills\Polyfills\ExpectPHPException;
|
||||
|
||||
class PdoTest extends BaseTest
|
||||
{
|
||||
use ExpectPHPException;
|
||||
|
||||
public function testCreatePdoStorageUsingPdoClass()
|
||||
{
|
||||
$dsn = sprintf('sqlite:%s', Bootstrap::getInstance()->getSqliteDir());
|
||||
@@ -30,11 +34,9 @@ class PdoTest extends BaseTest
|
||||
$this->assertNotNull($storage->getClientDetails('oauth_test_client'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException InvalidArgumentException dsn
|
||||
*/
|
||||
public function testCreatePdoStorageWithoutDSNThrowsException()
|
||||
{
|
||||
$this->expectErrorMessage('dsn');
|
||||
$config = array('username' => 'brent', 'password' => 'brentisaballer');
|
||||
$storage = new Pdo($config);
|
||||
}
|
||||
|
||||
+11
@@ -35,6 +35,17 @@ class BearerTest extends TestCase
|
||||
$this->assertEquals($response->getParameter('error_description'), 'The content type for POST requests must be "application/x-www-form-urlencoded"');
|
||||
}
|
||||
|
||||
public function testMissingContentTypeExpectsToBeCorrectContent()
|
||||
{
|
||||
$bearer = new Bearer();
|
||||
$request = TestRequest::createPost(array(
|
||||
'access_token' => 'ThisIsMyAccessToken'
|
||||
));
|
||||
|
||||
$param = $bearer->getAccessTokenParameter($request, $response = new Response());
|
||||
$this->assertEquals($param, 'ThisIsMyAccessToken');
|
||||
}
|
||||
|
||||
public function testValidRequestUsingAuthorizationHeader()
|
||||
{
|
||||
$bearer = new Bearer();
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
require_once(dirname(__FILE__).'/../src/OAuth2/Autoloader.php');
|
||||
OAuth2\Autoloader::register();
|
||||
|
||||
// register test classes
|
||||
OAuth2\Autoloader::register(dirname(__FILE__).'/lib');
|
||||
|
||||
// register vendors if possible
|
||||
if (file_exists(__DIR__.'/../vendor/autoload.php')) {
|
||||
require_once(__DIR__.'/../vendor/autoload.php');
|
||||
}
|
||||
|
||||
// remove the dynamoDB database that was created for this build
|
||||
OAuth2\Storage\Bootstrap::getInstance()->cleanupTravisDynamoDb();
|
||||
+7
-22
@@ -68,7 +68,7 @@ class Bootstrap
|
||||
public function getPostgresDriver()
|
||||
{
|
||||
try {
|
||||
$pdo = new \PDO('pgsql:host=localhost;dbname=oauth2_server_php', 'postgres');
|
||||
$pdo = new \PDO('pgsql:host=localhost;dbname=oauth2_server_php', 'postgres', 'postgres');
|
||||
|
||||
return $pdo;
|
||||
} catch (\PDOException $e) {
|
||||
@@ -118,7 +118,7 @@ class Bootstrap
|
||||
if (!$this->mysql) {
|
||||
$pdo = null;
|
||||
try {
|
||||
$pdo = new \PDO('mysql:host=localhost;', 'root');
|
||||
$pdo = new \PDO('mysql:host=localhost;', 'root', 'root');
|
||||
} catch (\PDOException $e) {
|
||||
$this->mysql = new NullStorage('MySQL', 'Unable to connect to MySQL on root@localhost');
|
||||
}
|
||||
@@ -352,11 +352,11 @@ class Bootstrap
|
||||
|
||||
private function createPostgresDb()
|
||||
{
|
||||
if (!`psql postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='postgres'"`) {
|
||||
`createuser -s -r postgres`;
|
||||
if (!`PGPASSWORD=postgres psql postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='postgres'" -h localhost -U postgres`) {
|
||||
`PGPASSWORD=postgres createuser -s -r postgres -h localhost -U postgres`;
|
||||
}
|
||||
|
||||
`createdb -O postgres oauth2_server_php`;
|
||||
`PGPASSWORD=postgres createdb -O postgres oauth2_server_php -h localhost -U postgres`;
|
||||
}
|
||||
|
||||
private function populatePostgresDb(\PDO $pdo)
|
||||
@@ -366,8 +366,8 @@ class Bootstrap
|
||||
|
||||
private function removePostgresDb()
|
||||
{
|
||||
if (trim(`psql -l | grep oauth2_server_php | wc -l`)) {
|
||||
`dropdb oauth2_server_php`;
|
||||
if (trim(`PGPASSWORD=postgres psql -l -h localhost -U postgres | grep oauth2_server_php | wc -l`)) {
|
||||
`PGPASSWORD=postgres dropdb oauth2_server_php -h localhost -U postgres`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,21 +945,6 @@ class Bootstrap
|
||||
));
|
||||
}
|
||||
|
||||
public function cleanupTravisDynamoDb($prefix = null)
|
||||
{
|
||||
if (is_null($prefix)) {
|
||||
// skip this when not applicable
|
||||
if (!$this->getEnvVar('TRAVIS') || self::DYNAMODB_PHP_VERSION != $this->getEnvVar('TRAVIS_PHP_VERSION')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = sprintf('build_%s_', $this->getEnvVar('TRAVIS_JOB_NUMBER'));
|
||||
}
|
||||
|
||||
$client = $this->getDynamoDbClient();
|
||||
$this->deleteDynamoDb($client, $prefix);
|
||||
}
|
||||
|
||||
private function getEnvVar($var, $default = null)
|
||||
{
|
||||
return isset($_SERVER[$var]) ? $_SERVER[$var] : (getenv($var) ?: $default);
|
||||
|
||||
@@ -4,7 +4,7 @@ Types to use Carbon in Doctrine
|
||||
|
||||
## Documentation
|
||||
|
||||
[Check how to use in the official Carbon documentation](https://carbon.nesbot.com/symfony/)
|
||||
[Check how to use in the official Carbon documentation](https://carbon.nesbot.com/guide/getting-started/symfony.html)
|
||||
|
||||
This package is an externalization of [src/Carbon/Doctrine](https://github.com/briannesbitt/Carbon/tree/2.71.0/src/Carbon/Doctrine)
|
||||
from `nestbot/carbon` package.
|
||||
|
||||
@@ -32,5 +32,10 @@
|
||||
"email": "kylekatarnls@gmail.com"
|
||||
}
|
||||
],
|
||||
"minimum-stability": "dev"
|
||||
"minimum-stability": "dev",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+4
-3
@@ -4,13 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Carbon\Doctrine;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Doctrine\DBAL\Platforms\AbstractPlatform;
|
||||
|
||||
interface CarbonDoctrineType
|
||||
{
|
||||
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform);
|
||||
public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform): string;
|
||||
|
||||
public function convertToPHPValue(mixed $value, AbstractPlatform $platform);
|
||||
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?CarbonInterface;
|
||||
|
||||
public function convertToDatabaseValue($value, AbstractPlatform $platform);
|
||||
public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string;
|
||||
}
|
||||
Vendored
+3
-3
@@ -62,7 +62,7 @@ trait CarbonTypeConverter
|
||||
/**
|
||||
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
|
||||
*/
|
||||
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
|
||||
public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return $value;
|
||||
@@ -79,11 +79,11 @@ trait CarbonTypeConverter
|
||||
);
|
||||
}
|
||||
|
||||
private function doConvertToPHPValue(mixed $value)
|
||||
private function doConvertToPHPValue(mixed $value): ?CarbonInterface
|
||||
{
|
||||
$class = $this->getCarbonClassName();
|
||||
|
||||
if ($value === null || is_a($value, $class)) {
|
||||
if ($value === null || (is_object($value) && is_a($value, $class))) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
|
||||
+19
-1
@@ -26,6 +26,12 @@ use Composer\Semver\VersionParser;
|
||||
*/
|
||||
class InstalledVersions
|
||||
{
|
||||
/**
|
||||
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
|
||||
* @internal
|
||||
*/
|
||||
private static $selfDir = null;
|
||||
|
||||
/**
|
||||
* @var mixed[]|null
|
||||
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
|
||||
@@ -322,6 +328,18 @@ class InstalledVersions
|
||||
self::$installedIsLocalDir = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
private static function getSelfDir()
|
||||
{
|
||||
if (self::$selfDir === null) {
|
||||
self::$selfDir = strtr(__DIR__, '\\', '/');
|
||||
}
|
||||
|
||||
return self::$selfDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array[]
|
||||
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
|
||||
@@ -336,7 +354,7 @@ class InstalledVersions
|
||||
$copiedLocalDir = false;
|
||||
|
||||
if (self::$canGetVendors) {
|
||||
$selfDir = strtr(__DIR__, '\\', '/');
|
||||
$selfDir = self::getSelfDir();
|
||||
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
|
||||
$vendorDir = strtr($vendorDir, '\\', '/');
|
||||
if (isset(self::$installedByVendor[$vendorDir])) {
|
||||
|
||||
+35
-2
@@ -6,12 +6,45 @@ $vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'AllowDynamicProperties' => $vendorDir . '/symfony/polyfill-php82/Resources/stubs/AllowDynamicProperties.php',
|
||||
'Attribute' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/Attribute.php',
|
||||
'CURLStringFile' => $vendorDir . '/symfony/polyfill-php81/Resources/stubs/CURLStringFile.php',
|
||||
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||
'DateError' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateError.php',
|
||||
'DateException' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateException.php',
|
||||
'DateInvalidOperationException' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateInvalidOperationException.php',
|
||||
'DateInvalidTimeZoneException' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateInvalidTimeZoneException.php',
|
||||
'DateMalformedIntervalStringException' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateMalformedIntervalStringException.php',
|
||||
'DateMalformedPeriodStringException' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateMalformedPeriodStringException.php',
|
||||
'DateMalformedStringException' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateMalformedStringException.php',
|
||||
'DateObjectError' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateObjectError.php',
|
||||
'DateRangeError' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateRangeError.php',
|
||||
'DelayedTargetValidation' => $vendorDir . '/symfony/polyfill-php85/Resources/stubs/DelayedTargetValidation.php',
|
||||
'Deprecated' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/Deprecated.php',
|
||||
'Filter\\FilterException' => $vendorDir . '/symfony/polyfill-php85/Resources/stubs/Filter/FilterException.php',
|
||||
'Filter\\FilterFailedException' => $vendorDir . '/symfony/polyfill-php85/Resources/stubs/Filter/FilterFailedException.php',
|
||||
'NoDiscard' => $vendorDir . '/symfony/polyfill-php85/Resources/stubs/NoDiscard.php',
|
||||
'Override' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/Override.php',
|
||||
'Pdo\\Dblib' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/Pdo/Dblib.php',
|
||||
'Pdo\\Firebird' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/Pdo/Firebird.php',
|
||||
'Pdo\\Mysql' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/Pdo/Mysql.php',
|
||||
'Pdo\\Odbc' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/Pdo/Odbc.php',
|
||||
'Pdo\\Pgsql' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/Pdo/Pgsql.php',
|
||||
'Pdo\\Sqlite' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/Pdo/Sqlite.php',
|
||||
'PhpToken' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/PhpToken.php',
|
||||
'ReturnTypeWillChange' => $vendorDir . '/symfony/polyfill-php81/Resources/stubs/ReturnTypeWillChange.php',
|
||||
'Random\\BrokenRandomEngineError' => $vendorDir . '/symfony/polyfill-php82/Resources/stubs/Random/BrokenRandomEngineError.php',
|
||||
'Random\\CryptoSafeEngine' => $vendorDir . '/symfony/polyfill-php82/Resources/stubs/Random/CryptoSafeEngine.php',
|
||||
'Random\\Engine' => $vendorDir . '/symfony/polyfill-php82/Resources/stubs/Random/Engine.php',
|
||||
'Random\\Engine\\Secure' => $vendorDir . '/symfony/polyfill-php82/Resources/stubs/Random/Engine/Secure.php',
|
||||
'Random\\RandomError' => $vendorDir . '/symfony/polyfill-php82/Resources/stubs/Random/RandomError.php',
|
||||
'Random\\RandomException' => $vendorDir . '/symfony/polyfill-php82/Resources/stubs/Random/RandomException.php',
|
||||
'ReflectionConstant' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/ReflectionConstant.php',
|
||||
'RoundingMode' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/RoundingMode.php',
|
||||
'SQLite3Exception' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/SQLite3Exception.php',
|
||||
'SensitiveParameter' => $vendorDir . '/symfony/polyfill-php82/Resources/stubs/SensitiveParameter.php',
|
||||
'SensitiveParameterValue' => $vendorDir . '/symfony/polyfill-php82/Resources/stubs/SensitiveParameterValue.php',
|
||||
'Stringable' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/Stringable.php',
|
||||
'Time\\Duration' => $vendorDir . '/symfony/polyfill-time/Resources/stubs/Time/Duration.php',
|
||||
'Time\\TimeException' => $vendorDir . '/symfony/polyfill-time/Resources/stubs/Time/TimeException.php',
|
||||
'UnhandledMatchError' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/UnhandledMatchError.php',
|
||||
'ValueError' => $vendorDir . '/symfony/polyfill-php80/Resources/stubs/ValueError.php',
|
||||
);
|
||||
+7
-5
@@ -6,16 +6,18 @@ $vendorDir = dirname(__DIR__);
|
||||
$baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'a4a119a56e50fbb293281d9a48007e0e' => $vendorDir . '/symfony/polyfill-php80/bootstrap.php',
|
||||
'5897ea0ac4cccf14d323035e65887801' => $vendorDir . '/symfony/polyfill-php82/bootstrap.php',
|
||||
'6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php',
|
||||
'0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php',
|
||||
'7b11c4dc42b3b3023073cb14e519683c' => $vendorDir . '/ralouphie/getallheaders/src/getallheaders.php',
|
||||
'c964ee0ededf28c96ebd9db5099ef910' => $vendorDir . '/guzzlehttp/promises/src/functions_include.php',
|
||||
'37a3dc5111fe8f707ab4c132ef1dbc62' => $vendorDir . '/guzzlehttp/guzzle/src/functions_include.php',
|
||||
'a4a119a56e50fbb293281d9a48007e0e' => $vendorDir . '/symfony/polyfill-php80/bootstrap.php',
|
||||
'662a729f963d39afe703c9d9b7ab4a8c' => $vendorDir . '/symfony/polyfill-php83/bootstrap.php',
|
||||
'2203a247e6fda86070a5e4e07aed533a' => $vendorDir . '/symfony/clock/Resources/now.php',
|
||||
'9d2b9fc6db0f153a0a149fefb182415e' => $vendorDir . '/symfony/polyfill-php84/bootstrap.php',
|
||||
'606a39d89246991a373564698c2d8383' => $vendorDir . '/symfony/polyfill-php85/bootstrap.php',
|
||||
'a1105708a18b76903365ca1c4aa61b02' => $vendorDir . '/symfony/translation/Resources/functions.php',
|
||||
'23f09fe3194f8c2f70923f90d6702129' => $vendorDir . '/illuminate/collections/functions.php',
|
||||
'60799491728b879e74601d83e38b2cad' => $vendorDir . '/illuminate/collections/helpers.php',
|
||||
'320cde22f66dd4f5d3fd621d3e88b98f' => $vendorDir . '/symfony/polyfill-ctype/bootstrap.php',
|
||||
'23c18046f52bef3eea034657bafda50f' => $vendorDir . '/symfony/polyfill-php81/bootstrap.php',
|
||||
'04c6c5c2f7095ccf6c481d3e53e1776f' => $vendorDir . '/mustangostang/spyc/Spyc.php',
|
||||
'89efb1254ef2d1c5d80096acd12c4098' => $vendorDir . '/twig/twig/src/Resources/core.php',
|
||||
'ffecb95d45175fd40f75be8a23b34f90' => $vendorDir . '/twig/twig/src/Resources/debug.php',
|
||||
|
||||
+6
-1
@@ -7,12 +7,17 @@ $baseDir = dirname($vendorDir);
|
||||
|
||||
return array(
|
||||
'Twig\\' => array($vendorDir . '/twig/twig/src'),
|
||||
'Symfony\\Polyfill\\Php81\\' => array($vendorDir . '/symfony/polyfill-php81'),
|
||||
'Symfony\\Polyfill\\Time\\' => array($vendorDir . '/symfony/polyfill-time'),
|
||||
'Symfony\\Polyfill\\Php85\\' => array($vendorDir . '/symfony/polyfill-php85'),
|
||||
'Symfony\\Polyfill\\Php84\\' => array($vendorDir . '/symfony/polyfill-php84'),
|
||||
'Symfony\\Polyfill\\Php83\\' => array($vendorDir . '/symfony/polyfill-php83'),
|
||||
'Symfony\\Polyfill\\Php82\\' => array($vendorDir . '/symfony/polyfill-php82'),
|
||||
'Symfony\\Polyfill\\Php80\\' => array($vendorDir . '/symfony/polyfill-php80'),
|
||||
'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'),
|
||||
'Symfony\\Polyfill\\Ctype\\' => array($vendorDir . '/symfony/polyfill-ctype'),
|
||||
'Symfony\\Contracts\\Translation\\' => array($vendorDir . '/symfony/translation-contracts'),
|
||||
'Symfony\\Component\\Translation\\' => array($vendorDir . '/symfony/translation'),
|
||||
'Symfony\\Component\\Clock\\' => array($vendorDir . '/symfony/clock'),
|
||||
'Stevenmaguire\\OAuth2\\Client\\' => array($vendorDir . '/stevenmaguire/oauth2-keycloak/src'),
|
||||
'RobThree\\Auth\\' => array($vendorDir . '/robthree/twofactorauth/lib'),
|
||||
'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
|
||||
|
||||
+70
-10
@@ -7,16 +7,18 @@ namespace Composer\Autoload;
|
||||
class ComposerStaticInit873464e4bd965a3168f133248b1b218b
|
||||
{
|
||||
public static $files = array (
|
||||
'a4a119a56e50fbb293281d9a48007e0e' => __DIR__ . '/..' . '/symfony/polyfill-php80/bootstrap.php',
|
||||
'5897ea0ac4cccf14d323035e65887801' => __DIR__ . '/..' . '/symfony/polyfill-php82/bootstrap.php',
|
||||
'6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php',
|
||||
'0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php',
|
||||
'7b11c4dc42b3b3023073cb14e519683c' => __DIR__ . '/..' . '/ralouphie/getallheaders/src/getallheaders.php',
|
||||
'c964ee0ededf28c96ebd9db5099ef910' => __DIR__ . '/..' . '/guzzlehttp/promises/src/functions_include.php',
|
||||
'37a3dc5111fe8f707ab4c132ef1dbc62' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/functions_include.php',
|
||||
'a4a119a56e50fbb293281d9a48007e0e' => __DIR__ . '/..' . '/symfony/polyfill-php80/bootstrap.php',
|
||||
'662a729f963d39afe703c9d9b7ab4a8c' => __DIR__ . '/..' . '/symfony/polyfill-php83/bootstrap.php',
|
||||
'2203a247e6fda86070a5e4e07aed533a' => __DIR__ . '/..' . '/symfony/clock/Resources/now.php',
|
||||
'9d2b9fc6db0f153a0a149fefb182415e' => __DIR__ . '/..' . '/symfony/polyfill-php84/bootstrap.php',
|
||||
'606a39d89246991a373564698c2d8383' => __DIR__ . '/..' . '/symfony/polyfill-php85/bootstrap.php',
|
||||
'a1105708a18b76903365ca1c4aa61b02' => __DIR__ . '/..' . '/symfony/translation/Resources/functions.php',
|
||||
'23f09fe3194f8c2f70923f90d6702129' => __DIR__ . '/..' . '/illuminate/collections/functions.php',
|
||||
'60799491728b879e74601d83e38b2cad' => __DIR__ . '/..' . '/illuminate/collections/helpers.php',
|
||||
'320cde22f66dd4f5d3fd621d3e88b98f' => __DIR__ . '/..' . '/symfony/polyfill-ctype/bootstrap.php',
|
||||
'23c18046f52bef3eea034657bafda50f' => __DIR__ . '/..' . '/symfony/polyfill-php81/bootstrap.php',
|
||||
'04c6c5c2f7095ccf6c481d3e53e1776f' => __DIR__ . '/..' . '/mustangostang/spyc/Spyc.php',
|
||||
'89efb1254ef2d1c5d80096acd12c4098' => __DIR__ . '/..' . '/twig/twig/src/Resources/core.php',
|
||||
'ffecb95d45175fd40f75be8a23b34f90' => __DIR__ . '/..' . '/twig/twig/src/Resources/debug.php',
|
||||
@@ -31,12 +33,17 @@ class ComposerStaticInit873464e4bd965a3168f133248b1b218b
|
||||
),
|
||||
'S' =>
|
||||
array (
|
||||
'Symfony\\Polyfill\\Php81\\' => 23,
|
||||
'Symfony\\Polyfill\\Time\\' => 22,
|
||||
'Symfony\\Polyfill\\Php85\\' => 23,
|
||||
'Symfony\\Polyfill\\Php84\\' => 23,
|
||||
'Symfony\\Polyfill\\Php83\\' => 23,
|
||||
'Symfony\\Polyfill\\Php82\\' => 23,
|
||||
'Symfony\\Polyfill\\Php80\\' => 23,
|
||||
'Symfony\\Polyfill\\Mbstring\\' => 26,
|
||||
'Symfony\\Polyfill\\Ctype\\' => 23,
|
||||
'Symfony\\Contracts\\Translation\\' => 30,
|
||||
'Symfony\\Component\\Translation\\' => 30,
|
||||
'Symfony\\Component\\Clock\\' => 24,
|
||||
'Stevenmaguire\\OAuth2\\Client\\' => 28,
|
||||
),
|
||||
'R' =>
|
||||
@@ -105,9 +112,25 @@ class ComposerStaticInit873464e4bd965a3168f133248b1b218b
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/twig/twig/src',
|
||||
),
|
||||
'Symfony\\Polyfill\\Php81\\' =>
|
||||
'Symfony\\Polyfill\\Time\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/polyfill-php81',
|
||||
0 => __DIR__ . '/..' . '/symfony/polyfill-time',
|
||||
),
|
||||
'Symfony\\Polyfill\\Php85\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/polyfill-php85',
|
||||
),
|
||||
'Symfony\\Polyfill\\Php84\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/polyfill-php84',
|
||||
),
|
||||
'Symfony\\Polyfill\\Php83\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/polyfill-php83',
|
||||
),
|
||||
'Symfony\\Polyfill\\Php82\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/polyfill-php82',
|
||||
),
|
||||
'Symfony\\Polyfill\\Php80\\' =>
|
||||
array (
|
||||
@@ -129,6 +152,10 @@ class ComposerStaticInit873464e4bd965a3168f133248b1b218b
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/translation',
|
||||
),
|
||||
'Symfony\\Component\\Clock\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/symfony/clock',
|
||||
),
|
||||
'Stevenmaguire\\OAuth2\\Client\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/stevenmaguire/oauth2-keycloak/src',
|
||||
@@ -253,12 +280,45 @@ class ComposerStaticInit873464e4bd965a3168f133248b1b218b
|
||||
);
|
||||
|
||||
public static $classMap = array (
|
||||
'AllowDynamicProperties' => __DIR__ . '/..' . '/symfony/polyfill-php82/Resources/stubs/AllowDynamicProperties.php',
|
||||
'Attribute' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Attribute.php',
|
||||
'CURLStringFile' => __DIR__ . '/..' . '/symfony/polyfill-php81/Resources/stubs/CURLStringFile.php',
|
||||
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||
'DateError' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateError.php',
|
||||
'DateException' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateException.php',
|
||||
'DateInvalidOperationException' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateInvalidOperationException.php',
|
||||
'DateInvalidTimeZoneException' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateInvalidTimeZoneException.php',
|
||||
'DateMalformedIntervalStringException' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateMalformedIntervalStringException.php',
|
||||
'DateMalformedPeriodStringException' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateMalformedPeriodStringException.php',
|
||||
'DateMalformedStringException' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateMalformedStringException.php',
|
||||
'DateObjectError' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateObjectError.php',
|
||||
'DateRangeError' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateRangeError.php',
|
||||
'DelayedTargetValidation' => __DIR__ . '/..' . '/symfony/polyfill-php85/Resources/stubs/DelayedTargetValidation.php',
|
||||
'Deprecated' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/Deprecated.php',
|
||||
'Filter\\FilterException' => __DIR__ . '/..' . '/symfony/polyfill-php85/Resources/stubs/Filter/FilterException.php',
|
||||
'Filter\\FilterFailedException' => __DIR__ . '/..' . '/symfony/polyfill-php85/Resources/stubs/Filter/FilterFailedException.php',
|
||||
'NoDiscard' => __DIR__ . '/..' . '/symfony/polyfill-php85/Resources/stubs/NoDiscard.php',
|
||||
'Override' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/Override.php',
|
||||
'Pdo\\Dblib' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/Pdo/Dblib.php',
|
||||
'Pdo\\Firebird' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/Pdo/Firebird.php',
|
||||
'Pdo\\Mysql' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/Pdo/Mysql.php',
|
||||
'Pdo\\Odbc' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/Pdo/Odbc.php',
|
||||
'Pdo\\Pgsql' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/Pdo/Pgsql.php',
|
||||
'Pdo\\Sqlite' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/Pdo/Sqlite.php',
|
||||
'PhpToken' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/PhpToken.php',
|
||||
'ReturnTypeWillChange' => __DIR__ . '/..' . '/symfony/polyfill-php81/Resources/stubs/ReturnTypeWillChange.php',
|
||||
'Random\\BrokenRandomEngineError' => __DIR__ . '/..' . '/symfony/polyfill-php82/Resources/stubs/Random/BrokenRandomEngineError.php',
|
||||
'Random\\CryptoSafeEngine' => __DIR__ . '/..' . '/symfony/polyfill-php82/Resources/stubs/Random/CryptoSafeEngine.php',
|
||||
'Random\\Engine' => __DIR__ . '/..' . '/symfony/polyfill-php82/Resources/stubs/Random/Engine.php',
|
||||
'Random\\Engine\\Secure' => __DIR__ . '/..' . '/symfony/polyfill-php82/Resources/stubs/Random/Engine/Secure.php',
|
||||
'Random\\RandomError' => __DIR__ . '/..' . '/symfony/polyfill-php82/Resources/stubs/Random/RandomError.php',
|
||||
'Random\\RandomException' => __DIR__ . '/..' . '/symfony/polyfill-php82/Resources/stubs/Random/RandomException.php',
|
||||
'ReflectionConstant' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/ReflectionConstant.php',
|
||||
'RoundingMode' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/RoundingMode.php',
|
||||
'SQLite3Exception' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/SQLite3Exception.php',
|
||||
'SensitiveParameter' => __DIR__ . '/..' . '/symfony/polyfill-php82/Resources/stubs/SensitiveParameter.php',
|
||||
'SensitiveParameterValue' => __DIR__ . '/..' . '/symfony/polyfill-php82/Resources/stubs/SensitiveParameterValue.php',
|
||||
'Stringable' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/Stringable.php',
|
||||
'Time\\Duration' => __DIR__ . '/..' . '/symfony/polyfill-time/Resources/stubs/Time/Duration.php',
|
||||
'Time\\TimeException' => __DIR__ . '/..' . '/symfony/polyfill-time/Resources/stubs/Time/TimeException.php',
|
||||
'UnhandledMatchError' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/UnhandledMatchError.php',
|
||||
'ValueError' => __DIR__ . '/..' . '/symfony/polyfill-php80/Resources/stubs/ValueError.php',
|
||||
);
|
||||
|
||||
+869
-507
File diff suppressed because it is too large.
Load diff
+145
-118
@@ -29,18 +29,18 @@
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'bshaffer/oauth2-server-php' => array(
|
||||
'pretty_version' => 'v1.11.1',
|
||||
'version' => '1.11.1.0',
|
||||
'reference' => '5a0c8000d4763b276919e2106f54eddda6bc50fa',
|
||||
'pretty_version' => 'v1.14.2',
|
||||
'version' => '1.14.2.0',
|
||||
'reference' => '1c715c2d2f71254d1c683b8c89a0f16b61ccd0d6',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../bshaffer/oauth2-server-php',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'carbonphp/carbon-doctrine-types' => array(
|
||||
'pretty_version' => '3.2.0',
|
||||
'version' => '3.2.0.0',
|
||||
'reference' => '18ba5ddfec8976260ead6e866180bd5d2f71aa1d',
|
||||
'pretty_version' => '3.2.1',
|
||||
'version' => '3.2.1.0',
|
||||
'reference' => '5fa5eacafd9ef47c8c6ab9143fc901ba0194d3dc',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../carbonphp/carbon-doctrine-types',
|
||||
'aliases' => array(),
|
||||
@@ -56,18 +56,18 @@
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'ddeboer/imap' => array(
|
||||
'pretty_version' => '1.13.1',
|
||||
'version' => '1.13.1.0',
|
||||
'reference' => '8b772d04b1deadb5df13782fb78c4b648f77496e',
|
||||
'pretty_version' => '1.19.0',
|
||||
'version' => '1.19.0.0',
|
||||
'reference' => '30800b1cfeacc4add5bb418e40a8b6e95a8a04ac',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../ddeboer/imap',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'directorytree/ldaprecord' => array(
|
||||
'pretty_version' => 'v3.8.5',
|
||||
'version' => '3.8.5.0',
|
||||
'reference' => '00e5f088f8c4028d5f398783cccc2e8119a27a65',
|
||||
'pretty_version' => 'v3.8.6',
|
||||
'version' => '3.8.6.0',
|
||||
'reference' => 'a1cab9f078eb4756fc6fad58caea1e3d11cad0ec',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../directorytree/ldaprecord',
|
||||
'aliases' => array(),
|
||||
@@ -80,90 +80,90 @@
|
||||
),
|
||||
),
|
||||
'firebase/php-jwt' => array(
|
||||
'pretty_version' => 'v6.5.0',
|
||||
'version' => '6.5.0.0',
|
||||
'reference' => 'e94e7353302b0c11ec3cfff7180cd0b1743975d2',
|
||||
'pretty_version' => 'v7.2.1',
|
||||
'version' => '7.2.1.0',
|
||||
'reference' => '0501f5729b9e2448516674aff07c7d1727869b8e',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../firebase/php-jwt',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'guzzlehttp/guzzle' => array(
|
||||
'pretty_version' => '7.5.0',
|
||||
'version' => '7.5.0.0',
|
||||
'reference' => 'b50a2a1251152e43f6a37f0fa053e730a67d25ba',
|
||||
'pretty_version' => '8.3.1',
|
||||
'version' => '8.3.1.0',
|
||||
'reference' => '7d9b1f430bb65ac664e120af227bf9a795ad6333',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../guzzlehttp/guzzle',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'guzzlehttp/promises' => array(
|
||||
'pretty_version' => '1.5.2',
|
||||
'version' => '1.5.2.0',
|
||||
'reference' => 'b94b2807d85443f9719887892882d0329d1e2598',
|
||||
'pretty_version' => '3.1.0',
|
||||
'version' => '3.1.0.0',
|
||||
'reference' => '094d0ec418eb3b1411ea271a180ec95cae6ae933',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../guzzlehttp/promises',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'guzzlehttp/psr7' => array(
|
||||
'pretty_version' => '2.4.5',
|
||||
'version' => '2.4.5.0',
|
||||
'reference' => '0454e12ef0cd597ccd2adb036f7bda4e7fface66',
|
||||
'pretty_version' => '3.2.0',
|
||||
'version' => '3.2.0.0',
|
||||
'reference' => 'cdc139396207a5eba10c242d241fb014cb098390',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../guzzlehttp/psr7',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'illuminate/collections' => array(
|
||||
'pretty_version' => 'v10.49.0',
|
||||
'version' => '10.49.0.0',
|
||||
'reference' => '6ae9c74fa92d4e1824d1b346cd435e8eacdc3232',
|
||||
'pretty_version' => 'v12.70.2',
|
||||
'version' => '12.70.2.0',
|
||||
'reference' => '83313b009c4afb6f02dbc090bdb67809756eefa2',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../illuminate/collections',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'illuminate/conditionable' => array(
|
||||
'pretty_version' => 'v10.49.0',
|
||||
'version' => '10.49.0.0',
|
||||
'reference' => '47c700320b7a419f0d188d111f3bbed978fcbd3f',
|
||||
'pretty_version' => 'v12.70.2',
|
||||
'version' => '12.70.2.0',
|
||||
'reference' => 'ec677967c1f2faf90b8428919124d2184a4c9b49',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../illuminate/conditionable',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'illuminate/contracts' => array(
|
||||
'pretty_version' => 'v10.44.0',
|
||||
'version' => '10.44.0.0',
|
||||
'reference' => '8d7152c4a1f5d9cf7da3e8b71f23e4556f6138ac',
|
||||
'pretty_version' => 'v12.70.2',
|
||||
'version' => '12.70.2.0',
|
||||
'reference' => 'c16fd7ba7d8e6b8f336639ceb6b7e1ff6ed0efb5',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../illuminate/contracts',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'illuminate/macroable' => array(
|
||||
'pretty_version' => 'v10.49.0',
|
||||
'version' => '10.49.0.0',
|
||||
'reference' => 'dff667a46ac37b634dcf68909d9d41e94dc97c27',
|
||||
'pretty_version' => 'v12.70.2',
|
||||
'version' => '12.70.2.0',
|
||||
'reference' => 'e295d62d89dcdb87e2b1bd70dd14d074a0ed73cc',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../illuminate/macroable',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'league/oauth2-client' => array(
|
||||
'pretty_version' => '2.7.0',
|
||||
'version' => '2.7.0.0',
|
||||
'reference' => '160d6274b03562ebeb55ed18399281d8118b76c8',
|
||||
'pretty_version' => '2.9.1',
|
||||
'version' => '2.9.1.0',
|
||||
'reference' => '8cedfef9d01a8d1fd2ecfabb41734b17592c4b71',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../league/oauth2-client',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'matthiasmullie/minify' => array(
|
||||
'pretty_version' => '1.3.66',
|
||||
'version' => '1.3.66.0',
|
||||
'reference' => '45fd3b0f1dfa2c965857c6d4a470bea52adc31a6',
|
||||
'pretty_version' => '1.3.75',
|
||||
'version' => '1.3.75.0',
|
||||
'reference' => '76ba4a5f555fd7bf4aa408af608e991569076671',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../matthiasmullie/minify',
|
||||
'aliases' => array(),
|
||||
@@ -194,36 +194,27 @@
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'nesbot/carbon' => array(
|
||||
'pretty_version' => '2.72.3',
|
||||
'version' => '2.72.3.0',
|
||||
'reference' => '0c6fd108360c562f6e4fd1dedb8233b423e91c83',
|
||||
'pretty_version' => '3.15.1',
|
||||
'version' => '3.15.1.0',
|
||||
'reference' => 'ce0e5636fae7d1e99856eb70cae4c719a284aa08',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../nesbot/carbon',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'paragonie/random_compat' => array(
|
||||
'pretty_version' => 'v9.99.100',
|
||||
'version' => '9.99.100.0',
|
||||
'reference' => '996434e5492cb4c3edcb9168db6fbb1359ef965a',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../paragonie/random_compat',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'php-mime-mail-parser/php-mime-mail-parser' => array(
|
||||
'pretty_version' => '7.0.0',
|
||||
'version' => '7.0.0.0',
|
||||
'reference' => '9d09a017f3f103fec8456211a4a538b80e0eca0d',
|
||||
'pretty_version' => '7.1.2',
|
||||
'version' => '7.1.2.0',
|
||||
'reference' => '81d84bbc6a73b913110e155e98059526825c7ee3',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../php-mime-mail-parser/php-mime-mail-parser',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'phpmailer/phpmailer' => array(
|
||||
'pretty_version' => 'v6.6.0',
|
||||
'version' => '6.6.0.0',
|
||||
'reference' => 'e43bac82edc26ca04b36143a48bde1c051cfd5b1',
|
||||
'pretty_version' => 'v6.12.0',
|
||||
'version' => '6.12.0.0',
|
||||
'reference' => 'd1ac35d784bf9f5e61b424901d5a014967f15b12',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../phpmailer/phpmailer',
|
||||
'aliases' => array(),
|
||||
@@ -254,9 +245,9 @@
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'psr/http-client' => array(
|
||||
'pretty_version' => '1.0.1',
|
||||
'version' => '1.0.1.0',
|
||||
'reference' => '2dfb5f6c5eff0e91e20e913f8c5452ed95b86621',
|
||||
'pretty_version' => '1.0.3',
|
||||
'version' => '1.0.3.0',
|
||||
'reference' => 'bb5906edc1c324c9a05aa0873d40117941e5fa90',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../psr/http-client',
|
||||
'aliases' => array(),
|
||||
@@ -269,9 +260,9 @@
|
||||
),
|
||||
),
|
||||
'psr/http-factory' => array(
|
||||
'pretty_version' => '1.0.1',
|
||||
'version' => '1.0.1.0',
|
||||
'reference' => '12ac7fcd07e5b077433f5f2bee95b3a771bf61be',
|
||||
'pretty_version' => '1.1.0',
|
||||
'version' => '1.1.0.0',
|
||||
'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../psr/http-factory',
|
||||
'aliases' => array(),
|
||||
@@ -280,13 +271,13 @@
|
||||
'psr/http-factory-implementation' => array(
|
||||
'dev_requirement' => false,
|
||||
'provided' => array(
|
||||
0 => '1.0',
|
||||
0 => '1.1',
|
||||
),
|
||||
),
|
||||
'psr/http-message' => array(
|
||||
'pretty_version' => '1.0.1',
|
||||
'version' => '1.0.1.0',
|
||||
'reference' => 'f6561bf28d520154e4b0ec72be95418abe6d9363',
|
||||
'pretty_version' => '2.0',
|
||||
'version' => '2.0.0.0',
|
||||
'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../psr/http-message',
|
||||
'aliases' => array(),
|
||||
@@ -295,40 +286,31 @@
|
||||
'psr/http-message-implementation' => array(
|
||||
'dev_requirement' => false,
|
||||
'provided' => array(
|
||||
0 => '1.0',
|
||||
0 => '2.0',
|
||||
),
|
||||
),
|
||||
'psr/log' => array(
|
||||
'pretty_version' => '3.0.0',
|
||||
'version' => '3.0.0.0',
|
||||
'reference' => 'fe5ea303b0887d5caefd3d431c3e61ad47037001',
|
||||
'pretty_version' => '3.0.2',
|
||||
'version' => '3.0.2.0',
|
||||
'reference' => 'f16e1d5863e37f8d8c2a01719f5b34baa2b714d3',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../psr/log',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'psr/simple-cache' => array(
|
||||
'pretty_version' => '2.0.0',
|
||||
'version' => '2.0.0.0',
|
||||
'reference' => '8707bf3cea6f710bf6ef05491234e3ab06f6432a',
|
||||
'pretty_version' => '3.0.0',
|
||||
'version' => '3.0.0.0',
|
||||
'reference' => '764e0b3939f5ca87cb904f570ef9be2d78a07865',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../psr/simple-cache',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'ralouphie/getallheaders' => array(
|
||||
'pretty_version' => '3.0.3',
|
||||
'version' => '3.0.3.0',
|
||||
'reference' => '120b605dfeb996808c31b6477290a714d356e822',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../ralouphie/getallheaders',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'robthree/twofactorauth' => array(
|
||||
'pretty_version' => '1.8.1',
|
||||
'version' => '1.8.1.0',
|
||||
'reference' => '5afcb45282f1c75562a48d479ecd1732c9bdb11b',
|
||||
'pretty_version' => '1.8.2',
|
||||
'version' => '1.8.2.0',
|
||||
'reference' => '65681de5a324eae05140ac58b08648a60212afc0',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../robthree/twofactorauth',
|
||||
'aliases' => array(),
|
||||
@@ -344,72 +326,117 @@
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'stevenmaguire/oauth2-keycloak' => array(
|
||||
'pretty_version' => '4.0.0',
|
||||
'version' => '4.0.0.0',
|
||||
'reference' => '05ead6bb6bcd2b6f96dfae87c769dcd3e5f6129d',
|
||||
'pretty_version' => '6.1.1',
|
||||
'version' => '6.1.1.0',
|
||||
'reference' => '31bb3b1fa15b444212ed43facc898fafc7c2707a',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../stevenmaguire/oauth2-keycloak',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/clock' => array(
|
||||
'pretty_version' => 'v7.4.21',
|
||||
'version' => '7.4.21.0',
|
||||
'reference' => '07437f612dca89c1894c1db04491002b1e798f03',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/clock',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/deprecation-contracts' => array(
|
||||
'pretty_version' => 'v3.5.0',
|
||||
'version' => '3.5.0.0',
|
||||
'reference' => '0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1',
|
||||
'pretty_version' => 'v3.7.1',
|
||||
'version' => '3.7.1.0',
|
||||
'reference' => 'f3202fa1b5097b0af062dc978b32ecf63404e31d',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/deprecation-contracts',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/polyfill-ctype' => array(
|
||||
'pretty_version' => 'v1.24.0',
|
||||
'version' => '1.24.0.0',
|
||||
'reference' => '30885182c981ab175d4d034db0f6f469898070ab',
|
||||
'pretty_version' => 'v1.37.0',
|
||||
'version' => '1.37.0.0',
|
||||
'reference' => '141046a8f9477948ff284fa65be2095baafb94f2',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/polyfill-ctype',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/polyfill-mbstring' => array(
|
||||
'pretty_version' => 'v1.29.0',
|
||||
'version' => '1.29.0.0',
|
||||
'reference' => '9773676c8a1bb1f8d4340a62efe641cf76eda7ec',
|
||||
'pretty_version' => 'v1.43.3',
|
||||
'version' => '1.43.3.0',
|
||||
'reference' => '122889ed24dc000165bcbfd77ec412f15cbdfc6a',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/polyfill-mbstring',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/polyfill-php80' => array(
|
||||
'pretty_version' => 'v1.29.0',
|
||||
'version' => '1.29.0.0',
|
||||
'reference' => '87b68208d5c1188808dd7839ee1e6c8ec3b02f1b',
|
||||
'pretty_version' => 'v1.43.0',
|
||||
'version' => '1.43.0.0',
|
||||
'reference' => '9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/polyfill-php80',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/polyfill-php81' => array(
|
||||
'pretty_version' => 'v1.31.0',
|
||||
'version' => '1.31.0.0',
|
||||
'reference' => '4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c',
|
||||
'symfony/polyfill-php82' => array(
|
||||
'pretty_version' => 'v1.43.0',
|
||||
'version' => '1.43.0.0',
|
||||
'reference' => '843bbe8b2a7934307b749751eede0f111f5d1cfc',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/polyfill-php81',
|
||||
'install_path' => __DIR__ . '/../symfony/polyfill-php82',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/polyfill-php83' => array(
|
||||
'pretty_version' => 'v1.43.0',
|
||||
'version' => '1.43.0.0',
|
||||
'reference' => '80ccff923a8d61f73ebe9da0aa94e25232f004ce',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/polyfill-php83',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/polyfill-php84' => array(
|
||||
'pretty_version' => 'v1.43.0',
|
||||
'version' => '1.43.0.0',
|
||||
'reference' => '22c6548499cbe5c5bb072fc0c42312dff062a015',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/polyfill-php84',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/polyfill-php85' => array(
|
||||
'pretty_version' => 'v1.43.0',
|
||||
'version' => '1.43.0.0',
|
||||
'reference' => '86a42cbcc07adc331481a5ab310a4024bfc227f6',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/polyfill-php85',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/polyfill-time' => array(
|
||||
'pretty_version' => 'v1.42.0',
|
||||
'version' => '1.42.0.0',
|
||||
'reference' => 'ac9f1ed802dd58f37e720388130c088434aa09fc',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/polyfill-time',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/translation' => array(
|
||||
'pretty_version' => 'v6.4.3',
|
||||
'version' => '6.4.3.0',
|
||||
'reference' => '637c51191b6b184184bbf98937702bcf554f7d04',
|
||||
'pretty_version' => 'v7.4.17',
|
||||
'version' => '7.4.17.0',
|
||||
'reference' => '2ee1e4a3b32a528a642babe041ff7c440b213b4b',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/translation',
|
||||
'aliases' => array(),
|
||||
'dev_requirement' => false,
|
||||
),
|
||||
'symfony/translation-contracts' => array(
|
||||
'pretty_version' => 'v3.4.1',
|
||||
'version' => '3.4.1.0',
|
||||
'reference' => '06450585bf65e978026bda220cdebca3f867fde7',
|
||||
'pretty_version' => 'v3.7.1',
|
||||
'version' => '3.7.1.0',
|
||||
'reference' => 'ccb206b98faccc511ebae8e5fad50f2dc0b30621',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../symfony/translation-contracts',
|
||||
'aliases' => array(),
|
||||
@@ -422,9 +449,9 @@
|
||||
),
|
||||
),
|
||||
'twig/twig' => array(
|
||||
'pretty_version' => 'v3.14.0',
|
||||
'version' => '3.14.0.0',
|
||||
'reference' => '126b2c97818dbff0cdf3fbfc881aedb3d40aae72',
|
||||
'pretty_version' => 'v3.30.0',
|
||||
'version' => '3.30.0.0',
|
||||
'reference' => '8c737079b726af72ff8ef3c595be9f6a810ea1ef',
|
||||
'type' => 'library',
|
||||
'install_path' => __DIR__ . '/../twig/twig',
|
||||
'aliases' => array(),
|
||||
|
||||
+4
-5
@@ -4,8 +4,8 @@
|
||||
|
||||
$issues = array();
|
||||
|
||||
if (!(PHP_VERSION_ID >= 80100)) {
|
||||
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.1.0". You are running ' . PHP_VERSION . '.';
|
||||
if (!(PHP_VERSION_ID >= 80200)) {
|
||||
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.2.0". You are running ' . PHP_VERSION . '.';
|
||||
}
|
||||
|
||||
if ($issues) {
|
||||
@@ -19,8 +19,7 @@ if ($issues) {
|
||||
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
|
||||
}
|
||||
}
|
||||
trigger_error(
|
||||
'Composer detected issues in your platform: ' . implode(' ', $issues),
|
||||
E_USER_ERROR
|
||||
throw new \RuntimeException(
|
||||
'Composer detected issues in your platform: ' . implode(' ', $issues)
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -17,7 +17,7 @@ return (new PhpCsFixer\Config())
|
||||
'binary_operator_spaces' => ['default' => 'align_single_space'],
|
||||
'blank_line_before_statement' => true,
|
||||
'class_definition' => ['single_item_single_line' => true],
|
||||
'compact_nullable_typehint' => true,
|
||||
'compact_nullable_type_declaration' => true,
|
||||
'concat_space' => ['spacing' => 'one'],
|
||||
'echo_tag_syntax' => ['format' => 'long'],
|
||||
'error_suppression' => false,
|
||||
@@ -39,7 +39,7 @@ return (new PhpCsFixer\Config())
|
||||
'no_null_property_initialization' => true,
|
||||
'no_php4_constructor' => true,
|
||||
'no_superfluous_elseif' => true,
|
||||
'no_unneeded_curly_braces' => true,
|
||||
'no_unneeded_braces' => true,
|
||||
'no_unneeded_final_method' => true,
|
||||
'no_unreachable_default_argument_value' => true,
|
||||
'no_useless_else' => true,
|
||||
@@ -49,7 +49,7 @@ return (new PhpCsFixer\Config())
|
||||
'php_unit_set_up_tear_down_visibility' => true,
|
||||
'php_unit_strict' => true,
|
||||
'php_unit_test_annotation' => true,
|
||||
'php_unit_test_case_static_method_calls' => true,
|
||||
'php_unit_test_case_static_method_calls' => false,
|
||||
'php_unit_test_class_requires_covers' => false,
|
||||
'phpdoc_add_missing_param_annotation' => true,
|
||||
'phpdoc_order' => true,
|
||||
|
||||
+20
-22
@@ -2,12 +2,12 @@
|
||||
|
||||
[](https://packagist.org/packages/ddeboer/imap)
|
||||
[](https://packagist.org/packages/ddeboer/imap)
|
||||
[](https://github.com/ddeboer/imap/actions)
|
||||
[](https://github.com/ddeboer/imap/actions)
|
||||
[](https://codecov.io/gh/ddeboer/imap?branch=master)
|
||||
|
||||
A PHP IMAP library to read and process e-mails over IMAP protocol, built with robust Object-Oriented architecture.
|
||||
|
||||
This library requires PHP >= 7.4 with [IMAP](https://www.php.net/manual/en/book.imap.php),
|
||||
This library requires PHP >= 8.2 with [IMAP](https://www.php.net/manual/en/book.imap.php),
|
||||
[iconv](https://www.php.net/manual/en/book.iconv.php) and
|
||||
[Multibyte String](https://www.php.net/manual/en/book.mbstring.php) extensions installed.
|
||||
|
||||
@@ -157,7 +157,7 @@ By the way most of the common search criteria are available and functioning, bro
|
||||
References:
|
||||
|
||||
1. https://stackoverflow.com/questions/36356715/imap-search-unknown-search-criterion-or
|
||||
1. imap-2007f.tar.gz: `./src/c-client/mail.c` and `./docs/internal.txt`
|
||||
2. imap-2007f.tar.gz: `./src/c-client/mail.c` and `./docs/internal.txt`
|
||||
|
||||
#### Message Properties and Operations
|
||||
|
||||
@@ -182,19 +182,29 @@ $message->isDraft();
|
||||
$message->isSeen();
|
||||
```
|
||||
|
||||
Get message headers as a [\Ddeboer\Imap\Message\Headers](/src/Ddeboer/Imap/Message/Headers.php) object:
|
||||
Get message headers as a [\Ddeboer\Imap\Message\Headers](/src/Message/Headers.php) object:
|
||||
|
||||
```php
|
||||
$message->getHeaders();
|
||||
```
|
||||
|
||||
Get message body as HTML or plain text:
|
||||
Get message body as HTML or plain text (only first part):
|
||||
|
||||
```php
|
||||
$message->getBodyHtml(); // Content of text/html part, if present
|
||||
$message->getBodyText(); // Content of text/plain part, if present
|
||||
```
|
||||
|
||||
|
||||
Get complete body (all parts):
|
||||
|
||||
```php
|
||||
$body = $message->getCompleteBodyHtml(); // Content of text/html part, if present
|
||||
if ($body === null) { // If body is null, there are no HTML parts, so let's try getting the text body
|
||||
$body = $message->getCompleteBodyText(); // Content of text/plain part, if present
|
||||
}
|
||||
```
|
||||
|
||||
Reading the message body keeps the message as unseen.
|
||||
If you want to mark the message as seen:
|
||||
|
||||
@@ -284,24 +294,12 @@ Mockability is granted by interfaces present for each API.
|
||||
Dig into [MockabilityTest](tests/MockabilityTest.php) for an example of a
|
||||
mocked workflow.
|
||||
|
||||
## Running the Tests
|
||||
## Contributing: run the build locally
|
||||
|
||||
This library is functionally tested on [Travis CI](https://travis-ci.org/ddeboer/imap)
|
||||
against a local Dovecot server.
|
||||
Docker is needed to run the build on your computer.
|
||||
|
||||
If you have your own IMAP (test) account, you can run the tests locally by
|
||||
providing your IMAP credentials:
|
||||
First command you need to run is `make start-imap-server`, which starts an IMAP server locally.
|
||||
|
||||
```bash
|
||||
$ composer install
|
||||
$ IMAP_SERVER_NAME="my.imap.server.com" IMAP_SERVER_PORT="60993" IMAP_USERNAME="johndoe" IMAP_PASSWORD="p4ssword" vendor/bin/phpunit
|
||||
```
|
||||
Then the local build can be triggered with a bare `make`.
|
||||
|
||||
You can also copy `phpunit.xml.dist` file to a custom `phpunit.xml` and put
|
||||
these environment variables in it.
|
||||
|
||||
**WARNING** Tests create new mailboxes without removing them.
|
||||
|
||||
```
|
||||
$ docker-compose run tests
|
||||
```
|
||||
When you finish the development, stop the local IMAP server with `make stop-imap-server`.
|
||||
+9
-13
@@ -22,19 +22,20 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.0.1",
|
||||
"php": "~8.2.0 || ~8.3.0",
|
||||
"ext-dom": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-imap": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^v3.4.0",
|
||||
"laminas/laminas-mail": "^2.15.1",
|
||||
"malukenho/mcbumpface": "^1.1.5",
|
||||
"phpstan/phpstan": "^1.3.3",
|
||||
"phpstan/phpstan-phpunit": "^1.0.0",
|
||||
"phpstan/phpstan-strict-rules": "^1.1.0",
|
||||
"phpunit/phpunit": "^9.5.11"
|
||||
"friendsofphp/php-cs-fixer": "^3.38.2",
|
||||
"laminas/laminas-mail": "^2.25.1",
|
||||
"phpstan/phpstan": "^1.10.43",
|
||||
"phpstan/phpstan-phpunit": "^1.3.15",
|
||||
"phpstan/phpstan-strict-rules": "^1.5.2",
|
||||
"phpunit/phpunit": "^10.4.2"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -45,10 +46,5 @@
|
||||
"psr-4": {
|
||||
"Ddeboer\\Imap\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"malukenho/mcbumpface": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -50,9 +50,11 @@ final class Connection implements ConnectionInterface
|
||||
|
||||
public function close(int $flag = 0): bool
|
||||
{
|
||||
$stream = $this->resource->getStream();
|
||||
|
||||
$this->resource->clearLastMailboxUsedCache();
|
||||
|
||||
return \imap_close($this->resource->getStream(), $flag);
|
||||
return \imap_close($stream, $flag);
|
||||
}
|
||||
|
||||
public function getQuota(string $root = 'INBOX'): array
|
||||
@@ -61,7 +63,7 @@ final class Connection implements ConnectionInterface
|
||||
$errorNumber = 0;
|
||||
\set_error_handler(static function ($nr, $message) use (&$errorMessage, &$errorNumber): bool {
|
||||
$errorMessage = $message;
|
||||
$errorNumber = $nr;
|
||||
$errorNumber = $nr;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace Ddeboer\Imap;
|
||||
|
||||
use Ddeboer\Imap\Exception\CreateMailboxException;
|
||||
use Ddeboer\Imap\Exception\DeleteMailboxException;
|
||||
use Ddeboer\Imap\Exception\InvalidResourceException;
|
||||
use Ddeboer\Imap\Exception\MailboxDoesNotExistException;
|
||||
|
||||
/**
|
||||
@@ -31,8 +30,6 @@ interface ConnectionInterface extends \Countable
|
||||
|
||||
/**
|
||||
* Check if the connection is still active.
|
||||
*
|
||||
* @throws InvalidResourceException If connection was closed
|
||||
*/
|
||||
public function ping(): bool;
|
||||
|
||||
|
||||
Vendored
Regular → Executable
+1
-1
@@ -4,6 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Ddeboer\Imap\Exception;
|
||||
|
||||
final class InvalidResourceException extends AbstractException
|
||||
final class RenameMailboxException extends AbstractException
|
||||
{
|
||||
}
|
||||
+5
-20
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Ddeboer\Imap;
|
||||
|
||||
use Ddeboer\Imap\Exception\InvalidResourceException;
|
||||
use Ddeboer\Imap\Exception\ReopenMailboxException;
|
||||
use IMAP\Connection;
|
||||
|
||||
@@ -13,33 +12,21 @@ use IMAP\Connection;
|
||||
*/
|
||||
final class ImapResource implements ImapResourceInterface
|
||||
{
|
||||
/**
|
||||
* @var resource
|
||||
*/
|
||||
private $resource;
|
||||
private ?MailboxInterface $mailbox = null;
|
||||
private Connection $resource;
|
||||
private ?MailboxInterface $mailbox;
|
||||
private static ?string $lastMailboxUsedCache = null;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param Connection|resource $resource
|
||||
*/
|
||||
public function __construct($resource, MailboxInterface $mailbox = null)
|
||||
public function __construct(Connection $resource, MailboxInterface $mailbox = null)
|
||||
{
|
||||
$this->resource = $resource;
|
||||
$this->mailbox = $mailbox;
|
||||
}
|
||||
|
||||
public function getStream()
|
||||
public function getStream(): Connection
|
||||
{
|
||||
if (
|
||||
!$this->resource instanceof Connection
|
||||
&& (false === \is_resource($this->resource) || 'imap' !== \get_resource_type($this->resource))
|
||||
) {
|
||||
throw new InvalidResourceException('Supplied resource is not a valid imap resource');
|
||||
}
|
||||
|
||||
$this->initMailbox();
|
||||
|
||||
return $this->resource;
|
||||
@@ -76,10 +63,8 @@ final class ImapResource implements ImapResourceInterface
|
||||
|
||||
/**
|
||||
* Check whether the current mailbox is open.
|
||||
*
|
||||
* @param resource $resource
|
||||
*/
|
||||
private static function isMailboxOpen(MailboxInterface $mailbox, $resource): bool
|
||||
private static function isMailboxOpen(MailboxInterface $mailbox, Connection $resource): bool
|
||||
{
|
||||
$currentMailboxName = $mailbox->getFullEncodedName();
|
||||
if ($currentMailboxName === self::$lastMailboxUsedCache) {
|
||||
|
||||
@@ -4,18 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Ddeboer\Imap;
|
||||
|
||||
use Ddeboer\Imap\Exception\InvalidResourceException;
|
||||
use IMAP\Connection;
|
||||
|
||||
interface ImapResourceInterface
|
||||
{
|
||||
/**
|
||||
* Get IMAP resource stream.
|
||||
*
|
||||
* @throws InvalidResourceException
|
||||
*
|
||||
* @return resource
|
||||
*/
|
||||
public function getStream();
|
||||
public function getStream(): Connection;
|
||||
|
||||
/**
|
||||
* Clear last mailbox used cache.
|
||||
|
||||
Loaded 100 of 1348 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user