Skip to content

Commit fbfa020

Browse files
authored
JWT tokens (#232)
JWT и стандарты OIDC позволят заменить UnionAuth из profcomff-auth на более универсальную альтернативу. Это повысит переиспользуемость сервисов. По идее, этот PR позволит использовать универсальный метод аутентификации как для Auth API и Authentik, которые используются в профкоме, так и для других OIDC провайдеров (Firebase, Keycloak, Auth0 и тп) ## Изменения - Поддержка выдачи JWT токена вместо случайной строки - Конфигурация OIDC в пути `/openid/.well_known/openid_configuration` - Список публичных ключей для проверки JWT токена ## Детали реализации - Утилиты шифровки и расшифровки JWT - Параметры конфигурации JWT токенов. - Можно включить генерацию JWT токенов, тогда токен будет генерироваться не просто случайной строкой, а JWT строкой - Можно передать приватный RSA ключ для подписи JWT ключей - Ручка `/openid/.well_known/openid_configuration` максимально приближена к спецификации OpenID Conneсt - Ручка `/openid/.well_known/jwks` полностью соответствует спецификации JWKS - Ручка `/openid/token` на предоставляет возможность обновлять токены - При включенной генерации JWT токенов необходимо периодически обновлять токен этой ручкой. Старые токены не будут автоматически продлеваться
1 parent 68fbec9 commit fbfa020

22 files changed

Lines changed: 658 additions & 33 deletions

.github/workflows/build_and_publish.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ jobs:
135135
--env KAFKA_PASSWORD='${{ secrets.KAFKA_PASSWORD }}' \
136136
--env KAFKA_USER_LOGIN_TOPIC_NAME='${{ secrets.KAFKA_USER_LOGIN_TOPIC_NAME }}' \
137137
--env GUNICORN_CMD_ARGS='--log-config logging_test.conf' \
138+
--env JWT_ENABLED=true \
139+
--env JWT_PRIVATE_KEY='${{ secrets.JWT_PRIVATE_KEY }}' \
138140
--name ${{ env.CONTAINER_NAME }} \
139141
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test
140142
docker network connect web ${{ env.CONTAINER_NAME }}

auth_backend/auth_plugins/email.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,20 @@ def __init__(self):
133133
)
134134
self.tags = ["Email"]
135135

136+
@classmethod
137+
async def login(
138+
cls,
139+
email: str,
140+
password: str,
141+
scopes: list[Scope],
142+
session_name: str | None,
143+
background_tasks: BackgroundTasks,
144+
) -> Session:
145+
return await cls._login(
146+
EmailLogin(email=email, password=password, scopes=scopes, session_name=session_name),
147+
background_tasks,
148+
)
149+
136150
@classmethod
137151
async def _login(cls, user_inp: EmailLogin, background_tasks: BackgroundTasks) -> Session:
138152
query = (

auth_backend/exceptions.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@ def __init__(self):
3333

3434

3535
class SessionExpired(AuthAPIError):
36-
def __init__(self, token: str):
37-
super().__init__(f"Session that matches {token} expired", f"Срок действия токена {token} истёк")
36+
def __init__(self, token: str = ""):
37+
super().__init__(
38+
f"Session expired or not exists",
39+
f"Срок действия токена истёк или токен не существует",
40+
)
3841

3942

4043
class AuthFailed(AuthAPIError):
@@ -68,3 +71,16 @@ def __init__(self, dtime: datetime.timedelta):
6871
class LastAuthMethodDelete(AuthAPIError):
6972
def __init__(self):
7073
super().__init__('Unable to remove last authentication method', 'Нельзя удалить последний метод входа')
74+
75+
76+
class OidcGrantTypeNotImplementedError(AuthAPIError):
77+
def __init__(self, method: str):
78+
super().__init__(f'Grant type {method} not implemented', f'Метод {method} не реализован')
79+
80+
81+
class OidcGrantTypeClientNotSupported(AuthAPIError):
82+
def __init__(self, method: str, client_id: str):
83+
super().__init__(
84+
f'Grant type {method} not supported by {client_id}',
85+
f'Метод {method} не поддерживается приложением {client_id}',
86+
)

auth_backend/models/db.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from auth_backend.models.base import BaseDbModel
1414
from auth_backend.models.dynamic_settings import DynamicOption
1515
from auth_backend.settings import get_settings
16+
from auth_backend.utils.user_session_basics import session_expires_date
1617

1718

1819
settings = get_settings()
@@ -64,6 +65,10 @@ def scopes(self) -> set[Scope]:
6465
_scopes.update(group.indirect_scopes)
6566
return _scopes
6667

68+
@hybrid_property
69+
def scope_names(self) -> set[str]:
70+
return set(s.name.lower() for s in self.scopes)
71+
6772
@hybrid_property
6873
def indirect_groups(self) -> set[Group]:
6974
_groups = set()
@@ -149,10 +154,6 @@ class AuthMethod(BaseDbModel):
149154
)
150155

