softauth 0.1.0__py3-none-any.whl
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.
- softauth/__init__.py +73 -0
- softauth/cli/__init__.py +3 -0
- softauth/cli/commands.py +233 -0
- softauth/core/__init__.py +35 -0
- softauth/core/auth.py +183 -0
- softauth/core/config.py +70 -0
- softauth/core/exceptions.py +67 -0
- softauth/database/__init__.py +5 -0
- softauth/database/models.py +58 -0
- softauth/database/repository.py +46 -0
- softauth/database/session.py +66 -0
- softauth/django/__init__.py +3 -0
- softauth/django/adapter.py +77 -0
- softauth/django/decorators.py +68 -0
- softauth/django/middleware.py +59 -0
- softauth/django/urls.py +36 -0
- softauth/django/views.py +139 -0
- softauth/fastapi/__init__.py +6 -0
- softauth/fastapi/adapter.py +55 -0
- softauth/fastapi/dependencies.py +107 -0
- softauth/fastapi/middleware.py +54 -0
- softauth/fastapi/routes.py +147 -0
- softauth/flask/__init__.py +6 -0
- softauth/flask/adapter.py +63 -0
- softauth/flask/decorators.py +73 -0
- softauth/flask/middleware.py +39 -0
- softauth/flask/routes.py +118 -0
- softauth/interfaces/__init__.py +12 -0
- softauth/interfaces/adapter.py +48 -0
- softauth/interfaces/auth_provider.py +34 -0
- softauth/interfaces/token_store.py +37 -0
- softauth/interfaces/user_store.py +39 -0
- softauth/jwt/__init__.py +4 -0
- softauth/jwt/handler.py +99 -0
- softauth/jwt/schemas.py +37 -0
- softauth/security/__init__.py +3 -0
- softauth/security/password.py +32 -0
- softauth-0.1.0.dist-info/METADATA +307 -0
- softauth-0.1.0.dist-info/RECORD +43 -0
- softauth-0.1.0.dist-info/WHEEL +5 -0
- softauth-0.1.0.dist-info/entry_points.txt +2 -0
- softauth-0.1.0.dist-info/licenses/LICENSE +21 -0
- softauth-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Default SQLAlchemy 2.0 user model.
|
|
2
|
+
|
|
3
|
+
Developers can replace this model by implementing the UserStore interface.
|
|
4
|
+
No framework imports; pure SQLAlchemy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from sqlalchemy import Boolean, DateTime, String
|
|
14
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Base(DeclarativeBase):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class User(Base):
|
|
22
|
+
"""Default user table created by ``auth.init_db()``."""
|
|
23
|
+
|
|
24
|
+
__tablename__ = "softauth_users"
|
|
25
|
+
|
|
26
|
+
id: Mapped[str] = mapped_column(
|
|
27
|
+
String(36),
|
|
28
|
+
primary_key=True,
|
|
29
|
+
default=lambda: str(uuid.uuid4()),
|
|
30
|
+
)
|
|
31
|
+
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
|
32
|
+
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
33
|
+
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
34
|
+
role: Mapped[str] = mapped_column(String(50), default="user", nullable=False)
|
|
35
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
36
|
+
DateTime(timezone=True),
|
|
37
|
+
default=lambda: datetime.now(timezone.utc),
|
|
38
|
+
nullable=False,
|
|
39
|
+
)
|
|
40
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
41
|
+
DateTime(timezone=True),
|
|
42
|
+
default=lambda: datetime.now(timezone.utc),
|
|
43
|
+
onupdate=lambda: datetime.now(timezone.utc),
|
|
44
|
+
nullable=False,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, Any]:
|
|
48
|
+
return {
|
|
49
|
+
"id": self.id,
|
|
50
|
+
"email": self.email,
|
|
51
|
+
"is_active": self.is_active,
|
|
52
|
+
"role": self.role,
|
|
53
|
+
"created_at": self.created_at.isoformat(),
|
|
54
|
+
"updated_at": self.updated_at.isoformat(),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def __repr__(self) -> str:
|
|
58
|
+
return f"<User id={self.id!r} email={self.email!r} role={self.role!r}>"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""UserRepository — synchronous CRUD over the default User model.
|
|
2
|
+
|
|
3
|
+
No framework imports. Accepts an open SQLAlchemy Session and performs
|
|
4
|
+
operations within the caller's transaction boundary.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from sqlalchemy.orm import Session
|
|
12
|
+
|
|
13
|
+
from softauth.core.exceptions import UserAlreadyExistsError
|
|
14
|
+
from softauth.database.models import User
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UserRepository:
|
|
18
|
+
"""Data-access object for the softauth_users table."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, session: Session) -> None:
|
|
21
|
+
self._s = session
|
|
22
|
+
|
|
23
|
+
def get_by_id(self, user_id: str) -> Optional[User]:
|
|
24
|
+
return self._s.get(User, user_id)
|
|
25
|
+
|
|
26
|
+
def get_by_email(self, email: str) -> Optional[User]:
|
|
27
|
+
return self._s.query(User).filter(User.email == email).first()
|
|
28
|
+
|
|
29
|
+
def create(self, email: str, hashed_password: str, role: str = "user") -> User:
|
|
30
|
+
if self.get_by_email(email) is not None:
|
|
31
|
+
raise UserAlreadyExistsError(f"A user with email '{email}' already exists.")
|
|
32
|
+
user = User(email=email, hashed_password=hashed_password, role=role)
|
|
33
|
+
self._s.add(user)
|
|
34
|
+
self._s.flush() # populate user.id before the context manager commits
|
|
35
|
+
return user
|
|
36
|
+
|
|
37
|
+
def update(self, user: User) -> User:
|
|
38
|
+
self._s.add(user)
|
|
39
|
+
return user
|
|
40
|
+
|
|
41
|
+
def deactivate(self, user_id: str) -> Optional[User]:
|
|
42
|
+
user = self.get_by_id(user_id)
|
|
43
|
+
if user:
|
|
44
|
+
user.is_active = False
|
|
45
|
+
self._s.add(user)
|
|
46
|
+
return user
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""SQLAlchemy session factory.
|
|
2
|
+
|
|
3
|
+
Wraps engine + SessionLocal into a single injectable object.
|
|
4
|
+
No framework imports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from typing import Generator
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import create_engine
|
|
13
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
14
|
+
from sqlalchemy.pool import StaticPool
|
|
15
|
+
|
|
16
|
+
from softauth.database.models import Base
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DatabaseSession:
|
|
20
|
+
"""Thin wrapper around an SQLAlchemy engine and session factory.
|
|
21
|
+
|
|
22
|
+
``expire_on_commit=False`` is intentional: detached User objects returned
|
|
23
|
+
from session contexts remain usable (all columns already loaded in memory).
|
|
24
|
+
|
|
25
|
+
For ``sqlite:///:memory:``, ``StaticPool`` is used so all connections share
|
|
26
|
+
the same in-memory database — essential for testing in threaded environments
|
|
27
|
+
such as FastAPI's TestClient.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, database_url: str) -> None:
|
|
31
|
+
connect_args: dict[str, object] = {}
|
|
32
|
+
kwargs: dict[str, object] = {}
|
|
33
|
+
|
|
34
|
+
if database_url.startswith("sqlite"):
|
|
35
|
+
connect_args["check_same_thread"] = False
|
|
36
|
+
if ":memory:" in database_url:
|
|
37
|
+
kwargs["poolclass"] = StaticPool
|
|
38
|
+
|
|
39
|
+
self._engine = create_engine(database_url, connect_args=connect_args, **kwargs) # type: ignore[arg-type]
|
|
40
|
+
self._factory = sessionmaker(
|
|
41
|
+
bind=self._engine,
|
|
42
|
+
autocommit=False,
|
|
43
|
+
autoflush=False,
|
|
44
|
+
expire_on_commit=False,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def create_tables(self) -> None:
|
|
48
|
+
"""Create all softauth tables (idempotent)."""
|
|
49
|
+
Base.metadata.create_all(self._engine)
|
|
50
|
+
|
|
51
|
+
def drop_tables(self) -> None:
|
|
52
|
+
"""Drop all softauth tables. Useful in tests."""
|
|
53
|
+
Base.metadata.drop_all(self._engine)
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def session(self) -> Generator[Session, None, None]:
|
|
57
|
+
"""Yield a transactional session; commit on success, rollback on error."""
|
|
58
|
+
s = self._factory()
|
|
59
|
+
try:
|
|
60
|
+
yield s
|
|
61
|
+
s.commit()
|
|
62
|
+
except Exception:
|
|
63
|
+
s.rollback()
|
|
64
|
+
raise
|
|
65
|
+
finally:
|
|
66
|
+
s.close()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Django adapter — implements BaseAdapter for Django applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
6
|
+
|
|
7
|
+
from softauth.core.config import SoftAuthConfig
|
|
8
|
+
from softauth.interfaces.adapter import BaseAdapter
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from softauth.core.auth import SoftAuth
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DjangoAdapter(BaseAdapter):
|
|
15
|
+
"""Wires SoftAuth into a Django application.
|
|
16
|
+
|
|
17
|
+
Responsibilities:
|
|
18
|
+
- Configures ``softauth.django.middleware.SoftAuthMiddleware`` via a
|
|
19
|
+
module-level singleton (called in ``on_startup()``).
|
|
20
|
+
- Appends auto-generated auth URL patterns to the provided ``urlpatterns``
|
|
21
|
+
list in ``init_app(urlpatterns)``.
|
|
22
|
+
- Exposes ``login_required``, ``admin_required``, and ``require_role()``
|
|
23
|
+
as standard Django view decorators.
|
|
24
|
+
|
|
25
|
+
Usage::
|
|
26
|
+
|
|
27
|
+
# settings.py
|
|
28
|
+
MIDDLEWARE = [
|
|
29
|
+
...
|
|
30
|
+
"softauth.django.middleware.SoftAuthMiddleware",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# urls.py
|
|
34
|
+
urlpatterns = []
|
|
35
|
+
auth.init_app(urlpatterns) # appends /auth/* patterns
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, auth: "SoftAuth", config: SoftAuthConfig) -> None:
|
|
39
|
+
self._auth = auth
|
|
40
|
+
self._config = config
|
|
41
|
+
|
|
42
|
+
from softauth.django.decorators import DecoratorFactory
|
|
43
|
+
self._dec = DecoratorFactory(auth, config)
|
|
44
|
+
|
|
45
|
+
def on_startup(self) -> None:
|
|
46
|
+
"""Configure the middleware singleton with this adapter's JWT handler."""
|
|
47
|
+
from softauth.django import middleware as mw
|
|
48
|
+
mw.configure(self._auth.jwt)
|
|
49
|
+
|
|
50
|
+
def init_app(self, urlpatterns: Any) -> None:
|
|
51
|
+
"""Append softauth URL patterns to *urlpatterns* (a Django ``urlpatterns`` list)."""
|
|
52
|
+
from softauth.django.urls import create_auth_urlpatterns
|
|
53
|
+
urlpatterns += create_auth_urlpatterns(self._auth, self._config)
|
|
54
|
+
|
|
55
|
+
# ── BaseAdapter ────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def get_current_user_dependency(self) -> Callable[..., Any]:
|
|
58
|
+
return self._dec.login_required
|
|
59
|
+
|
|
60
|
+
def get_current_admin_dependency(self) -> Callable[..., Any]:
|
|
61
|
+
return self._dec.admin_required
|
|
62
|
+
|
|
63
|
+
def get_require_role_dependency(self, role: str) -> Callable[..., Any]:
|
|
64
|
+
return self._dec.require_role(role)
|
|
65
|
+
|
|
66
|
+
# ── Shortcut properties used by SoftAuth ───────────────────────────────────
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def login_required(self) -> Callable[..., Any]:
|
|
70
|
+
return self._dec.login_required
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def admin_required(self) -> Callable[..., Any]:
|
|
74
|
+
return self._dec.admin_required
|
|
75
|
+
|
|
76
|
+
def require_role(self, role: str) -> Callable[..., Any]:
|
|
77
|
+
return self._dec.require_role(role)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Django decorators: @login_required, @admin_required, @require_role."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
7
|
+
|
|
8
|
+
from django.http import JsonResponse
|
|
9
|
+
|
|
10
|
+
from softauth.core.config import SoftAuthConfig
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from softauth.core.auth import SoftAuth
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DecoratorFactory:
|
|
17
|
+
"""Builds Django view decorators that validate the JWT-populated request attributes."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, auth: "SoftAuth", config: SoftAuthConfig) -> None:
|
|
20
|
+
self._auth = auth
|
|
21
|
+
self._config = config
|
|
22
|
+
|
|
23
|
+
def _load_user(self, request: Any) -> Any:
|
|
24
|
+
user_id = getattr(request, "softauth_user_id", None)
|
|
25
|
+
if not user_id:
|
|
26
|
+
return None
|
|
27
|
+
with self._auth._db.session() as s:
|
|
28
|
+
from softauth.database.repository import UserRepository
|
|
29
|
+
return UserRepository(s).get_by_id(user_id)
|
|
30
|
+
|
|
31
|
+
def login_required(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
32
|
+
"""Require any authenticated active user."""
|
|
33
|
+
@wraps(fn)
|
|
34
|
+
def _wrapper(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
35
|
+
user = self._load_user(request)
|
|
36
|
+
if user is None or not user.is_active:
|
|
37
|
+
return JsonResponse({"error": "Authentication required"}, status=401)
|
|
38
|
+
request.softauth_user = user
|
|
39
|
+
return fn(request, *args, **kwargs)
|
|
40
|
+
return _wrapper
|
|
41
|
+
|
|
42
|
+
def admin_required(self, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
43
|
+
"""Require an authenticated user with role == 'admin'."""
|
|
44
|
+
@wraps(fn)
|
|
45
|
+
def _wrapper(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
46
|
+
user = self._load_user(request)
|
|
47
|
+
if user is None or not user.is_active:
|
|
48
|
+
return JsonResponse({"error": "Authentication required"}, status=401)
|
|
49
|
+
if user.role != "admin":
|
|
50
|
+
return JsonResponse({"error": "Admin role required"}, status=403)
|
|
51
|
+
request.softauth_user = user
|
|
52
|
+
return fn(request, *args, **kwargs)
|
|
53
|
+
return _wrapper
|
|
54
|
+
|
|
55
|
+
def require_role(self, role: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
56
|
+
"""Require an authenticated user with ``role`` (admin is always allowed)."""
|
|
57
|
+
def _decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
58
|
+
@wraps(fn)
|
|
59
|
+
def _wrapper(request: Any, *args: Any, **kwargs: Any) -> Any:
|
|
60
|
+
user = self._load_user(request)
|
|
61
|
+
if user is None or not user.is_active:
|
|
62
|
+
return JsonResponse({"error": "Authentication required"}, status=401)
|
|
63
|
+
if user.role not in (role, "admin"):
|
|
64
|
+
return JsonResponse({"error": f"Role '{role}' required"}, status=403)
|
|
65
|
+
request.softauth_user = user
|
|
66
|
+
return fn(request, *args, **kwargs)
|
|
67
|
+
return _wrapper
|
|
68
|
+
return _decorator
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Django middleware: populates request.softauth_* from JWT on every request.
|
|
2
|
+
|
|
3
|
+
Add to Django settings:
|
|
4
|
+
MIDDLEWARE = [
|
|
5
|
+
...
|
|
6
|
+
"softauth.django.middleware.SoftAuthMiddleware",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
The middleware reads the jwt_handler configured by DjangoAdapter.on_startup().
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
15
|
+
|
|
16
|
+
from softauth.core.exceptions import TokenError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from softauth.jwt.handler import JWTHandler
|
|
20
|
+
|
|
21
|
+
_jwt_handler: Optional["JWTHandler"] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def configure(jwt_handler: "JWTHandler") -> None:
|
|
25
|
+
"""Register the JWTHandler used by SoftAuthMiddleware. Called by DjangoAdapter."""
|
|
26
|
+
global _jwt_handler
|
|
27
|
+
_jwt_handler = jwt_handler
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SoftAuthMiddleware:
|
|
31
|
+
"""Parses JWT from Authorization header and populates request.softauth_* attributes.
|
|
32
|
+
|
|
33
|
+
Sets on every request (regardless of whether a token is present):
|
|
34
|
+
request.softauth_user_id — str | None
|
|
35
|
+
request.softauth_role — str | None
|
|
36
|
+
request.softauth_payload — dict | None
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, get_response: Callable[..., Any]) -> None:
|
|
40
|
+
self.get_response = get_response
|
|
41
|
+
|
|
42
|
+
def __call__(self, request: Any) -> Any:
|
|
43
|
+
request.softauth_user_id = None
|
|
44
|
+
request.softauth_role = None
|
|
45
|
+
request.softauth_payload = None
|
|
46
|
+
|
|
47
|
+
if _jwt_handler is not None:
|
|
48
|
+
auth_header: str = request.META.get("HTTP_AUTHORIZATION", "")
|
|
49
|
+
if auth_header:
|
|
50
|
+
try:
|
|
51
|
+
token = _jwt_handler.extract_token_from_header(auth_header)
|
|
52
|
+
payload = _jwt_handler.decode_token(token)
|
|
53
|
+
request.softauth_user_id = payload.get("sub")
|
|
54
|
+
request.softauth_role = payload.get("role")
|
|
55
|
+
request.softauth_payload = payload
|
|
56
|
+
except TokenError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
return self.get_response(request)
|
softauth/django/urls.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""URL pattern factory for softauth's Django auth endpoints.
|
|
2
|
+
|
|
3
|
+
Usage in your urls.py:
|
|
4
|
+
from django.urls import path, include
|
|
5
|
+
from softauth.django.urls import create_auth_urlpatterns
|
|
6
|
+
|
|
7
|
+
urlpatterns = [
|
|
8
|
+
# ... your routes
|
|
9
|
+
] + create_auth_urlpatterns(auth, auth.config)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from softauth.core.config import SoftAuthConfig
|
|
17
|
+
from softauth.django.views import create_auth_views
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from softauth.core.auth import SoftAuth
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_auth_urlpatterns(auth: "SoftAuth", config: SoftAuthConfig) -> list[Any]:
|
|
24
|
+
"""Return Django URL patterns for all auth endpoints."""
|
|
25
|
+
from django.urls import path
|
|
26
|
+
|
|
27
|
+
views = create_auth_views(auth, config)
|
|
28
|
+
prefix = config.auth_prefix.strip("/")
|
|
29
|
+
|
|
30
|
+
return [
|
|
31
|
+
path(f"{prefix}/register/", views["register"], name="softauth-register"),
|
|
32
|
+
path(f"{prefix}/login/", views["login"], name="softauth-login"),
|
|
33
|
+
path(f"{prefix}/refresh/", views["refresh"], name="softauth-refresh"),
|
|
34
|
+
path(f"{prefix}/me/", views["me"], name="softauth-me"),
|
|
35
|
+
path(f"{prefix}/logout/", views["logout"], name="softauth-logout"),
|
|
36
|
+
]
|
softauth/django/views.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Django view functions for the auto-generated auth endpoints.
|
|
2
|
+
|
|
3
|
+
Provides: POST /register POST /login POST /refresh GET /me POST /logout
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from django.http import JsonResponse
|
|
12
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
13
|
+
|
|
14
|
+
from softauth.core.config import SoftAuthConfig
|
|
15
|
+
from softauth.core.exceptions import InvalidTokenError, TokenExpiredError, UserAlreadyExistsError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from softauth.core.auth import SoftAuth
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_auth_views(auth: "SoftAuth", config: SoftAuthConfig) -> dict[str, Any]:
|
|
22
|
+
"""Return a dict of Django view callables, one per auth endpoint."""
|
|
23
|
+
|
|
24
|
+
def _body(request: Any) -> dict[str, Any]:
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(request.body or b"{}") or {}
|
|
27
|
+
except (json.JSONDecodeError, ValueError):
|
|
28
|
+
return {}
|
|
29
|
+
|
|
30
|
+
@csrf_exempt
|
|
31
|
+
def register(request: Any) -> JsonResponse:
|
|
32
|
+
if request.method != "POST":
|
|
33
|
+
return JsonResponse({"error": "Method not allowed"}, status=405)
|
|
34
|
+
body = _body(request)
|
|
35
|
+
email: str = body.get("email", "")
|
|
36
|
+
password: str = body.get("password", "")
|
|
37
|
+
role: str = body.get("role", "user")
|
|
38
|
+
|
|
39
|
+
if not email or not password:
|
|
40
|
+
return JsonResponse({"error": "email and password are required"}, status=400)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
with auth._db.session() as s:
|
|
44
|
+
from softauth.database.repository import UserRepository
|
|
45
|
+
user = UserRepository(s).create(
|
|
46
|
+
email=email,
|
|
47
|
+
hashed_password=auth.passwords.hash_password(password),
|
|
48
|
+
role=role,
|
|
49
|
+
)
|
|
50
|
+
return JsonResponse({"message": "User registered successfully", "id": user.id}, status=201)
|
|
51
|
+
except UserAlreadyExistsError as exc:
|
|
52
|
+
return JsonResponse({"error": str(exc)}, status=409)
|
|
53
|
+
|
|
54
|
+
@csrf_exempt
|
|
55
|
+
def login(request: Any) -> JsonResponse:
|
|
56
|
+
if request.method != "POST":
|
|
57
|
+
return JsonResponse({"error": "Method not allowed"}, status=405)
|
|
58
|
+
body = _body(request)
|
|
59
|
+
email: str = body.get("email", "")
|
|
60
|
+
password: str = body.get("password", "")
|
|
61
|
+
|
|
62
|
+
with auth._db.session() as s:
|
|
63
|
+
from softauth.database.repository import UserRepository
|
|
64
|
+
user = UserRepository(s).get_by_email(email)
|
|
65
|
+
|
|
66
|
+
if user is None or not auth.passwords.verify_password(password, user.hashed_password):
|
|
67
|
+
return JsonResponse({"error": "Invalid credentials"}, status=401)
|
|
68
|
+
if not user.is_active:
|
|
69
|
+
return JsonResponse({"error": "Inactive user"}, status=400)
|
|
70
|
+
|
|
71
|
+
access_token = auth.jwt.create_access_token(subject=user.id, role=user.role)
|
|
72
|
+
refresh_token = auth.jwt.create_refresh_token(subject=user.id)
|
|
73
|
+
|
|
74
|
+
return JsonResponse({
|
|
75
|
+
"access_token": access_token,
|
|
76
|
+
"refresh_token": refresh_token,
|
|
77
|
+
"token_type": "bearer",
|
|
78
|
+
"expires_in": config.access_expiry_minutes * 60,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
@csrf_exempt
|
|
82
|
+
def refresh(request: Any) -> JsonResponse:
|
|
83
|
+
if request.method != "POST":
|
|
84
|
+
return JsonResponse({"error": "Method not allowed"}, status=405)
|
|
85
|
+
body = _body(request)
|
|
86
|
+
token: str = body.get("refresh_token", "")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
payload = auth.jwt.decode_token(token)
|
|
90
|
+
except TokenExpiredError:
|
|
91
|
+
return JsonResponse({"error": "Refresh token expired"}, status=401)
|
|
92
|
+
except InvalidTokenError:
|
|
93
|
+
return JsonResponse({"error": "Invalid refresh token"}, status=401)
|
|
94
|
+
|
|
95
|
+
if payload.get("type") != "refresh":
|
|
96
|
+
return JsonResponse({"error": "Not a refresh token"}, status=401)
|
|
97
|
+
|
|
98
|
+
user_id: str = payload["sub"]
|
|
99
|
+
with auth._db.session() as s:
|
|
100
|
+
from softauth.database.repository import UserRepository
|
|
101
|
+
user = UserRepository(s).get_by_id(user_id)
|
|
102
|
+
|
|
103
|
+
if user is None or not user.is_active:
|
|
104
|
+
return JsonResponse({"error": "User not found or inactive"}, status=401)
|
|
105
|
+
|
|
106
|
+
new_token = auth.jwt.create_access_token(subject=user_id, role=user.role)
|
|
107
|
+
return JsonResponse({
|
|
108
|
+
"access_token": new_token,
|
|
109
|
+
"token_type": "bearer",
|
|
110
|
+
"expires_in": config.access_expiry_minutes * 60,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
@csrf_exempt
|
|
114
|
+
def me(request: Any) -> JsonResponse:
|
|
115
|
+
if request.method != "GET":
|
|
116
|
+
return JsonResponse({"error": "Method not allowed"}, status=405)
|
|
117
|
+
user_id = getattr(request, "softauth_user_id", None)
|
|
118
|
+
if not user_id:
|
|
119
|
+
return JsonResponse({"error": "Authentication required"}, status=401)
|
|
120
|
+
|
|
121
|
+
with auth._db.session() as s:
|
|
122
|
+
from softauth.database.repository import UserRepository
|
|
123
|
+
user = UserRepository(s).get_by_id(user_id)
|
|
124
|
+
|
|
125
|
+
if user is None:
|
|
126
|
+
return JsonResponse({"error": "User not found"}, status=404)
|
|
127
|
+
return JsonResponse(user.to_dict())
|
|
128
|
+
|
|
129
|
+
@csrf_exempt
|
|
130
|
+
def logout(request: Any) -> JsonResponse:
|
|
131
|
+
return JsonResponse({"message": "Logged out successfully"})
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"register": register,
|
|
135
|
+
"login": login,
|
|
136
|
+
"refresh": refresh,
|
|
137
|
+
"me": me,
|
|
138
|
+
"logout": logout,
|
|
139
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from softauth.fastapi.adapter import FastAPIAdapter
|
|
2
|
+
from softauth.fastapi.dependencies import DependencyFactory
|
|
3
|
+
from softauth.fastapi.middleware import JWTMiddleware
|
|
4
|
+
from softauth.fastapi.routes import create_auth_router
|
|
5
|
+
|
|
6
|
+
__all__ = ["FastAPIAdapter", "DependencyFactory", "JWTMiddleware", "create_auth_router"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""FastAPI adapter — implements BaseAdapter for FastAPI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
6
|
+
|
|
7
|
+
from softauth.core.config import SoftAuthConfig
|
|
8
|
+
from softauth.interfaces.adapter import BaseAdapter
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from fastapi import FastAPI
|
|
12
|
+
from softauth.core.auth import SoftAuth
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FastAPIAdapter(BaseAdapter):
|
|
16
|
+
"""Wires SoftAuth into a FastAPI application.
|
|
17
|
+
|
|
18
|
+
Responsibilities:
|
|
19
|
+
- Registers ``JWTMiddleware`` so ``request.state.user_*`` is always set.
|
|
20
|
+
- Mounts the auto-generated auth router under ``config.auth_prefix``.
|
|
21
|
+
- Exposes ``current_user``, ``current_admin``, and ``require_role()``
|
|
22
|
+
as FastAPI-native ``Depends()``-compatible callables.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, auth: "SoftAuth", config: SoftAuthConfig) -> None:
|
|
26
|
+
self._auth = auth
|
|
27
|
+
self._config = config
|
|
28
|
+
|
|
29
|
+
from softauth.fastapi.dependencies import DependencyFactory
|
|
30
|
+
self._deps = DependencyFactory(auth, config)
|
|
31
|
+
|
|
32
|
+
def init_app(self, app: "FastAPI") -> None:
|
|
33
|
+
from softauth.fastapi.middleware import JWTMiddleware
|
|
34
|
+
from softauth.fastapi.routes import create_auth_router
|
|
35
|
+
|
|
36
|
+
app.add_middleware(
|
|
37
|
+
JWTMiddleware,
|
|
38
|
+
config=self._config,
|
|
39
|
+
jwt_handler=self._auth.jwt,
|
|
40
|
+
)
|
|
41
|
+
router = create_auth_router(self._auth, self._config, self._deps)
|
|
42
|
+
app.include_router(
|
|
43
|
+
router,
|
|
44
|
+
prefix=self._config.auth_prefix,
|
|
45
|
+
tags=["Authentication"],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def get_current_user_dependency(self) -> Callable[..., Any]:
|
|
49
|
+
return self._deps.current_user
|
|
50
|
+
|
|
51
|
+
def get_current_admin_dependency(self) -> Callable[..., Any]:
|
|
52
|
+
return self._deps.current_admin
|
|
53
|
+
|
|
54
|
+
def get_require_role_dependency(self, role: str) -> Callable[..., Any]:
|
|
55
|
+
return self._deps.require_role(role)
|