Skip to content

Commit 805bfab

Browse files
authored
Add API tokens support (#186)
* Add API tokens management in backend * Generate client * Add registration token generation
1 parent 348d779 commit 805bfab

File tree

20 files changed

+1018
-94
lines changed

20 files changed

+1018
-94
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Add API token table
2+
3+
Revision ID: 56cc00f2d285
4+
Revises: fc5e85011fea
5+
Create Date: 2026-04-18 15:49:06.026121
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# pylint: disable=E1101
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = '56cc00f2d285'
18+
down_revision: Union[str, None] = 'fc5e85011fea'
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
op.create_table(
25+
"api_token",
26+
sa.Column("id", sa.Integer(), nullable=False),
27+
sa.Column("name", sa.String(), nullable=False),
28+
sa.Column("token_hash", sa.String(), nullable=False),
29+
sa.Column("user_id", sa.Integer(), nullable=False),
30+
sa.Column("created_by", sa.Integer(), nullable=False),
31+
sa.Column("created_at", sa.DateTime(), nullable=False),
32+
sa.Column("expires_at", sa.DateTime(), nullable=True),
33+
sa.Column("last_used_at", sa.DateTime(), nullable=True),
34+
sa.PrimaryKeyConstraint("id"),
35+
sa.UniqueConstraint("token_hash"),
36+
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
37+
sa.ForeignKeyConstraint(["created_by"], ["user.id"]),
38+
)
39+
40+
41+
def downgrade() -> None:
42+
op.drop_table("api_token")

backend/app/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from app.api_token.models import ApiToken
12
from app.comments.models import Comment
23
from app.db import Base
34
from app.documents.models import (
@@ -19,6 +20,7 @@
1920
from app.translation_memory.models import TranslationMemory, TranslationMemoryRecord
2021

2122
__all__ = [
23+
"ApiToken",
2224
"Base",
2325
"Comment",
2426
"DocumentTask",

backend/app/api_token/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from app.api_token.models import ApiToken
2+
3+
__all__ = ["ApiToken"]

backend/app/api_token/models.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from datetime import UTC, datetime
2+
from typing import TYPE_CHECKING
3+
4+
from sqlalchemy import ForeignKey
5+
from sqlalchemy.orm import Mapped, mapped_column, relationship
6+
7+
from app.db import Base
8+
9+
if TYPE_CHECKING:
10+
from app.schema import User
11+
12+
13+
def utc_time():
14+
return datetime.now(UTC)
15+
16+
17+
class ApiToken(Base):
18+
__tablename__ = "api_token"
19+
20+
id: Mapped[int] = mapped_column(primary_key=True)
21+
name: Mapped[str] = mapped_column()
22+
token_hash: Mapped[str] = mapped_column(unique=True)
23+
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
24+
created_by: Mapped[int] = mapped_column(ForeignKey("user.id"))
25+
created_at: Mapped[datetime] = mapped_column(default=utc_time)
26+
expires_at: Mapped[datetime | None] = mapped_column(default=None)
27+
last_used_at: Mapped[datetime | None] = mapped_column(default=None)
28+
29+
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
30+
created_by_user: Mapped["User"] = relationship("User", foreign_keys=[created_by])

backend/app/api_token/query.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from datetime import UTC, datetime
2+
from typing import Sequence
3+
4+
from sqlalchemy import select, update
5+
from sqlalchemy.orm import Session
6+
7+
from app.api_token.models import ApiToken
8+
from app.base.exceptions import BaseQueryException
9+
10+
11+
class ApiTokenNotFoundExc(BaseQueryException):
12+
pass
13+
14+
15+
class ApiTokenQuery:
16+
def __init__(self, db: Session) -> None:
17+
self.__db = db
18+
19+
def create(
20+
self,
21+
name: str,
22+
token_hash: str,
23+
user_id: int,
24+
created_by: int,
25+
expires_at: datetime | None = None,
26+
) -> ApiToken:
27+
token = ApiToken(
28+
name=name,
29+
token_hash=token_hash,
30+
user_id=user_id,
31+
created_by=created_by,
32+
expires_at=expires_at,
33+
)
34+
self.__db.add(token)
35+
self.__db.commit()
36+
self.__db.refresh(token)
37+
return token
38+
39+
def get_by_hash(self, token_hash: str) -> ApiToken | None:
40+
return self.__db.execute(
41+
select(ApiToken).where(ApiToken.token_hash == token_hash)
42+
).scalar_one_or_none()
43+
44+
def get_all(self) -> Sequence[ApiToken]:
45+
return (
46+
self.__db.execute(select(ApiToken).order_by(ApiToken.created_at.desc()))
47+
.scalars()
48+
.all()
49+
)
50+
51+
def get_by_id(self, token_id: int) -> ApiToken | None:
52+
return self.__db.execute(
53+
select(ApiToken).where(ApiToken.id == token_id)
54+
).scalar_one_or_none()
55+
56+
def delete(self, token_id: int) -> None:
57+
token = self.get_by_id(token_id)
58+
if not token:
59+
raise ApiTokenNotFoundExc()
60+
self.__db.delete(token)
61+
self.__db.commit()
62+
63+
def update_last_used(self, token_id: int) -> None:
64+
self.__db.execute(
65+
update(ApiToken)
66+
.where(ApiToken.id == token_id)
67+
.values(last_used_at=datetime.now(UTC))
68+
)
69+
self.__db.commit()

backend/app/api_token/schema.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import datetime
2+
3+
from pydantic import BaseModel, ConfigDict
4+
5+
from app.base.schema import Identified
6+
7+
8+
class ApiTokenCreateRequest(BaseModel):
9+
name: str
10+
user_id: int
11+
expires_at: datetime.datetime | None = None
12+
13+
14+
class ApiTokenResponse(Identified):
15+
name: str
16+
user_id: int
17+
created_by: int
18+
created_at: datetime.datetime
19+
expires_at: datetime.datetime | None
20+
last_used_at: datetime.datetime | None
21+
22+
model_config = ConfigDict(from_attributes=True)
23+
24+
25+
class ApiTokenCreatedResponse(ApiTokenResponse):
26+
token: str

backend/app/routers/api_tokens.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, Depends, HTTPException, status
4+
from sqlalchemy.orm import Session
5+
6+
from app.api_token.schema import (
7+
ApiTokenCreatedResponse,
8+
ApiTokenCreateRequest,
9+
ApiTokenResponse,
10+
)
11+
from app.db import get_db
12+
from app.models import StatusMessage
13+
from app.permissions import P, PermissionChecker
14+
from app.services.api_token_service import ApiTokenService
15+
from app.user.depends import get_current_user_id
16+
17+
router = APIRouter(
18+
prefix="/api_tokens",
19+
tags=["apitokens"],
20+
dependencies=[Depends(PermissionChecker(P.USER_MANAGE))],
21+
)
22+
23+
24+
def get_service(db: Annotated[Session, Depends(get_db)]) -> ApiTokenService:
25+
return ApiTokenService(db)
26+
27+
28+
@router.post("/")
29+
def create_token(
30+
data: ApiTokenCreateRequest,
31+
current_user: Annotated[int, Depends(get_current_user_id)],
32+
service: Annotated[ApiTokenService, Depends(get_service)],
33+
) -> ApiTokenCreatedResponse:
34+
return service.create_token(data, current_user)
35+
36+
37+
@router.get("/")
38+
def list_tokens(
39+
service: Annotated[ApiTokenService, Depends(get_service)],
40+
) -> list[ApiTokenResponse]:
41+
return service.list_tokens()
42+
43+
44+
@router.delete("/{token_id}")
45+
def delete_token(
46+
token_id: int,
47+
service: Annotated[ApiTokenService, Depends(get_service)],
48+
) -> StatusMessage:
49+
try:
50+
return service.delete_token(token_id)
51+
except Exception as e:
52+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))

backend/app/services/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Service layer module for business logic operations."""
22

3+
from app.services.api_token_service import ApiTokenService
34
from app.services.auth_service import AuthService
45
from app.services.comment_service import CommentService
56
from app.services.document_service import DocumentService
@@ -9,6 +10,7 @@
910
from app.services.user_service import UserService
1011

1112
__all__ = [
13+
"ApiTokenService",
1214
"AuthService",
1315
"CommentService",
1416
"DocumentService",
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import hashlib
2+
import secrets
3+
4+
from sqlalchemy.orm import Session
5+
6+
from app.api_token.query import ApiTokenQuery
7+
from app.api_token.schema import (
8+
ApiTokenCreatedResponse,
9+
ApiTokenCreateRequest,
10+
ApiTokenResponse,
11+
)
12+
from app.base.exceptions import EntityNotFound
13+
from app.models import StatusMessage
14+
15+
16+
class ApiTokenService:
17+
def __init__(self, db: Session) -> None:
18+
self.__query = ApiTokenQuery(db)
19+
20+
def create_token(
21+
self, data: ApiTokenCreateRequest, created_by: int
22+
) -> ApiTokenCreatedResponse:
23+
raw_token = f"hat_{secrets.token_hex(32)}"
24+
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
25+
token = self.__query.create(
26+
name=data.name,
27+
token_hash=token_hash,
28+
user_id=data.user_id,
29+
created_by=created_by,
30+
expires_at=data.expires_at,
31+
)
32+
return ApiTokenCreatedResponse(
33+
id=token.id,
34+
name=token.name,
35+
user_id=token.user_id,
36+
created_by=token.created_by,
37+
created_at=token.created_at,
38+
expires_at=token.expires_at,
39+
last_used_at=token.last_used_at,
40+
token=raw_token,
41+
)
42+
43+
def list_tokens(self) -> list[ApiTokenResponse]:
44+
tokens = self.__query.get_all()
45+
return [ApiTokenResponse.model_validate(t) for t in tokens]
46+
47+
def delete_token(self, token_id: int) -> StatusMessage:
48+
try:
49+
self.__query.delete(token_id)
50+
except Exception:
51+
raise EntityNotFound("Api Token", token_id)
52+
return StatusMessage(message="ok")

backend/app/user/depends.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,40 @@
1+
import hashlib
2+
from datetime import UTC, datetime
13
from typing import Annotated
24

3-
from fastapi import Cookie, HTTPException, status
5+
from fastapi import Cookie, Depends, Header, HTTPException, status
46
from itsdangerous import URLSafeTimedSerializer
7+
from sqlalchemy.orm import Session
58

9+
from app.api_token.query import ApiTokenQuery
10+
from app.db import get_db
611
from app.settings import settings
712

813

914
def get_current_user_id(
15+
db: Annotated[Session, Depends(get_db)],
1016
session: Annotated[str | None, Cookie(include_in_schema=False)] = None,
17+
authorization: Annotated[str | None, Header(include_in_schema=False)] = None,
1118
) -> int:
12-
if not session:
13-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
19+
if session:
20+
serializer = URLSafeTimedSerializer(secret_key=settings.secret_key)
21+
data = serializer.loads(session)
22+
if "user_id" in data:
23+
return data["user_id"]
1424

15-
serializer = URLSafeTimedSerializer(secret_key=settings.secret_key)
16-
data = serializer.loads(session)
17-
if "user_id" in data:
18-
return data["user_id"]
25+
if authorization and authorization.startswith("Bearer "):
26+
query = ApiTokenQuery(db)
27+
raw_token = authorization[7:]
28+
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
29+
api_token = query.get_by_hash(token_hash)
30+
if api_token:
31+
if api_token.expires_at:
32+
exp = api_token.expires_at
33+
if exp.tzinfo is None:
34+
exp = exp.replace(tzinfo=UTC)
35+
if exp < datetime.now(UTC):
36+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
37+
query.update_last_used(api_token.id)
38+
return api_token.user_id
1939

2040
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)

0 commit comments

Comments
 (0)