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
|
- View upcoming games and practices
|
||||||
- See detailed information about events including player rosters
|
- 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:
|
The web API provides the following endpoints:
|
||||||
|
|
||||||
- `/schedule` - Get the schedule for a specific account
|
- `/schedule` - Get the schedule for a specific account
|
||||||
- `/game/{game_id}` - Get details for a specific game
|
- `/game/{game_id}` - Get details for a specific game
|
||||||
- `/accounts` - Get a list of available accounts
|
- `/accounts` - Get a list of available accounts
|
||||||
- `/health` - Health check endpoint
|
- `/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.
|
All endpoints (except `/health`, `/login`, and `/callback`) require an Authorization header with a Bearer token.
|
||||||
For development purposes, you can use `abc` as the token.
|
|
||||||
|
|
||||||
## mobile functions
|
## mobile functions
|
||||||
|
|
||||||
|
|||||||
136
index.html
136
index.html
@@ -13,7 +13,20 @@
|
|||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<h1 class="mb-4 text-center">MyIce - Games</h1>
|
<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 id="eventFilters" style="display: none;">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -50,7 +63,10 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
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 eventFilters = document.getElementById("eventFilters");
|
||||||
const accountSelect = document.getElementById("account");
|
const accountSelect = document.getElementById("account");
|
||||||
const agegroupSelect = document.getElementById("agegroup");
|
const agegroupSelect = document.getElementById("agegroup");
|
||||||
@@ -62,33 +78,86 @@
|
|||||||
let storedApiKey = localStorage.getItem("apikey");
|
let storedApiKey = localStorage.getItem("apikey");
|
||||||
let lastFetchedEvents = [];
|
let lastFetchedEvents = [];
|
||||||
let storedAccount = localStorage.getItem("account") || "default";
|
let storedAccount = localStorage.getItem("account") || "default";
|
||||||
|
let userInfo = JSON.parse(localStorage.getItem("userInfo") || "null");
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
}
|
||||||
|
|
||||||
function renderApiKeyInput() {
|
|
||||||
if (storedApiKey) {
|
|
||||||
apikeyContainer.innerHTML = `<button id="disconnect" class="btn btn-danger">Déconnecter</button>`;
|
|
||||||
document.getElementById("disconnect").addEventListener("click", () => {
|
|
||||||
localStorage.removeItem("apikey");
|
|
||||||
localStorage.removeItem("account");
|
|
||||||
location.reload();
|
|
||||||
});
|
|
||||||
eventFilters.style.display = "block";
|
eventFilters.style.display = "block";
|
||||||
// Load available accounts from server or use predefined ones
|
|
||||||
updateAccountOptions();
|
updateAccountOptions();
|
||||||
fetchEvents(storedApiKey, storedAccount);
|
if (storedApiKey) {
|
||||||
|
fetchEvents(storedApiKey, storedAccount);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
apikeyContainer.innerHTML = `
|
// User is not logged in
|
||||||
<label for="apikey" class="form-label">API Key</label>
|
connectedUser.style.display = "none";
|
||||||
<input type="text" id="apikey" class="form-control" placeholder="Entrez votre API Key">
|
oidcLoginSection.style.display = "block";
|
||||||
<button id="validateApiKey" class="btn btn-success mt-2">Valider</button>
|
apikeySection.style.display = "none";
|
||||||
`;
|
|
||||||
eventFilters.style.display = "none";
|
eventFilters.style.display = "none";
|
||||||
document.getElementById("validateApiKey").addEventListener("click", saveApiKey);
|
|
||||||
document.getElementById("apikey").addEventListener("keypress", function (event) {
|
document.getElementById("oidcLoginBtn").addEventListener("click", initiateOIDCLogin);
|
||||||
if (event.key === "Enter") {
|
|
||||||
saveApiKey();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveApiKey() {
|
function saveApiKey() {
|
||||||
@@ -128,7 +197,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
renderApiKeyInput();
|
renderLoginSection();
|
||||||
|
|
||||||
accountSelect.addEventListener("change", () => {
|
accountSelect.addEventListener("change", () => {
|
||||||
const selectedAccount = accountSelect.value;
|
const selectedAccount = accountSelect.value;
|
||||||
@@ -140,7 +209,7 @@
|
|||||||
|
|
||||||
fetchButton.addEventListener("click", () => {
|
fetchButton.addEventListener("click", () => {
|
||||||
if (!storedApiKey) {
|
if (!storedApiKey) {
|
||||||
alert("Veuillez entrer une clé API");
|
alert("Veuillez vous connecter");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectedAccount = accountSelect.value;
|
const selectedAccount = accountSelect.value;
|
||||||
@@ -155,11 +224,20 @@
|
|||||||
fetch(`${apiBaseUrl}/schedule?account=${account}`, {
|
fetch(`${apiBaseUrl}/schedule?account=${account}`, {
|
||||||
headers: { "Authorization": `Bearer ${apiKey}` }
|
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 => {
|
.then(data => {
|
||||||
lastFetchedEvents = data.filter(event => event.event === "Jeu");
|
if (data) {
|
||||||
updateAgeGroupOptions(lastFetchedEvents);
|
lastFetchedEvents = data.filter(event => event.event === "Jeu");
|
||||||
displayEvents(lastFetchedEvents);
|
updateAgeGroupOptions(lastFetchedEvents);
|
||||||
|
displayEvents(lastFetchedEvents);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(error => console.error("Erreur lors du chargement des événements:", error));
|
.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 logging
|
||||||
import requests
|
import requests
|
||||||
|
import os
|
||||||
from typing import Annotated
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from . import myice
|
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 = ["*"]
|
origins = ["*"]
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -41,10 +60,43 @@ class AuthHeaders(BaseModel):
|
|||||||
return self.Authorization.split(" ")[1]
|
return self.Authorization.split(" ")[1]
|
||||||
|
|
||||||
def authorized(self) -> bool:
|
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":
|
if self.token() == "abc":
|
||||||
return True
|
return True
|
||||||
return False
|
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):
|
class HealthCheck(BaseModel):
|
||||||
"""Response model to validate and return when performing a health check."""
|
"""Response model to validate and return when performing a health check."""
|
||||||
@@ -71,6 +123,79 @@ async def favico():
|
|||||||
return FileResponse("favicon.ico")
|
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")
|
@app.get("/schedule")
|
||||||
async def schedule(
|
async def schedule(
|
||||||
headers: Annotated[AuthHeaders, Header()],
|
headers: Annotated[AuthHeaders, Header()],
|
||||||
|
|||||||
Reference in New Issue
Block a user