yamilogic commited on
Commit
f54f330
·
1 Parent(s): b21180e

deploy: FastAPI auth backend with OTP email

Browse files
Files changed (5) hide show
  1. .dockerignore +4 -0
  2. Dockerfile +8 -0
  3. README.md +7 -5
  4. main.py +432 -0
  5. requirements.txt +6 -0
.dockerignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ *.db
4
+ .git
Dockerfile ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY . .
6
+ RUN mkdir -p /app/data
7
+ EXPOSE 7860
8
+ CMD sh -c "uvicorn main:app --host 0.0.0.0 --port ${PORT:-7860}"
README.md CHANGED
@@ -1,10 +1,12 @@
1
  ---
2
  title: Auth Backend
3
- emoji: 🌍
4
- colorFrom: gray
5
- colorTo: blue
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
  title: Auth Backend
3
+ emoji: 🔐
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  ---
9
 
10
+ # Auth Backend API
11
+
12
+ FastAPI backend with JWT authentication and OTP email verification.
main.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import bcrypt
2
+ from fastapi import FastAPI, Depends, HTTPException
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from fastapi.security import OAuth2PasswordBearer
5
+ from jose import jwt, JWTError
6
+ from datetime import datetime, timedelta
7
+ from pydantic import BaseModel, Field, validator
8
+ from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime
9
+ from sqlalchemy.orm import sessionmaker, Session, DeclarativeBase
10
+ import os
11
+ import re
12
+ import random
13
+ import string
14
+ import threading
15
+ import logging
16
+
17
+ logger = logging.getLogger(__name__)
18
+ logging.basicConfig(level=logging.INFO)
19
+
20
+ # Use /app/data in Docker, ./data locally
21
+ DB_DIR = os.environ.get("DB_DIR", "./data")
22
+ os.makedirs(DB_DIR, exist_ok=True)
23
+ DATABASE_URL = f"sqlite:///{os.path.join(DB_DIR, 'users.db')}"
24
+
25
+ SECRET_KEY = os.environ.get("SECRET_KEY", "mysecretkey123")
26
+ ALGORITHM = "HS256"
27
+ ACCESS_TOKEN_EXPIRE_MINUTES = 30
28
+
29
+ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
30
+ SessionLocal = sessionmaker(bind=engine)
31
+
32
+ class Base(DeclarativeBase):
33
+ pass
34
+
35
+ class User(Base):
36
+ __tablename__ = "users"
37
+
38
+ id = Column(Integer, primary_key=True)
39
+ username = Column(String, unique=True)
40
+ email = Column(String, unique=True)
41
+ password = Column(String)
42
+ is_activate = Column(Boolean, default=True)
43
+
44
+ class OTP(Base):
45
+ __tablename__ = "otp"
46
+
47
+ id = Column(Integer, primary_key=True)
48
+ email = Column(String)
49
+ code = Column(String)
50
+ purpose = Column(String)
51
+ expires_at = Column(DateTime)
52
+ is_used = Column(Boolean, default=False)
53
+ username = Column(String, nullable=True)
54
+ password_hash = Column(String, nullable=True)
55
+
56
+ class RegisterRequest(BaseModel):
57
+ username: str = Field(min_length=3, max_length=20)
58
+ email: str
59
+ password: str = Field(min_length=6)
60
+
61
+ @validator("username")
62
+ def validate_username(cls, v):
63
+ if not re.match(r"^[a-zA-Z0-9_]+$", v):
64
+ raise ValueError("Username must be alphanumeric with underscores only")
65
+ return v
66
+
67
+ @validator("email")
68
+ def validate_email(cls, v):
69
+ if not re.match(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", v):
70
+ raise ValueError("Invalid email format")
71
+ return v
72
+
73
+ class LoginRequest(BaseModel):
74
+ username: str
75
+ password: str
76
+
77
+ class TokenResponse(BaseModel):
78
+ access_token: str
79
+ refresh_token: str
80
+ token_type: str = "bearer"
81
+
82
+ class RefreshRequest(BaseModel):
83
+ refresh_token: str
84
+
85
+ class ForgotPasswordRequest(BaseModel):
86
+ email: str
87
+
88
+ class ForgotPasswordVerifyRequest(BaseModel):
89
+ email: str
90
+ otp: str
91
+ new_password: str = Field(min_length=6)
92
+
93
+ class VerifyRegisterRequest(BaseModel):
94
+ email: str
95
+ otp: str
96
+
97
+ # Email config - SMTP for local, Brevo HTTP for Render
98
+ BREVO_API_KEY = os.environ.get("BREVO_API_KEY", "")
99
+ EMAIL_FROM = "ashutoshknp12@gmail.com"
100
+ EMAIL_FROM_NAME = "Auth System"
101
+
102
+ # Gmail SMTP for local testing (use env vars so passwords stay out of code)
103
+ SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
104
+ SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
105
+ SMTP_USER = os.environ.get("SMTP_USER", "ashutoshknp12@gmail.com")
106
+ SMTP_PASS = os.environ.get("SMTP_PASS", "")
107
+
108
+ def generate_otp():
109
+ return ''.join(random.choices(string.digits, k=6))
110
+
111
+ def send_otp_email(to_email, otp_code, purpose):
112
+ """Send OTP email. Uses Brevo HTTP API on Render, Gmail SMTP locally."""
113
+ if purpose == "register":
114
+ subject = "Verify Your Account"
115
+ elif purpose == "forgot_password":
116
+ subject = "Password Reset Code"
117
+ else:
118
+ subject = "Your OTP Code"
119
+
120
+ html = f"<p>Your OTP code is: <strong>{otp_code}</strong></p><p>It expires in 5 minutes.</p>"
121
+ body = f"Your OTP code is: {otp_code}\nIt expires in 5 minutes."
122
+
123
+ # --- Method 1: Brevo HTTP API (works on Render, uses HTTPS port 443) ---
124
+ # Render free tier BLOCKS SMTP ports 25/465/587, so we MUST use HTTP API
125
+ if BREVO_API_KEY:
126
+ try:
127
+ import urllib.request
128
+ import json
129
+ payload = json.dumps({
130
+ "sender": {"name": EMAIL_FROM_NAME, "email": EMAIL_FROM},
131
+ "to": [{"email": to_email}],
132
+ "subject": subject,
133
+ "htmlContent": html
134
+ }).encode()
135
+ req = urllib.request.Request(
136
+ "https://api.brevo.com/v3/smtp/email",
137
+ data=payload,
138
+ headers={"api-key": BREVO_API_KEY, "Content-Type": "application/json"}
139
+ )
140
+ resp = urllib.request.urlopen(req, timeout=15)
141
+ resp_body = resp.read().decode()
142
+ logger.info(f"OTP email sent to {to_email} via Brevo. Response: {resp_body}")
143
+ return True
144
+ except Exception as e:
145
+ logger.error(f"Brevo HTTP API failed for {to_email}: {e}")
146
+ # Don't fall through to SMTP on Render — it's blocked anyway
147
+ return False
148
+
149
+ # --- Method 2: Gmail SMTP (works locally, NOT on Render free tier) ---
150
+ if SMTP_PASS:
151
+ try:
152
+ import smtplib
153
+ from email.mime.text import MIMEText
154
+ msg = MIMEText(body)
155
+ msg["Subject"] = subject
156
+ msg["From"] = EMAIL_FROM
157
+ msg["To"] = to_email
158
+ server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=10)
159
+ server.login(SMTP_USER, SMTP_PASS)
160
+ server.sendmail(EMAIL_FROM, to_email, msg.as_string())
161
+ server.quit()
162
+ logger.info(f"OTP email sent to {to_email} via Gmail SMTP")
163
+ return True
164
+ except Exception as e:
165
+ logger.error(f"Gmail SMTP failed for {to_email}: {e}")
166
+ return False
167
+
168
+ # No email method available
169
+ logger.error("No email method set up! Set BREVO_API_KEY (for Render) or SMTP_PASS (for local)")
170
+ return False
171
+
172
+ # Removed background thread — send email directly so we can report errors to user
173
+
174
+ def create_otp(db, email, purpose):
175
+ code = generate_otp()
176
+ otp = OTP(
177
+ email=email,
178
+ code=code,
179
+ purpose=purpose,
180
+ expires_at=datetime.utcnow() + timedelta(minutes=5)
181
+ )
182
+ db.add(otp)
183
+ db.commit()
184
+ return code
185
+
186
+ def hash_password(password):
187
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=4)).decode()
188
+
189
+ def verify_password(plain, hashed):
190
+ return bcrypt.checkpw(plain.encode(), hashed.encode())
191
+
192
+ def create_access_token(data):
193
+ to_encode = data.copy()
194
+ to_encode["sub"] = str(to_encode["sub"])
195
+ to_encode["exp"] = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
196
+ to_encode["type"] = "access"
197
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
198
+
199
+ def create_refresh_token(data):
200
+ to_encode = data.copy()
201
+ to_encode["sub"] = str(to_encode["sub"])
202
+ to_encode["exp"] = datetime.utcnow() + timedelta(days=7)
203
+ to_encode["type"] = "refresh"
204
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
205
+
206
+
207
+
208
+ def get_db():
209
+ db = SessionLocal()
210
+ try:
211
+ yield db
212
+ finally:
213
+ db.close()
214
+
215
+
216
+
217
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
218
+
219
+ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
220
+ try:
221
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
222
+ user_id = int(payload.get("sub"))
223
+ token_type = payload.get("type")
224
+ if user_id is None or token_type != "access":
225
+ raise HTTPException(status_code=401, detail="Invalid token")
226
+ except JWTError:
227
+ raise HTTPException(status_code=401, detail="Invalid token")
228
+
229
+ user = db.query(User).filter(User.id == user_id).first()
230
+ if not user:
231
+ raise HTTPException(status_code=401, detail="User not found")
232
+ return user
233
+
234
+
235
+ app = FastAPI()
236
+
237
+ CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://frontend-15-two.vercel.app,https://frontend-15-b3jpt0vcu-ashutoshs-projects-8cd9906b.vercel.app,http://localhost:5173,http://localhost:5174,http://localhost:7860").split(",")
238
+ app.add_middleware(
239
+ CORSMiddleware,
240
+ allow_origins=CORS_ORIGINS,
241
+ allow_credentials=True,
242
+ allow_methods=["*"],
243
+ allow_headers=["*"],
244
+ )
245
+
246
+ Base.metadata.create_all(bind=engine)
247
+
248
+ @app.post("/auth/register")
249
+ def register(req: RegisterRequest, db: Session = Depends(get_db)):
250
+ if db.query(User).filter(User.username == req.username).first():
251
+ raise HTTPException(400, "Username taken")
252
+ if db.query(User).filter(User.email == req.email).first():
253
+ raise HTTPException(400, "Email already registered")
254
+ code = generate_otp()
255
+ otp = OTP(
256
+ email=req.email,
257
+ code=code,
258
+ purpose="register",
259
+ expires_at=datetime.utcnow() + timedelta(minutes=5),
260
+ username=req.username,
261
+ password_hash=hash_password(req.password)
262
+ )
263
+ db.add(otp)
264
+ db.commit()
265
+ success = send_otp_email(req.email, code, "register")
266
+ if not success:
267
+ raise HTTPException(500, "Failed to send OTP email. Try again.")
268
+ return {"message": "OTP sent to email. Verify to activate account."}
269
+
270
+ @app.post("/auth/register/verify")
271
+ def register_verify(req: VerifyRegisterRequest, db: Session = Depends(get_db)):
272
+ otp = db.query(OTP).filter(
273
+ OTP.email == req.email,
274
+ OTP.code == req.otp,
275
+ OTP.purpose == "register",
276
+ OTP.is_used == False
277
+ ).first()
278
+ if not otp:
279
+ raise HTTPException(400, "Invalid OTP")
280
+ if otp.expires_at < datetime.utcnow():
281
+ raise HTTPException(400, "OTP expired")
282
+ user = User(
283
+ username=otp.username,
284
+ email=otp.email,
285
+ password=otp.password_hash,
286
+ is_activate=True
287
+ )
288
+ db.add(user)
289
+ otp.is_used = True
290
+ db.commit()
291
+ return {"message": "Account verified. You can now login."}
292
+
293
+ @app.post("/auth/login")
294
+ def login(req: LoginRequest, db: Session = Depends(get_db)):
295
+ user = db.query(User).filter(
296
+ (User.username == req.username) | (User.email == req.username)
297
+ ).first()
298
+ if not user or not verify_password(req.password, user.password):
299
+ raise HTTPException(401, "Wrong credentials")
300
+ if not user.is_activate:
301
+ raise HTTPException(401, "Account not verified. Check your email for OTP.")
302
+ return {
303
+ "access_token": create_access_token({"sub": user.id}),
304
+ "refresh_token": create_refresh_token({"sub": user.id}),
305
+ "user": {"id": user.id, "username": user.username, "email": user.email}
306
+ }
307
+
308
+ @app.get("/me")
309
+ def me(current_user: User = Depends(get_current_user)):
310
+ return {"id": current_user.id, "username": current_user.username, "email": current_user.email}
311
+
312
+ @app.post("/auth/refresh", response_model=TokenResponse)
313
+ def refresh(req: RefreshRequest, db: Session = Depends(get_db)):
314
+ try:
315
+ payload = jwt.decode(req.refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
316
+ if payload.get("type") != "refresh":
317
+ raise HTTPException(401, "Invalid token")
318
+ user_id = int(payload.get("sub"))
319
+ except JWTError:
320
+ raise HTTPException(401, "Invalid token")
321
+
322
+ user = db.query(User).filter(User.id == user_id).first()
323
+ if not user:
324
+ raise HTTPException(401, "User not found")
325
+
326
+ if not user.is_activate:
327
+ raise HTTPException(401, "User deactivated")
328
+
329
+ new_refresh_token = create_refresh_token({"sub": user.id})
330
+
331
+ return {
332
+ "access_token": create_access_token({"sub": user.id}),
333
+ "refresh_token": new_refresh_token
334
+ }
335
+
336
+ @app.post("/users")
337
+ def create_user(
338
+ req: RegisterRequest,
339
+ db: Session = Depends(get_db),
340
+ current_user: User = Depends(get_current_user)
341
+ ):
342
+ if db.query(User).filter(User.username == req.username).first():
343
+ raise HTTPException(400, "Username taken")
344
+ user = User(
345
+ username=req.username,
346
+ email=req.email,
347
+ password=hash_password(req.password)
348
+ )
349
+ db.add(user)
350
+ db.commit()
351
+ db.refresh(user)
352
+ return {"id": user.id, "username": user.username, "email": user.email}
353
+
354
+ @app.get("/users")
355
+ def get_users(
356
+ db: Session = Depends(get_db),
357
+ current_user: User = Depends(get_current_user)
358
+ ):
359
+ return db.query(User).all()
360
+
361
+ @app.get("/users/{user_id}")
362
+ def get_user(
363
+ user_id: int,
364
+ db: Session = Depends(get_db),
365
+ current_user: User = Depends(get_current_user)
366
+ ):
367
+ user = db.query(User).filter(User.id == user_id).first()
368
+ if not user:
369
+ raise HTTPException(status_code=404, detail="User not found")
370
+ return user
371
+
372
+ @app.put("/users/{user_id}")
373
+ def update_user(
374
+ user_id: int,
375
+ username: str,
376
+ db: Session = Depends(get_db),
377
+ current_user: User = Depends(get_current_user)
378
+ ):
379
+ user = db.query(User).filter(User.id == user_id).first()
380
+ if not user:
381
+ raise HTTPException(status_code=404, detail="User not found")
382
+ user.username = username
383
+ db.commit()
384
+ return user
385
+
386
+ @app.delete("/users/{user_id}")
387
+ def delete_user(
388
+ user_id: int,
389
+ db: Session = Depends(get_db),
390
+ current_user: User = Depends(get_current_user)
391
+ ):
392
+ user = db.query(User).filter(User.id == user_id).first()
393
+ if not user:
394
+ raise HTTPException(status_code=404, detail="User not found")
395
+ db.delete(user)
396
+ db.commit()
397
+ return {"message": "Deleted"}
398
+
399
+ @app.post("/auth/forgot-password")
400
+ def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_db)):
401
+ user = db.query(User).filter(User.email == req.email).first()
402
+ if not user:
403
+ raise HTTPException(404, "Email not found")
404
+ code = create_otp(db, req.email, "forgot_password")
405
+ success = send_otp_email(req.email, code, "forgot_password")
406
+ if not success:
407
+ raise HTTPException(500, "Failed to send OTP email. Try again.")
408
+ return {"message": "OTP sent to email"}
409
+
410
+ @app.post("/auth/forgot-password/verify")
411
+ def forgot_password_verify(req: ForgotPasswordVerifyRequest, db: Session = Depends(get_db)):
412
+ otp = db.query(OTP).filter(
413
+ OTP.email == req.email,
414
+ OTP.code == req.otp,
415
+ OTP.purpose == "forgot_password",
416
+ OTP.is_used == False
417
+ ).first()
418
+ if not otp:
419
+ raise HTTPException(400, "Invalid OTP")
420
+ if otp.expires_at < datetime.utcnow():
421
+ raise HTTPException(400, "OTP expired")
422
+ user = db.query(User).filter(User.email == req.email).first()
423
+ if not user:
424
+ raise HTTPException(404, "User not found")
425
+ user.password = hash_password(req.new_password)
426
+ otp.is_used = True
427
+ db.commit()
428
+ return {"message": "Password reset successful"}
429
+
430
+ @app.get("/health")
431
+ def health():
432
+ return {"status": "ok"}
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.100.0
2
+ uvicorn>=0.23.0
3
+ python-jose[cryptography]>=3.3.0
4
+ bcrypt>=4.0.0
5
+ SQLAlchemy>=2.0.0
6
+ python-multipart>=0.0.6