Skip to content

Commit 7515325

Browse files
committed
big overhaul of REST API, split into auth, core, and cli methods
1 parent e5aba0d commit 7515325

20 files changed

+788
-263
lines changed

archivebox/api/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__package__ = 'archivebox.api'

archivebox/api/apps.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
__package__ = 'archivebox.api'
2+
13
from django.apps import AppConfig
24

35

archivebox/api/archive.py

-184
This file was deleted.

archivebox/api/auth.py

+92-33
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,107 @@
1+
__package__ = 'archivebox.api'
2+
3+
from typing import Optional
4+
5+
from django.http import HttpRequest
6+
from django.contrib.auth import login
17
from django.contrib.auth import authenticate
2-
from ninja import Form, Router, Schema
3-
from ninja.security import HttpBearer
8+
from django.contrib.auth.models import AbstractBaseUser
49

5-
from api.models import Token
10+
from ninja.security import HttpBearer, APIKeyQuery, APIKeyHeader, HttpBasicAuth, django_auth_superuser
611

7-
router = Router()
812

13+
def auth_using_token(token, request: Optional[HttpRequest]=None) -> Optional[AbstractBaseUser]:
14+
"""Given an API token string, check if a corresponding non-expired APIToken exists, and return its user"""
15+
from api.models import APIToken # lazy import model to avoid loading it at urls.py import time
16+
17+
user = None
918

10-
class GlobalAuth(HttpBearer):
11-
def authenticate(self, request, token):
19+
submitted_empty_form = token in ('string', '', None)
20+
if submitted_empty_form:
21+
user = request.user # see if user is authed via django session and use that as the default
22+
else:
1223
try:
13-
return Token.objects.get(token=token).user
14-
except Token.DoesNotExist:
24+
token = APIToken.objects.get(token=token)
25+
if token.is_valid():
26+
user = token.user
27+
except APIToken.DoesNotExist:
1528
pass
1629

30+
if not user:
31+
print('[❌] Failed to authenticate API user using API Key:', request)
1732

18-
class AuthSchema(Schema):
19-
email: str
20-
password: str
21-
33+
return None
2234

23-
@router.post("/authenticate", auth=None) # overriding global auth
24-
def get_token(request, auth_data: AuthSchema):
25-
user = authenticate(username=auth_data.email, password=auth_data.password)
26-
if user:
27-
# Assuming a user can have multiple tokens and you want to create a new one every time
28-
new_token = Token.objects.create(user=user)
29-
return {"token": new_token.token, "expires": new_token.expiry_as_iso8601}
35+
def auth_using_password(username, password, request: Optional[HttpRequest]=None) -> Optional[AbstractBaseUser]:
36+
"""Given a username and password, check if they are valid and return the corresponding user"""
37+
user = None
38+
39+
submitted_empty_form = (username, password) in (('string', 'string'), ('', ''), (None, None))
40+
if submitted_empty_form:
41+
user = request.user # see if user is authed via django session and use that as the default
3042
else:
31-
return {"error": "Invalid credentials"}
43+
user = authenticate(
44+
username=username,
45+
password=password,
46+
)
47+
48+
if not user:
49+
print('[❌] Failed to authenticate API user using API Key:', request)
50+
51+
return user
52+
53+
54+
### Base Auth Types
55+
56+
class APITokenAuthCheck:
57+
"""The base class for authentication methods that use an api.models.APIToken"""
58+
def authenticate(self, request: HttpRequest, key: Optional[str]=None) -> Optional[AbstractBaseUser]:
59+
user = auth_using_token(
60+
token=key,
61+
request=request,
62+
)
63+
if user is not None:
64+
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
65+
return user
66+
67+
class UserPassAuthCheck:
68+
"""The base class for authentication methods that use a username & password"""
69+
def authenticate(self, request: HttpRequest, username: Optional[str]=None, password: Optional[str]=None) -> Optional[AbstractBaseUser]:
70+
user = auth_using_password(
71+
username=username,
72+
password=password,
73+
request=request,
74+
)
75+
if user is not None:
76+
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
77+
return user
78+
79+
80+
### Django-Ninja-Provided Auth Methods
81+
82+
class UsernameAndPasswordAuth(UserPassAuthCheck, HttpBasicAuth):
83+
"""Allow authenticating by passing username & password via HTTP Basic Authentication (not recommended)"""
84+
pass
85+
86+
class QueryParamTokenAuth(APITokenAuthCheck, APIKeyQuery):
87+
"""Allow authenticating by passing api_key=xyz as a GET/POST query parameter"""
88+
param_name = "api_key"
89+
90+
class HeaderTokenAuth(APITokenAuthCheck, APIKeyHeader):
91+
"""Allow authenticating by passing X-API-Key=xyz as a request header"""
92+
param_name = "X-API-Key"
3293

94+
class BearerTokenAuth(APITokenAuthCheck, HttpBearer):
95+
"""Allow authenticating by passing Bearer=xyz as a request header"""
96+
pass
3397

34-
class TokenValidationSchema(Schema):
35-
token: str
3698

99+
### Enabled Auth Methods
37100

38-
@router.post("/validate_token", auth=None) # No authentication required for this endpoint
39-
def validate_token(request, token_data: TokenValidationSchema):
40-
try:
41-
# Attempt to authenticate using the provided token
42-
user = GlobalAuth().authenticate(request, token_data.token)
43-
if user:
44-
return {"status": "valid"}
45-
else:
46-
return {"status": "invalid"}
47-
except Token.DoesNotExist:
48-
return {"status": "invalid"}
101+
API_AUTH_METHODS = [
102+
QueryParamTokenAuth(),
103+
HeaderTokenAuth(),
104+
BearerTokenAuth(),
105+
django_auth_superuser,
106+
UsernameAndPasswordAuth(),
107+
]
+7-6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
# Generated by Django 3.1.14 on 2024-04-09 18:52
1+
# Generated by Django 4.2.11 on 2024-04-25 04:19
22

33
import api.models
44
from django.conf import settings
55
from django.db import migrations, models
66
import django.db.models.deletion
7+
import uuid
78

89

910
class Migration(migrations.Migration):
@@ -16,13 +17,13 @@ class Migration(migrations.Migration):
1617

1718
operations = [
1819
migrations.CreateModel(
19-
name='Token',
20+
name='APIToken',
2021
fields=[
21-
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22-
('token', models.CharField(default=auth.models.hex_uuid, max_length=32, unique=True)),
22+
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
23+
('token', models.CharField(default=api.models.generate_secret_token, max_length=32, unique=True)),
2324
('created', models.DateTimeField(auto_now_add=True)),
24-
('expiry', models.DateTimeField(blank=True, null=True)),
25-
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)),
25+
('expires', models.DateTimeField(blank=True, null=True)),
26+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
2627
],
2728
),
2829
]

0 commit comments

Comments
 (0)