Skip to content

Commit 004b382

Browse files
authored
Merge pull request #128 from embray/github-username-prefix
Add an optional prefix to usernames of users logged in via GitHub
2 parents 694292e + e6063e1 commit 004b382

File tree

2 files changed

+144
-57
lines changed

2 files changed

+144
-57
lines changed

runtests.py

Lines changed: 93 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import json
1616
import os
1717
import random
18+
import re
1819
import shutil
1920
import signal
2021
import subprocess
@@ -41,6 +42,7 @@
4142

4243
ENV = 'test-trac-github'
4344
CONF = '%s/conf/trac.ini' % ENV
45+
HTDIGEST = '%s/passwd' % ENV
4446
URL = 'http://localhost:8765/%s' % ENV
4547
SECRET = 'test-secret'
4648
HEADERS = {'Content-Type': 'application/json', 'X-GitHub-Event': 'push'}
@@ -148,6 +150,8 @@ def createTracEnvironment(cls, **kwargs):
148150
conf.set('github', 'access_token', kwargs['access_token'])
149151
if 'webhook_secret' in kwargs:
150152
conf.set('github', 'webhook_secret', kwargs['webhook_secret'])
153+
if 'username_prefix' in kwargs:
154+
conf.set('github', 'username_prefix', kwargs['username_prefix'])
151155

152156
if SHOW_LOG:
153157
# The [logging] section already exists in the default trac.ini file.
@@ -177,6 +181,10 @@ def createTracEnvironment(cls, **kwargs):
177181
with open(CONF, 'wb') as fp:
178182
conf.write(fp)
179183

184+
with open(HTDIGEST, 'w') as fp:
185+
# user: user, pass: pass, realm: realm
186+
fp.write("user:realm:8493fbc53ba582fb4c044c456bdc40eb\n")
187+
180188
run_resync = kwargs['resync'] if 'resync' in kwargs else True
181189
if run_resync:
182190
# Allow skipping resync for perfomance reasons if not required
@@ -200,7 +208,7 @@ def startTracd(cls, **kwargs):
200208
if SHOW_LOG:
201209
kwargs['stdout'] = sys.stdout
202210
kwargs['stderr'] = sys.stderr
203-
cls.tracd = subprocess.Popen(tracd + ['--port', '8765', ENV], **kwargs)
211+
cls.tracd = subprocess.Popen(tracd + ['--port', '8765', '--auth=*,%s,realm' % HTDIGEST, ENV], **kwargs)
204212

205213
while True:
206214
try:
@@ -538,14 +546,42 @@ def testLoginWithUnconfiguredClientId(self):
538546
"An unconfigured GitHubLogin module should redirect and print "
539547
"a warning on login attempts.")
540548

