diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..d5a1b09 --- /dev/null +++ b/cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.haxx.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 1763753445 access_token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc2Mzc1MzQ0NX0.-n86_vvqIdgpcOXAO7xk_f2Ka1ZQYtRNqbjo3iijz6k diff --git a/justfile b/justfile index a7e1dd6..d82a09e 100644 --- a/justfile +++ b/justfile @@ -7,7 +7,6 @@ stop-auth: start-nginx: docker run \ --rm \ - -d \ --name nginx \ --network host \ -v "$PWD/site":/usr/share/nginx/html:ro \ diff --git a/main_auth.py b/main_auth.py index d98c564..419223d 100755 --- a/main_auth.py +++ b/main_auth.py @@ -4,6 +4,8 @@ # dependencies = [ # "fastapi", # "uvicorn[standard]", +# "python-jose[cryptography]", +# "python-multipart", # ] # /// from fastapi import FastAPI, Request, Response, HTTPException, Depends @@ -11,20 +13,51 @@ from fastapi.responses import RedirectResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from fastapi.security import HTTPBasic, HTTPBasicCredentials import secrets +from jose import JWTError, jwt +from datetime import datetime, timedelta +import os app = FastAPI() security = HTTPBasic() +# JWT Configuration +SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-super-secure-secret-key-change-this-in-production") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + USERS = { "admin": {"password": "admin", "role": "admin"}, "reader": {"password": "reader", "role": "reader"}, } -# Cookie format: session=username +def create_access_token(data: dict, expires_delta: timedelta = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def verify_jwt_token(token: str): + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + username: str = payload.get("sub") + if username is None: + return None + return username + except JWTError: + return None + def get_current_user(request: Request): - session = request.cookies.get("session") - if session and session in USERS: - return session + token = request.cookies.get("access_token") + if not token: + return None + + username = verify_jwt_token(token) + if username and username in USERS: + return username return None def get_current_role(user: str): @@ -35,8 +68,23 @@ async def login(credentials: HTTPBasicCredentials = Depends(security)): user = credentials.username pwd = credentials.password if user in USERS and secrets.compare_digest(USERS[user]['password'], pwd): + # Create JWT token + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user, "role": USERS[user]["role"]}, + expires_delta=access_token_expires + ) + resp = Response("OK", status_code=200) - resp.set_cookie("session", user, httponly=True, samesite='lax', path="/") + resp.set_cookie( + "access_token", + access_token, + httponly=True, + secure=False, # Set to True in production with HTTPS + samesite='lax', + path="/", + max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) # Ensure login response isn't cached resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" resp.headers["Pragma"] = "no-cache" @@ -46,7 +94,7 @@ async def login(credentials: HTTPBasicCredentials = Depends(security)): @app.get("/logout") def logout(): resp = RedirectResponse("/") - resp.delete_cookie("session") + resp.delete_cookie("access_token", path="/") # Ensure logout response isn't cached resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" resp.headers["Pragma"] = "no-cache" @@ -55,17 +103,40 @@ def logout(): @app.get("/authz") def authz(request: Request): - session = request.cookies.get("session") + token = request.cookies.get("access_token") path = request.headers.get("X-Original-URI") - if not session or session not in USERS: + + if not token: return Response("Not authenticated", status_code=401) - user_role = USERS[session]['role'] + + username = verify_jwt_token(token) + if not username or username not in USERS: + return Response("Invalid token", status_code=401) + + user_role = USERS[username]['role'] # Only admin may access /admin if path and path.startswith("/admin") and user_role != 'admin': return Response("Forbidden", status_code=403) # Everything else: allowed return Response("OK", status_code=200) +@app.get("/me") +def get_current_user_info(request: Request): + """Debug endpoint to see current user info""" + token = request.cookies.get("access_token") + if not token: + return {"authenticated": False, "message": "No token"} + + username = verify_jwt_token(token) + if not username or username not in USERS: + return {"authenticated": False, "message": "Invalid token"} + + return { + "authenticated": True, + "username": username, + "role": USERS[username]["role"] + } + if __name__ == "__main__": import uvicorn app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/nginx.conf b/nginx.conf index e101ad5..76e69ba 100644 --- a/nginx.conf +++ b/nginx.conf @@ -39,6 +39,18 @@ http { add_header Content-Type text/html; return 302 http://localhost:8000/login/; } + location /me { + auth_request /authz; + error_page 401 = @login; # If not authed, redirect to login page + error_page 403 = @forbidden; # If forbidden, show custom 403 page + + # Disable all caching for demo purposes + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + add_header Pragma "no-cache"; + add_header Expires "Thu, 01 Jan 1970 00:00:00 GMT"; + + proxy_pass http://localhost:5115/me; + } location @forbidden { add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; @@ -73,8 +85,12 @@ http { add_header Expires "Thu, 01 Jan 1970 00:00:00 GMT"; try_files $uri $uri/index.html =404; } - # AJAX login: POST to FastAPI - location /login { + # Handle /login - GET goes to page, POST goes to FastAPI + location = /login { + if ($request_method = GET) { + return 302 /login/; + } + # POST requests go to FastAPI proxy_pass http://127.0.0.1:5115/login; proxy_set_header Content-Type $content_type; proxy_pass_request_body on; diff --git a/reader_cookies.txt b/reader_cookies.txt new file mode 100644 index 0000000..a62fcab --- /dev/null +++ b/reader_cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.haxx.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 1763753499 access_token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWFkZXIiLCJyb2xlIjoicmVhZGVyIiwiZXhwIjoxNzYzNzUzNDk5fQ.VJipDyYYHl18pbb0XS8m5HBb-PLZ8VIz2eZT1ujgsG4 diff --git a/site/403.html b/site/403.html deleted file mode 100644 index 8857c80..0000000 --- a/site/403.html +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - 403 - Access Forbidden - - - -
-

403

-

Access Forbidden

-

- You don't have permission to access this resource. - You may need to log in with appropriate credentials or your current user role doesn't have access to this page. -

- -
- - diff --git a/site/404.html b/site/404.html deleted file mode 100644 index b1e74a5..0000000 --- a/site/404.html +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - 404 - Page Not Found - - - -
-

404

-

Page Not Found

-

- The page you are looking for might have been removed, had its name changed, - or is temporarily unavailable. -

-
- Available pages:
- Home | - Admin Page | - Login -
- -
- - diff --git a/site/admin.html b/site/admin.html deleted file mode 100644 index 2f12a6c..0000000 --- a/site/admin.html +++ /dev/null @@ -1,2 +0,0 @@ -

Admin page

-

Only admins can see this page!

diff --git a/site/login.html b/site/login.html deleted file mode 100644 index 5644fb8..0000000 --- a/site/login.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - - Login - - - -

Login

-
- - - - -
- - -