the37lab-authlib 0.1.1749238112__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.
Potentially problematic release.
This version of the37lab-authlib might be problematic. Click here for more details.
- the37lab_authlib-0.1.1749238112/PKG-INFO +115 -0
- the37lab_authlib-0.1.1749238112/README.md +97 -0
- the37lab_authlib-0.1.1749238112/pyproject.toml +17 -0
- the37lab_authlib-0.1.1749238112/setup.cfg +4 -0
- the37lab_authlib-0.1.1749238112/src/the37lab_authlib/__init__.py +5 -0
- the37lab_authlib-0.1.1749238112/src/the37lab_authlib/auth.py +513 -0
- the37lab_authlib-0.1.1749238112/src/the37lab_authlib/db.py +74 -0
- the37lab_authlib-0.1.1749238112/src/the37lab_authlib/decorators.py +31 -0
- the37lab_authlib-0.1.1749238112/src/the37lab_authlib/exceptions.py +11 -0
- the37lab_authlib-0.1.1749238112/src/the37lab_authlib/models.py +95 -0
- the37lab_authlib-0.1.1749238112/src/the37lab_authlib.egg-info/PKG-INFO +115 -0
- the37lab_authlib-0.1.1749238112/src/the37lab_authlib.egg-info/SOURCES.txt +13 -0
- the37lab_authlib-0.1.1749238112/src/the37lab_authlib.egg-info/dependency_links.txt +1 -0
- the37lab_authlib-0.1.1749238112/src/the37lab_authlib.egg-info/requires.txt +7 -0
- the37lab_authlib-0.1.1749238112/src/the37lab_authlib.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: the37lab_authlib
|
|
3
|
+
Version: 0.1.1749238112
|
|
4
|
+
Summary: Python SDK for the Authlib
|
|
5
|
+
Author-email: the37lab <info@the37lab.com>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: Other/Proprietary License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: flask
|
|
12
|
+
Requires-Dist: psycopg2-binary
|
|
13
|
+
Requires-Dist: pyjwt
|
|
14
|
+
Requires-Dist: python-dotenv
|
|
15
|
+
Requires-Dist: requests
|
|
16
|
+
Requires-Dist: authlib
|
|
17
|
+
Requires-Dist: bcrypt
|
|
18
|
+
|
|
19
|
+
# AuthLib
|
|
20
|
+
|
|
21
|
+
A Python authentication library that provides JWT, OAuth2, and API token authentication with PostgreSQL backend.
|
|
22
|
+
|
|
23
|
+
## Table of Contents
|
|
24
|
+
- [Installation](#installation)
|
|
25
|
+
- [Quick Start](#quick-start)
|
|
26
|
+
- [Configuration](#configuration)
|
|
27
|
+
- [API Endpoints](#api-endpoints)
|
|
28
|
+
- [Development](#development)
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install -e .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from flask import Flask
|
|
40
|
+
from authlib import AuthManager
|
|
41
|
+
|
|
42
|
+
app = Flask(__name__)
|
|
43
|
+
|
|
44
|
+
auth = AuthManager(
|
|
45
|
+
app=app,
|
|
46
|
+
db_dsn="postgresql://user:pass@localhost/dbname",
|
|
47
|
+
jwt_secret="your-secret-key",
|
|
48
|
+
oauth_config={
|
|
49
|
+
"google": {
|
|
50
|
+
"client_id": "your-client-id",
|
|
51
|
+
"client_secret": "your-client-secret"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@app.route("/protected")
|
|
57
|
+
@auth.require_auth(roles=["admin"])
|
|
58
|
+
def protected_route():
|
|
59
|
+
return "Protected content"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
### Required Parameters
|
|
65
|
+
- `app`: Flask application instance
|
|
66
|
+
- `db_dsn`: PostgreSQL connection string
|
|
67
|
+
- `jwt_secret`: Secret key for JWT signing
|
|
68
|
+
|
|
69
|
+
### Optional Parameters
|
|
70
|
+
- `oauth_config`: Dictionary of OAuth provider configurations
|
|
71
|
+
- `token_expiry`: JWT token expiry time in seconds (default: 3600)
|
|
72
|
+
- `refresh_token_expiry`: Refresh token expiry time in seconds (default: 2592000)
|
|
73
|
+
|
|
74
|
+
## API Endpoints
|
|
75
|
+
|
|
76
|
+
### Authentication
|
|
77
|
+
- `POST /v1/users/login` - Login with username/password
|
|
78
|
+
- `POST /v1/users/login/oauth` - Get OAuth redirect URL
|
|
79
|
+
- `GET /v1/users/login/oauth2callback` - OAuth callback
|
|
80
|
+
- `POST /v1/users/token-refresh` - Refresh JWT token
|
|
81
|
+
|
|
82
|
+
### User Management
|
|
83
|
+
- `POST /v1/users/register` - Register new user
|
|
84
|
+
- `GET /v1/users/login/profile` - Get user profile
|
|
85
|
+
- `GET /v1/users/roles` - Get available roles
|
|
86
|
+
|
|
87
|
+
### API Tokens
|
|
88
|
+
- `POST /v1/users/{user}/api-tokens` - Create API token
|
|
89
|
+
- `GET /v1/users/{user}/api-tokens` - List API tokens
|
|
90
|
+
- `DELETE /v1/users/{user}/api-tokens/{token_id}` - Delete API token
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
### Setup
|
|
95
|
+
1. Clone the repository
|
|
96
|
+
2. Create virtual environment:
|
|
97
|
+
```bash
|
|
98
|
+
python -m venv venv
|
|
99
|
+
venv\Scripts\activate
|
|
100
|
+
```
|
|
101
|
+
3. Install dependencies:
|
|
102
|
+
```bash
|
|
103
|
+
pip install -e ".[dev]"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Database Setup
|
|
107
|
+
```bash
|
|
108
|
+
createdb authlib
|
|
109
|
+
python -m authlib.cli db init
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Running Tests
|
|
113
|
+
```bash
|
|
114
|
+
pytest
|
|
115
|
+
```
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# AuthLib
|
|
2
|
+
|
|
3
|
+
A Python authentication library that provides JWT, OAuth2, and API token authentication with PostgreSQL backend.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
- [Installation](#installation)
|
|
7
|
+
- [Quick Start](#quick-start)
|
|
8
|
+
- [Configuration](#configuration)
|
|
9
|
+
- [API Endpoints](#api-endpoints)
|
|
10
|
+
- [Development](#development)
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install -e .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from flask import Flask
|
|
22
|
+
from authlib import AuthManager
|
|
23
|
+
|
|
24
|
+
app = Flask(__name__)
|
|
25
|
+
|
|
26
|
+
auth = AuthManager(
|
|
27
|
+
app=app,
|
|
28
|
+
db_dsn="postgresql://user:pass@localhost/dbname",
|
|
29
|
+
jwt_secret="your-secret-key",
|
|
30
|
+
oauth_config={
|
|
31
|
+
"google": {
|
|
32
|
+
"client_id": "your-client-id",
|
|
33
|
+
"client_secret": "your-client-secret"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@app.route("/protected")
|
|
39
|
+
@auth.require_auth(roles=["admin"])
|
|
40
|
+
def protected_route():
|
|
41
|
+
return "Protected content"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
### Required Parameters
|
|
47
|
+
- `app`: Flask application instance
|
|
48
|
+
- `db_dsn`: PostgreSQL connection string
|
|
49
|
+
- `jwt_secret`: Secret key for JWT signing
|
|
50
|
+
|
|
51
|
+
### Optional Parameters
|
|
52
|
+
- `oauth_config`: Dictionary of OAuth provider configurations
|
|
53
|
+
- `token_expiry`: JWT token expiry time in seconds (default: 3600)
|
|
54
|
+
- `refresh_token_expiry`: Refresh token expiry time in seconds (default: 2592000)
|
|
55
|
+
|
|
56
|
+
## API Endpoints
|
|
57
|
+
|
|
58
|
+
### Authentication
|
|
59
|
+
- `POST /v1/users/login` - Login with username/password
|
|
60
|
+
- `POST /v1/users/login/oauth` - Get OAuth redirect URL
|
|
61
|
+
- `GET /v1/users/login/oauth2callback` - OAuth callback
|
|
62
|
+
- `POST /v1/users/token-refresh` - Refresh JWT token
|
|
63
|
+
|
|
64
|
+
### User Management
|
|
65
|
+
- `POST /v1/users/register` - Register new user
|
|
66
|
+
- `GET /v1/users/login/profile` - Get user profile
|
|
67
|
+
- `GET /v1/users/roles` - Get available roles
|
|
68
|
+
|
|
69
|
+
### API Tokens
|
|
70
|
+
- `POST /v1/users/{user}/api-tokens` - Create API token
|
|
71
|
+
- `GET /v1/users/{user}/api-tokens` - List API tokens
|
|
72
|
+
- `DELETE /v1/users/{user}/api-tokens/{token_id}` - Delete API token
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
### Setup
|
|
77
|
+
1. Clone the repository
|
|
78
|
+
2. Create virtual environment:
|
|
79
|
+
```bash
|
|
80
|
+
python -m venv venv
|
|
81
|
+
venv\Scripts\activate
|
|
82
|
+
```
|
|
83
|
+
3. Install dependencies:
|
|
84
|
+
```bash
|
|
85
|
+
pip install -e ".[dev]"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Database Setup
|
|
89
|
+
```bash
|
|
90
|
+
createdb authlib
|
|
91
|
+
python -m authlib.cli db init
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Running Tests
|
|
95
|
+
```bash
|
|
96
|
+
pytest
|
|
97
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "the37lab_authlib"
|
|
7
|
+
version = "0.1.1749238112"
|
|
8
|
+
description = "Python SDK for the Authlib"
|
|
9
|
+
authors = [{name = "the37lab", email = "info@the37lab.com"}]
|
|
10
|
+
dependencies = ["flask", "psycopg2-binary", "pyjwt", "python-dotenv", "requests", "authlib", "bcrypt"]
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: Other/Proprietary License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
]
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from flask import Blueprint, request, jsonify, current_app, url_for, redirect
|
|
3
|
+
import jwt
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from .db import Database
|
|
6
|
+
from .models import User, Role, ApiToken
|
|
7
|
+
from .exceptions import AuthError
|
|
8
|
+
import uuid
|
|
9
|
+
import requests
|
|
10
|
+
import bcrypt
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from functools import wraps
|
|
14
|
+
|
|
15
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
def handle_auth_errors(f):
|
|
19
|
+
@wraps(f)
|
|
20
|
+
def decorated(*args, **kwargs):
|
|
21
|
+
try:
|
|
22
|
+
return f(*args, **kwargs)
|
|
23
|
+
except AuthError as e:
|
|
24
|
+
response = jsonify(e.to_dict())
|
|
25
|
+
response.status_code = e.status_code
|
|
26
|
+
return response
|
|
27
|
+
return decorated
|
|
28
|
+
|
|
29
|
+
class AuthManager:
|
|
30
|
+
def __init__(self, app=None, db_dsn=None, jwt_secret=None, oauth_config=None, id_type='integer'):
|
|
31
|
+
self.db = Database(db_dsn, id_type=id_type) if db_dsn else None
|
|
32
|
+
self.jwt_secret = jwt_secret
|
|
33
|
+
logger.debug(f"Initializing AuthManager with JWT secret: {jwt_secret[:5]}..." if jwt_secret else "No JWT secret provided")
|
|
34
|
+
self.oauth_config = oauth_config or {}
|
|
35
|
+
|
|
36
|
+
if app:
|
|
37
|
+
self.init_app(app)
|
|
38
|
+
|
|
39
|
+
def _extract_token_from_header(self):
|
|
40
|
+
auth_header = request.headers.get('Authorization')
|
|
41
|
+
if not auth_header:
|
|
42
|
+
raise AuthError('No authorization header', 401)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
scheme, token = auth_header.split()
|
|
46
|
+
if scheme.lower() != 'bearer':
|
|
47
|
+
raise AuthError('Invalid authorization scheme', 401)
|
|
48
|
+
|
|
49
|
+
if not token:
|
|
50
|
+
raise AuthError('No token provided', 401)
|
|
51
|
+
|
|
52
|
+
return token
|
|
53
|
+
except ValueError:
|
|
54
|
+
raise AuthError('Invalid authorization header format', 401)
|
|
55
|
+
|
|
56
|
+
def _validate_api_token(self, api_token):
|
|
57
|
+
try:
|
|
58
|
+
parsed = ApiToken.parse_token(api_token)
|
|
59
|
+
with self.db.get_cursor() as cur:
|
|
60
|
+
# First get the API token record
|
|
61
|
+
cur.execute("""
|
|
62
|
+
SELECT t.*, u.* FROM api_tokens t
|
|
63
|
+
JOIN users u ON t.user_id = u.id
|
|
64
|
+
WHERE t.id = %s
|
|
65
|
+
""", (parsed['id'],))
|
|
66
|
+
result = cur.fetchone()
|
|
67
|
+
if not result:
|
|
68
|
+
raise AuthError('Invalid API token')
|
|
69
|
+
|
|
70
|
+
# Verify the nonce
|
|
71
|
+
if not bcrypt.checkpw(parsed['nonce'].encode('utf-8'), result['token'].encode('utf-8')):
|
|
72
|
+
raise AuthError('Invalid API token')
|
|
73
|
+
|
|
74
|
+
# Check if token is expired
|
|
75
|
+
if result['expires_at'] and result['expires_at'] < datetime.utcnow():
|
|
76
|
+
raise AuthError('API token has expired')
|
|
77
|
+
|
|
78
|
+
# Update last used timestamp
|
|
79
|
+
cur.execute("""
|
|
80
|
+
UPDATE api_tokens
|
|
81
|
+
SET last_used_at = %s
|
|
82
|
+
WHERE id = %s
|
|
83
|
+
""", (datetime.utcnow(), parsed['id']))
|
|
84
|
+
|
|
85
|
+
# Fetch roles
|
|
86
|
+
cur.execute("""
|
|
87
|
+
SELECT r.name FROM roles r
|
|
88
|
+
JOIN user_roles ur ON ur.role_id = r.id
|
|
89
|
+
WHERE ur.user_id = %s
|
|
90
|
+
""", (result['user_id'],))
|
|
91
|
+
roles = [row['name'] for row in cur.fetchall()]
|
|
92
|
+
|
|
93
|
+
# Construct user object
|
|
94
|
+
return {
|
|
95
|
+
'id': result['user_id'],
|
|
96
|
+
'username': result['username'],
|
|
97
|
+
'email': result['email'],
|
|
98
|
+
'real_name': result['real_name'],
|
|
99
|
+
'roles': roles
|
|
100
|
+
}
|
|
101
|
+
except ValueError:
|
|
102
|
+
raise AuthError('Invalid token format')
|
|
103
|
+
|
|
104
|
+
def _authenticate_request(self):
|
|
105
|
+
auth_header = request.headers.get('Authorization')
|
|
106
|
+
api_token = request.headers.get('X-API-Token')
|
|
107
|
+
|
|
108
|
+
if auth_header and auth_header.startswith('Bearer '):
|
|
109
|
+
# JWT authentication
|
|
110
|
+
token = self._extract_token_from_header()
|
|
111
|
+
return self.validate_token(token)
|
|
112
|
+
elif api_token:
|
|
113
|
+
# API token authentication
|
|
114
|
+
return self._validate_api_token(api_token)
|
|
115
|
+
else:
|
|
116
|
+
raise AuthError('No authentication provided', 401)
|
|
117
|
+
|
|
118
|
+
def require_auth(self, f):
|
|
119
|
+
@wraps(f)
|
|
120
|
+
def decorated(*args, **kwargs):
|
|
121
|
+
user = self._authenticate_request()
|
|
122
|
+
sig = inspect.signature(f)
|
|
123
|
+
if 'requesting_user' in sig.parameters:
|
|
124
|
+
kwargs['requesting_user'] = user
|
|
125
|
+
|
|
126
|
+
return f(*args, **kwargs)
|
|
127
|
+
return decorated
|
|
128
|
+
|
|
129
|
+
def init_app(self, app):
|
|
130
|
+
app.auth_manager = self
|
|
131
|
+
app.register_blueprint(self.create_blueprint())
|
|
132
|
+
|
|
133
|
+
def create_blueprint(self):
|
|
134
|
+
bp = Blueprint('auth', __name__, url_prefix='/v1/users')
|
|
135
|
+
|
|
136
|
+
@bp.route('/login', methods=['POST'])
|
|
137
|
+
@handle_auth_errors
|
|
138
|
+
def login():
|
|
139
|
+
data = request.get_json()
|
|
140
|
+
username = data.get('username')
|
|
141
|
+
password = data.get('password')
|
|
142
|
+
|
|
143
|
+
if not username or not password:
|
|
144
|
+
raise AuthError('Username and password required', 400)
|
|
145
|
+
|
|
146
|
+
with self.db.get_cursor() as cur:
|
|
147
|
+
cur.execute("SELECT * FROM users WHERE username = %s", (username,))
|
|
148
|
+
user = cur.fetchone()
|
|
149
|
+
|
|
150
|
+
if not user or not self._verify_password(password, user['password_hash']):
|
|
151
|
+
raise AuthError('Invalid username or password', 401)
|
|
152
|
+
|
|
153
|
+
# Fetch roles
|
|
154
|
+
cur.execute("""
|
|
155
|
+
SELECT r.name FROM roles r
|
|
156
|
+
JOIN user_roles ur ON ur.role_id = r.id
|
|
157
|
+
WHERE ur.user_id = %s
|
|
158
|
+
""", (user['id'],))
|
|
159
|
+
roles = [row['name'] for row in cur.fetchall()]
|
|
160
|
+
user['roles'] = roles
|
|
161
|
+
|
|
162
|
+
token = self._create_token(user)
|
|
163
|
+
refresh_token = self._create_refresh_token(user)
|
|
164
|
+
|
|
165
|
+
return jsonify({
|
|
166
|
+
'token': token,
|
|
167
|
+
'refresh_token': refresh_token,
|
|
168
|
+
'user': user
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
@bp.route('/login/oauth', methods=['POST'])
|
|
172
|
+
@handle_auth_errors
|
|
173
|
+
def oauth_login():
|
|
174
|
+
provider = request.json.get('provider')
|
|
175
|
+
if provider not in self.oauth_config:
|
|
176
|
+
raise AuthError('Invalid OAuth provider', 400)
|
|
177
|
+
|
|
178
|
+
redirect_uri = url_for('auth.oauth_callback', _external=True)
|
|
179
|
+
return jsonify({
|
|
180
|
+
'redirect_url': self._get_oauth_url(provider, redirect_uri)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
@bp.route('/login/oauth2callback')
|
|
184
|
+
@handle_auth_errors
|
|
185
|
+
def oauth_callback():
|
|
186
|
+
code = request.args.get('code')
|
|
187
|
+
provider = request.args.get('state')
|
|
188
|
+
|
|
189
|
+
if not code or not provider:
|
|
190
|
+
raise AuthError('Invalid OAuth callback', 400)
|
|
191
|
+
|
|
192
|
+
user_info = self._get_oauth_user_info(provider, code)
|
|
193
|
+
token = self._create_token(user_info)
|
|
194
|
+
refresh_token = self._create_refresh_token(user_info)
|
|
195
|
+
|
|
196
|
+
# Redirect to frontend with tokens
|
|
197
|
+
frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:5173')
|
|
198
|
+
return redirect(f"{frontend_url}/oauth-callback?token={token}&refresh_token={refresh_token}")
|
|
199
|
+
|
|
200
|
+
@bp.route('/login/profile')
|
|
201
|
+
@handle_auth_errors
|
|
202
|
+
def profile():
|
|
203
|
+
token = request.headers.get('Authorization', '').split(' ')[-1]
|
|
204
|
+
user = self.validate_token(token)
|
|
205
|
+
return jsonify(user)
|
|
206
|
+
|
|
207
|
+
@bp.route('/api-tokens', methods=['GET'])
|
|
208
|
+
@handle_auth_errors
|
|
209
|
+
@self.require_auth
|
|
210
|
+
def get_tokens(requesting_user):
|
|
211
|
+
tokens = self.get_user_api_tokens(requesting_user['id'])
|
|
212
|
+
return jsonify(tokens)
|
|
213
|
+
|
|
214
|
+
@bp.route('/api-tokens', methods=['POST'])
|
|
215
|
+
@handle_auth_errors
|
|
216
|
+
@self.require_auth
|
|
217
|
+
def create_token(requesting_user):
|
|
218
|
+
name = request.json.get('name')
|
|
219
|
+
expires_in_days = request.json.get('expires_in_days')
|
|
220
|
+
if not name:
|
|
221
|
+
raise AuthError('Token name is required', 400)
|
|
222
|
+
api_token = self.create_api_token(requesting_user['id'], name, expires_in_days)
|
|
223
|
+
return jsonify({
|
|
224
|
+
'id': api_token.id,
|
|
225
|
+
'name': api_token.name,
|
|
226
|
+
'token': api_token.get_full_token(),
|
|
227
|
+
'created_at': api_token.created_at,
|
|
228
|
+
'expires_at': api_token.expires_at
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
@bp.route('/token-refresh', methods=['POST'])
|
|
232
|
+
@handle_auth_errors
|
|
233
|
+
def refresh_token():
|
|
234
|
+
refresh_token = request.json.get('refresh_token')
|
|
235
|
+
if not refresh_token:
|
|
236
|
+
raise AuthError('No refresh token provided', 400)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
payload = jwt.decode(refresh_token, self.jwt_secret, algorithms=['HS256'])
|
|
240
|
+
user_id = payload['sub']
|
|
241
|
+
|
|
242
|
+
with self.db.get_cursor() as cur:
|
|
243
|
+
cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
|
244
|
+
user = cur.fetchone()
|
|
245
|
+
|
|
246
|
+
if not user:
|
|
247
|
+
raise AuthError('User not found', 404)
|
|
248
|
+
|
|
249
|
+
return jsonify({
|
|
250
|
+
'token': self._create_token(user),
|
|
251
|
+
'refresh_token': self._create_refresh_token(user)
|
|
252
|
+
})
|
|
253
|
+
except jwt.InvalidTokenError:
|
|
254
|
+
raise AuthError('Invalid refresh token', 401)
|
|
255
|
+
|
|
256
|
+
@bp.route('/api-tokens', methods=['POST'])
|
|
257
|
+
@handle_auth_errors
|
|
258
|
+
@self.require_auth
|
|
259
|
+
def create_api_token(requesting_user):
|
|
260
|
+
name = request.json.get('name')
|
|
261
|
+
if not name:
|
|
262
|
+
raise AuthError('Token name required', 400)
|
|
263
|
+
|
|
264
|
+
token = self.create_api_token(requesting_user['id'], name)
|
|
265
|
+
return jsonify({'token': token.token})
|
|
266
|
+
|
|
267
|
+
@bp.route('/api-tokens/validate', methods=['GET'])
|
|
268
|
+
@handle_auth_errors
|
|
269
|
+
@self.require_auth
|
|
270
|
+
def validate_api_token(requesting_user):
|
|
271
|
+
token = request.json.get('token')
|
|
272
|
+
if not token:
|
|
273
|
+
raise AuthError('No API token provided', 401)
|
|
274
|
+
token = ApiToken.parse_token_id(token)
|
|
275
|
+
|
|
276
|
+
with self.db.get_cursor() as cur:
|
|
277
|
+
cur.execute("""
|
|
278
|
+
SELECT * FROM api_tokens
|
|
279
|
+
WHERE user_id = %s AND id = %s
|
|
280
|
+
""", (requesting_user['id'], token))
|
|
281
|
+
api_token = cur.fetchone()
|
|
282
|
+
|
|
283
|
+
if not api_token:
|
|
284
|
+
raise AuthError('Invalid API token', 401)
|
|
285
|
+
|
|
286
|
+
# Check if token is expired
|
|
287
|
+
if api_token['expires_at'] and api_token['expires_at'] < datetime.utcnow():
|
|
288
|
+
raise AuthError('API token has expired', 401)
|
|
289
|
+
|
|
290
|
+
# Update last used timestamp
|
|
291
|
+
with self.db.get_cursor() as cur:
|
|
292
|
+
cur.execute("""
|
|
293
|
+
UPDATE api_tokens
|
|
294
|
+
SET last_used_at = %s
|
|
295
|
+
WHERE id = %s
|
|
296
|
+
""", (datetime.utcnow(), api_token['id']))
|
|
297
|
+
|
|
298
|
+
return jsonify({'valid': True})
|
|
299
|
+
|
|
300
|
+
@bp.route('/api-tokens', methods=['DELETE'])
|
|
301
|
+
@handle_auth_errors
|
|
302
|
+
@self.require_auth
|
|
303
|
+
def delete_api_token(requesting_user):
|
|
304
|
+
token = request.json.get('token')
|
|
305
|
+
if not token:
|
|
306
|
+
raise AuthError('Token required', 400)
|
|
307
|
+
token = ApiToken.parse_token_id(token)
|
|
308
|
+
|
|
309
|
+
with self.db.get_cursor() as cur:
|
|
310
|
+
cur.execute("""
|
|
311
|
+
DELETE FROM api_tokens
|
|
312
|
+
WHERE user_id = %s AND id = %s
|
|
313
|
+
RETURNING id
|
|
314
|
+
""", (requesting_user['id'], token))
|
|
315
|
+
deleted_id = cur.fetchone()
|
|
316
|
+
if not deleted_id:
|
|
317
|
+
raise ValueError('Token not found or already deleted')
|
|
318
|
+
|
|
319
|
+
return jsonify({'deleted': True})
|
|
320
|
+
|
|
321
|
+
@bp.route('/register', methods=['POST'])
|
|
322
|
+
@handle_auth_errors
|
|
323
|
+
def register():
|
|
324
|
+
data = request.get_json()
|
|
325
|
+
|
|
326
|
+
# Hash the password
|
|
327
|
+
password = data.get('password')
|
|
328
|
+
if not password:
|
|
329
|
+
raise AuthError('Password is required', 400)
|
|
330
|
+
|
|
331
|
+
salt = bcrypt.gensalt()
|
|
332
|
+
password_hash = bcrypt.hashpw(password.encode('utf-8'), salt)
|
|
333
|
+
|
|
334
|
+
user = User(
|
|
335
|
+
username=data['username'],
|
|
336
|
+
email=data['email'],
|
|
337
|
+
real_name=data['real_name'],
|
|
338
|
+
roles=data.get('roles', []),
|
|
339
|
+
id_generator=self.db.get_id_generator()
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
with self.db.get_cursor() as cur:
|
|
343
|
+
if user.id is None:
|
|
344
|
+
cur.execute("""
|
|
345
|
+
INSERT INTO users (username, email, real_name, password_hash, created_at, updated_at)
|
|
346
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
347
|
+
RETURNING id
|
|
348
|
+
""", (user.username, user.email, user.real_name, password_hash.decode('utf-8'),
|
|
349
|
+
user.created_at, user.updated_at))
|
|
350
|
+
user.id = cur.fetchone()['id']
|
|
351
|
+
else:
|
|
352
|
+
cur.execute("""
|
|
353
|
+
INSERT INTO users (id, username, email, real_name, password_hash, created_at, updated_at)
|
|
354
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
355
|
+
""", (user.id, user.username, user.email, user.real_name, password_hash.decode('utf-8'),
|
|
356
|
+
user.created_at, user.updated_at))
|
|
357
|
+
|
|
358
|
+
return jsonify({'id': user.id}), 201
|
|
359
|
+
|
|
360
|
+
@bp.route('/roles', methods=['GET'])
|
|
361
|
+
@handle_auth_errors
|
|
362
|
+
def get_roles():
|
|
363
|
+
with self.db.get_cursor() as cur:
|
|
364
|
+
cur.execute("SELECT * FROM roles")
|
|
365
|
+
roles = cur.fetchall()
|
|
366
|
+
return jsonify(roles)
|
|
367
|
+
|
|
368
|
+
return bp
|
|
369
|
+
|
|
370
|
+
def validate_token(self, token):
|
|
371
|
+
try:
|
|
372
|
+
logger.debug(f"Validating token: {token}")
|
|
373
|
+
payload = jwt.decode(token, self.jwt_secret, algorithms=['HS256'])
|
|
374
|
+
logger.debug(f"Token payload: {payload}")
|
|
375
|
+
user_id = int(payload['sub']) # Convert string ID back to integer
|
|
376
|
+
|
|
377
|
+
with self.db.get_cursor() as cur:
|
|
378
|
+
cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
|
|
379
|
+
user = cur.fetchone()
|
|
380
|
+
if not user:
|
|
381
|
+
logger.error(f"User not found for ID: {user_id}")
|
|
382
|
+
raise AuthError('User not found', 404)
|
|
383
|
+
# Fetch roles
|
|
384
|
+
cur.execute("""
|
|
385
|
+
SELECT r.name FROM roles r
|
|
386
|
+
JOIN user_roles ur ON ur.role_id = r.id
|
|
387
|
+
WHERE ur.user_id = %s
|
|
388
|
+
""", (user_id,))
|
|
389
|
+
roles = [row['name'] for row in cur.fetchall()]
|
|
390
|
+
user['roles'] = roles
|
|
391
|
+
|
|
392
|
+
return user
|
|
393
|
+
except jwt.InvalidTokenError as e:
|
|
394
|
+
logger.error(f"Invalid token error: {str(e)}")
|
|
395
|
+
raise AuthError('Invalid token', 401)
|
|
396
|
+
except Exception as e:
|
|
397
|
+
logger.error(f"Unexpected error during token validation: {str(e)}")
|
|
398
|
+
raise AuthError(str(e), 500)
|
|
399
|
+
|
|
400
|
+
def get_current_user(self):
|
|
401
|
+
return self._authenticate_request()
|
|
402
|
+
|
|
403
|
+
def get_user_api_tokens(self, user_id):
|
|
404
|
+
"""Get all API tokens for a user."""
|
|
405
|
+
with self.db.get_cursor() as cur:
|
|
406
|
+
cur.execute("""
|
|
407
|
+
SELECT id, name, created_at, expires_at, last_used_at
|
|
408
|
+
FROM api_tokens
|
|
409
|
+
WHERE user_id = %s
|
|
410
|
+
ORDER BY created_at DESC
|
|
411
|
+
""", (user_id,))
|
|
412
|
+
return cur.fetchall()
|
|
413
|
+
|
|
414
|
+
def create_api_token(self, user_id, name, expires_in_days=None):
|
|
415
|
+
"""Create a new API token for a user."""
|
|
416
|
+
token = ApiToken(user_id, name, expires_in_days)
|
|
417
|
+
|
|
418
|
+
with self.db.get_cursor() as cur:
|
|
419
|
+
cur.execute("""
|
|
420
|
+
INSERT INTO api_tokens (id, user_id, name, token, created_at, expires_at)
|
|
421
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
422
|
+
""", (token.id, token.user_id, token.name, token.token, token.created_at, token.expires_at))
|
|
423
|
+
return token
|
|
424
|
+
|
|
425
|
+
def _create_token(self, user):
|
|
426
|
+
payload = {
|
|
427
|
+
'sub': str(user['id']),
|
|
428
|
+
'exp': datetime.utcnow() + timedelta(hours=1),
|
|
429
|
+
'iat': datetime.utcnow()
|
|
430
|
+
}
|
|
431
|
+
logger.debug(f"Creating token with payload: {payload}")
|
|
432
|
+
token = jwt.encode(payload, self.jwt_secret, algorithm='HS256')
|
|
433
|
+
logger.debug(f"Created token: {token}")
|
|
434
|
+
return token
|
|
435
|
+
|
|
436
|
+
def _create_refresh_token(self, user):
|
|
437
|
+
payload = {
|
|
438
|
+
'sub': str(user['id']),
|
|
439
|
+
'exp': datetime.utcnow() + timedelta(days=30),
|
|
440
|
+
'iat': datetime.utcnow()
|
|
441
|
+
}
|
|
442
|
+
return jwt.encode(payload, self.jwt_secret, algorithm='HS256')
|
|
443
|
+
|
|
444
|
+
def _verify_password(self, password, password_hash):
|
|
445
|
+
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
|
|
446
|
+
|
|
447
|
+
def _get_oauth_url(self, provider, redirect_uri):
|
|
448
|
+
if provider == 'google':
|
|
449
|
+
client_id = self.oauth_config['google']['client_id']
|
|
450
|
+
scope = 'openid email profile'
|
|
451
|
+
state = provider # Pass provider as state for callback
|
|
452
|
+
return f'https://accounts.google.com/o/oauth2/v2/auth?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code&scope={scope}&state={state}'
|
|
453
|
+
raise AuthError('Invalid OAuth provider')
|
|
454
|
+
|
|
455
|
+
def _get_oauth_user_info(self, provider, code):
|
|
456
|
+
if provider == 'google':
|
|
457
|
+
client_id = self.oauth_config['google']['client_id']
|
|
458
|
+
client_secret = self.oauth_config['google']['client_secret']
|
|
459
|
+
redirect_uri = url_for('auth.oauth_callback', _external=True)
|
|
460
|
+
|
|
461
|
+
# Exchange code for tokens
|
|
462
|
+
token_url = 'https://oauth2.googleapis.com/token'
|
|
463
|
+
token_data = {
|
|
464
|
+
'client_id': client_id,
|
|
465
|
+
'client_secret': client_secret,
|
|
466
|
+
'code': code,
|
|
467
|
+
'grant_type': 'authorization_code',
|
|
468
|
+
'redirect_uri': redirect_uri
|
|
469
|
+
}
|
|
470
|
+
token_response = requests.post(token_url, data=token_data)
|
|
471
|
+
token_response.raise_for_status()
|
|
472
|
+
tokens = token_response.json()
|
|
473
|
+
|
|
474
|
+
# Get user info
|
|
475
|
+
userinfo_url = 'https://www.googleapis.com/oauth2/v3/userinfo'
|
|
476
|
+
userinfo_response = requests.get(
|
|
477
|
+
userinfo_url,
|
|
478
|
+
headers={'Authorization': f"Bearer {tokens['access_token']}"}
|
|
479
|
+
)
|
|
480
|
+
userinfo_response.raise_for_status()
|
|
481
|
+
userinfo = userinfo_response.json()
|
|
482
|
+
|
|
483
|
+
# Create or update user
|
|
484
|
+
with self.db.get_cursor() as cur:
|
|
485
|
+
cur.execute("SELECT * FROM users WHERE email = %s", (userinfo['email'],))
|
|
486
|
+
user = cur.fetchone()
|
|
487
|
+
|
|
488
|
+
if not user:
|
|
489
|
+
# Create new user
|
|
490
|
+
user = User(
|
|
491
|
+
username=userinfo['email'],
|
|
492
|
+
email=userinfo['email'],
|
|
493
|
+
real_name=userinfo.get('name', userinfo['email']),
|
|
494
|
+
id_generator=self.db.get_id_generator()
|
|
495
|
+
)
|
|
496
|
+
cur.execute("""
|
|
497
|
+
INSERT INTO users (id, username, email, real_name, created_at, updated_at)
|
|
498
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
499
|
+
""", (user.id, user.username, user.email, user.real_name,
|
|
500
|
+
user.created_at, user.updated_at))
|
|
501
|
+
user = {'id': user.id, 'username': user.username, 'email': user.email,
|
|
502
|
+
'real_name': user.real_name, 'roles': []}
|
|
503
|
+
else:
|
|
504
|
+
# Update existing user
|
|
505
|
+
cur.execute("""
|
|
506
|
+
UPDATE users
|
|
507
|
+
SET real_name = %s, updated_at = %s
|
|
508
|
+
WHERE email = %s
|
|
509
|
+
""", (userinfo.get('name', userinfo['email']), datetime.utcnow(), userinfo['email']))
|
|
510
|
+
user['real_name'] = userinfo.get('name', userinfo['email'])
|
|
511
|
+
|
|
512
|
+
return user
|
|
513
|
+
raise AuthError('Invalid OAuth provider')
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import psycopg2
|
|
2
|
+
from psycopg2.extras import RealDictCursor
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from .models import UUIDGenerator, IntegerGenerator
|
|
5
|
+
|
|
6
|
+
class Database:
|
|
7
|
+
def __init__(self, dsn, id_type='uuid'):
|
|
8
|
+
self.dsn = dsn
|
|
9
|
+
self.id_generator = UUIDGenerator() if id_type == 'uuid' else IntegerGenerator()
|
|
10
|
+
self.id_type = id_type
|
|
11
|
+
self._init_db()
|
|
12
|
+
|
|
13
|
+
def _init_db(self):
|
|
14
|
+
with self.get_connection() as conn:
|
|
15
|
+
with conn.cursor() as cur:
|
|
16
|
+
# Create users table with configurable ID type
|
|
17
|
+
cur.execute(f"""
|
|
18
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
19
|
+
id {self._get_id_type()} PRIMARY KEY,
|
|
20
|
+
username VARCHAR(255) UNIQUE NOT NULL,
|
|
21
|
+
email VARCHAR(255) UNIQUE NOT NULL,
|
|
22
|
+
real_name VARCHAR(255) NOT NULL,
|
|
23
|
+
password_hash VARCHAR(255),
|
|
24
|
+
created_at TIMESTAMP NOT NULL,
|
|
25
|
+
updated_at TIMESTAMP NOT NULL
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS roles (
|
|
29
|
+
id {self._get_id_type()} PRIMARY KEY,
|
|
30
|
+
name VARCHAR(255) UNIQUE NOT NULL,
|
|
31
|
+
description TEXT,
|
|
32
|
+
created_at TIMESTAMP NOT NULL
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS user_roles (
|
|
36
|
+
user_id {self._get_id_type()} REFERENCES users(id),
|
|
37
|
+
role_id {self._get_id_type()} REFERENCES roles(id),
|
|
38
|
+
PRIMARY KEY (user_id, role_id)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
42
|
+
id VARCHAR(8) PRIMARY KEY,
|
|
43
|
+
user_id INTEGER REFERENCES users(id),
|
|
44
|
+
name VARCHAR(255) NOT NULL,
|
|
45
|
+
token VARCHAR(255) NOT NULL,
|
|
46
|
+
created_at TIMESTAMP NOT NULL,
|
|
47
|
+
expires_at TIMESTAMP,
|
|
48
|
+
last_used_at TIMESTAMP
|
|
49
|
+
);
|
|
50
|
+
""")
|
|
51
|
+
|
|
52
|
+
def _get_id_type(self):
|
|
53
|
+
return 'UUID' if self.id_type == 'uuid' else 'SERIAL'
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def get_connection(self):
|
|
57
|
+
conn = psycopg2.connect(self.dsn, cursor_factory=RealDictCursor)
|
|
58
|
+
try:
|
|
59
|
+
yield conn
|
|
60
|
+
conn.commit()
|
|
61
|
+
except Exception:
|
|
62
|
+
conn.rollback()
|
|
63
|
+
raise
|
|
64
|
+
finally:
|
|
65
|
+
conn.close()
|
|
66
|
+
|
|
67
|
+
@contextmanager
|
|
68
|
+
def get_cursor(self):
|
|
69
|
+
with self.get_connection() as conn:
|
|
70
|
+
with conn.cursor() as cur:
|
|
71
|
+
yield cur
|
|
72
|
+
|
|
73
|
+
def get_id_generator(self):
|
|
74
|
+
return self.id_generator
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from flask import request, current_app, jsonify
|
|
3
|
+
from .exceptions import AuthError
|
|
4
|
+
|
|
5
|
+
def require_auth(roles=None):
|
|
6
|
+
def decorator(f):
|
|
7
|
+
@wraps(f)
|
|
8
|
+
def decorated(*args, **kwargs):
|
|
9
|
+
try:
|
|
10
|
+
# Get the require_auth decorator from AuthManager
|
|
11
|
+
user = current_app.auth_manager.get_current_user()
|
|
12
|
+
if not user:
|
|
13
|
+
raise AuthError('User not authenticated', 401)
|
|
14
|
+
|
|
15
|
+
auth_decorator = current_app.auth_manager.require_auth
|
|
16
|
+
|
|
17
|
+
# Apply the AuthManager's decorator and get the result
|
|
18
|
+
decorated_func = auth_decorator(f)
|
|
19
|
+
|
|
20
|
+
# Check roles if specified
|
|
21
|
+
if roles and not any(role in user['roles'] for role in roles):
|
|
22
|
+
raise AuthError('Insufficient permissions', 403)
|
|
23
|
+
|
|
24
|
+
# Now execute the function
|
|
25
|
+
return decorated_func(*args, **kwargs)
|
|
26
|
+
except AuthError as e:
|
|
27
|
+
response = jsonify(e.to_dict())
|
|
28
|
+
response.status_code = e.status_code
|
|
29
|
+
return response
|
|
30
|
+
return decorated
|
|
31
|
+
return decorator
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class AuthError(Exception):
|
|
2
|
+
def __init__(self, message, status_code=401):
|
|
3
|
+
self.message = message
|
|
4
|
+
self.status_code = status_code
|
|
5
|
+
super().__init__(self.message)
|
|
6
|
+
|
|
7
|
+
def to_dict(self):
|
|
8
|
+
return {
|
|
9
|
+
'error': self.message,
|
|
10
|
+
'status_code': self.status_code
|
|
11
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
import uuid
|
|
3
|
+
import bcrypt
|
|
4
|
+
import secrets
|
|
5
|
+
import string
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
def generate_random_string(length, alphabet=string.ascii_letters + string.digits):
|
|
9
|
+
"""Generate a random string of specified length using the given alphabet."""
|
|
10
|
+
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
|
11
|
+
|
|
12
|
+
class IDGenerator(ABC):
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def generate(self):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
class UUIDGenerator(IDGenerator):
|
|
18
|
+
def generate(self):
|
|
19
|
+
return str(uuid.uuid4())
|
|
20
|
+
|
|
21
|
+
class IntegerGenerator(IDGenerator):
|
|
22
|
+
def generate(self):
|
|
23
|
+
return None # Let the database handle ID generation with SERIAL
|
|
24
|
+
|
|
25
|
+
class User:
|
|
26
|
+
def __init__(self, username, email, real_name, roles=None, id_generator=None):
|
|
27
|
+
self.id = id_generator.generate() if id_generator else str(uuid.uuid4())
|
|
28
|
+
if self.id is None: # Let database handle ID generation
|
|
29
|
+
self.id = None
|
|
30
|
+
self.username = username
|
|
31
|
+
self.email = email
|
|
32
|
+
self.real_name = real_name
|
|
33
|
+
self.roles = roles or []
|
|
34
|
+
self.created_at = datetime.utcnow()
|
|
35
|
+
self.updated_at = datetime.utcnow()
|
|
36
|
+
|
|
37
|
+
class Role:
|
|
38
|
+
def __init__(self, name, description=None, id_generator=None):
|
|
39
|
+
self.id = id_generator.generate() if id_generator else str(uuid.uuid4())
|
|
40
|
+
self.name = name
|
|
41
|
+
self.description = description
|
|
42
|
+
self.created_at = datetime.utcnow()
|
|
43
|
+
|
|
44
|
+
class ApiToken:
|
|
45
|
+
def __init__(self, user_id, name, expires_in_days=None):
|
|
46
|
+
self.id = generate_random_string(8) # 8 character ID
|
|
47
|
+
self.user_id = user_id
|
|
48
|
+
self.name = name
|
|
49
|
+
self.nonce = generate_random_string(32) # 32 character nonce
|
|
50
|
+
self.token = self._hash_nonce(self.nonce) # Hash the nonce
|
|
51
|
+
self.created_at = datetime.utcnow()
|
|
52
|
+
self.expires_at = datetime.utcnow() + timedelta(days=expires_in_days) if expires_in_days else None
|
|
53
|
+
self.last_used_at = None
|
|
54
|
+
|
|
55
|
+
def _hash_nonce(self, nonce):
|
|
56
|
+
"""Hash the nonce using bcrypt."""
|
|
57
|
+
salt = bcrypt.gensalt()
|
|
58
|
+
return bcrypt.hashpw(nonce.encode('utf-8'), salt).decode('utf-8')
|
|
59
|
+
|
|
60
|
+
def get_full_token(self):
|
|
61
|
+
"""Get the full token string in the format api_IDNONCE."""
|
|
62
|
+
return f"api_{self.id}{self.nonce}"
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def parse_token(token_string):
|
|
66
|
+
"""Parse a token string into its components."""
|
|
67
|
+
if not token_string.startswith('api_'):
|
|
68
|
+
raise ValueError('Invalid token format')
|
|
69
|
+
|
|
70
|
+
token_string = token_string[4:] # Remove 'api_' prefix
|
|
71
|
+
if len(token_string) != 40: # 8 (id) + 32 (nonce)
|
|
72
|
+
raise ValueError('Invalid token length')
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
'id': token_string[:8],
|
|
76
|
+
'nonce': token_string[8:]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def parse_token_id(token_string):
|
|
81
|
+
if len(token_string) == 8:
|
|
82
|
+
return token_string
|
|
83
|
+
if not token_string.startswith('api_'):
|
|
84
|
+
raise ValueError('Invalid token format')
|
|
85
|
+
return token_string[4:][:8]
|
|
86
|
+
|
|
87
|
+
def verify_token(self, token_string):
|
|
88
|
+
"""Verify if a token string matches this token."""
|
|
89
|
+
try:
|
|
90
|
+
parsed = self.parse_token(token_string)
|
|
91
|
+
if parsed['id'] != self.id:
|
|
92
|
+
return False
|
|
93
|
+
return bcrypt.checkpw(parsed['nonce'].encode('utf-8'), self.token.encode('utf-8'))
|
|
94
|
+
except ValueError:
|
|
95
|
+
return False
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: the37lab_authlib
|
|
3
|
+
Version: 0.1.1749238112
|
|
4
|
+
Summary: Python SDK for the Authlib
|
|
5
|
+
Author-email: the37lab <info@the37lab.com>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: Other/Proprietary License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: flask
|
|
12
|
+
Requires-Dist: psycopg2-binary
|
|
13
|
+
Requires-Dist: pyjwt
|
|
14
|
+
Requires-Dist: python-dotenv
|
|
15
|
+
Requires-Dist: requests
|
|
16
|
+
Requires-Dist: authlib
|
|
17
|
+
Requires-Dist: bcrypt
|
|
18
|
+
|
|
19
|
+
# AuthLib
|
|
20
|
+
|
|
21
|
+
A Python authentication library that provides JWT, OAuth2, and API token authentication with PostgreSQL backend.
|
|
22
|
+
|
|
23
|
+
## Table of Contents
|
|
24
|
+
- [Installation](#installation)
|
|
25
|
+
- [Quick Start](#quick-start)
|
|
26
|
+
- [Configuration](#configuration)
|
|
27
|
+
- [API Endpoints](#api-endpoints)
|
|
28
|
+
- [Development](#development)
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install -e .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from flask import Flask
|
|
40
|
+
from authlib import AuthManager
|
|
41
|
+
|
|
42
|
+
app = Flask(__name__)
|
|
43
|
+
|
|
44
|
+
auth = AuthManager(
|
|
45
|
+
app=app,
|
|
46
|
+
db_dsn="postgresql://user:pass@localhost/dbname",
|
|
47
|
+
jwt_secret="your-secret-key",
|
|
48
|
+
oauth_config={
|
|
49
|
+
"google": {
|
|
50
|
+
"client_id": "your-client-id",
|
|
51
|
+
"client_secret": "your-client-secret"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@app.route("/protected")
|
|
57
|
+
@auth.require_auth(roles=["admin"])
|
|
58
|
+
def protected_route():
|
|
59
|
+
return "Protected content"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
### Required Parameters
|
|
65
|
+
- `app`: Flask application instance
|
|
66
|
+
- `db_dsn`: PostgreSQL connection string
|
|
67
|
+
- `jwt_secret`: Secret key for JWT signing
|
|
68
|
+
|
|
69
|
+
### Optional Parameters
|
|
70
|
+
- `oauth_config`: Dictionary of OAuth provider configurations
|
|
71
|
+
- `token_expiry`: JWT token expiry time in seconds (default: 3600)
|
|
72
|
+
- `refresh_token_expiry`: Refresh token expiry time in seconds (default: 2592000)
|
|
73
|
+
|
|
74
|
+
## API Endpoints
|
|
75
|
+
|
|
76
|
+
### Authentication
|
|
77
|
+
- `POST /v1/users/login` - Login with username/password
|
|
78
|
+
- `POST /v1/users/login/oauth` - Get OAuth redirect URL
|
|
79
|
+
- `GET /v1/users/login/oauth2callback` - OAuth callback
|
|
80
|
+
- `POST /v1/users/token-refresh` - Refresh JWT token
|
|
81
|
+
|
|
82
|
+
### User Management
|
|
83
|
+
- `POST /v1/users/register` - Register new user
|
|
84
|
+
- `GET /v1/users/login/profile` - Get user profile
|
|
85
|
+
- `GET /v1/users/roles` - Get available roles
|
|
86
|
+
|
|
87
|
+
### API Tokens
|
|
88
|
+
- `POST /v1/users/{user}/api-tokens` - Create API token
|
|
89
|
+
- `GET /v1/users/{user}/api-tokens` - List API tokens
|
|
90
|
+
- `DELETE /v1/users/{user}/api-tokens/{token_id}` - Delete API token
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
### Setup
|
|
95
|
+
1. Clone the repository
|
|
96
|
+
2. Create virtual environment:
|
|
97
|
+
```bash
|
|
98
|
+
python -m venv venv
|
|
99
|
+
venv\Scripts\activate
|
|
100
|
+
```
|
|
101
|
+
3. Install dependencies:
|
|
102
|
+
```bash
|
|
103
|
+
pip install -e ".[dev]"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Database Setup
|
|
107
|
+
```bash
|
|
108
|
+
createdb authlib
|
|
109
|
+
python -m authlib.cli db init
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Running Tests
|
|
113
|
+
```bash
|
|
114
|
+
pytest
|
|
115
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/the37lab_authlib/__init__.py
|
|
4
|
+
src/the37lab_authlib/auth.py
|
|
5
|
+
src/the37lab_authlib/db.py
|
|
6
|
+
src/the37lab_authlib/decorators.py
|
|
7
|
+
src/the37lab_authlib/exceptions.py
|
|
8
|
+
src/the37lab_authlib/models.py
|
|
9
|
+
src/the37lab_authlib.egg-info/PKG-INFO
|
|
10
|
+
src/the37lab_authlib.egg-info/SOURCES.txt
|
|
11
|
+
src/the37lab_authlib.egg-info/dependency_links.txt
|
|
12
|
+
src/the37lab_authlib.egg-info/requires.txt
|
|
13
|
+
src/the37lab_authlib.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
the37lab_authlib
|