151156

152-
def session_expires_date():
153-
return datetime.datetime.utcnow() + datetime.timedelta(days=settings.SESSION_TIME_IN_DAYS)
154-
155-
156157
class UserSession(BaseDbModel):
157158
session_name: Mapped[str] = mapped_column(String, nullable=True)
158159
user_id: Mapped[int] = mapped_column(Integer, sqlalchemy.ForeignKey("user.id"))
@@ -179,6 +180,10 @@ class UserSession(BaseDbModel):
179180
def expired(self) -> bool:
180181
return self.expires <= datetime.datetime.utcnow()
181182

183+
@hybrid_property
184+
def scope_names(self) -> set[str]:
185+
return set(s.name.lower() for s in self.scopes)
186+
182187

183188
class Scope(BaseDbModel):
184189
creator_id: Mapped[int] = mapped_column(Integer, ForeignKey(User.id))
@@ -217,13 +222,14 @@ def create(cls, *, session: Session, **kwargs) -> Scope:
217222

218223
@classmethod
219224
def get_by_name(cls, name: str, *, with_deleted: bool = False, session: Session) -> Scope:
220-
scope = (
221-
cls.query(with_deleted=with_deleted, session=session)
222-
.filter(func.lower(cls.name) == name.lower())
223-
.one_or_none()
224-
)
225-
if not scope:
226-
raise ObjectNotFound(cls, name)
225+
return cls.get_by_names([name], with_deleted=with_deleted, session=session)[0]
226+
227+
@classmethod
228+
def get_by_names(cls, names: list[str], *, with_deleted: bool = False, session: Session) -> list[Scope]:
229+
names = [name.lower() for name in names]
230+
scope = cls.query(with_deleted=with_deleted, session=session).filter(func.lower(cls.name).in_(names)).all()
231+
if len(scope) < len(names):
232+
raise ObjectNotFound(cls, names)
227233
return scope
228234

229235

auth_backend/routes/base.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
from auth_backend.kafka.kafka import get_kafka_producer
1010
from auth_backend.settings import get_settings
1111

12-
from .groups import groups
13-
from .scopes import scopes
14-
from .user import user
15-
from .user_session import user_session
12+
from .groups import groups as groups_router
13+
from .oidc import router as openid_router
14+
from .scopes import scopes as scopes_router
15+
from .user import user as user_router
16+
from .user_session import user_session as user_session_router
1617

1718

1819
@asynccontextmanager
@@ -50,10 +51,11 @@ async def lifespan(app: FastAPI):
5051
allow_headers=settings.CORS_ALLOW_HEADERS,
5152
)
5253

53-
app.include_router(user_session)
54-
app.include_router(groups)
55-
app.include_router(scopes)
56-
app.include_router(user)
54+
app.include_router(groups_router)
55+
app.include_router(scopes_router)
56+
app.include_router(user_router)
57+
app.include_router(user_session_router)
58+
app.include_router(openid_router)
5759

