feat(user): V3 action endpoints + auth bug fixes (19/19 + 22/22 tests)
New router: /v3/action/user/ (api_v3_actions_user.py)
- POST /authenticate — credentials in body (not query params; security fix)
- POST /verify_password
- POST /{user_id}/change_password — optional current-password verification
- GET /{user_id}/new_auth_key
- GET /{user_id}/email_auth_key_url
Registered in registry.py under /v3/action/user with V3 AccountContext auth.
Bug fixes (from audit in previous session):
- user.py: fix broken @router.get decorator (authenticate was unreachable)
- user.py + user_methods.py: fix AttributeError id_random → id (Vision ID)
- user_models.py: add fields_to_exclude_from_db to User_New_Base; narrow
collision prevention to self-reference IDs only
- user_models.py: pre-inject hashed password in root_validator(pre=True) so
exclude_unset=True in CRUD POST handler includes it (was writing NULL)
- api_crud_v3.py: move sanitize_payload + account_id injection to after
model validation (fixes FK integer collision with Vision ID constraints)
Docs: GUIDE__AE_API_V3_for_Frontend.md — new Section 7 with full migration
table (legacy → V3), request/response docs for all 5 action endpoints,
and V3 CRUD search equivalents for the 3 lookup routes.
Tests: tests/e2e/test_e2e_v3_user_action_routes.py — 19 tests, 19/19 pass.
Legacy tests/e2e/test_e2e_v3_user_auth_routes.py — 22/22 still pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -189,7 +189,7 @@ class User_New_Base(BaseModel):
|
||||
if rid := values.get('id_random') or values.get('user_id_random'):
|
||||
values['id'] = rid
|
||||
values['user_id'] = rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if c_rid := values.get('contact_id_random'):
|
||||
@@ -198,12 +198,22 @@ class User_New_Base(BaseModel):
|
||||
values['organization_id'] = o_rid
|
||||
if p_rid := values.get('person_id_random'):
|
||||
values['person_id'] = p_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'user_id', 'account_id', 'contact_id', 'organization_id', 'person_id']:
|
||||
|
||||
# 2. Prevent "Collision Population" — only strip self-reference IDs.
|
||||
# FK IDs (account_id, contact_id, etc.) are resolved to integers by sanitize_payload
|
||||
# before model construction and must NOT be stripped, or they won't be written to the DB.
|
||||
for k in ['id', 'user_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
|
||||
|
||||
# 3. Pre-inject hashed password so it appears in __fields_set__.
|
||||
# The @validator('password', always=True) below computes the same hash, but
|
||||
# exclude_unset=True (used by the CRUD POST handler) only includes fields that
|
||||
# were in the original input dict. By injecting 'password' here (pre=True),
|
||||
# it is treated as part of the input and thus written to the DB.
|
||||
if new_pw := values.get('new_password'):
|
||||
values['password'] = secure_hash_string(string=new_pw)
|
||||
|
||||
return values
|
||||
|
||||
@validator('password', always=True)
|
||||
|
||||
@@ -440,14 +440,9 @@ async def post_obj(
|
||||
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
||||
|
||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||
if 'account_id' in input_model.__fields__:
|
||||
obj_data['account_id'] = account.account_id
|
||||
elif obj_name == 'account':
|
||||
if obj_name == 'account':
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Account creation is restricted.")
|
||||
|
||||
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
|
||||
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
||||
|
||||
try:
|
||||
validated_obj = input_model(**obj_data)
|
||||
except ValidationError as e:
|
||||
@@ -459,6 +454,18 @@ async def post_obj(
|
||||
|
||||
data_to_insert = validated_obj.dict(exclude_unset=True)
|
||||
|
||||
# Sanitize payload AFTER model validation so that:
|
||||
# 1. The model receives raw Vision ID strings (passes field-length constraints).
|
||||
# 2. ID resolution (string → integer) happens on the serialized dict that goes to the DB,
|
||||
# avoiding conflicts with root_validator collision-prevention logic.
|
||||
sanitize_payload(data_to_insert, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
||||
|
||||
# Enforce account ownership AFTER sanitize_payload so the integer account_id goes straight
|
||||
# to the DB without conflicting with Vision ID string constraints in the model.
|
||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||
if 'account_id' in input_model.__fields__:
|
||||
data_to_insert['account_id'] = account.account_id
|
||||
|
||||
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
||||
new_obj_id = sql_insert_result
|
||||
new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=obj_name)
|
||||
|
||||
299
app/routers/api_v3_actions_user.py
Normal file
299
app/routers/api_v3_actions_user.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Aether API V3 - User Action Router
|
||||
------------------------------------
|
||||
Handles secure, stateful user account operations that are not standard CRUD.
|
||||
|
||||
Routes:
|
||||
POST /authenticate — username+password or user_id+auth_key (body, not query params)
|
||||
POST /verify_password — verify a user's current password without changing it
|
||||
POST /{user_id}/change_password — change password (with optional current-password verification)
|
||||
GET /{user_id}/new_auth_key — generate a new one-time login auth key
|
||||
GET /{user_id}/email_auth_key_url — email a one-time login link to the user
|
||||
|
||||
Security improvements over legacy /user/* routes:
|
||||
- Credentials are in the POST body, never in query params (no URL logging exposure).
|
||||
- Uses V3 AccountContext (x-aether-api-key mandatory).
|
||||
- HTTPException for all error paths (native FastAPI status codes).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db_sql import redis_lookup_id_random, sql_select, sql_update
|
||||
from app.lib_general import secure_hash_string, verify_secure_hash_string
|
||||
from app.lib_general_v3 import AccountContext, get_account_context
|
||||
from app.methods.user_methods import email_user_auth_key_url, load_user_obj
|
||||
from app.models.common_field_schema import default_num_bytes
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --- Request Body Models ---
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
new_password: str = Field(..., min_length=10, max_length=100)
|
||||
current_password: Optional[str] = Field(None, description="If provided, verified before applying the change.")
|
||||
|
||||
|
||||
class AuthenticateRequest(BaseModel):
|
||||
"""Provide either username+password or user_id+auth_key."""
|
||||
username: Optional[str] = Field(None, min_length=3, max_length=50)
|
||||
password: Optional[str] = Field(None, min_length=8, max_length=100)
|
||||
user_id: Optional[str] = Field(None, min_length=11, max_length=22, description="Vision ID (id_random) of the user.")
|
||||
auth_key: Optional[str] = Field(None, min_length=11, max_length=22)
|
||||
valid_email: Optional[bool] = Field(None, description="If True, marks email_verified=True on successful auth.")
|
||||
|
||||
|
||||
class VerifyPasswordRequest(BaseModel):
|
||||
"""Provide user_id (Vision ID) or username, plus the password to verify."""
|
||||
current_password: str = Field(..., min_length=1, max_length=100)
|
||||
user_id: Optional[str] = Field(None, min_length=11, max_length=22)
|
||||
username: Optional[str] = Field(None, min_length=2, max_length=50)
|
||||
|
||||
|
||||
# --- Internal Helper ---
|
||||
|
||||
def _check_user_enabled(rec: dict) -> Optional[str]:
|
||||
"""
|
||||
Returns an error message string if the user account is not currently active, None if OK.
|
||||
Checks: enable flag, enable_from, enable_to (all treated as UTC).
|
||||
"""
|
||||
if not rec.get('enable'):
|
||||
return 'This user account is not enabled.'
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
if enable_from := rec.get('enable_from'):
|
||||
ef = enable_from.replace(tzinfo=datetime.timezone.utc)
|
||||
if ef > now:
|
||||
return f'This user account is not yet enabled (active from: {ef}).'
|
||||
if enable_to := rec.get('enable_to'):
|
||||
et = enable_to.replace(tzinfo=datetime.timezone.utc)
|
||||
if et < now:
|
||||
return f'This user account has expired (expired: {et}).'
|
||||
return None
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@router.post('/authenticate', response_model=Resp_Body_Base)
|
||||
async def action_authenticate(
|
||||
body: AuthenticateRequest = Body(...),
|
||||
inc_user_role_list: bool = Query(False),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
Authenticate a user by username+password or user_id+auth_key.
|
||||
|
||||
- Credentials are in the POST body (not query params) — safe from URL logging.
|
||||
- Auth key is one-time-use: cleared on successful authentication.
|
||||
- On success: stamps logged_in_on, returns the full user object.
|
||||
- Provide x-account-id to scope username lookups to the correct account.
|
||||
"""
|
||||
account_id = account.account_id
|
||||
|
||||
if body.username and body.password:
|
||||
sql = """
|
||||
SELECT id AS user_id, id_random AS user_id_random, password,
|
||||
enable, enable_from, enable_to
|
||||
FROM `user`
|
||||
WHERE account_id = :account_id AND username = :username
|
||||
LIMIT 1
|
||||
"""
|
||||
rec = sql_select(sql=sql, data={'account_id': account_id, 'username': body.username})
|
||||
if not rec:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found for this account and username.')
|
||||
if not rec.get('password'):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No password is set for this user.')
|
||||
if not verify_secure_hash_string(string=body.password, string_hash=rec['password']):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Password did not match.')
|
||||
if err := _check_user_enabled(rec):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=err)
|
||||
|
||||
db_user_id = rec['user_id']
|
||||
update_data = {'id': db_user_id, 'logged_in_on': datetime.datetime.utcnow()}
|
||||
if body.valid_email:
|
||||
update_data['email_verified'] = True
|
||||
sql_update(table_name='user', data=update_data)
|
||||
|
||||
elif body.user_id and body.auth_key:
|
||||
sql = """
|
||||
SELECT id AS user_id, id_random AS user_id_random, password,
|
||||
enable, enable_from, enable_to
|
||||
FROM `user`
|
||||
WHERE id_random = :user_id_random
|
||||
AND auth_key = :auth_key
|
||||
AND allow_auth_key = 1
|
||||
LIMIT 1
|
||||
"""
|
||||
rec = sql_select(sql=sql, data={'user_id_random': body.user_id, 'auth_key': body.auth_key})
|
||||
if not rec:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User + auth key combination not found.')
|
||||
if err := _check_user_enabled(rec):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=err)
|
||||
|
||||
db_user_id = rec['user_id']
|
||||
# Auth key is one-time-use — clear it immediately.
|
||||
update_data = {'id': db_user_id, 'auth_key': None, 'logged_in_on': datetime.datetime.utcnow()}
|
||||
if body.valid_email:
|
||||
update_data['email_verified'] = True
|
||||
sql_update(table_name='user', data=update_data)
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provide either username+password or user_id+auth_key.')
|
||||
|
||||
user_obj = load_user_obj(user_id=db_user_id, inc_user_role_list=inc_user_role_list)
|
||||
if not user_obj:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Authentication succeeded but user record could not be loaded.')
|
||||
|
||||
return mk_resp(data=user_obj.dict(by_alias=True), status_message='Authentication successful.')
|
||||
|
||||
|
||||
@router.post('/verify_password', response_model=Resp_Body_Base)
|
||||
async def action_verify_password(
|
||||
body: VerifyPasswordRequest = Body(...),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
Verify a user's current password without changing it.
|
||||
|
||||
Provide user_id (Vision ID) or username + current_password.
|
||||
Returns data=True on match, 403 on mismatch.
|
||||
"""
|
||||
account_id = account.account_id
|
||||
|
||||
if body.user_id:
|
||||
sql = """
|
||||
SELECT id AS user_id, username, password
|
||||
FROM `user`
|
||||
WHERE id_random = :user_id_random
|
||||
LIMIT 1
|
||||
"""
|
||||
rec = sql_select(sql=sql, data={'user_id_random': body.user_id})
|
||||
elif body.username:
|
||||
sql = """
|
||||
SELECT id AS user_id, username, password
|
||||
FROM `user`
|
||||
WHERE account_id = :account_id AND username = :username
|
||||
LIMIT 1
|
||||
"""
|
||||
rec = sql_select(sql=sql, data={'account_id': account_id, 'username': body.username})
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provide user_id or username.')
|
||||
|
||||
if not rec:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
|
||||
if not rec.get('password'):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No password is set for this user.')
|
||||
if not verify_secure_hash_string(string=body.current_password, string_hash=rec['password']):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Password did not match.')
|
||||
|
||||
return mk_resp(data=True, status_message='Password verified.')
|
||||
|
||||
|
||||
@router.post('/{user_id}/change_password', response_model=Resp_Body_Base)
|
||||
async def action_change_password(
|
||||
user_id: str = Path(min_length=11, max_length=22),
|
||||
body: ChangePasswordRequest = Body(...),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
Change a user's password.
|
||||
|
||||
- new_password is required (min 10 chars).
|
||||
- If current_password is provided, it is verified before the change is applied.
|
||||
- Stamps password_set_on on success.
|
||||
"""
|
||||
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
|
||||
if not db_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
|
||||
|
||||
if body.current_password:
|
||||
sql = "SELECT password FROM `user` WHERE id = :uid LIMIT 1"
|
||||
rec = sql_select(sql=sql, data={'uid': db_user_id})
|
||||
if not rec or not rec.get('password'):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found or password not set.')
|
||||
if not verify_secure_hash_string(string=body.current_password, string_hash=rec['password']):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Current password is incorrect.')
|
||||
|
||||
update_data = {
|
||||
'id': db_user_id,
|
||||
'password': secure_hash_string(string=body.new_password),
|
||||
'password_set_on': datetime.datetime.utcnow(),
|
||||
}
|
||||
if not sql_update(table_name='user', data=update_data):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Password update failed.')
|
||||
|
||||
return mk_resp(data=True, status_message='Password changed successfully.')
|
||||
|
||||
|
||||
@router.get('/{user_id}/new_auth_key', response_model=Resp_Body_Base)
|
||||
async def action_new_auth_key(
|
||||
user_id: str = Path(min_length=11, max_length=22),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
Generate a new one-time-use auth key for the user.
|
||||
|
||||
The key is written to the DB and returned in the response body.
|
||||
The user record must have allow_auth_key=1 for the key to be usable
|
||||
with the /authenticate endpoint.
|
||||
"""
|
||||
import secrets
|
||||
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
|
||||
if not db_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
|
||||
|
||||
new_key = secrets.token_urlsafe(default_num_bytes)
|
||||
update_data = {'id': db_user_id, 'auth_key': new_key}
|
||||
if not sql_update(table_name='user', data=update_data):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to write auth key.')
|
||||
|
||||
return mk_resp(data={'auth_key': new_key}, status_message='New auth key generated.')
|
||||
|
||||
|
||||
@router.get('/{user_id}/email_auth_key_url', response_model=Resp_Body_Base)
|
||||
async def action_email_auth_key_url(
|
||||
user_id: str = Path(min_length=11, max_length=22),
|
||||
root_url: Optional[str] = Query(None, min_length=10, max_length=200),
|
||||
key_param_name: str = Query('auth_key', min_length=2, max_length=30),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
Generate a new auth key and email a one-time login URL to the user.
|
||||
|
||||
root_url is the base URL the login link will be built from.
|
||||
key_param_name controls the query param name used for the auth key in the link (default: auth_key).
|
||||
Returns data=True on success (email sent), 500 if delivery failed.
|
||||
"""
|
||||
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
|
||||
if not db_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
|
||||
|
||||
result = email_user_auth_key_url(
|
||||
account_id=account.account_id,
|
||||
user_id=db_user_id,
|
||||
root_url=root_url,
|
||||
key_param_name=key_param_name,
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Auth key email could not be sent. Check account email config and user enable status.')
|
||||
|
||||
return mk_resp(data=True, status_message='Auth key email sent.')
|
||||
@@ -5,7 +5,7 @@ from app.routers import (
|
||||
data_store,
|
||||
event_badge_importing,
|
||||
event_importing,
|
||||
api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, lookup_v3,
|
||||
api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_user, lookup_v3,
|
||||
user,
|
||||
util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
|
||||
)
|
||||
@@ -50,6 +50,7 @@ def setup_routers(app: FastAPI):
|
||||
app.include_router(api_v3_actions_event_exhibit.router, prefix='/v3/action/event_exhibit', tags=['Event Exhibit (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)'])
|
||||
# app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup
|
||||
app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user