mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-05-07 22:21:48 +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]
|
||||
|
||||
- 🚸 — 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
|
||||
|
||||
Date: 2025-02-18
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
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.set_cookie(response, "")
|
||||
return response
|
||||
|
|
|
@ -10,6 +10,7 @@ from psutil import Process
|
|||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from argos import VERSION
|
||||
from argos.logging import logger, set_log_level
|
||||
from argos.server import models, routes, queries
|
||||
from argos.server.alerting import no_agent_alert
|
||||
|
@ -30,7 +31,17 @@ def get_application() -> FastAPI:
|
|||
root_path = root_path[:-1]
|
||||
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)
|
||||
appli.state.config = config
|
||||
|
@ -40,7 +51,9 @@ def get_application() -> FastAPI:
|
|||
if config.general.ldap is not None:
|
||||
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()
|
||||
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)
|
||||
|
||||
appli.include_router(routes.api, prefix="/api")
|
||||
appli.include_router(routes.api_app, prefix="/api/app")
|
||||
appli.include_router(routes.views)
|
||||
|
||||
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:
|
||||
"""Recurring DB cleanup and watch-agents tasks"""
|
||||
# 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)
|
||||
if agents == 0:
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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):
|
||||
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 urllib.parse import urljoin
|
||||
|
||||
import jwt
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import asc, func, Select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from argos import schemas
|
||||
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
|
||||
|
||||
|
||||
async def list_tasks(db: Session, agent_id: str, limit: int = 100):
|
||||
"""List tasks and mark them as selected"""
|
||||
# Process tasks which never has been processed first
|
||||
subquery = (
|
||||
db.query(func.distinct(Task.task_group))
|
||||
.filter(
|
||||
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)
|
||||
.subquery()
|
||||
)
|
||||
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()
|
||||
for task in tasks:
|
||||
task.selected_at = now
|
||||
|
@ -403,14 +438,14 @@ async def get_severity_counts(db: Session) -> dict:
|
|||
|
||||
async def reschedule_all(db: Session):
|
||||
"""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)}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
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)
|
||||
deleted = (
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""Remove outdated locks on tasks"""
|
||||
max_acceptable_time = datetime.now() - timedelta(seconds=max_lock_seconds)
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
from .api import route as api
|
||||
from .api_app import route as api_app
|
||||
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]}
|
||||
|
||||
|
||||
@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(
|
||||
"/stats",
|
||||
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.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
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.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()
|
||||
|
||||
|
@ -23,6 +32,11 @@ async def get_manager(request: Request) -> LoginManager:
|
|||
if request.app.state.config.general.unauthenticated_access is not None:
|
||||
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)
|
||||
|
||||
|
||||
|
@ -35,6 +49,33 @@ async def verify_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:
|
||||
"""Do a LDAP search for user and return its dn"""
|
||||
import ldap
|
||||
|
@ -65,3 +106,32 @@ async def find_ldap_user(config, ldapobj, user: str) -> str | None:
|
|||
return result[0][0]
|
||||
|
||||
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"""
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from functools import cmp_to_key
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
@ -10,7 +10,6 @@ from fastapi import APIRouter, Cookie, Depends, Form, Request, status
|
|||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
@ -19,7 +18,13 @@ from argos.schemas import Config
|
|||
from argos.server import queries
|
||||
from argos.server.exceptions import NotAuthenticatedException
|
||||
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()
|
||||
|
||||
|
@ -28,7 +33,7 @@ templates = Jinja2Templates(directory=current_dir / ".." / "templates")
|
|||
SEVERITY_LEVELS = {"ok": 1, "warning": 2, "critical": 3, "unknown": 4}
|
||||
|
||||
|
||||
@route.get("/login")
|
||||
@route.get("/login", include_in_schema=False)
|
||||
async def login_view(
|
||||
request: Request,
|
||||
msg: str | None = None,
|
||||
|
@ -52,6 +57,8 @@ async def login_view(
|
|||
|
||||
if msg == "logout":
|
||||
msg = "You have been successfully disconnected."
|
||||
elif msg == "not-authenticated":
|
||||
msg = "You are not authenticated or your token has expired"
|
||||
else:
|
||||
msg = None
|
||||
|
||||
|
@ -65,7 +72,7 @@ async def login_view(
|
|||
)
|
||||
|
||||
|
||||
@route.post("/login")
|
||||
@route.post("/login", include_in_schema=False)
|
||||
async def post_login(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
|
@ -79,70 +86,48 @@ async def post_login(
|
|||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
username = data.username
|
||||
|
||||
invalid_credentials = templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "msg": "Sorry, invalid username or bad password."},
|
||||
good_credentials = await good_user_credentials(
|
||||
config, request, data.username, data.password
|
||||
)
|
||||
|
||||
if config.general.ldap is not None:
|
||||
from ldap import INVALID_CREDENTIALS # pylint: disable-msg=no-name-in-module
|
||||
from argos.server.routes.dependencies import find_ldap_user
|
||||
|
||||
invalid_credentials = templates.TemplateResponse(
|
||||
if not good_credentials:
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{
|
||||
"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",
|
||||
{
|
||||
"request": request,
|
||||
"msg": "Sorry, invalid username or bad password. "
|
||||
"Or the LDAP server is unreachable (see logs to verify).",
|
||||
},
|
||||
{"request": request, "msg": "Sorry, invalid username or bad password."},
|
||||
)
|
||||
|
||||
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
|
||||
session_duration = config.general.session_duration
|
||||
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)
|
||||
token = await create_user_token(manager, config.general, data.username, rememberme)
|
||||
|
||||
response = RedirectResponse(
|
||||
request.url_for("get_severity_counts_view"),
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
response.set_cookie(
|
||||
key=manager.cookie_name,
|
||||
value=token,
|
||||
value=token["token"],
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
expires=int(delta.total_seconds()),
|
||||
expires=int(token["delta"].total_seconds()),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@route.get("/logout")
|
||||
@route.get("/logout", include_in_schema=False)
|
||||
async def logout_view(
|
||||
request: Request,
|
||||
config: Config = Depends(get_config),
|
||||
user: User | None = Depends(get_manager),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if config.general.unauthenticated_access == "all":
|
||||
return RedirectResponse(
|
||||
|
@ -150,6 +135,8 @@ async def logout_view(
|
|||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
await queries.block_token(db, request)
|
||||
|
||||
response = RedirectResponse(
|
||||
request.url_for("login_view").include_query_params(msg="logout"),
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
|
@ -158,7 +145,7 @@ async def logout_view(
|
|||
return response
|
||||
|
||||
|
||||
@route.get("/")
|
||||
@route.get("/", include_in_schema=False)
|
||||
async def get_severity_counts_view(
|
||||
request: Request,
|
||||
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(
|
||||
request: Request,
|
||||
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(
|
||||
request: Request,
|
||||
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(
|
||||
request: Request,
|
||||
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(
|
||||
request: Request,
|
||||
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(
|
||||
request: Request,
|
||||
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(
|
||||
request: Request,
|
||||
user: User | None = Depends(get_manager),
|
||||
|
|
Loading…
Reference in a new issue