ilyass yani commited on
Commit
61bb0c4
·
1 Parent(s): 3b85714

feat: password reset (endpoints forgot/reset, email service, migration colonnes)

Browse files
alembic/versions/add_password_reset_columns.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add password reset columns to users
2
+
3
+ Revision ID: add_password_reset
4
+ Revises: add_ner_extraction
5
+ Create Date: 2026-06-24 00:00:00.000000
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ revision: str = 'add_password_reset'
15
+ down_revision: Union[str, None] = 'add_ner_extraction'
16
+ branch_labels: Union[str, Sequence[str], None] = None
17
+ depends_on: Union[str, Sequence[str], None] = None
18
+
19
+
20
+ def upgrade() -> None:
21
+ op.add_column('users', sa.Column('reset_password_token', sa.String(), nullable=True))
22
+ op.add_column('users', sa.Column('reset_password_token_expires', sa.DateTime(), nullable=True))
23
+ op.create_index(op.f('ix_users_reset_password_token'), 'users', ['reset_password_token'], unique=False)
24
+
25
+
26
+ def downgrade() -> None:
27
+ op.drop_index(op.f('ix_users_reset_password_token'), table_name='users')
28
+ op.drop_column('users', 'reset_password_token_expires')
29
+ op.drop_column('users', 'reset_password_token')
app/api/auth.py CHANGED
@@ -3,10 +3,11 @@ Authentication API endpoints - ÉTAPE 3 COMPLÈTE
3
  This module handles user registration, login, and token generation.
4
  """
5
 
6
- from fastapi import APIRouter, Depends, status, HTTPException, Header
7
  from sqlalchemy.orm import Session
8
  from typing import Optional
9
- from datetime import datetime
 
10
 
11
  from app.core.security import (
12
  get_password_hash,
@@ -16,8 +17,14 @@ from app.core.security import (
16
  ACCESS_TOKEN_EXPIRE_MINUTES,
17
  )
18
  from app.core.dependencies import get_db, get_current_user, log_activity
19
- from app.schemas.user import UserCreate, UserLogin, UserResponse, Token, TokenData
 
 
 
20
  from app.models.models import User, UserRole as DBUserRole
 
 
 
21
 
22
 
23
  router = APIRouter(prefix="/api/auth", tags=["authentication"])
@@ -132,6 +139,55 @@ async def login(user_login: UserLogin, db: Session = Depends(get_db)) -> Token:
132
  )
133
 
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  @router.get("/me", response_model=UserResponse)
136
  async def get_me(
137
  current_user: User = Depends(get_current_user),
 
3
  This module handles user registration, login, and token generation.
4
  """
5
 
6
+ from fastapi import APIRouter, Depends, status, HTTPException, Header, BackgroundTasks
7
  from sqlalchemy.orm import Session
8
  from typing import Optional
9
+ from datetime import datetime, timedelta
10
+ import secrets
11
 
12
  from app.core.security import (
13
  get_password_hash,
 
17
  ACCESS_TOKEN_EXPIRE_MINUTES,
18
  )
19
  from app.core.dependencies import get_db, get_current_user, log_activity
20
+ from app.schemas.user import (
21
+ UserCreate, UserLogin, UserResponse, Token, TokenData,
22
+ ForgotPasswordRequest, ResetPasswordRequest, MessageResponse,
23
+ )
24
  from app.models.models import User, UserRole as DBUserRole
25
+ from app.services.email import send_password_reset_email
26
+
27
+ PASSWORD_RESET_TOKEN_EXPIRE_HOURS = 2
28
 
29
 
30
  router = APIRouter(prefix="/api/auth", tags=["authentication"])
 
139
  )
140
 
141
 
142
+ @router.post("/forgot-password", response_model=MessageResponse)
143
+ async def forgot_password(
144
+ request: ForgotPasswordRequest,
145
+ background_tasks: BackgroundTasks,
146
+ db: Session = Depends(get_db),
147
+ ) -> MessageResponse:
148
+ """
149
+ Request a password reset link.
150
+ Always returns the same message to avoid email enumeration.
151
+ """
152
+ user = db.query(User).filter(User.email == request.email).first()
153
+ if user:
154
+ token = secrets.token_urlsafe(32)
155
+ user.reset_password_token = token
156
+ user.reset_password_token_expires = datetime.utcnow() + timedelta(hours=PASSWORD_RESET_TOKEN_EXPIRE_HOURS)
157
+ db.commit()
158
+ background_tasks.add_task(send_password_reset_email, user.email, token)
159
+ return MessageResponse(
160
+ message="Si un compte existe avec cet email, un lien de réinitialisation a été envoyé."
161
+ )
162
+
163
+
164
+ @router.post("/reset-password", response_model=MessageResponse)
165
+ async def reset_password(
166
+ request: ResetPasswordRequest,
167
+ db: Session = Depends(get_db),
168
+ ) -> MessageResponse:
169
+ """
170
+ Reset password using a valid reset token.
171
+ """
172
+ user = db.query(User).filter(User.reset_password_token == request.token).first()
173
+ if not user or not user.reset_password_token_expires:
174
+ raise HTTPException(
175
+ status_code=status.HTTP_400_BAD_REQUEST,
176
+ detail="Lien de réinitialisation invalide ou expiré."
177
+ )
178
+ if datetime.utcnow() > user.reset_password_token_expires:
179
+ raise HTTPException(
180
+ status_code=status.HTTP_400_BAD_REQUEST,
181
+ detail="Lien de réinitialisation invalide ou expiré."
182
+ )
183
+ user.hashed_password = get_password_hash(request.new_password)
184
+ user.reset_password_token = None
185
+ user.reset_password_token_expires = None
186
+ db.commit()
187
+ log_activity(db, "auth.reset_password", user_id=user.id, detail=f"Mot de passe réinitialisé pour {user.email}")
188
+ return MessageResponse(message="Mot de passe réinitialisé avec succès.")
189
+
190
+
191
  @router.get("/me", response_model=UserResponse)
