mirror of
				https://github.com/optim-enterprises-bv/Mailu.git
				synced 2025-11-03 19:47:52 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			499 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			499 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
""" Mailu admin app utilities
 | 
						|
"""
 | 
						|
 | 
						|
try:
 | 
						|
    import cPickle as pickle
 | 
						|
except ImportError:
 | 
						|
    import pickle
 | 
						|
 | 
						|
import dns
 | 
						|
import dns.resolver
 | 
						|
 | 
						|
import hmac
 | 
						|
import secrets
 | 
						|
import time
 | 
						|
 | 
						|
from multiprocessing import Value
 | 
						|
 | 
						|
from mailu import limiter
 | 
						|
 | 
						|
from flask import current_app as app
 | 
						|
import flask
 | 
						|
import flask_login
 | 
						|
import flask_migrate
 | 
						|
import flask_babel
 | 
						|
import ipaddress
 | 
						|
import redis
 | 
						|
 | 
						|
from flask.sessions import SessionMixin, SessionInterface
 | 
						|
from itsdangerous.encoding import want_bytes
 | 
						|
from werkzeug.datastructures import CallbackDict
 | 
						|
from werkzeug.contrib import fixers
 | 
						|
 | 
						|
# Login configuration
 | 
						|
login = flask_login.LoginManager()
 | 
						|
login.login_view = "ui.login"
 | 
						|
 | 
						|
@login.unauthorized_handler
 | 
						|
def handle_needs_login():
 | 
						|
    """ redirect unauthorized requests to login page """
 | 
						|
    return flask.redirect(
 | 
						|
        flask.url_for('ui.login', next=flask.request.endpoint)
 | 
						|
    )
 | 
						|
 | 
						|
# DNS stub configured to do DNSSEC enabled queries
 | 
						|
resolver = dns.resolver.Resolver()
 | 
						|
resolver.use_edns(0, 0, 1232)
 | 
						|
resolver.flags = dns.flags.AD | dns.flags.RD
 | 
						|
 | 
						|
def has_dane_record(domain, timeout=10):
 | 
						|
    try:
 | 
						|
        result = resolver.query(f'_25._tcp.{domain}', dns.rdatatype.TLSA,dns.rdataclass.IN, lifetime=timeout)
 | 
						|
        if result.response.flags & dns.flags.AD:
 | 
						|
            for record in result:
 | 
						|
                if isinstance(record, dns.rdtypes.ANY.TLSA.TLSA):
 | 
						|
                    record.validate()
 | 
						|
                    if record.usage in [2,3] and record.selector in [0,1] and record.mtype in [0,1,2]:
 | 
						|
                        return True
 | 
						|
    except dns.resolver.NoNameservers:
 | 
						|
        # If the DNSSEC data is invalid and the DNS resolver is DNSSEC enabled
 | 
						|
        # we will receive this non-specific exception. The safe behaviour is to
 | 
						|
        # accept to defer the email.
 | 
						|
        app.logger.warn(f'Unable to lookup the TLSA record for {domain}. Is the DNSSEC zone okay on https://dnsviz.net/d/{domain}/dnssec/?')
 | 
						|
        return app.config['DEFER_ON_TLS_ERROR']
 | 
						|
    except dns.exception.Timeout:
 | 
						|
        app.logger.warn(f'Timeout while resolving the TLSA record for {domain} ({timeout}s).')
 | 
						|
    except dns.resolver.NXDOMAIN:
 | 
						|
        pass # this is expected, not TLSA record is fine
 | 
						|
    except Exception as e:
 | 
						|
        app.logger.error(f'Error while looking up the TLSA record for {domain} {e}')
 | 
						|
        pass
 | 
						|
 | 
						|
# Rate limiter
 | 
						|
limiter = limiter.LimitWraperFactory()
 | 
						|
 | 
						|
def extract_network_from_ip(ip):
 | 
						|
    n = ipaddress.ip_network(ip)
 | 
						|
    if isinstance(n, ipaddress.IPv4Network):
 | 
						|
        return str(n.supernet(prefixlen_diff=(32-int(app.config["AUTH_RATELIMIT_IP_V4_MASK"]))).network_address)
 | 
						|
    elif isinstance(n, ipaddress.IPv6Network):
 | 
						|
        return str(n.supernet(prefixlen_diff=(128-int(app.config["AUTH_RATELIMIT_IP_V6_MASK"]))).network_address)
 | 
						|
    else: # not sure what to do with it
 | 
						|
        return ip
 | 
						|
 | 
						|
# Application translation
 | 
						|
