feat: Add NetID OAuth provider integration and update login UI

- Implemented NetID OAuth provider in app/auth/oauth.py
- Added styles for NetID login button in app/static/css/styles.css
- Updated login.html to include NetID login option with custom button
- Modified docker-compose.yml to include NetID client credentials
- Enhanced social login setup documentation to include NetID instructions
- Created demo environment configuration file (.env.demo) for easier setup
- Updated .env.template to include NetID configuration options
- Added comprehensive README.md with project features and setup instructions
This commit is contained in:
Christian Krakau-Louis
2025-04-15 14:28:14 +02:00
parent 6306abf6d9
commit 6d796c95ff
8 changed files with 863 additions and 179 deletions
+108
View File
@@ -818,6 +818,113 @@ class LinkedInOAuth(OAuthProvider):
"raw": user_info
}
class NetIDOAuth(OAuthProvider):
"""NetID OAuth provider implementation"""
provider_id = "netid"
display_name = "netID"
icon_class = "fas fa-check" # Could be replaced with a custom netID icon class if available
button_color = "#76b82a"
# NetID OIDC endpoints
AUTHORIZATION_URL = "https://broker.netid.de/authorize"
TOKEN_URL = "https://broker.netid.de/token"
USERINFO_URL = "https://broker.netid.de/userinfo"
def __init__(self):
self.client_id = os.getenv("NETID_CLIENT_ID", "")
self.client_secret = os.getenv("NETID_CLIENT_SECRET", "")
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
# Create a custom OAuth2 client for NetID OpenID Connect
from httpx_oauth.oauth2 import OAuth2
self.client = OAuth2(
client_id=self.client_id,
client_secret=self.client_secret,
authorize_endpoint=self.AUTHORIZATION_URL,
access_token_endpoint=self.TOKEN_URL,
refresh_token_endpoint=self.TOKEN_URL,
base_scopes=["openid", "email", "profile"]
)
except Exception as e:
print(f"Error initializing NetID OAuth client: {str(e)}")
self.client = None
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="NetID OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
scope=["openid", "email", "profile"],
state=str(request.session.get("session_id", ""))
)
return authorization_url
except Exception as e:
print(f"Error getting NetID authorization URL: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
if not self.client:
self.initialize_client()
if not self.client:
raise HTTPException(status_code=500, detail="NetID OAuth client could not be initialized")
try:
# Exchange code for token
token = await self.client.get_access_token(
code=code,
redirect_uri=redirect_uri
)
access_token = token.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Could not get NetID access token")
# Get user info from NetID UserInfo endpoint
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(
self.USERINFO_URL,
headers=headers
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching NetID user info: {response.text}")
return response.json()
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"NetID OAuth error: {error_description}")
except Exception as e:
print(f"Error getting NetID user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
# NetID OpenID Connect response normalization
return {
"id": user_info.get("sub", ""), # 'sub' is the standard OIDC subject identifier
"email": user_info.get("email"),
"name": f"{user_info.get('given_name', '')} {user_info.get('family_name', '')}".strip(),
"first_name": user_info.get("given_name"),
"last_name": user_info.get("family_name"),
"picture": None, # NetID might not provide profile picture
"raw": user_info
}
class OAuthManager:
"""
Manager class for handling multiple OAuth providers
@@ -836,6 +943,7 @@ class OAuthManager:
self.register_provider(MicrosoftOAuth())
self.register_provider(DiscordOAuth())
self.register_provider(LinkedInOAuth())
self.register_provider(NetIDOAuth()) # Register NetID provider
def register_provider(self, provider: OAuthProvider):
"""Register a new provider"""
+322 -1
View File
@@ -1,7 +1,7 @@
/*
* LeagueLedger Main Stylesheet
* Version: 1.0.0
* Date: April 13, 2025
* Date: April 15, 2025
*/
:root {
@@ -155,4 +155,325 @@ h1, h2, h3, h4, h5, h6 {
font-weight: bold;
color: var(--irish-green);
}
}
/* Google Sign-In Button Styles */
.gsi-material-button {
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
-webkit-appearance: none;
background-color: WHITE;
background-image: none;
border: 1px solid #747775;
-webkit-border-radius: 4px;
border-radius: 4px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #1f1f1f;
cursor: pointer;
font-family: 'Roboto', arial, sans-serif;
font-size: 14px;
height: 40px;
letter-spacing: 0.25px;
outline: none;
overflow: hidden;
padding: 0 12px;
position: relative;
text-align: center;
-webkit-transition: background-color .218s, border-color .218s, box-shadow .218s;
transition: background-color .218s, border-color .218s, box-shadow .218s;
vertical-align: middle;
white-space: nowrap;
width: auto;
max-width: 400px;
min-width: min-content;
}
.gsi-material-button .gsi-material-button-icon {
height: 20px;
margin-right: 12px;
min-width: 20px;
width: 20px;
}
.gsi-material-button .gsi-material-button-content-wrapper {
-webkit-align-items: center;
align-items: center;
display: flex;
-webkit-flex-direction: row;
flex-direction: row;
-webkit-flex-wrap: nowrap;
flex-wrap: nowrap;
height: 100%;
justify-content: space-between;
position: relative;
width: 100%;
}
.gsi-material-button .gsi-material-button-contents {
-webkit-flex-grow: 1;
flex-grow: 1;
font-family: 'Roboto', arial, sans-serif;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
.gsi-material-button .gsi-material-button-state {
-webkit-transition: opacity .218s;
transition: opacity .218s;
bottom: 0;
left: 0;
opacity: 0;
position: absolute;
right: 0;
top: 0;
}
.gsi-material-button:disabled {
cursor: default;
background-color: #ffffff61;
border-color: #1f1f1f1f;
}
.gsi-material-button:disabled .gsi-material-button-contents {
opacity: 38%;
}
.gsi-material-button:disabled .gsi-material-button-icon {
opacity: 38%;
}
.gsi-material-button:not(:disabled):active .gsi-material-button-state,
.gsi-material-button:not(:disabled):focus .gsi-material-button-state {
background-color: #303030;
opacity: 12%;
}
.gsi-material-button:not(:disabled):hover {
-webkit-box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .30), 0 1px 3px 1px rgba(60, 64, 67, .15);
box-shadow: 0 1px 2px 0 rgba(60, 64, 67, .30), 0 1px 3px 1px rgba(60, 64, 67, .15);
}
.gsi-material-button:not(:disabled):hover .gsi-material-button-state {
background-color: #303030;
opacity: 8%;
}
/* OAuth Buttons Container */
.oauth-buttons-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 280px;
margin: 0 auto;
}
/* LinkedIn Login Button */
.linkedin-login-button {
background-color: #0077B5;
border: none;
border-radius: 4px;
color: white;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
font-weight: 600;
height: 40px;
letter-spacing: normal;
padding: 0;
position: relative;
text-align: center;
transition: background-color .218s;
width: 100%;
max-width: 280px;
margin: 0 auto 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.linkedin-login-button:hover {
background-color: #006097;
}
.linkedin-login-button .linkedin-icon {
display: inline-block;
margin-right: 8px;
height: 18px;
width: 18px;
}
.linkedin-login-button .linkedin-icon svg {
height: 18px;
width: 18px;
fill: white;
}
.linkedin-login-button .button-text {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
font-weight: 600;
}
/* Facebook Login Button */
.fb-login-button {
background-color: #1877F2;
border: none;
border-radius: 4px;
color: white;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
font-weight: 600;
height: 40px;
letter-spacing: normal;
padding: 0;
position: relative;
text-align: center;
transition: background-color .218s;
width: 100%;
max-width: 280px;
margin: 0 auto 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.fb-login-button:hover {
background-color: #166FE5;
}
.fb-login-button .fb-icon {
display: inline-block;
margin-right: 8px;
height: 18px;
width: 18px;
background-color: white;
border-radius: 2px;
}
.fb-login-button .fb-icon svg {
height: 18px;
width: 18px;
fill: #1877F2;
}
.fb-login-button .button-text {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
font-weight: 600;
}
/* GitHub Login Button */
.github-login-button {
background-color: #24292e;
border: 1px solid rgba(27, 31, 35, 0.15);
border-radius: 6px;
color: white;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 14px;
font-weight: 600;
height: 40px;
padding: 0;
position: relative;
text-align: center;
transition: background-color 0.2s;
width: 100%;
max-width: 280px;
margin: 0 auto 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.github-login-button:hover {
background-color: #2c3136;
}
.github-login-button .github-icon {
display: inline-block;
margin-right: 8px;
height: 20px;
width: 20px;
}
.github-login-button .github-icon svg {
height: 20px;
width: 20px;
fill: white;
}
.github-login-button .button-text {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 14px;
font-weight: 600;
}
/* NetID Login Button */
.netid-login-button {
background-color: #76b82a;
border: none;
border-radius: 3px;
color: white;
font-family: 'IBM Plex Sans', Verdana, Arial, sans-serif;
font-size: 14px;
font-weight: 600;
height: 40px;
padding: 0;
position: relative;
text-align: center;
transition: background-color 0.2s;
width: 100%;
max-width: 280px;
margin: 0 auto 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.netid-login-button:hover {
background-color: #5d9422;
}
.netid-login-button .netid-icon {
display: inline-block;
margin-right: 8px;
height: 20px;
width: 20px;
}
.netid-login-button .netid-icon svg {
height: 20px;
width: 20px;
fill: white;
}
.netid-login-button .button-text {
font-family: 'IBM Plex Sans', Verdana, Arial, sans-serif;
font-size: 14px;
font-weight: 600;
}
/* Other OAuth provider button customizations */
.oauth-button {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
transition: all 0.2s ease;
margin-bottom: 8px;
text-decoration: none;
}
+82 -21
View File
@@ -91,30 +91,91 @@
<div class="mt-6 pt-6 border-t border-gray-200">
<p class="text-center text-gray-600 mb-4">Or sign in with</p>
<!-- For 2 or fewer providers, show them side by side -->
{% if oauth_providers|length <= 2 %}
<div class="flex justify-center space-x-4">
<div class="oauth-buttons-container">
{% for provider in oauth_providers %}
<a href="/auth/oauth-login/{{ provider.id }}"
class="flex items-center justify-center w-full py-2 px-4 rounded-md transition"
style="background-color: {{ provider.color }}; color: white;">
<i class="{{ provider.icon }} mr-2"></i> {{ provider.name }}
</a>
{% if provider.id == 'google' %}
<!-- Custom Google Button -->
<a href="/auth/oauth-login/{{ provider.id }}" class="block w-full mb-3">
<button class="gsi-material-button" type="button">
<div class="gsi-material-button-state"></div>
<div class="gsi-material-button-content-wrapper">
<div class="gsi-material-button-icon">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" xmlns:xlink="http://www.w3.org/1999/xlink" style="display: block;">
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"></path>
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"></path>
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"></path>
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"></path>
<path fill="none" d="M0 0h48v48H0z"></path>
</svg>
</div>
<span class="gsi-material-button-contents">Sign in with Google</span>
<span style="display: none;">Sign in with Google</span>
</div>
</button>
</a>
{% elif provider.id == 'facebook' %}
<!-- Custom Facebook Button according to Meta's guidelines -->
<a href="/auth/oauth-login/{{ provider.id }}" class="block w-full mb-3">
<button class="fb-login-button" type="button">
<div class="fb-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M9.198 21.5h4v-8.01h3.604l.396-3.98h-4V7.5a1 1 0 0 1 1-1h3v-4h-3a5 5 0 0 0-5 5v2.01h-2l-.396 3.98h2.396v8.01Z" fill="#1877F2" />
</svg>
</div>
<span class="button-text">Continue with Facebook</span>
</button>
</a>
{% elif provider.id == 'linkedin' %}
<!-- Custom LinkedIn Button -->
<a href="/auth/oauth-login/{{ provider.id }}" class="block w-full mb-3">
<button class="linkedin-login-button" type="button">
<div class="linkedin-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1-2-2h14m-.5 15.5v-5.3a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.32 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1 1.4 1.4v4.93h2.79M6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69 1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37h2.77z" fill="white" />
</svg>
</div>
<span class="button-text">Sign in with LinkedIn</span>
</button>
</a>
{% elif provider.id == 'github' %}
<!-- Custom GitHub Button -->
<a href="/auth/oauth-login/{{ provider.id }}" class="block w-full mb-3">
<button class="github-login-button" type="button">
<div class="github-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" fill="white" />
</svg>
</div>
<span class="button-text">Login with GitHub</span>
</button>
</a>
{% elif provider.id == 'netid' %}
<!-- NetID explanation text -->
<div class="text-center mb-2 text-xs text-gray-600">
<p>Login with GMX, WEB.DE, or other email providers via netID</p>
</div>
<!-- Custom NetID Button -->
<a href="/auth/oauth-login/{{ provider.id }}" class="block w-full mb-3">
<button class="netid-login-button" type="button">
<div class="netid-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<path fill="#fff" d="M30.3 4.1a14.5 14.5 0 00-6-2.7C20.7 1 17 2 13.6 5.3L7 12.1l-6.9 7 19.8 19.8 8.8-8.9 5-4.9c3-3.1 4.2-6.7 3.8-10a12.9 12.9 0 00-1-3.8l-2 2a10 10 0 01.6 2c.3 2.7-.5 5.4-3.2 8l-4.9 5-16.3-16.4L15.5 7c3-3 5.7-3.6 8.3-3.1 1.6.2 3.2 1 4.7 2z"></path>
<path fill="#fff" d="M37.7 1.1l-12.9 13-4.6-4.7-2.3 2.3 6.9 7L40 3.5z"></path>
</svg>
</div>
<span class="button-text">Login mit netID</span>
</button>
</a>
{% else %}
<!-- Standard Button for other providers -->
<a href="/auth/oauth-login/{{ provider.id }}"
class="oauth-button flex items-center justify-center w-full py-2 px-4 rounded-md transition mb-3"
style="background-color: {{ provider.color }}; color: white;">
<i class="{{ provider.icon }} mr-2"></i> Sign in with {{ provider.name }}
</a>
{% endif %}
{% endfor %}
</div>
<!-- For more than 2 providers, stack them vertically -->
{% else %}
<div class="space-y-3">
{% for provider in oauth_providers %}
<a href="/auth/oauth-login/{{ provider.id }}"
class="flex items-center justify-center w-full py-2 px-4 rounded-md transition"
style="background-color: {{ provider.color }}; color: white;">
<i class="{{ provider.icon }} mr-2"></i> {{ provider.name }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>