PIN a 4 cifre: migrazione 017, login-pin, pin-users, PUT pin, capoturno via PIN (#59)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 12:27:31 +02:00
parent 86290274e5
commit 07e23b957d
12 changed files with 441 additions and 11 deletions
+38 -1
View File
@@ -1,5 +1,6 @@
"""Authentication router - login, logout, profile."""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
@@ -8,10 +9,17 @@ from src.backend.models.orm.user import User
from src.backend.models.api.user import (
LoginRequest,
LoginResponse,
PinLoginRequest,
PinUserItem,
UserProfileUpdate,
UserResponse,
)
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
from src.backend.services.auth_service import (
authenticate_pin,
authenticate_user,
login_user,
logout_user,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -32,6 +40,35 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
)
@router.get("/pin-users", response_model=list[PinUserItem])
async def pin_users(db: AsyncSession = Depends(get_db)):
"""Utenti che possono entrare con PIN (#59): attivi, non admin, con PIN.
Pubblico per costruzione (serve prima del login): espone solo id e nome.
"""
result = await db.execute(
select(User).where(
User.active == True, # noqa: E712
User.is_admin == False, # noqa: E712
User.pin_hash.is_not(None),
)
)
items = [
PinUserItem(id=u.id, display_name=u.display_name or u.username)
for u in result.scalars().all()
]
items.sort(key=lambda i: i.display_name.casefold())
return items
@router.post("/login-pin", response_model=LoginResponse)
async def login_pin(data: PinLoginRequest, db: AsyncSession = Depends(get_db)):
"""Login con user_id e PIN a 4 cifre (#59); stessa risposta del login."""
user = await authenticate_pin(db, data.user_id, data.pin)
api_key = await login_user(db, user)
return LoginResponse(user=UserResponse.model_validate(user), api_key=api_key)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
+1
View File
@@ -147,6 +147,7 @@ async def authorise_measurement(
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
measurement = await measurement_service.authorise_measurement(
db, measurement, supervisor,
+3
View File
@@ -141,6 +141,7 @@ async def pause_production_run(
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
@@ -157,6 +158,7 @@ async def resume_production_run(
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
@@ -173,6 +175,7 @@ async def close_production_run(
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
+32 -2
View File
@@ -6,8 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_admin_user
from src.backend.models.orm.user import User
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys
from src.backend.models.api.user import PinSet, UserCreate, UserPasswordChange, UserResponse, UserUpdate
from src.backend.services.auth_service import (
create_user,
hash_password,
regenerate_api_key,
revoke_all_keys,
set_user_pin,
)
router = APIRouter(prefix="/api/users", tags=["users"])
@@ -151,6 +157,30 @@ async def change_user_password(
return {"message": f"Password changed for user {user.username}"}
@router.put("/{user_id}/pin", status_code=status.HTTP_200_OK)
async def change_user_pin(
user_id: int,
data: PinSet,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Imposta o toglie (pin null) il PIN di un utente (admin, #59/#51).
422 se l'utente e' admin. Il PIN non si restituisce mai.
"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.is_admin:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Admin users cannot have a PIN",
)
await set_user_pin(db, user, data.pin)
return {"message": f"PIN {'set' if data.pin else 'removed'} for user {user.username}"}
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_user(
user_id: int,
@@ -0,0 +1,34 @@
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
Revision ID: 017_user_pin
Revises: 016_vision_recipe_dir
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '017_user_pin'
down_revision: Union[str, None] = '016_vision_recipe_dir'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
with op.batch_alter_table('users') as batch:
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
batch.add_column(sa.Column(
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('users') as batch:
batch.drop_column('pin_locked_until')
batch.drop_column('pin_failed_count')
batch.drop_column('pin_hash')
+16 -3
View File
@@ -2,7 +2,7 @@
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
class MeasurementCreate(BaseModel):
@@ -71,8 +71,21 @@ class MeasurementListResponse(BaseModel):
class MeasurementAuthorisation(BaseModel):
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
supervisor_username: str = Field(..., min_length=1)
supervisor_password: str = Field(..., min_length=1)
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
supervisor_username: Optional[str] = Field(None, min_length=1)
supervisor_password: Optional[str] = Field(None, min_length=1)
supervisor_user_id: Optional[int] = Field(None, gt=0)
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
@model_validator(mode="after")
def _one_credential_pair(self):
by_password = self.supervisor_username and self.supervisor_password
by_pin = self.supervisor_user_id and self.supervisor_pin
if not (by_password or by_pin):
raise ValueError(
"supervisor credentials required: username+password or user_id+pin"
)
return self
class TaskProgressResponse(BaseModel):
+16 -3
View File
@@ -2,7 +2,7 @@
from src.backend.models.api.tipi import UtcDateTime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
class ProductionRunCreate(BaseModel):
@@ -64,8 +64,21 @@ class ProductionRunWithEventsResponse(ProductionRunResponse):
class SupervisorAction(BaseModel):
"""Credentials of the supervisor authorising a line stop or a close."""
supervisor_username: str = Field(..., min_length=1)
supervisor_password: str = Field(..., min_length=1)
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
supervisor_username: Optional[str] = Field(None, min_length=1)
supervisor_password: Optional[str] = Field(None, min_length=1)
supervisor_user_id: Optional[int] = Field(None, gt=0)
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
@model_validator(mode="after")
def _one_credential_pair(self):
by_password = self.supervisor_username and self.supervisor_password
by_pin = self.supervisor_user_id and self.supervisor_pin
if not (by_password or by_pin):
raise ValueError(
"supervisor credentials required: username+password or user_id+pin"
)
return self
note: Optional[str] = None
+20
View File
@@ -50,6 +50,8 @@ class UserResponse(BaseModel):
active: bool
created_at: UtcDateTime
last_login: Optional[UtcDateTime] = None
# #59: solo il fatto che il PIN esista, mai l'hash
has_pin: bool = False
class UserPasswordChange(BaseModel):
@@ -68,3 +70,21 @@ class LoginResponse(BaseModel):
user: UserResponse
api_key: str
message: str = "Login successful"
class PinUserItem(BaseModel):
"""Voce dell'elenco pubblico per il login con PIN (#59): solo id e nome."""
id: int
display_name: str
class PinLoginRequest(BaseModel):
"""Login con PIN (#59)."""
user_id: int
pin: str = Field(..., pattern=r"^[0-9]{4}$")
class PinSet(BaseModel):
"""Imposta (4 cifre) o toglie (null) il PIN di un utente (#59)."""
pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
+12
View File
@@ -22,6 +22,13 @@ class User(Base):
# Admin flag separate from roles
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# PIN a 4 cifre (#59): solo l'hash; contatore errori e fine blocco per il lockout
pin_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
pin_failed_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
pin_locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# User preferences
language_pref: Mapped[str] = mapped_column(
Enum("it", "en", name="language_enum"), nullable=False, default="it"
@@ -39,6 +46,11 @@ class User(Base):
)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
@property
def has_pin(self) -> bool:
"""True se l'utente ha un PIN impostato (#59); serve a UserResponse.has_pin."""
return self.pin_hash is not None
def has_role(self, role: str) -> bool:
"""Check if user has a specific role."""
return role in (self.roles or [])
+83 -2
View File
@@ -54,7 +54,79 @@ async def authenticate_user(
return user
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
# #59: PIN a 4 cifre. Hash identico alle password (bcrypt); 5 errori -> blocco 15 minuti.
PIN_MAX_FAILURES = 5
PIN_LOCK_MINUTES = 15
async def set_user_pin(db: AsyncSession, user: User, pin: str | None) -> None:
"""Imposta o toglie (None) il PIN; azzera contatore e blocco. Admin esclusi (#59)."""
if pin is not None and user.is_admin:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Admin users cannot have a PIN",
)
user.pin_hash = hash_password(pin) if pin is not None else None
user.pin_failed_count = 0
user.pin_locked_until = None
await db.flush()
async def authenticate_pin(db: AsyncSession, user_id: int, pin: str) -> User:
"""Verifica user_id+PIN con blocco dopo 5 errori consecutivi (#59).
401 PIN errato (o utente non idoneo: stessa risposta, niente enumerazione),
423 utente bloccato. Il contatore va committato prima di sollevare: get_db fa
rollback su eccezione e l'errore andrebbe perso.
"""
result = await db.execute(
select(User).where(User.id == user_id, User.active == True) # noqa: E712
)
user = result.scalar_one_or_none()
if user is None or user.is_admin or user.pin_hash is None:
verify_password(pin, _DUMMY_PASSWORD_HASH) # stesso costo di un PIN errato
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN"
)
now = utcnow()
if user.pin_locked_until is not None:
if user.pin_locked_until > now:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail="PIN locked until "
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
+ " UTC",
)
# blocco scaduto: si riparte da zero
user.pin_locked_until = None
user.pin_failed_count = 0
if verify_password(pin, user.pin_hash):
if user.pin_failed_count:
user.pin_failed_count = 0
await db.flush()
return user
user.pin_failed_count += 1
locked = user.pin_failed_count >= PIN_MAX_FAILURES
if locked:
user.pin_locked_until = now + timedelta(minutes=PIN_LOCK_MINUTES)
await db.commit()
if locked:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail="PIN locked until "
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
+ " UTC",
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN")
async def authorise_supervisor(
db: AsyncSession,
username: str | None = None,
password: str | None = None,
user_id: int | None = None,
pin: str | None = None,
) -> User:
"""Check the credentials of the supervisor authorising something.
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
@@ -65,7 +137,16 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
Uses authenticate_user rather than a full login: logging in would issue a
needless API key for the supervisor.
"""
supervisor = await authenticate_user(db, username, password)
if user_id is not None and pin is not None:
# #59: via PIN, stesso blocco del login con PIN (401/423 sollevati da li')
supervisor = await authenticate_pin(db, user_id, pin)
elif username is not None and password is not None:
supervisor = await authenticate_user(db, username, password)
else:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Supervisor credentials required",
)
if supervisor is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+19
View File
@@ -149,3 +149,22 @@ class TestVisionRecipeDirMigration:
assert "vision_recipe_dir" not in cols()
command.upgrade(cfg, "head") # e di nuovo su
assert "vision_recipe_dir" in cols()
class TestUserPinMigration:
"""017_user_pin: users.pin_hash / pin_failed_count / pin_locked_until, su e giu (#59)."""
def test_017_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
new = {"pin_hash", "pin_failed_count", "pin_locked_until"}
def cols():
return {c["name"] for c in _inspect(db_path).get_columns("users")}
command.upgrade(cfg, "head")
assert new <= cols()
command.downgrade(cfg, "016_vision_recipe_dir")
assert not (new & cols())
command.upgrade(cfg, "head") # e di nuovo su
assert new <= cols()
+167
View File
@@ -0,0 +1,167 @@
"""PIN a 4 cifre: login rapido, blocco, capoturno via PIN (#59)."""
from datetime import timedelta
import pytest
from fastapi import HTTPException
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.user import User
from src.backend.services import auth_service
from src.backend.tests.conftest import _create_user, auth_headers
from src.backend.tests.test_production_runs import (
_open, _recipe_with_interval, _station,
)
from src.backend.utils.tempo import utcnow
async def _pin_user(db_session, username="operaio", pin="1234", **kw) -> User:
user = await _create_user(db_session, username=username, **kw)
await auth_service.set_user_pin(db_session, user, pin)
await db_session.commit()
return user
async def _login_pin(client, user_id, pin):
return await client.post("/api/auth/login-pin", json={"user_id": user_id, "pin": pin})
async def test_login_pin_ok_same_shape_as_login(client: AsyncClient, db_session):
user = await _pin_user(db_session)
resp = await _login_pin(client, user.id, "1234")
assert resp.status_code == 200
data = resp.json()
assert data["user"]["username"] == "operaio"
assert data["user"]["has_pin"] is True
assert data["api_key"]
assert "pin_hash" not in data["user"]
# la api_key vale davvero
me = await client.get("/api/auth/me", headers={"X-API-Key": data["api_key"]})
assert me.status_code == 200
async def test_login_pin_wrong_is_401(client: AsyncClient, db_session):
user = await _pin_user(db_session)
assert (await _login_pin(client, user.id, "9999")).status_code == 401
async def test_login_pin_format_422(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for bad in ("123", "12345", "12a4", ""):
assert (await _login_pin(client, user.id, bad)).status_code == 422
async def test_lock_after_five_errors_even_with_right_pin(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for _ in range(4):
assert (await _login_pin(client, user.id, "0000")).status_code == 401
resp = await _login_pin(client, user.id, "0000") # 5 errore: scatta il blocco
assert resp.status_code == 423
assert "locked until" in resp.json()["detail"]
assert (await _login_pin(client, user.id, "1234")).status_code == 423
async def test_lock_expires_and_counter_resets(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for _ in range(5):
await _login_pin(client, user.id, "0000")
row = (await db_session.execute(select(User).where(User.id == user.id))).scalar_one()
await db_session.refresh(row)
assert row.pin_locked_until > utcnow() + timedelta(minutes=14)
# tempo simulato: il blocco e scaduto un secondo fa
row.pin_locked_until = utcnow() - timedelta(seconds=1)
await db_session.commit()
assert (await _login_pin(client, user.id, "1234")).status_code == 200
await db_session.refresh(row)
assert row.pin_failed_count == 0 and row.pin_locked_until is None
async def test_success_resets_failed_counter(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for _ in range(3):
await _login_pin(client, user.id, "0000")
assert (await _login_pin(client, user.id, "1234")).status_code == 200
for _ in range(4): # il contatore e ripartito da zero: 4 errori non bloccano
assert (await _login_pin(client, user.id, "0000")).status_code == 401
async def test_admin_cannot_have_pin_or_login_with_it(client: AsyncClient, admin_user, db_session):
resp = await client.put(
f"/api/users/{admin_user.id}/pin", headers=auth_headers(admin_user), json={"pin": "1234"},
)
assert resp.status_code == 422
# anche un admin con pin_hash scritto a mano non entra da login-pin
admin_user.pin_hash = auth_service.hash_password("1234")
await db_session.commit()
assert (await _login_pin(client, admin_user.id, "1234")).status_code == 401
listed = (await client.get("/api/auth/pin-users")).json()
assert all(u["id"] != admin_user.id for u in listed)
async def test_put_pin_set_and_remove(client: AsyncClient, admin_user, db_session):
user = await _create_user(db_session, username="senzapin")
await db_session.commit()
h = auth_headers(admin_user)
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "4321"})).status_code == 200
assert (await _login_pin(client, user.id, "4321")).status_code == 200
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "12"})).status_code == 422
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": None})).status_code == 200
assert (await _login_pin(client, user.id, "4321")).status_code == 401
assert (await client.put("/api/users/99999/pin", headers=h, json={"pin": "1234"})).status_code == 404
# non admin: 403
assert (await client.put(
f"/api/users/{user.id}/pin", headers=auth_headers(user), json={"pin": "1234"},
)).status_code == 403
async def test_pin_users_list(client: AsyncClient, db_session):
a = await _pin_user(db_session, username="zeta", display_name="Zeta Rossi", pin="1111")
b = await _pin_user(db_session, username="alfa", display_name="Alfa Bianchi", pin="2222")
await _create_user(db_session, username="nopin")
await _pin_user(db_session, username="inattivo", pin="3333", active=False)
await db_session.commit()
resp = await client.get("/api/auth/pin-users") # senza autenticazione
assert resp.status_code == 200
data = resp.json()
assert [u["display_name"] for u in data] == ["Alfa Bianchi", "Zeta Rossi"]
assert all(set(u) == {"id", "display_name"} for u in data)
assert {u["id"] for u in data} == {a.id, b.id}
async def test_supervisor_via_pin(client: AsyncClient, measurement_tec_user, admin_user, db_session):
station = await _station(db_session, admin_user.id, code="ST-PIN")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PIN")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
capo = await _pin_user(db_session, username="capo-pin", pin="5555", roles=["Supervisor"])
h = auth_headers(measurement_tec_user)
bad = await client.post(
f"/api/production-runs/{run_id}/pause", headers=h,
json={"supervisor_user_id": capo.id, "supervisor_pin": "0000"},
)
assert bad.status_code == 401
ok = await client.post(
f"/api/production-runs/{run_id}/pause", headers=h,
json={"supervisor_user_id": capo.id, "supervisor_pin": "5555"},
)
assert ok.status_code == 200
# nessuna coppia di credenziali: 422
none = await client.post(f"/api/production-runs/{run_id}/resume", headers=h, json={})
assert none.status_code == 422
async def test_supervisor_pin_role_still_checked(db_session):
plain = await _pin_user(db_session, username="non-capo", pin="6666", roles=["Maker"])
with pytest.raises(HTTPException) as e:
await auth_service.authorise_supervisor(db_session, user_id=plain.id, pin="6666")
assert e.value.status_code == 403
async def test_supervisor_pin_lockout(db_session):
capo = await _pin_user(db_session, username="capo-lock", pin="7777", roles=["Supervisor"])
codes = []
for pin in ["0000"] * 5 + ["7777"]:
with pytest.raises(HTTPException) as e:
await auth_service.authorise_supervisor(db_session, user_id=capo.id, pin=pin)
codes.append(e.value.status_code)
assert codes == [401, 401, 401, 401, 423, 423]