hkayabilisim commited on
Commit
5f5723e
·
1 Parent(s): e69d497

feature: github/google authentication via OAuth2

Browse files

It is now possible to login (/account) via GitHub
or Google account. The user profile is not saved to
a database.

.env.global ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # W A R N I N G ! ! !
2
+ # Please leave sensitive variables empty.
3
+ # You can override the empty variables in .env.local
4
+ # They are empty for two reasons: (1) secrecy or (2) need to
5
+ # be defined to reflect the local environment
6
+ root_url=""
7
+
8
+ github_authorization_base_url="https://github.com/login/oauth/authorize"
9
+ github_token_url="https://github.com/login/oauth/access_token"
10
+ github_user_api="https://api.github.com/user"
11
+ github_scope="read:user"
12
+ github_redirect_uri=""
13
+ github_client_id=""
14
+ github_client_secret=""
15
+
16
+ google_authorization_base_url="https://accounts.google.com/o/oauth2/auth"
17
+ google_token_url="https://oauth2.googleapis.com/token"
18
+ google_user_api="https://www.googleapis.com/oauth2/v1/userinfo"
19
+ google_scope="https://www.googleapis.com/auth/userinfo.profile"
20
+ google_redirect_uri=""
21
+ google_client_id=""
22
+ google_client_secret=""
23
+
24
+ # S3 Storage
25
+ aws_access_key_id=
26
+ aws_secret_access_key=
27
+ region_name=
28
+ bucket_name=
.gitignore CHANGED
@@ -158,3 +158,4 @@ cython_debug/
158
  # and can be added to the global gitignore or merged into this file. For a more nuclear
159
  # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
  #.idea/
 
 
158
  # and can be added to the global gitignore or merged into this file. For a more nuclear
159
  # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
  #.idea/
161
+ .env.local
pyproject.toml CHANGED
@@ -23,6 +23,8 @@ dependencies = [
23
  "boto3",
24
  "cryptography",
25
  "ipydatagrid",
 
 
26
  ]
27
 
28
  [tool.hatch.version]
 
23
  "boto3",
24
  "cryptography",
25
  "ipydatagrid",
26
+ "requests_oauthlib",
27
+ "python-dotenv",
28
  ]
29
 
30
  [tool.hatch.version]
tomorrowcities/pages/__init__.py CHANGED
@@ -7,10 +7,42 @@ import os
7
  import pickle
8
  import pprint
9
  from cryptography.fernet import Fernet
10
-
 
 
 
11
  from ..data import articles
12
 
