Skip to content

Commit 495f65c

Browse files
authored
Merge pull request #6 from sandwichcloud/authstuff
Redo auth system, add service accounts, add builtin auth, other stuffs
2 parents 6626944 + a1448ed commit 495f65c

39 files changed

+881
-441
lines changed

.env-sample

+11-3
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,22 @@ RABBITMQ_PASSWORD=hunter2
2020
# Auth #
2121
####################
2222

23-
# Choose an auth driver
23+
# Choose auth drivers to use (comma separated list)
24+
# The first driver is shown as the default in /v1/auth/discover
2425
# Github: deli_counter.auth.drivers.github.driver:GithubAuthDriver
2526
# Gitlab: deli_counter.auth.drivers.gitlab.driver:GitlabAuthDriver
2627
# OpenID: deli_counter.auth.drivers.openid.driver:OpenIDAuthDriver
27-
# LDAP: deli_counter.auth_drivers.ldap.driver:LDAPAuthDriver
28-
# DB: deli_counter.auth_drivers.db.driver:DBAuthDriver (always enabled)
28+
# LDAP: deli_counter.auth.drivers.ldap.driver:LDAPAuthDriver
29+
# DB: deli_counter.auth.drivers.builtin.driver:BuiltInAuthDriver
2930
AUTH_DRIVERS=deli_counter.auth.drivers.github.driver:GithubAuthDriver
3031

32+
# A url safe 32 bit base64 encoded string used to encrypt tokens
33+
# Multiple keys can be listed to allow rotation (comma separated). The first
34+
# key in the list is the primary key.
35+
# To rotate keys simply generate a new key and put it in the front of the list
36+
# then after a while remove the old key from the list
37+
AUTH_FERNET_KEYS=
38+
3139
####################
3240
# GITHUB AUTH #
3341
####################

Dockerfile

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ WORKDIR /usr/src/app
1010
# Install build time dependencies for uwsgi
1111
# Install uwsgi and dumb-init
1212
RUN apk --no-cache add --virtual build-deps \
13-
build-base bash linux-headers pcre-dev postgresql-dev && \
13+
build-base bash linux-headers pcre-dev postgresql-dev libffi-dev && \
1414
pip install uwsgi dumb-init
1515

1616
# COPY tar.gz from build container
@@ -24,7 +24,7 @@ COPY wsgi.ini wsgi.ini
2424
# Remove build time dependencies
2525
# Install runtime dependencies
2626
RUN apk del build-deps && \
27-
apk --no-cache add openssl pcre libpq ca-certificates
27+
apk --no-cache add openssl pcre libpq libffi ca-certificates
2828

2929
# add entrypoint
3030
COPY docker-entrypoint.sh /bin/docker-entrypoint.sh

deli_counter/auth/driver.py

+34-18
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import json
12
import logging
2-
import secrets
33
from abc import ABCMeta, abstractmethod
44
from typing import Dict
55

6-
from ingredients_db.models.authn import AuthNUser, AuthNToken, AuthNTokenRole
6+
from cryptography.fernet import Fernet
7+
from simple_settings import settings
8+
9+
from ingredients_db.models.authn import AuthNUser
710
from ingredients_db.models.authz import AuthZRole
811
from ingredients_http.router import Router
912

@@ -23,7 +26,8 @@ def discover_options(self) -> Dict:
2326
def auth_router(self) -> Router:
2427
raise NotImplementedError
2528

