File size: 17,747 Bytes
08a1fe6
 
 
 
 
d4a51a1
08a1fe6
 
 
 
 
836f140
 
08a1fe6
 
 
9264f0c
fdce0db
9264f0c
836f140
 
 
 
 
08a1fe6
 
 
 
 
 
dcbc5cc
08a1fe6
 
 
 
cfb5a7e
08a1fe6
 
 
 
 
 
836f140
08a1fe6
 
 
 
 
9381a7e
 
 
131f2b6
9381a7e
 
 
 
 
08a1fe6
 
 
 
 
 
131f2b6
08a1fe6
 
 
fdce0db
 
 
 
08a1fe6
836f140
 
 
08a1fe6
 
 
 
b2a340a
07c16c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
518534a
07c16c1
 
 
08a1fe6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d022d1e
 
 
 
 
 
95ea68c
 
02c2293
95ea68c
 
 
 
d022d1e
95ea68c
 
 
 
 
 
 
 
d022d1e
95ea68c
 
 
 
 
 
d022d1e
 
 
 
 
 
 
 
95ea68c
d022d1e
95ea68c
 
 
836f140
4c88a77
836f140
95ea68c
d022d1e
95ea68c
 
 
 
 
c598104
4c88a77
c598104
 
 
 
 
d4a51a1
 
f5e2ee2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d4a51a1
 
f5e2ee2
 
 
 
 
 
 
 
 
 
 
9264f0c
bcfbb95
bb4b044
9264f0c
 
 
08a1fe6
 
836f140
95ea68c
 
836f140
95ea68c
 
 
 
 
 
 
 
 
 
 
08a1fe6
9381a7e
836f140
05777c4
836f140
 
05777c4
836f140
08a1fe6
 
 
 
05777c4
836f140
05777c4
 
08a1fe6
 
 
05777c4
836f140
aece130
836f140
05777c4
836f140
 
 
05777c4
836f140
08a1fe6
05777c4
9381a7e
aece130
9381a7e
 
 
50aa6f9
9381a7e
 
08a1fe6
 
836f140
8ba99a8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
836f140
8ba99a8
08a1fe6
 
5e372f6
836f140
f5e2ee2
 
8ba99a8
f5e2ee2
 
 
 
05777c4
f5e2ee2
 
 
 
 
836f140
4b20e92
 
 
 
 
 
 
 
 
 
08a1fe6
4b20e92
 
 
 
 
 
 
 
 
f5e2ee2
9381a7e
836f140
 
fdce0db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
836f140
 
 
aece130
836f140
 
 
 
 
 
 
aece130
9381a7e
08a1fe6
 
836f140
4b20e92
08a1fe6
aece130
836f140
08a1fe6
 
 
 
836f140
08a1fe6
836f140
 
 
 
 
 
 
08a1fe6
 
 
836f140
4b20e92
08a1fe6
aece130
836f140
08a1fe6
 
 
 
aece130
836f140
08a1fe6
 
 
836f140
 
 
 
 
 
 
 
08a1fe6
 
836f140
4b20e92
08a1fe6
aece130
836f140
08a1fe6
 
 
 
836f140
 
 
 
 
 
 
 
aece130
836f140
08a1fe6
 
 
 
836f140
 
 
 
 
 
 
08a1fe6
 
836f140
4b20e92
08a1fe6
aece130
836f140
08a1fe6
 
 
 
aece130
836f140
08a1fe6
 
 
 
836f140
08a1fe6
 
 
 
 
 
 
 
aece130
836f140
 
 
 
 
 
 
08a1fe6
 
616cbd3
4b20e92
08a1fe6
836f140
08a1fe6
836f140
08a1fe6
836f140
08a1fe6
836f140
08a1fe6
aece130
836f140
 
 
 
08a1fe6
836f140
 
 
 
 
 
 
08a1fe6
 
f7e8bff
08a1fe6
 
836f140
 
 
 
0b396f5
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
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
import os
from fastapi import FastAPI, HTTPException, Header, status, APIRouter
from pydantic import BaseModel, EmailStr
from typing import Dict, List
from datetime import datetime, timedelta, timezone
from dateutil import parser
import secrets
import hashlib
import uuid
from user_agents import parse
from dotenv import load_dotenv
from supabase import create_client, Client
from typing import Optional

load_dotenv()

SERVER_NAME = "Nexus Authentication Service"
VERSION = "1.0.6 debug"

# Supabase Configuration
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_KEY = os.getenv("SUPABASE_KEY")
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)