babel = flask_babel.Babel()
 | 
						|
 | 
						|
@babel.localeselector
 | 
						|
def get_locale():
 | 
						|
    """ selects locale for translation """
 | 
						|
    language = flask.session.get('language')
 | 
						|
    if not language in app.config.translations:
 | 
						|
        language = flask.request.accept_languages.best_match(app.config.translations.keys())
 | 
						|
        flask.session['language'] = language
 | 
						|
    return language
 | 
						|
 | 
						|
 | 
						|
# Proxy fixer
 | 
						|
class PrefixMiddleware(object):
 | 
						|
    """ fix proxy headers """
 | 
						|
    def __init__(self):
 | 
						|
        self.app = None
 | 
						|
 | 
						|
    def __call__(self, environ, start_response):
 | 
						|
        prefix = environ.get('HTTP_X_FORWARDED_PREFIX', '')
 | 
						|
        if prefix:
 | 
						|
            environ['SCRIPT_NAME'] = prefix
 | 
						|
        return self.app(environ, start_response)
 | 
						|
 | 
						|
    def init_app(self, app):
 | 
						|
        self.app = fixers.ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
 | 
						|
        app.wsgi_app = self
 | 
						|
 | 
						|
proxy = PrefixMiddleware()
 | 
						|
 | 
						|
 | 
						|
# Data migrate
 | 
						|
migrate = flask_migrate.Migrate()
 | 
						|
 | 
						|
 | 
						|
# session store (inspired by https://github.com/mbr/flask-kvsession)
 | 
						|
class RedisStore:
 | 
						|
    """ Stores session data in a redis db. """
 | 
						|
 | 
						|
    has_ttl = True
 | 
						|
 | 
						|
    def __init__(self, redisstore):
 | 
						|
        self.redis = redisstore
 | 
						|
 | 
						|
    def get(self, key):
 | 
						|
        """ load item from store. """
 | 
						|
        value = self.redis.get(key)
 | 
						|
        if value is None:
 | 
						|
            raise KeyError(key)
 | 
						|
        return value
 | 
						|
 | 
						|
    def put(self, key, value, ttl=None):
 | 
						|
        """ save item to store. """
 | 
						|
        if ttl:
 | 
						|
            self.redis.setex(key, int(ttl), value)
 | 
						|
        else:
 | 
						|
            self.redis.set(key, value)
 | 
						|
 | 
						|
    def delete(self, key):
 | 
						|
        """ delete item from store. """
 | 
						|
        self.redis.delete(key)
 | 
						|
 | 
						|
    def list(self, prefix=None):
 | 
						|
        """ return list of keys starting with prefix """
 | 
						|
        if prefix:
 | 
						|
            prefix += b'*'
 | 
						|
        return list(self.redis.scan_iter(match=prefix))
 | 
						|
 | 
						|
class DictStore:
 | 
						|
    """ Stores session data in a python dict. """
 | 
						|
 | 
						|
    has_ttl = False
 | 
						|
 | 
						|
    def __init__(self):
 | 
						|
        self.dict = {}
 | 
						|
 | 
						|
    def get(self, key):
 | 
						|
        """ load item from store. """
 | 
						|
        return self.dict[key]
 | 
						|
 | 
						|
    def put(self, key, value, ttl_secs=None):
 | 
						|
        """ save item to store. """
 | 
						|
        self.dict[key] = value
 | 
						|
 | 
						|
    def delete(self, key):
 | 
						|
        """ delete item from store. """
 | 
						|
        try:
 | 
						|
            del self.dict[key]
 | 
						|
        except KeyError:
 | 
						|
            pass
 | 
						|
 | 
						|
    def list(self, prefix=None):
 | 
						|
        """ return list of keys starting with prefix """
 | 
						|
        if prefix is None:
 | 
						|
            return list(self.dict.keys())
 | 
						|
        return [key for key in self.dict if key.startswith(prefix)]
 | 
						|
 | 
						|
