zndraw-auth 0.1.0__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.
- zndraw_auth-0.1.0/PKG-INFO +267 -0
- zndraw_auth-0.1.0/README.md +249 -0
- zndraw_auth-0.1.0/pyproject.toml +81 -0
- zndraw_auth-0.1.0/src/zndraw_auth/__init__.py +74 -0
- zndraw_auth-0.1.0/src/zndraw_auth/db.py +80 -0
- zndraw_auth-0.1.0/src/zndraw_auth/schemas.py +23 -0
- zndraw_auth-0.1.0/src/zndraw_auth/settings.py +36 -0
- zndraw_auth-0.1.0/src/zndraw_auth/users.py +139 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: zndraw-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared authentication for ZnDraw using fastapi-users
|
|
5
|
+
Requires-Dist: fastapi>=0.128.0
|
|
6
|
+
Requires-Dist: fastapi-users[sqlalchemy]>=14.0.0
|
|
7
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
8
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.0
|
|
9
|
+
Requires-Dist: aiosqlite>=0.19.0
|
|
10
|
+
Requires-Dist: pytest>=8.0.0 ; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest-asyncio>=0.23.0 ; extra == 'dev'
|
|
12
|
+
Requires-Dist: httpx>=0.27.0 ; extra == 'dev'
|
|
13
|
+
Requires-Dist: ruff>=0.8.0 ; extra == 'dev'
|
|
14
|
+
Requires-Dist: mypy>=1.0.0 ; extra == 'dev'
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# zndraw-auth
|
|
20
|
+
|
|
21
|
+
Shared authentication package for the ZnDraw ecosystem using [fastapi-users](https://fastapi-users.github.io/fastapi-users/).
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install zndraw-auth
|
|
27
|
+
# or with uv
|
|
28
|
+
uv add zndraw-auth
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from contextlib import asynccontextmanager
|
|
35
|
+
from fastapi import Depends, FastAPI
|
|
36
|
+
|
|
37
|
+
from zndraw_auth import (
|
|
38
|
+
User,
|
|
39
|
+
UserCreate,
|
|
40
|
+
UserRead,
|
|
41
|
+
auth_backend,
|
|
42
|
+
create_db_and_tables,
|
|
43
|
+
current_active_user,
|
|
44
|
+
fastapi_users,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@asynccontextmanager
|
|
49
|
+
async def lifespan(app: FastAPI):
|
|
50
|
+
await create_db_and_tables()
|
|
51
|
+
yield
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
app = FastAPI(lifespan=lifespan)
|
|
55
|
+
|
|
56
|
+
# Include auth routers
|
|
57
|
+
app.include_router(
|
|
58
|
+
fastapi_users.get_auth_router(auth_backend),
|
|
59
|
+
prefix="/auth/jwt",
|
|
60
|
+
tags=["auth"],
|
|
61
|
+
)
|
|
62
|
+
app.include_router(
|
|
63
|
+
fastapi_users.get_register_router(UserRead, UserCreate),
|
|
64
|
+
prefix="/auth",
|
|
65
|
+
tags=["auth"],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.get("/protected")
|
|
70
|
+
async def protected_route(user: User = Depends(current_active_user)):
|
|
71
|
+
return {"message": f"Hello {user.email}!"}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Extending with Custom Models (e.g., zndraw-joblib)
|
|
75
|
+
|
|
76
|
+
Other packages can import `Base` and `get_async_session` to define models that share the same database and have foreign key relationships to `User`.
|
|
77
|
+
|
|
78
|
+
### Example: Adding a Job model in zndraw-joblib
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
# zndraw_joblib/models.py
|
|
82
|
+
import uuid
|
|
83
|
+
from typing import TYPE_CHECKING
|
|
84
|
+
|
|
85
|
+
from sqlalchemy import ForeignKey, String
|
|
86
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
87
|
+
|
|
88
|
+
from zndraw_auth import Base
|
|
89
|
+
|
|
90
|
+
if TYPE_CHECKING:
|
|
91
|
+
from zndraw_auth import User
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Job(Base):
|
|
95
|
+
"""A compute job owned by a user."""
|
|
96
|
+
|
|
97
|
+
__tablename__ = "job"
|
|
98
|
+
|
|
99
|
+
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
|
100
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
101
|
+
status: Mapped[str] = mapped_column(String(50), default="pending")
|
|
102
|
+
|
|
103
|
+
# Foreign key to User from zndraw-auth (cascade delete when user is deleted)
|
|
104
|
+
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user.id", ondelete="cascade"))
|
|
105
|
+
|
|
106
|
+
# Relationship (optional, for ORM navigation)
|
|
107
|
+
user: Mapped["User"] = relationship("User", lazy="selectin")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Example: Using the shared session in endpoints
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# zndraw_joblib/routes.py
|
|
114
|
+
from typing import Annotated
|
|
115
|
+
from uuid import UUID
|
|
116
|
+
|
|
117
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
118
|
+
from sqlalchemy import select
|
|
119
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
120
|
+
|
|
121
|
+
from zndraw_auth import User, current_active_user, get_async_session
|
|
122
|
+
|
|
123
|
+
from .models import Job
|
|
124
|
+
|
|
125
|
+
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@router.post("/")
|
|
129
|
+
async def create_job(
|
|
130
|
+
name: str,
|
|
131
|
+
user: Annotated[User, Depends(current_active_user)],
|
|
132
|
+
session: Annotated[AsyncSession, Depends(get_async_session)],
|
|
133
|
+
):
|
|
134
|
+
"""Create a new job for the current user."""
|
|
135
|
+
job = Job(name=name, user_id=user.id)
|
|
136
|
+
session.add(job)
|
|
137
|
+
await session.commit()
|
|
138
|
+
await session.refresh(job)
|
|
139
|
+
return {"id": str(job.id), "name": job.name, "status": job.status}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@router.get("/")
|
|
143
|
+
async def list_jobs(
|
|
144
|
+
user: Annotated[User, Depends(current_active_user)],
|
|
145
|
+
session: Annotated[AsyncSession, Depends(get_async_session)],
|
|
146
|
+
):
|
|
147
|
+
"""List all jobs for the current user."""
|
|
148
|
+
result = await session.execute(
|
|
149
|
+
select(Job).where(Job.user_id == user.id)
|
|
150
|
+
)
|
|
151
|
+
jobs = result.scalars().all()
|
|
152
|
+
return [{"id": str(j.id), "name": j.name, "status": j.status} for j in jobs]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.get("/{job_id}")
|
|
156
|
+
async def get_job(
|
|
157
|
+
job_id: UUID,
|
|
158
|
+
user: Annotated[User, Depends(current_active_user)],
|
|
159
|
+
session: Annotated[AsyncSession, Depends(get_async_session)],
|
|
160
|
+
):
|
|
161
|
+
"""Get a specific job (must belong to current user)."""
|
|
162
|
+
result = await session.execute(
|
|
163
|
+
select(Job).where(Job.id == job_id, Job.user_id == user.id)
|
|
164
|
+
)
|
|
165
|
+
job = result.scalar_one_or_none()
|
|
166
|
+
if not job:
|
|
167
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
168
|
+
return {"id": str(job.id), "name": job.name, "status": job.status}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Example: App setup with multiple routers
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
# main.py (in zndraw-fastapi or combined app)
|
|
175
|
+
from contextlib import asynccontextmanager
|
|
176
|
+
|
|
177
|
+
from fastapi import FastAPI
|
|
178
|
+
|
|
179
|
+
from zndraw_auth import (
|
|
180
|
+
UserCreate,
|
|
181
|
+
UserRead,
|
|
182
|
+
auth_backend,
|
|
183
|
+
create_db_and_tables,
|
|
184
|
+
fastapi_users,
|
|
185
|
+
)
|
|
186
|
+
from zndraw_joblib.routes import router as jobs_router
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@asynccontextmanager
|
|
190
|
+
async def lifespan(app: FastAPI):
|
|
191
|
+
# Creates tables for User AND Job (all models using Base)
|
|
192
|
+
await create_db_and_tables()
|
|
193
|
+
yield
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
app = FastAPI(lifespan=lifespan)
|
|
197
|
+
|
|
198
|
+
# Auth routes from zndraw-auth
|
|
199
|
+
app.include_router(
|
|
200
|
+
fastapi_users.get_auth_router(auth_backend),
|
|
201
|
+
prefix="/auth/jwt",
|
|
202
|
+
tags=["auth"],
|
|
203
|
+
)
|
|
204
|
+
app.include_router(
|
|
205
|
+
fastapi_users.get_register_router(UserRead, UserCreate),
|
|
206
|
+
prefix="/auth",
|
|
207
|
+
tags=["auth"],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Job routes from zndraw-joblib
|
|
211
|
+
app.include_router(jobs_router)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Configuration
|
|
215
|
+
|
|
216
|
+
Settings are loaded from environment variables with the `ZNDRAW_AUTH_` prefix:
|
|
217
|
+
|
|
218
|
+
| Variable | Default | Description |
|
|
219
|
+
|----------|---------|-------------|
|
|
220
|
+
| `ZNDRAW_AUTH_SECRET_KEY` | `CHANGE-ME-IN-PRODUCTION` | JWT signing secret |
|
|
221
|
+
| `ZNDRAW_AUTH_TOKEN_LIFETIME_SECONDS` | `3600` | JWT token lifetime |
|
|
222
|
+
| `ZNDRAW_AUTH_DATABASE_URL` | `sqlite+aiosqlite:///./zndraw_auth.db` | Database connection URL |
|
|
223
|
+
| `ZNDRAW_AUTH_RESET_PASSWORD_TOKEN_SECRET` | `CHANGE-ME-RESET` | Password reset token secret |
|
|
224
|
+
| `ZNDRAW_AUTH_VERIFICATION_TOKEN_SECRET` | `CHANGE-ME-VERIFY` | Email verification token secret |
|
|
225
|
+
|
|
226
|
+
## Exports
|
|
227
|
+
|
|
228
|
+
```python
|
|
229
|
+
from zndraw_auth import (
|
|
230
|
+
# SQLAlchemy Base (for extending with your own models)
|
|
231
|
+
Base,
|
|
232
|
+
|
|
233
|
+
# User model
|
|
234
|
+
User,
|
|
235
|
+
|
|
236
|
+
# Database utilities
|
|
237
|
+
create_db_and_tables,
|
|
238
|
+
get_async_session,
|
|
239
|
+
get_user_db,
|
|
240
|
+
|
|
241
|
+
# Pydantic schemas
|
|
242
|
+
UserCreate,
|
|
243
|
+
UserRead,
|
|
244
|
+
UserUpdate,
|
|
245
|
+
|
|
246
|
+
# Settings
|
|
247
|
+
AuthSettings,
|
|
248
|
+
get_auth_settings,
|
|
249
|
+
|
|
250
|
+
# User manager (for custom lifecycle hooks)
|
|
251
|
+
UserManager,
|
|
252
|
+
get_user_manager,
|
|
253
|
+
|
|
254
|
+
# FastAPIUsers instance (for including routers)
|
|
255
|
+
fastapi_users,
|
|
256
|
+
auth_backend,
|
|
257
|
+
|
|
258
|
+
# Dependencies for Depends()
|
|
259
|
+
current_active_user, # Requires authenticated active user
|
|
260
|
+
current_superuser, # Requires superuser
|
|
261
|
+
current_optional_user, # User | None (optional auth)
|
|
262
|
+
)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## License
|
|
266
|
+
|
|
267
|
+
MIT
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# zndraw-auth
|
|
2
|
+
|
|
3
|
+
Shared authentication package for the ZnDraw ecosystem using [fastapi-users](https://fastapi-users.github.io/fastapi-users/).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install zndraw-auth
|
|
9
|
+
# or with uv
|
|
10
|
+
uv add zndraw-auth
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
from fastapi import Depends, FastAPI
|
|
18
|
+
|
|
19
|
+
from zndraw_auth import (
|
|
20
|
+
User,
|
|
21
|
+
UserCreate,
|
|
22
|
+
UserRead,
|
|
23
|
+
auth_backend,
|
|
24
|
+
create_db_and_tables,
|
|
25
|
+
current_active_user,
|
|
26
|
+
fastapi_users,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@asynccontextmanager
|
|
31
|
+
async def lifespan(app: FastAPI):
|
|
32
|
+
await create_db_and_tables()
|
|
33
|
+
yield
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
app = FastAPI(lifespan=lifespan)
|
|
37
|
+
|
|
38
|
+
# Include auth routers
|
|
39
|
+
app.include_router(
|
|
40
|
+
fastapi_users.get_auth_router(auth_backend),
|
|
41
|
+
prefix="/auth/jwt",
|
|
42
|
+
tags=["auth"],
|
|
43
|
+
)
|
|
44
|
+
app.include_router(
|
|
45
|
+
fastapi_users.get_register_router(UserRead, UserCreate),
|
|
46
|
+
prefix="/auth",
|
|
47
|
+
tags=["auth"],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.get("/protected")
|
|
52
|
+
async def protected_route(user: User = Depends(current_active_user)):
|
|
53
|
+
return {"message": f"Hello {user.email}!"}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Extending with Custom Models (e.g., zndraw-joblib)
|
|
57
|
+
|
|
58
|
+
Other packages can import `Base` and `get_async_session` to define models that share the same database and have foreign key relationships to `User`.
|
|
59
|
+
|
|
60
|
+
### Example: Adding a Job model in zndraw-joblib
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# zndraw_joblib/models.py
|
|
64
|
+
import uuid
|
|
65
|
+
from typing import TYPE_CHECKING
|
|
66
|
+
|
|
67
|
+
from sqlalchemy import ForeignKey, String
|
|
68
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
69
|
+
|
|
70
|
+
from zndraw_auth import Base
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from zndraw_auth import User
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Job(Base):
|
|
77
|
+
"""A compute job owned by a user."""
|
|
78
|
+
|
|
79
|
+
__tablename__ = "job"
|
|
80
|
+
|
|
81
|
+
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
|
82
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
83
|
+
status: Mapped[str] = mapped_column(String(50), default="pending")
|
|
84
|
+
|
|
85
|
+
# Foreign key to User from zndraw-auth (cascade delete when user is deleted)
|
|
86
|
+
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("user.id", ondelete="cascade"))
|
|
87
|
+
|
|
88
|
+
# Relationship (optional, for ORM navigation)
|
|
89
|
+
user: Mapped["User"] = relationship("User", lazy="selectin")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Example: Using the shared session in endpoints
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# zndraw_joblib/routes.py
|
|
96
|
+
from typing import Annotated
|
|
97
|
+
from uuid import UUID
|
|
98
|
+
|
|
99
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
100
|
+
from sqlalchemy import select
|
|
101
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
102
|
+
|
|
103
|
+
from zndraw_auth import User, current_active_user, get_async_session
|
|
104
|
+
|
|
105
|
+
from .models import Job
|
|
106
|
+
|
|
107
|
+
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.post("/")
|
|
111
|
+
async def create_job(
|
|
112
|
+
name: str,
|
|
113
|
+
user: Annotated[User, Depends(current_active_user)],
|
|
114
|
+
session: Annotated[AsyncSession, Depends(get_async_session)],
|
|
115
|
+
):
|
|
116
|
+
"""Create a new job for the current user."""
|
|
117
|
+
job = Job(name=name, user_id=user.id)
|
|
118
|
+
session.add(job)
|
|
119
|
+
await session.commit()
|
|
120
|
+
await session.refresh(job)
|
|
121
|
+
return {"id": str(job.id), "name": job.name, "status": job.status}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@router.get("/")
|
|
125
|
+
async def list_jobs(
|
|
126
|
+
user: Annotated[User, Depends(current_active_user)],
|
|
127
|
+
session: Annotated[AsyncSession, Depends(get_async_session)],
|
|
128
|
+
):
|
|
129
|
+
"""List all jobs for the current user."""
|
|
130
|
+
result = await session.execute(
|
|
131
|
+
select(Job).where(Job.user_id == user.id)
|
|
132
|
+
)
|
|
133
|
+
jobs = result.scalars().all()
|
|
134
|
+
return [{"id": str(j.id), "name": j.name, "status": j.status} for j in jobs]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.get("/{job_id}")
|
|
138
|
+
async def get_job(
|
|
139
|
+
job_id: UUID,
|
|
140
|
+
user: Annotated[User, Depends(current_active_user)],
|
|
141
|
+
session: Annotated[AsyncSession, Depends(get_async_session)],
|
|
142
|
+
):
|
|
143
|
+
"""Get a specific job (must belong to current user)."""
|
|
144
|
+
result = await session.execute(
|
|
145
|
+
select(Job).where(Job.id == job_id, Job.user_id == user.id)
|
|
146
|
+
)
|
|
147
|
+
job = result.scalar_one_or_none()
|
|
148
|
+
if not job:
|
|
149
|
+
raise HTTPException(status_code=404, detail="Job not found")
|
|
150
|
+
return {"id": str(job.id), "name": job.name, "status": job.status}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Example: App setup with multiple routers
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
# main.py (in zndraw-fastapi or combined app)
|
|
157
|
+
from contextlib import asynccontextmanager
|
|
158
|
+
|
|
159
|
+
from fastapi import FastAPI
|
|
160
|
+
|
|
161
|
+
from zndraw_auth import (
|
|
162
|
+
UserCreate,
|
|
163
|
+
UserRead,
|
|
164
|
+
auth_backend,
|
|
165
|
+
create_db_and_tables,
|
|
166
|
+
fastapi_users,
|
|
167
|
+
)
|
|
168
|
+
from zndraw_joblib.routes import router as jobs_router
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@asynccontextmanager
|
|
172
|
+
async def lifespan(app: FastAPI):
|
|
173
|
+
# Creates tables for User AND Job (all models using Base)
|
|
174
|
+
await create_db_and_tables()
|
|
175
|
+
yield
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
app = FastAPI(lifespan=lifespan)
|
|
179
|
+
|
|
180
|
+
# Auth routes from zndraw-auth
|
|
181
|
+
app.include_router(
|
|
182
|
+
fastapi_users.get_auth_router(auth_backend),
|
|
183
|
+
prefix="/auth/jwt",
|
|
184
|
+
tags=["auth"],
|
|
185
|
+
)
|
|
186
|
+
app.include_router(
|
|
187
|
+
fastapi_users.get_register_router(UserRead, UserCreate),
|
|
188
|
+
prefix="/auth",
|
|
189
|
+
tags=["auth"],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Job routes from zndraw-joblib
|
|
193
|
+
app.include_router(jobs_router)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Configuration
|
|
197
|
+
|
|
198
|
+
Settings are loaded from environment variables with the `ZNDRAW_AUTH_` prefix:
|
|
199
|
+
|
|
200
|
+
| Variable | Default | Description |
|
|
201
|
+
|----------|---------|-------------|
|
|
202
|
+
| `ZNDRAW_AUTH_SECRET_KEY` | `CHANGE-ME-IN-PRODUCTION` | JWT signing secret |
|
|
203
|
+
| `ZNDRAW_AUTH_TOKEN_LIFETIME_SECONDS` | `3600` | JWT token lifetime |
|
|
204
|
+
| `ZNDRAW_AUTH_DATABASE_URL` | `sqlite+aiosqlite:///./zndraw_auth.db` | Database connection URL |
|
|
205
|
+
| `ZNDRAW_AUTH_RESET_PASSWORD_TOKEN_SECRET` | `CHANGE-ME-RESET` | Password reset token secret |
|
|
206
|
+
| `ZNDRAW_AUTH_VERIFICATION_TOKEN_SECRET` | `CHANGE-ME-VERIFY` | Email verification token secret |
|
|
207
|
+
|
|
208
|
+
## Exports
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from zndraw_auth import (
|
|
212
|
+
# SQLAlchemy Base (for extending with your own models)
|
|
213
|
+
Base,
|
|
214
|
+
|
|
215
|
+
# User model
|
|
216
|
+
User,
|
|
217
|
+
|
|
218
|
+
# Database utilities
|
|
219
|
+
create_db_and_tables,
|
|
220
|
+
get_async_session,
|
|
221
|
+
get_user_db,
|
|
222
|
+
|
|
223
|
+
# Pydantic schemas
|
|
224
|
+
UserCreate,
|
|
225
|
+
UserRead,
|
|
226
|
+
UserUpdate,
|
|
227
|
+
|
|
228
|
+
# Settings
|
|
229
|
+
AuthSettings,
|
|
230
|
+
get_auth_settings,
|
|
231
|
+
|
|
232
|
+
# User manager (for custom lifecycle hooks)
|
|
233
|
+
UserManager,
|
|
234
|
+
get_user_manager,
|
|
235
|
+
|
|
236
|
+
# FastAPIUsers instance (for including routers)
|
|
237
|
+
fastapi_users,
|
|
238
|
+
auth_backend,
|
|
239
|
+
|
|
240
|
+
# Dependencies for Depends()
|
|
241
|
+
current_active_user, # Requires authenticated active user
|
|
242
|
+
current_superuser, # Requires superuser
|
|
243
|
+
current_optional_user, # User | None (optional auth)
|
|
244
|
+
)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
MIT
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "zndraw-auth"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared authentication for ZnDraw using fastapi-users"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastapi>=0.128.0",
|
|
9
|
+
"fastapi-users[sqlalchemy]>=14.0.0",
|
|
10
|
+
"pydantic-settings>=2.0.0",
|
|
11
|
+
"sqlalchemy[asyncio]>=2.0.0",
|
|
12
|
+
"aiosqlite>=0.19.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.0.0",
|
|
18
|
+
"pytest-asyncio>=0.23.0",
|
|
19
|
+
"httpx>=0.27.0",
|
|
20
|
+
"ruff>=0.8.0",
|
|
21
|
+
"mypy>=1.0.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build>=0.9.28,<0.10.0"]
|
|
26
|
+
build-backend = "uv_build"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/zndraw_auth"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
asyncio_mode = "auto"
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 88
|
|
37
|
+
target-version = "py311"
|
|
38
|
+
src = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = [
|
|
42
|
+
"E", # pycodestyle errors
|
|
43
|
+
"W", # pycodestyle warnings
|
|
44
|
+
"F", # Pyflakes
|
|
45
|
+
"UP", # pyupgrade
|
|
46
|
+
"B", # flake8-bugbear
|
|
47
|
+
"SIM", # flake8-simplify
|
|
48
|
+
"I", # isort
|
|
49
|
+
"C4", # flake8-comprehensions
|
|
50
|
+
"RUF", # Ruff-specific rules
|
|
51
|
+
"TC", # flake8-type-checking
|
|
52
|
+
"ASYNC", # flake8-async
|
|
53
|
+
"PTH", # flake8-use-pathlib
|
|
54
|
+
"RET", # flake8-return
|
|
55
|
+
"ARG", # flake8-unused-arguments
|
|
56
|
+
"PL", # Pylint
|
|
57
|
+
"PERF", # Perflint
|
|
58
|
+
]
|
|
59
|
+
ignore = [
|
|
60
|
+
"ARG001", # Unused function argument (common in FastAPI dependencies)
|
|
61
|
+
"ARG002", # Unused method argument (required by base class interface)
|
|
62
|
+
"PLR0913", # Too many arguments (FastAPI routes often have many deps)
|
|
63
|
+
"RUF022", # __all__ not sorted (we prefer logical grouping)
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.isort]
|
|
67
|
+
known-first-party = ["zndraw_auth"]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint.per-file-ignores]
|
|
70
|
+
"tests/*" = ["ARG", "PLR2004", "PLC0415"] # Allow magic values, unused args, late imports in tests
|
|
71
|
+
|
|
72
|
+
[tool.mypy]
|
|
73
|
+
python_version = "3.11"
|
|
74
|
+
strict = true
|
|
75
|
+
warn_return_any = true
|
|
76
|
+
warn_unused_configs = true
|
|
77
|
+
plugins = ["pydantic.mypy"]
|
|
78
|
+
|
|
79
|
+
[[tool.mypy.overrides]]
|
|
80
|
+
module = ["fastapi_users.*"]
|
|
81
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""ZnDraw Auth - Shared authentication for ZnDraw ecosystem.
|
|
2
|
+
|
|
3
|
+
Example usage:
|
|
4
|
+
from zndraw_auth import (
|
|
5
|
+
current_active_user,
|
|
6
|
+
current_superuser,
|
|
7
|
+
fastapi_users,
|
|
8
|
+
auth_backend,
|
|
9
|
+
get_async_session,
|
|
10
|
+
create_db_and_tables,
|
|
11
|
+
User,
|
|
12
|
+
UserRead,
|
|
13
|
+
UserCreate,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# In your FastAPI app:
|
|
17
|
+
app.include_router(
|
|
18
|
+
fastapi_users.get_auth_router(auth_backend),
|
|
19
|
+
prefix="/auth/jwt",
|
|
20
|
+
tags=["auth"],
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@app.get("/protected")
|
|
24
|
+
async def protected(user: User = Depends(current_active_user)):
|
|
25
|
+
return {"user_id": str(user.id)}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from zndraw_auth.db import (
|
|
29
|
+
Base,
|
|
30
|
+
User,
|
|
31
|
+
create_db_and_tables,
|
|
32
|
+
get_async_session,
|
|
33
|
+
get_user_db,
|
|
34
|
+
)
|
|
35
|
+
from zndraw_auth.schemas import UserCreate, UserRead, UserUpdate
|
|
36
|
+
from zndraw_auth.settings import AuthSettings, get_auth_settings
|
|
37
|
+
from zndraw_auth.users import (
|
|
38
|
+
UserManager,
|
|
39
|
+
auth_backend,
|
|
40
|
+
current_active_user,
|
|
41
|
+
current_optional_user,
|
|
42
|
+
current_superuser,
|
|
43
|
+
fastapi_users,
|
|
44
|
+
get_user_manager,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
# SQLAlchemy Base (for extending with your own models)
|
|
49
|
+
"Base",
|
|
50
|
+
# User model
|
|
51
|
+
"User",
|
|
52
|
+
# Database
|
|
53
|
+
"create_db_and_tables",
|
|
54
|
+
"get_async_session",
|
|
55
|
+
"get_user_db",
|
|
56
|
+
# Schemas
|
|
57
|
+
"UserCreate",
|
|
58
|
+
"UserRead",
|
|
59
|
+
"UserUpdate",
|
|
60
|
+
# Settings
|
|
61
|
+
"AuthSettings",
|
|
62
|
+
"get_auth_settings",
|
|
63
|
+
# User manager
|
|
64
|
+
"UserManager",
|
|
65
|
+
"get_user_manager",
|
|
66
|
+
# Auth backend
|
|
67
|
+
"auth_backend",
|
|
68
|
+
# FastAPIUsers instance
|
|
69
|
+
"fastapi_users",
|
|
70
|
+
# Dependencies for Depends()
|
|
71
|
+
"current_active_user",
|
|
72
|
+
"current_superuser",
|
|
73
|
+
"current_optional_user",
|
|
74
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Database models and session management."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends
|
|
9
|
+
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
|
|
10
|
+
from sqlalchemy.ext.asyncio import (
|
|
11
|
+
AsyncEngine,
|
|
12
|
+
AsyncSession,
|
|
13
|
+
async_sessionmaker,
|
|
14
|
+
create_async_engine,
|
|
15
|
+
)
|
|
16
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
17
|
+
|
|
18
|
+
from zndraw_auth.settings import AuthSettings, get_auth_settings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Base(DeclarativeBase):
|
|
22
|
+
"""SQLAlchemy declarative base."""
|
|
23
|
+
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class User(SQLAlchemyBaseUserTableUUID, Base):
|
|
28
|
+
"""User model for authentication.
|
|
29
|
+
|
|
30
|
+
Inherits from fastapi-users base which provides:
|
|
31
|
+
- id: UUID (primary key)
|
|
32
|
+
- email: str (unique, indexed)
|
|
33
|
+
- hashed_password: str
|
|
34
|
+
- is_active: bool (default True)
|
|
35
|
+
- is_superuser: bool (default False)
|
|
36
|
+
- is_verified: bool (default False)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@lru_cache
|
|
43
|
+
def get_engine(database_url: str) -> AsyncEngine:
|
|
44
|
+
"""Get or create the async engine (cached by URL)."""
|
|
45
|
+
return create_async_engine(database_url, echo=False)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@lru_cache
|
|
49
|
+
def get_session_maker(database_url: str) -> async_sessionmaker[AsyncSession]:
|
|
50
|
+
"""Get or create the session maker (cached by URL)."""
|
|
51
|
+
engine = get_engine(database_url)
|
|
52
|
+
return async_sessionmaker(engine, expire_on_commit=False)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def create_db_and_tables(settings: AuthSettings | None = None) -> None:
|
|
56
|
+
"""Create all database tables.
|
|
57
|
+
|
|
58
|
+
Call this in your app's lifespan or startup.
|
|
59
|
+
"""
|
|
60
|
+
if settings is None:
|
|
61
|
+
settings = get_auth_settings()
|
|
62
|
+
engine = get_engine(settings.database_url)
|
|
63
|
+
async with engine.begin() as conn:
|
|
64
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def get_async_session(
|
|
68
|
+
settings: Annotated[AuthSettings, Depends(get_auth_settings)],
|
|
69
|
+
) -> AsyncGenerator[AsyncSession, None]:
|
|
70
|
+
"""FastAPI dependency that yields an async database session."""
|
|
71
|
+
session_maker = get_session_maker(settings.database_url)
|
|
72
|
+
async with session_maker() as session:
|
|
73
|
+
yield session
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def get_user_db(
|
|
77
|
+
session: Annotated[AsyncSession, Depends(get_async_session)],
|
|
78
|
+
) -> AsyncGenerator[SQLAlchemyUserDatabase[User, uuid.UUID], None]:
|
|
79
|
+
"""FastAPI dependency that yields the user database adapter."""
|
|
80
|
+
yield SQLAlchemyUserDatabase[User, uuid.UUID](session, User)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Pydantic schemas for user operations."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from fastapi_users import schemas
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UserRead(schemas.BaseUser[uuid.UUID]):
|
|
9
|
+
"""Schema for reading user data (responses)."""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UserCreate(schemas.BaseUserCreate):
|
|
15
|
+
"""Schema for creating a new user."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UserUpdate(schemas.BaseUserUpdate):
|
|
21
|
+
"""Schema for updating an existing user."""
|
|
22
|
+
|
|
23
|
+
pass
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Configuration settings for zndraw-auth."""
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
|
|
5
|
+
from pydantic import SecretStr
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthSettings(BaseSettings):
|
|
10
|
+
"""Authentication settings loaded from environment variables.
|
|
11
|
+
|
|
12
|
+
All settings can be overridden with ZNDRAW_AUTH_ prefix.
|
|
13
|
+
Example: ZNDRAW_AUTH_SECRET_KEY=your-secret-key
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
model_config = SettingsConfigDict(
|
|
17
|
+
env_prefix="ZNDRAW_AUTH_",
|
|
18
|
+
env_file=".env",
|
|
19
|
+
extra="ignore",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# JWT settings
|
|
23
|
+
secret_key: SecretStr = SecretStr("CHANGE-ME-IN-PRODUCTION")
|
|
24
|
+
token_lifetime_seconds: int = 3600 # 1 hour
|
|
25
|
+
|
|
26
|
+
# Database
|
|
27
|
+
database_url: str = "sqlite+aiosqlite:///./zndraw_auth.db"
|
|
28
|
+
|
|
29
|
+
# Password reset / verification tokens
|
|
30
|
+
reset_password_token_secret: SecretStr = SecretStr("CHANGE-ME-RESET")
|
|
31
|
+
verification_token_secret: SecretStr = SecretStr("CHANGE-ME-VERIFY")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@lru_cache
|
|
35
|
+
def get_auth_settings() -> AuthSettings:
|
|
36
|
+
return AuthSettings()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""FastAPI-Users configuration and exported dependencies.
|
|
2
|
+
|
|
3
|
+
This module exports the key dependencies that other packages should import:
|
|
4
|
+
- current_active_user: Depends() for authenticated active user
|
|
5
|
+
- current_superuser: Depends() for authenticated superuser
|
|
6
|
+
- fastapi_users: The FastAPIUsers instance for including routers
|
|
7
|
+
- auth_backend: The JWT authentication backend
|
|
8
|
+
|
|
9
|
+
Example usage in other packages:
|
|
10
|
+
from zndraw_auth import current_active_user, User
|
|
11
|
+
|
|
12
|
+
@router.get("/protected")
|
|
13
|
+
async def protected_route(user: User = Depends(current_active_user)):
|
|
14
|
+
return {"user_id": str(user.id)}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import uuid
|
|
18
|
+
from collections.abc import AsyncGenerator
|
|
19
|
+
from typing import Annotated
|
|
20
|
+
|
|
21
|
+
from fastapi import Depends, Request
|
|
22
|
+
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
|
|
23
|
+
from fastapi_users.authentication import (
|
|
24
|
+
AuthenticationBackend,
|
|
25
|
+
BearerTransport,
|
|
26
|
+
JWTStrategy,
|
|
27
|
+
)
|
|
28
|
+
from fastapi_users.db import SQLAlchemyUserDatabase
|
|
29
|
+
|
|
30
|
+
from zndraw_auth.db import User, get_user_db
|
|
31
|
+
from zndraw_auth.settings import AuthSettings, get_auth_settings
|
|
32
|
+
|
|
33
|
+
# --- User Manager ---
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|
37
|
+
"""Custom user manager with lifecycle hooks.
|
|
38
|
+
|
|
39
|
+
Token secrets are set via dependency injection in get_user_manager.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
reset_password_token_secret: str
|
|
43
|
+
verification_token_secret: str
|
|
44
|
+
|
|
45
|
+
async def on_after_register(
|
|
46
|
+
self, user: User, request: Request | None = None
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Called after successful registration."""
|
|
49
|
+
print(f"User {user.id} has registered.")
|
|
50
|
+
|
|
51
|
+
async def on_after_forgot_password(
|
|
52
|
+
self, user: User, token: str, request: Request | None = None
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Called after password reset requested."""
|
|
55
|
+
print(f"User {user.id} forgot password. Reset token: {token}")
|
|
56
|
+
|
|
57
|
+
async def on_after_request_verify(
|
|
58
|
+
self, user: User, token: str, request: Request | None = None
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Called after verification requested."""
|
|
61
|
+
print(f"Verification requested for {user.id}. Token: {token}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def get_user_manager(
|
|
65
|
+
user_db: Annotated[SQLAlchemyUserDatabase[User, uuid.UUID], Depends(get_user_db)],
|
|
66
|
+
settings: Annotated[AuthSettings, Depends(get_auth_settings)],
|
|
67
|
+
) -> AsyncGenerator[UserManager, None]:
|
|
68
|
+
"""FastAPI dependency that yields the user manager."""
|
|
69
|
+
manager = UserManager(user_db)
|
|
70
|
+
manager.reset_password_token_secret = (
|
|
71
|
+
settings.reset_password_token_secret.get_secret_value()
|
|
72
|
+
)
|
|
73
|
+
manager.verification_token_secret = (
|
|
74
|
+
settings.verification_token_secret.get_secret_value()
|
|
75
|
+
)
|
|
76
|
+
yield manager
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- Authentication Backend ---
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_jwt_strategy(
|
|
86
|
+
settings: Annotated[AuthSettings, Depends(get_auth_settings)],
|
|
87
|
+
) -> JWTStrategy[User, uuid.UUID]:
|
|
88
|
+
"""Get JWT strategy with settings."""
|
|
89
|
+
return JWTStrategy(
|
|
90
|
+
secret=settings.secret_key.get_secret_value(),
|
|
91
|
+
lifetime_seconds=settings.token_lifetime_seconds,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
auth_backend = AuthenticationBackend(
|
|
96
|
+
name="jwt",
|
|
97
|
+
transport=bearer_transport,
|
|
98
|
+
get_strategy=get_jwt_strategy,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- FastAPI Users Instance ---
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
fastapi_users = FastAPIUsers[User, uuid.UUID](
|
|
106
|
+
get_user_manager,
|
|
107
|
+
[auth_backend],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --- Exported Dependencies ---
|
|
112
|
+
# These are the main exports that other packages should use
|
|
113
|
+
|
|
114
|
+
current_active_user = fastapi_users.current_user(active=True)
|
|
115
|
+
"""Dependency for routes requiring an authenticated active user.
|
|
116
|
+
|
|
117
|
+
Usage:
|
|
118
|
+
@router.get("/protected")
|
|
119
|
+
async def route(user: User = Depends(current_active_user)):
|
|
120
|
+
...
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
current_superuser = fastapi_users.current_user(active=True, superuser=True)
|
|
124
|
+
"""Dependency for routes requiring superuser privileges.
|
|
125
|
+
|
|
126
|
+
Usage:
|
|
127
|
+
@router.get("/admin")
|
|
128
|
+
async def route(user: User = Depends(current_superuser)):
|
|
129
|
+
...
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
current_optional_user = fastapi_users.current_user(active=True, optional=True)
|
|
133
|
+
"""Dependency for routes with optional authentication.
|
|
134
|
+
|
|
135
|
+
Usage:
|
|
136
|
+
@router.get("/public")
|
|
137
|
+
async def route(user: User | None = Depends(current_optional_user)):
|
|
138
|
+
...
|
|
139
|
+
"""
|