Skip to content

Commit d3c4989

Browse files
authored
Merge pull request #92 from MyElectricalData/develop
Develop
2 parents 27a6fc5 + e93f633 commit d3c4989

File tree

13 files changed

+139
-32
lines changed

13 files changed

+139
-32
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## [1.4.1-dev.3](https://github.com/MyElectricalData/myelectricaldata_new/compare/1.4.1-dev.2...1.4.1-dev.3) (2025-12-21)
4+
5+
### Bug Fixes
6+
7+
* normalize API base URL to prevent double slashes ([1edeb1d](https://github.com/MyElectricalData/myelectricaldata_new/commit/1edeb1d0ddac287242348d542c16dc7c50f7e954))
8+
9+
## [1.4.1-dev.2](https://github.com/MyElectricalData/myelectricaldata_new/compare/1.4.1-dev.1...1.4.1-dev.2) (2025-12-21)
10+
11+
### Bug Fixes
12+
13+
* address Copilot code review feedback ([57f4da5](https://github.com/MyElectricalData/myelectricaldata_new/commit/57f4da598a23030b236fe9b0e18fcfd57031c183))
14+
315
## [1.4.1-dev.1](https://github.com/MyElectricalData/myelectricaldata_new/compare/1.4.0...1.4.1-dev.1) (2025-12-21)
416

517
### Bug Fixes

apps/api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "myelectricaldata-api"
3-
version = "1.4.1-dev.1"
3+
version = "1.4.1-dev.3"
44
description = "MyElectricalData API Gateway for Enedis data"
55
authors = [{name = "m4dm4rtig4n"}]
66
license = {text = "Apache-2.0"}

apps/api/src/config/settings.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
from typing import Literal
1+
import secrets
2+
from typing import Literal, Self
23

4+
from pydantic import model_validator
35
from pydantic_settings import BaseSettings, SettingsConfigDict
46

57

@@ -33,7 +35,9 @@ def database_type(self) -> str:
3335
RTE_BASE_URL: str = "https://digital.iservices.rte-france.com"
3436

3537
# API Security
36-
SECRET_KEY: str = "dev-secret-key-change-in-production"
38+
# SECRET_KEY is required in production (no default value for security)
39+
# In DEBUG mode, a random key is generated if not provided
40+
SECRET_KEY: str = ""
3741
ALGORITHM: str = "HS256"
3842
ACCESS_TOKEN_EXPIRE_MINUTES: int = 43200
3943

@@ -94,5 +98,46 @@ def enedis_authorize_url(self) -> str:
9498
return "https://mon-compte-particulier.enedis.fr/dataconnect/v1/oauth2/authorize"
9599
return f"{self.enedis_base_url}/dataconnect/v1/oauth2/authorize"
96100

101+
@model_validator(mode="after")
102+
def validate_secret_key(self) -> Self:
103+
"""Validate SECRET_KEY configuration.
104+
105+
- Production (DEBUG=False): SECRET_KEY is required and must be secure
106+
- Development (DEBUG=True): Generate a random key if not provided (with warning)
107+
"""
108+
insecure_patterns = ["dev-", "changeme", "secret", "password", "test", "example"]
109+
110+
if not self.SECRET_KEY:
111+
if self.DEBUG:
112+
# Generate random key for development (will change on restart)
113+
object.__setattr__(self, "SECRET_KEY", secrets.token_urlsafe(32))
114+
import warnings
115+
warnings.warn(
116+
"SECRET_KEY not configured - using random key (sessions will be invalidated on restart). "
117+
"Set SECRET_KEY environment variable for persistent sessions.",
118+
UserWarning,
119+
stacklevel=2,
120+
)
121+
else:
122+
raise ValueError(
123+
"SECRET_KEY environment variable is required in production (DEBUG=False). "
124+
"Generate a secure key with: python -c \"import secrets; print(secrets.token_urlsafe(32))\""
125+
)
126+
elif any(pattern in self.SECRET_KEY.lower() for pattern in insecure_patterns):
127+
if not self.DEBUG:
128+
raise ValueError(
129+
"SECRET_KEY appears to be insecure (contains common patterns). "
130+
"Generate a secure key with: python -c \"import secrets; print(secrets.token_urlsafe(32))\""
131+
)
132+
else:
133+
import warnings
134+
warnings.warn(
135+
"SECRET_KEY appears to be insecure. Use a strong random key in production.",
136+
UserWarning,
137+
stacklevel=2,
138+
)
139+
140+
return self
141+
97142

98143
settings = Settings()

apps/api/src/middleware/auth.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import secrets
12
from typing import Optional
3+
24
from fastapi import Security, HTTPException, status, Depends, Request
35
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
46
from fastapi.security import OAuth2
@@ -92,11 +94,16 @@ async def get_current_user(
9294
else:
9395
logger.error("[AUTH] User not found in database")
9496

95-
# Try API key (client_secret)
97+
# Try API key (client_secret) - iterate to use constant-time comparison
9698
logger.debug("[AUTH] Trying API key authentication")
97-
result = await db.execute(select(User).where(User.client_secret == token))
98-
user = result.scalar_one_or_none()
99-
if user and user.is_active:
99+
result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712
100+
users = result.scalars().all()
101+
user = None
102+
for u in users:
103+
if secrets.compare_digest(u.client_secret, token):
104+
user = u
105+
break
106+
if user:
100107
if settings.REQUIRE_EMAIL_VERIFICATION and not user.email_verified:
101108
raise HTTPException(
102109
status_code=status.HTTP_403_FORBIDDEN,
@@ -145,11 +152,12 @@ async def get_current_user_optional(
145152
if user and user.is_active:
146153
return user
147154

148-
# Try API key (client_secret)
149-
result = await db.execute(select(User).where(User.client_secret == token))
150-
user = result.scalar_one_or_none()
151-
if user and user.is_active:
152-
return user
155+
# Try API key (client_secret) - iterate to use constant-time comparison
156+
result = await db.execute(select(User).where(User.is_active == True)) # noqa: E712
157+
users = result.scalars().all()
158+
for u in users:
159+
if secrets.compare_digest(u.client_secret, token):
160+
return u
153161

154162
return None
155163

apps/api/src/routers/accounts.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,6 @@ async def get_token(
225225
226226
Accepts credentials either in form data or in Authorization header (Basic Auth).
227227
"""
228-
logger.debug(f"[TOKEN] Received request - Content-Type: {request.headers.get('content-type')}")
229-
logger.debug(f"[TOKEN] Authorization header: {request.headers.get('authorization', 'None')[:50] if request.headers.get('authorization') else 'None'}")
230-
logger.debug(f"[TOKEN] Form client_id: {client_id}, client_secret: {'***' if client_secret else None}")
231-
232228
# Try to get credentials from Authorization header (Basic Auth)
233229
if not client_id or not client_secret:
234230
auth_header = request.headers.get('authorization')
@@ -238,35 +234,31 @@ async def get_token(
238234
encoded = auth_header.split(' ')[1]
239235
decoded = base64.b64decode(encoded).decode('utf-8')
240236
client_id, client_secret = decoded.split(':', 1)
241-
logger.debug(f"[TOKEN] From Basic Auth - client_id: {client_id}, client_secret: ***")
242-
except Exception as e:
243-
logger.error(f"[TOKEN] Failed to parse Basic Auth: {e}")
237+
except Exception:
238+
pass # Invalid Basic Auth format, will try form data next
244239

245240
# If still not found, try form data
246241
if not client_id or not client_secret:
247242
try:
248243
form_data = await request.form()
249-
logger.debug(f"[TOKEN] Form data keys: {list(form_data.keys())}")
250244
client_id_form = form_data.get('client_id')
251245
client_secret_form = form_data.get('client_secret')
252246
# Only use if it's a string (not UploadFile)
253247
if isinstance(client_id_form, str):
254248
client_id = client_id_form or client_id
255249
if isinstance(client_secret_form, str):
256250
client_secret = client_secret_form or client_secret
257-
logger.debug(f"[TOKEN] After form parse - client_id: {client_id}, client_secret: {'***' if client_secret else None}")
258-
except Exception as e:
259-
logger.error(f"[TOKEN] Failed to parse form: {e}")
251+
except Exception:
252+
pass # Form parsing failed, credentials may still be from Basic Auth
260253

261254
if not client_id or not client_secret:
262-
logger.debug(f"[TOKEN] Missing credentials - client_id: {client_id}, client_secret: {'***' if client_secret else None}")
263255
raise HTTPException(status_code=422, detail="client_id and client_secret are required (provide in form data or Basic Auth header)")
264256

265257
# Find user by client_id
266258
result = await db.execute(select(User).where(User.client_id == client_id))
267259
user = result.scalar_one_or_none()
268260

269-
if not user or user.client_secret != client_secret:
261+
if not user or not secrets.compare_digest(user.client_secret, client_secret):
270262
raise HTTPException(status_code=401, detail="Invalid client credentials")
271263

272264
if not user.is_active:

apps/api/src/schemas/requests.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,33 @@
1+
import re
12
from typing import Optional
2-
from pydantic import BaseModel, EmailStr, Field
3+
4+
from pydantic import BaseModel, EmailStr, Field, field_validator
35

46

57
class UserCreate(BaseModel):
68
email: EmailStr
79
password: str = Field(..., min_length=8)
810
turnstile_token: Optional[str] = None
911

12+
@field_validator("password")
13+
@classmethod
14+
def validate_password_strength(cls, v: str) -> str:
15+
"""Validate password meets security requirements.
16+
17+
Requirements:
18+
- Minimum 8 characters (enforced by Field)
19+
- At least one uppercase letter
20+
- At least one lowercase letter
21+
- At least one digit
22+
"""
23+
if not re.search(r"[A-Z]", v):
24+
raise ValueError("Password must contain at least one uppercase letter")
25+
if not re.search(r"[a-z]", v):
26+
raise ValueError("Password must contain at least one lowercase letter")
27+
if not re.search(r"\d", v):
28+
raise ValueError("Password must contain at least one digit")
29+
return v
30+
1031

1132
class UserLogin(BaseModel):
1233
email: EmailStr

apps/web/nginx.conf

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,14 @@ server {
2626
add_header X-Frame-Options "SAMEORIGIN" always;
2727
add_header X-Content-Type-Options "nosniff" always;
2828
add_header X-XSS-Protection "1; mode=block" always;
29+
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
30+
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
31+
32+
# HSTS - Force HTTPS for 1 year (enable in production behind HTTPS proxy)
33+
# Uncomment when served over HTTPS:
34+
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
35+
36+
# Content Security Policy
37+
# Allows inline styles (Tailwind), scripts from self, and API connections
38+
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://logo.clearbit.com; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
2939
}

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "myelectricaldata-web",
3-
"version": "1.4.1-dev.1",
3+
"version": "1.4.1-dev.3",
44
"type": "module",
55
"scripts": {
66
"dev": "vite",

apps/web/src/pages/ApiDocs.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ declare global {
1818
export default function ApiDocs() {
1919
const { isDark } = useThemeStore()
2020
// Use runtime config first, then build-time env, then default
21-
const apiBaseUrl = window.__ENV__?.VITE_API_BASE_URL || import.meta.env.VITE_API_BASE_URL || '/api'
21+
// Remove trailing slash to avoid double slashes in URLs
22+
const rawApiBaseUrl = window.__ENV__?.VITE_API_BASE_URL || import.meta.env.VITE_API_BASE_URL || '/api'
23+
const apiBaseUrl = rawApiBaseUrl.replace(/\/+$/, '')
2224

2325
useEffect(() => {
2426
// Hide the "Explore" link and customize Swagger UI colors

apps/web/src/pages/ConsentRedirect.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ declare global {
1212
}
1313

1414
// Use runtime config first, then build-time env, then default
15-
const API_BASE_URL = window.__ENV__?.VITE_API_BASE_URL || import.meta.env.VITE_API_BASE_URL || '/api'
15+
// Remove trailing slash to avoid double slashes in URLs
16+
const rawApiBaseUrl = window.__ENV__?.VITE_API_BASE_URL || import.meta.env.VITE_API_BASE_URL || '/api'
17+
const API_BASE_URL = rawApiBaseUrl.replace(/\/+$/, '')
1618

1719
export default function ConsentRedirect() {
1820
const [searchParams] = useSearchParams()

0 commit comments

Comments
 (0)