zndraw-auth 0.1.0__tar.gz → 0.2.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 → zndraw_auth-0.2.0}/PKG-INFO +22 -1
- {zndraw_auth-0.1.0 → zndraw_auth-0.2.0}/README.md +21 -0
- {zndraw_auth-0.1.0 → zndraw_auth-0.2.0}/pyproject.toml +1 -1
- {zndraw_auth-0.1.0 → zndraw_auth-0.2.0}/src/zndraw_auth/__init__.py +2 -0
- zndraw_auth-0.2.0/src/zndraw_auth/db.py +146 -0
- {zndraw_auth-0.1.0 → zndraw_auth-0.2.0}/src/zndraw_auth/settings.py +21 -1
- {zndraw_auth-0.1.0 → zndraw_auth-0.2.0}/src/zndraw_auth/users.py +8 -1
- zndraw_auth-0.1.0/src/zndraw_auth/db.py +0 -80
- {zndraw_auth-0.1.0 → zndraw_auth-0.2.0}/src/zndraw_auth/schemas.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: zndraw-auth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Shared authentication for ZnDraw using fastapi-users
|
|
5
5
|
Requires-Dist: fastapi>=0.128.0
|
|
6
6
|
Requires-Dist: fastapi-users[sqlalchemy]>=14.0.0
|
|
@@ -222,6 +222,27 @@ Settings are loaded from environment variables with the `ZNDRAW_AUTH_` prefix:
|
|
|
222
222
|
| `ZNDRAW_AUTH_DATABASE_URL` | `sqlite+aiosqlite:///./zndraw_auth.db` | Database connection URL |
|
|
223
223
|
| `ZNDRAW_AUTH_RESET_PASSWORD_TOKEN_SECRET` | `CHANGE-ME-RESET` | Password reset token secret |
|
|
224
224
|
| `ZNDRAW_AUTH_VERIFICATION_TOKEN_SECRET` | `CHANGE-ME-VERIFY` | Email verification token secret |
|
|
225
|
+
| `ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL` | `None` | Email for the default admin user |
|
|
226
|
+
| `ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD` | `None` | Password for the default admin user |
|
|
227
|
+
|
|
228
|
+
### Dev Mode vs Production Mode
|
|
229
|
+
|
|
230
|
+
The system has two operating modes based on admin configuration:
|
|
231
|
+
|
|
232
|
+
**Dev Mode** (default - no admin configured):
|
|
233
|
+
- All newly registered users are automatically granted superuser privileges
|
|
234
|
+
- Useful for development and testing
|
|
235
|
+
|
|
236
|
+
**Production Mode** (admin configured):
|
|
237
|
+
- Set `ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL` and `ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD`
|
|
238
|
+
- The configured admin user is created/promoted on startup
|
|
239
|
+
- New users are created as regular users (not superusers)
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
# Production mode example
|
|
243
|
+
export ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL=admin@example.com
|
|
244
|
+
export ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD=secure-password
|
|
245
|
+
```
|
|
225
246
|
|
|
226
247
|
## Exports
|
|
227
248
|
|
|
@@ -204,6 +204,27 @@ Settings are loaded from environment variables with the `ZNDRAW_AUTH_` prefix:
|
|
|
204
204
|
| `ZNDRAW_AUTH_DATABASE_URL` | `sqlite+aiosqlite:///./zndraw_auth.db` | Database connection URL |
|
|
205
205
|
| `ZNDRAW_AUTH_RESET_PASSWORD_TOKEN_SECRET` | `CHANGE-ME-RESET` | Password reset token secret |
|
|
206
206
|
| `ZNDRAW_AUTH_VERIFICATION_TOKEN_SECRET` | `CHANGE-ME-VERIFY` | Email verification token secret |
|
|
207
|
+
| `ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL` | `None` | Email for the default admin user |
|
|
208
|
+
| `ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD` | `None` | Password for the default admin user |
|
|
209
|
+
|
|
210
|
+
### Dev Mode vs Production Mode
|
|
211
|
+
|
|
212
|
+
The system has two operating modes based on admin configuration:
|
|
213
|
+
|
|
214
|
+
**Dev Mode** (default - no admin configured):
|
|
215
|
+
- All newly registered users are automatically granted superuser privileges
|
|
216
|
+
- Useful for development and testing
|
|
217
|
+
|
|
218
|
+
**Production Mode** (admin configured):
|
|
219
|
+
- Set `ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL` and `ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD`
|
|
220
|
+
- The configured admin user is created/promoted on startup
|
|
221
|
+
- New users are created as regular users (not superusers)
|
|
222
|
+
|
|
223
|
+
```bash
|
|
224
|
+
# Production mode example
|
|
225
|
+
export ZNDRAW_AUTH_DEFAULT_ADMIN_EMAIL=admin@example.com
|
|
226
|
+
export ZNDRAW_AUTH_DEFAULT_ADMIN_PASSWORD=secure-password
|
|
227
|
+
```
|
|
207
228
|
|
|
208
229
|
## Exports
|
|
209
230
|
|
|
@@ -29,6 +29,7 @@ from zndraw_auth.db import (
|
|
|
29
29
|
Base,
|
|
30
30
|
User,
|
|
31
31
|
create_db_and_tables,
|
|
32
|
+
ensure_default_admin,
|
|
32
33
|
get_async_session,
|
|
33
34
|
get_user_db,
|
|
34
35
|
)
|
|
@@ -51,6 +52,7 @@ __all__ = [
|
|
|
51
52
|
"User",
|
|
52
53
|
# Database
|
|
53
54
|
"create_db_and_tables",
|
|
55
|
+
"ensure_default_admin",
|
|
54
56
|
"get_async_session",
|
|
55
57
|
"get_user_db",
|
|
56
58
|
# Schemas
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Database models and session management."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from fastapi import Depends
|
|
10
|
+
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
|
|
11
|
+
from fastapi_users.password import PasswordHelper
|
|
12
|
+
from sqlalchemy import select
|
|
13
|
+
from sqlalchemy.ext.asyncio import (
|
|
14
|
+
AsyncEngine,
|
|
15
|
+
AsyncSession,
|
|
16
|
+
async_sessionmaker,
|
|
17
|
+
create_async_engine,
|
|
18
|
+
)
|
|
19
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
20
|
+
|
|
21
|
+
from zndraw_auth.settings import AuthSettings, get_auth_settings
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Base(DeclarativeBase):
|
|
27
|
+
"""SQLAlchemy declarative base."""
|
|
28
|
+
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class User(SQLAlchemyBaseUserTableUUID, Base):
|
|
33
|
+
"""User model for authentication.
|
|
34
|
+
|
|
35
|
+
Inherits from fastapi-users base which provides:
|
|
36
|
+
- id: UUID (primary key)
|
|
37
|
+
- email: str (unique, indexed)
|
|
38
|
+
- hashed_password: str
|
|
39
|
+
- is_active: bool (default True)
|
|
40
|
+
- is_superuser: bool (default False)
|
|
41
|
+
- is_verified: bool (default False)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@lru_cache
|
|
48
|
+
def get_engine(database_url: str) -> AsyncEngine:
|
|
49
|
+
"""Get or create the async engine (cached by URL)."""
|
|
50
|
+
return create_async_engine(database_url, echo=False)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@lru_cache
|
|
54
|
+
def get_session_maker(database_url: str) -> async_sessionmaker[AsyncSession]:
|
|
55
|
+
"""Get or create the session maker (cached by URL)."""
|
|
56
|
+
engine = get_engine(database_url)
|
|
57
|
+
return async_sessionmaker(engine, expire_on_commit=False)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def create_db_and_tables(settings: AuthSettings | None = None) -> None:
|
|
61
|
+
"""Create all database tables and ensure default admin exists.
|
|
62
|
+
|
|
63
|
+
Call this in your app's lifespan or startup.
|
|
64
|
+
"""
|
|
65
|
+
if settings is None:
|
|
66
|
+
settings = get_auth_settings()
|
|
67
|
+
engine = get_engine(settings.database_url)
|
|
68
|
+
async with engine.begin() as conn:
|
|
69
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
70
|
+
|
|
71
|
+
# Ensure default admin user exists (if configured)
|
|
72
|
+
await ensure_default_admin(settings)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def ensure_default_admin(settings: AuthSettings | None = None) -> None:
|
|
76
|
+
"""Create or promote the default admin user if configured.
|
|
77
|
+
|
|
78
|
+
If DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD are set:
|
|
79
|
+
- Creates the user with is_superuser=True if they don't exist
|
|
80
|
+
- Promotes existing user to superuser if they exist but aren't one
|
|
81
|
+
|
|
82
|
+
If not configured, does nothing (dev mode - all users are superusers).
|
|
83
|
+
"""
|
|
84
|
+
if settings is None:
|
|
85
|
+
settings = get_auth_settings()
|
|
86
|
+
|
|
87
|
+
admin_email = settings.default_admin_email
|
|
88
|
+
admin_password = settings.default_admin_password
|
|
89
|
+
|
|
90
|
+
if admin_email is None:
|
|
91
|
+
log.info("No default admin configured - running in dev mode")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if admin_password is None:
|
|
95
|
+
log.warning(
|
|
96
|
+
"DEFAULT_ADMIN_EMAIL is set but DEFAULT_ADMIN_PASSWORD is not - "
|
|
97
|
+
"skipping admin creation"
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
session_maker = get_session_maker(settings.database_url)
|
|
102
|
+
password_helper = PasswordHelper()
|
|
103
|
+
|
|
104
|
+
async with session_maker() as session:
|
|
105
|
+
# Check if user already exists
|
|
106
|
+
result = await session.execute(
|
|
107
|
+
select(User).where(User.email == admin_email) # type: ignore[arg-type]
|
|
108
|
+
)
|
|
109
|
+
existing_user = result.scalar_one_or_none()
|
|
110
|
+
|
|
111
|
+
if existing_user is None:
|
|
112
|
+
# Create new admin user
|
|
113
|
+
hashed_password = password_helper.hash(admin_password.get_secret_value())
|
|
114
|
+
admin_user = User(
|
|
115
|
+
email=admin_email,
|
|
116
|
+
hashed_password=hashed_password,
|
|
117
|
+
is_active=True,
|
|
118
|
+
is_superuser=True,
|
|
119
|
+
is_verified=True,
|
|
120
|
+
)
|
|
121
|
+
session.add(admin_user)
|
|
122
|
+
await session.commit()
|
|
123
|
+
log.info(f"Created default admin user: {admin_email}")
|
|
124
|
+
elif not existing_user.is_superuser:
|
|
125
|
+
# Promote existing user to superuser
|
|
126
|
+
existing_user.is_superuser = True
|
|
127
|
+
await session.commit()
|
|
128
|
+
log.info(f"Promoted user to superuser: {admin_email}")
|
|
129
|
+
else:
|
|
130
|
+
log.debug(f"Default admin already exists: {admin_email}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def get_async_session(
|
|
134
|
+
settings: Annotated[AuthSettings, Depends(get_auth_settings)],
|
|
135
|
+
) -> AsyncGenerator[AsyncSession, None]:
|
|
136
|
+
"""FastAPI dependency that yields an async database session."""
|
|
137
|
+
session_maker = get_session_maker(settings.database_url)
|
|
138
|
+
async with session_maker() as session:
|
|
139
|
+
yield session
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def get_user_db(
|
|
143
|
+
session: Annotated[AsyncSession, Depends(get_async_session)],
|
|
144
|
+
) -> AsyncGenerator[SQLAlchemyUserDatabase[User, uuid.UUID], None]:
|
|
145
|
+
"""FastAPI dependency that yields the user database adapter."""
|
|
146
|
+
yield SQLAlchemyUserDatabase[User, uuid.UUID](session, User)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from functools import lru_cache
|
|
4
4
|
|
|
5
|
-
from pydantic import SecretStr
|
|
5
|
+
from pydantic import SecretStr, computed_field
|
|
6
6
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
7
|
|
|
8
8
|
|
|
@@ -11,6 +11,13 @@ class AuthSettings(BaseSettings):
|
|
|
11
11
|
|
|
12
12
|
All settings can be overridden with ZNDRAW_AUTH_ prefix.
|
|
13
13
|
Example: ZNDRAW_AUTH_SECRET_KEY=your-secret-key
|
|
14
|
+
|
|
15
|
+
Admin mode vs Dev mode:
|
|
16
|
+
- If DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD are set, the system
|
|
17
|
+
runs in "production mode": only the configured admin is a superuser,
|
|
18
|
+
and new users are created as regular users.
|
|
19
|
+
- If they are NOT set, the system runs in "dev mode": all newly
|
|
20
|
+
registered users are automatically granted superuser privileges.
|
|
14
21
|
"""
|
|
15
22
|
|
|
16
23
|
model_config = SettingsConfigDict(
|
|
@@ -30,6 +37,19 @@ class AuthSettings(BaseSettings):
|
|
|
30
37
|
reset_password_token_secret: SecretStr = SecretStr("CHANGE-ME-RESET")
|
|
31
38
|
verification_token_secret: SecretStr = SecretStr("CHANGE-ME-VERIFY")
|
|
32
39
|
|
|
40
|
+
# Default admin user (production mode)
|
|
41
|
+
default_admin_email: str | None = None
|
|
42
|
+
"""Email for the default admin user. If set, enables production mode."""
|
|
43
|
+
|
|
44
|
+
default_admin_password: SecretStr | None = None
|
|
45
|
+
"""Password for the default admin user."""
|
|
46
|
+
|
|
47
|
+
@computed_field # type: ignore[prop-decorator]
|
|
48
|
+
@property
|
|
49
|
+
def is_dev_mode(self) -> bool:
|
|
50
|
+
"""True if no admin credentials configured (all users become superusers)."""
|
|
51
|
+
return self.default_admin_email is None
|
|
52
|
+
|
|
33
53
|
|
|
34
54
|
@lru_cache
|
|
35
55
|
def get_auth_settings() -> AuthSettings:
|
|
@@ -28,6 +28,7 @@ from fastapi_users.authentication import (
|
|
|
28
28
|
from fastapi_users.db import SQLAlchemyUserDatabase
|
|
29
29
|
|
|
30
30
|
from zndraw_auth.db import User, get_user_db
|
|
31
|
+
from zndraw_auth.schemas import UserUpdate
|
|
31
32
|
from zndraw_auth.settings import AuthSettings, get_auth_settings
|
|
32
33
|
|
|
33
34
|
# --- User Manager ---
|
|
@@ -41,12 +42,17 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|
|
41
42
|
|
|
42
43
|
reset_password_token_secret: str
|
|
43
44
|
verification_token_secret: str
|
|
45
|
+
is_dev_mode: bool = False
|
|
44
46
|
|
|
45
47
|
async def on_after_register(
|
|
46
48
|
self, user: User, request: Request | None = None
|
|
47
49
|
) -> None:
|
|
48
50
|
"""Called after successful registration."""
|
|
49
|
-
|
|
51
|
+
if self.is_dev_mode and not user.is_superuser:
|
|
52
|
+
await self.update(UserUpdate(is_superuser=True), user, safe=False)
|
|
53
|
+
print(f"User {user.id} has registered (granted superuser - dev mode).")
|
|
54
|
+
else:
|
|
55
|
+
print(f"User {user.id} has registered.")
|
|
50
56
|
|
|
51
57
|
async def on_after_forgot_password(
|
|
52
58
|
self, user: User, token: str, request: Request | None = None
|
|
@@ -73,6 +79,7 @@ async def get_user_manager(
|
|
|
73
79
|
manager.verification_token_secret = (
|
|
74
80
|
settings.verification_token_secret.get_secret_value()
|
|
75
81
|
)
|
|
82
|
+
manager.is_dev_mode = settings.is_dev_mode
|
|
76
83
|
yield manager
|
|
77
84
|
|
|
78
85
|
|
|
@@ -1,80 +0,0 @@
|
|
|
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)
|
|
File without changes
|