549+
def attemptHttpAuth(self, testenv, **kwargs):
550+
"""
551+
Helper method that attempts to log in using HTTP authentication in the
552+
given testenv; returns a tuple where the first item is the error
553+
message in the notification box on the trac page loaded after the login
554+
attempt (or an empty string on success) and the second item is the
555+
username as seen by trac.
556+
"""
557+
with TracContext(self, env=testenv, resync=False, **kwargs):
558+
session = requests.Session()
559+
560+
# This logs into trac using HTTP authentication
561+
# This adds a oauth_state parameter in the Trac session.
562+
response = session.get(URL + '/login', auth=requests.auth.HTTPDigestAuth('user', 'pass'))
563+
self.assertNotEqual(response.status_code, 403)
564+
565+
response = session.get(URL + '/newticket') # this should trigger IPermissionGroupProvider
566+
self.assertEqual(response.status_code, 200)
567+
tree = html.fromstring(response.content)
568+
warning = ''.join(tree.xpath('//div[@id="warning"]/text()')).strip()
569+
user = ''
570+
match = re.match(r"logged in as (.*)",
571+
', '.join(tree.xpath('//div[@id="metanav"]/ul/li[@class="first"]/text()')))
572+
if match:
573+
user = match.group(1)
574+
return (warning, user)
575+
541576
def attemptValidOauth(self, testenv, callback, **kwargs):
542577
"""
543578
Helper method that runs a valid OAuth2 attempt in the given testenv
544579
with the given callback; returns a tuple where the first item is the
545580
error message in the notification box on the trac page loaded after the
546-
login attempt (or an empty string on success) and the second item is
581+
login attempt (or an empty string on success), the second item is
547582
a list of email addresses found in email fields of the preferences
548-
after login.
583+
after login and the third item is the username of the user that was
584+
logged in as seen by trac..
549585
"""
550586
ctxt_kwargs = {}
551587
other_kwargs = {}
@@ -579,8 +615,14 @@ def attemptValidOauth(self, testenv, callback, **kwargs):
579615
response = session.get(URL + '/prefs')
580616
self.assertEqual(response.status_code, 200)
581617
tree = html.fromstring(response.content)
582-
return (''.join(tree.xpath('//div[@id="warning"]/text()')).strip(),
583-
tree.xpath('//input[@id="email"]/@value'))
618+
warning = ''.join(tree.xpath('//div[@id="warning"]/text()')).strip()
619+
email = tree.xpath('//input[@id="email"]/@value')
620+
user = ''
621+
match = re.match(r"logged in as (.*)",
622+
', '.join(tree.xpath('//div[@id="metanav"]/ul/li[@class="first"]/text()')))
623+
if match:
624+
user = match.group(1)
625+
return (warning, email, user)
584626
finally:
585627
# disable callback again
586628
updateMockData(self.mockdata, postcallback="")
@@ -590,7 +632,7 @@ def testOauthBackendUnavailable(self):
590632
Test that an OAuth backend that resets the connection does not crash
591633
the login
592634
"""
593-
errmsg, emails = self.attemptValidOauth(self.trac_env_broken, "")
635+
errmsg, emails, _ = self.attemptValidOauth(self.trac_env_broken, "")
594636
self.assertIn(
595637
"Invalid request. Please try to login again.",
596638
errmsg,
@@ -600,7 +642,7 @@ def testOauthBackendFails(self):
600642
"""Test that an OAuth backend that fails does not crash the login"""
601643
def cb(path, args):
602644
return 403, {}
603-
errmsg, emails = self.attemptValidOauth(self.trac_env, cb)
645+
errmsg, emails, _ = self.attemptValidOauth(self.trac_env, cb)
604646
self.assertIn(
605647
"Invalid request. Please try to login again.",
606648
errmsg,
@@ -621,7 +663,7 @@ def testOauthValidButUnavailAPI(self):
621663
Test that accessing an unavailable GitHub API with what seems to be
622664
a valid OAuth2 token does not crash the login
623665
"""
624-
errmsg, emails = self.attemptValidOauth(self.trac_env_broken_api, self.oauthCallbackSuccess)
666+
errmsg, emails, _ = self.attemptValidOauth(self.trac_env_broken_api, self.oauthCallbackSuccess)
625667
self.assertIn(
626668
"An error occurred while communicating with the GitHub API",
627669
errmsg,
@@ -632,9 +674,9 @@ def testOauthValidButBrokenAPI(self):
632674
Test that accessing an broken GitHub API with what seems to be a valid
633675
OAuth2 token does not crash the login
634676
"""
635-
errmsg, emails = self.attemptValidOauth(self.trac_env_broken_api,
636-
self.oauthCallbackSuccess,
637-
retcode=403)
677+
errmsg, emails, _ = self.attemptValidOauth(self.trac_env_broken_api,
678+
self.oauthCallbackSuccess,
679+
retcode=403)
638680
self.assertIn(
639681
"An error occurred while communicating with the GitHub API",
640682
errmsg,
@@ -656,7 +698,7 @@ def testOauthValidEmailAPIInvalid(self):
656698
}
657699
}
658700

659-
errmsg, emails = self.attemptValidOauth(
701+
errmsg, emails, _ = self.attemptValidOauth(
660702
self.trac_env, self.oauthCallbackSuccess, retcode=200,
661703
answers=answers, request_email=True)
662704
self.assertIn(
@@ -666,14 +708,24 @@ def testOauthValidEmailAPIInvalid(self):
666708

667709
def getEmail(self, answers, **kwargs):
668710
"""Get and return the email address the system has chosen for the given config and answers"""
669-
errmsg, emails = self.attemptValidOauth(
711+
errmsg, emails, _ = self.attemptValidOauth(
670712
self.trac_env, self.oauthCallbackSuccess, retcode=200,
671713
answers=answers, **kwargs)
672714
self.assertEqual(len(errmsg), 0,
673715
"Successful login should not print an error.")
674716

675717
return emails
676718

719+
def getUser(self, answers, **kwargs):
720+
"""Get and return the user name the system has chosen for the given config and answers"""
721+
errmsg, _, user = self.attemptValidOauth(
722+
self.trac_env, self.oauthCallbackSuccess, retcode=200,
723+
answers=answers, **kwargs)
724+
self.assertEqual(len(errmsg), 0,
725+
"Successful login should not print an error.")
726+
727+
return user
728+
677729
def testOauthValid(self):
678730
"""Test that a login with a valid OAuth2 token succeeds"""
679731
answers = {
@@ -686,6 +738,31 @@ def testOauthValid(self):
686738

687739
email = self.getEmail(answers)
688740
self.assertEqual(email, ['[email protected]'])
741+
user = self.getUser(answers)
742+
self.assertEqual(user, 'trololol')
743+
744+
def testUsernamePrefix(self):
745+
"""Test that setting a prefix for GitHub usernames works"""
746+
answers = {
747+
'/user': {
748+
'user': 'trololol',
749+
'email': '[email protected]',
750+
'login': 'trololol'
751+
}
752+
}
753+
754+
user = self.getUser(answers, username_prefix='github-')
755+
self.assertEqual(user, 'github-trololol')
756+
757+
errmsg, user = self.attemptHttpAuth(self.trac_env,
758+
username_prefix='github-',
759+
organization='org',
760+
username='github-bot-user',
761+
access_token='accesstoken')
762+
self.assertEqual(len(errmsg), 0,
763+
"HTTP authentication should still work.")
764+
self.assertEqual(user, "user",
765+
"Non-GitHub-authentication should yield unprefixed usernames")
689766

690767
def testOauthEmailIgnoresUnverified(self):
691768
"""
@@ -1163,6 +1240,7 @@ class TracContext(object):
11631240
'username',
11641241
'access_token',
11651242
'webhook_secret',
1243+
'username_prefix',
11661244
'resync')
11671245
""" List of all valid attributes to be passed to createTracEnvironment() """
11681246

