2020-02-20 00:28:43 -05:00
|
|
|
import base64
|
|
|
|
import binascii
|
|
|
|
import hashlib
|
|
|
|
import hmac
|
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
import struct
|
2020-02-20 03:15:32 -05:00
|
|
|
import time
|
2021-04-25 04:12:52 -04:00
|
|
|
from typing import Optional
|
2020-02-20 00:28:43 -05:00
|
|
|
|
|
|
|
import gssapi
|
|
|
|
import ldap
|
2020-02-20 03:15:32 -05:00
|
|
|
from flask import Flask, Response, redirect, request
|
|
|
|
from gssapi.exceptions import BadMechanismError, GSSError, GeneralError
|
2020-03-09 02:24:43 -04:00
|
|
|
from werkzeug.routing import Rule
|
2020-02-20 00:28:43 -05:00
|
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
app.logger.setLevel(logging.INFO)
|
2021-08-21 17:14:44 -04:00
|
|
|
app.url_map.add(Rule('/krbauth', endpoint='krbauth.auth'))
|
2020-03-09 02:24:43 -04:00
|
|
|
app.url_map.add(Rule('/krbauth/check', endpoint='krbauth.check'))
|
2020-02-20 00:28:43 -05:00
|
|
|
|
|
|
|
timestamp = struct.Struct('!q')
|
|
|
|
hmac_digest = hashlib.sha512
|
|
|
|
digest_size = hmac_digest().digest_size
|
|
|
|
|
|
|
|
HMAC_KEY = os.environ['KRBAUTH_HMAC_KEY'].encode('utf-8')
|
|
|
|
DURATION = int(os.environ.get('KRBAUTH_KEY_DURATION', 3600))
|
|
|
|
RANDOM_SIZE = int(os.environ.get('KRBAUTH_RANDOM_SIZE', 32))
|
2020-02-20 02:55:19 -05:00
|
|
|
LDAP_SERVER = os.environ.get('KRBAUTH_LDAP_SERVER')
|
2020-02-20 00:28:43 -05:00
|
|
|
LDAP_BIND_DN = os.environ.get('KRBAUTH_LDAP_BIND_DN')
|
|
|
|
LDAP_BIND_AUTHTOK = os.environ.get('KRBAUTH_LDAP_BIND_AUTHTOK')
|
2020-02-20 02:55:19 -05:00
|
|
|
LDAP_SEARCH_BASE = os.environ.get('KRBAUTH_LDAP_SEARCH_BASE')
|
2020-02-20 03:05:29 -05:00
|
|
|
LDAP_USER_DN = os.environ.get('KRBAUTH_LDAP_USER_DN')
|
|
|
|
assert not LDAP_USER_DN or LDAP_USER_DN.count('%s') == 1
|
2020-02-20 00:28:43 -05:00
|
|
|
|
|
|
|
GSSAPI_NAME = os.environ.get('KRBAUTH_GSSAPI_NAME')
|
|
|
|
if GSSAPI_NAME:
|
|
|
|
gssapi_name = gssapi.Name(GSSAPI_NAME, gssapi.NameType.hostbased_service)
|
|
|
|
gssapi_creds = gssapi.Credentials(name=gssapi_name, usage='accept')
|
|
|
|
else:
|
|
|
|
gssapi_creds = None
|
|
|
|
|
|
|
|
COOKIE_SECURE = os.environ.get('KRBAUTH_SECURE_COOKIE', '1').lower() not in ('0', 'no')
|
|
|
|
|
|
|
|
|
|
|
|
class Context:
|
2021-04-25 04:12:52 -04:00
|
|
|
def __init__(self, ldap_group: Optional[str]) -> None:
|
2020-02-20 00:28:43 -05:00
|
|
|
self.ldap_group = ldap_group
|
|
|
|
|
|
|
|
@classmethod
|
2021-04-25 04:12:52 -04:00
|
|
|
def from_request(cls) -> 'Context':
|
2020-02-20 00:28:43 -05:00
|
|
|
return cls(ldap_group=request.environ.get('KRBAUTH_LDAP_GROUP'))
|
|
|
|
|
2021-04-25 04:12:52 -04:00
|
|
|
def bytes(self) -> bytes:
|
|
|
|
assert self.ldap_group
|
2020-02-20 00:28:43 -05:00
|
|
|
return ''.join([self.ldap_group]).encode('utf-8')
|
|
|
|
|
|
|
|
|
2021-04-25 04:12:52 -04:00
|
|
|
def make_cookie(context: Context) -> bytes:
|
2020-02-20 00:28:43 -05:00
|
|
|
message = timestamp.pack(int(time.time()) + DURATION) + os.urandom(RANDOM_SIZE) + context.bytes()
|
|
|
|
signature = hmac.new(HMAC_KEY, message, hmac_digest).digest()
|
|
|
|
return base64.b64encode(signature + message)
|
|
|
|
|
|
|
|
|
2021-04-25 04:12:52 -04:00
|
|
|
def verify_cookie(cookie: Optional[str], context: Context) -> bool:
|
2020-02-20 00:28:43 -05:00
|
|
|
if not cookie:
|
|
|
|
return False
|
|
|
|
try:
|
|
|
|
data = base64.b64decode(cookie)
|
|
|
|
signature = data[:digest_size]
|
|
|
|
message = data[digest_size:]
|
|
|
|
ts = timestamp.unpack(message[:timestamp.size])[0]
|
|
|
|
except (struct.error, binascii.Error):
|
|
|
|
return False
|
|
|
|
if ts < time.time():
|
|
|
|
return False
|
|
|
|
if not hmac.compare_digest(message[timestamp.size + RANDOM_SIZE:], context.bytes()):
|
|
|
|
return False
|
2020-07-04 15:40:06 -04:00
|
|
|
expected = hmac.new(HMAC_KEY, message, hmac_digest).digest()
|
2020-02-20 00:28:43 -05:00
|
|
|
return hmac.compare_digest(expected, signature)
|
|
|
|
|
|
|
|
|
2021-04-25 04:12:52 -04:00
|
|
|
def make_401(reason: str, negotiate: Optional[str] = 'Negotiate', **kwargs) -> Response:
|
2020-02-20 03:05:29 -05:00
|
|
|
app.logger.info('Returning unauthorized: %s (%s)', reason, kwargs)
|
|
|
|
resp = Response('''\
|
2020-02-20 00:28:43 -05:00
|
|
|
<html>
|
|
|
|
<head>
|
|
|
|
<title>401 Unauthorized</title>
|
|
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<center><h1>401 Unauthorized</h1></center>
|
|
|
|
<hr>
|
|
|
|
<center>%s</center>
|
|
|
|
</body>
|
|
|
|
</html>
|
2020-02-20 03:05:29 -05:00
|
|
|
''' % (reason,), status=401)
|
2020-05-03 01:06:40 -04:00
|
|
|
if negotiate:
|
2020-02-20 03:05:34 -05:00
|
|
|
resp.headers.add('WWW-Authenticate', negotiate)
|
2020-02-20 03:05:29 -05:00
|
|
|
if LDAP_USER_DN:
|
|
|
|
resp.headers.add('WWW-Authenticate', 'Basic')
|
|
|
|
return resp
|
2020-02-20 00:28:43 -05:00
|
|
|
|
|
|
|
|
2021-04-25 04:12:52 -04:00
|
|
|
def auth_success(context: Context, next_url: str) -> Response:
|
|
|
|
resp = redirect(next_url, code=307, Response=Response)
|
2020-02-20 03:05:29 -05:00
|
|
|
resp.set_cookie('krbauth', make_cookie(context), secure=COOKIE_SECURE, httponly=True, samesite='Strict')
|
|
|
|
return resp
|
2020-02-20 00:28:43 -05:00
|
|
|
|
|
|
|
|
2021-04-25 04:12:52 -04:00
|
|
|
def auth_spnego(context: Context, next_url: str) -> Response:
|
2020-02-20 00:28:43 -05:00
|
|
|
try:
|
2021-04-25 04:12:52 -04:00
|
|
|
in_token = base64.b64decode(request.headers['Authorization'][len('Negotiate '):])
|
2020-02-20 00:28:43 -05:00
|
|
|
except binascii.Error:
|
|
|
|
return Response(status=400)
|
|
|
|
|
|
|
|
try:
|
|
|
|
krb5_ctx = gssapi.SecurityContext(creds=gssapi_creds, usage='accept')
|
|
|
|
out_token = krb5_ctx.step(in_token)
|
|
|
|
|
|
|
|
if not krb5_ctx.complete:
|
2021-04-25 04:12:52 -04:00
|
|
|
return make_401('Negotiation in progress',
|
|
|
|
negotiate=f'Negotiate {base64.b64encode(out_token).decode("ascii")}')
|
2020-02-20 00:28:43 -05:00
|
|
|
|
2021-04-25 04:12:52 -04:00
|
|
|
krb5_name = krb5_ctx.initiator_name
|
2020-02-20 03:05:34 -05:00
|
|
|
except BadMechanismError:
|
2021-04-25 04:12:52 -04:00
|
|
|
return make_401('GSSAPI mechanism not supported', negotiate=None)
|
2020-02-20 00:28:43 -05:00
|
|
|
except (GSSError, GeneralError) as e:
|
2021-04-25 04:12:52 -04:00
|
|
|
return make_401(str(e))
|
2020-02-20 00:28:43 -05:00
|
|
|
|
2020-02-20 02:55:19 -05:00
|
|
|
if LDAP_SERVER and LDAP_SEARCH_BASE and context.ldap_group:
|
2020-02-20 00:28:43 -05:00
|
|
|
ldap_ctx = ldap.initialize(LDAP_SERVER)
|
|
|
|
if LDAP_BIND_DN and LDAP_BIND_AUTHTOK:
|
|
|
|
ldap_ctx.bind_s(LDAP_BIND_DN, LDAP_BIND_AUTHTOK, ldap.AUTH_SIMPLE)
|
|
|
|
ldap_filter = '(&(memberOf=%s)(krbPrincipalName=%s))' % (context.ldap_group, krb5_name)
|
2020-02-20 03:05:29 -05:00
|
|
|
result = ldap_ctx.search_s(LDAP_SEARCH_BASE, ldap.SCOPE_SUBTREE, ldap_filter, ['cn'])
|
2020-02-20 00:28:43 -05:00
|
|
|
if not result:
|
2021-04-25 04:12:52 -04:00
|
|
|
return make_401('Did not find LDAP group member', krb5_name=krb5_name)
|
2020-02-20 03:05:29 -05:00
|
|
|
app.logger.info('Authenticated via Kerberos as: %s, %s', krb5_name, result[0][0])
|
2020-02-20 00:28:43 -05:00
|
|
|
else:
|
2020-02-20 03:05:29 -05:00
|
|
|
app.logger.info('Authenticated via Kerberos as: %s', krb5_name)
|
2020-02-20 00:28:43 -05:00
|
|
|
|
2020-02-20 03:05:29 -05:00
|
|
|
return auth_success(context, next_url)
|
|
|
|
|
|
|
|
|
2021-04-25 04:12:52 -04:00
|
|
|
def auth_basic(context: Context, next_url: str) -> Response:
|
2020-02-20 03:05:29 -05:00
|
|
|
try:
|
|
|
|
token = base64.b64decode(request.headers['Authorization'][6:])
|
|
|
|
username, _, password = token.decode('utf-8').partition(':')
|
|
|
|
except (binascii.Error, UnicodeDecodeError):
|
|
|
|
return Response(status=400)
|
|
|
|
|
2020-02-20 03:05:34 -05:00
|
|
|
if not username or not password:
|
2021-04-25 04:12:52 -04:00
|
|
|
return make_401('Invalid username or password')
|
2020-02-20 03:05:34 -05:00
|
|
|
|
2021-04-25 04:12:52 -04:00
|
|
|
assert LDAP_USER_DN is not None
|
2020-02-20 03:05:29 -05:00
|
|
|
dn = LDAP_USER_DN % (username,)
|
|
|
|
ldap_ctx = ldap.initialize(LDAP_SERVER)
|
|
|
|
try:
|
|
|
|
ldap_ctx.bind_s(dn, password)
|
|
|
|
except ldap.INVALID_CREDENTIALS:
|
2021-04-25 04:12:52 -04:00
|
|
|
return make_401('Failed to authenticate to LDAP', dn=dn)
|
2020-02-20 03:05:29 -05:00
|
|
|
|
|
|
|
if context.ldap_group:
|
|
|
|
if not ldap_ctx.search_s(dn, ldap.SCOPE_BASE, '(memberof=%s)' % (context.ldap_group,)):
|
2021-04-25 04:12:52 -04:00
|
|
|
return make_401('Did not find LDAP group member', dn=dn, group=context.ldap_group)
|
2020-02-20 03:05:29 -05:00
|
|
|
app.logger.info('Authenticated via LDAP as: %s in %s', dn, context.ldap_group)
|
|
|
|
else:
|
|
|
|
app.logger.info('Authenticated via LDAP as: %s', dn)
|
|
|
|
|
|
|
|
return auth_success(context, next_url)
|
|
|
|
|
|
|
|
|
2021-08-21 17:14:44 -04:00
|
|
|
@app.endpoint('krbauth.auth')
|
2021-04-25 04:12:52 -04:00
|
|
|
def auth() -> Response:
|
2020-02-20 03:05:29 -05:00
|
|
|
next_url = request.args.get('next', '/')
|
|
|
|
context = Context.from_request()
|
|
|
|
authorization = request.headers.get('Authorization', '')
|
|
|
|
|
|
|
|
if authorization.startswith('Negotiate '):
|
|
|
|
return auth_spnego(context, next_url)
|
|
|
|
if LDAP_USER_DN and authorization.startswith('Basic '):
|
|
|
|
return auth_basic(context, next_url)
|
|
|
|
|
2021-04-25 04:12:52 -04:00
|
|
|
return make_401('No Authorization header sent')
|
2020-02-20 00:28:43 -05:00
|
|
|
|
|
|
|
|
2020-03-09 02:24:43 -04:00
|
|
|
@app.endpoint('krbauth.check')
|
2021-04-25 04:12:52 -04:00
|
|
|
def check() -> Response:
|
2020-02-20 00:28:43 -05:00
|
|
|
if verify_cookie(request.cookies.get('krbauth'), Context.from_request()):
|
|
|
|
return Response(status=200)
|
|
|
|
return Response(status=401)
|