SYSTEM_USER = os.getenv("SYSTEM_USER")
SYSTEM_PASSWORD = os.getenv("SYSTEM_PASSWORD")

app = FastAPI()

# Routers
root_router = APIRouter()
auth_router = APIRouter(prefix="/v1/api/auth", tags=["Authentication"])
admin_router = APIRouter(prefix="/v1/api/admin", tags=["Admin"])

# Constants
TOKEN_EXPIRATION_MINUTES = 1440 # 24 hours
ACCESS_LEVELS = ["default", "member", "admin", "dev", "hush"]

# Models
class SignupRequest(BaseModel):
    username: str
    password: str
    email: Optional[EmailStr] = None

class LoginRequest(BaseModel):
    username: str
    password: str

class LoginResponse(BaseModel):
    user_id: str
    username: str
    email: Optional[EmailStr]
    access_level: str
    date_joined: datetime
    access_token: str
    token_type: str = "bearer"

class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"

class UserResponse(BaseModel):
    username: str
    email: Optional[EmailStr]
    access_level: str
    date_joined: datetime

class UsernameAvailabilityResponse(BaseModel):
    username: str
    is_available: bool

class UpdateUserRequest(BaseModel):
    password: Optional[str] = None
    email: Optional[EmailStr] = None
    username: Optional[str] = None

class UpdateAccessLevelRequest(BaseModel):
    access_level: str

def generate_numeric_user_id(length=16):
  """
  Generates a numeric user ID with the specified length.

  Args:
    length: The desired length of the user ID.

  Returns:
    A string representing the numeric user ID.
  """
  # Generate a random UUID
  uuid_str = str(uuid.uuid4()).replace('-', '') 

  # Convert the UUID to an integer
  uuid_int = int(uuid_str, 16) 

  # Generate a numeric ID with the specified length
  numeric_id = str(uuid_int)[-length:] 
  print(numeric_id)

  return numeric_id

# Utility functions
def hash_password(password: str) -> str:
    return hashlib.sha256(password.encode()).hexdigest()

def verify_password(password: str, hashed_password: str) -> bool:
    return hash_password(password) == hashed_password

def create_device_token(username: str, user_agent: str) -> str:
    device_info = parse(user_agent)
    token_seed = f"{username}-{device_info.device.family}-{device_info.os.family}-{secrets.token_hex(16)}"
    return hashlib.sha256(token_seed.encode()).hexdigest()

def is_token_expired(expiration_time: datetime) -> bool:
    return datetime.now(timezone.utc) > expiration_time

async def create_user(
    username: str,
    password: str,
    email: Optional[str] = None,
    access_level: Optional[str] = None,
):
    """
    Creates a new user in the database.

    Args:
        username: The username of the new user.
        password: The password of the new user (hashed).
        email: The email address of the new user (optional).
        access_level: The access level of the new user (optional).

    Returns:
        The created user object.

    Raises:
        HTTPException: If the username already exists.
    """

    # Check if the username already exists
    existing_user = supabase.table("users").select("*").eq("username", username).execute()
    if existing_user.data:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, detail="Username already exists"
        )

    # Ensure the generated user_id is unique
    while True:
        user_id = generate_numeric_user_id()
        existing_user_id = supabase.table("users").select("*").eq("user_id", user_id).execute()
        if not existing_user_id.data:
            break

    # Prepare user data
    user_data = {
        "user_id": user_id,
        "username": username,
        "password": password,
        "email": email,
        "date_joined": datetime.now(timezone.utc).isoformat(),
        "access_level": access_level or "default",
    }

    # Insert the user into the database
    inserted_user = supabase.table("users").insert(user_data).execute()
    return inserted_user.data[0]

# Initialize system user
async def init_system_user():
    try:
        await create_user(SYSTEM_USER, hash_password(SYSTEM_PASSWORD), None, "hush")
    except HTTPException as e:
        if e.status_code == status.HTTP_400_BAD_REQUEST and "Username already exists" in e.detail:
            print("System user already exists, continuing...")
        else:
            raise e


async def validate_session(user_id: str, token: str, user_agent: str) -> Optional[dict]:
    """
    Validates the session for the given user_id, token, and user_agent.

    Args:
        user_id: The user ID associated with the session.
        token: The token to validate.
        user_agent: The user agent/device identifier.

    Returns:
        The session data if valid.

    Raises:
        HTTPException: If the session is invalid or the token is expired.
    """
    # Query to validate session by user_id, token, and device
    session_query = (
        supabase.table("sessions")
        .select("*")
        .eq("user_id", user_id)
        .eq("token", token)
        .eq("device", user_agent)
        .execute()
    )

    # If no session found, raise unauthorized error
    if not session_query.data:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
        )

    session = session_query.data[0]

    # Get the current time (UTC) with timezone awareness
    current_time = datetime.now(timezone.utc)

    # Parse the 'expires' field from the session using dateutil.parser
    session_expiry = parser.parse(session["expires"])

    # Check if the token has expired
    if session_expiry <= current_time:
        # Delete the session if expired
        supabase.table("sessions").delete().eq("user_id", user_id).eq("token", token).execute()
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
        )

    return session