@@ -1195,6 +1273,8 @@ def __init__(self, testobj, env=None, **kwargs):
11951273
group syncing. Defaults to unset.
11961274
:param webhook_secret: Secret used to validate WebHook API calls if
11971275
present. Defaults to unset.
1276+
:param username_prefix: Prefix for GitHub usernames to allow
1277+
co-existance of non-GitHub with GitHub accounts.
11981278
:param resync: `False` to skip running `trac admin repository resync`
11991279
during environment setup for speed reasons. Defaults to
12001280
`True`.

tracext/github/__init__.py

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,51 @@ def _config_secret(value):
3939
return value
4040

4141

42-
class GitHubLoginModule(LoginModule):
42+
class GitHubMixin(object):
43+
username_prefix = Option('github', 'username_prefix', '',
44+
doc="A unique username prefix for all users "
45+
"authenticated via GitHub (as opposed to any "
46+
"other authentication method).")
47+
48+
webhook_secret = Option('github', 'webhook_secret', '',
49+
doc="""GitHub webhook secret token.
50+
Uppercase environment variable name, filename starting with '/' or './', or plain string.""")
51+
52+
def _verify_webhook_signature(self, signature, reqdata):
53+
if not self.webhook_secret:
54+
return True
55+
if not signature:
56+
return False
57+
58+
algorithm, _, expected = signature.partition("=")
59+
supported_algorithms = {
60+
"sha1": hashlib.sha1,
61+
"sha256": hashlib.sha256,
62+
"sha512": hashlib.sha512
63+
}
64+
if algorithm not in supported_algorithms:
65+
return False
66+
67+
webhook_secret = _config_secret(self.webhook_secret)
68+
69+
hmac_hash = hmac.new(
70+
webhook_secret.encode('utf-8'),
71+
reqdata,
72+
supported_algorithms[algorithm])
73+
computed = hmac_hash.hexdigest()
74+
75+
return hmac.compare_digest(expected, computed)
76+
77+
def get_gh_repo(self, reponame):
78+
key = 'repository' if is_default(reponame) else '%s.repository' % reponame
79+
return self.config.get('github', key)
80+
81+
def get_branches(self, reponame):
82+
key = 'branches' if is_default(reponame) else '%s.branches' % reponame
83+
return self.config.getlist('github', key, sep=' ')
84+
85+
86+
class GitHubLoginModule(GitHubMixin, LoginModule):
4387

