Skip to content

Commit 3655f16

Browse files
authored
Implement command line authentication (#9)
* Add working version of OAuth2 with github * Add tests * Add readme and constants * Update guthub client and tests
1 parent 55d8bd6 commit 3655f16

25 files changed

+569
-207
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,4 @@ dmypy.json
129129
.pyre/
130130
.idea/
131131
run.py
132+
environment/local.env

README.md

+24-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,24 @@
1-
# pephubClient
1+
# `PEPHubClient`
2+
3+
`PEPHubClient` is a tool to provide Python and CLI interface for `pephub`.
4+
Authorization is based on `OAuth` with using GitHub.
5+
6+
The authorization process is slightly complex and needs more explanation.
7+
The explanation will be provided based on two commands:
8+
9+
10+
## 1. `pephubclient login`
11+
To authenticate itself user must execute `pephubclient login` command (1).
12+
Command triggers the process of authenticating with GitHub.
13+
`PEPHubClient` sends the request for user and device verification codes (2), and
14+
GitHub responds with the data (3). Next, if user is not logged in, GitHub
15+
asks for login (4), user logs in (5) and then GitHub asks to input
16+
verification code (6) that is shown to user in the CLI.
17+
After inputting the correct verification code (7), `PEPHubClient`
18+
sends the request to GitHub and asks about access token (8), which is then
19+
provided by GitHub based on data from authentication (9).
20+
![](static/pephubclient_login.png)
21+
22+
23+
## 2. `pephubclient pull project/name:tag`
24+
![](static/pephubclient_pull.png)

error_handling/constants.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from enum import Enum
2+
3+
4+
class ResponseStatusCodes(int, Enum):
5+
FORBIDDEN_403 = 403
6+
NOT_EXIST_404 = 404
7+
OK_200 = 200

error_handling/error_handler.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from error_handling.exceptions import AuthorizationPendingError
2+
from typing import Union
3+
import pydantic
4+
from error_handling.models import GithubErrorModel
5+
from contextlib import suppress
6+
7+
8+
class ErrorHandler:
9+
@staticmethod
10+
def parse_github_response_error(github_response) -> Union[Exception, None]:
11+
with suppress(pydantic.ValidationError):
12+
GithubErrorModel(**github_response)
13+
return AuthorizationPendingError(
14+
message="You must first authorize with GitHub by using "
15+
"provided code."
16+
)

error_handling/exceptions.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
class BasePephubclientException(Exception):
2+
def __init__(self, message: str):
3+
super().__init__(message)
4+
5+
6+
class IncorrectQueryStringError(BasePephubclientException):
7+
def __init__(self, query_string: str = None):
8+
self.query_string = query_string
9+
super().__init__(
10+
f"PEP data with passed namespace and project ({self.query_string}) name not found."
11+
)
12+
13+
14+
class ResponseError(BasePephubclientException):
15+
default_message = "The response looks incorrect and must be verified manually."
16+
17+
def __init__(self, message: str = None):
18+
self.message = message
19+
super().__init__(self.message or self.default_message)
20+
21+
22+
class AuthorizationPendingError(BasePephubclientException):
23+
...

error_handling/models.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from pydantic import BaseModel
2+
3+
4+
class GithubErrorModel(BaseModel):
5+
error: str
6+
error_description: str

github_oauth_client/__init__.py

Whitespace-only changes.

github_oauth_client/constants.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
GITHUB_BASE_API_URL = "https://api.github.com"
2+
GITHUB_BASE_LOGIN_URL = "https://github.com/login"
3+
GITHUB_VERIFICATION_CODES_ENDPOINT = "/device/code"
4+
GITHUB_OAUTH_ENDPOINT = "/oauth/access_token"
5+
6+
HEADERS = {"Content-Type": "application/json", "Accept": "application/json"}
7+
ENCODING = "utf-8"
8+
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import json
2+
from typing import Type
3+
from error_handling.error_handler import ErrorHandler
4+
import requests
5+
from github_oauth_client.constants import (
6+
GITHUB_BASE_LOGIN_URL,
7+
GITHUB_OAUTH_ENDPOINT,
8+
GITHUB_VERIFICATION_CODES_ENDPOINT,
9+
GRANT_TYPE,
10+
HEADERS,
11+
)
12+
from github_oauth_client.models import (
13+
AccessTokenResponseModel,
14+
VerificationCodesResponseModel,
15+
)
16+
from pephubclient.models import ClientData
17+
from pydantic import BaseModel, ValidationError
18+
from error_handling.exceptions import ResponseError
19+
from helpers import RequestManager
20+
21+
22+
class GitHubOAuthClient(RequestManager):
23+
"""
24+
Class responsible for authorization with GitHub.
25+
"""
26+
27+
def get_access_token(self, client_data: ClientData):
28+
"""
29+
Requests user with specified ClientData.client_id to enter the verification code, and then
30+
responds with GitHub access token.
31+
"""
32+
device_code = self._get_device_verification_code(client_data)
33+
return self._request_github_for_access_token(device_code, client_data)
34+
35+
def _get_device_verification_code(self, client_data: ClientData) -> str:
36+
"""
37+
Send the request for verification codes, parse the response and return device code.
38+
39+
Returns:
40+
Device code which is needed later to obtain the access code.
41+
"""
42+
resp = GitHubOAuthClient.send_request(
43+
method="POST",
44+
url=f"{GITHUB_BASE_LOGIN_URL}{GITHUB_VERIFICATION_CODES_ENDPOINT}",
45+
params={"client_id": client_data.client_id},
46+
headers=HEADERS,
47+
)
48+
verification_codes_response = self._handle_github_response(
49+
resp, VerificationCodesResponseModel
50+
)
51+
print(
52+
f"User verification code: {verification_codes_response.user_code}, "
53+
f"please enter the code here: {verification_codes_response.verification_uri} and"
54+
f"hit enter when you are done with authentication on the website"
55+
)
56+
input()
57+
58+
return verification_codes_response.device_code
59+
60+
def _request_github_for_access_token(
61+
self, device_code: str, client_data: ClientData
62+
) -> str:
63+
"""
64+
Send the request for access token, parse the response and return access token.
65+
66+
Args:
67+
device_code: Device code from verification codes request.
68+
69+
Returns:
70+
Access token.
71+
"""
72+
response = GitHubOAuthClient.send_request(
73+
method="POST",
74+
url=f"{GITHUB_BASE_LOGIN_URL}{GITHUB_OAUTH_ENDPOINT}",
75+
params={
76+
"client_id": client_data.client_id,
77+
"device_code": device_code,
78+
"grant_type": GRANT_TYPE,
79+
},
80+
headers=HEADERS,
81+
)
82+
return self._handle_github_response(
83+
response, AccessTokenResponseModel
84+
).access_token
85+
86+
@staticmethod
87+
def _handle_github_response(response: requests.Response, model: Type[BaseModel]):
88+
"""
89+
Decode the response from GitHub and pack the returned data into appropriate model.
90+
91+
Args:
92+
response: Response from GitHub.
93+
model: Model that the data will be packed to.
94+
95+
Returns:
96+
Response data as an instance of correct model.
97+
"""
98+
try:
99+
content = json.loads(GitHubOAuthClient.decode_response(response))
100+
except json.JSONDecodeError:
101+
raise ResponseError("Something went wrong with GitHub response")
102+
103+
try:
104+
return model(**content)
105+
except ValidationError:
106+
raise ErrorHandler.parse_github_response_error(content) or ResponseError()

github_oauth_client/models.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from typing import Optional
2+
from pydantic import BaseModel
3+
4+
5+
class VerificationCodesResponseModel(BaseModel):
6+
device_code: str
7+
user_code: str
8+
verification_uri: str
9+
expires_in: Optional[int]
10+
interval: Optional[int]
11+
12+
13+
class AccessTokenResponseModel(BaseModel):
14+
access_token: str
15+
scope: Optional[str]
16+
token_type: Optional[str]

helpers.py

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import json
2+
from typing import Optional
3+
4+
import requests
5+
6+
from error_handling.exceptions import ResponseError
7+
from github_oauth_client.constants import ENCODING
8+
9+
10+
class RequestManager:
11+
@staticmethod
12+
def send_request(
13+
method: str,
14+
url: str,
15+
headers: Optional[dict] = None,
16+
cookies: Optional[dict] = None,
17+
params: Optional[dict] = None,
18+
) -> requests.Response:
19+
return requests.request(
20+
method=method,
21+
url=url,
22+
verify=False,
23+
cookies=cookies,
24+
headers=headers,
25+
params=params,
26+
)
27+
28+
@staticmethod
29+
def decode_response(response: requests.Response) -> str:
30+
"""
31+
Decode the response from GitHub and pack the returned data into appropriate model.
32+
33+
Args:
34+
response: Response from GitHub.
35+
model: Model that the data will be packed to.
36+
37+
Returns:
38+
Response data as an instance of correct model.
39+
"""
40+
try:
41+
return response.content.decode(ENCODING)
42+
except json.JSONDecodeError:
43+
raise ResponseError()

pephubclient/__init__.py

+1-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
from .pephubclient import PEPHubClient
2-
3-
41
__app_name__ = "pephubclient"
52
__version__ = "0.1.0"
63

74

8-
__all__ = ["PEPHubClient", "__app_name__", "__version__"]
5+
__all__ = ["__app_name__", "__version__"]

pephubclient/__main__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pephubclient.cli import app, __app_name__
1+
from pephubclient.cli import __app_name__, app
22

33

44
def main():

pephubclient/cli.py

+17-5
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
11
import typer
2-
2+
from github_oauth_client.github_oauth_client import GitHubOAuthClient
33
from pephubclient import __app_name__, __version__
44
from pephubclient.pephubclient import PEPHubClient
5+
from pephubclient.models import ClientData
6+
7+
8+
GITHUB_CLIENT_ID = "20a452cc59b908235e50"
9+
510

611
pep_hub_client = PEPHubClient()
12+
github_client = GitHubOAuthClient()
713
app = typer.Typer()
14+
client_data = ClientData(client_id=GITHUB_CLIENT_ID)
815

916

1017
@app.command()
11-
def pull(project_query_string: str):
12-
pep_hub_client.save_pep_locally(project_query_string)
18+
def login():
19+
pep_hub_client.login(client_data)
1320

1421

1522
@app.command()
16-
def login():
17-
print("Logging in...")
23+
def logout():
24+
pep_hub_client.logout()
25+
26+
27+
@app.command()
28+
def pull(project_query_string: str):
29+
pep_hub_client.pull(project_query_string)
1830

1931

2032
@app.command()

pephubclient/constants.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
from typing import Optional
2+
23
from pydantic import BaseModel
34

4-
PEPHUB_BASE_URL = "https://pephub.databio.org/pep/"
5-
DEFAULT_FILENAME = "pep_project.csv"
5+
# PEPHUB_BASE_URL = "https://pephub.databio.org/"
6+
# PEPHUB_PEP_API_BASE_URL = "https://pephub.databio.org/pep/"
7+
# PEPHUB_LOGIN_URL = "https://pephub.databio.org/auth/login"
8+
PEPHUB_BASE_URL = "http://0.0.0.0:8000/"
9+
PEPHUB_PEP_API_BASE_URL = "http://0.0.0.0:8000/pep/"
10+
PEPHUB_LOGIN_URL = "http://127.0.0.1:8000/auth/login"
611

712

813
class RegistryPath(BaseModel):

pephubclient/exceptions.py

-6
This file was deleted.

0 commit comments

Comments
 (0)