@root_router.get("/", status_code=status.HTTP_200_OK)
async def root():
    message = f'{SERVER_NAME} v{VERSION}'
    return {"message": message}

# Authentication Routes
@auth_router.post("/signup", status_code=status.HTTP_201_CREATED)
async def signup(request: SignupRequest):
    """
    Signup route handler.

    Uses the create_user helper function to create a new user.

    Args:
        request: Signup request data.

    Returns:
        A JSON response with a success message.
    """

    created_user = await create_user(request.username, hash_password(request.password), request.email)
    return {"message": "User created successfully", "user": created_user}

@auth_router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, user_agent: str = Header(...)):
    # Query the user based on the username
    user_query = supabase.table("users").select("*").eq("username", request.username).execute()
    
    # If user not found or password verification fails, raise an error
    if not user_query.data or not verify_password(request.password, user_query.data[0]["password"]):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
        )

    # Extract user details
    user = user_query.data[0]

    # Create a token and calculate expiration time in UTC
    token = create_device_token(request.username, user_agent)
    expiration_time = datetime.now(timezone.utc) + timedelta(minutes=TOKEN_EXPIRATION_MINUTES)

    # Prepare session data with the expiration time in ISO 8601 format (UTC)
    session_data = {
        "user_id": user["user_id"],
        "token": token,
        "expires": expiration_time.isoformat(),  # ISO 8601 format ensures the timezone is stored
        "device": user_agent
    }
    
    # Insert the session data into the database
    supabase.table("sessions").insert(session_data).execute()

    # Return the login response with relevant user details
    return LoginResponse(
        user_id=user["user_id"],
        username=user["username"],
        email=user["email"],
        access_level=user["access_level"],
        date_joined=(user["date_joined"]),
        access_token=token
    )

@auth_router.post("/logout")
async def logout(user_id: str, token: str):
    # Query to check if the session exists for the given user_id and token
    session_query = (
        supabase.table("sessions")
        .select("*")
        .eq("user_id", user_id)
        .eq("token", token)
        .execute()
    )

    # If session is not found, raise an unauthorized error
    if not session_query.data:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Session not found or already expired"
        )

    # Delete the session using the composite key (user_id and token)
    supabase.table("sessions").delete().eq("user_id", user_id).eq("token", token).execute()

    return {"message": "Session forcefully expired"}

@auth_router.get("/validate", response_model=LoginResponse)
async def validate_token(user_id: str, token: str, user_agent: str = Header(...)):
    """
    Route to validate a token based on user_id, token, and user agent.

    Args:
        user_id: The user ID associated with the token.
        token: The token to validate.
        user_agent: The user agent (from request header).

    Returns:
        TokenResponse: The validated token response.
    """
    # Use the helper function to validate the session
    await validate_session(user_id=user_id, token=token, user_agent=user_agent)

    user_query = supabase.table("users").select("*").eq("user_id", user_id).execute()
    
    # If user not found or password verification fails, raise an error
    if not user_query.data:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
        )

    # Extract user details
    user = user_query.data[0]

    # Return the login response with relevant user details
    return LoginResponse(
        user_id=user["user_id"],
        username=user["username"],
        email=user["email"],
        access_level=user["access_level"],
        date_joined=(user["date_joined"]),
        access_token=token
    )

@auth_router.get("/search-users", response_model=List[str])
async def search_users(query: str):
    users = supabase.table("users").select("username").ilike("username", f"%{query}%").execute()
    usernames = [user["username"] for user in users.data]
    
    # Exclude SYSTEM_USER from the list if it's present
    if SYSTEM_USER and SYSTEM_USER in usernames:
        usernames.remove(SYSTEM_USER)
    
    return usernames

@auth_router.get("/is-username-available", response_model=UsernameAvailabilityResponse)
async def is_username_available(query: str):
    users = supabase.table("users").select("username").eq("username", query).execute()
    return UsernameAvailabilityResponse(
        username=query,
        is_available=len(users.data) == 0  # If no users are found, the username is available
    )