class MailuSession(CallbackDict, SessionMixin):
 | 
						|
    """ Custom flask session storage. """
 | 
						|
 | 
						|
    # default modified to false
 | 
						|
    modified = False
 | 
						|
 | 
						|
    def __init__(self, key=None, app=None):
 | 
						|
 | 
						|
        self.app = app or flask.current_app
 | 
						|
 | 
						|
        initial = None
 | 
						|
 | 
						|
        key = want_bytes(key)
 | 
						|
        if parsed := self.app.session_config.parse_key(key, self.app):
 | 
						|
            try:
 | 
						|
                initial = pickle.loads(app.session_store.get(key))
 | 
						|
            except (KeyError, EOFError, pickle.UnpicklingError):
 | 
						|
                # either the cookie was manipulated or we did not find the
 | 
						|
                # session in the backend or the pickled data is invalid.
 | 
						|
                # => start new session
 | 
						|
                pass
 | 
						|
            else:
 | 
						|
                (self._uid, self._sid, self._created) = parsed
 | 
						|
                self._key = key
 | 
						|
 | 
						|
        if initial is None:
 | 
						|
            # start new session
 | 
						|
            self.new = True
 | 
						|
            self._uid = None
 | 
						|
            self._sid = None
 | 
						|
            self._created = self.app.session_config.gen_created()
 | 
						|
            self._key = None
 | 
						|
 | 
						|
        def _on_update(obj):
 | 
						|
            obj.modified = True
 | 
						|
 | 
						|
        CallbackDict.__init__(self, initial, _on_update)
 | 
						|
 | 
						|
    @property
 | 
						|
    def saved(self):
 | 
						|
        """ this reflects if the session was saved. """
 | 
						|
        return self._key is not None
 | 
						|
 | 
						|
    @property
 | 
						|
    def sid(self):
 | 
						|
        """ this reflects the session's id. """
 | 
						|
        if self._sid is None or self._uid is None or self._created is None:
 | 
						|
            return None
 | 
						|
        return b''.join([self._uid, self._sid, self._created])
 | 
						|
 | 
						|
    def destroy(self):
 | 
						|
        """ destroy session for security reasons. """
 | 
						|
 | 
						|
        self.delete()
 | 
						|
 | 
						|
        self._uid = None
 | 
						|
        self._sid = None
 | 
						|
        self._created = None
 | 
						|
 | 
						|
        self.clear()
 | 
						|
 | 
						|
        self.modified = True
 | 
						|
        self.new = False
 | 
						|
 | 
						|
    def regenerate(self):
 | 
						|
        """ generate new id for session to avoid `session fixation`. """
 | 
						|
 | 
						|
        self.delete()
 | 
						|
 | 
						|
        self._sid = None
 | 
						|
        self._created = self.app.session_config.gen_created()
 | 
						|
 | 
						|
        self.modified = True
 | 
						|
 | 
						|
    def delete(self):
 | 
						|
        """ Delete stored session. """
 | 
						|
        if self.saved:
 | 
						|
            self.app.session_store.delete(self._key)
 | 
						|
            self._key = None
 | 
						|
 | 
						|
    def save(self):
 | 
						|
        """ Save session to store. """
 | 
						|
 | 
						|
        set_cookie = False
 | 
						|
 | 
						|
        # set uid from dict data
 | 
						|
        if self._uid is None:
 | 
						|
            self._uid = self.app.session_config.gen_uid(self.get('user_id', ''))
 | 
						|
 | 
						|
        # create new session id for new or regenerated sessions and force setting the cookie
 | 
						|
        if self._sid is None:
 | 
						|
            self._sid = self.app.session_config.gen_sid()
 | 
						|
            set_cookie = True
 | 
						|
 | 
						|
        # get new session key
 | 
						|
        key = self.sid
 | 
						|
 | 
						|
        # delete old session if key has changed
 | 
						|
        if key != self._key:
 | 
						|
            self.delete()
 | 
						|
 | 
						|
        # remember time to refresh
 | 
						|
        self['_refresh'] = int(time.time()) + self.app.permanent_session_lifetime.total_seconds()/2
 | 
						|
 | 
						|
        # save session
 | 
						|
        self.app.session_store.put(
 | 
						|
            key,
 | 
						|
            pickle.dumps(dict(self)),
 | 
						|
            self.app.permanent_session_lifetime.total_seconds()
 | 
						|
        )
 | 
						|
 | 
						|
        self._key = key
 | 
						|
 | 
						|
        self.new = False
 | 
						|
        self.modified = False
 | 
						|
 | 
						|
        return set_cookie
 | 
						|
 | 
						|
    def needs_refresh(self):
 | 
						|
        """ Checks if server side session needs to be refreshed. """
 | 
						|
 | 
						|
        return int(time.time()) > self.get('_refresh', 0)
 | 
						|
 | 
						|
