feat: implement OpenID Connect authentication with Infomaniak
This commit is contained in:
23
README.md
23
README.md
@@ -76,15 +76,34 @@ Then open your browser at `http://localhost:8000`. The web interface allows you
|
||||
- View upcoming games and practices
|
||||
- See detailed information about events including player rosters
|
||||
|
||||
### Authentication
|
||||
|
||||
The web interface supports two authentication methods:
|
||||
|
||||
1. **Infomaniak OpenID Connect (Recommended)**: Click the "Se connecter avec Infomaniak" button to authenticate using Infomaniak's OIDC provider. Only users in the allowed list will be granted access.
|
||||
|
||||
2. **Static API Key**: For development purposes, you can still use `abc` as the token.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
To configure OIDC authentication, set the following environment variables:
|
||||
|
||||
- `CLIENT_ID`: Your OIDC client ID (default: 8ea04fbb-4237-4b1d-a895-0b3575a3af3f)
|
||||
- `CLIENT_SECRET`: Your OIDC client secret
|
||||
- `REDIRECT_URI`: The redirect URI (default: http://localhost:8000/callback)
|
||||
- `ALLOWED_USERS`: Comma-separated list of allowed email addresses (e.g., "user1@example.com,user2@example.com")
|
||||
|
||||
The web API provides the following endpoints:
|
||||
|
||||
- `/schedule` - Get the schedule for a specific account
|
||||
- `/game/{game_id}` - Get details for a specific game
|
||||
- `/accounts` - Get a list of available accounts
|
||||
- `/health` - Health check endpoint
|
||||
- `/login` - Initiate OIDC login flow
|
||||
- `/callback` - Handle OIDC callback
|
||||
- `/userinfo` - Get user information
|
||||
|
||||
All endpoints (except `/health`) require an Authorization header with a Bearer token.
|
||||
For development purposes, you can use `abc` as the token.
|
||||
All endpoints (except `/health`, `/login`, and `/callback`) require an Authorization header with a Bearer token.
|
||||
|
||||
## mobile functions
|
||||
|
||||
|
||||
132
index.html
132
index.html
@@ -13,7 +13,20 @@
|
||||
<div class="container mt-4">
|
||||
<h1 class="mb-4 text-center">MyIce - Games</h1>
|
||||
|
||||
<div id="apikeyContainer" class="mb-3"></div>
|
||||
<div id="loginContainer" class="mb-3">
|
||||
<div id="oidcLoginSection">
|
||||
<button id="oidcLoginBtn" class="btn btn-primary">Se connecter avec Infomaniak</button>
|
||||
</div>
|
||||
<div id="apikeySection" style="display: none;">
|
||||
<label for="apikey" class="form-label">API Key</label>
|
||||
<input type="text" id="apikey" class="form-control" placeholder="Entrez votre API Key">
|
||||
<button id="validateApiKey" class="btn btn-success mt-2">Valider</button>
|
||||
</div>
|
||||
<div id="connectedUser" style="display: none;">
|
||||
<p>Connecté en tant que: <span id="userName"></span></p>
|
||||
<button id="disconnect" class="btn btn-danger">Déconnecter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="eventFilters" style="display: none;">
|
||||
<div class="mb-3">
|
||||
@@ -50,7 +63,10 @@
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const apikeyContainer = document.getElementById("apikeyContainer");
|
||||
const loginContainer = document.getElementById("loginContainer");
|
||||
const oidcLoginSection = document.getElementById("oidcLoginSection");
|
||||
const apikeySection = document.getElementById("apikeySection");
|
||||
const connectedUser = document.getElementById("connectedUser");
|
||||
const eventFilters = document.getElementById("eventFilters");
|
||||
const accountSelect = document.getElementById("account");
|
||||
const agegroupSelect = document.getElementById("agegroup");
|
||||
@@ -62,33 +78,86 @@
|
||||
let storedApiKey = localStorage.getItem("apikey");
|
||||
let lastFetchedEvents = [];
|
||||
let storedAccount = localStorage.getItem("account") || "default";
|
||||
let userInfo = JSON.parse(localStorage.getItem("userInfo") || "null");
|
||||
|
||||
function renderApiKeyInput() {
|
||||
// Handle OIDC callback
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
if (code) {
|
||||
// We're coming back from OIDC login
|
||||
exchangeCodeForToken(code);
|
||||
// Remove code from URL
|
||||
window.history.replaceState({}, document.title, "/");
|
||||
}
|
||||
|
||||
function renderLoginSection() {
|
||||
if (storedApiKey || userInfo) {
|
||||
// User is logged in
|
||||
connectedUser.style.display = "block";
|
||||
oidcLoginSection.style.display = "none";
|
||||
apikeySection.style.display = "none";
|
||||
|
||||
const userNameElement = document.getElementById("userName");
|
||||
if (userInfo && userInfo.email) {
|
||||
userNameElement.textContent = userInfo.email;
|
||||
} else {
|
||||
userNameElement.textContent = "Utilisateur";
|
||||
}
|
||||
|
||||
eventFilters.style.display = "block";
|
||||
updateAccountOptions();
|
||||
if (storedApiKey) {
|
||||
apikeyContainer.innerHTML = `<button id="disconnect" class="btn btn-danger">Déconnecter</button>`;
|
||||
document.getElementById("disconnect").addEventListener("click", () => {
|
||||
fetchEvents(storedApiKey, storedAccount);
|
||||
}
|
||||
} else {
|
||||
// User is not logged in
|
||||
connectedUser.style.display = "none";
|
||||
oidcLoginSection.style.display = "block";
|
||||
apikeySection.style.display = "none";
|
||||
eventFilters.style.display = "none";
|
||||
|
||||
document.getElementById("oidcLoginBtn").addEventListener("click", initiateOIDCLogin);
|
||||
}
|
||||
|
||||
// Add disconnect handler
|
||||
const disconnectBtn = document.getElementById("disconnect");
|
||||
if (disconnectBtn) {
|
||||
disconnectBtn.addEventListener("click", logout);
|
||||
}
|
||||
}
|
||||
|
||||
function initiateOIDCLogin() {
|
||||
// Redirect to backend login endpoint
|
||||
window.location.href = `${apiBaseUrl}/login`;
|
||||
}
|
||||
|
||||
function exchangeCodeForToken(code) {
|
||||
fetch(`${apiBaseUrl}/callback?code=${code}&state=ignored`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.access_token) {
|
||||
localStorage.setItem("apikey", data.access_token);
|
||||
localStorage.setItem("userInfo", JSON.stringify(data.user));
|
||||
storedApiKey = data.access_token;
|
||||
userInfo = data.user;
|
||||
renderLoginSection();
|
||||
} else {
|
||||
alert("Échec de la connexion OIDC");
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Erreur lors de l'échange du code:", error);
|
||||
alert("Échec de la connexion OIDC");
|
||||
});
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("apikey");
|
||||
localStorage.removeItem("account");
|
||||
localStorage.removeItem("userInfo");
|
||||
storedApiKey = null;
|
||||
userInfo = null;
|
||||
location.reload();
|
||||
});
|
||||
eventFilters.style.display = "block";
|
||||
// Load available accounts from server or use predefined ones
|
||||
updateAccountOptions();
|
||||
fetchEvents(storedApiKey, storedAccount);
|
||||
} else {
|
||||
apikeyContainer.innerHTML = `
|
||||
<label for="apikey" class="form-label">API Key</label>
|
||||
<input type="text" id="apikey" class="form-control" placeholder="Entrez votre API Key">
|
||||
<button id="validateApiKey" class="btn btn-success mt-2">Valider</button>
|
||||
`;
|
||||
eventFilters.style.display = "none";
|
||||
document.getElementById("validateApiKey").addEventListener("click", saveApiKey);
|
||||
document.getElementById("apikey").addEventListener("keypress", function (event) {
|
||||
if (event.key === "Enter") {
|
||||
saveApiKey();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function saveApiKey() {
|
||||
@@ -128,7 +197,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
renderApiKeyInput();
|
||||
renderLoginSection();
|
||||
|
||||
accountSelect.addEventListener("change", () => {
|
||||
const selectedAccount = accountSelect.value;
|
||||
@@ -140,7 +209,7 @@
|
||||
|
||||
fetchButton.addEventListener("click", () => {
|
||||
if (!storedApiKey) {
|
||||
alert("Veuillez entrer une clé API");
|
||||
alert("Veuillez vous connecter");
|
||||
return;
|
||||
}
|
||||
const selectedAccount = accountSelect.value;
|
||||
@@ -155,11 +224,20 @@
|
||||
fetch(`${apiBaseUrl}/schedule?account=${account}`, {
|
||||
headers: { "Authorization": `Bearer ${apiKey}` }
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
if (response.status === 401) {
|
||||
// Token expired or invalid
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data) {
|
||||
lastFetchedEvents = data.filter(event => event.event === "Jeu");
|
||||
updateAgeGroupOptions(lastFetchedEvents);
|
||||
displayEvents(lastFetchedEvents);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error("Erreur lors du chargement des événements:", error));
|
||||
}
|
||||
|
||||
129
myice/webapi.py
129
myice/webapi.py
@@ -1,12 +1,31 @@
|
||||
import logging
|
||||
import requests
|
||||
import os
|
||||
from typing import Annotated
|
||||
from fastapi import FastAPI, Header, HTTPException, status
|
||||
from fastapi import FastAPI, Header, HTTPException, status, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from . import myice
|
||||
|
||||
# OIDC Configuration
|
||||
CLIENT_ID = os.environ.get("CLIENT_ID", "8ea04fbb-4237-4b1d-a895-0b3575a3af3f")
|
||||
CLIENT_SECRET = os.environ.get(
|
||||
"CLIENT_SECRET", "5iXycu7aU6o9e17NNTOUeetQkRkBqQlomoD2hTyLGLTAcwj0dwkkLH3mz1IrZSmJ"
|
||||
)
|
||||
REDIRECT_URI = os.environ.get("REDIRECT_URI", "http://localhost:8000/callback")
|
||||
AUTHORIZATION_ENDPOINT = "https://login.infomaniak.com/authorize"
|
||||
TOKEN_ENDPOINT = "https://login.infomaniak.com/token"
|
||||
USERINFO_ENDPOINT = "https://login.infomaniak.com/oauth2/userinfo"
|
||||
JWKS_URI = "https://login.infomaniak.com/oauth2/jwks"
|
||||
|
||||
# Allowed users (based on email claim)
|
||||
ALLOWED_USERS = (
|
||||
os.environ.get("ALLOWED_USERS", "").split(",")
|
||||
if os.environ.get("ALLOWED_USERS")
|
||||
else []
|
||||
)
|
||||
|
||||
origins = ["*"]
|
||||
|
||||
app = FastAPI()
|
||||
@@ -41,10 +60,43 @@ class AuthHeaders(BaseModel):
|
||||
return self.Authorization.split(" ")[1]
|
||||
|
||||
def authorized(self) -> bool:
|
||||
# First check if it's a valid OIDC token
|
||||
if self.validate_oidc_token():
|
||||
return True
|
||||
# Fallback to the old static key for compatibility
|
||||
if self.token() == "abc":
|
||||
return True
|
||||
return False
|
||||
|
||||
def validate_oidc_token(self) -> bool:
|
||||
try:
|
||||
token = self.token()
|
||||
# Basic validation - in production, you would validate the JWT signature
|
||||
# and check issuer, audience, expiration, etc.
|
||||
import base64
|
||||
import json
|
||||
|
||||
# Decode JWT header and payload (without verification for simplicity in this example)
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return False
|
||||
|
||||
# Decode payload (part 1)
|
||||
payload = parts[1]
|
||||
# Add padding if needed
|
||||
payload += "=" * (4 - len(payload) % 4)
|
||||
decoded_payload = base64.urlsafe_b64decode(payload)
|
||||
payload_data = json.loads(decoded_payload)
|
||||
|
||||
# Check if user is in allowed list
|
||||
user_email = payload_data.get("email")
|
||||
if ALLOWED_USERS and user_email not in ALLOWED_USERS:
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class HealthCheck(BaseModel):
|
||||
"""Response model to validate and return when performing a health check."""
|
||||
@@ -71,6 +123,79 @@ async def favico():
|
||||
return FileResponse("favicon.ico")
|
||||
|
||||
|
||||
@app.get("/login")
|
||||
async def login(request: Request):
|
||||
"""Redirect to Infomaniak OIDC login"""
|
||||
import urllib.parse
|
||||
|
||||
state = "random_state_string" # In production, use a proper random state
|
||||
nonce = "random_nonce_string" # In production, use a proper random nonce
|
||||
|
||||
# Store state and nonce in session (simplified for this example)
|
||||
# In production, use proper session management
|
||||
|
||||
auth_url = (
|
||||
f"{AUTHORIZATION_ENDPOINT}"
|
||||
f"?client_id={CLIENT_ID}"
|
||||
f"&redirect_uri={urllib.parse.quote(REDIRECT_URI)}"
|
||||
f"&response_type=code"
|
||||
f"&scope=openid email profile"
|
||||
f"&state={state}"
|
||||
f"&nonce={nonce}"
|
||||
)
|
||||
|
||||
return RedirectResponse(auth_url)
|
||||
|
||||
|
||||
@app.get("/callback")
|
||||
async def callback(code: str, state: str):
|
||||
"""Handle OIDC callback and exchange code for token"""
|
||||
|
||||
# Exchange authorization code for access token
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
"code": code,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
}
|
||||
|
||||
response = requests.post(TOKEN_ENDPOINT, data=token_data)
|
||||
token_response = response.json()
|
||||
|
||||
if "access_token" not in token_response:
|
||||
raise HTTPException(status_code=400, detail="Failed to obtain access token")
|
||||
|
||||
access_token = token_response["access_token"]
|
||||
|
||||
# Get user info
|
||||
userinfo_response = requests.get(
|
||||
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
|
||||
userinfo = userinfo_response.json()
|
||||
|
||||
# Check if user is in allowed list
|
||||
user_email = userinfo.get("email")
|
||||
if ALLOWED_USERS and user_email not in ALLOWED_USERS:
|
||||
raise HTTPException(status_code=403, detail="User not authorized")
|
||||
|
||||
# Return token to frontend
|
||||
return {"access_token": access_token, "user": userinfo}
|
||||
|
||||
|
||||
@app.get("/userinfo")
|
||||
async def userinfo(headers: Annotated[AuthHeaders, Header()]):
|
||||
"""Get user info from access token"""
|
||||
access_token = headers.token()
|
||||
|
||||
userinfo_response = requests.get(
|
||||
USERINFO_ENDPOINT, headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
|
||||
return userinfo_response.json()
|
||||
|
||||
|
||||
@app.get("/schedule")
|
||||
async def schedule(
|
||||
headers: Annotated[AuthHeaders, Header()],
|
||||
|
||||
Reference in New Issue
Block a user