@auth_router.get("/get-user-id", response_model=str)
async def get_user_id(username: str):
    user_query = supabase.table("users").select("user_id").eq("username", username).execute()
    
    if not user_query.data:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="Username not found"
        )
    
    return user_query.data[0]["user_id"]

# Admin Routes
@admin_router.get("/users", response_model=List[UserResponse])
async def get_all_users(user_id: str, token: str, user_agent: str = Header(...)):
    await validate_session(user_id, token, user_agent)

    admin_query = supabase.table("users").select("access_level").eq("user_id", user_id).execute()
    if not admin_query.data or admin_query.data[0]["access_level"] != "hush":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions"
        )

    users = supabase.table("users").select("*").execute()
    return [
        UserResponse(
            username=user["username"],
            email=user["email"],
            access_level=user["access_level"],
            date_joined=datetime.fromisoformat(user["date_joined"])
        )
        for user in users.data
    ]

@admin_router.get("/user/{user_id}", response_model=UserResponse)
async def get_user(admin_id: str, token: str, user_id: str, user_agent: str = Header(...)):
    await validate_session(admin_id, token, user_agent)

    admin_query = supabase.table("users").select("access_level").eq("user_id", admin_id).execute()
    if not admin_query.data or admin_query.data[0]["access_level"] != "hush":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions"
        )

    user_query = supabase.table("users").select("*").eq("user_id", user_id).execute()
    if not user_query.data:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
        )

    user = user_query.data[0]
    return UserResponse(
        username=user["username"],
        email=user["email"],
        access_level=user["access_level"],
        date_joined=datetime.fromisoformat(user["date_joined"])
    )

@admin_router.put("/user/{user_id}", response_model=UserResponse)
async def update_user(admin_id: str, token: str, user_id: str, request: UpdateUserRequest, user_agent: str = Header(...)):
    await validate_session(admin_id, token, user_agent)

    admin_query = supabase.table("users").select("access_level").eq("user_id", admin_id).execute()
    if not admin_query.data or admin_query.data[0]["access_level"] != "hush":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions"
        )

    update_data = {}
    if request.password:
        update_data["password"] = hash_password(request.password)
    if request.email:
        update_data["email"] = request.email
    if request.username:
        update_data["username"] = request.username

    updated_user = supabase.table("users").update(update_data).eq("user_id", user_id).execute()
    if not updated_user.data:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
        )

    user = updated_user.data[0]
    return UserResponse(
        username=user["username"],
        email=user["email"],
        access_level=user["access_level"],
        date_joined=datetime.fromisoformat(user["date_joined"])
    )

@admin_router.put("/user/{user_id}/access-level")
async def update_access_level(admin_id: str, token: str, user_id: str, request: UpdateAccessLevelRequest, user_agent: str = Header(...)):
    await validate_session(admin_id, token, user_agent)

    admin_query = supabase.table("users").select("access_level").eq("user_id", admin_id).execute()
    if not admin_query.data or admin_query.data[0]["access_level"] != "hush":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions"
        )

    user_query = supabase.table("users").select("*").eq("user_id", user_id).execute()
    if not user_query.data:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
        )

    user = user_query.data[0]
    new_access_level = request.access_level

    if ACCESS_LEVELS.index(new_access_level) <= ACCESS_LEVELS.index(user["access_level"]):
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Cannot downgrade a user or change to the same level",
        )

    updated_user = supabase.table("users").update({"access_level": new_access_level}).eq("user_id", user_id).execute()
    user = updated_user.data[0]
    return UserResponse(
        username=user["username"],
        email=user["email"],
        access_level=user["access_level"],
        date_joined=datetime.fromisoformat(user["date_joined"])
    )

@auth_router.put("/user/update", response_model=UserResponse)
async def update_own_data(user_id: str,token: str, request: UpdateUserRequest,  user_agent: str = Header(...)):
    await validate_session(user_id, token, user_agent)

    update_data = {}
    if request.password:
        update_data["password"] = hash_password(request.password)
    if request.email:
        update_data["email"] = request.email
    if request.username:
        update_data["username"] = request.username

    updated_user = supabase.table("users").update(update_data).eq("user_id", user_id).execute()
    if not updated_user.data:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
        )

    user = updated_user.data[0]
    return UserResponse(
        username=user["username"],
        email=user["email"],
        access_level=user["access_level"],
        date_joined=datetime.fromisoformat(user["date_joined"])
    )

# Include routes
app.include_router(root_router)
app.include_router(auth_router)
app.include_router(admin_router)

# Initialize system user on startup
@app.on_event("startup")
async def startup_event():
    await init_system_user()