Commit 1266195c authored by ale's avatar ale

Initial commit

parents
image: docker:latest
stages:
- docker_build
- release
services:
- docker:dind
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
RELEASE_TAG: $CI_REGISTRY_IMAGE:latest
GIT_SUBMODULE_STRATEGY: recursive
docker_build:
stage: docker_build
script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.git.autistici.org
- docker build --build-arg ci_token=$CI_JOB_TOKEN --pull -t $IMAGE_TAG .
- docker push $IMAGE_TAG
release:
stage: release
script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.git.autistici.org
- docker pull $IMAGE_TAG
- docker tag $IMAGE_TAG $RELEASE_TAG
- docker push $RELEASE_TAG
only:
- master
FROM debian:buster AS build
RUN apt-get -q update && \
env DEBIAN_FRONTEND=noninteractive apt-get -qy install --no-install-recommends \
python python-dev python-pip python-setuptools python-wheel git build-essential
ADD . /src
WORKDIR /src
RUN mkdir -p dist; pip wheel -r requirements.txt -w dist
RUN python setup.py bdist_wheel
FROM debian:buster
COPY --from=build /src/dist/*.whl /tmp/wheels/
RUN cd /tmp/wheels && pip install *.whl && rm -fr /tmp/wheels
ENTRYPOINT ["/usr/bin/python-mysql-api-server"]
import json
import optparse
import os
import re
import ssl
import subprocess
import sys
from functools import wraps
from flask import Flask, request, abort, make_response
import pymysql
import pymysql.cursors
from .sso_api import sso_api_auth_required, init_sso
from .tls_auth import tls_auth, init_tls_auth, PeerCertWSGIRequestHandler
app = Flask(__name__)
app.config['TLS_AUTH_ACLS'] = [
('/', 'client'),
]
def to_json(fn):
"""Encode response as JSON."""
@wraps(fn)
def _json_wrapper(*args, **kwargs):
result = fn(*args, **kwargs)
resp = make_response(json.dumps(result))
resp.headers['Content-Type'] = 'application/json'
return resp
return _json_wrapper
def _random_password():
return os.urandom(12).encode('base64').rstrip('=\n')
def _connect(host='localhost', dbname=None,
cursor_class=None, defaults_file=None):
args = {'host': host,
'read_default_file': defaults_file,
'init_command': 'SET NAMES utf8'}
if dbname:
args['db'] = dbname
c = pymysql.connect(**args)
return c.cursor(cursor_class)
@app.route('/api/reset_password', methods=('POST',))
@tls_auth
@sso_api_auth_required
@to_json
def reset_password():
db_user = request.json['db_user']
password = _random_password()
conn = _connect(
dbname='mysql',
host=app.config.get('MYSQL_HOST', 'localhost'),
defaults_file=app.config.get('MYSQL_DEFAULTS_FILE'),
)
conn.execute('SET PASSWORD FOR ?@localhost = PASSWORD(?)',
db_user, password)
app.logger.info('mysql password reset for db_user=%s on behalf of %s',
db_user, g.current_user)
return {
'password': password,
}
### SSL server with the right parameters.
def serve_ssl(app, host='localhost', port=3000, **kwargs):
# Create a validating SSLContext.
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
ssl_ctx.set_ciphers('ECDHE-ECDSA-AES256-GCM-SHA384')
ssl_ctx.load_cert_chain(certfile=app.config['SSL_CERT'],
keyfile=app.config['SSL_KEY'])
ssl_ctx.load_verify_locations(cafile=app.config['SSL_CA'])
ssl_ctx.verify_mode = ssl.CERT_REQUIRED
app.run(
host, port,
ssl_context=ssl_ctx,
request_handler=PeerCertWSGIRequestHandler,
**kwargs
)
def main():
parser = optparse.OptionParser()
parser.add_option('--config', default='/etc/mysql_api.conf')
parser.add_option('--port', type='int', default=5778)
parser.add_option('--addr', default='0.0.0.0')
opts, args = parser.parse_args()
if args:
parser.error('Too many arguments')
app.config.from_pyfile(opts.config)
init_sso(app)
init_tls_auth(app)
serve_ssl(app, host=opts.addr, port=opts.port)
if __name__ == '__main__':
main()
import os
import sso
from functools import wraps
from flask import current_app, request, make_response, abort, g
def init_sso(app):
if 'SSO_LOGIN_SERVER' not in app.config:
raise Exception('Must configure SSO_LOGIN_SERVER')
if 'SSO_SERVICE' not in app.config:
raise Exception('Must configure SSO_SERVICE')
if 'SSO_DOMAIN' not in app.config:
raise Exception('Must configure SSO_DOMAIN')
pubkey_file = app.config.get(
'SSO_PUBLIC_KEY_FILE', '/etc/sso/public.key')
with open(pubkey_file) as f:
pubkey = f.read()
# Ensure the login server URL is /-terminated.
app.sso_login_server = app.config['SSO_LOGIN_SERVER'].rstrip('/') + '/'
app.sso_service = app.config['SSO_SERVICE']
app.sso_validator = sso.Verifier(
pubkey, app.config['SSO_SERVICE'], app.config['SSO_DOMAIN'],
app.config.get('SSO_GROUPS'))
if app.config.get('SSO_DEBUG'):
app.logger.info(
'SSO verifier created (service=%s, domain=%s, groups=%s)',
app.config['SSO_SERVICE'], app.config['SSO_DOMAIN'],
app.config.get('SSO_GROUPS'))
def sso_api_auth_required(func):
"""Wrap an API (non-interactive) handler with SSO authentication.
This wrapper will redirect the user to the single sign-on login
server if the request lacks a valid SSO ticket.
Sets the 'g.current_user' variable to the name of the
authenticated user, and 'g.sso_ticket' to the SSO ticket itself.
Does not support interactive clients (browsers), so it will not
support nonces and won't send redirects to the login server.
Instead, it expects the SSO ticket to be specified as the 'sso'
attribute of the JSON request payload.
"""
@wraps(func)
def _auth_wrapper(*args, **kwargs):
if current_app.config.get('FAKE_SSO_USER'):
g.current_user = current_app.config['FAKE_SSO_USER']
g.sso_ticket = 'sso_ticket'
return func(*args, **kwargs)
sso_ticket = request.json.get('sso')
if not sso_ticket:
abort(401)
try:
ticket = current_app.sso_validator.verify(sso_ticket.encode())
g.current_user = ticket.user()
g.sso_is_admin = ('admins' in ticket.groups())
g.sso_ticket = ticket
except sso.Error as e:
current_app.logger.error('authentication failed: %s', e)
abort(403)
return func(*args, **kwargs)
return _auth_wrapper
import re
import werkzeug.serving
from functools import wraps
from flask import current_app, request, abort
DEFAULT_TLS_AUTH_ACLS = [('/', '.*')]
class PeerCertWSGIRequestHandler(werkzeug.serving.WSGIRequestHandler):
"""
We subclass this class so that we can gain access to the connection
property. self.connection is the underlying client socket. When a TLS
connection is established, the underlying socket is an instance of
SSLSocket, which in turn exposes the getpeercert() method.
The output from that method is what we want to make available elsewhere
in the application.
"""
def make_environ(self):
"""
The superclass method develops the environ hash that eventually
forms part of the Flask request object.
We allow the superclass method to run first, then we insert the
peer certificate into the hash. That exposes it to us later in
the request variable that Flask provides
"""
environ = super(PeerCertWSGIRequestHandler, self).make_environ()
environ['peercert'] = self.connection.getpeercert()
return environ
def init_tls_auth(app):
compiled = []
for acl_path, acl_cn_pattern in app.config.get(
'TLS_AUTH_ACLS', DEFAULT_TLS_AUTH_ACLS):
acl_cn_rx = re.compile('^%s$' % acl_cn_pattern)
compiled.append((acl_path, acl_cn_rx))
app.tls_auth_acls = compiled
def _get_subject_cn(peercert):
"""Extract subject CN from the parsed peercert data."""
parsed_subject = peercert['subject']
if len(parsed_subject) != 1:
raise Exception('multiple subjects')
for attr, value in parsed_subject[0]:
if attr == 'commonName':
return value
def _regexp_match(rx, s):
"""Returns True if the anchored rx matches s."""
return rx.match(s) is not None
def tls_auth(fn):
"""Enable TLS client authentication for this endpoint."""
@wraps(fn)
def _tls_auth_wrapper(*args, **kwargs):
cn = _get_subject_cn(request.environ['peercert'])
for acl_path, acl_cn_rx in current_app.tls_auth_acls:
if request.path.startswith(acl_path) and _regexp_match(acl_cn_rx, cn):
return fn(*args, **kwargs)
abort(403)
return _tls_auth_wrapper
git+https://git.autistici.org/ai/sso.git#egg=sso&subdirectory=src/python
Flask
PyMySQL
#!/usr/bin/python
from setuptools import setup, find_packages
setup(
name="mysql-api",
version="0.1",
description="Internal Mailman API server",
author="Autistici/Inventati",
author_email="info@autistici.org",
url="https://git.autistici.org/ai3/python-mysql-api",
install_requires=["Flask",
"PyMySQL",
"sso"],
packages=find_packages(),
entry_points={
"console_scripts": [
"python-mysql-api-server = mysql_api.mysql_api:main",
],
},
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment