Fast-Permissions 0.1.0b0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.1
2
+ Name: Fast-Permissions
3
+ Version: 0.1.0b0
4
+ Summary: Add robust authentication to your FastAPI endpoints
5
+ Keywords: authentication,authorization,fastapi,permissions,security,access
6
+ Author-Email: Cody M Sommer <bassmastacod@gmail.com>
7
+ License: MIT
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Typing :: Typed
18
+ Project-URL: Repository, https://github.com/BassMastaCod/Fast-Permissions.git
19
+ Project-URL: Issues, https://github.com/BassMastaCod/Fast-Permissions/issues
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: fastapi
22
+ Requires-Dist: daomodel
23
+ Requires-Dist: pyjwt
24
+ Requires-Dist: python-multipart
25
+ Requires-Dist: jinja2
26
+ Requires-Dist: bcrypt
27
+ Provides-Extra: pwa
28
+ Requires-Dist: fastpwa[controller]; extra == "pwa"
29
+ Description-Content-Type: text/markdown
30
+
31
+ # Fast-Permissions
32
+
33
+ Fast-Permissions is a library designed to add authentication and authorization capabilities to FastAPI applications,
34
+ particularly those using the Fast-Controller framework.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install Fast-Permissions
40
+ ```
41
+
42
+ For PWA functionality, install with the PWA extra:
43
+
44
+ ```bash
45
+ pip install Fast-Permissions[pwa]
46
+ ```
47
+
48
+ **NOTE**: The rest of the README is AI-generated.
49
+ I will rewrite once the library is in a stable state with most of the planned features implemented.
50
+
51
+ ## Usage
52
+
53
+ Here's a simple example of how to use Fast-Permissions with Fast-Controller:
54
+
55
+ ```python
56
+ from fastapi import FastAPI, Request
57
+ from typing import Optional
58
+
59
+ from daomodel.db import create_engine, init_db
60
+ from daomodel.fields import Identifier
61
+ from fast_controller import Resource, Action
62
+ from fast_permissions import RestrictedController
63
+ from fast_permissions.models import User
64
+ from fast_permissions.service import UserService, Unauthorized
65
+
66
+ # Define your resources
67
+ class Item(Resource, table=True):
68
+ name: Identifier[str]
69
+ description: Optional[str] = None
70
+
71
+ # Set up the database
72
+ engine = create_engine("sqlite:///app.db")
73
+ init_db(engine)
74
+
75
+ # Create the FastAPI app
76
+ app = FastAPI()
77
+
78
+ # Define a function to get the current user from the request
79
+ def get_current_user(request: Request) -> User:
80
+ token = request.cookies.get('access_token')
81
+ if not token:
82
+ raise Unauthorized('No access token provided')
83
+
84
+ # You'll need to provide a way to get DAOs - this is just an example
85
+ with controller.dao_context() as daos:
86
+ return UserService(daos).from_token(token)
87
+
88
+ # Create a RestrictedController
89
+ controller = RestrictedController(
90
+ app=app,
91
+ engine=engine,
92
+ get_current_user=get_current_user,
93
+ public_by_default=True # Set to False to require auth by default
94
+ )
95
+
96
+ # Register your resources, specifying which actions don't require authentication
97
+ # When public_by_default=True, all actions are public unless marked restricted
98
+ controller.register_resource(Item)
99
+
100
+ # Create an admin user (for development/testing)
101
+ # In production, you would create users through your API
102
+ controller.register_admin("secure-password")
103
+ ```
104
+
105
+ ## Authentication
106
+
107
+ Fast-Permissions uses cookie-based authentication with JWT tokens. Users can authenticate by sending a POST request to the `/api/sessions` endpoint:
108
+
109
+ ```
110
+ POST /api/sessions
111
+ Content-Type: application/x-www-form-urlencoded
112
+
113
+ username=admin&password=secure-password
114
+ ```
115
+
116
+ This will set an HTTP-only cookie with the JWT token. The authentication is handled automatically through cookies, so no manual token management is required in the browser.
117
+
118
+ ## Configuration
119
+
120
+ Before using Fast-Permissions, you need to set a secret key for JWT token signing:
121
+
122
+ ```python
123
+ from fast_permissions import config
124
+ config.SECRET_KEY = "your-secret-key-here"
125
+ ```
126
+
127
+ ## User Management
128
+
129
+ You can manage users through the User resource that is automatically registered by RestrictedController:
130
+
131
+ ```python
132
+ # Create a new user
133
+ POST /user
134
+ {
135
+ "username": "john",
136
+ "password": "password123"
137
+ }
138
+
139
+ # Get a user
140
+ GET /user/john
141
+
142
+ # Update a user's password
143
+ PUT /user/john
144
+ {
145
+ "password": "new-password"
146
+ }
147
+
148
+ # Delete a user
149
+ DELETE /user/john
150
+ ```
151
+
152
+ ## Resource Ownership
153
+
154
+ Fast-Permissions provides two base classes for resource ownership:
155
+
156
+ 1. `OrphanableResource`: Resources that can exist without an owner
157
+ 2. `OwnedResource`: Resources that are deleted when their owner is deleted
158
+
159
+ Example:
160
+
161
+ ```python
162
+ from daomodel.fields import Identifier
163
+ from fast_permissions.models import OwnedResource
164
+
165
+ class Note(OwnedResource, table=True):
166
+ id: Identifier[int]
167
+ content: str
168
+ ```
169
+
170
+ When a user creates a Note, they automatically become its owner. Only the owner can modify or delete the Note.
171
+
172
+
173
+ ## PWA (Progressive Web App) Support
174
+
175
+ Fast-Permissions provides PWA support through the `PWAWithAuth` class, which extends the FastPWA library with authentication capabilities.
176
+
177
+ ### Installation
178
+
179
+ To use PWA features, install with the PWA extra:
180
+
181
+ ```bash
182
+ pip install Fast-Permissions[pwa]
183
+ ```
184
+
185
+ ### Basic PWA Setup
186
+
187
+ ```python
188
+ from fast_permissions.pwa import PWAWithAuth
189
+
190
+ # Create a PWA with authentication
191
+ pwa = PWAWithAuth(
192
+ title="My App",
193
+ public_by_default=True, # Set to False to require auth by default
194
+ unauthorized_redirect="/login" # Where to redirect when not authenticated
195
+ )
196
+
197
+ # Register a simple login page
198
+ pwa.register_simple_login_page()
199
+
200
+ # Create restricted pages that require authentication
201
+ @pwa.restricted_page('/dashboard', 'dashboard.html')
202
+ async def dashboard(request):
203
+ return {'title': 'Dashboard'}
204
+
205
+ # Create public pages (no authentication required)
206
+ @pwa.page('/public', 'public.html')
207
+ async def public_page(request):
208
+ return {'title': 'Public Page'}
209
+ ```
210
+
211
+ ### Custom Authentication
212
+
213
+ You can provide your own authentication function:
214
+
215
+ ```python
216
+ from fastapi import Request
217
+ from fast_permissions.models import User
218
+ from fast_permissions.service import UserService, Unauthorized
219
+
220
+ def my_get_current_user(request: Request) -> User:
221
+ # Your custom authentication logic
222
+ token = request.cookies.get('access_token')
223
+ if not token:
224
+ raise Unauthorized('No token provided')
225
+ # ... validate token and return user
226
+ return user
227
+
228
+ pwa = PWAWithAuth(
229
+ title="My App",
230
+ get_current_user=my_get_current_user,
231
+ unauthorized_redirect="/login"
232
+ )
233
+ ```
@@ -0,0 +1,203 @@
1
+ # Fast-Permissions
2
+
3
+ Fast-Permissions is a library designed to add authentication and authorization capabilities to FastAPI applications,
4
+ particularly those using the Fast-Controller framework.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ pip install Fast-Permissions
10
+ ```
11
+
12
+ For PWA functionality, install with the PWA extra:
13
+
14
+ ```bash
15
+ pip install Fast-Permissions[pwa]
16
+ ```
17
+
18
+ **NOTE**: The rest of the README is AI-generated.
19
+ I will rewrite once the library is in a stable state with most of the planned features implemented.
20
+
21
+ ## Usage
22
+
23
+ Here's a simple example of how to use Fast-Permissions with Fast-Controller:
24
+
25
+ ```python
26
+ from fastapi import FastAPI, Request
27
+ from typing import Optional
28
+
29
+ from daomodel.db import create_engine, init_db
30
+ from daomodel.fields import Identifier
31
+ from fast_controller import Resource, Action
32
+ from fast_permissions import RestrictedController
33
+ from fast_permissions.models import User
34
+ from fast_permissions.service import UserService, Unauthorized
35
+
36
+ # Define your resources
37
+ class Item(Resource, table=True):
38
+ name: Identifier[str]
39
+ description: Optional[str] = None
40
+
41
+ # Set up the database
42
+ engine = create_engine("sqlite:///app.db")
43
+ init_db(engine)
44
+
45
+ # Create the FastAPI app
46
+ app = FastAPI()
47
+
48
+ # Define a function to get the current user from the request
49
+ def get_current_user(request: Request) -> User:
50
+ token = request.cookies.get('access_token')
51
+ if not token:
52
+ raise Unauthorized('No access token provided')
53
+
54
+ # You'll need to provide a way to get DAOs - this is just an example
55
+ with controller.dao_context() as daos:
56
+ return UserService(daos).from_token(token)
57
+
58
+ # Create a RestrictedController
59
+ controller = RestrictedController(
60
+ app=app,
61
+ engine=engine,
62
+ get_current_user=get_current_user,
63
+ public_by_default=True # Set to False to require auth by default
64
+ )
65
+
66
+ # Register your resources, specifying which actions don't require authentication
67
+ # When public_by_default=True, all actions are public unless marked restricted
68
+ controller.register_resource(Item)
69
+
70
+ # Create an admin user (for development/testing)
71
+ # In production, you would create users through your API
72
+ controller.register_admin("secure-password")
73
+ ```
74
+
75
+ ## Authentication
76
+
77
+ Fast-Permissions uses cookie-based authentication with JWT tokens. Users can authenticate by sending a POST request to the `/api/sessions` endpoint:
78
+
79
+ ```
80
+ POST /api/sessions
81
+ Content-Type: application/x-www-form-urlencoded
82
+
83
+ username=admin&password=secure-password
84
+ ```
85
+
86
+ This will set an HTTP-only cookie with the JWT token. The authentication is handled automatically through cookies, so no manual token management is required in the browser.
87
+
88
+ ## Configuration
89
+
90
+ Before using Fast-Permissions, you need to set a secret key for JWT token signing:
91
+
92
+ ```python
93
+ from fast_permissions import config
94
+ config.SECRET_KEY = "your-secret-key-here"
95
+ ```
96
+
97
+ ## User Management
98
+
99
+ You can manage users through the User resource that is automatically registered by RestrictedController:
100
+
101
+ ```python
102
+ # Create a new user
103
+ POST /user
104
+ {
105
+ "username": "john",
106
+ "password": "password123"
107
+ }
108
+
109
+ # Get a user
110
+ GET /user/john
111
+
112
+ # Update a user's password
113
+ PUT /user/john
114
+ {
115
+ "password": "new-password"
116
+ }
117
+
118
+ # Delete a user
119
+ DELETE /user/john
120
+ ```
121
+
122
+ ## Resource Ownership
123
+
124
+ Fast-Permissions provides two base classes for resource ownership:
125
+
126
+ 1. `OrphanableResource`: Resources that can exist without an owner
127
+ 2. `OwnedResource`: Resources that are deleted when their owner is deleted
128
+
129
+ Example:
130
+
131
+ ```python
132
+ from daomodel.fields import Identifier
133
+ from fast_permissions.models import OwnedResource
134
+
135
+ class Note(OwnedResource, table=True):
136
+ id: Identifier[int]
137
+ content: str
138
+ ```
139
+
140
+ When a user creates a Note, they automatically become its owner. Only the owner can modify or delete the Note.
141
+
142
+
143
+ ## PWA (Progressive Web App) Support
144
+
145
+ Fast-Permissions provides PWA support through the `PWAWithAuth` class, which extends the FastPWA library with authentication capabilities.
146
+
147
+ ### Installation
148
+
149
+ To use PWA features, install with the PWA extra:
150
+
151
+ ```bash
152
+ pip install Fast-Permissions[pwa]
153
+ ```
154
+
155
+ ### Basic PWA Setup
156
+
157
+ ```python
158
+ from fast_permissions.pwa import PWAWithAuth
159
+
160
+ # Create a PWA with authentication
161
+ pwa = PWAWithAuth(
162
+ title="My App",
163
+ public_by_default=True, # Set to False to require auth by default
164
+ unauthorized_redirect="/login" # Where to redirect when not authenticated
165
+ )
166
+
167
+ # Register a simple login page
168
+ pwa.register_simple_login_page()
169
+
170
+ # Create restricted pages that require authentication
171
+ @pwa.restricted_page('/dashboard', 'dashboard.html')
172
+ async def dashboard(request):
173
+ return {'title': 'Dashboard'}
174
+
175
+ # Create public pages (no authentication required)
176
+ @pwa.page('/public', 'public.html')
177
+ async def public_page(request):
178
+ return {'title': 'Public Page'}
179
+ ```
180
+
181
+ ### Custom Authentication
182
+
183
+ You can provide your own authentication function:
184
+
185
+ ```python
186
+ from fastapi import Request
187
+ from fast_permissions.models import User
188
+ from fast_permissions.service import UserService, Unauthorized
189
+
190
+ def my_get_current_user(request: Request) -> User:
191
+ # Your custom authentication logic
192
+ token = request.cookies.get('access_token')
193
+ if not token:
194
+ raise Unauthorized('No token provided')
195
+ # ... validate token and return user
196
+ return user
197
+
198
+ pwa = PWAWithAuth(
199
+ title="My App",
200
+ get_current_user=my_get_current_user,
201
+ unauthorized_redirect="/login"
202
+ )
203
+ ```
@@ -0,0 +1,155 @@
1
+ from typing import Annotated, Callable
2
+
3
+ from daomodel.db import DAOFactory
4
+
5
+ from fast_controller import Controller, Action
6
+ from fast_controller.util import no_cache
7
+ from fastapi import Depends, APIRouter, Response, Request, status, FastAPI, Security, HTTPException
8
+ from fastapi.security import OAuth2PasswordRequestForm, APIKeyCookie
9
+
10
+ from fast_permissions import config
11
+ from fast_permissions.exceptions import Unauthorized
12
+ from fast_permissions.models import Session, User
13
+ from fast_permissions.service import UserService
14
+
15
+
16
+ _security = Security(APIKeyCookie(name='HTTP Only Cookie', description='Must use endpoint to login', auto_error=False))
17
+
18
+
19
+ class Auth:
20
+ """Provides decorators for specifying access levels for endpoints."""
21
+ def access(self, level: str):
22
+ """Decorator that sets the access level of an endpoint i.e. public, restricted, etc..."""
23
+ def wrapper(func):
24
+ func._fp_access = level
25
+ return func
26
+ return wrapper
27
+
28
+ @property
29
+ def public(self):
30
+ """Decorator that configures an endpoint to have no access restrictions."""
31
+ return self.access('public')
32
+
33
+ @property
34
+ def restricted(self):
35
+ """Decorator that configures an endpoint to require authentication."""
36
+ return self.access('restricted')
37
+
38
+ auth = Auth()
39
+
40
+
41
+ def default_session_endpoints(router: APIRouter, controller: Controller):
42
+ @router.post('', status_code=status.HTTP_204_NO_CONTENT)
43
+ @auth.public
44
+ async def login(response: Response,
45
+ form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
46
+ daos: DAOFactory = controller.daos) -> None:
47
+ """Authenticates the user and sets a cookie with the access token."""
48
+ try:
49
+ user = UserService(daos).authenticate(form_data.username, form_data.password)
50
+ response.set_cookie(
51
+ key='access_token',
52
+ value=user.access_token,
53
+ httponly=True,
54
+ secure=True,
55
+ samesite='lax',
56
+ max_age=60 * 60 * 24,
57
+ path='/'
58
+ )
59
+ except TypeError:
60
+ if config.SECRET_KEY is None:
61
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Fast-Permissions SECRET_KEY is not configured')
62
+ except Unauthorized:
63
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Incorrect username or password')
64
+
65
+ @router.delete('', status_code=status.HTTP_204_NO_CONTENT)
66
+ @auth.restricted
67
+ async def logout(response: Response, request: Request, daos: DAOFactory = controller.daos) -> None:
68
+ """Invalidates the caller's token and clears the cookie."""
69
+ caller_token = request.cookies.get('access_token')
70
+ UserService(daos).invalidate_token(caller_token)
71
+
72
+ response.status_code = status.HTTP_204_NO_CONTENT
73
+ response.delete_cookie(key='access_token', path='/')
74
+
75
+ @no_cache
76
+ @router.head('')
77
+ @auth.restricted
78
+ async def check_auth() -> Response:
79
+ """Checks if the user is authenticated.
80
+
81
+ Returns 200 if the caller has a valid access token and 401 otherwise.
82
+ """
83
+ return Response()
84
+
85
+
86
+ class RestrictedRouter(APIRouter):
87
+ """A custom APIRouter that automatically adds authentication middleware.
88
+
89
+ :param user_dep: The dependency that validates the user
90
+ :param public_by_default: True to only require authentication for endpoints marked with @auth.restricted
91
+ """
92
+ def __init__(self, *args, user_dep: Depends, public_by_default: bool = False, **kwargs):
93
+ super().__init__(*args, **kwargs)
94
+ self.user_dep = user_dep
95
+ self.public_by_default = public_by_default
96
+
97
+ def add_api_route(self, path, endpoint, **kwargs) -> None:
98
+ """Overrides the default add_api_route method to add authentication middleware."""
99
+ deps = kwargs.pop('dependencies')
100
+ if not deps:
101
+ deps = []
102
+
103
+ level = getattr(endpoint, '_fp_access', None)
104
+ if level == 'restricted':
105
+ deps.append(Depends(self.user_dep))
106
+ deps.append(_security)
107
+ elif level == 'public':
108
+ pass
109
+ else:
110
+ if not self.public_by_default:
111
+ deps.append(Depends(self.user_dep))
112
+ deps.append(_security)
113
+ kwargs['dependencies'] = deps
114
+ super().add_api_route(path, endpoint, **kwargs)
115
+
116
+
117
+ class RestrictedController(Controller):
118
+ """A controller that includes authentication middleware."""
119
+ def __init__(self, *args,
120
+ get_current_user: Callable,
121
+ public_by_default: bool = False,
122
+ token_endpoints: Callable = default_session_endpoints,
123
+ **kwargs):
124
+ self.get_current_user = get_current_user
125
+ self.public_by_default = public_by_default
126
+ super().__init__(*args, **kwargs)
127
+ self.register_resource(Session, skip=set(Action), additional_endpoints=token_endpoints)
128
+ self.register_resource(User)
129
+
130
+ def _create_router(self) -> APIRouter:
131
+ return RestrictedRouter(
132
+ prefix=self.prefix,
133
+ user_dep=self.get_current_user,
134
+ public_by_default=self.public_by_default
135
+ )
136
+
137
+ def include_controller(self, app: FastAPI) -> None:
138
+ super().include_controller(app)
139
+
140
+ @app.exception_handler(Unauthorized)
141
+ async def unauthorized_handler(request: Request, exc: Unauthorized):
142
+ return Response(status_code=status.HTTP_401_UNAUTHORIZED)
143
+
144
+ def register_admin(self, password: str) -> None:
145
+ """Creates an admin user having the given password.
146
+
147
+ This only needs to be called once.
148
+ Once logged in as the admin, additional users can be created through the ReST API.
149
+
150
+ :param password: The password for the admin user (this will be hashed and stored in the database).
151
+ """
152
+ admin = User(username='admin')
153
+ admin.password = password
154
+ with self.dao_context() as daos:
155
+ daos[User].upsert(admin)
@@ -0,0 +1,2 @@
1
+ SECRET_KEY = None
2
+ ALGORITHM = 'HS256'
@@ -0,0 +1,6 @@
1
+ class InvalidPassword(Exception):
2
+ pass
3
+
4
+
5
+ class Unauthorized(Exception):
6
+ pass
@@ -0,0 +1,28 @@
1
+ login_template = '''
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Login</title>
8
+ <style>
9
+ body { font-family: Arial, sans-serif; text-align: center; margin: 0; padding: 0; background-color: #f4f4f4; }
10
+ .login-form { width: 300px; margin: 100px auto; padding: 20px; background: white; border: 1px solid #ccc; border-radius: 5px; }
11
+ .login-form h2 { margin-bottom: 20px; }
12
+ .login-form input { width: calc(100% - 20px); margin-bottom: 15px; padding: 8px; border: 1px solid #ccc; border-radius: 5px; }
13
+ .login-form button { padding: 10px 20px; border: none; border-radius: 5px; background: #007BFF; color: white; cursor: pointer; }
14
+ .login-form button:hover { background: #0056b3; }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <div class="login-form">
19
+ <h2>Login</h2>
20
+ <form action="${path}" method="post">
21
+ <input type="text" name="username" placeholder="Username" required>
22
+ <input type="password" name="password" placeholder="Password" required>
23
+ <button type="submit">Login</button>
24
+ </form>
25
+ </div>
26
+ </body>
27
+ </html>
28
+ '''
@@ -0,0 +1,51 @@
1
+ from typing import Optional, Any
2
+
3
+ import bcrypt
4
+ from daomodel.fields import Identifier, Unsearchable
5
+ from fast_controller import Resource
6
+ from fast_controller.schema import schemas
7
+
8
+ from fast_permissions.exceptions import InvalidPassword
9
+
10
+
11
+ class UserBase(Resource):
12
+ username: Identifier[str]
13
+
14
+
15
+ @schemas(output=UserBase)
16
+ class User(UserBase, table=True):
17
+ password: Unsearchable[str]
18
+
19
+ def __setattr__(self, key: str, value: Any) -> None:
20
+ # Automatically hash any set password
21
+ if key == 'password':
22
+ value = bcrypt.hashpw(value.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
23
+ super.__setattr__(self, key, value)
24
+
25
+ def verify(self, password: str) -> None:
26
+ """Verify the user's password, raises InvalidPassword if incorrect."""
27
+ if not bcrypt.checkpw(password.encode('utf-8'), self.password.encode('utf-8')):
28
+ raise InvalidPassword()
29
+
30
+
31
+ class OrphanableResource(Resource):
32
+ """A resource that can be owned by a user, but not necessarily."""
33
+ __abstract__ = True
34
+ owner: Optional[User]
35
+
36
+ def is_owned(self) -> bool:
37
+ return self.owner is not None
38
+
39
+ def is_owned_by(self, user: User) -> bool:
40
+ return self.owner == user.username
41
+
42
+
43
+ class OwnedResource(OrphanableResource):
44
+ """A resource that belongs to a specific User."""
45
+ __abstract__ = True
46
+ owner: User
47
+
48
+
49
+ class Session(OwnedResource, table=True):
50
+ access_token: Identifier[str]
51
+ token_type: str = 'bearer' # is this valid or needed/weanted?
@@ -0,0 +1,136 @@
1
+ from pathlib import Path
2
+ from typing import Optional, Callable
3
+ from urllib.parse import quote
4
+
5
+ from daomodel.db import init_db, create_engine
6
+ from fastapi import Request, Depends, HTTPException, status
7
+ from fastapi.responses import HTMLResponse
8
+
9
+ from fast_permissions import RestrictedController
10
+ from fast_permissions.html import login_template
11
+ from fast_permissions.models import User
12
+ from fast_permissions.service import Unauthorized, UserService
13
+
14
+ try:
15
+ from fastpwa import PWA, ensure_list, logger
16
+ except ImportError:
17
+ raise ImportError(
18
+ 'PWAWithAuth requires FastPWA. Install with: pip install fast-permissions[pwa]'
19
+ )
20
+
21
+
22
+ class Redirect(HTTPException):
23
+ """HTTP Response that redirects the client to the given URL."""
24
+ def __init__(self, url: str, code: int = status.HTTP_302_FOUND):
25
+ super().__init__(
26
+ status_code=code,
27
+ headers={"Location": url}
28
+ )
29
+
30
+
31
+ class PWAWithAuth(PWA):
32
+ """Extension of PWA that adds authentication functionality.
33
+
34
+ This implementation provides a way to create pages that require authentication.
35
+ """
36
+ def __init__(self, *args,
37
+ get_current_user: Optional[Callable] = None,
38
+ public_by_default: Optional[bool] = None,
39
+ unauthorized_redirect: Optional[str] = None,
40
+ **kwargs):
41
+ self.get_current_user = get_current_user or self._default_get_current_user
42
+ self.public_by_default = public_by_default
43
+ if public_by_default is not None:
44
+ if get_current_user:
45
+ raise ValueError('`public_by_default` can only be set if not using a custom `get_current_user` function')
46
+ self.unauthorized_redirect = unauthorized_redirect
47
+ super().__init__(*args, **kwargs)
48
+
49
+ def _default_controller(self):
50
+ controller = RestrictedController(
51
+ prefix=self.api_prefix,
52
+ get_current_user=self.get_current_user,
53
+ public_by_default=self.public_by_default
54
+ )
55
+ controller.engine = create_engine('database.db')
56
+ init_db(controller.engine)
57
+ return controller
58
+
59
+ def _default_get_current_user(self, request: Request) -> User:
60
+ """Returns the currently logged-in user, or raises Unauthorized if not logged in."""
61
+ token = request.cookies.get('access_token')
62
+ with self.controller.dao_context() as daos:
63
+ return UserService(daos).from_token(token)
64
+
65
+ @property
66
+ def restricted_dep(self):
67
+ return Depends(self.get_current_user_with_redirect(no_return=True))
68
+
69
+ def register_simple_login_page(self,
70
+ page_path: str = 'login',
71
+ api_path: str = '/api/sessions',
72
+ redirect: bool = True) -> None:
73
+ """Creates a basic login page.
74
+
75
+ This page is extremely rudimentary and is only intended for developer use.
76
+ UX is lacking; it does not redirect after login or even provide feedback to the user.
77
+
78
+ :param page_path: Where to host the login page (/login by default)
79
+ :param api_path: Where to send the login form (/api/sessions by default)
80
+ :param redirect: False to avoid automatically redirecting to this page when not logged in
81
+ """
82
+ @self.page(page_path, html=login_template.replace('${path}', api_path))
83
+ async def login_page() -> dict:
84
+ return {'title': f'{self.title} Login'}
85
+
86
+ if redirect:
87
+ self.unauthorized_redirect = '/login'
88
+
89
+ def get_current_user_with_redirect(self, url: Optional[str] = None, no_return: bool = False):
90
+ """Returns a dependency that validates the user and redirects back to the original page once logged in."""
91
+ if not url:
92
+ if not self.unauthorized_redirect:
93
+ raise ValueError('Unauthorized redirect URL not specified. '
94
+ 'Please set unauthorized_redirect= when creating PWA or page.')
95
+ url = self.unauthorized_redirect
96
+ async def wrapper(request: Request):
97
+ try:
98
+ return self.get_current_user(request)
99
+ except Unauthorized:
100
+ if no_return:
101
+ raise Redirect(url)
102
+ original = quote(request.url.path)
103
+ sep = '&' if '?' in url else '?'
104
+ raise Redirect(f'{url}{sep}from={original}')
105
+ return wrapper
106
+
107
+ def restricted_page(self,
108
+ route: str,
109
+ html: str | Path,
110
+ css: Optional[str | list[str]] = None,
111
+ js: Optional[str | list[str]] = None,
112
+ js_libraries: Optional[str | list[str]] = None,
113
+ color: Optional[str] = None,
114
+ unauthorized_redirect: Optional[str] = None,
115
+ **get_kwargs):
116
+ """Decorator that creates a page requiring authentication."""
117
+ route = self.with_prefix(route)
118
+ url = unauthorized_redirect or self.unauthorized_redirect
119
+ get_user = self.get_current_user_with_redirect(url) if url else self.get_current_user
120
+
121
+ def decorator(func):
122
+ async def response_wrapper(request: Request, context: dict = Depends(func), user: User = Depends(get_user)):
123
+ return HTMLResponse(self.page_template.render(
124
+ path_prefix=self.prefix,
125
+ request=request,
126
+ title=context.get('title', self.title),
127
+ color=color,
128
+ css=ensure_list(css) + self.global_css,
129
+ js=ensure_list(js) + self.global_js,
130
+ js_libraries=ensure_list(js_libraries),
131
+ body=self.env.get_template(html).render(**context)
132
+ ))
133
+ self.get(route, include_in_schema=False, **get_kwargs)(response_wrapper)
134
+ logger.info(f'Registered restricted page at {route}')
135
+ return func
136
+ return decorator
@@ -0,0 +1,116 @@
1
+ from datetime import timedelta, datetime, timezone
2
+ from typing import Optional, Any
3
+
4
+ import jwt
5
+ from daomodel.dao import NotFound
6
+ from daomodel.db import DAOFactory
7
+ from fastapi import HTTPException
8
+
9
+ from fast_permissions import config
10
+ from fast_permissions.exceptions import InvalidPassword, Unauthorized
11
+ from fast_permissions.models import User, Session, OwnedResource
12
+
13
+
14
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
15
+ """Creates a JWT access token.
16
+
17
+ :param data: The payload to include in the token
18
+ :param expires_delta: The duration for which the token is valid
19
+ :return: The encoded access token
20
+ """
21
+ expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
22
+ to_encode = {**data, 'exp': expire}
23
+ return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
24
+
25
+
26
+ def decode_token(token: str) -> dict[str, Any]:
27
+ """Decodes a JWT token and returns the payload."""
28
+ try:
29
+ payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
30
+ if not payload.get('username'):
31
+ raise HTTPException(status_code=401, detail='Invalid token')
32
+ return payload
33
+ except jwt.ExpiredSignatureError:
34
+ raise HTTPException(status_code=401, detail='Token expired')
35
+ except Exception:
36
+ raise HTTPException(status_code=401, detail='Invalid token')
37
+
38
+
39
+ class UserService:
40
+ def __init__(self, daos: DAOFactory):
41
+ self.daos = daos
42
+ self.user_dao = daos[User]
43
+ self.token_dao = daos[Session]
44
+
45
+ def register(self, username: str, password: str) -> User:
46
+ """Creates a new User and saves it to the database.
47
+
48
+ :param username: The new username
49
+ :param password: The unencrypted password for the new username
50
+ :return: The newly created User
51
+ :raises PrimaryKeyConflict: If the username is already taken
52
+ """
53
+ user = self.user_dao.create_with(commit=False, username=username)
54
+ self.set_password(user, password)
55
+ return user
56
+
57
+ def authenticate(self, username: str, password: str) -> User:
58
+ """Authenticates a User and returns a token if successful.
59
+
60
+ :param username: The username to authenticate
61
+ :param password: The unencrypted password for the username
62
+ :return: The authenticated User, containing the access token
63
+ :raises HTTPException: If the username or password is incorrect
64
+ """
65
+ try:
66
+ user = self.get_user(username)
67
+ user.verify(password)
68
+ user.access_token = create_access_token(user.model_dump(), expires_delta=timedelta(days=1))
69
+ self.token_dao.create_with(access_token=user.access_token, owner=user.username)
70
+ return user
71
+ except (NotFound, InvalidPassword) as e:
72
+ raise Unauthorized('Authentication failed due to incorrect username or password') from e
73
+
74
+ def get_user(self, username: str) -> User:
75
+ """Finds a User by their username."""
76
+ return self.user_dao.get(username)
77
+
78
+ def set_password(self, user: User, password: str) -> None:
79
+ """Sets a new password for a user and updates the User record in the database."""
80
+ user.password = password
81
+ self.user_dao.update(user)
82
+
83
+ def get_owned(self, user: User, resource: type[OwnedResource]) -> list[OwnedResource]:
84
+ """Returns resources that belong to a specific User.
85
+
86
+ :param user: The user whose resources to find
87
+ :param resource: The type of resource to find
88
+ :return: A list of resources owned by the user
89
+ """
90
+ return self.daos[resource].find(owner=user.username)
91
+
92
+ def from_token(self, token: str) -> User:
93
+ """Finds a User by their token.
94
+
95
+ :param token: The token the User is authenticated with
96
+ :return: The User associated with the token
97
+ """
98
+ if not token:
99
+ raise Unauthorized('No token provided')
100
+ try:
101
+ payload = decode_token(token)
102
+ username = payload['username']
103
+ self.token_dao.get(token)
104
+ return self.get_user(username)
105
+ except Exception as e:
106
+ raise Unauthorized(f'Authentication failed: {str(e)}') from e
107
+
108
+ def invalidate_token(self, token: str) -> None:
109
+ """Deauthenticates a session by invalidating its token."""
110
+ if not token:
111
+ return
112
+ try:
113
+ entry = self.token_dao.get(token)
114
+ self.token_dao.remove(entry)
115
+ except NotFound:
116
+ pass
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = [
3
+ "pdm-backend",
4
+ ]
5
+ build-backend = "pdm.backend"
6
+
7
+ [project]
8
+ name = "Fast-Permissions"
9
+ dynamic = []
10
+ authors = [
11
+ { name = "Cody M Sommer", email = "bassmastacod@gmail.com" },
12
+ ]
13
+ description = "Add robust authentication to your FastAPI endpoints"
14
+ keywords = [
15
+ "authentication",
16
+ "authorization",
17
+ "fastapi",
18
+ "permissions",
19
+ "security",
20
+ "access",
21
+ ]
22
+ readme = "README.md"
23
+ requires-python = ">=3.10"
24
+ dependencies = [
25
+ "fastapi",
26
+ "daomodel",
27
+ "pyjwt",
28
+ "python-multipart",
29
+ "jinja2",
30
+ "bcrypt",
31
+ ]
32
+ classifiers = [
33
+ "License :: OSI Approved :: MIT License",
34
+ "Programming Language :: Python :: 3.10",
35
+ "Programming Language :: Python :: 3.11",
36
+ "Programming Language :: Python :: 3.12",
37
+ "Programming Language :: Python :: 3.13",
38
+ "Programming Language :: Python :: 3.14",
39
+ "Development Status :: 4 - Beta",
40
+ "Intended Audience :: Developers",
41
+ "Topic :: Software Development :: Libraries",
42
+ "Typing :: Typed",
43
+ ]
44
+ version = "0.1.0b0"
45
+
46
+ [project.license]
47
+ text = "MIT"
48
+
49
+ [project.urls]
50
+ Repository = "https://github.com/BassMastaCod/Fast-Permissions.git"
51
+ Issues = "https://github.com/BassMastaCod/Fast-Permissions/issues"
52
+
53
+ [project.optional-dependencies]
54
+ pwa = [
55
+ "fastpwa[controller]",
56
+ ]
57
+
58
+ [tool.pdm.version]
59
+ source = "scm"
60
+
61
+ [tool.pytest.ini_options]
62
+ pythonpath = "fast_permissions"
63
+ addopts = [
64
+ "--import-mode=importlib",
65
+ ]