13
- route_order = ["/", "engine","explore","settings"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  def check_auth(route, children):
16
  # This can be replaced by a custom function that checks if the user is
@@ -36,10 +68,11 @@ def check_auth(route, children):
36
  @dataclasses.dataclass
37
  class User:
38
  username: str
 
39
  admin: bool = False
40
 
41
 
42
- user = solara.reactive(cast(Optional[User], None))
43
  login_failed = solara.reactive(False)
44
 
45
 
@@ -48,24 +81,42 @@ def login_control(username: str, password: str):
48
  if username == "test" and password == "test":
49
  user.value = User(username, admin=False)
50
  login_failed.value = False
 
51
  elif username == "admin" and password == "admin":
52
  user.value = User(username, admin=True)
53
  login_failed.value = False
 
54
  else:
55
  login_failed.value = True
56
 
57
 
58
  @solara.component
59
  def LoginForm():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  username = solara.use_reactive("")
61
  password = solara.use_reactive("")
62
  with solara.Card("Login"):
63
- solara.Markdown(
64
- """
65
- This is an example login form.
66
- * use test/test to login as a normal user.
67
- """
68
- )
 
69
  solara.InputText(label="Username", value=username)
70
  solara.InputText(label="Password", password=True, value=password)
71
  solara.Button(label="Login", on_click=lambda: login_control(username.value, password.value))
 
7
  import pickle
8
  import pprint
9
  from cryptography.fernet import Fernet
10
+ from dotenv import dotenv_values
11
+ import secrets
12
+ from requests_oauthlib import OAuth2Session
13
+ from typing import Dict
14
  from ..data import articles
15
 
16
+ config = {
17
+ **dotenv_values(".env.global"), # global
18
+ **dotenv_values(".env.local"), # local sensitive variables
19
+ **os.environ, # override loaded values with environment variables
20
+ }
21
+
22
+ session_storage: Dict[str, Dict[str,str]] = {}
23
+
24
+ github_client = OAuth2Session(config['github_client_id'],
25
+ scope=[config['github_scope']],
26
+ redirect_uri=config['github_redirect_uri'])
27
+ google_client = OAuth2Session(config['google_client_id'],
28
+ scope=[config['google_scope']],
29
+ redirect_uri=config['google_redirect_uri'])
30
+
31
+ route_order = ["/", "engine","explore","settings","account"]
32
+
33
+ def store_in_session_storage(key, value):
34
+ sesssion_id = solara.get_session_id()
35
+ if sesssion_id in session_storage.keys():
36
+ session_storage[sesssion_id][key] = value
37
+ else:
38
+ session_storage[sesssion_id] = {key: value}
39
+
40
+ def read_from_session_storage(key):
41
+ sesssion_id = solara.get_session_id()
42
+ if sesssion_id in session_storage.keys():
43
+ if key in session_storage[sesssion_id].keys():
44
+ return session_storage[sesssion_id][key]
45
+ return None
46
 
47
  def check_auth(route, children):
48
  # This can be replaced by a custom function that checks if the user is
 
68
  @dataclasses.dataclass
69
  class User:
70
  username: str
71
+ user_profile: Dict = None
72
  admin: bool = False
73
 
74
 
75
+ user = solara.reactive(cast(Optional[User], read_from_session_storage('user')))
76
  login_failed = solara.reactive(False)
77
 
78
 
 
81
  if username == "test" and password == "test":
82
  user.value = User(username, admin=False)
83
  login_failed.value = False
84
+ store_in_session_storage('user', user.value)
85
  elif username == "admin" and password == "admin":
86
  user.value = User(username, admin=True)
87
  login_failed.value = False
88
+ store_in_session_storage('user', user.value)
89
  else:
90
  login_failed.value = True
91
 
92
 
93
  @solara.component
94
  def LoginForm():
95
+ github_authorization_url, github_state = github_client.authorization_url(
96
+ config['github_authorization_base_url'],
97
+ access_type="offline",
98
+ prompt="select_account"
99
+ )
100
+
101
+ google_authorization_url, google_state = google_client.authorization_url(
102
+ config['google_authorization_base_url'],
103
+ access_type="offline",
104
+ prompt="select_account"
105
+ )
106
+
107
+ store_in_session_storage('github_state', github_state)
108
+ store_in_session_storage('google_state', google_state)
109
+
110
  username = solara.use_reactive("")
111
  password = solara.use_reactive("")
112
  with solara.Card("Login"):
113
+ with solara.Row():
114
+ solara.Button(label="Login via Google", icon_name="mdi-google",
115
+ attributes={"href": google_authorization_url}, text=True, outlined=True,
116
+ on_click=lambda: store_in_session_storage('auth_company','google'))
117
+ solara.Button(label="Login via GitHub", icon_name="mdi-github-circle",
118
+ attributes={"href": github_authorization_url}, text=True, outlined=True,
119
+ on_click=lambda: store_in_session_storage('auth_company','github'))
120
  solara.InputText(label="Username", value=username)
121
  solara.InputText(label="Password", password=True, value=password)
122
  solara.Button(label="Login", on_click=lambda: login_control(username.value, password.value))
tomorrowcities/pages/account.py CHANGED
@@ -2,13 +2,83 @@ from typing import Optional
2
  import solara
3
  import os
4
  import pprint
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- from . import user
7
- from . import LoginForm
8
 
9
  @solara.component
10
  def Page():
11
  solara.Title(" ")
 
12
  if user.value is None:
13
- LoginForm()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
 
2
  import solara
3
  import os
4
  import pprint
5
+ from urllib.parse import parse_qs
6
+ import json
7
+
8
+ from . import user, User, login_failed, config, LoginForm, \
9
+ github_client, google_client, \
10
+ session_storage, read_from_session_storage, store_in_session_storage
11
+
12
+
13
+ # used only to force updating of the page
14
+ force_update_counter = solara.reactive(0)
15
+
16
+ def is_callback(router):
17
+ if router.search is not None:
18
+ params = parse_qs(router.search)
19
+ if set({'code','state'}).issubset(set(params.keys())):
20
+ return True
21
+ return False
22
 
 
 
23
 
24
  @solara.component
25
  def Page():
26
  solara.Title(" ")
27
+
28
  if user.value is None:
29
+ LoginForm()
30
+ else:
31
+ solara.Text(f'Hello {user.value.username}')
32
+ if user.value.user_profile:
33
+ for key, value in user.value.user_profile.items():
34
+ if key in ['avatar_url', 'picture']:
35
+ solara.Image(value)
36
+ else:
37
+ solara.Text(f'{key}: {value}')
38
+
39
+ router = solara.use_router()
40
+ if is_callback(router):
41
+ print('entering callback')
42
+ # callback is made
43
+ if router.search is not None:
44
+ params = parse_qs(router.search)
45
+ print('callback is made',params)
46
+ code = params['code'][0]
47
+ state = params['state'][0]
48
+
49
+ auth_company = read_from_session_storage('auth_company')
50
+ state_in_session = read_from_session_storage(f'{auth_company}_state')
51
+
52
+ # Silently ignore state mismatch
53
+ # TODO: display a warning message
54
+ if state_in_session != state:
55
+ return
56
+ redirect_response = config['root_url'] + '/' +router.path+'?'+router.search
57
+ if auth_company == 'github':
58
+ client = github_client
59
+ elif auth_company == 'google':
60
+ client = google_client
61
+ else:
62
+ client = None
63
+
64
+ client.fetch_token(config[f'{auth_company}_token_url'],
65
+ client_secret=config[f'{auth_company}_client_secret'],
66
+ authorization_response=redirect_response)
67
+
68
+ r = client.get(config[f'{auth_company}_user_api'])
69
+ user_dict = json.loads(r.content.decode('utf-8'))
70
+ print(user_dict)
71
+ username = user_dict['name']
72
+ user.value = User(username, admin=False, user_profile=user_dict)
73
+
74
+ login_failed.value = False
75
+
76
+ store_in_session_storage('user', user.value)
77
+
78
+ # used only to force updating of the page
79
+ force_update_counter.value += 1
80
+ router.push('/account')
81
+
82
+
83
+
84