fastapi-fullauth 0.2.0__tar.gz → 0.3.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.
- fastapi_fullauth-0.3.0/CHANGELOG.md +87 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/PKG-INFO +52 -31
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/README.md +51 -7
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/auth.py +2 -2
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/__init__.py +3 -3
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/base.py +3 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/memory.py +5 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlalchemy/adapter.py +9 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlmodel/adapter.py +9 -0
- fastapi_fullauth-0.3.0/fastapi_fullauth/core/crypto.py +47 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/core/tokens.py +20 -9
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/dependencies/__init__.py +6 -7
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/dependencies/current_user.py +25 -15
- fastapi_fullauth-0.3.0/fastapi_fullauth/dependencies/require_role.py +33 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/login.py +13 -9
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/password_reset.py +3 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/register.py +8 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/fullauth.py +40 -20
- fastapi_fullauth-0.3.0/fastapi_fullauth/hooks.py +50 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/protection/ratelimit.py +8 -4
- fastapi_fullauth-0.3.0/fastapi_fullauth/router/auth.py +505 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/types.py +20 -15
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/pyproject.toml +1 -17
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_customization.py +2 -5
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_dx_improvements.py +4 -14
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_email_verify.py +2 -5
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_tokens.py +7 -5
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/uv.lock +2 -379
- fastapi_fullauth-0.2.0/CHANGELOG.md +0 -47
- fastapi_fullauth-0.2.0/fastapi_fullauth/core/crypto.py +0 -19
- fastapi_fullauth-0.2.0/fastapi_fullauth/dependencies/require_role.py +0 -78
- fastapi_fullauth-0.2.0/fastapi_fullauth/hooks.py +0 -16
- fastapi_fullauth-0.2.0/fastapi_fullauth/router/auth.py +0 -486
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/.github/workflows/ci.yml +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/.github/workflows/publish.yml +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/.gitignore +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/.python-version +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/LICENSE +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/memory_app/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/memory_app/auth.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/memory_app/main.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/memory_app/routes.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/config.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/main.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/models.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/examples/sqlmodel_app/routes.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlalchemy/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlalchemy/models.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlmodel/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlmodel/models.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/backends/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/backends/base.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/backends/bearer.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/backends/cookie.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/config.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/core/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/core/redis_blacklist.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/exceptions.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/email_verify.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/flows/logout.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/middleware/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/middleware/csrf.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/middleware/ratelimit.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/middleware/security_headers.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/migrations/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/migrations/helpers.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/protection/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/protection/lockout.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/rbac/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/router/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/utils.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/validators.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/__init__.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/conftest.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_auth_flows.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_crypto.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_lockout.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_middleware.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_new_endpoints.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_refresh_tokens.py +0 -0
- {fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/tests/test_roles.py +0 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Breaking changes
|
|
6
|
+
|
|
7
|
+
- **`create_refresh_token` returns `RefreshTokenMeta`** — previously returned a plain `str`. Now returns a `NamedTuple` with `.token`, `.expires_at`, `.family_id`. Callers that used the raw string must access `.token`.
|
|
8
|
+
- **`create_token_pair` returns `tuple[str, RefreshTokenMeta]`** — second element is now `RefreshTokenMeta` instead of `str`.
|
|
9
|
+
- **`revoke_all_user_refresh_tokens` is now required** on custom adapters — new abstract method on `AbstractUserAdapter`.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `current_superuser` dependency and `SuperUser` annotated type
|
|
14
|
+
- `CurrentUser`, `VerifiedUser`, `SuperUser` annotated types in `dependencies.current_user` for cleaner route signatures
|
|
15
|
+
- `RefreshTokenMeta` named tuple — avoids decoding freshly created tokens just to read `expires_at` and `family_id`
|
|
16
|
+
- `FullAuth.get_custom_claims(user)` — moved custom claims logic from router into the class, with validation against reserved JWT keys (`sub`, `exp`, `type`, etc.)
|
|
17
|
+
- `revoke_all_user_refresh_tokens(user_id)` on all adapters — bulk session revocation
|
|
18
|
+
- Session revocation on password reset, password change, and account deletion
|
|
19
|
+
- `configure_hasher()` — wires `PASSWORD_HASH_ALGORITHM` config to the actual hasher; supports `argon2id` and `bcrypt`
|
|
20
|
+
- Automatic password rehash on login when hash algorithm or params have changed
|
|
21
|
+
- Register now checks uniqueness on `login_field` (not just email) when `login_field != "email"`
|
|
22
|
+
- `InMemoryBlacklist` now respects `ttl_seconds` — expired entries are evicted on lookup
|
|
23
|
+
- `RateLimiter` evicts keys with empty timestamp lists to prevent unbounded dict growth
|
|
24
|
+
- `description` parameter on all route decorators for Swagger docs
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- `current_active_verified_user` was missing `payload.type != "access"` check — refresh tokens could pass through
|
|
29
|
+
- Purpose tokens (password reset, email verify) could be used as regular access tokens — `current_user` now rejects tokens with `extra.purpose`
|
|
30
|
+
- Duplicate token decode + user lookup across dependencies, router endpoints, and admin routes — consolidated into reusable `current_user` dependency chain
|
|
31
|
+
- Duplicate `roles` + `extra_claims` fetch in refresh route — pulled above the if/else branch
|
|
32
|
+
- Login flow fetched the user from DB twice (once in router, once in `login()`) — now accepts pre-fetched user
|
|
33
|
+
- Unused `request: Request` parameters in dependencies and routes
|
|
34
|
+
- Removed duplicate docstrings on routes (kept `description=` on decorators)
|
|
35
|
+
- `require_permission` was a full copy of `require_role` — now delegates to it
|
|
36
|
+
|
|
37
|
+
### Internal
|
|
38
|
+
|
|
39
|
+
- Route order follows auth lifecycle: register → login → refresh → logout → user → email/password → admin
|
|
40
|
+
- `require_role` / `require_permission` use `Depends(current_user)` instead of duplicating token logic
|
|
41
|
+
- Removed `_get_custom_claims` module-level function from router
|
|
42
|
+
|
|
43
|
+
## 0.2.0
|
|
44
|
+
|
|
45
|
+
### Breaking changes
|
|
46
|
+
|
|
47
|
+
- **JSON login** — `POST /login` now accepts `{"email": "...", "password": "..."}` instead of form data. Swagger auth uses bearer token input instead of username/password form.
|
|
48
|
+
- **No default User model** — SQLModel and SQLAlchemy adapters no longer ship a concrete `User`/`UserModel` table class. Users must define their own model from `UserBase`. This eliminates relationship conflicts when subclassing.
|
|
49
|
+
- **`user_model` is required** — `SQLModelAdapter(session_maker, user_model=MyUser)` — no default.
|
|
50
|
+
- **Removed `min_length=8`** from `CreateUserSchema` — password length is now fully controlled by `PasswordValidator` and `PASSWORD_MIN_LENGTH` config.
|
|
51
|
+
- **`SQLAlchemyAdapter` renamed `UserModel` to `UserBase`** — import `UserBase` instead.
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- `POST /auth/change-password` — verifies current password, validates new
|
|
56
|
+
- `PATCH /auth/me` — update profile with protected field filtering
|
|
57
|
+
- `DELETE /auth/me` — self-deletion
|
|
58
|
+
- `expires_in` in login/refresh responses
|
|
59
|
+
- Per-IP auth rate limiting on login, register, password-reset (`AUTH_RATE_LIMIT_*` config)
|
|
60
|
+
- `LOGIN_FIELD` config — login by email, username, phone, or any model field
|
|
61
|
+
- `get_user_by_field()` on all adapters for generic field lookups
|
|
62
|
+
- Structured example apps (`examples/memory_app/`, `examples/sqlmodel_app/`)
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- `InMemoryAdapter.update_user` returning base `UserSchema` instead of custom schema
|
|
67
|
+
- Stale `User.id` / `UserModel` references in adapter queries after model removal
|
|
68
|
+
- Parameter ordering in adapter constructors (required params before optional)
|
|
69
|
+
|
|
70
|
+
## 0.1.0
|
|
71
|
+
|
|
72
|
+
Initial release.
|
|
73
|
+
|
|
74
|
+
- JWT access/refresh tokens with rotation and blacklisting
|
|
75
|
+
- Argon2id password hashing
|
|
76
|
+
- Auth flows: register, login, logout, password reset, email verification
|
|
77
|
+
- Brute-force lockout, per-IP rate limiting, CSRF, security headers
|
|
78
|
+
- Bearer and cookie backends
|
|
79
|
+
- SQLAlchemy, SQLModel, and InMemory adapters
|
|
80
|
+
- Redis blacklist backend
|
|
81
|
+
- Refresh token persistence with family tracking and reuse detection
|
|
82
|
+
- Flat config (`secret_key=...`) or full `FullAuthConfig` object
|
|
83
|
+
- Auto-derive schemas from ORM model fields
|
|
84
|
+
- Auto-wire middleware from config flags
|
|
85
|
+
- Route enum, event hooks, email hooks
|
|
86
|
+
- `current_user`, `current_active_verified_user`, `require_role` dependencies
|
|
87
|
+
- 97 tests
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-fullauth
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Production-grade, async-native authentication and authorization library for FastAPI
|
|
5
5
|
Project-URL: Homepage, https://github.com/mdfarhankc/fastapi-fullauth
|
|
6
6
|
Project-URL: Documentation, https://github.com/mdfarhankc/fastapi-fullauth
|
|
@@ -25,29 +25,12 @@ Requires-Dist: fastapi>=0.110
|
|
|
25
25
|
Requires-Dist: pydantic-settings>=2.0
|
|
26
26
|
Requires-Dist: pydantic[email]>=2.0
|
|
27
27
|
Requires-Dist: pyjwt>=2.8
|
|
28
|
-
Requires-Dist: python-multipart>=0.0.22
|
|
29
28
|
Requires-Dist: uuid-utils>=0.14.1
|
|
30
29
|
Provides-Extra: all
|
|
31
30
|
Requires-Dist: alembic>=1.13; extra == 'all'
|
|
32
|
-
Requires-Dist: beanie>=1.25; extra == 'all'
|
|
33
|
-
Requires-Dist: httpx-oauth>=0.13; extra == 'all'
|
|
34
|
-
Requires-Dist: itsdangerous>=2.1; extra == 'all'
|
|
35
|
-
Requires-Dist: pyotp>=2.9; extra == 'all'
|
|
36
|
-
Requires-Dist: qrcode>=7.4; extra == 'all'
|
|
37
31
|
Requires-Dist: redis>=5.0; extra == 'all'
|
|
38
32
|
Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'all'
|
|
39
33
|
Requires-Dist: sqlmodel>=0.0.16; extra == 'all'
|
|
40
|
-
Requires-Dist: tortoise-orm>=0.21; extra == 'all'
|
|
41
|
-
Requires-Dist: webauthn>=2.0; extra == 'all'
|
|
42
|
-
Provides-Extra: audit
|
|
43
|
-
Provides-Extra: beanie
|
|
44
|
-
Requires-Dist: beanie>=1.25; extra == 'beanie'
|
|
45
|
-
Provides-Extra: magic-link
|
|
46
|
-
Requires-Dist: itsdangerous>=2.1; extra == 'magic-link'
|
|
47
|
-
Provides-Extra: oauth
|
|
48
|
-
Requires-Dist: httpx-oauth>=0.13; extra == 'oauth'
|
|
49
|
-
Provides-Extra: passkeys
|
|
50
|
-
Requires-Dist: webauthn>=2.0; extra == 'passkeys'
|
|
51
34
|
Provides-Extra: redis
|
|
52
35
|
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
53
36
|
Provides-Extra: sqlalchemy
|
|
@@ -56,12 +39,6 @@ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == 'sqlalchemy'
|
|
|
56
39
|
Provides-Extra: sqlmodel
|
|
57
40
|
Requires-Dist: alembic>=1.13; extra == 'sqlmodel'
|
|
58
41
|
Requires-Dist: sqlmodel>=0.0.16; extra == 'sqlmodel'
|
|
59
|
-
Provides-Extra: tenants
|
|
60
|
-
Provides-Extra: tortoise
|
|
61
|
-
Requires-Dist: tortoise-orm>=0.21; extra == 'tortoise'
|
|
62
|
-
Provides-Extra: totp
|
|
63
|
-
Requires-Dist: pyotp>=2.9; extra == 'totp'
|
|
64
|
-
Requires-Dist: qrcode>=7.4; extra == 'totp'
|
|
65
42
|
Description-Content-Type: text/markdown
|
|
66
43
|
|
|
67
44
|
# fastapi-fullauth
|
|
@@ -131,16 +108,31 @@ No need to create separate schema classes or subclass the adapter. Registration
|
|
|
131
108
|
|
|
132
109
|
## Protected routes
|
|
133
110
|
|
|
111
|
+
Use the `Annotated` types for clean route signatures:
|
|
112
|
+
|
|
134
113
|
```python
|
|
135
|
-
from
|
|
136
|
-
from fastapi_fullauth.dependencies import current_user, require_role
|
|
114
|
+
from fastapi_fullauth.dependencies import CurrentUser, VerifiedUser, SuperUser, require_role
|
|
137
115
|
|
|
138
116
|
@app.get("/profile")
|
|
139
|
-
async def profile(user
|
|
117
|
+
async def profile(user: CurrentUser):
|
|
140
118
|
return user
|
|
141
119
|
|
|
120
|
+
@app.get("/dashboard")
|
|
121
|
+
async def dashboard(user: VerifiedUser):
|
|
122
|
+
# only email-verified users
|
|
123
|
+
return {"email": user.email}
|
|
124
|
+
|
|
142
125
|
@app.delete("/admin/users/{id}")
|
|
143
|
-
async def delete_user(user
|
|
126
|
+
async def delete_user(user: SuperUser):
|
|
127
|
+
# only superusers
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
# or use require_role for custom roles
|
|
131
|
+
from fastapi import Depends
|
|
132
|
+
from fastapi_fullauth.dependencies import require_role
|
|
133
|
+
|
|
134
|
+
@app.get("/editor")
|
|
135
|
+
async def editor_panel(user=Depends(require_role("editor"))):
|
|
144
136
|
...
|
|
145
137
|
```
|
|
146
138
|
|
|
@@ -190,15 +182,44 @@ fullauth.hooks.on("after_register", welcome)
|
|
|
190
182
|
|
|
191
183
|
Events: `after_register`, `after_login`, `after_logout`, `after_password_change`, `after_password_reset`, `after_email_verify`, `send_verification_email`, `send_password_reset_email`
|
|
192
184
|
|
|
193
|
-
##
|
|
185
|
+
## Custom token claims
|
|
186
|
+
|
|
187
|
+
Embed app-specific data into JWTs (available in `payload.extra`):
|
|
194
188
|
|
|
195
189
|
```python
|
|
196
|
-
|
|
190
|
+
async def add_claims(user):
|
|
191
|
+
return {"tenant_id": "acme", "plan": "pro"}
|
|
192
|
+
|
|
193
|
+
fullauth = FullAuth(
|
|
194
|
+
secret_key="...",
|
|
195
|
+
adapter=adapter,
|
|
196
|
+
on_create_token_claims=add_claims,
|
|
197
|
+
)
|
|
198
|
+
```
|
|
197
199
|
|
|
200
|
+
Reserved keys (`sub`, `exp`, `iat`, `jti`, `type`, `roles`, `extra`, `family_id`) are rejected to prevent accidental overwrites.
|
|
201
|
+
|
|
202
|
+
## Password hashing
|
|
203
|
+
|
|
204
|
+
Argon2id by default. Switch to bcrypt via config:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
fullauth = FullAuth(
|
|
208
|
+
secret_key="...",
|
|
209
|
+
adapter=adapter,
|
|
210
|
+
password_hash_algorithm="bcrypt", # requires: pip install bcrypt
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
When switching algorithms, existing users are transparently rehashed on their next login.
|
|
215
|
+
|
|
216
|
+
## Route control
|
|
217
|
+
|
|
218
|
+
```python
|
|
198
219
|
fullauth = FullAuth(
|
|
199
220
|
secret_key="...",
|
|
200
221
|
adapter=adapter,
|
|
201
|
-
enabled_routes=[
|
|
222
|
+
enabled_routes=["login", "logout", "refresh"],
|
|
202
223
|
)
|
|
203
224
|
```
|
|
204
225
|
|
|
@@ -65,16 +65,31 @@ No need to create separate schema classes or subclass the adapter. Registration
|
|
|
65
65
|
|
|
66
66
|
## Protected routes
|
|
67
67
|
|
|
68
|
+
Use the `Annotated` types for clean route signatures:
|
|
69
|
+
|
|
68
70
|
```python
|
|
69
|
-
from
|
|
70
|
-
from fastapi_fullauth.dependencies import current_user, require_role
|
|
71
|
+
from fastapi_fullauth.dependencies import CurrentUser, VerifiedUser, SuperUser, require_role
|
|
71
72
|
|
|
72
73
|
@app.get("/profile")
|
|
73
|
-
async def profile(user
|
|
74
|
+
async def profile(user: CurrentUser):
|
|
74
75
|
return user
|
|
75
76
|
|
|
77
|
+
@app.get("/dashboard")
|
|
78
|
+
async def dashboard(user: VerifiedUser):
|
|
79
|
+
# only email-verified users
|
|
80
|
+
return {"email": user.email}
|
|
81
|
+
|
|
76
82
|
@app.delete("/admin/users/{id}")
|
|
77
|
-
async def delete_user(user
|
|
83
|
+
async def delete_user(user: SuperUser):
|
|
84
|
+
# only superusers
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
# or use require_role for custom roles
|
|
88
|
+
from fastapi import Depends
|
|
89
|
+
from fastapi_fullauth.dependencies import require_role
|
|
90
|
+
|
|
91
|
+
@app.get("/editor")
|
|
92
|
+
async def editor_panel(user=Depends(require_role("editor"))):
|
|
78
93
|
...
|
|
79
94
|
```
|
|
80
95
|
|
|
@@ -124,15 +139,44 @@ fullauth.hooks.on("after_register", welcome)
|
|
|
124
139
|
|
|
125
140
|
Events: `after_register`, `after_login`, `after_logout`, `after_password_change`, `after_password_reset`, `after_email_verify`, `send_verification_email`, `send_password_reset_email`
|
|
126
141
|
|
|
127
|
-
##
|
|
142
|
+
## Custom token claims
|
|
143
|
+
|
|
144
|
+
Embed app-specific data into JWTs (available in `payload.extra`):
|
|
128
145
|
|
|
129
146
|
```python
|
|
130
|
-
|
|
147
|
+
async def add_claims(user):
|
|
148
|
+
return {"tenant_id": "acme", "plan": "pro"}
|
|
149
|
+
|
|
150
|
+
fullauth = FullAuth(
|
|
151
|
+
secret_key="...",
|
|
152
|
+
adapter=adapter,
|
|
153
|
+
on_create_token_claims=add_claims,
|
|
154
|
+
)
|
|
155
|
+
```
|
|
131
156
|
|
|
157
|
+
Reserved keys (`sub`, `exp`, `iat`, `jti`, `type`, `roles`, `extra`, `family_id`) are rejected to prevent accidental overwrites.
|
|
158
|
+
|
|
159
|
+
## Password hashing
|
|
160
|
+
|
|
161
|
+
Argon2id by default. Switch to bcrypt via config:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
fullauth = FullAuth(
|
|
165
|
+
secret_key="...",
|
|
166
|
+
adapter=adapter,
|
|
167
|
+
password_hash_algorithm="bcrypt", # requires: pip install bcrypt
|
|
168
|
+
)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
When switching algorithms, existing users are transparently rehashed on their next login.
|
|
172
|
+
|
|
173
|
+
## Route control
|
|
174
|
+
|
|
175
|
+
```python
|
|
132
176
|
fullauth = FullAuth(
|
|
133
177
|
secret_key="...",
|
|
134
178
|
adapter=adapter,
|
|
135
|
-
enabled_routes=[
|
|
179
|
+
enabled_routes=["login", "logout", "refresh"],
|
|
136
180
|
)
|
|
137
181
|
```
|
|
138
182
|
|
|
@@ -21,8 +21,8 @@ async def add_custom_claims(user: UserSchema) -> dict:
|
|
|
21
21
|
fullauth = FullAuth(
|
|
22
22
|
secret_key="change-me-use-a-32-byte-key-here",
|
|
23
23
|
adapter=SQLModelAdapter(session_maker=session_maker, user_model=User),
|
|
24
|
-
on_send_verification_email=send_verification_email,
|
|
25
|
-
on_send_password_reset_email=send_password_reset_email,
|
|
26
24
|
on_create_token_claims=add_custom_claims,
|
|
27
25
|
include_user_in_login=True,
|
|
28
26
|
)
|
|
27
|
+
fullauth.hooks.on("send_verification_email", send_verification_email)
|
|
28
|
+
fullauth.hooks.on("send_password_reset_email", send_password_reset_email)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.3.0"
|
|
2
2
|
|
|
3
3
|
from fastapi_fullauth.config import FullAuthConfig
|
|
4
4
|
from fastapi_fullauth.fullauth import FullAuth
|
|
5
|
-
from fastapi_fullauth.types import
|
|
5
|
+
from fastapi_fullauth.types import RouteName
|
|
6
6
|
from fastapi_fullauth.utils import create_superuser, generate_secret_key
|
|
7
7
|
from fastapi_fullauth.validators import PasswordValidator
|
|
8
8
|
|
|
@@ -10,7 +10,7 @@ __all__ = [
|
|
|
10
10
|
"FullAuth",
|
|
11
11
|
"FullAuthConfig",
|
|
12
12
|
"PasswordValidator",
|
|
13
|
-
"
|
|
13
|
+
"RouteName",
|
|
14
14
|
"create_superuser",
|
|
15
15
|
"generate_secret_key",
|
|
16
16
|
]
|
|
@@ -55,6 +55,9 @@ class AbstractUserAdapter(ABC):
|
|
|
55
55
|
@abstractmethod
|
|
56
56
|
async def revoke_refresh_token_family(self, family_id: str) -> None: ...
|
|
57
57
|
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def revoke_all_user_refresh_tokens(self, user_id: str) -> None: ...
|
|
60
|
+
|
|
58
61
|
@abstractmethod
|
|
59
62
|
async def set_user_verified(self, user_id: str) -> None: ...
|
|
60
63
|
|
|
@@ -82,6 +82,11 @@ class InMemoryAdapter(AbstractUserAdapter):
|
|
|
82
82
|
if tok.family_id == family_id:
|
|
83
83
|
tok.revoked = True
|
|
84
84
|
|
|
85
|
+
async def revoke_all_user_refresh_tokens(self, user_id: str) -> None:
|
|
86
|
+
for tok in self._refresh_tokens.values():
|
|
87
|
+
if tok.user_id == user_id:
|
|
88
|
+
tok.revoked = True
|
|
89
|
+
|
|
85
90
|
async def set_user_verified(self, user_id: str) -> None:
|
|
86
91
|
if user_id in self._users:
|
|
87
92
|
self._users[user_id]["is_verified"] = True
|
{fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlalchemy/adapter.py
RENAMED
|
@@ -203,6 +203,15 @@ class SQLAlchemyAdapter(AbstractUserAdapter):
|
|
|
203
203
|
)
|
|
204
204
|
await session.commit()
|
|
205
205
|
|
|
206
|
+
async def revoke_all_user_refresh_tokens(self, user_id: str) -> None:
|
|
207
|
+
async with self._session_maker() as session:
|
|
208
|
+
await session.execute(
|
|
209
|
+
update(RefreshTokenModel)
|
|
210
|
+
.where(RefreshTokenModel.user_id == user_id)
|
|
211
|
+
.values(revoked=True)
|
|
212
|
+
)
|
|
213
|
+
await session.commit()
|
|
214
|
+
|
|
206
215
|
async def set_user_verified(self, user_id: str) -> None:
|
|
207
216
|
async with self._session_maker() as session:
|
|
208
217
|
await session.execute(
|
{fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/adapters/sqlmodel/adapter.py
RENAMED
|
@@ -180,6 +180,15 @@ class SQLModelAdapter(AbstractUserAdapter):
|
|
|
180
180
|
)
|
|
181
181
|
await session.commit()
|
|
182
182
|
|
|
183
|
+
async def revoke_all_user_refresh_tokens(self, user_id: str) -> None:
|
|
184
|
+
async with self._session_maker() as session:
|
|
185
|
+
await session.execute(
|
|
186
|
+
update(RefreshTokenRecord)
|
|
187
|
+
.where(RefreshTokenRecord.user_id == user_id)
|
|
188
|
+
.values(revoked=True)
|
|
189
|
+
)
|
|
190
|
+
await session.commit()
|
|
191
|
+
|
|
183
192
|
async def set_user_verified(self, user_id: str) -> None:
|
|
184
193
|
async with self._session_maker() as session:
|
|
185
194
|
await session.execute(
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from argon2 import PasswordHasher
|
|
4
|
+
from argon2.exceptions import VerificationError, VerifyMismatchError
|
|
5
|
+
|
|
6
|
+
_argon2_hasher = PasswordHasher()
|
|
7
|
+
_algorithm: Literal["argon2id", "bcrypt"] = "argon2id"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def configure_hasher(algorithm: Literal["argon2id", "bcrypt"] = "argon2id") -> None:
|
|
11
|
+
global _algorithm
|
|
12
|
+
if algorithm == "bcrypt":
|
|
13
|
+
try:
|
|
14
|
+
import bcrypt # noqa: F401
|
|
15
|
+
except ImportError:
|
|
16
|
+
raise ImportError(
|
|
17
|
+
"bcrypt is not installed. Install it with: pip install bcrypt"
|
|
18
|
+
) from None
|
|
19
|
+
_algorithm = algorithm
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hash_password(password: str) -> str:
|
|
23
|
+
if _algorithm == "bcrypt":
|
|
24
|
+
import bcrypt
|
|
25
|
+
|
|
26
|
+
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
27
|
+
return _argon2_hasher.hash(password)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def verify_password(plain: str, hashed: str) -> bool:
|
|
31
|
+
if _algorithm == "bcrypt" or hashed.startswith("$2b$"):
|
|
32
|
+
try:
|
|
33
|
+
import bcrypt
|
|
34
|
+
|
|
35
|
+
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
|
36
|
+
except ImportError:
|
|
37
|
+
return False
|
|
38
|
+
try:
|
|
39
|
+
return _argon2_hasher.verify(hashed, plain)
|
|
40
|
+
except (VerifyMismatchError, VerificationError):
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def password_needs_rehash(hashed: str) -> bool:
|
|
45
|
+
if hashed.startswith("$2b$"):
|
|
46
|
+
return _algorithm != "bcrypt"
|
|
47
|
+
return _argon2_hasher.check_needs_rehash(hashed)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import time
|
|
1
2
|
import uuid
|
|
2
3
|
from datetime import datetime, timedelta, timezone
|
|
3
4
|
|
|
@@ -5,7 +6,7 @@ import jwt
|
|
|
5
6
|
|
|
6
7
|
from fastapi_fullauth.config import FullAuthConfig
|
|
7
8
|
from fastapi_fullauth.exceptions import TokenBlacklistedError, TokenError, TokenExpiredError
|
|
8
|
-
from fastapi_fullauth.types import TokenPayload
|
|
9
|
+
from fastapi_fullauth.types import RefreshTokenMeta, TokenPayload
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class TokenBlacklist:
|
|
@@ -18,13 +19,20 @@ class TokenBlacklist:
|
|
|
18
19
|
|
|
19
20
|
class InMemoryBlacklist(TokenBlacklist):
|
|
20
21
|
def __init__(self) -> None:
|
|
21
|
-
self._blacklisted:
|
|
22
|
+
self._blacklisted: dict[str, float | None] = {}
|
|
22
23
|
|
|
23
24
|
async def add(self, jti: str, ttl_seconds: int | None = None) -> None:
|
|
24
|
-
|
|
25
|
+
expires_at = (time.monotonic() + ttl_seconds) if ttl_seconds else None
|
|
26
|
+
self._blacklisted[jti] = expires_at
|
|
25
27
|
|
|
26
28
|
async def is_blacklisted(self, jti: str) -> bool:
|
|
27
|
-
|
|
29
|
+
expires_at = self._blacklisted.get(jti)
|
|
30
|
+
if expires_at is None and jti not in self._blacklisted:
|
|
31
|
+
return False
|
|
32
|
+
if expires_at is not None and time.monotonic() > expires_at:
|
|
33
|
+
del self._blacklisted[jti]
|
|
34
|
+
return False
|
|
35
|
+
return True
|
|
28
36
|
|
|
29
37
|
|
|
30
38
|
class TokenEngine:
|
|
@@ -54,17 +62,20 @@ class TokenEngine:
|
|
|
54
62
|
self,
|
|
55
63
|
user_id: str,
|
|
56
64
|
family_id: str | None = None,
|
|
57
|
-
) ->
|
|
65
|
+
) -> RefreshTokenMeta:
|
|
58
66
|
now = datetime.now(timezone.utc)
|
|
67
|
+
expires_at = now + timedelta(days=self.config.REFRESH_TOKEN_EXPIRE_DAYS)
|
|
68
|
+
resolved_family_id = family_id or uuid.uuid4().hex
|
|
59
69
|
payload = {
|
|
60
70
|
"sub": user_id,
|
|
61
|
-
"exp":
|
|
71
|
+
"exp": expires_at,
|
|
62
72
|
"iat": now,
|
|
63
73
|
"jti": uuid.uuid4().hex,
|
|
64
74
|
"type": "refresh",
|
|
65
|
-
"family_id":
|
|
75
|
+
"family_id": resolved_family_id,
|
|
66
76
|
}
|
|
67
|
-
|
|
77
|
+
token = jwt.encode(payload, self.config.SECRET_KEY, algorithm=self.config.ALGORITHM)
|
|
78
|
+
return RefreshTokenMeta(token=token, expires_at=expires_at, family_id=resolved_family_id)
|
|
68
79
|
|
|
69
80
|
async def decode_token(self, token: str) -> TokenPayload:
|
|
70
81
|
try:
|
|
@@ -98,7 +109,7 @@ class TokenEngine:
|
|
|
98
109
|
roles: list[str] | None = None,
|
|
99
110
|
extra: dict | None = None,
|
|
100
111
|
family_id: str | None = None,
|
|
101
|
-
) -> tuple[str,
|
|
112
|
+
) -> tuple[str, RefreshTokenMeta]:
|
|
102
113
|
access = self.create_access_token(user_id, roles, extra)
|
|
103
114
|
refresh = self.create_refresh_token(user_id, family_id)
|
|
104
115
|
return access, refresh
|
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
from typing import Annotated
|
|
2
|
-
|
|
3
|
-
from fastapi import Depends
|
|
4
|
-
|
|
5
1
|
from fastapi_fullauth.dependencies.current_user import (
|
|
6
2
|
current_active_verified_user,
|
|
3
|
+
current_superuser,
|
|
7
4
|
current_user,
|
|
5
|
+
CurrentUser,
|
|
6
|
+
VerifiedUser,
|
|
7
|
+
SuperUser,
|
|
8
8
|
)
|
|
9
9
|
from fastapi_fullauth.dependencies.require_role import require_permission, require_role
|
|
10
|
-
from fastapi_fullauth.types import UserSchema
|
|
11
10
|
|
|
12
|
-
CurrentUser = Annotated[UserSchema, Depends(current_user)]
|
|
13
|
-
VerifiedUser = Annotated[UserSchema, Depends(current_active_verified_user)]
|
|
14
11
|
|
|
15
12
|
__all__ = [
|
|
16
13
|
"CurrentUser",
|
|
17
14
|
"VerifiedUser",
|
|
15
|
+
"SuperUser",
|
|
18
16
|
"current_active_verified_user",
|
|
17
|
+
"current_superuser",
|
|
19
18
|
"current_user",
|
|
20
19
|
"require_permission",
|
|
21
20
|
"require_role",
|
{fastapi_fullauth-0.2.0 → fastapi_fullauth-0.3.0}/fastapi_fullauth/dependencies/current_user.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import TYPE_CHECKING
|
|
1
|
+
from typing import TYPE_CHECKING, Annotated
|
|
2
2
|
|
|
3
3
|
from fastapi import Depends, Request
|
|
4
4
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
@@ -6,6 +6,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
|
6
6
|
from fastapi_fullauth.exceptions import CREDENTIALS_EXCEPTION
|
|
7
7
|
from fastapi_fullauth.types import UserSchema
|
|
8
8
|
|
|
9
|
+
|
|
9
10
|
if TYPE_CHECKING:
|
|
10
11
|
from fastapi_fullauth.fullauth import FullAuth
|
|
11
12
|
|
|
@@ -14,7 +15,8 @@ _bearer_scheme = HTTPBearer(auto_error=False)
|
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def _get_fullauth(request: Request) -> "FullAuth":
|
|
17
|
-
|
|
18
|
+
# type: ignore[union-attr]
|
|
19
|
+
fullauth: FullAuth | None = request.app.state.fullauth
|
|
18
20
|
if fullauth is None:
|
|
19
21
|
raise RuntimeError("FullAuth not initialized on app.state")
|
|
20
22
|
return fullauth
|
|
@@ -36,7 +38,6 @@ async def _extract_token(
|
|
|
36
38
|
|
|
37
39
|
|
|
38
40
|
async def current_user(
|
|
39
|
-
request: Request,
|
|
40
41
|
fullauth: "FullAuth" = Depends(_get_fullauth),
|
|
41
42
|
token: str = Depends(_extract_token),
|
|
42
43
|
) -> UserSchema:
|
|
@@ -50,6 +51,9 @@ async def current_user(
|
|
|
50
51
|
if payload.type != "access":
|
|
51
52
|
raise CREDENTIALS_EXCEPTION
|
|
52
53
|
|
|
54
|
+
if payload.extra.get("purpose"):
|
|
55
|
+
raise CREDENTIALS_EXCEPTION
|
|
56
|
+
|
|
53
57
|
user = await fullauth.adapter.get_user_by_id(payload.sub)
|
|
54
58
|
if user is None or not user.is_active:
|
|
55
59
|
raise CREDENTIALS_EXCEPTION
|
|
@@ -57,22 +61,28 @@ async def current_user(
|
|
|
57
61
|
return user
|
|
58
62
|
|
|
59
63
|
|
|
64
|
+
CurrentUser = Annotated[UserSchema, Depends(current_user)]
|
|
65
|
+
|
|
66
|
+
|
|
60
67
|
async def current_active_verified_user(
|
|
61
|
-
|
|
62
|
-
fullauth: "FullAuth" = Depends(_get_fullauth),
|
|
63
|
-
token: str = Depends(_extract_token),
|
|
68
|
+
user: CurrentUser,
|
|
64
69
|
) -> UserSchema:
|
|
65
|
-
from fastapi_fullauth.exceptions import FORBIDDEN_EXCEPTION
|
|
66
|
-
|
|
67
|
-
try:
|
|
68
|
-
payload = await fullauth.token_engine.decode_token(token)
|
|
69
|
-
except TokenError:
|
|
70
|
-
raise CREDENTIALS_EXCEPTION
|
|
70
|
+
from fastapi_fullauth.exceptions import FORBIDDEN_EXCEPTION
|
|
71
71
|
|
|
72
|
-
user = await fullauth.adapter.get_user_by_id(payload.sub)
|
|
73
|
-
if user is None or not user.is_active:
|
|
74
|
-
raise CREDENTIALS_EXCEPTION
|
|
75
72
|
if not user.is_verified:
|
|
76
73
|
raise FORBIDDEN_EXCEPTION
|
|
74
|
+
return user
|
|
77
75
|
|
|
76
|
+
|
|
77
|
+
async def current_superuser(
|
|
78
|
+
user: CurrentUser,
|
|
79
|
+
) -> UserSchema:
|
|
80
|
+
from fastapi_fullauth.exceptions import FORBIDDEN_EXCEPTION
|
|
81
|
+
|
|
82
|
+
if not user.is_superuser:
|
|
83
|
+
raise FORBIDDEN_EXCEPTION
|
|
78
84
|
return user
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
VerifiedUser = Annotated[UserSchema, Depends(current_active_verified_user)]
|
|
88
|
+
SuperUser = Annotated[UserSchema, Depends(current_superuser)]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from fastapi import Depends
|
|
2
|
+
|
|
3
|
+
from fastapi_fullauth.dependencies.current_user import current_user
|
|
4
|
+
from fastapi_fullauth.exceptions import FORBIDDEN_EXCEPTION
|
|
5
|
+
from fastapi_fullauth.types import UserSchema
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def require_role(*roles: str):
|
|
9
|
+
"""Dependency that checks the user has at least one of the given roles."""
|
|
10
|
+
|
|
11
|
+
async def _dep(
|
|
12
|
+
user: UserSchema = Depends(current_user),
|
|
13
|
+
) -> UserSchema:
|
|
14
|
+
if user.is_superuser:
|
|
15
|
+
return user
|
|
16
|
+
|
|
17
|
+
user_roles = set(user.roles)
|
|
18
|
+
if not user_roles.intersection(roles):
|
|
19
|
+
raise FORBIDDEN_EXCEPTION
|
|
20
|
+
|
|
21
|
+
return user
|
|
22
|
+
|
|
23
|
+
return _dep
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def require_permission(*permissions: str):
|
|
27
|
+
"""Dependency that checks the user has at least one of the given permissions.
|
|
28
|
+
|
|
29
|
+
Permissions use the format 'resource:action' (e.g. 'posts:delete').
|
|
30
|
+
When a full permission engine is added, this will resolve permissions
|
|
31
|
+
against it. For now, permissions map 1:1 to roles.
|
|
32
|
+
"""
|
|
33
|
+
return require_role(*permissions)
|