4488
auth_path_prefix = Option(
4589
'github', 'auth_path_prefix', '/github',
@@ -174,7 +218,7 @@ def _do_oauth(self, req):
174218
reason=_("An error occurred while retrieving your email address "
175219
"from the GitHub API"))
176220
# Small hack to pass the username to _do_login.
177-
req.environ['REMOTE_USER'] = login
221+
req.environ['REMOTE_USER'] = self.username_prefix + login
178222
# Save other available values in the session.
179223
req.session.setdefault('name', name or '')
180224
req.session.setdefault('email', email or '')
@@ -209,47 +253,6 @@ def _oauth_session(self, req, state=None):
209253
)
210254

211255

212-
213-
class GitHubMixin(object):
214-
215-
webhook_secret = Option('github', 'webhook_secret', '',
216-
doc="""GitHub webhook secret token.
217-
Uppercase environment variable name, filename starting with '/' or './', or plain string.""")
218-
219-
def _verify_webhook_signature(self, signature, reqdata):
220-
if not self.webhook_secret:
221-
return True
222-
if not signature:
223-
return False
224-
225-
algorithm, _, expected = signature.partition("=")
226-
supported_algorithms = {
227-
"sha1": hashlib.sha1,
228-
"sha256": hashlib.sha256,
229-
"sha512": hashlib.sha512
230-
}
231-
if algorithm not in supported_algorithms:
232-
return False
233-
234-
webhook_secret = _config_secret(self.webhook_secret)
235-
236-
hmac_hash = hmac.new(
237-
webhook_secret.encode('utf-8'),
238-
reqdata,
239-
supported_algorithms[algorithm])
240-
computed = hmac_hash.hexdigest()
241-
242-
return hmac.compare_digest(expected, computed)
243-
244-
def get_gh_repo(self, reponame):
245-
key = 'repository' if is_default(reponame) else '%s.repository' % reponame
246-
return self.config.get('github', key)
247-
248-
def get_branches(self, reponame):
249-
key = 'branches' if is_default(reponame) else '%s.branches' % reponame
250-
return self.config.getlist('github', key, sep=' ')
251-
252-
253256
class GitHubBrowser(GitHubMixin, ChangesetModule):
254257

255258
repository = Option('github', 'repository', '',
@@ -742,12 +745,16 @@ def get_permission_groups(self, username):
742745
"""
743746
if not self.organization or not self.username or not self.access_token:
744747
return []
748+
elif (self.username_prefix and
749+
not username.startswith(self.username_prefix)):
750+
return []
751+
745752
data = self._fetch_groups()
746753
if not data:
747754
self.log.error("No cached groups from GitHub available") # pylint: disable=no-member
748755
return []
749756
else:
750-
return data.get(username, [])
757+
return data.get(username[len(self.username_prefix):], [])
751758

752759
# IRequestHandler methods
753760
_request_re = re.compile(r"/github-groups(/.*)?$")

0 commit comments

Comments
 (0)