5860
for method in AuthPluginMeta.active_auth_methods():
5961
app.include_router(router=method().router, prefix=method.prefix, tags=[method.get_name()])

auth_backend/routes/exc_handlers.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
OauthAuthFailed,
1111
OauthCredentialsIncorrect,
1212
ObjectNotFound,
13+
OidcGrantTypeClientNotSupported,
14+
OidcGrantTypeNotImplementedError,
1315
SessionExpired,
1416
TooManyEmailRequests,
1517
)
@@ -100,6 +102,24 @@ async def last_auth_method_delete_handler(req: starlette.requests.Request, exc:
100102
)
101103

102104

105+
@app.exception_handler(
106+
OidcGrantTypeClientNotSupported,
107+
)
108+
async def oidc_grant_type_client_not_supported_handler(req: starlette.requests.Request, exc: Exception):
109+
return JSONResponse(
110+
StatusResponseModel(status="Error", message=exc.eng, ru=exc.ru).model_dump(),
111+
status_code=400,
112+
)
113+
114+
115+
@app.exception_handler(OidcGrantTypeNotImplementedError)
116+
async def oidc_grant_type_not_implemented_error_handler(req: starlette.requests.Request, exc: Exception):
117+
return JSONResponse(
118+
StatusResponseModel(status="Error", message=exc.eng, ru=exc.ru).model_dump(),
119+
status_code=400,
120+
)
121+
122+
103123
@app.exception_handler(Exception)
104124
async def http_error_handler(req: starlette.requests.Request, exc: Exception):
105125
return JSONResponse(

auth_backend/routes/oidc.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import logging
2+
from datetime import datetime
3+
from typing import Annotated, Optional
4+
5+
from fastapi import APIRouter, BackgroundTasks, Form, Header
6+
from fastapi_sqlalchemy import db
7+
8+
from auth_backend.auth_plugins.email import Email
9+
from auth_backend.exceptions import OidcGrantTypeClientNotSupported, OidcGrantTypeNotImplementedError
10+
from auth_backend.models.db import Scope
11+
from auth_backend.schemas.oidc import PostTokenResponse
12+
from auth_backend.settings import get_settings
13+
from auth_backend.utils.jwt import create_jwks
14+
from auth_backend.utils.oidc_token import OidcGrantType, token_by_client_credentials, token_by_refresh_token
15+
16+
17+
settings = get_settings()
18+
router = APIRouter(prefix="/openid", tags=["OpenID"])
19+
logger = logging.getLogger(__name__)
20+
21+
22+
@router.get("/.well_known/openid_configuration")
23+
def openid_configuration():
24+
"""Конфигурация для подключения OpenID Connect совместимых приложений
25+
26+
**Attention:** ручка соответствует спецификации не полностью, не все OIDC приложения смогут ей пользоваться
27+
"""
28+
return {
29+
"issuer": f"{settings.APPLICATION_HOST}",
30+
"token_endpoint": f"{settings.APPLICATION_HOST}/openid/token",
31+
"userinfo_endpoint": f"{settings.APPLICATION_HOST}/me",
32+
"jwks_uri": f"{settings.APPLICATION_HOST}/.well-known/jwks",
33+
"scopes_supported": list(x[0] for x in db.session.query(Scope.name).all()),
34+
"response_types_supported": ["token"],
35+
"subject_types_supported": ["public"],
36+
"id_token_signing_alg_values_supported": ["RS256"],
37+
"claims_supported": ["sub", "iss", "exp", "iat"],
38+
"grant_types_supported": [
39+
"refresh_token",
40+
"client_credentials",
41+
],
42+
}
43+
44+
45+
@router.get("/.well_known/jwks")
46+
def jwks():
47+
"""Публичные ключи для проверки JWT токенов"""
48+
return {"keys": [create_jwks()]}
49+
50+
51+
@router.post("/token")
52+
async def token(
53+
background_tasks: BackgroundTasks,
54+
# Общие OIDC параметры
55+
grant_type: Annotated[str, Form()],
56+
client_id: Annotated[str, Form()], # Тут должна быть любая строка, которую проверяем в БД
57+
client_secret: Annotated[Optional[str], Form()] = None,
58+
scopes: Annotated[list[str] | None, Form()] = None,
59+
user_agent: Annotated[str | None, Header()] = None,
60+
# grant_type=refresh_token
61+
refresh_token: Annotated[Optional[str], Form()] = None,
62+
# grant_type=client_credentials
63+
username: Annotated[Optional[str], Form()] = None,
64+
password: Annotated[Optional[str], Form()] = None,
65+
) -> PostTokenResponse:
66+
"""Ручка для получения токена доступа
67+
68+
## Позволяет
69+
- Обменять старый не-JWT токен на новый c таким же набором доступов и таким же сроком давности
70+
- Обменять JWT токен на новый, если у него есть SESSION_UPDATE_SCOPE
71+
72+
Потенциально будет позволять:
73+
- Обменивать Refresh Token на пару Access Token + Refresh Token
74+
- Обменивать Code (см. Oauth Authorization Code Flow) на пару Access Token + Refresh Token
75+
76+
## Параметры:
77+
Для всех запросов
78+
- `grant_type` – refresh_token/client_credentials (см. список в `/.well_known/openid_configuration` в поле `grant_types_supported`)
79+
- `client_id` – строка, по которой проверяется принадлежность к проекту (сейчас только app)
80+
- `scopes` – список прав для нового токена
81+
82+
### `grant_type=refresh_token`
83+
- refresh_token – токен, выданный этой ручкой или ручкой `/login` в методе авторизации
84+
85+
### `grant_type=client_credentials`
86+
- `username` – логин пользователя
87+
- `password` – пароль пользователя
88+
"""
89+
scopes = scopes or []
90+
91+
if client_id != 'app':
92+
raise OidcGrantTypeClientNotSupported(grant_type, client_id)
93+
if grant_type == OidcGrantType.authorization_code:
94+
raise OidcGrantTypeNotImplementedError("authorization_code")
95+
96+
# Разные методы обмена токенов
97+
if grant_type == OidcGrantType.refresh_token:
98+
new_session = await token_by_refresh_token(refresh_token, scopes)
99+
elif grant_type == OidcGrantType.client_credentials and Email.is_active():
100+
new_session = await token_by_client_credentials(username, password, scopes, user_agent, background_tasks)
101+
else:
102+
raise OidcGrantTypeClientNotSupported(grant_type, client_id)
103+
104+
return PostTokenResponse(
105+
access_token=new_session.token,
106+
token_type="Bearer",
107+
expires_in=int((new_session.expires - datetime.utcnow()).total_seconds()),
108+
refresh_token=new_session.token,
109+
)

auth_backend/schemas/oidc.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from auth_backend.base import Base
2+
3+
4+
class PostTokenResponse(Base):
5+
access_token: str
6+
token_type: str
7+
expires_in: int
8+
refresh_token: str

auth_backend/settings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
import random
33
import string
44
from functools import lru_cache
5+
from pathlib import Path
56
from typing import Annotated
67

78
from annotated_types import Gt
89
from pydantic import PostgresDsn
10+
from pydantic.types import PathType
911
from pydantic_settings import BaseSettings, SettingsConfigDict
1012

1113

@@ -49,6 +51,10 @@ class Settings(BaseSettings):
4951
EMAIL_DELAY_COUNT: int = 3
5052
model_config = SettingsConfigDict(case_sensitive=True, env_file=".env", extra='ignore')
5153

54+
JWT_ENABLED: bool = False
55+
JWT_PRIVATE_KEY_FILE: Annotated[Path, PathType('file')] | None = './tests/private-key.pem'
56+
JWT_PRIVATE_KEY: bytes | None = None
57+
5258

5359
@lru_cache
5460
def get_settings():

0 commit comments

Comments
 (0)