class MailuSessionConfig:
 | 
						|
    """ Stores sessions crypto config """
 | 
						|
 | 
						|
    # default size of session key parts
 | 
						|
    uid_bits = 64 # default if SESSION_KEY_BITS is not set in config
 | 
						|
    sid_bits = 128 # for now. must be multiple of 8!
 | 
						|
    time_bits = 32 # for now. must be multiple of 8!
 | 
						|
 | 
						|
    def __init__(self, app=None):
 | 
						|
 | 
						|
        if app is None:
 | 
						|
            app = flask.current_app
 | 
						|
 | 
						|
        bits = app.config.get('SESSION_KEY_BITS', self.uid_bits)
 | 
						|
        if not 64 <= bits <= 256:
 | 
						|
            raise ValueError('SESSION_KEY_BITS must be between 64 and 256!')
 | 
						|
 | 
						|
        uid_bytes = bits//8 + (bits%8>0)
 | 
						|
        sid_bytes = self.sid_bits//8
 | 
						|
 | 
						|
        key = want_bytes(app.secret_key)
 | 
						|
 | 
						|
        self._hmac    = hmac.new(hmac.digest(key, b'SESSION_UID_HASH', digest='sha256'), digestmod='sha256')
 | 
						|
        self._uid_len = uid_bytes
 | 
						|
        self._uid_b64 = len(self._encode(bytes(uid_bytes)))
 | 
						|
        self._sid_len = sid_bytes
 | 
						|
        self._sid_b64 = len(self._encode(bytes(sid_bytes)))
 | 
						|
        self._key_min = self._uid_b64 + self._sid_b64
 | 
						|
        self._key_max = self._key_min + len(self._encode(bytes(self.time_bits//8)))
 | 
						|
 | 
						|
    def gen_sid(self):
 | 
						|
        """ Generate random session id. """
 | 
						|
        return self._encode(secrets.token_bytes(self._sid_len))
 | 
						|
 | 
						|
    def gen_uid(self, uid):
 | 
						|
        """ Generate hashed user id part of session key. """
 | 
						|
        _hmac = self._hmac.copy()
 | 
						|
        _hmac.update(want_bytes(uid))
 | 
						|
        return self._encode(_hmac.digest()[:self._uid_len])
 | 
						|
 | 
						|
    def gen_created(self, now=None):
 | 
						|
        """ Generate base64 representation of creation time. """
 | 
						|
        return self._encode(int(now or time.time()).to_bytes(8, byteorder='big').lstrip(b'\0'))
 | 
						|
 | 
						|
    def parse_key(self, key, app=None, validate=False, now=None):
 | 
						|
        """ Split key into sid, uid and creation time. """
 | 
						|
 | 
						|
        if not (isinstance(key, bytes) and self._key_min <= len(key) <= self._key_max):
 | 
						|
            return None
 | 
						|
 | 
						|
        uid = key[:self._uid_b64]
 | 
						|
        sid = key[self._uid_b64:self._key_min]
 | 
						|
        crt = key[self._key_min:]
 | 
						|
 | 
						|
        # validate if parts are decodeable
 | 
						|
        created = self._decode(crt)
 | 
						|
        if created is None or self._decode(uid) is None or self._decode(sid) is None:
 | 
						|
            return None
 | 
						|
 | 
						|
        # validate creation time when requested or store does not support ttl
 | 
						|
        if validate or not app.session_store.has_ttl:
 | 
						|
            if now is None:
 | 
						|
                now = int(time.time())
 | 
						|
            created = int.from_bytes(created, byteorder='big')
 | 
						|
            if not created < now < created + app.permanent_session_lifetime.total_seconds():
 | 
						|
                return None
 | 
						|
 | 
						|
        return (uid, sid, crt)
 | 
						|
 | 
						|
    def _encode(self, value):
 | 
						|
        return secrets.base64.urlsafe_b64encode(value).rstrip(b'=')
 | 
						|
 | 
						|
    def _decode(self, value):
 | 
						|
        try:
 | 
						|
            return secrets.base64.urlsafe_b64decode(value + b'='*(4-len(value)%4))
 | 
						|
        except secrets.binascii.Error:
 | 
						|
            return None
 | 
						|
 | 
						|
class MailuSessionInterface(SessionInterface):
 | 
						|
    """ Custom flask session interface. """
 | 
						|
 | 
						|
    def open_session(self, app, request):
 | 
						|
        """ Load or create session. """
 | 
						|
        return MailuSession(request.cookies.get(app.config['SESSION_COOKIE_NAME'], None), app)
 | 
						|
 | 
						|
    def save_session(self, app, session, response):
 | 
						|
        """ Save modified session. """
 | 
						|
 | 
						|
        # If the session is modified to be empty, remove the cookie.
 | 
						|
        # If the session is empty, return without setting the cookie.
 | 
						|
        if not session:
 | 
						|
            if session.modified:
 | 
						|
                session.delete()
 | 
						|
                response.delete_cookie(
 | 
						|
                    app.session_cookie_name,
 | 
						|
                    domain=self.get_cookie_domain(app),
 | 
						|
                    path=self.get_cookie_path(app),
 | 
						|
                )
 | 
						|
            return
 | 
						|
 | 
						|
        # Add a "Vary: Cookie" header if the session was accessed
 | 
						|
        if session.accessed:
 | 
						|
            response.vary.add('Cookie')
 | 
						|
 | 
						|
        set_cookie = session.permanent and app.config['SESSION_REFRESH_EACH_REQUEST']
 | 
						|
        need_refresh = session.needs_refresh()
 | 
						|
 | 
						|
        # save modified session or refresh unmodified session
 | 
						|
        if session.modified or need_refresh:
 | 
						|
            set_cookie |= session.save()
 | 
						|
 | 
						|
        # set cookie on refreshed permanent sessions
 | 
						|
        if need_refresh and session.permanent:
 | 
						|
            set_cookie = True
 | 
						|
 | 
						|
        # set or update cookie if necessary
 | 
						|
        if set_cookie:
 | 
						|
            response.set_cookie(
 | 
						|
                app.session_cookie_name,
 | 
						|
                session.sid,
 | 
						|
                expires=self.get_expiration_time(app, session),
 | 
						|
                httponly=self.get_cookie_httponly(app),
 | 
						|
                domain=self.get_cookie_domain(app),
 | 
						|
                path=self.get_cookie_path(app),
 | 
						|
                secure=self.get_cookie_secure(app),
 | 
						|
                samesite=self.get_cookie_samesite(app)
 | 
						|
            )
 | 
						|
 | 
						|
class MailuSessionExtension:
 | 
						|
    """ Server side session handling """
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def cleanup_sessions(app=None):
 | 
						|
        """ Remove invalid or expired sessions. """
 | 
						|
 | 
						|
        app = app or flask.current_app
 | 
						|
        now = int(time.time())
 | 
						|
 | 
						|
        count = 0
 | 
						|
        for key in app.session_store.list():
 | 
						|
            if not app.session_config.parse_key(key, app, validate=True, now=now):
 | 
						|
                app.session_store.delete(key)
 | 
						|
                count += 1
 | 
						|
 | 
						|
        return count
 | 
						|
 | 
						|
    @staticmethod
 | 
						|
    def prune_sessions(uid=None, keep=None, app=None):
 | 
						|
        """ Remove sessions
 | 
						|
            uid: remove all sessions (NONE) or sessions belonging to a specific user
 | 
						|
            keep: keep listed sessions
 | 
						|
        """
 | 
						|
 | 
						|
        keep = keep or set()
 | 
						|
        app = app or flask.current_app
 | 
						|
 | 
						|
        prefix = None if uid is None else app.session_config.gen_uid(uid)
 | 
						|
 | 
						|
        count = 0
 | 
						|
        for key in app.session_store.list(prefix):
 | 
						|
            if key not in keep:
 | 
						|
                app.session_store.delete(key)
 | 
						|
                count += 1
 | 
						|
 | 
						|
        return count
 | 
						|
 | 
						|
    def init_app(self, app):
 | 
						|
        """ Replace session management of application. """
 | 
						|
 | 
						|
        if app.config.get('MEMORY_SESSIONS'):
 | 
						|
            # in-memory session store for use in development
 | 
						|
            app.session_store = DictStore()
 | 
						|
 | 
						|
        else:
 | 
						|
            # redis-based session store for use in production
 | 
						|
            app.session_store = RedisStore(
 | 
						|
                redis.StrictRedis().from_url(app.config['SESSION_STORAGE_URL'])
 | 
						|
            )
 | 
						|
 | 
						|
            # clean expired sessions oonce on first use in case lifetime was changed
 | 
						|
            def cleaner():
 | 
						|
                with cleaned.get_lock():
 | 
						|
                    if not cleaned.value:
 | 
						|
                        cleaned.value = True
 | 
						|
                        app.logger.info('cleaning session store')
 | 
						|
                        MailuSessionExtension.cleanup_sessions(app)
 | 
						|
 | 
						|
            app.before_first_request(cleaner)
 | 
						|
 | 
						|
        app.session_config = MailuSessionConfig(app)
 | 
						|
        app.session_interface = MailuSessionInterface()
 | 
						|
 | 
						|
cleaned = Value('i', False)
 | 
						|
session = MailuSessionExtension()
 |