26-
def generate_user_token(self, session, username, roles):
29+
def generate_user_token(self, session, expires_at, username, global_role_names, project_id=None,
30+
project_role_ids=None):
2731
user = session.query(AuthNUser).filter(AuthNUser.username == username).filter(
2832
AuthNUser.driver == self.name).first()
2933
if user is None:
@@ -32,19 +36,31 @@ def generate_user_token(self, session, username, roles):
3236
user.driver = self.name
3337
session.add(user)
3438
session.flush()
39+
session.refresh(user)
40+
41+
global_role_ids = []
42+
43+
for role_name in global_role_names:
44+
role = session.query(AuthZRole).filter(AuthZRole.name == role_name).filter(
45+
AuthZRole.project_id == None).first() # noqa: E711
46+
if role is not None:
47+
global_role_ids.append(role.id)
48+
49+
fernet = Fernet(settings.AUTH_FERNET_KEYS[0])
50+
51+
token_data = {
52+
'expires_at': expires_at,
53+
'user_id': user.id,
54+
'roles': {
55+
'global': global_role_ids,
56+
'project': []
57+
}
58+
}
59+
60+
if project_id is not None:
61+
token_data['project_id'] = project_id
62+
if project_role_ids is None:
63+
project_role_ids = []
64+
token_data['roles']['project'] = project_role_ids
3565

36-
token = AuthNToken()
37-
token.user_id = user.id
38-
token.access_token = secrets.token_urlsafe()
39-
session.add(token)
40-
session.flush()
41-
42-
for role in roles:
43-
db_role = session.query(AuthZRole).filter(AuthZRole.name == role).first()
44-
if db_role is not None:
45-
token_role = AuthNTokenRole()
46-
token_role.token_id = token.id
47-
token_role.role_id = db_role.id
48-
session.add(token_role)
49-
50-
return token
66+
return fernet.encrypt(json.dumps(token_data).encode())