192
  async def get_me(
193
  current_user: User = Depends(get_current_user),
app/models/models.py CHANGED
@@ -40,6 +40,8 @@ class User(Base):
40
  role = Column(Enum(UserRole), default=UserRole.recruiter, nullable=False)
41
  is_active = Column(Boolean, default=True, nullable=False)
42
  created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
 
 
43
 
44
  # Relationships
45
  job_criteria = relationship("JobCriteria", back_populates="recruiter")
 
40
  role = Column(Enum(UserRole), default=UserRole.recruiter, nullable=False)
41
  is_active = Column(Boolean, default=True, nullable=False)
42
  created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
43
+ reset_password_token = Column(String, nullable=True, index=True)
44
+ reset_password_token_expires = Column(DateTime, nullable=True)
45
 
46
  # Relationships
47
  job_criteria = relationship("JobCriteria", back_populates="recruiter")
app/schemas/user.py CHANGED
@@ -46,3 +46,16 @@ class UserResponse(BaseModel):
46
 
47
  class Config:
48
  from_attributes = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
  class Config:
48
  from_attributes = True
49
+
50
+
51
+ class ForgotPasswordRequest(BaseModel):
52
+ email: EmailStr
53
+
54
+
55
+ class ResetPasswordRequest(BaseModel):
56
+ token: str
57
+ new_password: str = Field(..., min_length=6)
58
+
59
+
60
+ class MessageResponse(BaseModel):
61
+ message: str
app/services/email.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import smtplib
3
+ import asyncio
4
+ from email.mime.text import MIMEText
5
+ from email.mime.multipart import MIMEMultipart
6
+ from functools import partial
7
+
8
+
9
+ SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
10
+ SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
11
+ SMTP_USER = os.getenv("SMTP_USER", "")
12
+ SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
13
+ SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USER)
14
+ SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "AI Talent Finder")
15
+ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
16
+
17
+
18
+ def _send_email_sync(to_email: str, subject: str, html_body: str) -> None:
19
+ msg = MIMEMultipart("alternative")
20
+ msg["Subject"] = subject
21
+ msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>"
22
+ msg["To"] = to_email
23
+ msg.attach(MIMEText(html_body, "html"))
24
+
25
+ with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
26
+ server.ehlo()
27
+ server.starttls()
28
+ server.login(SMTP_USER, SMTP_PASSWORD)
29
+ server.sendmail(SMTP_FROM_EMAIL, to_email, msg.as_string())
30
+
31
+
32
+ async def send_email(to_email: str, subject: str, html_body: str) -> None:
33
+ loop = asyncio.get_event_loop()
34
+ await loop.run_in_executor(None, partial(_send_email_sync, to_email, subject, html_body))
35
+
36
+
37
+ async def send_password_reset_email(to_email: str, reset_token: str) -> None:
38
+ reset_url = f"{FRONTEND_URL}/auth/reset-password?token={reset_token}"
39
+ subject = "Réinitialisation de votre mot de passe – AI Talent Finder"
40
+ html_body = f"""
41
+ <!DOCTYPE html>
42
+ <html lang="fr">
43
+ <head><meta charset="UTF-8"></head>
44
+ <body style="font-family: Arial, sans-serif; background: #f8fafc; padding: 40px 0;">
45
+ <div style="max-width: 520px; margin: 0 auto; background: #fff; border-radius: 16px;
46
+ border: 1px solid #e2e8f0; padding: 40px;">
47
+ <div style="text-align: center; margin-bottom: 32px;">
48
+ <div style="display: inline-block; background: linear-gradient(135deg, #4f46e5, #6366f1);
49
+ border-radius: 12px; padding: 12px 16px;">
50
+ <span style="color: #fff; font-weight: 800; font-size: 18px;">AI Talent Finder</span>
51
+ </div>
52
+ </div>
53
+ <h2 style="color: #0f172a; font-size: 22px; margin: 0 0 12px;">
54
+ Réinitialisation de votre mot de passe
55
+ </h2>
56
+ <p style="color: #475569; font-size: 15px; line-height: 1.6; margin: 0 0 28px;">
57
+ Nous avons reçu une demande de réinitialisation du mot de passe associé à votre compte
58
+ <strong>{to_email}</strong>. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe.
59
+ </p>
60
+ <div style="text-align: center; margin-bottom: 28px;">
61
+ <a href="{reset_url}"
62
+ style="display: inline-block; background: linear-gradient(135deg, #4f46e5, #6366f1);
63
+ color: #fff; text-decoration: none; padding: 14px 32px;
64
+ border-radius: 10px; font-weight: 700; font-size: 15px;">
65
+ Réinitialiser mon mot de passe
66
+ </a>
67
+ </div>
68
+ <p style="color: #94a3b8; font-size: 13px; line-height: 1.6; margin: 0 0 8px;">
69
+ Ce lien est valable pendant <strong>2 heures</strong>. Si vous n'avez pas fait cette demande,
70
+ ignorez simplement cet email.
71
+ </p>
72
+ <hr style="border: none; border-top: 1px solid #e2e8f0; margin: 24px 0;">
73
+ <p style="color: #cbd5e1; font-size: 12px; text-align: center; margin: 0;">
74
+ &copy; 2026 AI Talent Finder. Tous droits réservés.
75
+ </p>
76
+ </div>
77
+ </body>
78
+ </html>
79
+ """
80
+ await send_email(to_email, subject, html_body)