1- from fastapi import APIRouter , HTTPException , Response , Depends
1+ import time
2+ from collections import defaultdict
3+ from fastapi import APIRouter , HTTPException , Response , Depends , Request
24from app .schemas import LoginRequest , UserResponse
3- from app .auth import verify_password , create_token , get_current_user
5+ from typing import Optional
6+ from pydantic import BaseModel
7+ from app .auth import verify_password , create_token , get_current_user , hash_password
8+ from app .config import settings
49from app .database import get_db
510
611router = APIRouter (prefix = "/api/auth" , tags = ["auth" ])
712
13+ # Simple in-memory rate limiter for login: max 5 attempts per IP per 60 seconds
14+ _login_attempts : dict [str , list [float ]] = defaultdict (list )
15+ _RATE_LIMIT_MAX = 5
16+ _RATE_LIMIT_WINDOW = 60 # seconds
17+
18+
19+ def _check_rate_limit (ip : str ):
20+ now = time .monotonic ()
21+ attempts = _login_attempts [ip ]
22+ # Prune old entries
23+ _login_attempts [ip ] = [t for t in attempts if now - t < _RATE_LIMIT_WINDOW ]
24+ if len (_login_attempts [ip ]) >= _RATE_LIMIT_MAX :
25+ raise HTTPException (
26+ status_code = 429 ,
27+ detail = "Too many login attempts. Please wait before trying again." ,
28+ )
29+ _login_attempts [ip ].append (now )
30+
831
932@router .post ("/login" )
10- async def login (body : LoginRequest , response : Response ):
33+ async def login (body : LoginRequest , request : Request , response : Response ):
34+ client_ip = request .client .host if request .client else "unknown"
35+ _check_rate_limit (client_ip )
36+
1137 db = await get_db ()
1238 rows = await db .execute_fetchall (
13- "SELECT id, username, password_hash, role FROM users WHERE username = ?" ,
39+ "SELECT id, username, password_hash, role, display_name, email FROM users WHERE username = ?" ,
1440 (body .username ,),
1541 )
1642 if not rows or not verify_password (body .password , rows [0 ]["password_hash" ]):
@@ -22,12 +48,18 @@ async def login(body: LoginRequest, response: Response):
2248 key = "token" ,
2349 value = token ,
2450 httponly = True ,
51+ secure = settings .COOKIE_SECURE ,
2552 samesite = "lax" ,
2653 max_age = 86400 ,
2754 )
2855 return {
29- "token" : token ,
30- "user" : {"id" : user ["id" ], "username" : user ["username" ], "role" : user ["role" ]},
56+ "user" : {
57+ "id" : user ["id" ],
58+ "username" : user ["username" ],
59+ "role" : user ["role" ],
60+ "display_name" : user ["display_name" ] or "" ,
61+ "email" : user ["email" ] or "" ,
62+ },
3163 }
3264
3365
@@ -40,3 +72,71 @@ async def logout(response: Response):
4072@router .get ("/me" , response_model = UserResponse )
4173async def me (user = Depends (get_current_user )):
4274 return user
75+
76+
77+ class ProfileUpdateRequest (BaseModel ):
78+ display_name : Optional [str ] = None
79+ email : Optional [str ] = None
80+
81+
82+ @router .get ("/profile" )
83+ async def get_profile (user = Depends (get_current_user )):
84+ db = await get_db ()
85+ rows = await db .execute_fetchall (
86+ "SELECT id, username, role, display_name, email, created_at FROM users WHERE id = ?" ,
87+ (user ["id" ],),
88+ )
89+ return dict (rows [0 ])
90+
91+
92+ @router .put ("/profile" )
93+ async def update_profile (body : ProfileUpdateRequest , user = Depends (get_current_user )):
94+ db = await get_db ()
95+ updates = []
96+ values = []
97+ if body .display_name is not None :
98+ updates .append ("display_name = ?" )
99+ values .append (body .display_name )
100+ if body .email is not None :
101+ updates .append ("email = ?" )
102+ values .append (body .email )
103+
104+ if not updates :
105+ raise HTTPException (status_code = 400 , detail = "No fields to update" )
106+
107+ values .append (user ["id" ])
108+ await db .execute (
109+ f"UPDATE users SET { ', ' .join (updates )} WHERE id = ?" , values
110+ )
111+ await db .commit ()
112+
113+ rows = await db .execute_fetchall (
114+ "SELECT id, username, role, display_name, email, created_at FROM users WHERE id = ?" ,
115+ (user ["id" ],),
116+ )
117+ return dict (rows [0 ])
118+
119+
120+ class ChangePasswordRequest (BaseModel ):
121+ old_password : str
122+ new_password : str
123+
124+
125+ @router .put ("/password" )
126+ async def change_password (body : ChangePasswordRequest , user = Depends (get_current_user )):
127+ if len (body .new_password ) < 4 :
128+ raise HTTPException (status_code = 400 , detail = "New password must be at least 4 characters" )
129+
130+ db = await get_db ()
131+ rows = await db .execute_fetchall (
132+ "SELECT password_hash FROM users WHERE id = ?" , (user ["id" ],)
133+ )
134+ if not rows or not verify_password (body .old_password , rows [0 ]["password_hash" ]):
135+ raise HTTPException (status_code = 400 , detail = "Current password is incorrect" )
136+
137+ await db .execute (
138+ "UPDATE users SET password_hash = ? WHERE id = ?" ,
139+ (hash_password (body .new_password ), user ["id" ]),
140+ )
141+ await db .commit ()
142+ return {"ok" : True }
0 commit comments