deli_counter/auth/drivers/builtin/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from typing import Dict
2+
3+
from deli_counter.auth.driver import AuthDriver
4+
from deli_counter.auth.drivers.builtin.router import DatabaseAuthRouter
5+
from ingredients_http.router import Router
6+
7+
8+
class BuiltInAuthDriver(AuthDriver):
9+
def __init__(self):
10+
super().__init__('builtin')
11+
12+
def discover_options(self) -> Dict:
13+
return {}
14+
15+
def auth_router(self) -> Router:
16+
return DatabaseAuthRouter(self)
+163
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import uuid
2+
3+
import arrow
4+
import cherrypy
5+
6+
from deli_counter.auth.validation_models.builtin import RequestBuiltInLogin, RequestBuiltInCreateUser, \
7+
ResponseBuiltInUser, RequestBuiltInChangePassword, RequestBuiltInUserRole, ParamsBuiltInUser, ParamsListBuiltInUser
8+
from deli_counter.http.mounts.root.routes.v1.auth.validation_models.tokens import ResponseOAuthToken
9+
from ingredients_db.models.builtin import BuiltInUser
10+
from ingredients_http.request_methods import RequestMethods
11+
from ingredients_http.route import Route
12+
from ingredients_http.router import Router
13+
14+
15+
class DatabaseAuthRouter(Router):
16+
def __init__(self, driver):
17+
super().__init__(uri_base='builtin')
18+
self.driver = driver
19+
20+
@Route(route='login', methods=[RequestMethods.POST])
21+
@cherrypy.config(**{'tools.authentication.on': False})
22+
@cherrypy.tools.model_in(cls=RequestBuiltInLogin)
23+
@cherrypy.tools.model_out(cls=ResponseOAuthToken)
24+
def login(self):
25+
request: RequestBuiltInLogin = cherrypy.request.model
26+
with cherrypy.request.db_session() as session:
27+
user: BuiltInUser = session.query(BuiltInUser).filter(BuiltInUser.username == request.username).first()
28+
if user is None or user.password != request.password:
29+
raise cherrypy.HTTPError(403, "Invalid username or password")
30+
31+
expiry = arrow.now().shift(days=+1)
32+
token = self.driver.generate_user_token(session, expiry, user.username, user.roles)
33+
session.commit()
34+
35+
response = ResponseOAuthToken()
36+
response.access_token = token
37+
response.expiry = expiry
38+
return response
39+
40+
@Route(route='users', methods=[RequestMethods.POST])
41+
@cherrypy.tools.enforce_policy(policy_name="builtin:users:create")
42+
@cherrypy.tools.model_in(cls=RequestBuiltInCreateUser)
43+
@cherrypy.tools.model_out(cls=ResponseBuiltInUser)
44+
def create_user(self):
45+
request: RequestBuiltInCreateUser = cherrypy.request.model
46+
with cherrypy.request.db_session() as session:
47+
user = BuiltInUser()
48+
user.username = request.username
49+
user.password = request.password
50+
51+
session.add(user)
52+
session.commit(user)
53+
session.refresh(user)
54+
55+
return ResponseBuiltInUser.from_database(user)
56+
57+
@Route(route='users/{user_id}')
58+
@cherrypy.tools.model_params(cls=ParamsBuiltInUser)
59+
@cherrypy.tools.enforce_policy(policy_name="builtin:users:get")
60+
@cherrypy.tools.model_out(cls=ResponseBuiltInUser)
61+
@cherrypy.tools.resource_object(id_param="user_id", cls=BuiltInUser)
62+
def get_user(self, user_id):
63+
return ResponseBuiltInUser.from_database(cherrypy.request.resource_object)
64+
65+
@Route(route='users')
66+
@cherrypy.tools.model_params(cls=ParamsListBuiltInUser)
67+
@cherrypy.tools.enforce_policy(policy_name="builtin:users:list")
68+
@cherrypy.tools.model_out_pagination(cls=ResponseBuiltInUser)
69+
def list_users(self, limit: int, marker: uuid.UUID):
70+
return self.paginate(BuiltInUser, ResponseBuiltInUser, limit, marker)
71+
72+
@Route(route='users/{user_id}', methods=[RequestMethods.DELETE])
73+
@cherrypy.tools.model_params(cls=ParamsBuiltInUser)
74+
@cherrypy.tools.enforce_policy(policy_name="builtin:users:delete")
75+
@cherrypy.tools.resource_object(id_param="user_id", cls=BuiltInUser)
76+
def delete_user(self, user_id):
77+
cherrypy.response.status = 204
78+
# Fix for https://github.com/cherrypy/cherrypy/issues/1657
79+
del cherrypy.response.headers['Content-Type']
80+
with cherrypy.request.db_session() as session:
81+
user: BuiltInUser = session.merge(cherrypy.request.resource_object, load=False)
82+
83+
if user.username == "admin":
84+
raise cherrypy.HTTPError(400, "Cannot delete admin user.")
85+
86+
session.delete(user)
87+
session.commit()
88+
89+
@Route(route='users', methods=[RequestMethods.PATCH])
90+
@cherrypy.tools.model_in(cls=RequestBuiltInChangePassword)
91+
def change_password_self(self):
92+
request: RequestBuiltInChangePassword = cherrypy.request.model
93+
with cherrypy.request.db_session() as session:
94+
if cherrypy.request.user.driver != self.driver.name:
95+
raise cherrypy.HTTPError(400, "Token is not using 'builtin' authentication.")
96+
97+
user: BuiltInUser = session.query(BuiltInUser).filter(
98+
BuiltInUser.username == cherrypy.request.user.username).first()
99+
user.password = request.password
100+
session.commit()
101+
102+
cherrypy.response.status = 204
103+
# Fix for https://github.com/cherrypy/cherrypy/issues/1657
104+
del cherrypy.response.headers['Content-Type']
105+
106+
@Route(route='users/{user_id}', methods=[RequestMethods.PATCH])
107+
@cherrypy.tools.model_params(cls=ParamsBuiltInUser)
108+
@cherrypy.tools.enforce_policy(policy_name="builtin:users:password")
109+
@cherrypy.tools.model_in(cls=RequestBuiltInChangePassword)
110+
@cherrypy.tools.resource_object(id_param="user_id", cls=BuiltInUser)
111+
def change_password_other(self, user_id):
112+
cherrypy.response.status = 204
113+
# Fix for https://github.com/cherrypy/cherrypy/issues/1657
114+
del cherrypy.response.headers['Content-Type']
115+
request: RequestBuiltInChangePassword = cherrypy.request.model
116+
with cherrypy.request.db_session() as session:
117+
user: BuiltInUser = session.merge(cherrypy.request.resource_object, load=False)
118+
119+
if user.username == "admin":
120+
raise cherrypy.HTTPError(400, "Only the admin user can change it's password.")
121+
122+
user.password = request.password
123+
session.commit()
124+
125+
@Route(route='users/{user_id}/role/add', methods=[RequestMethods.PUT])
126+
@cherrypy.tools.model_params(cls=ParamsBuiltInUser)
127+
@cherrypy.tools.enforce_policy(policy_name="builtin:users:role:add")
128+
@cherrypy.tools.model_in(cls=RequestBuiltInUserRole)
129+
@cherrypy.tools.resource_object(id_param="user_id", cls=BuiltInUser)
130+
def add_user_role(self, user_id):
131+
cherrypy.response.status = 204
132+
# Fix for https://github.com/cherrypy/cherrypy/issues/1657
133+
del cherrypy.response.headers['Content-Type']
134+
request: RequestBuiltInUserRole = cherrypy.request.model
135+
with cherrypy.request.db_session() as session:
136+
user: BuiltInUser = session.merge(cherrypy.request.resource_object, load=False)
137+
138+
if user.username == "admin":
139+
raise cherrypy.HTTPError(400, "Cannot change roles for the admin user.")
140+
141+
user.roles.append(request.role)
142+
session.commit()
143+
144+
@Route(route='users/{user_id}/role/remove', methods=[RequestMethods.PUT])
145+
@cherrypy.tools.model_params(cls=ParamsBuiltInUser)
146+
@cherrypy.tools.enforce_policy(policy_name="builtin:users:role:remove")
147+
@cherrypy.tools.model_in(cls=RequestBuiltInUserRole)
148+
@cherrypy.tools.resource_object(id_param="user_id", cls=BuiltInUser)
149+
def remove_user_role(self, user_id):
150+
cherrypy.response.status = 204
151+
# Fix for https://github.com/cherrypy/cherrypy/issues/1657
152+
del cherrypy.response.headers['Content-Type']
153+
request: RequestBuiltInUserRole = cherrypy.request.model
154+
with cherrypy.request.db_session() as session:
155+
user: BuiltInUser = session.merge(cherrypy.request.resource_object, load=False)
156+
157+
if user.username == "admin":
158+
raise cherrypy.HTTPError(400, "Cannot change roles for the admin user.")
159+
160+
if request.role not in user.roles:
161+
raise cherrypy.HTTPError(400, "User does not have the requested role.")
162+
user.roles.remove(request.role)
163+
session.commit()

