Commit f4c1b651 authored by Your Name's avatar Your Name

User signup and oauth2 signup/login support

parent 51a7e2a5
...@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ...@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
-**OAuth2 Authentication Support**
- Google OAuth2 authentication and signup
- GitHub OAuth2 authentication and signup
- Admin configurable OAuth2 providers in dashboard settings
- Dynamic OAuth2 button display on login and signup pages
- Automatic user account creation for first time OAuth users
- Automatic email verification for OAuth authenticated users
- Full session integration matching existing authentication system
- PKCE security implemented for Google OAuth2 flow
- State parameter validation for CSRF protection
- Proxy-aware redirect URI handling
- Secure random password generation for OAuth users
## [0.99.20] - 2026-04-11 ## [0.99.20] - 2026-04-11
### Added ### Added
......
...@@ -25,6 +25,8 @@ from .kiro import KiroAuthManager, AuthType ...@@ -25,6 +25,8 @@ from .kiro import KiroAuthManager, AuthType
from .claude import ClaudeAuth from .claude import ClaudeAuth
from .kilo import KiloOAuth2 from .kilo import KiloOAuth2
from .qwen import QwenOAuth2 from .qwen import QwenOAuth2
from .google import GoogleOAuth2
from .github import GitHubOAuth2
__all__ = [ __all__ = [
"KiroAuthManager", "KiroAuthManager",
...@@ -32,4 +34,6 @@ __all__ = [ ...@@ -32,4 +34,6 @@ __all__ = [
"ClaudeAuth", "ClaudeAuth",
"KiloOAuth2", "KiloOAuth2",
"QwenOAuth2", "QwenOAuth2",
"GoogleOAuth2",
"GitHubOAuth2",
] ]
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import json
import logging
import urllib.parse
import secrets
from typing import Dict, Optional
import httpx
logger = logging.getLogger(__name__)
GITHUB_OAUTH_AUTH_URL = "https://github.com/login/oauth/authorize"
GITHUB_OAUTH_TOKEN_URL = "https://github.com/login/oauth/access_token"
GITHUB_API_USER_URL = "https://api.github.com/user"
GITHUB_API_EMAILS_URL = "https://api.github.com/user/emails"
class GitHubOAuth2:
"""GitHub OAuth2 Authentication Handler"""
def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self._state = None
def get_authorization_url(self, scopes: list = None) -> str:
"""Generate GitHub authorization URL"""
if scopes is None:
scopes = ["user:email", "read:user"]
state = secrets.token_urlsafe(16)
self._state = state
params = {
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"scope": " ".join(scopes),
"state": state,
"allow_signup": "true"
}
query = urllib.parse.urlencode(params)
return f"{GITHUB_OAUTH_AUTH_URL}?{query}"
async def exchange_code_for_tokens(self, code: str, state: str) -> Optional[Dict]:
"""Exchange authorization code for access token"""
if state != self._state:
logger.warning("GitHub OAuth2: State parameter mismatch")
return None
try:
async with httpx.AsyncClient() as client:
response = await client.post(
GITHUB_OAUTH_TOKEN_URL,
data={
"code": code,
"client_id": self.client_id,
"client_secret": self.client_secret,
"redirect_uri": self.redirect_uri
},
headers={"Accept": "application/json"}
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"GitHub OAuth2 token exchange failed: {e}")
return None
async def get_user_info(self, access_token: str) -> Optional[Dict]:
"""Get user information from GitHub including primary email"""
try:
async with httpx.AsyncClient() as client:
# Get user profile
user_response = await client.get(
GITHUB_API_USER_URL,
headers={"Authorization": f"Bearer {access_token}"}
)
user_response.raise_for_status()
user_data = user_response.json()
# Get emails if needed
if not user_data.get("email"):
emails_response = await client.get(
GITHUB_API_EMAILS_URL,
headers={"Authorization": f"Bearer {access_token}"}
)
emails_response.raise_for_status()
emails = emails_response.json()
# Find primary verified email
for email in emails:
if email.get("primary") and email.get("verified"):
user_data["email"] = email.get("email")
break
return user_data
except Exception as e:
logger.error(f"GitHub OAuth2 user info request failed: {e}")
return None
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import json
import logging
import urllib.parse
import secrets
import hashlib
import base64
from typing import Dict, Optional, Tuple
import httpx
logger = logging.getLogger(__name__)
GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_OAUTH_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
class GoogleOAuth2:
"""Google OAuth2 Authentication Handler"""
def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self._state = None
self._code_verifier = None
def generate_pkce_pair(self) -> Tuple[str, str]:
"""Generate PKCE code verifier and challenge"""
code_verifier = secrets.token_urlsafe(32)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).decode().rstrip('=')
return code_verifier, code_challenge
def get_authorization_url(self, scopes: list = None) -> str:
"""Generate authorization URL with PKCE"""
if scopes is None:
scopes = ["openid", "email", "profile"]
state = secrets.token_urlsafe(16)
code_verifier, code_challenge = self.generate_pkce_pair()
self._state = state
self._code_verifier = code_verifier
params = {
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"response_type": "code",
"scope": " ".join(scopes),
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"access_type": "offline",
"prompt": "select_account consent"
}
query = urllib.parse.urlencode(params)
return f"{GOOGLE_OAUTH_AUTH_URL}?{query}"
async def exchange_code_for_tokens(self, code: str, state: str) -> Optional[Dict]:
"""Exchange authorization code for tokens"""
if state != self._state:
logger.warning("Google OAuth2: State parameter mismatch")
return None
try:
async with httpx.AsyncClient() as client:
response = await client.post(
GOOGLE_OAUTH_TOKEN_URL,
data={
"code": code,
"client_id": self.client_id,
"client_secret": self.client_secret,
"redirect_uri": self.redirect_uri,
"grant_type": "authorization_code",
"code_verifier": self._code_verifier
},
headers={"Accept": "application/json"}
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Google OAuth2 token exchange failed: {e}")
return None
async def get_user_info(self, access_token: str) -> Optional[Dict]:
"""Get user information from Google"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(
GOOGLE_OAUTH_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"}
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Google OAuth2 user info request failed: {e}")
return None
...@@ -131,5 +131,20 @@ ...@@ -131,5 +131,20 @@
"use_ssl": false, "use_ssl": false,
"from_email": "noreply@example.com", "from_email": "noreply@example.com",
"from_name": "AISBF" "from_name": "AISBF"
},
"oauth2": {
"google": {
"enabled": false,
"client_id": "",
"client_secret": "",
"scopes": ["openid", "email", "profile"]
},
"github": {
"enabled": false,
"client_id": "",
"client_secret": "",
"scopes": ["user:email", "read:user"]
}
} }
} }
...@@ -480,6 +480,13 @@ def setup_template_globals(): ...@@ -480,6 +480,13 @@ def setup_template_globals():
"""Setup Jinja2 template globals for proxy-aware URLs""" """Setup Jinja2 template globals for proxy-aware URLs"""
templates.env.globals['url_for'] = url_for templates.env.globals['url_for'] = url_for
templates.env.globals['get_base_url'] = get_base_url templates.env.globals['get_base_url'] = get_base_url
# Add md5 filter for Gravatar email hashing (handles None/empty values gracefully)
def md5_filter(s):
if not s:
# Fallback to empty string hash for users without email
return hashlib.md5(b'').hexdigest().lower()
return hashlib.md5(s.encode('utf-8')).hexdigest().lower()
templates.env.filters['md5'] = md5_filter
# Clear the template cache to avoid stale cache issues # Clear the template cache to avoid stale cache issues
templates.env.cache.clear() templates.env.cache.clear()
...@@ -1476,7 +1483,7 @@ async def dashboard_login_page(request: Request): ...@@ -1476,7 +1483,7 @@ async def dashboard_login_page(request: Request):
# Get and render template # Get and render template
template = env.get_template("dashboard/login.html") template = env.get_template("dashboard/login.html")
html_content = template.render(request=request, signup_enabled=signup_enabled) html_content = template.render(request=request, signup_enabled=signup_enabled, config=config.aisbf if config and config.aisbf else {})
return HTMLResponse(content=html_content) return HTMLResponse(content=html_content)
except Exception as e: except Exception as e:
...@@ -1544,7 +1551,7 @@ async def dashboard_login(request: Request, username: str = Form(...), password: ...@@ -1544,7 +1551,7 @@ async def dashboard_login(request: Request, username: str = Form(...), password:
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="dashboard/login.html", name="dashboard/login.html",
context={"request": request, "error": "Invalid credentials", "signup_enabled": signup_enabled} context={"request": request, "error": "Invalid credentials", "signup_enabled": signup_enabled, "config": config.aisbf if config and config.aisbf else {}}
) )
...@@ -1572,7 +1579,7 @@ async def dashboard_signup_page(request: Request): ...@@ -1572,7 +1579,7 @@ async def dashboard_signup_page(request: Request):
# Get and render template # Get and render template
template = env.get_template("dashboard/signup.html") template = env.get_template("dashboard/signup.html")
html_content = template.render(request=request) html_content = template.render(request=request, config=config.aisbf if config and config.aisbf else {})
return HTMLResponse(content=html_content) return HTMLResponse(content=html_content)
except Exception as e: except Exception as e:
...@@ -1735,6 +1742,448 @@ async def dashboard_logout(request: Request): ...@@ -1735,6 +1742,448 @@ async def dashboard_logout(request: Request):
request.session.clear() request.session.clear()
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303) return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
@app.get("/dashboard/profile", response_class=HTMLResponse)
async def dashboard_profile(request: Request):
"""User profile edit page"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
# User dashboard - load usage stats same as main dashboard user route
from aisbf.database import get_database
db = get_database()
user_id = request.session.get('user_id')
# Get user statistics
usage_stats = {
'total_tokens': 0,
'requests_today': 0
}
if user_id:
# Get token usage for this user
token_usage = db.get_user_token_usage(user_id)
usage_stats['total_tokens'] = sum(row['token_count'] for row in token_usage)
# Count requests today
from datetime import datetime, timedelta
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
usage_stats['requests_today'] = len([
row for row in token_usage
if datetime.fromisoformat(row['timestamp']) >= today
])
# Get user config counts
providers_count = len(db.get_user_providers(user_id))
rotations_count = len(db.get_user_rotations(user_id))
autoselects_count = len(db.get_user_autoselects(user_id))
# Get recent activity (last 10)
recent_activity = token_usage[-10:] if token_usage else []
else:
providers_count = 0
rotations_count = 0
autoselects_count = 0
recent_activity = []
return templates.TemplateResponse(
request=request,
name="dashboard/profile.html",
context={
"session": request.session,
"success": request.query_params.get('success'),
"error": request.query_params.get('error')
}
)
@app.post("/dashboard/profile")
async def dashboard_profile_save(request: Request, username: str = Form(...), email: str = Form(...)):
"""Save user profile changes"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
from aisbf.database import get_database
user_id = request.session.get('user_id')
db = get_database()
try:
db.update_user_profile(user_id, username, email)
# Update session with new username
request.session['username'] = username
request.session['email'] = email
return RedirectResponse(url=url_for(request, "/dashboard/profile?success=Profile updated successfully"), status_code=303)
except Exception as e:
return RedirectResponse(url=url_for(request, f"/dashboard/profile?error=Failed to update profile: {str(e)}"), status_code=303)
@app.get("/dashboard/change-password", response_class=HTMLResponse)
async def dashboard_change_password(request: Request):
"""Change user password page"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
# User dashboard - load usage stats same as main dashboard user route
from aisbf.database import get_database
db = get_database()
user_id = request.session.get('user_id')
# Get user statistics
usage_stats = {
'total_tokens': 0,
'requests_today': 0
}
if user_id:
# Get token usage for this user
token_usage = db.get_user_token_usage(user_id)
usage_stats['total_tokens'] = sum(row['token_count'] for row in token_usage)
# Count requests today
from datetime import datetime, timedelta
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
usage_stats['requests_today'] = len([
row for row in token_usage
if datetime.fromisoformat(row['timestamp']) >= today
])
# Get user config counts
providers_count = len(db.get_user_providers(user_id))
rotations_count = len(db.get_user_rotations(user_id))
autoselects_count = len(db.get_user_autoselects(user_id))
# Get recent activity (last 10)
recent_activity = token_usage[-10:] if token_usage else []
else:
providers_count = 0
rotations_count = 0
autoselects_count = 0
recent_activity = []
return templates.TemplateResponse(
request=request,
name="dashboard/change_password.html",
context={
"session": request.session,
"success": request.query_params.get('success'),
"error": request.query_params.get('error')
}
)
@app.post("/dashboard/change-password")
async def dashboard_change_password_save(request: Request, current_password: str = Form(...), new_password: str = Form(...), confirm_password: str = Form(...)):
"""Save password change"""
auth_check = require_dashboard_auth(request)
if isinstance(auth_check, RedirectResponse):
return auth_check
from aisbf.database import get_database
user_id = request.session.get('user_id')
db = get_database()
if new_password != confirm_password:
return RedirectResponse(url=url_for(request, "/dashboard/change-password?error=New passwords do not match"), status_code=303)
if len(new_password) < 6:
return RedirectResponse(url=url_for(request, "/dashboard/change-password?error=New password must be at least 6 characters"), status_code=303)
try:
# Verify current password
if not db.verify_user_password(user_id, current_password):
return RedirectResponse(url=url_for(request, "/dashboard/change-password?error=Current password is incorrect"), status_code=303)
# Update password
db.update_user_password(user_id, new_password)
return RedirectResponse(url=url_for(request, "/dashboard/change-password?success=Password changed successfully"), status_code=303)
except Exception as e:
return RedirectResponse(url=url_for(request, f"/dashboard/change-password?error=Failed to change password: {str(e)}"), status_code=303)
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
# ==============================================
# OAuth2 Authentication Endpoints (Google + GitHub)
# ==============================================
# OAuth2 handler instances are stored in session during auth flow
_oauth2_instances = {}
@app.get("/auth/oauth2/google")
async def oauth2_google_initiate(request: Request):
"""Initiate Google OAuth2 authentication flow"""
if not (config and config.aisbf and config.aisbf.oauth2 and
config.aisbf.oauth2.google and config.aisbf.oauth2.google.enabled):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
from aisbf.auth.google import GoogleOAuth2
client_id = config.aisbf.oauth2.google.client_id
client_secret = config.aisbf.oauth2.google.client_secret
# Build proper redirect URI respecting proxy headers
base_url = get_base_url(request)
redirect_uri = f"{base_url}/auth/oauth2/google/callback"
oauth = GoogleOAuth2(client_id, client_secret, redirect_uri)
auth_url = oauth.get_authorization_url(config.aisbf.oauth2.google.scopes)
# Store oauth instance and state in session for callback
request.session['oauth2_google'] = {
'state': oauth._state,
'code_verifier': oauth._code_verifier
}
return RedirectResponse(url=auth_url, status_code=303)
except Exception as e:
logger.error(f"Google OAuth2 initiation failed: {e}")
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={
"request": request,
"error": "Google authentication service is temporarily unavailable"
}
)
@app.get("/auth/oauth2/google/callback")
async def oauth2_google_callback(request: Request, code: str = Query(...), state: str = Query(...)):
"""Handle Google OAuth2 callback"""
if not (config and config.aisbf and config.aisbf.oauth2 and
config.aisbf.oauth2.google and config.aisbf.oauth2.google.enabled):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
from aisbf.auth.google import GoogleOAuth2
from aisbf.database import get_database
client_id = config.aisbf.oauth2.google.client_id
client_secret = config.aisbf.oauth2.google.client_secret
base_url = get_base_url(request)
redirect_uri = f"{base_url}/auth/oauth2/google/callback"
# Verify state matches
session_state = request.session.get('oauth2_google', {}).get('state')
if state != session_state:
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Invalid authentication state"}
)
# Restore oauth instance
oauth = GoogleOAuth2(client_id, client_secret, redirect_uri)
oauth._state = session_state
oauth._code_verifier = request.session.get('oauth2_google', {}).get('code_verifier')
# Exchange code for tokens
tokens = await oauth.exchange_code_for_tokens(code, state)
if not tokens:
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Failed to authenticate with Google"}
)
# Get user profile
user_info = await oauth.get_user_info(tokens.get('access_token'))
if not user_info or not user_info.get('email'):
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Could not retrieve your profile from Google"}
)
email = user_info.get('email')
email_verified = user_info.get('email_verified', False)
db = get_database()
# Lookup existing user
existing_user = db.get_user_by_email(email)
if existing_user:
# Existing user - login directly
request.session['logged_in'] = True
request.session['username'] = existing_user['username']
request.session['role'] = existing_user['role']
request.session['user_id'] = existing_user['id']
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
else:
# New user - create account automatically (no password required)
if not email_verified:
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Google email must be verified to create an account"}
)
# Generate secure random password for OAuth users (never used for login)
random_password = secrets.token_urlsafe(32)
password_hash = hashlib.sha256(random_password.encode()).hexdigest()
# Create user with verified email (no verification required)
user_id = db.create_user(email, password_hash, None)
db.verify_email(email) # Mark email as verified automatically
# Login the new user
request.session['logged_in'] = True
request.session['username'] = email
request.session['role'] = 'user'
request.session['user_id'] = user_id
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
# Cleanup session data
request.session.pop('oauth2_google', None)
# Redirect to dashboard
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
except Exception as e:
logger.error(f"Google OAuth2 callback failed: {e}", exc_info=True)
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Authentication failed. Please try again."}
)
@app.get("/auth/oauth2/github")
async def oauth2_github_initiate(request: Request):
"""Initiate GitHub OAuth2 authentication flow"""
if not (config and config.aisbf and config.aisbf.oauth2 and
config.aisbf.oauth2.github and config.aisbf.oauth2.github.enabled):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
from aisbf.auth.github import GitHubOAuth2
client_id = config.aisbf.oauth2.github.client_id
client_secret = config.aisbf.oauth2.github.client_secret
# Build proper redirect URI respecting proxy headers
base_url = get_base_url(request)
redirect_uri = f"{base_url}/auth/oauth2/github/callback"
oauth = GitHubOAuth2(client_id, client_secret, redirect_uri)
auth_url = oauth.get_authorization_url(config.aisbf.oauth2.github.scopes)
# Store state in session for callback
request.session['oauth2_github'] = {
'state': oauth._state
}
return RedirectResponse(url=auth_url, status_code=303)
except Exception as e:
logger.error(f"GitHub OAuth2 initiation failed: {e}")
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={
"request": request,
"error": "GitHub authentication service is temporarily unavailable"
}
)
@app.get("/auth/oauth2/github/callback")
async def oauth2_github_callback(request: Request, code: str = Query(...), state: str = Query(...)):
"""Handle GitHub OAuth2 callback"""
if not (config and config.aisbf and config.aisbf.oauth2 and
config.aisbf.oauth2.github and config.aisbf.oauth2.github.enabled):
return RedirectResponse(url=url_for(request, "/dashboard/login"), status_code=303)
try:
from aisbf.auth.github import GitHubOAuth2
from aisbf.database import get_database
client_id = config.aisbf.oauth2.github.client_id
client_secret = config.aisbf.oauth2.github.client_secret
base_url = get_base_url(request)
redirect_uri = f"{base_url}/auth/oauth2/github/callback"
# Verify state matches
session_state = request.session.get('oauth2_github', {}).get('state')
if state != session_state:
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Invalid authentication state"}
)
# Restore oauth instance
oauth = GitHubOAuth2(client_id, client_secret, redirect_uri)
oauth._state = session_state
# Exchange code for tokens
tokens = await oauth.exchange_code_for_tokens(code, state)
if not tokens:
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Failed to authenticate with GitHub"}
)
# Get user profile
user_info = await oauth.get_user_info(tokens.get('access_token'))
if not user_info or not user_info.get('email'):
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Could not retrieve your profile from GitHub. Please ensure your email is public."}
)
email = user_info.get('email')
db = get_database()
# Lookup existing user
existing_user = db.get_user_by_email(email)
if existing_user:
# Existing user - login directly
request.session['logged_in'] = True
request.session['username'] = existing_user['username']
request.session['role'] = existing_user['role']
request.session['user_id'] = existing_user['id']
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
else:
# New user - create account automatically (no password required)
# Generate secure random password for OAuth users (never used for login)
random_password = secrets.token_urlsafe(32)
password_hash = hashlib.sha256(random_password.encode()).hexdigest()
# Create user with verified email (GitHub emails are always verified)
user_id = db.create_user(email, password_hash, None)
db.verify_email(email) # Mark email as verified automatically
# Login the new user
request.session['logged_in'] = True
request.session['username'] = email
request.session['role'] = 'user'
request.session['user_id'] = user_id
request.session['expires_at'] = int(time.time()) + 14 * 24 * 60 * 60
# Cleanup session data
request.session.pop('oauth2_github', None)
# Redirect to dashboard
return RedirectResponse(url=url_for(request, "/dashboard"), status_code=303)
except Exception as e:
logger.error(f"GitHub OAuth2 callback failed: {e}", exc_info=True)
return templates.TemplateResponse(
request=request,
name="dashboard/login.html",
context={"request": request, "error": "Authentication failed. Please try again."}
)
def require_dashboard_auth(request: Request): def require_dashboard_auth(request: Request):
"""Check if user is logged in to dashboard""" """Check if user is logged in to dashboard"""
if not request.session.get('logged_in'): if not request.session.get('logged_in'):
...@@ -2735,8 +3184,8 @@ async def dashboard_settings(request: Request): ...@@ -2735,8 +3184,8 @@ async def dashboard_settings(request: Request):
} }
) )
@app.post("/dashboard/settings") @app.post("/dashboard/settings")
async def dashboard_settings_save( async def dashboard_settings_save(
request: Request, request: Request,
host: str = Form(...), host: str = Form(...),
port: int = Form(...), port: int = Form(...),
...@@ -2777,8 +3226,14 @@ async def dashboard_settings(request: Request): ...@@ -2777,8 +3226,14 @@ async def dashboard_settings(request: Request):
smtp_username: str = Form(""), smtp_username: str = Form(""),
smtp_password: str = Form(""), smtp_password: str = Form(""),
smtp_use_tls: bool = Form(True), smtp_use_tls: bool = Form(True),
smtp_from_address: str = Form("") smtp_from_address: str = Form(""),
): oauth2_google_enabled: bool = Form(False),
oauth2_google_client_id: str = Form(""),
oauth2_google_client_secret: str = Form(""),
oauth2_github_enabled: bool = Form(False),
oauth2_github_client_id: str = Form(""),
oauth2_github_client_secret: str = Form("")
):
"""Save server settings""" """Save server settings"""
auth_check = require_admin(request) auth_check = require_admin(request)
if auth_check: if auth_check:
...@@ -2864,17 +3319,6 @@ async def dashboard_settings(request: Request): ...@@ -2864,17 +3319,6 @@ async def dashboard_settings(request: Request):
} }
) )
return templates.TemplateResponse(
request=request,
name="dashboard/settings.html",
context={
"request": request,
"session": request.session,
"config": aisbf_config,
"success": "Settings saved successfully! Restart server for changes to take effect."
}
)
# Admin user management routes # Admin user management routes
@app.get("/dashboard/users", response_class=HTMLResponse) @app.get("/dashboard/users", response_class=HTMLResponse)
async def dashboard_users(request: Request): async def dashboard_users(request: Request):
......
...@@ -144,6 +144,8 @@ setup( ...@@ -144,6 +144,8 @@ setup(
'aisbf/auth/kilo.py', 'aisbf/auth/kilo.py',
'aisbf/auth/codex.py', 'aisbf/auth/codex.py',
'aisbf/auth/qwen.py', 'aisbf/auth/qwen.py',
'aisbf/auth/google.py',
'aisbf/auth/github.py',
]), ]),
# Install dashboard templates # Install dashboard templates
('share/aisbf/templates', [ ('share/aisbf/templates', [
...@@ -168,6 +170,8 @@ setup( ...@@ -168,6 +170,8 @@ setup(
'templates/dashboard/rate_limits.html', 'templates/dashboard/rate_limits.html',
'templates/dashboard/users.html', 'templates/dashboard/users.html',
'templates/dashboard/signup.html', 'templates/dashboard/signup.html',
'templates/dashboard/profile.html',
'templates/dashboard/change_password.html',
]), ]),
# Install static files (extension and favicon) # Install static files (extension and favicon)
('share/aisbf/static', [ ('share/aisbf/static', [
......
...@@ -53,6 +53,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -53,6 +53,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #0f3460; color: #e0e0e0; } th, td { padding: 12px; text-align: left; border-bottom: 1px solid #0f3460; color: #e0e0e0; }
th { background: #0f3460; font-weight: 600; color: #e0e0e0; } th { background: #0f3460; font-weight: 600; color: #e0e0e0; }
.code { background: #0f3460; padding: 15px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 13px; overflow-x: auto; color: #e0e0e0; } .code { background: #0f3460; padding: 15px; border-radius: 4px; font-family: 'Courier New', monospace; font-size: 13px; overflow-x: auto; color: #e0e0e0; }
.nav .account-menu { position: relative; margin-left: auto; }
.nav .account-trigger { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 4px; cursor: pointer; color: #a0a0a0; }
.nav .account-trigger:hover { background: #0f3460; color: #e0e0e0; }
.nav .account-trigger img { width: 24px; height: 24px; border-radius: 50%; vertical-align: middle; }
.nav .account-dropdown { position: absolute; top: 100%; right: 0; background: #16213e; border-radius: 8px; min-width: 200px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); margin-top: 8px; display: none; z-index: 100; border: 1px solid #0f3460; }
.nav .account-dropdown.active { display: block; }
.nav .account-dropdown a { display: block; padding: 12px 16px; color: #a0a0a0; text-decoration: none; border-radius: 0; }
.nav .account-dropdown a:first-child { border-radius: 8px 8px 0 0; }
.nav .account-dropdown a:last-child { border-radius: 0 0 8px 8px; }
.nav .account-dropdown a:hover { background: #0f3460; color: #e0e0e0; }
</style> </style>
<script> <script>
/* /*
...@@ -71,6 +81,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -71,6 +81,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
function toggleAccountMenu() {
const dropdown = document.getElementById('account-dropdown');
dropdown.classList.toggle('active');
}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
const accountMenu = document.querySelector('.account-menu');
const dropdown = document.getElementById('account-dropdown');
if (!accountMenu.contains(event.target)) {
dropdown.classList.remove('active');
}
});
function restartServer() { function restartServer() {
if (confirm('Are you sure you want to restart the server? This will disconnect all active connections.')) { if (confirm('Are you sure you want to restart the server? This will disconnect all active connections.')) {
fetch('{{ url_for(request, "/dashboard/restart") }}', { fetch('{{ url_for(request, "/dashboard/restart") }}', {
...@@ -424,6 +448,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -424,6 +448,22 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
{% endif %} {% endif %}
<a href="{{ url_for(request, '/dashboard/docs') }}" {% if '/docs' in request.path %}class="active"{% endif %}>Docs</a> <a href="{{ url_for(request, '/dashboard/docs') }}" {% if '/docs' in request.path %}class="active"{% endif %}>Docs</a>
<a href="{{ url_for(request, '/dashboard/about') }}" {% if '/about' in request.path %}class="active"{% endif %}>About</a> <a href="{{ url_for(request, '/dashboard/about') }}" {% if '/about' in request.path %}class="active"{% endif %}>About</a>
{% if request.session.user_id %}
<div class="account-menu">
<div class="account-trigger" onclick="toggleAccountMenu()">
<img src="https://www.gravatar.com/avatar/{{ request.session.email|md5 if request.session.email else '' }}?s=48&d=identicon" alt="User avatar">
<span>Account</span>
</div>
<div class="account-dropdown" id="account-dropdown">
{% if request.session.user_id %}
{% if request.session.user_id %}
<a href="{{ url_for(request, '/dashboard/profile') }}">Edit Profile</a>
<a href="{{ url_for(request, '/dashboard/change-password') }}">Change Password</a>
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
......
{% extends "base.html" %}
{% block title %}Change Password - AISBF{% endblock %}
{% block content %}
<div class="container">
<h1>Change Password</h1>
<p>Update your account password</p>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<div class="card">
<h2>Password Settings</h2>
<form method="POST" action="{{ url_for(request, '/dashboard/change-password') }}">
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password" required>
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" required minlength="6">
</div>
<div class="form-group">
<label for="confirm_password">Confirm New Password</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="6">
</div>
<div style="margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary">Change Password</button>
</div>
</form>
</div>
</div>
<style>
.card {
background: #16213e;
padding: 2rem;
border-radius: 8px;
margin-top: 1.5rem;
}
.card h2 {
margin-bottom: 1.5rem;
color: #e0e0e0;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #e0e0e0;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #0f3460;
border-radius: 4px;
background: #1a1a2e;
color: #e0e0e0;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
</style>
{% endblock %}
\ No newline at end of file
...@@ -52,5 +52,35 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -52,5 +52,35 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
{% endif %} {% endif %}
</form> </form>
{% if config.oauth2 and (config.oauth2.google.enabled or config.oauth2.github.enabled) %}
<div style="margin: 25px 0; text-align: center; position: relative;">
<hr style="border: none; border-top: 1px solid #ddd; margin: 0;">
<span style="background: white; padding: 0 15px; position: relative; top: -13px; color: #666;">or continue with</span>
</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
{% if config.oauth2.google.enabled %}
<a href="/auth/oauth2/google" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #dadce0; border-radius: 4px; background: white; color: #3c4043; font-weight: 500; text-decoration: none; transition: background 0.2s;">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Continue with Google
</a>
{% endif %}
{% if config.oauth2.github.enabled %}
<a href="/auth/oauth2/github" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #d1d5da; border-radius: 4px; background: #24292e; color: #ffffff; font-weight: 500; text-decoration: none;">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Continue with GitHub
</a>
{% endif %}
</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% extends "base.html" %}
{% block title %}Edit Profile - AISBF{% endblock %}
{% block content %}
<div class="container">
<h1>Edit Profile</h1>
<p>Update your account information</p>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<div class="card">
<h2>Account Information</h2>
<form method="POST" action="{{ url_for(request, '/dashboard/profile') }}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ session.username }}" required>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" value="{{ session.email }}" required>
</div>
<div class="form-group">
<label>Profile Picture</label>
<div style="display: flex; align-items: center; gap: 1rem;">
<img src="https://www.gravatar.com/avatar/{{ session.email|md5 }}?s=96&d=identicon" alt="Current avatar" style="border-radius: 8px;">
<p style="color: #a0a0a0;">Profile pictures are managed via <a href="https://gravatar.com" target="_blank">Gravatar</a> using your email address</p>
</div>
</div>
<div style="margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<style>
.card {
background: #16213e;
padding: 2rem;
border-radius: 8px;
margin-top: 1.5rem;
}
.card h2 {
margin-bottom: 1.5rem;
color: #e0e0e0;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #e0e0e0;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #0f3460;
border-radius: 4px;
background: #1a1a2e;
color: #e0e0e0;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
</style>
{% endblock %}
\ No newline at end of file
...@@ -448,6 +448,46 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -448,6 +448,46 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<small style="color: #666; display: block; margin-top: 5px;">How long verification links remain valid (1-168 hours)</small> <small style="color: #666; display: block; margin-top: 5px;">How long verification links remain valid (1-168 hours)</small>
</div> </div>
<h3 style="margin: 30px 0 20px;">OAuth2 Authentication</h3>
<h4 style="margin: 25px 0 15px;">Google OAuth2</h4>
<div class="form-group">
<label>
<input type="checkbox" name="oauth2_google_enabled" {% if config.oauth2 and config.oauth2.google and config.oauth2.google.enabled %}checked{% endif %}>
Enable Google OAuth2 Authentication
</label>
</div>
<div class="form-group">
<label for="oauth2_google_client_id">Google Client ID</label>
<input type="text" id="oauth2_google_client_id" name="oauth2_google_client_id" value="{{ config.oauth2.google.client_id if config.oauth2 and config.oauth2.google else '' }}" placeholder="Client ID from Google Cloud Console">
</div>
<div class="form-group">
<label for="oauth2_google_client_secret">Google Client Secret</label>
<input type="password" id="oauth2_google_client_secret" name="oauth2_google_client_secret" value="{{ config.oauth2.google.client_secret if config.oauth2 and config.oauth2.google else '' }}" placeholder="Client Secret from Google Cloud Console">
</div>
<h4 style="margin: 25px 0 15px;">GitHub OAuth2</h4>
<div class="form-group">
<label>
<input type="checkbox" name="oauth2_github_enabled" {% if config.oauth2 and config.oauth2.github and config.oauth2.github.enabled %}checked{% endif %}>
Enable GitHub OAuth2 Authentication
</label>
</div>
<div class="form-group">
<label for="oauth2_github_client_id">GitHub Client ID</label>
<input type="text" id="oauth2_github_client_id" name="oauth2_github_client_id" value="{{ config.oauth2.github.client_id if config.oauth2 and config.oauth2.github else '' }}" placeholder="Client ID from GitHub Developer Settings">
</div>
<div class="form-group">
<label for="oauth2_github_client_secret">GitHub Client Secret</label>
<input type="password" id="oauth2_github_client_secret" name="oauth2_github_client_secret" value="{{ config.oauth2.github.client_secret if config.oauth2 and config.oauth2.github else '' }}" placeholder="Client Secret from GitHub Developer Settings">
</div>
<h3 style="margin: 30px 0 20px;">SMTP Email Configuration</h3> <h3 style="margin: 30px 0 20px;">SMTP Email Configuration</h3>
<div class="form-group"> <div class="form-group">
......
...@@ -78,6 +78,36 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -78,6 +78,36 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
<p>Already have an account? <a href="{{ url_for(request, '/dashboard/login') }}" style="color: #4CAF50;">Login here</a></p> <p>Already have an account? <a href="{{ url_for(request, '/dashboard/login') }}" style="color: #4CAF50;">Login here</a></p>
</div> </div>
</form> </form>
{% if config.oauth2 and (config.oauth2.google.enabled or config.oauth2.github.enabled) %}
<div style="margin: 25px 0; text-align: center; position: relative;">
<hr style="border: none; border-top: 1px solid #ddd; margin: 0;">
<span style="background: white; padding: 0 15px; position: relative; top: -13px; color: #666;">or sign up with</span>
</div>
<div style="display: flex; flex-direction: column; gap: 12px;">
{% if config.oauth2.google.enabled %}
<a href="/auth/oauth2/google" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #dadce0; border-radius: 4px; background: white; color: #3c4043; font-weight: 500; text-decoration: none; transition: background 0.2s;">
<svg viewBox="0 0 24 24" width="20" height="20">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Sign up with Google
</a>
{% endif %}
{% if config.oauth2.github.enabled %}
<a href="/auth/oauth2/github" style="display: flex; align-items: center; justify-content: center; gap: 10px; padding: 12px; border: 1px solid #d1d5da; border-radius: 4px; background: #24292e; color: #ffffff; font-weight: 500; text-decoration: none;">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
Sign up with GitHub
</a>
{% endif %}
</div>
{% endif %}
{% endif %} {% endif %}
</div> </div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment