first version
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
|
||||
from accounts.models import ApiToken, User
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class OsirisUserAdmin(UserAdmin):
|
||||
pass
|
||||
|
||||
|
||||
@admin.register(ApiToken)
|
||||
class ApiTokenAdmin(admin.ModelAdmin):
|
||||
"""Tokens are created by the Flutter app via /api/auth/login; this is for revoking."""
|
||||
|
||||
list_display = ["name", "user", "key", "created_at", "last_used_at"]
|
||||
readonly_fields = ["key", "created_at", "last_used_at"]
|
||||
list_filter = ["user"]
|
||||
@@ -0,0 +1,50 @@
|
||||
from django.contrib.auth import authenticate
|
||||
from django.http import HttpRequest
|
||||
from ninja import Router, Schema
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from accounts.auth import AUTH
|
||||
from accounts.models import ApiToken
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class LoginIn(Schema):
|
||||
username: str
|
||||
password: str
|
||||
device_name: str = ""
|
||||
|
||||
|
||||
class TokenOut(Schema):
|
||||
token: str
|
||||
username: str
|
||||
|
||||
|
||||
class UserOut(Schema):
|
||||
username: str
|
||||
is_staff: bool
|
||||
|
||||
|
||||
@router.post("/login", response=TokenOut, auth=None)
|
||||
def login(request: HttpRequest, payload: LoginIn):
|
||||
"""Exchange username/password for a bearer token. There is no registration."""
|
||||
user = authenticate(request, username=payload.username, password=payload.password)
|
||||
if user is None:
|
||||
raise HttpError(401, "Invalid username or password.")
|
||||
|
||||
token = ApiToken.objects.create(user=user, name=payload.device_name)
|
||||
return {"token": token.key, "username": user.username}
|
||||
|
||||
|
||||
@router.post("/logout", auth=AUTH)
|
||||
def logout(request: HttpRequest):
|
||||
"""Revoke the token used for this request. Session logins are unaffected."""
|
||||
token = request.auth
|
||||
if isinstance(token, ApiToken):
|
||||
token.delete()
|
||||
return {"detail": "Logged out."}
|
||||
|
||||
|
||||
@router.get("/me", response=UserOut, auth=AUTH)
|
||||
def me(request: HttpRequest):
|
||||
return request.user
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "accounts"
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Optional
|
||||
|
||||
from django.http import HttpRequest
|
||||
from ninja.security import HttpBearer, django_auth
|
||||
|
||||
from accounts.models import ApiToken
|
||||
|
||||
|
||||
class TokenAuth(HttpBearer):
|
||||
"""`Authorization: Bearer <key>` — how the Flutter app authenticates."""
|
||||
|
||||
def authenticate(self, request: HttpRequest, token: str) -> Optional[ApiToken]:
|
||||
try:
|
||||
api_token = ApiToken.objects.select_related("user").get(key=token)
|
||||
except ApiToken.DoesNotExist:
|
||||
return None
|
||||
|
||||
if not api_token.user.is_active:
|
||||
return None
|
||||
|
||||
api_token.touch()
|
||||
# Downstream views read request.user, same as with session auth.
|
||||
request.user = api_token.user
|
||||
return api_token
|
||||
|
||||
|
||||
# Bearer first: a request carrying a token never reaches the cookie auth, which
|
||||
# would reject it for a missing CSRF token.
|
||||
AUTH = [TokenAuth(), django_auth]
|
||||
@@ -0,0 +1,169 @@
|
||||
# Generated by Django 6.0.7 on 2026-07-14 18:04
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"abstract": False,
|
||||
},
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ApiToken",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("key", models.CharField(editable=False, max_length=64, unique=True)),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Which device this token is for.",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("last_used_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="api_tokens",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""Plain Django user. Accounts are created with `manage.py createsuperuser`."""
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
|
||||
class ApiToken(models.Model):
|
||||
"""Bearer token for the Flutter app (the web admin uses session auth)."""
|
||||
|
||||
key = models.CharField(max_length=64, unique=True, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="api_tokens"
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=100, blank=True, help_text="Which device this token is for."
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} — {self.name or 'unnamed device'}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.key:
|
||||
self.key = secrets.token_urlsafe(32)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def touch(self):
|
||||
self.last_used_at = timezone.now()
|
||||
self.save(update_fields=["last_used_at"])
|
||||
Reference in New Issue
Block a user