deli_counter/auth/drivers/github/router.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import arrow
12
import cherrypy
23
import github
34
import github.AuthenticatedUser
@@ -8,7 +9,7 @@
89
from sqlalchemy_utils.types.json import json
910

1011
from deli_counter.auth.validation_models.github import RequestGithubAuthorization, RequestGithubToken
11-
from deli_counter.http.mounts.root.routes.v1.auth.z.validation_models.auth import ResponseOAuthToken
12+
from deli_counter.http.mounts.root.routes.v1.auth.validation_models.tokens import ResponseOAuthToken
1213
from ingredients_http.request_methods import RequestMethods
1314
from ingredients_http.route import Route
1415
from ingredients_http.router import Router
@@ -25,11 +26,15 @@ def generate_token(self, token_github_client):
2526
raise cherrypy.HTTPError(403, "User not a member of GitHub organization: '" + settings.GITHUB_ORG + "'")
2627

2728
with cherrypy.request.db_session() as session:
28-
token = self.driver.generate_user_token(session, github_user.login, self.driver.find_roles(github_user))
29+
expiry = arrow.now().shift(days=+1)
30+
token = self.driver.generate_user_token(session, expiry, github_user.login,
31+
self.driver.find_roles(github_user))
2932
session.commit()
30-
session.refresh(token)
3133

32-
return ResponseOAuthToken.from_database(token)
34+
response = ResponseOAuthToken()
35+
response.access_token = token
36+
response.expiry = expiry
37+
return response
3338

3439
@Route(route='authorization', methods=[RequestMethods.POST])
3540
@cherrypy.config(**{'tools.authentication.on': False})

0 commit comments

Comments
 (0)