mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-05-11 07:51:51 +02:00
Compare commits
7 commits
9389e3a005
...
32d11c5598
Author | SHA1 | Date | |
---|---|---|---|
![]() |
32d11c5598 | ||
![]() |
a601fccad3 | ||
![]() |
be2b4f2114 | ||
![]() |
837cd548ad | ||
![]() |
dbe05178b8 | ||
![]() |
33fd5441e1 | ||
![]() |
0a02855e60 |
11 changed files with 489 additions and 80 deletions
|
@ -2,6 +2,14 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- 🚸 — Use ReconnectLDAPObject
|
||||||
|
- ⏰ — Set recurring task to every minute
|
||||||
|
- 🔊 — Improve check agent log
|
||||||
|
- 🔒️ — Logging out now invalidate tokens
|
||||||
|
- 📝 — Improve OpenAPI doc
|
||||||
|
- 🤕 — Fix order of tasks sent to agent
|
||||||
|
- ✨ — Add application API (fix #86)
|
||||||
|
|
||||||
## 0.9.0
|
## 0.9.0
|
||||||
|
|
||||||
Date: 2025-02-18
|
Date: 2025-02-18
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
|
||||||
class NotAuthenticatedException(Exception):
|
class NotAuthenticatedException(Exception):
|
||||||
|
@ -10,7 +10,19 @@ def auth_exception_handler(request: Request, exc: NotAuthenticatedException):
|
||||||
"""
|
"""
|
||||||
Redirect the user to the login page if not logged in
|
Redirect the user to the login page if not logged in
|
||||||
"""
|
"""
|
||||||
response = RedirectResponse(url=request.url_for("login_view"))
|
print(request.headers.get("accept", ""))
|
||||||
|
print("application/json" in request.headers.get("accept", ""))
|
||||||
|
if "application/json" in request.headers.get("accept", ""):
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"access": "denied",
|
||||||
|
"msg": "You are not authenticated or your token has expired",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = RedirectResponse(
|
||||||
|
url=request.url_for("login_view").include_query_params(msg="not-authenticated")
|
||||||
|
)
|
||||||
manager = request.app.state.manager
|
manager = request.app.state.manager
|
||||||
manager.set_cookie(response, "")
|
manager.set_cookie(response, "")
|
||||||
return response
|
return response
|
||||||
|
|
|
@ -10,6 +10,7 @@ from psutil import Process
|
||||||
from sqlalchemy import create_engine, event
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from argos import VERSION
|
||||||
from argos.logging import logger, set_log_level
|
from argos.logging import logger, set_log_level
|
||||||
from argos.server import models, routes, queries
|
from argos.server import models, routes, queries
|
||||||
from argos.server.alerting import no_agent_alert
|
from argos.server.alerting import no_agent_alert
|
||||||
|
@ -30,7 +31,17 @@ def get_application() -> FastAPI:
|
||||||
root_path = root_path[:-1]
|
root_path = root_path[:-1]
|
||||||
logger.info("Fixed root path for Argos: %s", root_path)
|
logger.info("Fixed root path for Argos: %s", root_path)
|
||||||
|
|
||||||
appli = FastAPI(lifespan=lifespan, root_path=root_path)
|
appli = FastAPI(
|
||||||
|
title="Argos Panoptès",
|
||||||
|
summary="A monitoring and status board for websites.",
|
||||||
|
version=VERSION,
|
||||||
|
license_info={
|
||||||
|
"name": "GNU Affero General Public License v3",
|
||||||
|
"url": "https://www.gnu.org/licenses/agpl-3.0.en.html",
|
||||||
|
},
|
||||||
|
lifespan=lifespan,
|
||||||
|
root_path=root_path,
|
||||||
|
)
|
||||||
|
|
||||||
# Config is the argos config object (built from yaml)
|
# Config is the argos config object (built from yaml)
|
||||||
appli.state.config = config
|
appli.state.config = config
|
||||||
|
@ -40,7 +51,9 @@ def get_application() -> FastAPI:
|
||||||
if config.general.ldap is not None:
|
if config.general.ldap is not None:
|
||||||
import ldap
|
import ldap
|
||||||
|
|
||||||
appli.state.ldap = ldap.initialize(config.general.ldap.uri)
|
appli.state.ldap = ldap.ldapobject.ReconnectLDAPObject(
|
||||||
|
config.general.ldap.uri, retry_max=5, retry_delay=5
|
||||||
|
)
|
||||||
|
|
||||||
@appli.state.manager.user_loader()
|
@appli.state.manager.user_loader()
|
||||||
async def query_user(user: str) -> None | str | models.User:
|
async def query_user(user: str) -> None | str | models.User:
|
||||||
|
@ -57,6 +70,7 @@ def get_application() -> FastAPI:
|
||||||
return await queries.get_user(appli.state.db, user)
|
return await queries.get_user(appli.state.db, user)
|
||||||
|
|
||||||
appli.include_router(routes.api, prefix="/api")
|
appli.include_router(routes.api, prefix="/api")
|
||||||
|
appli.include_router(routes.api_app, prefix="/api/app")
|
||||||
appli.include_router(routes.views)
|
appli.include_router(routes.views)
|
||||||
|
|
||||||
static_dir = Path(__file__).resolve().parent / "static"
|
static_dir = Path(__file__).resolve().parent / "static"
|
||||||
|
@ -114,7 +128,7 @@ def create_manager(cookie_secret: str) -> LoginManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@repeat_every(seconds=120, logger=logger)
|
@repeat_every(seconds=60, logger=logger)
|
||||||
async def recurring_tasks() -> None:
|
async def recurring_tasks() -> None:
|
||||||
"""Recurring DB cleanup and watch-agents tasks"""
|
"""Recurring DB cleanup and watch-agents tasks"""
|
||||||
# If we are using gunicorn
|
# If we are using gunicorn
|
||||||
|
@ -137,7 +151,11 @@ async def recurring_tasks() -> None:
|
||||||
agents = await queries.get_recent_agents_count(db, config.time_without_agent)
|
agents = await queries.get_recent_agents_count(db, config.time_without_agent)
|
||||||
if agents == 0:
|
if agents == 0:
|
||||||
no_agent_alert(app.state.config)
|
no_agent_alert(app.state.config)
|
||||||
logger.info("Agent presence checked")
|
logger.info(
|
||||||
|
"Agent presence checked (%i agent(s) in the last %i minute(s))",
|
||||||
|
agents,
|
||||||
|
config.time_without_agent,
|
||||||
|
)
|
||||||
|
|
||||||
removed = await queries.remove_old_results(db, config.max_results_age)
|
removed = await queries.remove_old_results(db, config.max_results_age)
|
||||||
logger.info("%i result(s) removed", removed)
|
logger.info("%i result(s) removed", removed)
|
||||||
|
@ -145,6 +163,9 @@ async def recurring_tasks() -> None:
|
||||||
updated = await queries.release_old_locks(db, config.max_lock_seconds)
|
updated = await queries.release_old_locks(db, config.max_lock_seconds)
|
||||||
logger.info("%i lock(s) released", updated)
|
logger.info("%i lock(s) released", updated)
|
||||||
|
|
||||||
|
removed_tokens = await queries.remove_old_tokens(db)
|
||||||
|
logger.info("%i old token(s) removed", removed_tokens)
|
||||||
|
|
||||||
processed_jobs = await queries.process_jobs(db)
|
processed_jobs = await queries.process_jobs(db)
|
||||||
logger.info("%i job(s) processed", processed_jobs)
|
logger.info("%i job(s) processed", processed_jobs)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Add blocked_tokens table
|
||||||
|
|
||||||
|
Revision ID: 1d0aaa07743c
|
||||||
|
Revises: 5f6cb30db996
|
||||||
|
Create Date: 2025-03-19 15:23:20.233843
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "1d0aaa07743c"
|
||||||
|
down_revision: Union[str, None] = "5f6cb30db996"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"blocked_tokens",
|
||||||
|
sa.Column("token", sa.String(), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("excluded_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("blocked_tokens")
|
|
@ -217,3 +217,18 @@ class User(Base):
|
||||||
|
|
||||||
def update_last_login_at(self):
|
def update_last_login_at(self):
|
||||||
self.last_login_at = datetime.now()
|
self.last_login_at = datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
class BlockedToken(Base):
|
||||||
|
"""
|
||||||
|
List of tokens discarded by their users
|
||||||
|
(when they logout)
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "blocked_tokens"
|
||||||
|
token: Mapped[str] = mapped_column(primary_key=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column()
|
||||||
|
excluded_at: Mapped[datetime] = mapped_column(default=datetime.now())
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"DB BlockedToken {self.token} - {self.expires_at} - {self.excluded_at}"
|
||||||
|
|
|
@ -4,28 +4,63 @@ from hashlib import sha256
|
||||||
from typing import List
|
from typing import List
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
from sqlalchemy import asc, func, Select
|
from sqlalchemy import asc, func, Select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from argos import schemas
|
from argos import schemas
|
||||||
from argos.logging import logger
|
from argos.logging import logger
|
||||||
from argos.server.models import ConfigCache, Job, Result, Task, User
|
from argos.server.models import BlockedToken, ConfigCache, Job, Result, Task, User
|
||||||
from argos.server.settings import read_config
|
from argos.server.settings import read_config
|
||||||
|
|
||||||
|
|
||||||
async def list_tasks(db: Session, agent_id: str, limit: int = 100):
|
async def list_tasks(db: Session, agent_id: str, limit: int = 100):
|
||||||
"""List tasks and mark them as selected"""
|
"""List tasks and mark them as selected"""
|
||||||
|
# Process tasks which never has been processed first
|
||||||
subquery = (
|
subquery = (
|
||||||
db.query(func.distinct(Task.task_group))
|
db.query(func.distinct(Task.task_group))
|
||||||
.filter(
|
.filter(
|
||||||
Task.selected_by == None, # noqa: E711
|
Task.selected_by == None, # noqa: E711
|
||||||
((Task.next_run <= datetime.now()) | (Task.next_run == None)), # noqa: E711
|
Task.next_run == None, # noqa: E711
|
||||||
)
|
)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
tasks = db.query(Task).filter(Task.task_group.in_(Select(subquery))).all()
|
tasks = db.query(Task).filter(Task.task_group.in_(Select(subquery))).all()
|
||||||
|
|
||||||
|
if len(tasks):
|
||||||
|
now = datetime.now()
|
||||||
|
for task in tasks:
|
||||||
|
task.selected_at = now
|
||||||
|
task.selected_by = agent_id
|
||||||
|
db.commit()
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
# Now we can process tasks normally
|
||||||
|
all_task_groups = (
|
||||||
|
db.query(Task.task_group)
|
||||||
|
.filter(
|
||||||
|
Task.selected_by == None, # noqa: E711
|
||||||
|
Task.next_run <= datetime.now(), # noqa: E711
|
||||||
|
)
|
||||||
|
.order_by(asc(Task.next_run))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# We need to do distinct(Task.task_group) in Python
|
||||||
|
# since distinct(Task.task_group) is not compatible with
|
||||||
|
# an order_by(asc(Task.next_run))
|
||||||
|
task_groups: list[str] = []
|
||||||
|
for row in all_task_groups:
|
||||||
|
if len(task_groups) > limit:
|
||||||
|
break
|
||||||
|
task_group = row.task_group
|
||||||
|
if task_group not in task_groups:
|
||||||
|
task_groups.append(task_group)
|
||||||
|
|
||||||
|
tasks = db.query(Task).filter(Task.task_group.in_(task_groups)).all()
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
task.selected_at = now
|
task.selected_at = now
|
||||||
|
@ -403,14 +438,14 @@ async def get_severity_counts(db: Session) -> dict:
|
||||||
|
|
||||||
async def reschedule_all(db: Session):
|
async def reschedule_all(db: Session):
|
||||||
"""Reschedule checks of all non OK tasks ASAP"""
|
"""Reschedule checks of all non OK tasks ASAP"""
|
||||||
db.query(Task).filter(Task.severity.in_(["warning", "critical", "unknown"])).update(
|
db.query(Task).filter(Task.severity != "ok").update(
|
||||||
{Task.next_run: datetime.now() - timedelta(days=1)}
|
{Task.next_run: datetime.now() - timedelta(days=1)}
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def remove_old_results(db: Session, max_results_age: float):
|
async def remove_old_results(db: Session, max_results_age: float):
|
||||||
"""Remove old results, base on age"""
|
"""Remove old results, based on age"""
|
||||||
max_acceptable_time = datetime.now() - timedelta(seconds=max_results_age)
|
max_acceptable_time = datetime.now() - timedelta(seconds=max_results_age)
|
||||||
deleted = (
|
deleted = (
|
||||||
db.query(Result).filter(Result.submitted_at < max_acceptable_time).delete()
|
db.query(Result).filter(Result.submitted_at < max_acceptable_time).delete()
|
||||||
|
@ -420,6 +455,30 @@ async def remove_old_results(db: Session, max_results_age: float):
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def block_token(db: Session, request: Request):
|
||||||
|
"""Discard user token"""
|
||||||
|
manager = request.app.state.manager
|
||||||
|
token = await manager._get_token(request) # pylint: disable-msg=protected-access
|
||||||
|
payload = jwt.decode(
|
||||||
|
token, manager.secret.secret_for_decode, algorithms=[manager.algorithm]
|
||||||
|
)
|
||||||
|
blocked_token = BlockedToken(
|
||||||
|
token=token, expires_at=datetime.utcfromtimestamp(payload["exp"])
|
||||||
|
)
|
||||||
|
db.add(blocked_token)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_old_tokens(db: Session):
|
||||||
|
"""Remove expired discarded tokens"""
|
||||||
|
deleted = (
|
||||||
|
db.query(BlockedToken).filter(BlockedToken.expires_at < datetime.now()).delete()
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
async def release_old_locks(db: Session, max_lock_seconds: int):
|
async def release_old_locks(db: Session, max_lock_seconds: int):
|
||||||
"""Remove outdated locks on tasks"""
|
"""Remove outdated locks on tasks"""
|
||||||
max_acceptable_time = datetime.now() - timedelta(seconds=max_lock_seconds)
|
max_acceptable_time = datetime.now() - timedelta(seconds=max_lock_seconds)
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
from .api import route as api
|
from .api import route as api
|
||||||
|
from .api_app import route as api_app
|
||||||
from .views import route as views
|
from .views import route as views
|
||||||
|
|
|
@ -85,22 +85,6 @@ async def create_results( # pylint: disable-msg=too-many-positional-arguments
|
||||||
return {"result_ids": [r.id for r in db_results]}
|
return {"result_ids": [r.id for r in db_results]}
|
||||||
|
|
||||||
|
|
||||||
@route.post(
|
|
||||||
"/reschedule/all",
|
|
||||||
responses={
|
|
||||||
200: {
|
|
||||||
"content": {
|
|
||||||
"application/json": {"example": {"msg": "Non OK tasks reschuled"}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def reschedule_all(request: Request, db: Session = Depends(get_db)):
|
|
||||||
"""Reschedule checks of all non OK tasks ASAP"""
|
|
||||||
await queries.reschedule_all(db)
|
|
||||||
return {"msg": "Non OK tasks reschuled"}
|
|
||||||
|
|
||||||
|
|
||||||
@route.get(
|
@route.get(
|
||||||
"/stats",
|
"/stats",
|
||||||
responses={
|
responses={
|
||||||
|
|
220
argos/server/routes/api_app.py
Normal file
220
argos/server/routes/api_app.py
Normal file
|
@ -0,0 +1,220 @@
|
||||||
|
"""Web interface for humans"""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated, Dict, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from argos import VERSION
|
||||||
|
from argos.schemas import Config
|
||||||
|
from argos.server import queries
|
||||||
|
from argos.server.models import Result, User
|
||||||
|
from argos.server.routes.dependencies import (
|
||||||
|
create_user_token,
|
||||||
|
get_config,
|
||||||
|
get_db,
|
||||||
|
get_manager,
|
||||||
|
good_user_credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
route = APIRouter()
|
||||||
|
|
||||||
|
current_dir = Path(__file__).resolve().parent
|
||||||
|
templates = Jinja2Templates(directory=current_dir / ".." / "templates")
|
||||||
|
SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4}
|
||||||
|
|
||||||
|
|
||||||
|
class Version(BaseModel):
|
||||||
|
argos_monitoring: str
|
||||||
|
logged_in: bool
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"examples": [
|
||||||
|
{"argos_monitoring": VERSION, "logged_in": True},
|
||||||
|
{"argos_monitoring": VERSION, "logged_in": False},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@route.get("/version", response_model=Version)
|
||||||
|
async def version(
|
||||||
|
request: Request,
|
||||||
|
):
|
||||||
|
"""Return a JSON object containing argos version
|
||||||
|
and indicates if you are logged in"""
|
||||||
|
token = request.cookies.get("access-token")
|
||||||
|
if token is not None and token != "":
|
||||||
|
manager = request.app.state.manager
|
||||||
|
user = await manager.get_current_user(token)
|
||||||
|
return Version(argos_monitoring=VERSION, logged_in=user is not None)
|
||||||
|
|
||||||
|
return Version(argos_monitoring=VERSION, logged_in=False)
|
||||||
|
|
||||||
|
|
||||||
|
class GrantedLogin(BaseModel):
|
||||||
|
access: Literal["granted"]
|
||||||
|
msg: str
|
||||||
|
cookie_name: Literal["access-token"] | None
|
||||||
|
cookie: str | None
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"access": "granted",
|
||||||
|
"msg": "Login successful",
|
||||||
|
"cookie_name": "access-token",
|
||||||
|
"cookie": "foobarbaz",
|
||||||
|
},
|
||||||
|
{"access": "granted", "msg": "No authentication needed"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DeniedLogin(BaseModel):
|
||||||
|
access: Literal["deniel"]
|
||||||
|
msg: str
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"examples": [
|
||||||
|
{"access": "denied", "msg": "Sorry, invalid username or bad password."},
|
||||||
|
{
|
||||||
|
"access": "denied",
|
||||||
|
"msg": "Sorry, invalid username or bad password. "
|
||||||
|
"Or the LDAP server is unreachable (see logs to verify).",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@route.post(
|
||||||
|
"/login", response_model=GrantedLogin, responses={401: {"model": DeniedLogin}}
|
||||||
|
)
|
||||||
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
rememberme: Annotated[str | None, Form()] = None,
|
||||||
|
config: Config = Depends(get_config),
|
||||||
|
):
|
||||||
|
"""If the login is successful, sends a token to be included as cookie
|
||||||
|
in requests needing authentication.
|
||||||
|
"""
|
||||||
|
if config.general.unauthenticated_access == "all":
|
||||||
|
return {"access": "granted", "msg": "No authentication needed"}
|
||||||
|
|
||||||
|
good_credentials = await good_user_credentials(
|
||||||
|
config, request, data.username, data.password
|
||||||
|
)
|
||||||
|
if config.general.ldap is not None:
|
||||||
|
if not good_credentials:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"access": "denied",
|
||||||
|
"msg": "Sorry, invalid username or bad password. "
|
||||||
|
"Or the LDAP server is unreachable (see logs to verify).",
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
elif not good_credentials:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"access": "denied",
|
||||||
|
"msg": "Sorry, invalid username or bad password.",
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = request.app.state.manager
|
||||||
|
token = await create_user_token(manager, config.general, data.username, rememberme)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"access": "granted",
|
||||||
|
"msg": "Login successful",
|
||||||
|
"cookie_name": manager.cookie_name,
|
||||||
|
"cookie": token["token"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SuccessLogout(BaseModel):
|
||||||
|
logout: Literal["success"]
|
||||||
|
msg: str
|
||||||
|
|
||||||
|
|
||||||
|
@route.get("/logout", response_model=SuccessLogout)
|
||||||
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
config: Config = Depends(get_config),
|
||||||
|
user: User | None = Depends(get_manager),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Discard the token used for the request, so logging out the user"""
|
||||||
|
if config.general.unauthenticated_access == "all":
|
||||||
|
return {"logout": "error", "msg": "No authentication needed"}
|
||||||
|
|
||||||
|
await queries.block_token(db, request)
|
||||||
|
|
||||||
|
response = JSONResponse(content={"logout": "success", "msg": "logout successful"})
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class SeverityCounts(BaseModel):
|
||||||
|
severities: Dict[Literal["ok", "warning", "critical", "unknown"], int]
|
||||||
|
agents: int
|
||||||
|
|
||||||
|
model_config = {
|
||||||
|
"json_schema_extra": {
|
||||||
|
"examples": [
|
||||||
|
{
|
||||||
|
"severities": {"ok": 10, "warning": 0, "critical": 2, "unknown": 0},
|
||||||
|
"agents": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@route.get("/", response_model=SeverityCounts)
|
||||||
|
async def get_severity_counts(
|
||||||
|
request: Request,
|
||||||
|
user: User | None = Depends(get_manager),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Shows the number of results per severity and number of known agents"""
|
||||||
|
counts_dict = await queries.get_severity_counts(db)
|
||||||
|
|
||||||
|
agents = db.query(Result.agent_id).distinct().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"severities": counts_dict,
|
||||||
|
"agents": len(agents),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@route.post(
|
||||||
|
"/reschedule/all",
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"content": {
|
||||||
|
"application/json": {"example": {"msg": "Non OK tasks reschuled"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def reschedule_all(
|
||||||
|
request: Request,
|
||||||
|
user: User | None = Depends(get_manager),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Reschedule checks of all non OK tasks ASAP"""
|
||||||
|
await queries.reschedule_all(db)
|
||||||
|
return {"msg": "Non OK tasks reschuled"}
|
|
@ -1,8 +1,17 @@
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from fastapi_login import LoginManager
|
from fastapi_login import LoginManager
|
||||||
|
from ldap import INVALID_CREDENTIALS # pylint: disable-msg=no-name-in-module
|
||||||
|
from ldap.ldapobject import ReconnectLDAPObject
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
from argos.logging import logger
|
from argos.logging import logger
|
||||||
|
from argos.schemas import Config, General
|
||||||
|
from argos.server.exceptions import NotAuthenticatedException
|
||||||
|
from argos.server.models import BlockedToken
|
||||||
|
from argos.server.queries import get_user
|
||||||
|
|
||||||
auth_scheme = HTTPBearer()
|
auth_scheme = HTTPBearer()
|
||||||
|
|
||||||
|
@ -23,6 +32,11 @@ async def get_manager(request: Request) -> LoginManager:
|
||||||
if request.app.state.config.general.unauthenticated_access is not None:
|
if request.app.state.config.general.unauthenticated_access is not None:
|
||||||
return await request.app.state.manager.optional(request)
|
return await request.app.state.manager.optional(request)
|
||||||
|
|
||||||
|
token = await request.app.state.manager._get_token(request) # pylint: disable-msg=protected-access
|
||||||
|
db = request.app.state.SessionLocal()
|
||||||
|
if db.query(BlockedToken).filter(BlockedToken.token == token).count() > 0:
|
||||||
|
raise NotAuthenticatedException
|
||||||
|
|
||||||
return await request.app.state.manager(request)
|
return await request.app.state.manager(request)
|
||||||
|
|
||||||
|
|
||||||
|
@ -35,6 +49,33 @@ async def verify_token(
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def good_user_credentials(
|
||||||
|
config: Config, request: Request, username: str, password: str
|
||||||
|
):
|
||||||
|
if config.general.ldap is not None:
|
||||||
|
return await good_ldap_user_credentials(
|
||||||
|
config, request.app.state.ldap, username, password
|
||||||
|
)
|
||||||
|
|
||||||
|
return await good_internal_user_credentials(
|
||||||
|
request.app.state.SessionLocal(), username, password
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def good_ldap_user_credentials(
|
||||||
|
config: Config, ldapobj: ReconnectLDAPObject, username: str, password: str
|
||||||
|
) -> bool:
|
||||||
|
ldap_dn = await find_ldap_user(config, ldapobj, username)
|
||||||
|
if ldap_dn is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ldapobj.simple_bind_s(ldap_dn, password)
|
||||||
|
except INVALID_CREDENTIALS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def find_ldap_user(config, ldapobj, user: str) -> str | None:
|
async def find_ldap_user(config, ldapobj, user: str) -> str | None:
|
||||||
"""Do a LDAP search for user and return its dn"""
|
"""Do a LDAP search for user and return its dn"""
|
||||||
import ldap
|
import ldap
|
||||||
|
@ -65,3 +106,32 @@ async def find_ldap_user(config, ldapobj, user: str) -> str | None:
|
||||||
return result[0][0]
|
return result[0][0]
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def good_internal_user_credentials(db, username, password) -> bool:
|
||||||
|
user = await get_user(db, username)
|
||||||
|
if user is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
if not pwd_context.verify(password, user.password):
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.last_login_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user_token(
|
||||||
|
manager, config: General, username: str, rememberme: str | None = None
|
||||||
|
):
|
||||||
|
session_duration = config.session_duration
|
||||||
|
if config.remember_me_duration is not None and rememberme == "on":
|
||||||
|
session_duration = config.remember_me_duration
|
||||||
|
|
||||||
|
delta = timedelta(minutes=session_duration)
|
||||||
|
return {
|
||||||
|
"token": manager.create_access_token(data={"sub": username}, expires=delta),
|
||||||
|
"delta": delta,
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"""Web interface for humans"""
|
"""Web interface for humans"""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
from functools import cmp_to_key
|
from functools import cmp_to_key
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
@ -10,7 +10,6 @@ from fastapi import APIRouter, Cookie, Depends, Form, Request, status
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from passlib.context import CryptContext
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
@ -19,7 +18,13 @@ from argos.schemas import Config
|
||||||
from argos.server import queries
|
from argos.server import queries
|
||||||
from argos.server.exceptions import NotAuthenticatedException
|
from argos.server.exceptions import NotAuthenticatedException
|
||||||
from argos.server.models import Result, Task, User
|
from argos.server.models import Result, Task, User
|
||||||
from argos.server.routes.dependencies import get_config, get_db, get_manager
|
from argos.server.routes.dependencies import (
|
||||||
|
create_user_token,
|
||||||
|
get_config,
|
||||||
|
get_db,
|
||||||
|
get_manager,
|
||||||
|
good_user_credentials,
|
||||||
|
)
|
||||||
|
|
||||||
route = APIRouter()
|
route = APIRouter()
|
||||||
|
|
||||||
|
@ -28,7 +33,7 @@ templates = Jinja2Templates(directory=current_dir / ".." / "templates")
|
||||||
SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4}
|
SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4}
|
||||||
|
|
||||||
|
|
||||||
@route.get("/login")
|
@route.get("/login", include_in_schema=False)
|
||||||
async def login_view(
|
async def login_view(
|
||||||
request: Request,
|
request: Request,
|
||||||
msg: str | None = None,
|
msg: str | None = None,
|
||||||
|
@ -52,6 +57,8 @@ async def login_view(
|
||||||
|
|
||||||
if msg == "logout":
|
if msg == "logout":
|
||||||
msg = "You have been successfully disconnected."
|
msg = "You have been successfully disconnected."
|
||||||
|
elif msg == "not-authenticated":
|
||||||
|
msg = "You are not authenticated or your token has expired"
|
||||||
else:
|
else:
|
||||||
msg = None
|
msg = None
|
||||||
|
|
||||||
|
@ -65,7 +72,7 @@ async def login_view(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@route.post("/login")
|
@route.post("/login", include_in_schema=False)
|
||||||
async def post_login(
|
async def post_login(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
@ -79,70 +86,48 @@ async def post_login(
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
)
|
)
|
||||||
|
|
||||||
username = data.username
|
good_credentials = await good_user_credentials(
|
||||||
|
config, request, data.username, data.password
|
||||||
invalid_credentials = templates.TemplateResponse(
|
|
||||||
"login.html",
|
|
||||||
{"request": request, "msg": "Sorry, invalid username or bad password."},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if config.general.ldap is not None:
|
if config.general.ldap is not None:
|
||||||
from ldap import INVALID_CREDENTIALS # pylint: disable-msg=no-name-in-module
|
if not good_credentials:
|
||||||
from argos.server.routes.dependencies import find_ldap_user
|
return templates.TemplateResponse(
|
||||||
|
"login.html",
|
||||||
invalid_credentials = templates.TemplateResponse(
|
{
|
||||||
|
"request": request,
|
||||||
|
"msg": "Sorry, invalid username or bad password. "
|
||||||
|
"Or the LDAP server is unreachable (see logs to verify).",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif not good_credentials:
|
||||||
|
return templates.TemplateResponse(
|
||||||
"login.html",
|
"login.html",
|
||||||
{
|
{"request": request, "msg": "Sorry, invalid username or bad password."},
|
||||||
"request": request,
|
|
||||||
"msg": "Sorry, invalid username or bad password. "
|
|
||||||
"Or the LDAP server is unreachable (see logs to verify).",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ldap_dn = await find_ldap_user(config, request.app.state.ldap, username)
|
|
||||||
if ldap_dn is None:
|
|
||||||
return invalid_credentials
|
|
||||||
try:
|
|
||||||
request.app.state.ldap.simple_bind_s(ldap_dn, data.password)
|
|
||||||
except INVALID_CREDENTIALS:
|
|
||||||
return invalid_credentials
|
|
||||||
else:
|
|
||||||
user = await queries.get_user(db, username)
|
|
||||||
if user is None:
|
|
||||||
return invalid_credentials
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
if not pwd_context.verify(data.password, user.password):
|
|
||||||
return invalid_credentials
|
|
||||||
|
|
||||||
user.last_login_at = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
manager = request.app.state.manager
|
manager = request.app.state.manager
|
||||||
session_duration = config.general.session_duration
|
token = await create_user_token(manager, config.general, data.username, rememberme)
|
||||||
if config.general.remember_me_duration is not None and rememberme == "on":
|
|
||||||
session_duration = config.general.remember_me_duration
|
|
||||||
delta = timedelta(minutes=session_duration)
|
|
||||||
token = manager.create_access_token(data={"sub": username}, expires=delta)
|
|
||||||
response = RedirectResponse(
|
response = RedirectResponse(
|
||||||
request.url_for("get_severity_counts_view"),
|
request.url_for("get_severity_counts_view"),
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
)
|
)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=manager.cookie_name,
|
key=manager.cookie_name,
|
||||||
value=token,
|
value=token["token"],
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="strict",
|
samesite="strict",
|
||||||
expires=int(delta.total_seconds()),
|
expires=int(token["delta"].total_seconds()),
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@route.get("/logout")
|
@route.get("/logout", include_in_schema=False)
|
||||||
async def logout_view(
|
async def logout_view(
|
||||||
request: Request,
|
request: Request,
|
||||||
config: Config = Depends(get_config),
|
config: Config = Depends(get_config),
|
||||||
user: User | None = Depends(get_manager),
|
user: User | None = Depends(get_manager),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
if config.general.unauthenticated_access == "all":
|
if config.general.unauthenticated_access == "all":
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
|
@ -150,6 +135,8 @@ async def logout_view(
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await queries.block_token(db, request)
|
||||||
|
|
||||||
response = RedirectResponse(
|
response = RedirectResponse(
|
||||||
request.url_for("login_view").include_query_params(msg="logout"),
|
request.url_for("login_view").include_query_params(msg="logout"),
|
||||||
status_code=status.HTTP_303_SEE_OTHER,
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
@ -158,7 +145,7 @@ async def logout_view(
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@route.get("/")
|
@route.get("/", include_in_schema=False)
|
||||||
async def get_severity_counts_view(
|
async def get_severity_counts_view(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: User | None = Depends(get_manager),
|
user: User | None = Depends(get_manager),
|
||||||
|
@ -184,7 +171,7 @@ async def get_severity_counts_view(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@route.get("/domains")
|
@route.get("/domains", include_in_schema=False)
|
||||||
async def get_domains_view(
|
async def get_domains_view(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: User | None = Depends(get_manager),
|
user: User | None = Depends(get_manager),
|
||||||
|
@ -241,7 +228,7 @@ async def get_domains_view(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@route.get("/domain/{domain}")
|
@route.get("/domain/{domain}", include_in_schema=False)
|
||||||
async def get_domain_tasks_view(
|
async def get_domain_tasks_view(
|
||||||
request: Request,
|
request: Request,
|
||||||
domain: str,
|
domain: str,
|
||||||
|
@ -266,7 +253,7 @@ async def get_domain_tasks_view(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@route.get("/result/{result_id}")
|
@route.get("/result/{result_id}", include_in_schema=False)
|
||||||
async def get_result_view(
|
async def get_result_view(
|
||||||
request: Request,
|
request: Request,
|
||||||
result_id: int,
|
result_id: int,
|
||||||
|
@ -291,7 +278,7 @@ async def get_result_view(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@route.get("/task/{task_id}/results")
|
@route.get("/task/{task_id}/results", include_in_schema=False)
|
||||||
async def get_task_results_view(
|
async def get_task_results_view(
|
||||||
request: Request,
|
request: Request,
|
||||||
task_id: int,
|
task_id: int,
|
||||||
|
@ -327,7 +314,7 @@ async def get_task_results_view(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@route.get("/agents")
|
@route.get("/agents", include_in_schema=False)
|
||||||
async def get_agents_view(
|
async def get_agents_view(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: User | None = Depends(get_manager),
|
user: User | None = Depends(get_manager),
|
||||||
|
@ -355,7 +342,7 @@ async def get_agents_view(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@route.post("/refresh")
|
@route.post("/refresh", include_in_schema=False)
|
||||||
async def set_refresh_cookies_view(
|
async def set_refresh_cookies_view(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: User | None = Depends(get_manager),
|
user: User | None = Depends(get_manager),
|
||||||
|
|
Loading…
Reference in a new issue