352 lines
13 KiB
Python
352 lines
13 KiB
Python
import json
|
|
import os
|
|
import re
|
|
import uuid
|
|
from urllib.parse import urlencode
|
|
|
|
import tornado.auth
|
|
import tornado.gen
|
|
import tornado.web
|
|
from celery.utils.imports import instantiate
|
|
from tornado.options import options
|
|
|
|
from ..views import BaseHandler
|
|
from ..views.error import NotFoundErrorHandler
|
|
|
|
# pylint: disable=invalid-name
|
|
|
|
|
|
def authenticate(pattern, email):
|
|
if '|' in pattern:
|
|
return email in pattern.split('|')
|
|
if '*' in pattern:
|
|
pattern = re.escape(pattern).replace(r'\.\*', r"[A-Za-z0-9!#$%&'*+/=?^_`{|}~.\-]*")
|
|
return re.fullmatch(pattern, email)
|
|
return pattern == email
|
|
|
|
|
|
def validate_auth_option(pattern):
|
|
if pattern.count('*') > 1:
|
|
return False
|
|
if '*' in pattern and '|' in pattern:
|
|
return False
|
|
if '*' in pattern.rsplit('@', 1)[-1]:
|
|
return False
|
|
return True
|
|
|
|
|
|
class GoogleAuth2LoginHandler(BaseHandler, tornado.auth.GoogleOAuth2Mixin):
|
|
_OAUTH_SETTINGS_KEY = 'oauth'
|
|
|
|
async def get(self):
|
|
redirect_uri = self.settings[self._OAUTH_SETTINGS_KEY]['redirect_uri']
|
|
if self.get_argument('code', False):
|
|
user = await self.get_authenticated_user(
|
|
redirect_uri=redirect_uri,
|
|
code=self.get_argument('code'),
|
|
)
|
|
await self._on_auth(user)
|
|
else:
|
|
self.authorize_redirect(
|
|
redirect_uri=redirect_uri,
|
|
client_id=self.settings[self._OAUTH_SETTINGS_KEY]['key'],
|
|
scope=['profile', 'email'],
|
|
response_type='code',
|
|
extra_params={'approval_prompt': ''}
|
|
)
|
|
|
|
async def _on_auth(self, user):
|
|
if not user:
|
|
raise tornado.web.HTTPError(403, 'Google auth failed')
|
|
access_token = user['access_token']
|
|
|
|
try:
|
|
response = await self.get_auth_http_client().fetch(
|
|
'https://www.googleapis.com/userinfo/v2/me',
|
|
headers={'Authorization': f'Bearer {access_token}'})
|
|
except Exception as e:
|
|
raise tornado.web.HTTPError(403, f'Google auth failed: {e}')
|
|
|
|
email = json.loads(response.body.decode('utf-8'))['email']
|
|
if not authenticate(self.application.options.auth, email):
|
|
message = f"Access denied to '{email}'. Please use another account or ask your admin to add your email to flower --auth."
|
|
raise tornado.web.HTTPError(403, message)
|
|
|
|
self.set_secure_cookie("user", str(email))
|
|
|
|
next_ = self.get_argument('next', self.application.options.url_prefix or '/')
|
|
if self.application.options.url_prefix and next_[0] != '/':
|
|
next_ = '/' + next_
|
|
|
|
self.redirect(next_)
|
|
|
|
|
|
class LoginHandler(BaseHandler):
|
|
def __new__(cls, *args, **kwargs):
|
|
return instantiate(options.auth_provider or NotFoundErrorHandler, *args, **kwargs)
|
|
|
|
|
|
class GithubLoginHandler(BaseHandler, tornado.auth.OAuth2Mixin):
|
|
|
|
_OAUTH_DOMAIN = os.getenv(
|
|
"FLOWER_GITHUB_OAUTH_DOMAIN", "github.com")
|
|
_OAUTH_AUTHORIZE_URL = f'https://{_OAUTH_DOMAIN}/login/oauth/authorize'
|
|
_OAUTH_ACCESS_TOKEN_URL = f'https://{_OAUTH_DOMAIN}/login/oauth/access_token'
|
|
_OAUTH_NO_CALLBACKS = False
|
|
_OAUTH_SETTINGS_KEY = 'oauth'
|
|
|
|
async def get_authenticated_user(self, redirect_uri, code):
|
|
body = urlencode({
|
|
"redirect_uri": redirect_uri,
|
|
"code": code,
|
|
"client_id": self.settings[self._OAUTH_SETTINGS_KEY]['key'],
|
|
"client_secret": self.settings[self._OAUTH_SETTINGS_KEY]['secret'],
|
|
"grant_type": "authorization_code",
|
|
})
|
|
|
|
response = await self.get_auth_http_client().fetch(
|
|
self._OAUTH_ACCESS_TOKEN_URL,
|
|
method="POST",
|
|
headers={'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Accept': 'application/json'}, body=body)
|
|
|
|
if response.error:
|
|
raise tornado.auth.AuthError(f'OAuth authenticator error: {response}')
|
|
|
|
return json.loads(response.body.decode('utf-8'))
|
|
|
|
async def get(self):
|
|
redirect_uri = self.settings[self._OAUTH_SETTINGS_KEY]['redirect_uri']
|
|
if self.get_argument('code', False):
|
|
user = await self.get_authenticated_user(
|
|
redirect_uri=redirect_uri,
|
|
code=self.get_argument('code'),
|
|
)
|
|
await self._on_auth(user)
|
|
else:
|
|
self.authorize_redirect(
|
|
redirect_uri=redirect_uri,
|
|
client_id=self.settings[self._OAUTH_SETTINGS_KEY]['key'],
|
|
scope=['user:email'],
|
|
response_type='code',
|
|
extra_params={'approval_prompt': ''}
|
|
)
|
|
|
|
async def _on_auth(self, user):
|
|
if not user:
|
|
raise tornado.web.HTTPError(500, 'OAuth authentication failed')
|
|
access_token = user['access_token']
|
|
|
|
response = await self.get_auth_http_client().fetch(
|
|
f'https://api.{self._OAUTH_DOMAIN}/user/emails',
|
|
headers={'Authorization': 'token ' + access_token,
|
|
'User-agent': 'Tornado auth'})
|
|
|
|
emails = [email['email'].lower() for email in json.loads(response.body.decode('utf-8'))
|
|
if email['verified'] and authenticate(self.application.options.auth, email['email'])]
|
|
|
|
if not emails:
|
|
message = (
|
|
"Access denied. Please use another account or "
|
|
"ask your admin to add your email to flower --auth."
|
|
)
|
|
raise tornado.web.HTTPError(403, message)
|
|
|
|
self.set_secure_cookie("user", str(emails.pop()))
|
|
|
|
next_ = self.get_argument('next', self.application.options.url_prefix or '/')
|
|
if self.application.options.url_prefix and next_[0] != '/':
|
|
next_ = '/' + next_
|
|
self.redirect(next_)
|
|
|
|
|
|
class GitLabLoginHandler(BaseHandler, tornado.auth.OAuth2Mixin):
|
|
|
|
_OAUTH_GITLAB_DOMAIN = os.getenv(
|
|
"FLOWER_GITLAB_OAUTH_DOMAIN", "gitlab.com")
|
|
_OAUTH_AUTHORIZE_URL = f'https://{_OAUTH_GITLAB_DOMAIN}/oauth/authorize'
|
|
_OAUTH_ACCESS_TOKEN_URL = f'https://{_OAUTH_GITLAB_DOMAIN}/oauth/token'
|
|
_OAUTH_NO_CALLBACKS = False
|
|
|
|
async def get_authenticated_user(self, redirect_uri, code):
|
|
body = urlencode({
|
|
'redirect_uri': redirect_uri,
|
|
'code': code,
|
|
'client_id': self.settings['oauth']['key'],
|
|
'client_secret': self.settings['oauth']['secret'],
|
|
'grant_type': 'authorization_code',
|
|
})
|
|
response = await self.get_auth_http_client().fetch(
|
|
self._OAUTH_ACCESS_TOKEN_URL,
|
|
method='POST',
|
|
headers={'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Accept': 'application/json'},
|
|
body=body
|
|
)
|
|
if response.error:
|
|
raise tornado.auth.AuthError(f'OAuth authenticator error: {response}')
|
|
return json.loads(response.body.decode('utf-8'))
|
|
|
|
async def get(self):
|
|
redirect_uri = self.settings['oauth']['redirect_uri']
|
|
if self.get_argument('code', False):
|
|
user = await self.get_authenticated_user(
|
|
redirect_uri=redirect_uri,
|
|
code=self.get_argument('code'),
|
|
)
|
|
await self._on_auth(user)
|
|
else:
|
|
self.authorize_redirect(
|
|
redirect_uri=redirect_uri,
|
|
client_id=self.settings['oauth']['key'],
|
|
scope=['read_api'],
|
|
response_type='code',
|
|
extra_params={'approval_prompt': ''},
|
|
)
|
|
|
|
async def _on_auth(self, user):
|
|
if not user:
|
|
raise tornado.web.HTTPError(500, 'OAuth authentication failed')
|
|
access_token = user['access_token']
|
|
allowed_groups = os.environ.get('FLOWER_GITLAB_AUTH_ALLOWED_GROUPS', '')
|
|
allowed_groups = [group.strip() for group in allowed_groups.split(',') if group]
|
|
|
|
# Check user email address against regexp
|
|
try:
|
|
response = await self.get_auth_http_client().fetch(
|
|
f'https://{self._OAUTH_GITLAB_DOMAIN}/api/v4/user',
|
|
headers={'Authorization': 'Bearer ' + access_token,
|
|
'User-agent': 'Tornado auth'}
|
|
)
|
|
except Exception as e:
|
|
raise tornado.web.HTTPError(403, f'GitLab auth failed: {e}')
|
|
|
|
user_email = json.loads(response.body.decode('utf-8'))['email']
|
|
email_allowed = authenticate(self.application.options.auth, user_email)
|
|
|
|
# Check user's groups against list of allowed groups
|
|
matching_groups = []
|
|
if allowed_groups:
|
|
min_access_level = os.environ.get('FLOWER_GITLAB_MIN_ACCESS_LEVEL', '20')
|
|
response = await self.get_auth_http_client().fetch(
|
|
f'https://{self._OAUTH_GITLAB_DOMAIN}/api/v4/groups?min_access_level={min_access_level}',
|
|
headers={
|
|
'Authorization': 'Bearer ' + access_token,
|
|
'User-agent': 'Tornado auth'
|
|
}
|
|
)
|
|
matching_groups = [
|
|
group['id']
|
|
for group in json.loads(response.body.decode('utf-8'))
|
|
if group['full_path'] in allowed_groups
|
|
]
|
|
|
|
if not email_allowed or (allowed_groups and len(matching_groups) == 0):
|
|
message = 'Access denied. Please use another account or contact your admin.'
|
|
raise tornado.web.HTTPError(403, message)
|
|
|
|
self.set_secure_cookie('user', str(user_email))
|
|
next_ = self.get_argument('next', self.application.options.url_prefix or '/')
|
|
if self.application.options.url_prefix and next_[0] != '/':
|
|
next_ = '/' + next_
|
|
self.redirect(next_)
|
|
|
|
|
|
class OktaLoginHandler(BaseHandler, tornado.auth.OAuth2Mixin):
|
|
_OAUTH_NO_CALLBACKS = False
|
|
_OAUTH_SETTINGS_KEY = 'oauth'
|
|
|
|
@property
|
|
def base_url(self):
|
|
return os.environ.get('FLOWER_OAUTH2_OKTA_BASE_URL')
|
|
|
|
@property
|
|
def _OAUTH_AUTHORIZE_URL(self):
|
|
return f"{self.base_url}/v1/authorize"
|
|
|
|
@property
|
|
def _OAUTH_ACCESS_TOKEN_URL(self):
|
|
return f"{self.base_url}/v1/token"
|
|
|
|
@property
|
|
def _OAUTH_USER_INFO_URL(self):
|
|
return f"{self.base_url}/v1/userinfo"
|
|
|
|
async def get_access_token(self, redirect_uri, code):
|
|
body = urlencode({
|
|
"redirect_uri": redirect_uri,
|
|
"code": code,
|
|
"client_id": self.settings[self._OAUTH_SETTINGS_KEY]['key'],
|
|
"client_secret": self.settings[self._OAUTH_SETTINGS_KEY]['secret'],
|
|
"grant_type": "authorization_code",
|
|
})
|
|
|
|
response = await self.get_auth_http_client().fetch(
|
|
self._OAUTH_ACCESS_TOKEN_URL,
|
|
method="POST",
|
|
headers={'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Accept': 'application/json'}, body=body)
|
|
|
|
if response.error:
|
|
raise tornado.auth.AuthError(f'OAuth authenticator error: {response}')
|
|
|
|
return json.loads(response.body.decode('utf-8'))
|
|
|
|
async def get(self):
|
|
redirect_uri = self.settings[self._OAUTH_SETTINGS_KEY]['redirect_uri']
|
|
if self.get_argument('code', False):
|
|
expected_state = (self.get_secure_cookie('oauth_state') or b'').decode('utf-8')
|
|
returned_state = self.get_argument('state')
|
|
|
|
if returned_state is None or returned_state != expected_state:
|
|
raise tornado.auth.AuthError(
|
|
'OAuth authenticator error: State tokens do not match')
|
|
|
|
access_token_response = await self.get_access_token(
|
|
redirect_uri=redirect_uri,
|
|
code=self.get_argument('code'),
|
|
)
|
|
await self._on_auth(access_token_response)
|
|
else:
|
|
state = str(uuid.uuid4())
|
|
self.set_secure_cookie("oauth_state", state)
|
|
self.authorize_redirect(
|
|
redirect_uri=redirect_uri,
|
|
client_id=self.settings[self._OAUTH_SETTINGS_KEY]['key'],
|
|
scope=['openid email'],
|
|
response_type='code',
|
|
extra_params={'state': state}
|
|
)
|
|
|
|
async def _on_auth(self, access_token_response):
|
|
if not access_token_response:
|
|
raise tornado.web.HTTPError(500, 'OAuth authentication failed')
|
|
access_token = access_token_response['access_token']
|
|
|
|
response = await self.get_auth_http_client().fetch(
|
|
self._OAUTH_USER_INFO_URL,
|
|
headers={'Authorization': 'Bearer ' + access_token,
|
|
'User-agent': 'Tornado auth'})
|
|
|
|
decoded_body = json.loads(response.body.decode('utf-8'))
|
|
email = (decoded_body.get('email') or '').strip()
|
|
email_verified = (
|
|
decoded_body.get('email_verified') and
|
|
authenticate(self.application.options.auth, email)
|
|
)
|
|
|
|
if not email_verified:
|
|
message = (
|
|
"Access denied. Please use another account or "
|
|
"ask your admin to add your email to flower --auth."
|
|
)
|
|
raise tornado.web.HTTPError(403, message)
|
|
|
|
self.set_secure_cookie("user", str(email))
|
|
self.clear_cookie('oauth_state')
|
|
|
|
next_ = self.get_argument('next', self.application.options.url_prefix or '/')
|
|
if self.application.options.url_prefix and next_[0] != '/':
|
|
next_ = '/' + next_
|
|
self.redirect(next_)
|