File size: 7,171 Bytes
ffdf96a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
"""One Neon-backed account and session for logistics and Trace."""
import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone
from typing import Literal

from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from psycopg2.errors import UniqueViolation
from database import get_db_connection

router = APIRouter(prefix='/api', tags=['UzoAgro accounts'])
security = HTTPBearer(auto_error=False)


class Login(BaseModel):
    phone: str = Field(pattern=r'^\+?[0-9]{10,15}$')
    pin: str = Field(pattern=r'^[0-9]{4}$')


class Signup(Login):
    role: Literal['farmer', 'driver']
    name: str = Field(min_length=2, max_length=80)
    primary_city: str = Field(min_length=2, max_length=120)
    nin: str = Field(pattern=r'^[0-9]{11}$')
    email: str | None = Field(default=None, max_length=254)
    capacity: float = Field(default=15, gt=0, le=100)
    reference: str | None = None


def public(account):
    return {k: account.get(k) for k in ('name', 'role', 'phone', 'email', 'primary_city')}


def hash_pin(pin, salt):
    return hashlib.pbkdf2_hmac('sha256', pin.encode(), salt.encode(), 600000).hex()


def issue_session(cur, account):
    token = secrets.token_urlsafe(32)
    cur.execute('INSERT INTO user_sessions (token_hash, phone, expires_at) VALUES (%s, %s, %s)',
                (hashlib.sha256(token.encode()).hexdigest(), account['phone'], datetime.now(timezone.utc) + timedelta(days=1)))
    return {'status': 'success', 'token': token, 'user': public(account)}


def current_user(auth: HTTPAuthorizationCredentials = Depends(security)):
    if not auth:
        raise HTTPException(401, 'Sign in to your UzoAgro account.')
    conn = get_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute('''SELECT u.name, u.role, u.phone, u.email, u.primary_city
                FROM user_sessions s JOIN users u ON u.phone=s.phone
                WHERE s.token_hash=%s AND s.expires_at>%s''',
                (hashlib.sha256(auth.credentials.encode()).hexdigest(), datetime.now(timezone.utc)))
            account = cur.fetchone()
        if not account:
            raise HTTPException(401, 'Your session expired. Please sign in again.')
        if account['role'] not in ('farmer', 'driver'):
            raise HTTPException(403, 'This account has an unsupported role.')
        return public(account)
    finally:
        conn.close()


def farmer(account=Depends(current_user)):
    if account['role'] != 'farmer':
        raise HTTPException(403, 'Crop assessments and batch management are for farmer accounts.')
    return account


@router.post('/login')
def login(data: Login):
    conn = get_db_connection()
    try:
        with conn.cursor() as cur:
            cur.execute('SELECT failed_attempts, locked_until FROM login_attempts WHERE phone=%s', (data.phone,))
            attempt = cur.fetchone()
            if attempt and attempt['locked_until'] and attempt['locked_until'] > datetime.now(timezone.utc):
                raise HTTPException(429, 'Too many attempts. Try again in 15 minutes.')
            cur.execute('SELECT name, role, phone, email, primary_city, pin, pin_hash, pin_salt FROM users WHERE phone=%s', (data.phone,))
            account = cur.fetchone()
            valid = account and (hmac.compare_digest(hash_pin(data.pin, account['pin_salt']), account['pin_hash'])
                                 if account['pin_hash'] and account['pin_salt'] else hmac.compare_digest(data.pin, str(account['pin'] or '')))
            if not valid:
                count = (attempt['failed_attempts'] if attempt and not attempt['locked_until'] else 0) + 1
                until = datetime.now(timezone.utc) + timedelta(minutes=15) if count >= 5 else None
                cur.execute('''INSERT INTO login_attempts(phone,failed_attempts,locked_until) VALUES (%s,%s,%s)
                    ON CONFLICT(phone) DO UPDATE SET failed_attempts=excluded.failed_attempts, locked_until=excluded.locked_until''', (data.phone, count, until))
                conn.commit()
                raise HTTPException(401, 'Invalid phone number or PIN.')
            if account['role'] not in ('farmer', 'driver'):
                raise HTTPException(403, 'This account has an unsupported role.')
            # Existing four-digit PINs remain valid; add a hash without changing the PIN.
            if not account['pin_hash']:
                salt = secrets.token_hex(16)
                cur.execute('UPDATE users SET pin_hash=%s, pin_salt=%s WHERE phone=%s', (hash_pin(data.pin, salt), salt, data.phone))
            cur.execute('DELETE FROM login_attempts WHERE phone=%s', (data.phone,))
            result = issue_session(cur, account)
        conn.commit()
        return result
    finally:
        conn.close()


@router.post('/signup')
def signup(data: Signup):
    conn = get_db_connection()
    try:
        salt = secrets.token_hex(16)
        with conn.cursor() as cur:
            cur.execute('''INSERT INTO users(role,name,phone,nin,primary_city,email,pin_hash,pin_salt)
                VALUES (%s,%s,%s,%s,%s,%s,%s,%s)''',
                (data.role, data.name, data.phone, data.nin, data.primary_city, data.email, hash_pin(data.pin, salt), salt))
            if data.role == 'driver':
                # Respect the actual columns in the existing drivers table.
                cur.execute("SELECT column_name FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='drivers'")
                columns = {r['column_name'] for r in cur.fetchall()}
                values = {'driver_id': 'DRV-' + secrets.token_hex(6).upper(), 'name': data.name, 'driver_name': data.name,
                          'phone': data.phone, 'current_city': data.primary_city, 'home_base_city': data.primary_city,
                          'home_base': data.primary_city, 'available_capacity': data.capacity, 'capacity': data.capacity,
                          'allowed_crops': 'All', 'available_date': datetime.now(timezone.utc).date().isoformat()}
                selected = {k: v for k, v in values.items() if k in columns}
                cur.execute('INSERT INTO drivers (' + ','.join(selected) + ') VALUES (' + ','.join(['%s'] * len(selected)) + ')', tuple(selected.values()))
            result = issue_session(cur, data.model_dump())
        conn.commit()
        return result
    except UniqueViolation:
        conn.rollback()
        raise HTTPException(409, 'Phone number already registered. Sign in instead.')
    finally:
        conn.close()


@router.get('/me')
def me(account=Depends(current_user)):
    return {'user': account}


@router.post('/logout')
def logout(auth: HTTPAuthorizationCredentials = Depends(security)):
    if auth:
        conn = get_db_connection()
        try:
            with conn.cursor() as cur:
                cur.execute('DELETE FROM user_sessions WHERE token_hash=%s', (hashlib.sha256(auth.credentials.encode()).hexdigest(),))
            conn.commit()
        finally:
            conn.close()
    return {'status': 'signed out'}