fastapi-async-auth-kit 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.
- fastapi_async_auth_kit-0.1.0.dist-info/METADATA +21 -0
- fastapi_async_auth_kit-0.1.0.dist-info/RECORD +17 -0
- fastapi_async_auth_kit-0.1.0.dist-info/WHEEL +5 -0
- fastapi_async_auth_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastapi_async_auth_kit-0.1.0.dist-info/top_level.txt +1 -0
- fastapi_auth_kit/__init__.py +5 -0
- fastapi_auth_kit/api/auth.py +34 -0
- fastapi_auth_kit/core/config.py +6 -0
- fastapi_auth_kit/core/security.py +33 -0
- fastapi_auth_kit/core/setup.py +14 -0
- fastapi_auth_kit/db/base.py +6 -0
- fastapi_auth_kit/db/factory.py +10 -0
- fastapi_auth_kit/db/mongo_repo.py +27 -0
- fastapi_auth_kit/db/sqlalchemy_repo.py +61 -0
- fastapi_auth_kit/dependencies/auth.py +14 -0
- fastapi_auth_kit/schemas/auth.py +49 -0
- fastapi_auth_kit/services/auth_service.py +100 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-async-auth-kit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async FastAPI auth with JWT, refresh tokens, logout
|
|
5
|
+
Author-email: Keyurkumar <kmistry1110@gmail.com>
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: fastapi
|
|
9
|
+
Requires-Dist: pydantic
|
|
10
|
+
Requires-Dist: python-jose
|
|
11
|
+
Requires-Dist: argon2-cffi
|
|
12
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
13
|
+
Provides-Extra: postgres
|
|
14
|
+
Requires-Dist: asyncpg; extra == "postgres"
|
|
15
|
+
Provides-Extra: mysql
|
|
16
|
+
Requires-Dist: aiomysql; extra == "mysql"
|
|
17
|
+
Provides-Extra: sqlite
|
|
18
|
+
Requires-Dist: aiosqlite; extra == "sqlite"
|
|
19
|
+
Provides-Extra: mongodb
|
|
20
|
+
Requires-Dist: motor; extra == "mongodb"
|
|
21
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
fastapi_async_auth_kit-0.1.0.dist-info/licenses/LICENSE,sha256=_NXHqoXfXIN9cX2fcEQZRjJnIeYAo-1zB8J7LsAihlg,1089
|
|
2
|
+
fastapi_auth_kit/__init__.py,sha256=frlKWesm2oGWoCerX1dxcnIrECX6ZybwpYeeHhv9H-A,228
|
|
3
|
+
fastapi_auth_kit/api/auth.py,sha256=sPsWnMWpnz-Ko6bT3iIvKd70l0skjzBVxZYge899NOI,986
|
|
4
|
+
fastapi_auth_kit/core/config.py,sha256=6sYXF4xW0lNplO2xW5SrnIGeyVekqChYgJRpK3cyqf4,157
|
|
5
|
+
fastapi_auth_kit/core/security.py,sha256=Qs6FHWKPhCSvKQZZeRNNLH7d32g0TAWMtN8FG6jwfEE,817
|
|
6
|
+
fastapi_auth_kit/core/setup.py,sha256=j7cSbyoIzsSu8nWKbMsbdxo9pQBst72rAMv78M3YI1o,451
|
|
7
|
+
fastapi_auth_kit/db/base.py,sha256=jjaKf8nclEwdX8IV7J_jKnB1MgqVSl9yK-KDjKFwOV4,279
|
|
8
|
+
fastapi_auth_kit/db/factory.py,sha256=DomyoHGZiasank4yRR1aRDsGmD2dFLL5vX4fE_qJIDU,295
|
|
9
|
+
fastapi_auth_kit/db/mongo_repo.py,sha256=jKmTXl0yVgLCxCoCqp6c1TxO0bt-KufPG810SrHMrHQ,911
|
|
10
|
+
fastapi_auth_kit/db/sqlalchemy_repo.py,sha256=hb2XQKY3smA5RVjNFBZMszotGqcsO57p325_xT6CmM8,2068
|
|
11
|
+
fastapi_auth_kit/dependencies/auth.py,sha256=4g9zFcSzJ43MHtYRUpC9h-IXsfrNVV6ILx6ECJuh80I,458
|
|
12
|
+
fastapi_auth_kit/schemas/auth.py,sha256=f4Hn3wlYuU1Q7tJozh5CbNy8KMvZol4Uno0b9HE_j0w,1340
|
|
13
|
+
fastapi_auth_kit/services/auth_service.py,sha256=NhkKHUfIJDGc3QYdSrosHnwEdTZXmp2jdO93B8s6QMY,3177
|
|
14
|
+
fastapi_async_auth_kit-0.1.0.dist-info/METADATA,sha256=jqxGzQcr7AFc5yxRYg2wM4OyA6zZ8SmPK5OXr123wY4,661
|
|
15
|
+
fastapi_async_auth_kit-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
fastapi_async_auth_kit-0.1.0.dist-info/top_level.txt,sha256=LCeW47pupGZVWyFMSeK48VYfdXS-5RF_FeNNX8-tN0A,17
|
|
17
|
+
fastapi_async_auth_kit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kmistry1110
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fastapi_auth_kit
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, Depends
|
|
2
|
+
from fastapi_auth_kit.schemas.auth import (
|
|
3
|
+
RegisterRequest,
|
|
4
|
+
LoginRequest,
|
|
5
|
+
RefreshRequest,
|
|
6
|
+
TokenResponse
|
|
7
|
+
)
|
|
8
|
+
from fastapi_auth_kit.dependencies.auth import get_current_user
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.post("/register")
|
|
14
|
+
async def register(req: Request, data: RegisterRequest):
|
|
15
|
+
return await req.app.state.auth.register(data.username, data.password)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.post("/login", response_model=TokenResponse)
|
|
19
|
+
async def login(req: Request, data: LoginRequest):
|
|
20
|
+
return await req.app.state.auth.login(data.username, data.password)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.post("/refresh")
|
|
24
|
+
async def refresh(req: Request, data: RefreshRequest):
|
|
25
|
+
return await req.app.state.auth.refresh(data.refresh)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.post("/logout")
|
|
29
|
+
async def logout(req: Request, data: RefreshRequest):
|
|
30
|
+
return await req.app.state.auth.logout(data.refresh)
|
|
31
|
+
|
|
32
|
+
@router.get("/me")
|
|
33
|
+
async def me(user=Depends(get_current_user)):
|
|
34
|
+
return user
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from passlib.context import CryptContext
|
|
2
|
+
from jose import jwt
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
pwd = CryptContext(schemes=["argon2"], deprecated="auto")
|
|
6
|
+
|
|
7
|
+
ALGO = "HS256"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def hash_password(password: str) -> str:
|
|
11
|
+
return pwd.hash(password)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def verify_password(password: str, hashed: str) -> bool:
|
|
15
|
+
valid = pwd.verify(password, hashed)
|
|
16
|
+
|
|
17
|
+
if valid and pwd.needs_update(hashed):
|
|
18
|
+
new_hash = pwd.hash(password)
|
|
19
|
+
|
|
20
|
+
return valid
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_token(data, secret, minutes, ttype):
|
|
24
|
+
payload = data.copy()
|
|
25
|
+
payload.update({
|
|
26
|
+
"exp": datetime.utcnow() + timedelta(minutes=minutes),
|
|
27
|
+
"type": ttype
|
|
28
|
+
})
|
|
29
|
+
return jwt.encode(payload, secret, algorithm=ALGO)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decode_token(token, secret):
|
|
33
|
+
return jwt.decode(token, secret, algorithms=[ALGO])
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from fastapi_auth_kit.db.factory import get_repo
|
|
2
|
+
from fastapi_auth_kit.services.auth_service import AuthService
|
|
3
|
+
from fastapi_auth_kit.api.auth import router
|
|
4
|
+
from fastapi_auth_kit.dependencies import auth
|
|
5
|
+
|
|
6
|
+
async def init_auth(app, config):
|
|
7
|
+
repo = await get_repo(config)
|
|
8
|
+
|
|
9
|
+
service = AuthService(repo, config.secret_key)
|
|
10
|
+
|
|
11
|
+
auth.SECRET = config.secret_key
|
|
12
|
+
|
|
13
|
+
app.state.auth = service
|
|
14
|
+
app.include_router(router, prefix="/auth")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from fastapi_auth_kit.db.sqlalchemy_repo import SQLRepo
|
|
2
|
+
from fastapi_auth_kit.db.mongo_repo import MongoRepo
|
|
3
|
+
|
|
4
|
+
async def get_repo(config):
|
|
5
|
+
if config.db_type == "mongodb":
|
|
6
|
+
return MongoRepo(config.db_url)
|
|
7
|
+
|
|
8
|
+
repo = SQLRepo(config.db_url)
|
|
9
|
+
await repo.init()
|
|
10
|
+
return repo
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from motor.motor_asyncio import AsyncIOMotorClient
|
|
2
|
+
|
|
3
|
+
class MongoRepo:
|
|
4
|
+
def __init__(self, url):
|
|
5
|
+
client = AsyncIOMotorClient(url)
|
|
6
|
+
db = client["auth"]
|
|
7
|
+
self.users = db["users"]
|
|
8
|
+
self.tokens = db["tokens"]
|
|
9
|
+
|
|
10
|
+
async def get_user(self, username):
|
|
11
|
+
return await self.users.find_one({"username": username})
|
|
12
|
+
|
|
13
|
+
async def create_user(self, u, p):
|
|
14
|
+
await self.users.insert_one({"username": u, "password": p})
|
|
15
|
+
|
|
16
|
+
async def save_refresh_token(self, token):
|
|
17
|
+
await self.tokens.insert_one({"token": token, "blacklisted": False})
|
|
18
|
+
|
|
19
|
+
async def is_token_blacklisted(self, token):
|
|
20
|
+
t = await self.tokens.find_one({"token": token})
|
|
21
|
+
return t and t["blacklisted"]
|
|
22
|
+
|
|
23
|
+
async def blacklist_token(self, token):
|
|
24
|
+
await self.tokens.update_one(
|
|
25
|
+
{"token": token},
|
|
26
|
+
{"$set": {"blacklisted": True}}
|
|
27
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
2
|
+
from sqlalchemy.orm import sessionmaker, declarative_base
|
|
3
|
+
from sqlalchemy import Column, String, Integer, select
|
|
4
|
+
|
|
5
|
+
Base = declarative_base()
|
|
6
|
+
|
|
7
|
+
class User(Base):
|
|
8
|
+
__tablename__ = "users"
|
|
9
|
+
id = Column(Integer, primary_key=True)
|
|
10
|
+
username = Column(String, unique=True)
|
|
11
|
+
password = Column(String)
|
|
12
|
+
|
|
13
|
+
class Token(Base):
|
|
14
|
+
__tablename__ = "tokens"
|
|
15
|
+
token = Column(String, primary_key=True)
|
|
16
|
+
blacklisted = Column(Integer, default=0)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SQLRepo:
|
|
20
|
+
def __init__(self, url):
|
|
21
|
+
self.engine = create_async_engine(url, future=True)
|
|
22
|
+
self.Session = sessionmaker(
|
|
23
|
+
self.engine,
|
|
24
|
+
class_=AsyncSession,
|
|
25
|
+
expire_on_commit=False
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
async def init(self):
|
|
29
|
+
async with self.engine.begin() as conn:
|
|
30
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
31
|
+
|
|
32
|
+
async def get_user(self, username):
|
|
33
|
+
async with self.Session() as db:
|
|
34
|
+
res = await db.execute(select(User).where(User.username == username))
|
|
35
|
+
return res.scalar_one_or_none()
|
|
36
|
+
|
|
37
|
+
async def create_user(self, u, p):
|
|
38
|
+
async with self.Session() as db:
|
|
39
|
+
user = User(username=u, password=p)
|
|
40
|
+
db.add(user)
|
|
41
|
+
await db.commit()
|
|
42
|
+
return user
|
|
43
|
+
|
|
44
|
+
async def save_refresh_token(self, token):
|
|
45
|
+
async with self.Session() as db:
|
|
46
|
+
db.add(Token(token=token))
|
|
47
|
+
await db.commit()
|
|
48
|
+
|
|
49
|
+
async def is_token_blacklisted(self, token):
|
|
50
|
+
async with self.Session() as db:
|
|
51
|
+
res = await db.execute(select(Token).where(Token.token == token))
|
|
52
|
+
t = res.scalar_one_or_none()
|
|
53
|
+
return t and t.blacklisted == 1
|
|
54
|
+
|
|
55
|
+
async def blacklist_token(self, token):
|
|
56
|
+
async with self.Session() as db:
|
|
57
|
+
res = await db.execute(select(Token).where(Token.token == token))
|
|
58
|
+
t = res.scalar_one_or_none()
|
|
59
|
+
if t:
|
|
60
|
+
t.blacklisted = 1
|
|
61
|
+
await db.commit()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from fastapi import Depends, HTTPException
|
|
2
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
3
|
+
from fastapi_auth_kit.core.security import decode_token
|
|
4
|
+
|
|
5
|
+
security = HTTPBearer()
|
|
6
|
+
|
|
7
|
+
async def get_current_user(
|
|
8
|
+
credentials: HTTPAuthorizationCredentials = Depends(security)
|
|
9
|
+
):
|
|
10
|
+
try:
|
|
11
|
+
token = credentials.credentials
|
|
12
|
+
return decode_token(token, SECRET)
|
|
13
|
+
except:
|
|
14
|
+
raise HTTPException(401, "Invalid token")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, field_validator
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
USERNAME_REGEX = r"^[a-zA-Z][a-zA-Z0-9_]{2,29}$"
|
|
6
|
+
PASSWORD_REGEX = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RegisterRequest(BaseModel):
|
|
10
|
+
username: str = Field(
|
|
11
|
+
...,
|
|
12
|
+
description="3-30 chars, letters/numbers/_ only, must start with letter"
|
|
13
|
+
)
|
|
14
|
+
password: str = Field(
|
|
15
|
+
...,
|
|
16
|
+
description="Min 8 chars, include uppercase, lowercase, number, special char"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
@field_validator("username")
|
|
20
|
+
@classmethod
|
|
21
|
+
def validate_username(cls, v):
|
|
22
|
+
if not re.match(USERNAME_REGEX, v):
|
|
23
|
+
raise ValueError(
|
|
24
|
+
"Username must be 3-30 chars, start with letter, and contain only letters, numbers, underscores"
|
|
25
|
+
)
|
|
26
|
+
return v
|
|
27
|
+
|
|
28
|
+
@field_validator("password")
|
|
29
|
+
@classmethod
|
|
30
|
+
def validate_password(cls, v):
|
|
31
|
+
if not re.match(PASSWORD_REGEX, v):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
"Password must be at least 8 characters long and include uppercase, lowercase, number, and special character"
|
|
34
|
+
)
|
|
35
|
+
return v
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LoginRequest(BaseModel):
|
|
39
|
+
username: str
|
|
40
|
+
password: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RefreshRequest(BaseModel):
|
|
44
|
+
refresh: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TokenResponse(BaseModel):
|
|
48
|
+
access: str
|
|
49
|
+
refresh: str
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from fastapi import HTTPException, status
|
|
2
|
+
from fastapi_auth_kit.core.security import *
|
|
3
|
+
|
|
4
|
+
class AuthService:
|
|
5
|
+
def __init__(self, repo, secret):
|
|
6
|
+
self.repo = repo
|
|
7
|
+
self.secret = secret
|
|
8
|
+
|
|
9
|
+
async def register(self, username: str, password: str):
|
|
10
|
+
existing_user = await self.repo.get_user(username)
|
|
11
|
+
|
|
12
|
+
if existing_user:
|
|
13
|
+
raise HTTPException(
|
|
14
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
15
|
+
detail="User already exists"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
user = await self.repo.create_user(
|
|
19
|
+
username,
|
|
20
|
+
hash_password(password)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
"message": "User registered successfully",
|
|
25
|
+
"username": username
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async def login(self, username: str, password: str):
|
|
29
|
+
user = await self.repo.get_user(username)
|
|
30
|
+
|
|
31
|
+
if not user or not verify_password(password, user.password):
|
|
32
|
+
raise HTTPException(
|
|
33
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
34
|
+
detail="Invalid username or password"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
access = create_token({"sub": username}, self.secret, 15, "access")
|
|
38
|
+
refresh = create_token({"sub": username}, self.secret, 60*24, "refresh")
|
|
39
|
+
|
|
40
|
+
await self.repo.save_refresh_token(refresh)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
"access": access,
|
|
44
|
+
"refresh": refresh
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async def refresh(self, token: str):
|
|
48
|
+
try:
|
|
49
|
+
data = decode_token(token, self.secret)
|
|
50
|
+
except Exception:
|
|
51
|
+
raise HTTPException(
|
|
52
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
53
|
+
detail="Invalid or expired refresh token"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if data.get("type") != "refresh":
|
|
57
|
+
raise HTTPException(
|
|
58
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
59
|
+
detail="Invalid token type (expected refresh token)"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if await self.repo.is_token_blacklisted(token):
|
|
63
|
+
raise HTTPException(
|
|
64
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
65
|
+
detail="Token has been revoked (logout)"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"access": create_token(
|
|
70
|
+
{"sub": data["sub"]},
|
|
71
|
+
self.secret,
|
|
72
|
+
15,
|
|
73
|
+
"access"
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async def logout(self, token: str):
|
|
78
|
+
try:
|
|
79
|
+
data = decode_token(token, self.secret)
|
|
80
|
+
except Exception:
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
83
|
+
detail="Invalid or expired refresh token"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if data.get("type") != "refresh":
|
|
87
|
+
raise HTTPException(
|
|
88
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
89
|
+
detail="Invalid token type"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if await self.repo.is_token_blacklisted(token):
|
|
93
|
+
raise HTTPException(
|
|
94
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
95
|
+
detail="Token already logged out"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
await self.repo.blacklist_token(token)
|
|
99
|
+
|
|
100
|
+
return {"message": "Logged out successfully"}
|